rerender on assign/alive/shell, add Windows build tooling - Board.add_target/add_ally's id auto-assignment used a bare next(c for c in string.ascii_uppercase if c not in used), which raises StopIteration once 26 entities of a group exist -- a real crash confirmed via a live traceback, and a direct regression from moving that sequence from per-type to per-group. This was the actual cause of "Accept as"/"Accept all" silently doing nothing. Fixed with _next_free_id(), which rolls over to two-letter ids instead of raising. - New TargetType.STRIKE_REQUEST: the bearing/distance-offset "taking fire" fire-support request (see the earlier two-entity split) now creates this instead of reusing STRIKE, so a radioed-in request is never confused with a strike the player placed themselves. Same crosshair icon, excluded from type pickers/dedupe like STRIKE. - The "Accept as..." popover on a detected map marker now uses the same icon grid the entity-edit "Change type" popover does (was a plain unfiltered text list of every TargetType, which also wrongly offered STRIKE/STRIKE_REQUEST as pickable). - Firing panel: _cycle_assignment/_toggle_alive/_pick_shell no longer route through app.py's full solver+dedupe+canvas+panel refresh -- none of the three can affect the solver or dedupe, and none change which cards exist or their order (except _toggle_alive in hide/sort_later mode). New FiringPanel._rebuild_one() rebuilds just the one changed card; on_visual_change is a new, lighter callback (just a map redraw) for the two of these three that actually affect it. This was a real, confirmed lag source with many units on the board: every click on any of these was previously rebuilding every card of every target. - Map right-click entity menu: added "Mark destroyed"/"Mark alive", reusing the same cheap-refresh path (new FiringPanel.refresh_after_alive_change). - packaging/windows/: a from-scratch (untested against a real boot) MSYS2 + WiX .msi build pipeline for Windows, driven from Linux via dockur/windows (KVM-in-container), no Windows machine or GitHub required. See its own README for status/caveats. - New/updated tests: id-namespace sharing + the 26-entity overflow regression (tests/test_models.py), StrikeRequest split (tests/test_ocr.py), warp_to_map's img_scale param (tests/test_map_vision_warp.py). 44/44 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
327 lines
13 KiB
Python
327 lines
13 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 -- two different places, so this becomes two
|
|
entries: Infantry#3 stays at its own reported position (no shell/
|
|
deadline, it's not the fire point), 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.targets
|
|
raw, clues, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "3")]
|
|
assert coord == Coord("J", 6, 2, 5)
|
|
assert shell is None
|
|
assert requested_time is None
|
|
|
|
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.targets
|
|
_, _, coord, shell, requested_time = info.targets[(TargetType.INFANTRY, "11")]
|
|
assert coord == Coord("I", 7, 0, 8)
|
|
assert shell is None
|
|
assert requested_time is None
|
|
|
|
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"
|