mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 17:23:05 +02:00
- Add comprehensive tests for all packages (3572 tests, 100% branch coverage) - Split oversized test files to stay under 500-line limit - Add per-file ruff ignores for test-appropriate suppressions - Fix _cache_decks.py to properly convert JSON lists to tuples - Add session-scoped conftest fixture for logging handler cleanup (Python 3.14) - Update ruff pre-commit hook to v0.15.2 - Add codespell ignore words for test data - Add generated output files to .gitignore
495 lines
17 KiB
Python
495 lines
17 KiB
Python
"""Tests for python_pkg.puzzle_solver.solver module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
from unittest.mock import mock_open, patch
|
|
|
|
import pytest
|
|
|
|
from python_pkg.puzzle_solver.solver import (
|
|
Puzzle,
|
|
SquareType,
|
|
State,
|
|
_map_keys_to_locks,
|
|
_pair_teleporters,
|
|
_parse_square_list,
|
|
_simulate_move,
|
|
print_puzzle,
|
|
solve,
|
|
)
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _minimal_puzzle_data() -> dict[str, Any]:
|
|
"""A 3-square puzzle: player -> normal -> goal in a row."""
|
|
return {
|
|
"squares": [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "normal"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
],
|
|
}
|
|
|
|
|
|
def _make_puzzle(squares_data: list[dict[str, Any]]) -> Puzzle:
|
|
return Puzzle.from_json({"squares": squares_data})
|
|
|
|
|
|
# ── SquareType ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSquareType:
|
|
def test_values(self) -> None:
|
|
assert SquareType("normal") == SquareType.NORMAL
|
|
assert SquareType("player") == SquareType.PLAYER
|
|
assert SquareType("goal") == SquareType.GOAL
|
|
assert SquareType("portal") == SquareType.PORTAL
|
|
assert SquareType("teleporter") == SquareType.TELEPORTER
|
|
assert SquareType("key") == SquareType.KEY
|
|
assert SquareType("lock") == SquareType.LOCK
|
|
|
|
|
|
# ── _parse_square_list ───────────────────────────────────────────────
|
|
|
|
|
|
class TestParseSquareList:
|
|
def test_basic(self) -> None:
|
|
sds = [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "goal"},
|
|
]
|
|
squares, meta = _parse_square_list(sds)
|
|
assert (0, 0) in squares
|
|
assert squares[(0, 0)].square_type == SquareType.PLAYER
|
|
assert meta.player_start == (0, 0)
|
|
assert meta.goal_pos == (0, 1)
|
|
|
|
def test_no_player_raises(self) -> None:
|
|
sds = [{"pos": [0, 0], "type": "goal"}]
|
|
with pytest.raises(ValueError, match="No player start"):
|
|
_parse_square_list(sds)
|
|
|
|
def test_no_goal_raises(self) -> None:
|
|
sds = [{"pos": [0, 0], "type": "player"}]
|
|
with pytest.raises(ValueError, match="No goal position"):
|
|
_parse_square_list(sds)
|
|
|
|
def test_teleporter_group(self) -> None:
|
|
sds = [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "goal"},
|
|
{"pos": [1, 0], "type": "teleporter", "group": 1},
|
|
{"pos": [1, 1], "type": "teleporter", "group": 1},
|
|
]
|
|
_, meta = _parse_square_list(sds)
|
|
assert 1 in meta.teleporter_groups
|
|
assert len(meta.teleporter_groups[1]) == 2
|
|
|
|
def test_key_lock_maps(self) -> None:
|
|
sds = [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
{"pos": [1, 0], "type": "key", "lock_id": 1},
|
|
{"pos": [1, 1], "type": "lock", "lock_id": 1},
|
|
]
|
|
_, meta = _parse_square_list(sds)
|
|
assert meta.key_map[1] == (1, 0)
|
|
assert meta.lock_map[1] == (1, 1)
|
|
|
|
def test_portal_side(self) -> None:
|
|
sds = [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
{"pos": [0, 1], "type": "portal", "side": "left"},
|
|
]
|
|
squares, _ = _parse_square_list(sds)
|
|
assert squares[(0, 1)].portal_side == "left"
|
|
|
|
def test_teleporter_without_group(self) -> None:
|
|
sds = [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "goal"},
|
|
{"pos": [1, 0], "type": "teleporter"},
|
|
]
|
|
_, meta = _parse_square_list(sds)
|
|
assert not meta.teleporter_groups
|
|
|
|
def test_key_without_lock_id(self) -> None:
|
|
sds = [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "goal"},
|
|
{"pos": [1, 0], "type": "key"},
|
|
]
|
|
_, meta = _parse_square_list(sds)
|
|
assert not meta.key_map
|
|
|
|
def test_lock_without_lock_id(self) -> None:
|
|
sds = [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "goal"},
|
|
{"pos": [1, 0], "type": "lock"},
|
|
]
|
|
_, meta = _parse_square_list(sds)
|
|
assert not meta.lock_map
|
|
|
|
|
|
# ── _pair_teleporters ────────────────────────────────────────────────
|
|
|
|
|
|
class TestPairTeleporters:
|
|
def test_valid_pair(self) -> None:
|
|
groups = {1: [(0, 0), (1, 1)]}
|
|
pairs = _pair_teleporters(groups)
|
|
assert pairs[(0, 0)] == (1, 1)
|
|
assert pairs[(1, 1)] == (0, 0)
|
|
|
|
def test_wrong_member_count_raises(self) -> None:
|
|
groups = {1: [(0, 0)]}
|
|
with pytest.raises(ValueError, match="Teleporter group 1"):
|
|
_pair_teleporters(groups)
|
|
|
|
def test_empty_groups(self) -> None:
|
|
assert _pair_teleporters({}) == {}
|
|
|
|
|
|
# ── _map_keys_to_locks ──────────────────────────────────────────────
|
|
|
|
|
|
class TestMapKeysToLocks:
|
|
def test_valid(self) -> None:
|
|
key_map = {1: (0, 0)}
|
|
lock_map = {1: (1, 1)}
|
|
result = _map_keys_to_locks(key_map, lock_map)
|
|
assert result[(0, 0)] == (1, 1)
|
|
|
|
def test_missing_lock_raises(self) -> None:
|
|
key_map = {1: (0, 0)}
|
|
lock_map: dict[int, tuple[int, int]] = {}
|
|
with pytest.raises(ValueError, match="lock_id=1 has no matching lock"):
|
|
_map_keys_to_locks(key_map, lock_map)
|
|
|
|
def test_empty(self) -> None:
|
|
assert _map_keys_to_locks({}, {}) == {}
|
|
|
|
|
|
# ── Puzzle ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPuzzle:
|
|
def test_from_json(self) -> None:
|
|
data = _minimal_puzzle_data()
|
|
p = Puzzle.from_json(data)
|
|
assert p.player_start == (0, 0)
|
|
assert p.goal_pos == (0, 2)
|
|
assert len(p.squares) == 3
|
|
|
|
def test_from_json_bounds(self) -> None:
|
|
data = _minimal_puzzle_data()
|
|
p = Puzzle.from_json(data)
|
|
min_r, max_r, min_c, max_c = p.grid_bounds
|
|
assert min_r == -1
|
|
assert max_r == 1
|
|
assert min_c == -1
|
|
assert max_c == 3
|
|
|
|
def test_from_file(self) -> None:
|
|
data = _minimal_puzzle_data()
|
|
m = mock_open(read_data=json.dumps(data))
|
|
with patch("pathlib.Path.open", m):
|
|
p = Puzzle.from_file("dummy.json")
|
|
assert p.player_start == (0, 0)
|
|
|
|
def test_from_json_with_teleporters(self) -> None:
|
|
data = {
|
|
"squares": [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 3], "type": "goal"},
|
|
{"pos": [1, 0], "type": "teleporter", "group": 1},
|
|
{"pos": [1, 3], "type": "teleporter", "group": 1},
|
|
],
|
|
}
|
|
p = Puzzle.from_json(data)
|
|
assert (1, 0) in p.teleporter_pairs
|
|
assert p.teleporter_pairs[(1, 0)] == (1, 3)
|
|
|
|
def test_from_json_with_key_lock(self) -> None:
|
|
data = {
|
|
"squares": [
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 3], "type": "goal"},
|
|
{"pos": [1, 0], "type": "key", "lock_id": 1},
|
|
{"pos": [1, 1], "type": "lock", "lock_id": 1},
|
|
],
|
|
}
|
|
p = Puzzle.from_json(data)
|
|
assert p.key_to_lock[(1, 0)] == (1, 1)
|
|
|
|
|
|
# ── solve ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSolve:
|
|
def test_simple_right(self) -> None:
|
|
"""Player slides right to goal."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "normal"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
]
|
|
)
|
|
moves = solve(p)
|
|
assert moves is not None
|
|
assert "right" in moves
|
|
|
|
def test_no_solution(self) -> None:
|
|
"""Player has no path to goal."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [2, 2], "type": "goal"},
|
|
]
|
|
)
|
|
assert solve(p) is None
|
|
|
|
def test_with_teleporter(self) -> None:
|
|
"""Player hits teleporter and warps."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "teleporter", "group": 1},
|
|
{"pos": [2, 0], "type": "teleporter", "group": 1},
|
|
{"pos": [2, 1], "type": "goal"},
|
|
]
|
|
)
|
|
moves = solve(p)
|
|
assert moves is not None
|
|
|
|
def test_with_key_lock(self) -> None:
|
|
"""Player collects key to unlock path."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "key", "lock_id": 1},
|
|
{"pos": [0, 2], "type": "normal"},
|
|
{"pos": [1, 0], "type": "normal"},
|
|
{"pos": [1, 2], "type": "lock", "lock_id": 1},
|
|
{"pos": [2, 0], "type": "normal"},
|
|
{"pos": [2, 2], "type": "goal"},
|
|
]
|
|
)
|
|
moves = solve(p)
|
|
assert moves is not None
|
|
|
|
def test_with_portal_passthrough(self) -> None:
|
|
"""Portal is passthrough from its marked side."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "portal", "side": "left"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
]
|
|
)
|
|
moves = solve(p)
|
|
assert moves == ["right"]
|
|
|
|
def test_portal_blocks_from_other_side(self) -> None:
|
|
"""Portal blocks approach from non-marked side."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "portal", "side": "right"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
]
|
|
)
|
|
# approaching from left, but side is "right" => should stop at portal
|
|
moves = solve(p)
|
|
# Player lands on portal, doesn't reach goal directly by going right
|
|
assert moves is not None
|
|
|
|
|
|
# ── _simulate_move ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestSimulateMove:
|
|
def test_off_grid_returns_none(self) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "goal"},
|
|
]
|
|
)
|
|
state = State((0, 0), frozenset())
|
|
# Move up from (0,0) → off grid
|
|
result = _simulate_move(p, state, -1, 0)
|
|
assert result is None
|
|
|
|
def test_land_on_normal(self) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "normal"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
]
|
|
)
|
|
state = State((0, 0), frozenset())
|
|
result = _simulate_move(p, state, 0, 1)
|
|
assert result is not None
|
|
new_state, is_goal = result
|
|
assert new_state.pos == (0, 1)
|
|
assert not is_goal
|
|
|
|
def test_land_on_goal(self) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "goal"},
|
|
]
|
|
)
|
|
state = State((0, 0), frozenset())
|
|
result = _simulate_move(p, state, 0, 1)
|
|
assert result is not None
|
|
_, is_goal = result
|
|
assert is_goal
|
|
|
|
def test_slide_through_vanished_lock(self) -> None:
|
|
"""Lock is inactive (not in active_locks) → slide through."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "lock", "lock_id": 1},
|
|
{"pos": [0, 2], "type": "key", "lock_id": 1},
|
|
{"pos": [0, 3], "type": "goal"},
|
|
]
|
|
)
|
|
# Lock at (0,1) is not in active_locks → vanished
|
|
state = State((0, 0), frozenset())
|
|
result = _simulate_move(p, state, 0, 1)
|
|
assert result is not None
|
|
# Should slide through the vanished lock
|
|
|
|
def test_portal_passthrough(self) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "portal", "side": "left"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
]
|
|
)
|
|
state = State((0, 0), frozenset())
|
|
result = _simulate_move(p, state, 0, 1)
|
|
assert result is not None
|
|
new_state, is_goal = result
|
|
assert is_goal
|
|
assert new_state.pos == (0, 2)
|
|
|
|
def test_teleporter_landing(self) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "teleporter", "group": 1},
|
|
{"pos": [2, 2], "type": "teleporter", "group": 1},
|
|
{"pos": [2, 3], "type": "goal"},
|
|
]
|
|
)
|
|
state = State((0, 0), frozenset())
|
|
result = _simulate_move(p, state, 0, 1)
|
|
assert result is not None
|
|
new_state, is_goal = result
|
|
assert new_state.pos == (2, 2)
|
|
assert not is_goal
|
|
|
|
def test_key_landing_removes_lock(self) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "key", "lock_id": 1},
|
|
{"pos": [1, 0], "type": "lock", "lock_id": 1},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
]
|
|
)
|
|
lock_pos = (1, 0)
|
|
state = State((0, 0), frozenset({lock_pos}))
|
|
result = _simulate_move(p, state, 0, 1)
|
|
assert result is not None
|
|
new_state, is_goal = result
|
|
assert new_state.pos == (0, 1)
|
|
assert lock_pos not in new_state.active_locks
|
|
assert not is_goal
|
|
|
|
def test_active_lock_blocks(self) -> None:
|
|
"""When lock is active, it blocks movement."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "lock", "lock_id": 1},
|
|
{"pos": [0, 2], "type": "key", "lock_id": 1},
|
|
{"pos": [0, 3], "type": "goal"},
|
|
]
|
|
)
|
|
lock_pos = (0, 1)
|
|
state = State((0, 0), frozenset({lock_pos}))
|
|
result = _simulate_move(p, state, 0, 1)
|
|
assert result is not None
|
|
new_state, is_goal = result
|
|
# Lands on the lock since it's active
|
|
assert new_state.pos == (0, 1)
|
|
assert not is_goal
|
|
|
|
|
|
# ── print_puzzle ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPrintPuzzle:
|
|
def test_basic(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "normal"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
]
|
|
)
|
|
print_puzzle(p)
|
|
|
|
def test_all_types(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "normal"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
{"pos": [1, 0], "type": "portal", "side": "left"},
|
|
{"pos": [1, 1], "type": "portal", "side": "right"},
|
|
{"pos": [1, 2], "type": "portal", "side": "up"},
|
|
{"pos": [2, 0], "type": "portal", "side": "down"},
|
|
{"pos": [2, 1], "type": "teleporter", "group": 1},
|
|
{"pos": [2, 2], "type": "teleporter", "group": 1},
|
|
]
|
|
)
|
|
print_puzzle(p)
|
|
|
|
def test_portal_no_side(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 1], "type": "portal"},
|
|
{"pos": [0, 2], "type": "goal"},
|
|
]
|
|
)
|
|
print_puzzle(p)
|
|
|
|
def test_empty_cells(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Grid with gaps should print spaces."""
|
|
p = _make_puzzle(
|
|
[
|
|
{"pos": [0, 0], "type": "player"},
|
|
{"pos": [0, 3], "type": "goal"},
|
|
]
|
|
)
|
|
print_puzzle(p)
|
|
|
|
|
|
# ── print_solution ───────────────────────────────────────────────────
|