From 61805ba704b692b1f47056f54c2e370dbd035aec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 04:07:22 +0000 Subject: [PATCH 1/5] grass.app: Check lock files and error messages in CLI tests The mapset lock and unlock tests only checked the exit status, so a lock command which did nothing would pass. Check that the lock file is created and removed, that locking a locked mapset fails and keeps the lock, that a lock can be forced, and that unlocking a mapset without a lock is not an error. Check the error messages printed when creating a project or a mapset fails. Found with mutation testing, run on the existing tests to look for changes of the code which none of them detect. Written with AI assistance, and the expected values were checked against the behavior of the code. --- python/grass/app/tests/grass_app_cli_test.py | 55 ++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) 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 From 4f9ae257f25468bc782cbce16fd60f4f23944744 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 04:07:22 +0000 Subject: [PATCH 2/5] grass.temporal: Test more units and intervals in datetime_math Test singular unit names and counts other than one for incrementing, test decrementing by a string, including months and years, and test intervals spanning days for compute_datetime_delta, where hours and minutes are computed over days. Found with mutation testing, run on the existing tests to look for changes of the code which none of them detect. Written with AI assistance, and the expected values were checked against the behavior of the code. --- .../grass_temporal_datetime_math_test.py | 55 +++++++++++++++++++ ...tetime_math_compute_datetime_delta_test.py | 44 +++++++++++++++ 2 files changed, 99 insertions(+) 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 From e22e6d8626c563da13a80f7f906668f0fdafafea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 04:07:22 +0000 Subject: [PATCH 3/5] grass.script: Test restoring state in mask and region managers Check that MaskManager restores a mask name set before it started, that RegionManager removes the region it saved and that a nested RegionManager makes the outer region active again. Add tests of raster_info, which was only exercised indirectly: the extent and resolution are numbers, and the minimum and maximum are numbers or None for a raster with only nulls. Found with mutation testing, run on the existing tests to look for changes of the code which none of them detect. Written with AI assistance, and the expected values were checked against the behavior of the code. --- .../tests/grass_script_raster_info_test.py | 52 +++++++++++++++++++ .../tests/grass_script_raster_mask_test.py | 9 ++++ .../tests/grass_script_raster_region_test.py | 28 ++++++++++ 3 files changed, 89 insertions(+) create mode 100644 python/grass/script/tests/grass_script_raster_info_test.py 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..b401589a6cb 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,31 @@ 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 + From 19bed22e6d57fd7814f174691736449d6ee36c94 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 04:07:23 +0000 Subject: [PATCH 4/5] grass.script: Remove the saved region of a nested RegionManager RegionManager.deactivate() removed the region it saved only when no WIND_OVERRIDE was set before activation. In a nested RegionManager the inner one found the region of the outer one, restored it, and left its own region in the mapset. Remove the saved region in both cases, like MaskManager does with its mask. Found by checking whether the tests detect the removal of the g.remove call, with AI assistance. --- python/grass/script/raster.py | 17 +++++++++-------- .../tests/grass_script_raster_region_test.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) 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_region_test.py b/python/grass/script/tests/grass_script_raster_region_test.py index b401589a6cb..ea3f04acc28 100644 --- a/python/grass/script/tests/grass_script_raster_region_test.py +++ b/python/grass/script/tests/grass_script_raster_region_test.py @@ -215,3 +215,15 @@ def test_region_manager_nested(session_2x2): 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) From 484c3d337b1607eea5214b39b232dd9ac036fce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 04:29:53 +0000 Subject: [PATCH 5/5] grass.script: Test restoring the region in a nested RegionManagerEnv RegionManagerEnv saves the value of GRASS_REGION before activation, but no test checked that it is restored when managers are nested. Found with mutation testing, with AI assistance. --- .../tests/grass_script_raster_region_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 ea3f04acc28..3382f783965 100644 --- a/python/grass/script/tests/grass_script_raster_region_test.py +++ b/python/grass/script/tests/grass_script_raster_region_test.py @@ -227,3 +227,17 @@ def test_region_manager_nested_removes_temporary_regions(session_2x2): 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