From efe4890b743dc47621e1b2a353d704a9336573b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 10:05:53 +0000 Subject: [PATCH 1/7] test(diff_tool): cover normalize_path_arg and install detection Co-authored-by: PuritanWizard --- .../PyKotor/tests/diff_tool/test_cli_utils.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Libraries/PyKotor/tests/diff_tool/test_cli_utils.py diff --git a/Libraries/PyKotor/tests/diff_tool/test_cli_utils.py b/Libraries/PyKotor/tests/diff_tool/test_cli_utils.py new file mode 100644 index 000000000..4011b59ec --- /dev/null +++ b/Libraries/PyKotor/tests/diff_tool/test_cli_utils.py @@ -0,0 +1,55 @@ +"""Regression tests for diff_tool CLI path normalization and install detection.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from pykotor.diff_tool.cli_utils import is_kotor_install_dir, normalize_path_arg + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, None), + ("", None), + (" ", None), + ('"C:\\Games\\KOTOR"', r"C:\Games\KOTOR"), + ("'C:/Games/KOTOR'", "C:/Games/KOTOR"), + (r'C:\Program Files\Steam" C:\other', r"C:\Program Files\Steam"), + (r'C:\Program Files\folder\\', r"C:\Program Files\folder"), + ("C:/folder/", "C:/folder"), + ], +) +def test_normalize_path_arg(raw: str | None, expected: str | None) -> None: + assert normalize_path_arg(raw) == expected + + +def test_is_kotor_install_dir_false_when_not_directory(tmp_path: Path) -> None: + key_file = tmp_path / "chitin.key" + key_file.write_bytes(b"") + assert is_kotor_install_dir(key_file) is False + + +def test_is_kotor_install_dir_false_without_chitin(tmp_path: Path) -> None: + assert is_kotor_install_dir(tmp_path) is False + + +def test_is_kotor_install_dir_true_with_chitin_key(tmp_path: Path) -> None: + (tmp_path / "chitin.key").write_bytes(b"") + assert is_kotor_install_dir(tmp_path) is True + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Case-mismatch path semantics differ on Windows filesystems.", +) +def test_is_kotor_install_dir_case_mismatched_chitin_key(tmp_path: Path) -> None: + install_dir = tmp_path / "GameRoot" + install_dir.mkdir() + (install_dir / "chitin.key").write_bytes(b"") + + mismatched = tmp_path / "gameroot" + assert is_kotor_install_dir(mismatched) is True From 53a05350088237e0e0eecba5955838b45ff013d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 10:06:00 +0000 Subject: [PATCH 2/7] test(common): add cross-implementation is_kotor_install_dir parity Co-authored-by: PuritanWizard --- .../tests/common/test_is_kotor_install_dir.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 Libraries/PyKotor/tests/common/test_is_kotor_install_dir.py diff --git a/Libraries/PyKotor/tests/common/test_is_kotor_install_dir.py b/Libraries/PyKotor/tests/common/test_is_kotor_install_dir.py new file mode 100644 index 000000000..c143ffb2c --- /dev/null +++ b/Libraries/PyKotor/tests/common/test_is_kotor_install_dir.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import pathlib +import sys +import tempfile +import unittest + +THIS_SCRIPT_PATH = pathlib.Path(__file__).resolve() +PYKOTOR_PATH = THIS_SCRIPT_PATH.parents[3].joinpath("src") +UTILITY_PATH = THIS_SCRIPT_PATH.parents[5].joinpath("Libraries", "Utility", "src") + + +def add_sys_path(p: pathlib.Path) -> None: + working_dir = str(p) + if working_dir not in sys.path: + sys.path.append(working_dir) + + +if PYKOTOR_PATH.joinpath("pykotor").exists(): + add_sys_path(PYKOTOR_PATH) +if UTILITY_PATH.joinpath("utility").exists(): + add_sys_path(UTILITY_PATH) + +from pykotor.diff_tool.cli_utils import is_kotor_install_dir as cli_is_kotor_install_dir +from pykotor.tools.patching import is_kotor_install_dir as patching_is_kotor_install_dir +from pykotor.tslpatcher.diff.engine import is_kotor_install_dir as engine_is_kotor_install_dir + + +class TestIsKotorInstallDir(unittest.TestCase): + _IMPLEMENTATIONS = ( + ("cli_utils", cli_is_kotor_install_dir), + ("patching", patching_is_kotor_install_dir), + ("diff_engine", engine_is_kotor_install_dir), + ) + + def test_detects_install_with_exact_case(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) / "KotorInstall" + root.mkdir() + (root / "chitin.key").write_bytes(b"key") + + for name, is_install in self._IMPLEMENTATIONS: + with self.subTest(implementation=name): + self.assertTrue(is_install(root)) + + def test_rejects_directory_without_chitin_key(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) / "NotInstall" + root.mkdir() + + for name, is_install in self._IMPLEMENTATIONS: + with self.subTest(implementation=name): + self.assertFalse(is_install(root)) + + def test_rejects_file_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + file_path = pathlib.Path(tmp) / "not_a_dir" + file_path.write_bytes(b"x") + + for name, is_install in self._IMPLEMENTATIONS: + with self.subTest(implementation=name): + self.assertFalse(is_install(file_path)) + + @unittest.skipIf( + sys.platform == "win32", + "Case mismatch semantics differ on Windows filesystems.", + ) + def test_detects_install_with_case_mismatched_chitin_key(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) / "KotorInstall" + root.mkdir() + (root / "CHITIN.KEY").write_bytes(b"key") + + for name, is_install in self._IMPLEMENTATIONS: + with self.subTest(implementation=name): + self.assertTrue(is_install(root)) + + @unittest.skipIf( + sys.platform == "win32", + "Case mismatch semantics differ on Windows filesystems.", + ) + def test_detects_install_with_case_mismatched_root_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) / "KotorInstall" + root.mkdir() + (root / "chitin.key").write_bytes(b"key") + + mismatched_root = root.parent / "kotorinstall" + + for name, is_install in self._IMPLEMENTATIONS: + with self.subTest(implementation=name): + self.assertTrue(is_install(mismatched_root)) + + +if __name__ == "__main__": + unittest.main() From 76a97582307b99dda63fb7c63ad0101d1034a192 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 10:06:04 +0000 Subject: [PATCH 3/7] test(common): cover case-aware path cache and ambiguous matching Co-authored-by: PuritanWizard --- .../common/test_case_aware_path_cache.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 Libraries/PyKotor/tests/common/test_case_aware_path_cache.py diff --git a/Libraries/PyKotor/tests/common/test_case_aware_path_cache.py b/Libraries/PyKotor/tests/common/test_case_aware_path_cache.py new file mode 100644 index 000000000..48d061d30 --- /dev/null +++ b/Libraries/PyKotor/tests/common/test_case_aware_path_cache.py @@ -0,0 +1,68 @@ +"""Regression tests for CaseAwarePath directory cache and ambiguous case matching.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +import pytest + +from pykotor.tools import path as path_mod +from pykotor.tools.path import CaseAwarePath, _choose_case_match, clear_cache + + +class TestChooseCaseMatch(unittest.TestCase): + def test_prefers_exact_case_when_ambiguous(self) -> None: + chosen = _choose_case_match("File", ["file", "File"], "/tmp") + self.assertEqual(chosen, "File") + + def test_picks_best_character_overlap_when_no_exact(self) -> None: + chosen = _choose_case_match("teSt", ["TEST", "tEst", "teSt"], "/tmp") + self.assertEqual(chosen, "teSt") + + def test_single_match_returns_only_candidate(self) -> None: + chosen = _choose_case_match("only", ["only"], "/tmp") + self.assertEqual(chosen, "only") + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Case-mismatch path semantics differ on Windows filesystems.", +) +class TestCaseAwarePathCache: + def test_clear_cache_empties_directory_lookup(self, tmp_path: Path) -> None: + base = tmp_path / "BaseDir" + base.mkdir() + (base / "file.txt").write_text("a") + + path_mod._DIR_CACHE[str(tmp_path)] = (0, {"basedir": ["BaseDir"]}) + assert len(path_mod._DIR_CACHE) >= 1 + + clear_cache() + assert len(path_mod._DIR_CACHE) == 0 + + def test_resolves_case_mismatched_directory_segment(self, tmp_path: Path) -> None: + actual = tmp_path / "ActualCase" + actual.mkdir() + (actual / "data.txt").write_text("x") + + clear_cache() + resolved = str(CaseAwarePath(tmp_path / "actualcase" / "data.txt")) + assert resolved.endswith(str(actual / "data.txt")) + + def test_dir_cache_reused_for_repeated_resolution(self, tmp_path: Path) -> None: + base = tmp_path / "CacheDir" + base.mkdir() + (base / "item.txt").write_text("a") + + clear_cache() + CaseAwarePath(tmp_path / "cachedir" / "item.txt") + size_after_first = len(path_mod._DIR_CACHE) + + CaseAwarePath(tmp_path / "cachedir" / "item.txt") + assert len(path_mod._DIR_CACHE) == size_after_first + + +if __name__ == "__main__": + unittest.main() From 4efaea9eb377b1e20b72b00c44aa20171eea805b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 10:06:08 +0000 Subject: [PATCH 4/7] test(indoorkit): cover case-mismatched kits directory loading Co-authored-by: PuritanWizard --- .../PyKotor/tests/test_indoorkit_case_path.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 Libraries/PyKotor/tests/test_indoorkit_case_path.py diff --git a/Libraries/PyKotor/tests/test_indoorkit_case_path.py b/Libraries/PyKotor/tests/test_indoorkit_case_path.py new file mode 100644 index 000000000..f920b53be --- /dev/null +++ b/Libraries/PyKotor/tests/test_indoorkit_case_path.py @@ -0,0 +1,29 @@ +"""Regression tests for CaseAwarePath usage in indoor kit loading (PR #151).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +from pykotor.tools.indoorkit import load_kits_unified + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Case-mismatch path semantics differ on Windows filesystems.", +) +def test_load_kits_unified_case_mismatched_kits_directory(tmp_path: Path) -> None: + kits_dir = tmp_path / "MyKits" + kits_dir.mkdir() + v1 = {"name": "Legacy", "id": "legacy", "doors": [], "components": []} + (kits_dir / "legacy.json").write_text(json.dumps(v1), encoding="utf-8") + + mismatched_path = tmp_path / "mykits" + kits, tile_kits = load_kits_unified(mismatched_path) + + assert len(kits) == 1 + assert kits[0].id == "legacy" + assert tile_kits == [] From c06f788d8dd6b5e66739a22841bdd60bb9292e79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 10:06:08 +0000 Subject: [PATCH 5/7] test(cli): cover ci env guard for json export live progress Co-authored-by: PuritanWizard --- .../PyKotor/tests/cli/test_json_commands.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Libraries/PyKotor/tests/cli/test_json_commands.py b/Libraries/PyKotor/tests/cli/test_json_commands.py index 67a02752f..ac60c48be 100644 --- a/Libraries/PyKotor/tests/cli/test_json_commands.py +++ b/Libraries/PyKotor/tests/cli/test_json_commands.py @@ -1,11 +1,13 @@ from __future__ import annotations import base64 +import io import json import logging from argparse import Namespace from pathlib import Path +from unittest.mock import patch import pytest from loggerplus import RobustLogger @@ -28,6 +30,7 @@ from pykotor.resource.type import ResourceType from pykotor.tools.resource_json import ( _serialize_mdl_face, + _supports_live_progress, export_installation_to_json_tree, iter_installation_resource_documents, serialize_file_resource_document, @@ -922,3 +925,27 @@ def fake_main(argv: list[str]) -> int: assert "--merge-module" in captured_argv assert captured_argv.count("--merge-path") == 2 assert "--merge-conflict-policy" in captured_argv + + +@pytest.mark.parametrize("env_name", ["CI", "GITHUB_ACTIONS"]) +@pytest.mark.parametrize("env_value", ["true", "1", "yes", "TRUE"]) +def test_supports_live_progress_disabled_in_ci_env( + env_name: str, env_value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + tty_stream = io.StringIO() + monkeypatch.setenv(env_name, env_value) + with patch.object(tty_stream, "isatty", return_value=True): + assert _supports_live_progress(tty_stream) is False + + +def test_supports_live_progress_follows_tty_when_not_in_ci( + monkeypatch: pytest.MonkeyPatch, +) -> None: + tty_stream = io.StringIO() + non_tty_stream = io.StringIO() + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + with patch.object(tty_stream, "isatty", return_value=True): + assert _supports_live_progress(tty_stream) is True + with patch.object(non_tty_stream, "isatty", return_value=False): + assert _supports_live_progress(non_tty_stream) is False From 640f8ba06558ff5fe2db3dc7ea84f090385e17a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 10:06:08 +0000 Subject: [PATCH 6/7] test(extract): assert modules directory casing on create_installation Co-authored-by: PuritanWizard --- Libraries/PyKotor/tests/extract/test_installation.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Libraries/PyKotor/tests/extract/test_installation.py b/Libraries/PyKotor/tests/extract/test_installation.py index 6b0371cc3..76d8e9fd5 100644 --- a/Libraries/PyKotor/tests/extract/test_installation.py +++ b/Libraries/PyKotor/tests/extract/test_installation.py @@ -43,6 +43,15 @@ def add_sys_path(p: pathlib.Path): class TestInstallation(TestCase): + def test_create_installation_uses_modules_directory_casing(self): + with tempfile.TemporaryDirectory() as tmp: + install_path = Path(tmp) / "k1_install" + create_installation(install_path, Game.K1) + + self.assertTrue((install_path / "Modules").is_dir()) + if sys.platform != "win32": + self.assertFalse((install_path / "modules").exists()) + @classmethod def setUpClass(cls): # Create temporary directory for installation From fa99111ecaa275ba486483225feb16eeed7ca8af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 10:06:08 +0000 Subject: [PATCH 7/7] test(tslpatcher): cover case-aware ini path resolution Co-authored-by: PuritanWizard --- .../PyKotor/tests/tslpatcher/test_reader.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Libraries/PyKotor/tests/tslpatcher/test_reader.py b/Libraries/PyKotor/tests/tslpatcher/test_reader.py index 613bb544c..bfe8fa055 100644 --- a/Libraries/PyKotor/tests/tslpatcher/test_reader.py +++ b/Libraries/PyKotor/tests/tslpatcher/test_reader.py @@ -1850,5 +1850,25 @@ def _setupIniAndConfig(self, ini_text: str) -> PatcherConfig: # endregion +class TestConfigReaderFromFilepath(unittest.TestCase): + @unittest.skipIf( + sys.platform == "win32", + "Case mismatch semantics differ on Windows filesystems.", + ) + def test_from_filepath_resolves_case_mismatched_mod_directory(self): + with tempfile.TemporaryDirectory() as tmp: + mod_root = Path(tmp) / "MyMod" + mod_root.mkdir() + ini_path = mod_root / "changes.ini" + ini_path.write_text( + "[Settings]\nLookupGameFolder=0\nLookupGameNumber=1\n", + encoding="utf-8", + ) + + reader = ConfigReader.from_filepath(mod_root.parent / "mymod" / "CHANGES.INI") + self.assertTrue(reader.mod_path.is_dir()) + self.assertEqual(reader.mod_path.name, "MyMod") + + if __name__ == "__main__": unittest.main()