Files
m3te/tests/anim_plan.sx
swipelab 6f7d2f4db2 lang migration: rename signed integer types sN -> iN
Mechanical sweep of all .sx sources, plan docs, and tests/expected
snapshots for the sx language rename (s8/s16/s32/s64 -> i8/i16/i32/i64).
Verified: tools/run_tests.sh 23/23.

Note: the ios-sim build has 2 pre-existing 'restart' dot-call errors
from the sx opt-in UFCS change (sx a47ea14) — independent of this
rename (present pre-sweep); migrated in the follow-up commit.
2026-06-12 09:36:51 +03:00

158 lines
7.3 KiB
Plaintext

// Animation-layer determinism guard (P6.1): prove the swap/clear/fall animation
// timeline is PURELY VISUAL — it never changes the model's result. `plan_and_commit`
// commits the move on the real board (authoritative) AND records the visual
// timeline on a value-copy; this test asserts, on the SAME seed the app renders
// (SEED 1337):
// - the board `plan_and_commit` leaves is byte-for-byte identical to an
// independent `commit_swap` of the same move, with the same score + moves;
// - the recorded timeline ENDS on that exact state: `move.final` equals the
// model board, the rounds are contiguous (round 0 starts on the swapped board,
// each later round starts on the prior round's settled board), and the last
// round's `after` equals `final`;
// - an illegal swap records no rounds and leaves the board untouched.
// It also guards the P6.1 input-lock fix: `accepts_input` rejects a gesture for
// the FULL in-flight animation window (mouse_down → settle), so a swipe begun
// while a move animates never latches a drag and never commits — even if it is
// released after the timeline ends.
// No rendering — it calls exactly what BoardView.handle_event calls. Links headless
// like tests/swipe_commit.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.
#import "modules/std.sx";
#import "board.sx";
#import "board_anim.sx";
SEED :: 1337;
boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS (i) {
if !(x.cells[i] == y.cells[i]) { return false; }
}
true
}
main :: () -> i32 {
fails : i64 = 0;
// ── Legal swap: plan == model, timeline ends on the model ───────────────
// (5,4)->(6,4): brings R into (5,4), completing R,R,R across cols 3-5 of row
// 4 — the same legal swap tests/swipe_commit.sx commits.
print("== legal swap: plan matches model ==\n");
a := Cell.{ col = 5, row = 4 };
b := Cell.{ col = 6, row = 4 };
bm : Board = ---;
bm.init(SEED);
mvm := commit_swap(@bm, a, b);
ba : Board = ---;
ba.init(SEED);
move := plan_and_commit(@ba, a, b);
print("model: legal {} depth {} score {} moves {}\n",
mvm.legal, mvm.cascade.depth, bm.score, bm.moves_made);
print("plan: legal {} rounds {} score {} moves {}\n",
move.legal, move.rounds.len, ba.score, ba.moves_made);
if !move.legal { fails += 1; }
if !boards_equal(@ba, @bm) { fails += 1; } // committed board == model
if ba.score != bm.score { fails += 1; }
if ba.moves_made != bm.moves_made { fails += 1; }
if move.rounds.len != mvm.cascade.depth { fails += 1; }
// move.final equals the model board.
final_eq := true;
for 0..BOARD_CELLS (i) {
if !(move.final[i] == bm.cells[i]) { final_eq = false; }
}
if !final_eq { fails += 1; }
print("final==model {}\n", final_eq);
// Timeline contiguity: round 0 starts on the swapped pre board; each later
// round starts on the previous round's settled board; final == last after.
contiguous := true;
if move.rounds.len > 0 {
ai := Board.idx(a.col, a.row);
bi := Board.idx(b.col, b.row);
r0 := @move.rounds.items[0];
for 0..BOARD_CELLS (i) {
expect : Gem = move.pre[i];
if i == ai { expect = move.pre[bi]; }
else if i == bi { expect = move.pre[ai]; }
if !(r0.before[i] == expect) { contiguous = false; }
}
for 1..move.rounds.len (k) {
prev := @move.rounds.items[k - 1];
cur := @move.rounds.items[k];
for 0..BOARD_CELLS (i) {
if !(cur.before[i] == prev.after[i]) { contiguous = false; }
}
}
last := @move.rounds.items[move.rounds.len - 1];
for 0..BOARD_CELLS (i) {
if !(last.after[i] == move.final[i]) { contiguous = false; }
}
}
if !contiguous { fails += 1; }
print("contiguous {}\n", contiguous);
out("final board:\n");
out(board_dump(@bm));
// ── Illegal swap: no timeline, board untouched ──────────────────────────
// (0,0)->(1,0): two reds → no match. plan_and_commit must leave the board
// exactly as it was, spend no move, and record zero rounds.
print("== illegal swap: untouched ==\n");
bi2 : Board = ---;
bi2.init(SEED);
pre2 : Board = bi2;
mi := plan_and_commit(@bi2, Cell.{ col = 0, row = 0 }, Cell.{ col = 1, row = 0 });
print("legal {} rounds {} score {} moves {}\n", mi.legal, mi.rounds.len, bi2.score, bi2.moves_made);
if mi.legal { fails += 1; }
if mi.rounds.len != 0 { fails += 1; }
if !boards_equal(@pre2, @bi2) { fails += 1; }
if bi2.score != 0 { fails += 1; }
if bi2.moves_made != 0 { fails += 1; }
// ── Input gate: locked for the FULL in-flight animation ─────────────────
// The view begins a drag on mouse_down only when `accepts_input` is true,
// and commits on mouse_up only if a drag latched. So a gesture that BEGINS
// while a move animates must NEVER commit — even if it is released after the
// animation has fully settled. This guards the P6.1 input-lock fix.
print("== input gate: locked while animating ==\n");
gate : BoardAnim = ---;
gate.init();
idle_ok := accepts_input(@gate); // no move in flight → accept
gate.begin(move); // a legal move's timeline starts
busy_ok := accepts_input(@gate); // mouse_down DURING animation → reject
while gate.active { gate.tick(0.05); } // player holds; timeline plays out
settled_ok := accepts_input(@gate); // animation fully settled → accept
print("accepts idle {} busy {} settled {}\n", idle_ok, busy_ok, settled_ok);
if !idle_ok { fails += 1; }
if busy_ok { fails += 1; } // MUST be locked while animating
if !settled_ok { fails += 1; }
// The board MUST decide a gesture at PRESS, not at RELEASE. Over the exact
// failure scenario — a gesture PRESSED while animating and RELEASED after the
// timeline has settled — the two policies diverge:
// release-gate: commit unless animating AT RELEASE → COMMITS (the timeline
// finished first), letting input slip through mid-transition.
// press-gate: latch only if input accepted AT PRESS → DROPS, because
// input was locked for the whole window the timeline ran.
gate.init();
gate.begin(move);
accept_at_press := accepts_input(@gate); // mouse_down while animating
while gate.active { gate.tick(0.05); }
accept_at_release := accepts_input(@gate); // mouse_up after settle
release_gate_commits := accept_at_release;
press_gate_commits := accept_at_press;
print("release_gate_commits {} press_gate_commits {}\n", release_gate_commits, press_gate_commits);
if !release_gate_commits { fails += 1; } // the scenario release-gating lets through
if press_gate_commits { fails += 1; } // the board press-gates: MUST NOT commit
if fails == 0 {
print("ok: animation layer leaves the model result unchanged\n");
return 0;
}
print("FAIL: {} anim-determinism checks failed\n", fails);
return 1;
}