- Board.clear() now also drops allies; the "clear board?" guard checks
allies too. New Board.clear_units() + Clear button right-click menu
("clear enemies, units & flights", keeps Nest/spotters/RPs).
- An Ally with the ad-hoc TargetType.ENEMY showed "Enemy" on the map
popover/toast instead of "Ally" (icons.target_type_label already had
the fix for the picker, now reused everywhere else via app.py's
_display_name).
- Firing panel drag-reorder no longer triggers a full app refresh
(solver + dedupe + map redraw) on every drop, just a local rebuild.
- "Always show geo" didn't draw for Allies (missing from the overlay
candidate list); blast radius only respected selection, not the
show_geo_desc pin.
- ocr.py: added a second fire-support-request grammar ("Infantry#N
taking fire ... Requesting X Shell on our position at <coord> before
<time>", plus a bearing/distance-from-position variant), distinct
from the existing Marine Garrison one.
- New debug_capture.py: saves screenshots (+ metadata) the app handled
badly, for later tuning of map_vision/ocr against real failures:
map-read errors, user grid corrections (paired with the auto-detected
grid), screenshots that read as text but may have been a map, and
marker-detection ground truth (every proposal's accept/reject verdict
plus units added with no matching proposal) captured whenever a
screenshot stops being the active one.
- README: Known issues section (map screenshot reading, grid + unit
detection, is unreliable and fails often).
- 14 new tests (tests/test_models.py, tests/test_debug_capture.py, +
additions to tests/test_ocr.py), 38/38 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""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
|
|
from fenigma.shells import Shell
|
|
|
|
|
|
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.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():
|
|
"""'Coastal Battery' is an alias for HostileArtillery (see
|
|
_TYPE_WORD_ALIASES), not its own TargetType, it's just the
|
|
fixed-emplacement flavor of the same thing."""
|
|
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.ARTILLERY, "2") in info.targets
|
|
_, clues, *_ = info.targets[(TargetType.ARTILLERY, "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)
|
|
|
|
|
|
def test_friendly_prefix_routes_to_allies_hostile_and_bare_stay_targets():
|
|
"""A 'Friendly' prefix routes a parsed entry into info.allies
|
|
entirely, a separate collection from info.targets, not a flag
|
|
alongside it, an ally and a same-typed hostile target don't share
|
|
an id namespace. 'Hostile' and no prefix at all both mean a regular
|
|
(non-ally) Target."""
|
|
text = """FriendlyTank#1 Spotted. 088, 12.10km from Spotter#1
|
|
.
|
|
HostileTank#2 Spotted. 090, 5.00km from Spotter#1
|
|
.
|
|
Tank#3 Spotted. 095, 3.00km from Spotter#1
|
|
"""
|
|
info = ocr.parse_text(text)
|
|
assert (TargetType.TANK, "1") in info.allies
|
|
assert (TargetType.TANK, "1") not in info.targets
|
|
assert (TargetType.TANK, "2") in info.targets
|
|
assert (TargetType.TANK, "3") in info.targets
|
|
|
|
|
|
def test_infantry_taking_fire_direct_position_request():
|
|
"""A different fire-support-request grammar from Marine Garrison's:
|
|
shell word order reversed ('Requesting X Shell' not 'X Shells
|
|
requested'), deadline is a bare 'before <time>' with no 'Requested'/
|
|
dashes. The '<b>id1</b>' attacker mention is just prose here, not
|
|
parsed into anything -- only the request itself (shell, position,
|
|
deadline) matters."""
|
|
text = ("Infantry#1 taking fire from <b>id1</b>!\n"
|
|
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:7</b> "
|
|
"before <u>10:38:57</u>!")
|
|
info = ocr.parse_text(text)
|
|
assert (TargetType.INFANTRY, "1") in info.targets
|
|
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "1")]
|
|
assert coord == Coord("J", 6, 2, 7)
|
|
assert shell is Shell.SMK
|
|
assert requested_time == "10:38:57"
|
|
|
|
|
|
def test_infantry_taking_fire_no_attacker_mention():
|
|
text = ("Infantry#3 taking fire!\n"
|
|
"Requesting <u><b>SMK Shell</b></u> on our position at <b>J6 2:5</b> "
|
|
"before <u>10:37:52</u>!")
|
|
info = ocr.parse_text(text)
|
|
assert (TargetType.INFANTRY, "3") in info.targets
|
|
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
|
|
assert coord == Coord("J", 6, 2, 5)
|
|
assert shell is Shell.SMK
|
|
assert requested_time == "10:37:52"
|
|
|
|
|
|
def test_infantry_taking_fire_bearing_distance_from_position():
|
|
"""The other request shape: the shell isn't wanted right on top of the
|
|
reporting unit, but at a bearing/distance offset from its own
|
|
(inline-given) position -- 'our position' isn't a named board entity
|
|
to hang a Clue off of, so this resolves straight to an absolute
|
|
coord."""
|
|
text = ("Infantry#3 taking fire!\n"
|
|
"Requesting <u><b>HE Shell</b></u> at bearing <b>239°</b>, distance "
|
|
"<b>10.76km</b> from our position, <b>J6 2:5</b>, by <u>10:38:18</u> "
|
|
"or we will be overrun!")
|
|
info = ocr.parse_text(text)
|
|
assert (TargetType.INFANTRY, "3") in info.targets
|
|
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
|
|
assert shell is Shell.HE
|
|
assert requested_time == "10:38:18"
|
|
from fenigma import solver
|
|
expected = solver.point_to_coord(
|
|
solver.point_from_bearing_distance(Coord("J", 6, 2, 5).as_fraction(), 239.0, 10.76))
|
|
assert coord == expected
|