Skip to content
27 changes: 27 additions & 0 deletions Libraries/PyKotor/tests/cli/test_json_commands.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
68 changes: 68 additions & 0 deletions Libraries/PyKotor/tests/common/test_case_aware_path_cache.py
Original file line number Diff line number Diff line change
@@ -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")

Check warning

Code scanning / Bandit

Probable insecure usage of temp file/directory. Warning test

Probable insecure usage of temp file/directory.

Check warning

Code scanning / Bandit

Probable insecure usage of temp file/directory. Warning test

Probable insecure usage of temp file/directory.
self.assertEqual(chosen, "File")

def test_picks_best_character_overlap_when_no_exact(self) -> None:
chosen = _choose_case_match("teSt", ["TEST", "tEst", "teSt"], "/tmp")

Check warning

Code scanning / Bandit

Probable insecure usage of temp file/directory. Warning test

Probable insecure usage of temp file/directory.

Check warning

Code scanning / Bandit

Probable insecure usage of temp file/directory. Warning test

Probable insecure usage of temp file/directory.
self.assertEqual(chosen, "teSt")

def test_single_match_returns_only_candidate(self) -> None:
chosen = _choose_case_match("only", ["only"], "/tmp")

Check warning

Code scanning / Bandit

Probable insecure usage of temp file/directory. Warning test

Probable insecure usage of temp file/directory.

Check warning

Code scanning / Bandit

Probable insecure usage of temp file/directory. Warning test

Probable insecure usage of temp file/directory.
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()
96 changes: 96 additions & 0 deletions Libraries/PyKotor/tests/common/test_is_kotor_install_dir.py
Original file line number Diff line number Diff line change
@@ -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()
55 changes: 55 additions & 0 deletions Libraries/PyKotor/tests/diff_tool/test_cli_utils.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions Libraries/PyKotor/tests/extract/test_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions Libraries/PyKotor/tests/test_indoorkit_case_path.py
Original file line number Diff line number Diff line change
@@ -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 == []
20 changes: 20 additions & 0 deletions Libraries/PyKotor/tests/tslpatcher/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading