diff --git a/python/grass/app/tests/grass_app_cli_test.py b/python/grass/app/tests/grass_app_cli_test.py index 6cbf57779a6..bf8f5628724 100644 --- a/python/grass/app/tests/grass_app_cli_test.py +++ b/python/grass/app/tests/grass_app_cli_test.py @@ -138,20 +138,63 @@ def test_subcommand_run_with_crs_as_pack_subprocess(pack_raster_file4x5_rows, ca def test_create_lock_unlock(tmp_path): - """Check that we can create, lock, unlock (smoke test)""" + """Check that locking creates the lock file and unlocking removes it""" project = tmp_path / "test_1" + lock_file = project / "PERMANENT" / ".gislock" assert main(["project", "create", str(project)]) == 0 + assert not lock_file.exists() assert main(["mapset", "lock", str(project / "PERMANENT")]) == 0 + assert lock_file.exists() assert main(["mapset", "unlock", str(project / "PERMANENT")]) == 0 + assert not lock_file.exists() def test_create_mapset_lock_unlock(tmp_path): - """Check that we can create, lock, unlock (smoke test)""" + """Check that a created mapset can be locked and unlocked""" project = tmp_path / "test_1" + lock_file = project / "data_1" / ".gislock" assert main(["project", "create", str(project)]) == 0 assert main(["mapset", "create", str(project / "data_1")]) == 0 assert main(["mapset", "lock", str(project / "data_1")]) == 0 + assert lock_file.exists() assert main(["mapset", "unlock", str(project / "data_1")]) == 0 + assert not lock_file.exists() + + +def test_lock_mapset_already_locked(tmp_path, capsys): + """Check that locking a locked mapset fails and keeps the existing lock""" + project = tmp_path / "test_1" + mapset = project / "PERMANENT" + assert main(["project", "create", str(project)]) == 0 + assert main(["mapset", "lock", str(mapset)]) == 0 + capsys.readouterr() + assert main(["mapset", "lock", str(mapset), "--timeout", "0"]) == 1 + assert "currently being used by another GRASS session" in capsys.readouterr().err + assert (mapset / ".gislock").exists() + + +def test_lock_mapset_force_remove_lock(tmp_path, capsys): + """Check that an existing lock can be forced to be removed and is reported""" + project = tmp_path / "test_1" + mapset = project / "PERMANENT" + assert main(["project", "create", str(project)]) == 0 + assert main(["mapset", "lock", str(mapset)]) == 0 + capsys.readouterr() + assert main(["mapset", "lock", str(mapset), "--force-remove-lock"]) == 0 + assert "Removing lock in the selected mapset" in capsys.readouterr().out + + +def test_lock_mapset_missing_mapset(tmp_path, capsys): + """Check that locking a mapset which does not exist is reported""" + assert main(["mapset", "lock", str(tmp_path / "missing")]) == 1 + assert "doesn't exist" in capsys.readouterr().err + + +def test_unlock_mapset_without_lock(tmp_path): + """Check that unlocking a mapset which is not locked is not an error""" + project = tmp_path / "test_1" + assert main(["project", "create", str(project)]) == 0 + assert main(["mapset", "unlock", str(project / "PERMANENT")]) == 0 def test_create_mapset(tmp_path): @@ -239,21 +282,25 @@ def test_create_mapset(tmp_path): assert region["cols"] == 1 -def test_mapset_create_exists(tmp_path): +def test_mapset_create_exists(tmp_path, capsys): """Check that creating mapset fails unless --overwrite is used""" project = tmp_path / "test_1" mapset = project / "data_1" assert main(["project", "create", str(project)]) == 0 assert main(["mapset", "create", str(mapset)]) == 0 + capsys.readouterr() assert main(["mapset", "create", str(mapset)]) == 1 + assert "Error creating mapset" in capsys.readouterr().err assert main(["mapset", "create", "--overwrite", str(mapset)]) == 0 -def test_create_overwrite(tmp_path): +def test_create_overwrite(tmp_path, capsys): """Check that creating when project exists fails unless overwrite is True""" project = tmp_path / "test_1" assert main(["project", "create", str(project)]) == 0 + capsys.readouterr() assert main(["project", "create", str(project)]) == 1 + assert "Error creating project" in capsys.readouterr().err assert main(["project", "create", "--overwrite", str(project)]) == 0 diff --git a/python/grass/script/raster.py b/python/grass/script/raster.py index 01092ce9029..ed4033cbaf7 100644 --- a/python/grass/script/raster.py +++ b/python/grass/script/raster.py @@ -583,14 +583,15 @@ def deactivate(self): self.env["WIND_OVERRIDE"] = self._original_value else: self.env.pop("WIND_OVERRIDE", None) - run_command( - "g.remove", - flags="f", - quiet=True, - type="region", - name=self.region_name, - env=self.env, - ) + + run_command( + "g.remove", + flags="f", + quiet=True, + type="region", + name=self.region_name, + env=self.env, + ) self._active = False def __enter__(self): diff --git a/python/grass/script/tests/grass_script_raster_info_test.py b/python/grass/script/tests/grass_script_raster_info_test.py new file mode 100644 index 00000000000..d44b7ecee7e --- /dev/null +++ b/python/grass/script/tests/grass_script_raster_info_test.py @@ -0,0 +1,52 @@ +"""Test grass.script.raster_info""" + +import pytest + +import grass.script as gs + + +def test_raster_info_extent_and_resolution_are_floats(session_2x2): + """Test the extent and the resolution are numbers, not text""" + info = gs.raster_info("ones", env=session_2x2.env) + assert info["north"] == pytest.approx(1.0) + assert info["south"] == pytest.approx(0.0) + assert info["east"] == pytest.approx(1.0) + assert info["west"] == pytest.approx(0.0) + assert info["nsres"] == pytest.approx(0.5) + assert info["ewres"] == pytest.approx(0.5) + + +def test_raster_info_range_of_integer_raster(session_2x2): + """Test the minimum and the maximum of an integer raster are floats""" + info = gs.raster_info("ones", env=session_2x2.env) + assert info["min"] == pytest.approx(1.0) + assert info["max"] == pytest.approx(1.0) + assert isinstance(info["min"], float) + assert isinstance(info["max"], float) + + +def test_raster_info_range_of_floating_point_raster(session_2x2): + """Test the minimum and the maximum of a floating point raster""" + gs.mapcalc("quarters = float(row()) / 4", env=session_2x2.env) + info = gs.raster_info("quarters", env=session_2x2.env) + assert info["min"] == pytest.approx(0.25) + assert info["max"] == pytest.approx(0.5) + assert info["datatype"] == "FCELL" + + +def test_raster_info_range_of_raster_with_only_nulls(session_2x2): + """Test the minimum and the maximum are None when there are no values""" + gs.mapcalc("only_nulls = null()", env=session_2x2.env) + info = gs.raster_info("only_nulls", env=session_2x2.env) + assert info["min"] is None + assert info["max"] is None + + +def test_raster_info_other_values_are_text(session_2x2): + """Test the values which are not converted to numbers""" + info = gs.raster_info("ones", env=session_2x2.env) + assert info["datatype"] == "CELL" + assert info["rows"] == "2" + assert info["cols"] == "2" + assert info["map"] == "ones" + assert info["mapset"] == "PERMANENT" diff --git a/python/grass/script/tests/grass_script_raster_mask_test.py b/python/grass/script/tests/grass_script_raster_mask_test.py index e0a66a4869e..77b6b29b4cc 100644 --- a/python/grass/script/tests/grass_script_raster_mask_test.py +++ b/python/grass/script/tests/grass_script_raster_mask_test.py @@ -258,3 +258,12 @@ def test_mask_manager_activate_deactivate(session_2x2): manager.activate() assert "GRASS_MASK" in session_2x2.env manager.deactivate() + + +def test_mask_manager_restores_previous_mask_name(session_2x2): + """Test MaskManager restores the value of GRASS_MASK set before it started.""" + env = session_2x2.env + env["GRASS_MASK"] = "previous_mask" + with gs.MaskManager(mask_name="temporary_mask", remove=False, env=env): + assert env["GRASS_MASK"] == "temporary_mask" + assert env["GRASS_MASK"] == "previous_mask" diff --git a/python/grass/script/tests/grass_script_raster_region_test.py b/python/grass/script/tests/grass_script_raster_region_test.py index ca75bc375af..3382f783965 100644 --- a/python/grass/script/tests/grass_script_raster_region_test.py +++ b/python/grass/script/tests/grass_script_raster_region_test.py @@ -187,3 +187,57 @@ def test_region_manager_env_save(session_2x2): region = gs.region(env=session_2x2.env) assert region["rows"] == 6 assert region["cols"] == 6 + + +def test_region_manager_removes_temporary_region(session_2x2): + """Test RegionManager does not leave the region it saved in the mapset.""" + env = session_2x2.env + with gs.RegionManager(env=env) as manager: + assert any( + name.startswith(manager.region_name) + for name in gs.list_strings("region", env=env) + ) + assert not any( + name.startswith(manager.region_name) + for name in gs.list_strings("region", env=env) + ) + + +def test_region_manager_nested(session_2x2): + """Test the outer region is active again after a nested RegionManager ends.""" + env = session_2x2.env + with gs.RegionManager(n=4, s=0, e=4, w=0, res=1, env=env) as outer: + with gs.RegionManager(n=6, s=0, e=6, w=0, res=1, env=env) as inner: + assert env["WIND_OVERRIDE"] == inner.region_name + assert gs.region(env=env)["rows"] == 6 + assert env["WIND_OVERRIDE"] == outer.region_name + assert gs.region(env=env)["rows"] == 4 + assert "WIND_OVERRIDE" not in env + assert gs.region(env=env)["rows"] == 2 + + +def test_region_manager_nested_removes_temporary_regions(session_2x2): + """Test a nested RegionManager does not leave its saved region in the mapset.""" + env = session_2x2.env + with ( + gs.RegionManager(env=env) as outer, + gs.RegionManager(env=env) as inner, + ): + pass + saved = gs.list_strings("region", env=env) + assert not any(name.startswith(outer.region_name) for name in saved) + assert not any(name.startswith(inner.region_name) for name in saved) + + +def test_region_manager_env_nested(session_2x2): + """Test the outer region is active again after a nested RegionManagerEnv ends.""" + env = session_2x2.env + with gs.RegionManagerEnv(n=4, s=0, e=4, w=0, res=1, env=env): + outer_region = env["GRASS_REGION"] + with gs.RegionManagerEnv(n=6, s=0, e=6, w=0, res=1, env=env): + assert env["GRASS_REGION"] != outer_region + assert gs.region(env=env)["rows"] == 6 + assert env["GRASS_REGION"] == outer_region + assert gs.region(env=env)["rows"] == 4 + assert "GRASS_REGION" not in env + assert gs.region(env=env)["rows"] == 2 diff --git a/python/grass/temporal/tests/grass_temporal_datetime_math_test.py b/python/grass/temporal/tests/grass_temporal_datetime_math_test.py index 92767f47b58..2a0cf368167 100644 --- a/python/grass/temporal/tests/grass_temporal_datetime_math_test.py +++ b/python/grass/temporal/tests/grass_temporal_datetime_math_test.py @@ -10,6 +10,7 @@ from grass.temporal.datetime_math import ( adjust_datetime_to_granularity, + decrement_datetime_by_string, increment_datetime_by_string, ) @@ -48,6 +49,14 @@ def test_increment_datetime_by_string(start, increment, expected) -> None: ("2 weeks", datetime(2001, 8, 6, 0, 0)), ("6 months", datetime(2001, 8, 1, 0, 0)), ("2 years", datetime(2001, 1, 1, 0, 0)), + # The singular unit names work the same way. + ("1 second", datetime(2001, 8, 8, 12, 30, 30)), + ("1 minute", datetime(2001, 8, 8, 12, 30)), + ("1 hour", datetime(2001, 8, 8, 12, 0)), + ("1 day", datetime(2001, 8, 8, 0, 0)), + ("1 week", datetime(2001, 8, 6, 0, 0)), + ("1 month", datetime(2001, 8, 1, 0, 0)), + ("1 year", datetime(2001, 1, 1, 0, 0)), # With several units the finest one present wins, because truncating to # it already satisfies all the coarser ones. ( @@ -63,3 +72,49 @@ def test_adjust_datetime_to_granularity(granularity, expected) -> None: adjust_datetime_to_granularity(datetime(2001, 8, 8, 12, 30, 30), granularity) == expected ) + + +@pytest.mark.parametrize( + ("increment", "expected"), + [ + # Each unit is accepted in singular and plural form. + ("1 second", datetime(2001, 1, 1, 0, 0, 1)), + ("9 seconds", datetime(2001, 1, 1, 0, 0, 9)), + ("1 minute", datetime(2001, 1, 1, 0, 1)), + ("7 minutes", datetime(2001, 1, 1, 0, 7)), + ("1 hour", datetime(2001, 1, 1, 1)), + ("5 hours", datetime(2001, 1, 1, 5)), + ("1 day", datetime(2001, 1, 2)), + ("2 days", datetime(2001, 1, 3)), + # A week is seven days, also for counts other than one. + ("1 week", datetime(2001, 1, 8)), + ("2 weeks", datetime(2001, 1, 15)), + ("1 month", datetime(2001, 2, 1)), + ("1 year", datetime(2002, 1, 1)), + ("3 years", datetime(2004, 1, 1)), + ], +) +def test_increment_datetime_by_string_single_unit(increment, expected) -> None: + assert increment_datetime_by_string(datetime(2001, 1, 1), increment) == expected + + +@pytest.mark.parametrize( + ("decrement", "expected"), + [ + ("1 second", datetime(2002, 3, 1, 12, 30, 29)), + ("1 minute", datetime(2002, 3, 1, 12, 29, 30)), + ("1 hour", datetime(2002, 3, 1, 11, 30, 30)), + ("1 day", datetime(2002, 2, 28, 12, 30, 30)), + ("2 days", datetime(2002, 2, 27, 12, 30, 30)), + ("1 week", datetime(2002, 2, 22, 12, 30, 30)), + ("2 weeks", datetime(2002, 2, 15, 12, 30, 30)), + ("1 month", datetime(2002, 2, 1, 12, 30, 30)), + # More than twelve months has to roll the year back. + ("13 months", datetime(2001, 2, 1, 12, 30, 30)), + ("1 year", datetime(2001, 3, 1, 12, 30, 30)), + ("3 years", datetime(1999, 3, 1, 12, 30, 30)), + ], +) +def test_decrement_datetime_by_string(decrement, expected) -> None: + start = datetime(2002, 3, 1, 12, 30, 30) + assert decrement_datetime_by_string(start, decrement) == expected diff --git a/python/grass/temporal/tests/test_datetime_math_compute_datetime_delta_test.py b/python/grass/temporal/tests/test_datetime_math_compute_datetime_delta_test.py index dbcbaa2830f..9aed9b119d4 100644 --- a/python/grass/temporal/tests/test_datetime_math_compute_datetime_delta_test.py +++ b/python/grass/temporal/tests/test_datetime_math_compute_datetime_delta_test.py @@ -1,4 +1,7 @@ from datetime import datetime + +import pytest + from grass.temporal.datetime_math import compute_datetime_delta, datetime_delta @@ -287,3 +290,44 @@ def test_compute_datetime_delta_month_full() -> None: assert comp == datetime_delta( year=0, month=0, day=30, hour=0, minute=0, second=0, max_days=30 ) + + +@pytest.mark.parametrize( + ("start", "end", "expected"), + [ + # 28 hours: the hour of the end is before the one of the start. + ( + datetime(2001, 1, 1, 22, 0), + datetime(2001, 1, 3, 2, 0), + datetime_delta( + year=0, month=0, day=1, hour=28, minute=0, second=0, max_days=1 + ), + ), + # 26.5 hours: the minutes are counted over the hours. + ( + datetime(2001, 1, 1, 10, 0), + datetime(2001, 1, 2, 12, 30), + datetime_delta( + year=0, month=0, day=1, hour=26, minute=1590, second=0, max_days=1 + ), + ), + # Two days with the same minute: the minutes are counted over the days. + ( + datetime(2001, 1, 1, 0, 15), + datetime(2001, 1, 3, 0, 15), + datetime_delta( + year=0, month=0, day=2, hour=0, minute=2880, second=0, max_days=2 + ), + ), + # 26 hours with the same minute. + ( + datetime(2001, 1, 1, 1, 15), + datetime(2001, 1, 2, 3, 15), + datetime_delta( + year=0, month=0, day=1, hour=26, minute=1560, second=0, max_days=1 + ), + ), + ], +) +def test_compute_datetime_delta_spanning_days(start, end, expected) -> None: + assert compute_datetime_delta(start, end) == expected