- 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>
101 lines
3.7 KiB
Python
101 lines
3.7 KiB
Python
"""debug_capture just needs to reliably write what it's given and never
|
|
raise into caller code -- these are format/plumbing checks, not vision
|
|
tests."""
|
|
import json
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from fenigma import debug_capture
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_debug_dir(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
|
return tmp_path
|
|
|
|
|
|
def _tiny_png_bytes() -> bytes:
|
|
import io
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (4, 4), (10, 20, 30)).save(buf, format="PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def test_save_map_read_failure_writes_png_and_reason(_isolated_debug_dir):
|
|
path = debug_capture.save_map_read_failure(_tiny_png_bytes(), "too few grid line families")
|
|
assert path is not None
|
|
assert path.exists()
|
|
meta = json.loads(path.with_suffix(".json").read_text())
|
|
assert meta["reason"] == "too few grid line families"
|
|
|
|
|
|
def test_save_maybe_map_accepts_numpy_bgr_array(_isolated_debug_dir):
|
|
bgr = np.zeros((4, 4, 3), dtype=np.uint8)
|
|
bgr[..., 0] = 200 # blue channel, would come out red if BGR/RGB got swapped
|
|
path = debug_capture.save_maybe_map(bgr)
|
|
assert path is not None
|
|
saved = Image.open(path)
|
|
assert saved.getpixel((0, 0)) == (0, 0, 200) # still blue, not swapped to red
|
|
|
|
|
|
def test_save_grid_correction_writes_both_solutions(_isolated_debug_dir):
|
|
class FakeSolution:
|
|
def __init__(self, du):
|
|
self.H = np.eye(3)
|
|
self.si, self.sj, self.du, self.dv = 1, 1, du, 0
|
|
|
|
path = debug_capture.save_grid_correction(_tiny_png_bytes(), FakeSolution(0.0), FakeSolution(0.3))
|
|
assert path is not None
|
|
meta = json.loads(path.with_suffix(".json").read_text())
|
|
assert meta["original"]["du"] == 0.0
|
|
assert meta["corrected"]["du"] == 0.3
|
|
|
|
|
|
def test_unsupported_image_type_returns_none_without_raising(_isolated_debug_dir):
|
|
assert debug_capture.save_maybe_map(object()) is None
|
|
|
|
|
|
def _proposal(**overrides):
|
|
from fenigma.map_import import Proposal
|
|
defaults = dict(side="hostile", label="K8", sub_x=3, sub_y=4, unit="Armor_Tank.png",
|
|
centre=(0, 0), box=(0, 0, 0, 0))
|
|
defaults.update(overrides)
|
|
return Proposal(**defaults)
|
|
|
|
|
|
def test_marker_ground_truth_records_verdict_per_proposal(_isolated_debug_dir):
|
|
accepted = _proposal(accepted=True, confirmed_type="TANK")
|
|
rejected = _proposal(label="K9", rejected=True)
|
|
undecided = _proposal(label="L1")
|
|
|
|
path = debug_capture.save_marker_ground_truth(_tiny_png_bytes(), [accepted, rejected, undecided])
|
|
assert path is not None
|
|
meta = json.loads(path.with_suffix(".json").read_text())
|
|
by_label = {p["label"]: p for p in meta["proposals"]}
|
|
assert by_label["K8"]["verdict"] == "accepted"
|
|
assert by_label["K8"]["confirmed_type"] == "TANK"
|
|
assert by_label["K9"]["verdict"] == "rejected"
|
|
assert by_label["L1"]["verdict"] == "undecided"
|
|
|
|
|
|
def test_marker_ground_truth_records_units_with_no_matching_proposal(_isolated_debug_dir):
|
|
from fenigma.models import Board, Coord, TargetType
|
|
|
|
board = Board()
|
|
manual_target = board.add_target(TargetType.TANK, Coord("K", 8, 3, 4))
|
|
manual_ally = board.add_ally(TargetType.INFANTRY, Coord("K", 9, 0, 0))
|
|
|
|
path = debug_capture.save_marker_ground_truth(
|
|
_tiny_png_bytes(), [], added_targets=[manual_target], added_allies=[manual_ally])
|
|
assert path is not None
|
|
meta = json.loads(path.with_suffix(".json").read_text())
|
|
kinds = {(u["kind"], u["type"], u["coord"]) for u in meta["added_units"]}
|
|
assert ("target", "TANK", "K8 3:4") in kinds
|
|
assert ("ally", "INFANTRY", "K9 0:0") in kinds
|
|
|
|
|
|
def test_marker_ground_truth_skips_when_nothing_to_say(_isolated_debug_dir):
|
|
assert debug_capture.save_marker_ground_truth(_tiny_png_bytes(), []) is None
|