Add a real pytest suite: 21 tests covering every OCR format + solver geometry
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.
This commit is contained in:
parent
9c7588eb12
commit
4b427e5b0d
@ -36,6 +36,15 @@ Sets up a venv for the Python deps (Pillow, numpy, pytesseract) and checks for t
|
||||
|
||||
Uses the venv from `install.sh` if one exists, otherwise falls back to system `python3`. GTK apps with this app ID are single-instance, if a run gets killed uncleanly it can leave a zombie registered on D-Bus and silently no-op the next launch. If `./run.sh` seems to do nothing, `pgrep -af fenigma` and kill any stragglers first.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
Regression coverage for every intel-text format the OCR pipeline understands and the solver's geometry, in `tests/`. Run this before trusting a change to `ocr.py`/`solver.py`, several of the formats have collided with each other in non-obvious ways before.
|
||||
|
||||
## Stack
|
||||
|
||||
GTK4 + libadwaita (PyGObject) for the UI, Tesseract (via pytesseract) for OCR, Pillow/numpy for preprocessing. Details on the coordinate system, OCR formats, and solver internals live in code comments (`solver.py`, `ocr.py`, `models.py`) rather than here.
|
||||
|
||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
pythonpath = src
|
||||
3
requirements-dev.txt
Normal file
3
requirements-dev.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# Only needed to run the test suite (pytest), not the app itself.
|
||||
-r requirements.txt
|
||||
pytest
|
||||
216
tests/test_ocr.py
Normal file
216
tests/test_ocr.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""Regression coverage for every intel-text format ocr.py understands.
|
||||
|
||||
Each format below was added incrementally in response to a real
|
||||
screenshot/paste the user hit, and at least one of them (the '<ref>:
|
||||
<value>' grammar colliding with the 'Type#id:' header shape) has already
|
||||
regressed silently once because there was no test suite to catch it.
|
||||
One test per format, named after what it covers, so a future change
|
||||
that breaks an old format fails loudly and specifically instead of
|
||||
being noticed (or not) days later.
|
||||
"""
|
||||
from fenigma import ocr
|
||||
from fenigma.models import Coord, TargetType
|
||||
|
||||
|
||||
def test_standard_target_and_rp_blocks():
|
||||
text = """
|
||||
Target#5 Spotted. 088, 12.10km from Spotter#1
|
||||
.
|
||||
Reference Point Alpha:
|
||||
Bearing 094 from Spotter#1
|
||||
Distance 13.26km from Spotter#2
|
||||
.
|
||||
AmmoCache#3:
|
||||
Bearing 217 & Distance 10.48km from AmmoCache#2
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
|
||||
assert (TargetType.UNKNOWN, "5") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.UNKNOWN, "5")]
|
||||
assert clues == [ocr.Clue(reference="Spotter#1", bearing_deg=88.0, distance_km=12.1)]
|
||||
|
||||
# "AmmoCache" is an alias for SupplyCache, both in the header and in
|
||||
# a reference to an existing one.
|
||||
assert (TargetType.SUPPLY_CACHE, "3") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.SUPPLY_CACHE, "3")]
|
||||
assert clues == [ocr.Clue(reference="SupplyCache#2", bearing_deg=217.0, distance_km=10.48)]
|
||||
|
||||
assert "Alpha" in info.reference_points
|
||||
|
||||
|
||||
def test_calibration_target_line():
|
||||
text = "IRON NEST location - H3 5:5\nTARGET COORDINATES:\nTarget is at- Q4 4:2"
|
||||
info = ocr.parse_text(text)
|
||||
assert info.nest_coord == Coord("H", 3, 5, 5)
|
||||
assert info.targets[(TargetType.UNKNOWN, "1")][2] == Coord("Q", 4, 4, 2)
|
||||
|
||||
|
||||
def test_destroyed_reports_digit_and_letter_id():
|
||||
text = "SupplyCache#2 Destroyed. Additional Requisition Granted.\nDirect Hit! HostileTank#3 Destroyed."
|
||||
info = ocr.parse_text(text)
|
||||
assert info.destroyed == {(TargetType.SUPPLY_CACHE, "2"), (TargetType.HOSTILE_TANK, "3")}
|
||||
|
||||
|
||||
def test_train_arrival_intel():
|
||||
text = """ARRIVAL STATION:
|
||||
Valle de Mula MainStation: J6 0:4
|
||||
.
|
||||
Estimated arrival: T=10:16:50
|
||||
.
|
||||
TRACK ALIGNMENT:
|
||||
Rail line runs straight. Bearing 090 from MainStation.
|
||||
.
|
||||
FINAL APPROACH:
|
||||
Waypoint A - 6.00km from station: T=10:06:50
|
||||
Waypoint B - 4.00km from station: T=10:10:10
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert "MainStation" in info.reference_points
|
||||
assert info.reference_points["MainStation"][2] == Coord("J", 6, 0, 4)
|
||||
assert "Waypoint A" in info.reference_points
|
||||
_, clues, coord = info.reference_points["Waypoint A"]
|
||||
assert clues == [ocr.Clue(reference="MainStation", bearing_deg=90.0, distance_km=6.0)]
|
||||
|
||||
|
||||
def test_enemy_multiword_name_becomes_its_own_target_type():
|
||||
text = """Enemy Signal Station:
|
||||
Distance 4.40km from Spotter#1
|
||||
Distance 5.96km from Spotter#2
|
||||
.
|
||||
Enemy Assembly Area:
|
||||
Bearing 034 from Enemy Signal Station
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.ENEMY, "SignalStation") in info.targets
|
||||
assert (TargetType.ENEMY, "AssemblyArea") in info.targets
|
||||
_, clues, *_ = info.targets[(TargetType.ENEMY, "AssemblyArea")]
|
||||
assert clues == [ocr.Clue(reference="Enemy#SignalStation", bearing_deg=34.0)]
|
||||
|
||||
|
||||
def test_enemy_destroyed_report():
|
||||
text = "Priority target Enemy Signal Station Destroyed, +25 Requisition."
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.ENEMY, "SignalStation") in info.destroyed
|
||||
|
||||
|
||||
def test_listening_post_and_coastal_battery():
|
||||
text = """Listening Post#1 at K6 7:8 audio reports on:
|
||||
Coastal Battery#2:
|
||||
Distance 6.28km South-East from Listening Post#1
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert info.reference_points["ListeningPost#1"][2] == Coord("K", 6, 7, 8)
|
||||
assert (TargetType.COASTAL_BATTERY, "2") in info.targets
|
||||
_, clues, *_ = info.targets[(TargetType.COASTAL_BATTERY, "2")]
|
||||
assert clues == [ocr.Clue(reference="ListeningPost#1", bearing_deg=135.0, distance_km=6.28)]
|
||||
|
||||
|
||||
def test_marine_garrison_fire_support_request():
|
||||
text = """Marine Garrison#1 pinned!
|
||||
SMK Shells requested on J6 8:3
|
||||
Requested before - T10:31:41 -
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.MARINE_GARRISON, "1") in info.targets
|
||||
raw, clues, coord, shell, requested_time = info.targets[(TargetType.MARINE_GARRISON, "1")]
|
||||
assert coord == Coord("J", 6, 8, 3)
|
||||
from fenigma.shells import Shell
|
||||
assert shell is Shell.SMK
|
||||
assert requested_time == "T10:31:41"
|
||||
|
||||
|
||||
def test_multiword_rp_names_with_bold_markup():
|
||||
text = """Reference Point <b>The Mole</b>:
|
||||
Bearing <b>100°</b> from <b>Spotter#1</b>
|
||||
Bearing <b>048°</b> from <b>Spotter#2</b>
|
||||
.
|
||||
Reference Point <b>Dockmaster's House</b>:
|
||||
Distance <b>6.14km</b> from <b>The Mole</b>
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert "TheMole" in info.reference_points
|
||||
assert "DockmastersHouse" in info.reference_points
|
||||
_, clues, _ = info.reference_points["DockmastersHouse"]
|
||||
assert clues == [ocr.Clue(reference="TheMole", distance_km=6.14)]
|
||||
|
||||
|
||||
def test_bare_name_header_becomes_a_target_not_an_rp():
|
||||
text = """<b>HMS Rockingham</b>:
|
||||
Distance <b>4.65km</b> from <b>Spotter#2</b>
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.UNKNOWN, "HMSRockingham") in info.targets
|
||||
assert not info.reference_points
|
||||
|
||||
|
||||
def test_bold_coordinate_spans_are_not_corrupted_by_name_squashing():
|
||||
"""A real regression: squashing multi-word bold spans into single
|
||||
tokens (for names) once also mangled multi-word COORD spans like
|
||||
'C9 7:9' into garbage ('C979'), because nothing distinguished the
|
||||
two cases. Guarded here permanently."""
|
||||
text = """IRON NEST - <b>A2 9:8</b>
|
||||
.
|
||||
OBSERVATION ASSETS:
|
||||
<b>Spotter#1</b> - <b>C9 7:9</b>
|
||||
<b>Spotter#2</b> - <b>E5 5:0</b>
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert info.nest_coord == Coord("A", 2, 9, 8)
|
||||
assert info.spotters == {1: Coord("C", 9, 7, 9), 2: Coord("E", 5, 5, 0)}
|
||||
|
||||
|
||||
def test_forward_observer_reports_triangulate_without_leaving_ephemeral_entities():
|
||||
text = """FO#5 Audio report on HMS Rockingham: 2.24km From I8 6:9 . . .
|
||||
- - -
|
||||
FO#4 Eyes on HMS Rockingham: 087° From G7 6:7 . . .
|
||||
- - -
|
||||
FO Eyes on HMS Rockingham: 099° From C9 1:2 . . .
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.UNKNOWN, "HMSRockingham") in info.targets
|
||||
_, clues, coord, *_ = info.targets[(TargetType.UNKNOWN, "HMSRockingham")]
|
||||
# solved immediately via a scratch board, no Clues referencing an FO
|
||||
# persist, and no FO ever leaks in as a real reference point.
|
||||
assert clues == []
|
||||
assert coord is not None
|
||||
assert not info.reference_points
|
||||
|
||||
|
||||
def test_ref_colon_value_clue_grammar():
|
||||
"""'Spotter#2: 4.04km' has the exact same 'Word#digits:' shape as a
|
||||
real block header ('AmmoCache#3:'), a real regression: it hijacked
|
||||
the block before any clues could attach to the entity above it."""
|
||||
text = """Enemy Assembly Area:
|
||||
Spotter#2: 4.04km
|
||||
Spotter#3: 298°
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
assert (TargetType.ENEMY, "AssemblyArea") in info.targets
|
||||
_, clues, *_ = info.targets[(TargetType.ENEMY, "AssemblyArea")]
|
||||
assert clues == [
|
||||
ocr.Clue(reference="Spotter#2", distance_km=4.04),
|
||||
ocr.Clue(reference="Spotter#3", bearing_deg=298.0),
|
||||
]
|
||||
|
||||
|
||||
def test_compass_word_bearings_carry_a_tolerance():
|
||||
text = """Enemy Field Command:
|
||||
Spotter#1: West
|
||||
Spotter#2: North-West
|
||||
Spotter#3: North Northwest
|
||||
"""
|
||||
info = ocr.parse_text(text)
|
||||
_, clues, *_ = info.targets[(TargetType.ENEMY, "FieldCommand")]
|
||||
by_ref = {c.reference: c for c in clues}
|
||||
assert by_ref["Spotter#1"].bearing_deg == 270.0
|
||||
assert by_ref["Spotter#2"].bearing_deg == 315.0
|
||||
assert by_ref["Spotter#3"].bearing_deg == 337.5
|
||||
for clue in clues:
|
||||
assert clue.bearing_tolerance_deg == 11.25
|
||||
|
||||
|
||||
def test_grid_only_coord_no_sub_position():
|
||||
text = "Enemy Signal Station:\n Reported active in grid D10"
|
||||
info = ocr.parse_text(text)
|
||||
_, clues, coord, *_ = info.targets[(TargetType.ENEMY, "SignalStation")]
|
||||
assert coord == Coord("D", 10, 5, 5)
|
||||
89
tests/test_solver.py
Normal file
89
tests/test_solver.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""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
|
||||
Loading…
Reference in New Issue
Block a user