FEnigma/tests/test_ocr.py
Dominik Roth 6e34f50b6a Fix phantom "Important:" header stealing a taking-fire report's deadline
Turned out to be one bug, not two: "Answer by <time>" phrasing was
already covered by _TAKING_FIRE_TIME_RE ('before|by <time>'). The real
bug was the last-resort bare-"<Name>:" header fallback matching a
same-message "Important:" follow-up line as a brand new named entity
(nothing excluded common prose lead-ins), creating a bogus
Target#Important that stole the deadline into its own requested_time
instead of the real report's. Fixed with a blocklist on that fallback
rule (important/note/warning/attention/caution/alert/reminder/priority).
New regression test, 55 total passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 22:09:15 +02:00

416 lines
18 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_destroyed_report_strips_a_leading_enemy_prefix():
"""A real kill-feed paste with an "Enemy <Type>#<id> Destroyed" shape
(squashed by squash_multiword_ids to "EnemyMechanizedInfantry#1"
before this ever runs) was silently dropping every single-word type
("Enemy Infantry#11 Destroyed") -- the un-stripped "Enemy" prefix
only accidentally fuzzy-matched for longer/more distinctive type
words (Mechanized Infantry), not shorter/more different ones (Field
Gun -- see test_field_gun_is_an_artillery_alias). _ALLY_PREFIX_RE now
strips "Enemy" the same as "Hostile"."""
text = ("Enemy Mechanized Infantry#1 Destroyed, +5 Requisition.\n"
"Enemy Infantry#11 Destroyed, +5 Requisition.")
info = ocr.parse_text(text)
assert info.destroyed == {(TargetType.INFANTRY_MECHANIZED, "1"), (TargetType.INFANTRY, "11")}
def test_bare_enemy_destroyed_report_is_still_target_type_enemy():
"""The lookahead in _ALLY_PREFIX_RE (only strip "Enemy" when there's
something AFTER it) matters here specifically: a BARE "Enemy#N" is
TargetType.ENEMY itself (its own value IS "Enemy") -- stripping the
prefix unconditionally would leave an empty type_word and silently
drop every ad-hoc "Enemy#N Destroyed" report instead."""
text = "Enemy#7 Destroyed, +5 Requisition."
info = ocr.parse_text(text)
assert (TargetType.ENEMY, "7") in info.destroyed
def test_field_gun_is_an_artillery_alias():
"""The game calls plain Artillery "Field Gun" in at least this kill-
feed message -- confirmed by the user against a real "Enemy Field
Gun#1 Destroyed" line that was otherwise silently dropping (no
TargetType.FIELD_GUN exists, nor should one -- see _TYPE_WORD_ALIASES'
own comment, same treatment as AmmoCache/CoastalBattery)."""
text = "Priority target Enemy Field Gun#1 Destroyed, +50 Requisition."
info = ocr.parse_text(text)
assert (TargetType.ARTILLERY, "1") in info.destroyed
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.
The reporting unit ('Infantry#1 taking fire') is always a FRIENDLY
calling in its own distress -- no hostile ever radios in about
itself -- so it lands in info.allies, not info.targets (a real bug:
it used to default to not-ally, no "Friendly"/"Hostile" prefix word
exists in this grammar for the usual inference to key off of). The
shell/deadline still need a home a plain Ally tuple doesn't have
room for, so they move to a synthetic StrikeRequest target at the
SAME coord as the reporting unit ('on our position' means the fire
point IS that position, no offset given)."""
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.allies
assert (TargetType.INFANTRY, "1") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "1")]
assert coord == Coord("J", 6, 2, 7)
assert (TargetType.STRIKE_REQUEST, "Infantry1") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry1")]
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.allies
assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
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 -- two different places, so this becomes two
entries: Infantry#3 stays at its own reported position, as an ALLY
(see test_infantry_taking_fire_no_attacker_mention's own docstring --
same reasoning, this is still a taking-fire report), and a separate
synthetic Strike entry carries the shell/deadline at the computed
offset coord ('our position' isn't a named board entity to hang a
Clue off of, so this resolves straight to an absolute coord rather
than via one)."""
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.allies
assert (TargetType.INFANTRY, "3") not in info.targets
raw, clues, coord = info.allies[(TargetType.INFANTRY, "3")]
assert coord == Coord("J", 6, 2, 5)
assert (TargetType.STRIKE_REQUEST, "Infantry3") in info.targets
raw, clues, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry3")]
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
def test_infantry_taking_fire_bearing_distance_short_range():
"""Same shape, a sub-1km offset (the earlier fixture's own distance,
10.76km, is far enough that a rounding slip in the offset math could
have gone unnoticed inside the same large cell -- this one crosses a
cell boundary, I7 0:8 -> H7 8:4, so a sign/axis error would visibly
land in the wrong cell letter entirely, not just a slightly-off
sub-position)."""
text = ("Infantry#11 taking fire!\n"
"Requesting <u><b>HE Shell</b></u> at bearing <b>210°</b>, distance "
"<b>0.43km</b> from our position, <b>I7 0:8</b>, by <u>10:17:37</u> "
"or we will be overrun!")
info = ocr.parse_text(text)
assert (TargetType.INFANTRY, "11") in info.allies
assert (TargetType.INFANTRY, "11") not in info.targets
_, _, coord = info.allies[(TargetType.INFANTRY, "11")]
assert coord == Coord("I", 7, 0, 8)
assert (TargetType.STRIKE_REQUEST, "Infantry11") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry11")]
assert coord == Coord("H", 7, 8, 4)
assert shell is Shell.HE
assert requested_time == "10:17:37"
def test_taking_fire_important_followup_line_does_not_steal_the_deadline():
"""A real user-pasted message: a same-report "Important: ... Answer by
<time>" follow-up line was being misread as a brand new named entity
header (the last-resort bare-"<Name>:" fallback matched "Important:"
itself), creating a bogus Target#Important that stole the actual
report's own deadline into that wrong entry instead of the real
StrikeRequest. "Answer by <time>" is also a deadline phrasing
_TAKING_FIRE_TIME_RE already covers (any 'before'/'by <time>') --
once the phantom split stops happening, it resolves correctly with
no extra fix needed."""
text = ("Infantry#4 taking fire!\n"
"Requesting <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>,\n"
"at bearing <b>308°</b>, distance <b>1.86km</b> from our position, <b>N2 0:9</b>\n"
"<u>Important:</u> <u><b>TEAR Shell</b></u> first, then <u><b>HE Shell</b></u>.\n"
"Answer by <u>10:30:00</u>")
info = ocr.parse_text(text)
assert (TargetType.UNKNOWN, "Important") not in info.targets
assert (TargetType.INFANTRY, "4") in info.allies
assert (TargetType.INFANTRY, "4") not in info.targets
assert (TargetType.STRIKE_REQUEST, "Infantry4") in info.targets
_, _, coord, shell, requested_time = info.targets[(TargetType.STRIKE_REQUEST, "Infantry4")]
assert requested_time == "10:30:00"
# Known gap, not asserted as fixed here: only the FIRST shell of a
# "X first, then Y" sequence is captured -- see TODO.md.
assert shell is Shell.TEAR