diff --git a/README.md b/README.md index 19a3684..b056f65 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..fcccae1 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = src diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..04a9fcc --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +# Only needed to run the test suite (pytest), not the app itself. +-r requirements.txt +pytest diff --git a/tests/test_ocr.py b/tests/test_ocr.py new file mode 100644 index 0000000..1f2d243 --- /dev/null +++ b/tests/test_ocr.py @@ -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 ': +' 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 The Mole: + Bearing 100° from Spotter#1 + Bearing 048° from Spotter#2 +. +Reference Point Dockmaster's House: + Distance 6.14km from The Mole +""" + 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 = """HMS Rockingham: + Distance 4.65km from Spotter#2 +""" + 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 - A2 9:8 +. +OBSERVATION ASSETS: + Spotter#1 - C9 7:9 + Spotter#2 - E5 5:0 +""" + 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) diff --git a/tests/test_solver.py b/tests/test_solver.py new file mode 100644 index 0000000..fe60519 --- /dev/null +++ b/tests/test_solver.py @@ -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