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>
416 lines
18 KiB
Python
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
|