// UI deadlock-recovery golden (final-review F1): prove the RENDERED swipe-commit // path reshuffles a deadlocked board, just like the headless turn loop's no-moves // rule (tests/level.sx). The iOS/macOS view commits a swipe through // `plan_and_commit` (NOT `play_turn`), so the reshuffle must live on THAT shared // path or a stuck board stays stuck on screen. This test drives exactly what // BoardView.handle_event does — resolve a drag with `swipe_intent`, feed the intent // into `plan_and_commit` — on the provably deadlocked diagonal-Latin-square board // from tests/level.sx, asserting: // - BEFORE: no immediate match, zero legal swaps, status in_progress (stuck); // - AFTER the UI commit path resolves: the board RESHUFFLED — `has_legal_swap` // is true and >=1 legal swap exists, still with no immediate match — and the // reshuffle itself spent no move and no score (turn accounting unchanged). // Pre-fix `plan_and_commit` skipped the reshuffle, so `has_legal_swap` stayed false // after and this FAILS; with the shared `reshuffle_if_deadlocked` wired in it PASSES. // No rendering, no model reach-around. Links headless like tests/anim_plan.sx; // avoids tests/test.sx (its trace.sx pulls in a second `Frame` that collides with // the UI one). Failure is a non-zero exit code (the runner checks exit + stdout). #import "modules/std.sx"; #import "modules/ui/types.sx"; #import "board.sx"; #import "board_anim.sx"; #import "board_layout.sx"; #import "swipe.sx"; SEED :: 1337; // Inverse of `gem_char`: map a board character back to its Gem so the deadlocked // board can be written as a human-readable grid (mirrors tests/level.sx). char_to_gem :: (c: u8) -> Gem { if c == EMPTY_CHAR { return .empty; } for 0..GEM_COUNT (i) { if GEM_CHARS[i] == c { return cast(Gem) i; } } .red } // Load an 8x8 board from `rows` (top row first), seeded RNG, score zeroed, turn // counters reset to a fresh game, and the per-level goal set. load_board :: (rows: []string, seed: i64, move_limit: i64, target_score: i64) -> Board { b : Board = ---; for 0..BOARD_ROWS (row) { line := rows[row]; for 0..BOARD_COLS (col) { b.set(col, row, char_to_gem(line[col])); } } b.rng = rng_seeded(seed); b.score = 0; b.moves_made = 0; b.move_limit = move_limit; b.target_score = target_score; b } cell_center :: (lay: *BoardLayout, col: i64, row: i64) -> Point { cf := lay.cell_frame(col, row); Point.{ x = cf.mid_x(), y = cf.mid_y() } } main :: () -> i32 { fails : i64 = 0; // 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A 60px // drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis — the // SAME layout/threshold tests/swipe_commit.sx drives the UI path through. lay : BoardLayout = ---; lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero()); D : f32 = 60.0; // The provably deadlocked board from tests/level.sx: gem(col,row)=(col-row) mod // 6, a diagonal Latin square. Equal gems lie only on slope-1 diagonals, so no // row/column holds two of a kind within reach — no immediate match and no // orthogonal swap can line up three. print("== deadlocked board: UI swipe-commit path must reshuffle ==\n"); b := load_board(.[ "ROYGBPRO", "PROYGBPR", "BPROYGBP", "GBPROYGB", "YGBPROYG", "OYGBPROY", "ROYGBPRO", "PROYGBPR", ], SEED, 10, 1500); out(board_dump(@b)); pre_m := find_matches(@b); pre_sw := legal_swaps(@b); print("before: matches {} legal_swaps {} has_legal_swap {} status {}\n", pre_m.count(), pre_sw.len, has_legal_swap(@b), status_name(level_status(@b))); if pre_m.count() != 0 { fails += 1; } if pre_sw.len != 0 { fails += 1; } if has_legal_swap(@b) { fails += 1; } if level_status(@b) != .in_progress { fails += 1; } score_before := b.score; made_before := b.moves_made; remain_before := b.moves_remaining(); // Drive the EXACT UI path: a rightward drag on (0,0) resolves to the (0,0)->(1,0) // swap intent, which BoardView.handle_event feeds straight into plan_and_commit. // On this deadlocked board every swap is illegal (no match), so the swipe itself // commits nothing — the recovery must come from the post-settle reshuffle. a0 := cell_center(@lay, 0, 0); if s := swipe_intent(@lay, a0, Point.{ x = a0.x + D, y = a0.y }) { print("intent ({},{})->({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row); if !(s.a.col == 0 and s.a.row == 0 and s.b.col == 1 and s.b.row == 0) { fails += 1; } mv := plan_and_commit(@b, s.a, s.b); print("commit: legal {} rounds {} awarded {}\n", mv.legal, mv.rounds.len, mv.awarded); // The illegal swipe spends nothing; its timeline is the bare ping-back. if mv.legal { fails += 1; } if mv.rounds.len != 0 { fails += 1; } } else { print("intent none\n"); fails += 1; } post_m := find_matches(@b); post_sw := legal_swaps(@b); print("after: matches {} legal_swaps {} has_legal_swap {} status {}\n", post_m.count(), post_sw.len, has_legal_swap(@b), status_name(level_status(@b))); print("after: score {} moves_made {} moves_remaining {}\n", b.score, b.moves_made, b.moves_remaining()); out(board_dump(@b)); // The fix: the UI commit path reshuffled the deadlock away. has_legal_swap flips // false -> true; >=1 legal swap exists; still no immediate match. if !has_legal_swap(@b) { fails += 1; } if post_sw.len <= 0 { fails += 1; } if post_m.count() != 0 { fails += 1; } // The reshuffle is NOT a move: score, moves spent, and budget are untouched. if b.score != score_before { fails += 1; } if b.moves_made != made_before { fails += 1; } if b.moves_remaining() != remain_before { fails += 1; } if level_status(@b) != .in_progress { fails += 1; } if fails == 0 { print("ok: UI swipe-commit path reshuffles a deadlocked board\n"); return 0; } print("FAIL: {} UI-reshuffle checks failed\n", fails); return 1; }