"""Locks the Gradio output-arity contract: every handler must emit exactly len(app.outputs) values, in lockstep, and never put a gr.update() in the game_state slot (which would corrupt gr.State). This is the recurring bug class on this app (15 -> 17 -> 18 migration), so it gets a dedicated, exhaustive test: init (both branches), do_step (all 3 yields incl. the None branch), do_tempt, do_move, do_reset, and do_watch (both branches). Run: TTW_DUMMY=1 python -m pytest tests/test_app_arity.py -q """ import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # The app must build with no GPU/Modal. Set before importing app (import builds # the council strip and the Blocks graph, neither of which launches a server). os.environ["TTW_DUMMY"] = "1" import app # noqa: E402 from ttw import moves # noqa: E402 from ttw.replay import FRAME_KEYS, record_frame # noqa: E402 N = len(app.outputs) def _assert_frame(out): """A handler frame is the right width and never corrupts the state slot.""" assert len(out) == N, f"expected {N} outputs, got {len(out)}" # Slot 0 is gr.State; it must carry the real value (a Game or None), never a # gr.update() payload (a dict), which would silently corrupt the state. assert not isinstance(out[0], dict), "game_state slot must never be gr.update()" def test_outputs_is_eighteen(): assert N == 18 def test_frame_to_views_plus_tail_equals_outputs(): # 15 frame views + game_state(1) + tail(2) == outputs. Locks the relationship # so widening the frame or the tail without the other is caught. g = app.new_game() frame = record_frame(g) assert app.frame_to_views(frame).__len__() + 1 + 2 == N def test_exposure_view_in_views_width_and_frame_keys(): # The exposure meter is the 15th frame view and the 15th FRAME_KEY; both must # carry it so _views, the recorded frame, and frame_to_views stay in lockstep. g = app.new_game() assert len(app._views(g)) == 15 assert len(FRAME_KEYS) == 15 assert FRAME_KEYS[-1] == "exposure" def test_init_live_branch_arity(monkeypatch): monkeypatch.setattr(app, "ATTRACT_FRAMES", []) _assert_frame(app.init()) def test_init_replay_branch_arity(monkeypatch): frame = record_frame(app.new_game()) monkeypatch.setattr(app, "ATTRACT_FRAMES", [frame]) _assert_frame(app.init()) def test_do_step_all_yields_arity(): g = app.new_game() frames = list(app.do_step(g)) assert len(frames) == 2 # thinking frame, then result for f in frames: _assert_frame(f) def test_do_step_none_branch_arity(): frames = list(app.do_step(None)) assert len(frames) == 1 _assert_frame(frames[0]) assert frames[0][0] is None # state slot is the real (None) value def test_do_tempt_arity(): _assert_frame(app.do_tempt(app.new_game())) def test_do_move_arity(): g = app.new_game() out = app.do_move(g, "lend", "Pip", "berries", 50, "", "Mossback", "false") _assert_frame(out) def test_do_reset_arity(): _assert_frame(app.do_reset()) def test_do_watch_replay_arity(monkeypatch): # Two synthetic frames; patch out the inter-frame sleep so the test is fast. frames = [record_frame(app.new_game()), record_frame(app.new_game())] monkeypatch.setattr(app, "ATTRACT_FRAMES", frames) monkeypatch.setattr(app.time, "sleep", lambda *a, **k: None) yielded = list(app.do_watch(app.new_game())) assert len(yielded) == len(frames) for f in yielded: _assert_frame(f) assert f[-2] == app.PILL_REPLAY # mode pill reads REPLAY during the saga def test_do_watch_empty_frames_falls_back_live(monkeypatch): monkeypatch.setattr(app, "ATTRACT_FRAMES", []) yielded = list(app.do_watch(app.new_game())) assert len(yielded) == 1 _assert_frame(yielded[0]) assert yielded[0][-2] == app.PILL_LIVE # the fallback is a live frame def test_do_watch_empty_frames_and_none_game_yields_nothing(monkeypatch): monkeypatch.setattr(app, "ATTRACT_FRAMES", []) assert list(app.do_watch(None)) == [] def test_gr_update_count_in_none_branch(): # The None branch must fill all 17 view slots with gr.update() and keep the # state slot real, so the count of dict payloads is exactly 17 (N - 1). out = list(app.do_step(None))[0] dicts = sum(1 for x in out if isinstance(x, dict)) assert dicts == N - 1 # every slot but game_state # --- contextual console: do_move via selector, preset, visibility matrix ------- def test_do_move_arity_for_each_move_kind(): # do_move now reads `kind` from the selector (move_inputs index 1); confirm # the 18-tuple holds for every move kind, not just one. for kind in moves.MOVE_KINDS: g = app.new_game() out = app.do_move(g, kind, "Pip", "berries", 50, "a crash is coming", "Mossback", "true") _assert_frame(out) def test_do_preset_arity_and_state(): # do_preset is a sibling of do_tempt/do_move: None is unreachable (demo.load # seeds the game), so like them it does not guard a None game. g = app.new_game() out = app.do_preset(g) _assert_frame(out) assert out[0] is g # state slot carries the real game def test_do_preset_arms_short_and_true_tip(): g = app.new_game() app.do_preset(g) queued = [m["kind"] for m in g.world.pending_moves] assert "short" in queued and "tip" in queued # The tip is TRUE and on the same good as the short (so the gambit pays). tip = next(m for m in g.world.pending_moves if m["kind"] == "tip") short = next(m for m in g.world.pending_moves if m["kind"] == "short") assert tip["truth"] is True assert tip["good"] == short["good"] def test_move_fields_cover_all_moves_and_valid_names(): # The visibility matrix must cover exactly the six engine moves, and every # field name must be a real console field (no typos -> silent mis-toggle). assert set(app.MOVE_FIELDS.keys()) == set(moves.MOVE_KINDS) valid = set(app.VIS_ORDER) for fields in app.MOVE_FIELDS.values(): assert fields <= valid def test_on_move_kind_returns_one_update_per_field(): for kind in moves.MOVE_KINDS: updates = app.on_move_kind(kind) assert len(updates) == len(app.VIS_ORDER) assert all(isinstance(u, dict) for u in updates) # Short reveals only the good field. short_vis = dict(zip(app.VIS_ORDER, app.on_move_kind("short"))) assert short_vis["good"]["visible"] is True assert short_vis["target"]["visible"] is False