No test suite existed before this, which is exactly how a real regression (the bold-span coordinate-squashing bug, and the 'Type#id:'/'<ref>: <value>' header collision, both from this session) went unnoticed until manually re-triggered. One test per format, cross-referenced against the full commit history so nothing already shipped gets silently dropped by a future change: tests/test_ocr.py: standard blocks, the calibration target line, destruction reports (digit and letter id), train-arrival intel, ad-hoc Enemy installations (+ their destroyed reports), Listening Post/Coastal Battery, Marine Garrison fire-support requests, multi- word RP names, bare-name-header targets, the bold-span coordinate- squashing regression specifically, forward-observer report triangulation, the '<ref>: <value>' clue grammar (+ its header- collision regression specifically), 16-point compass tolerance, and grid-only coordinates. tests/test_solver.py: direct bearing+distance resolution, two-bearing triangulation, genuine two-distance ambiguity, the nested-circles compromise-point fallback, toleranced bearings never being used to triangulate, and manual coord overrides clearing a stale note. Runs via ============================= test session starts ============================== platform linux -- Python 3.14.6, pytest-8.4.2, pluggy-1.6.0 rootdir: /home/dodox/Projects/FeNigma configfile: pytest.ini plugins: anyio-4.13.0 collected 21 items tests/test_ocr.py ............... [ 71%] tests/test_solver.py ...... [100%] ============================== 21 passed in 0.72s ============================== (pythonpath configured in pytest.ini), dev-only dependency in requirements-dev.txt so the app itself stays dependency-light. Documented in the README.
90 lines
3.7 KiB
Python
90 lines
3.7 KiB
Python
"""Regression coverage for solver.py's geometric resolution."""
|
|
from fenigma import solver
|
|
from fenigma.models import Board, Clue, Coord, Location, TargetType
|
|
|
|
|
|
def _board_with_spotters(*coords):
|
|
board = Board()
|
|
for i, coord in enumerate(coords, start=1):
|
|
board.add_spotter(coord, i)
|
|
return board
|
|
|
|
|
|
def test_bearing_and_distance_from_one_reference_resolves_directly():
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0))
|
|
target = board.add_target(TargetType.HOSTILE_TANK, id_="1")
|
|
target.location = Location.from_desc("x", [Clue(reference="Spotter#1", bearing_deg=90.0, distance_km=3.0)])
|
|
solver.resolve_board(board)
|
|
assert target.coord is not None
|
|
|
|
|
|
def test_two_bearings_resolve_via_ray_ray_intersection():
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0))
|
|
target = board.add_target(TargetType.HOSTILE_TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", bearing_deg=90.0),
|
|
Clue(reference="Spotter#2", bearing_deg=180.0),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert target.coord is not None
|
|
|
|
|
|
def test_two_distances_that_actually_cross_are_ambiguous_not_resolved():
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("P", 5, 0, 0))
|
|
target = board.add_target(TargetType.HOSTILE_TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=7.33),
|
|
Clue(reference="Spotter#2", distance_km=3.43),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert target.coord is None
|
|
assert len(target.location.potential_coords) == 2
|
|
|
|
|
|
def test_nested_distance_circles_fall_back_to_compromise_point():
|
|
"""Two distances that don't actually cross (one circle nested inside
|
|
the other, given how close the two spotters are) used to just give
|
|
up entirely. closest_compromise_point() finds a bounded, sane
|
|
stand-in instead, flagged via Location.note rather than treated as
|
|
a clean resolution."""
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("J", 5, 1, 0))
|
|
target = board.add_target(TargetType.HOSTILE_TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=7.33),
|
|
Clue(reference="Spotter#2", distance_km=3.43),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert target.coord is not None
|
|
assert target.location.note is not None
|
|
assert "approximate" in target.location.note
|
|
|
|
|
|
def test_toleranced_bearing_is_never_used_to_triangulate():
|
|
"""A compass-word bearing (bearing_tolerance_deg set) names a whole
|
|
sector, solve_location() must never use it as if it were a precise
|
|
ray, even when it's the only bearing-shaped clue available."""
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("L", 4, 3, 0))
|
|
target = board.add_target(TargetType.HOSTILE_TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=3.0),
|
|
Clue(reference="Spotter#2", bearing_deg=270.0, bearing_tolerance_deg=11.25),
|
|
])
|
|
solver.resolve_board(board)
|
|
# only one usable (distance-only) clue remains once the toleranced
|
|
# bearing is excluded, not enough to resolve anything on its own.
|
|
assert target.coord is None
|
|
assert not target.location.potential_coords
|
|
|
|
|
|
def test_manual_coord_override_clears_a_stale_note():
|
|
board = _board_with_spotters(Coord("J", 5, 0, 0), Coord("J", 5, 1, 0))
|
|
target = board.add_target(TargetType.HOSTILE_TANK, id_="1")
|
|
target.location = Location.from_desc("x", [
|
|
Clue(reference="Spotter#1", distance_km=7.33),
|
|
Clue(reference="Spotter#2", distance_km=3.43),
|
|
])
|
|
solver.resolve_board(board)
|
|
assert target.location.note is not None
|
|
target.coord = Coord("A", 1, 0, 0)
|
|
assert target.location.note is None
|