FEnigma/tests/test_map_vision_warp.py
Dominik Roth 23615a8c92 Fix id-namespace regression, add StrikeRequest type, kill full-panel
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>
2026-08-11 20:48:57 +02:00

60 lines
2.5 KiB
Python

"""warp_to_map's img_scale param: a caller can hand it a differently-sized
image than the one `sol` was actually solved against (see
map_vision.load_full_res / ScreenshotImport.full_image), scaled to
compensate. This checks that compensation is correct, without needing a
real fixture screenshot or the (slow) line-detection/solve pipeline --
just a synthetic image and a stub solution with a predictable transform.
"""
import numpy as np
import pytest
from fenigma import map_vision
class _IdentitySolution:
"""H and lattice_to_grid() both identity: warp_to_map's transform then
reduces to just grid_to_map, so the output is a directly px_per_km-
scaled (and row-flipped, per warp_to_map's own comment) copy of
whatever region of the input `warp_to_map` reads as "grid space"."""
H = np.eye(3)
def lattice_to_grid(self):
return np.eye(3)
def test_img_scale_compensates_for_a_bigger_source_image():
# A small solid-color source, plus a 2x upscaled copy of it -- same
# content, different pixel dimensions.
small = np.zeros((20, 20, 3), dtype=np.uint8)
small[:, :] = (10, 20, 30) # BGR
big = np.zeros((40, 40, 3), dtype=np.uint8)
big[:, :] = (10, 20, 30)
sol = _IdentitySolution()
out_small, ppk_small = map_vision.warp_to_map(small, sol, px_per_km=1)
out_big, ppk_big = map_vision.warp_to_map(big, sol, px_per_km=1, img_scale=2.0)
assert ppk_small == ppk_big == 1
assert out_small.shape == out_big.shape # output is always MAP_KM_W/H * px_per_km, regardless of source size
# Same solid color warped in (opaque region only -- compare where both
# actually painted something, alpha channel nonzero).
painted = (out_small[:, :, 3] > 0) & (out_big[:, :, 3] > 0)
assert painted.any()
np.testing.assert_array_equal(out_small[painted][:, :3], out_big[painted][:, :3])
def test_default_img_scale_is_unchanged_behavior():
"""img_scale's default (1.0) must reproduce pre-existing behavior
exactly -- every other warp_to_map call site doesn't pass it."""
img = np.zeros((20, 20, 3), dtype=np.uint8)
img[:, :] = (1, 2, 3)
sol = _IdentitySolution()
out_default, _ = map_vision.warp_to_map(img, sol, px_per_km=1)
out_explicit, _ = map_vision.warp_to_map(img, sol, px_per_km=1, img_scale=1.0)
np.testing.assert_array_equal(out_default, out_explicit)
def test_load_full_res_raises_like_load_on_a_bad_path(tmp_path):
with pytest.raises(ValueError):
map_vision.load_full_res(tmp_path / "does-not-exist.png")