diff --git a/.gitignore b/.gitignore index ee6eb2d..9ea2e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ dist-ssr .superpowers/ *.tsbuildinfo GITEA_API.md + +# Backups (global safety rule keeps these locally; never commit them) +.backup/ diff --git a/scripts/smoke.py b/scripts/smoke.py new file mode 100644 index 0000000..cf99915 --- /dev/null +++ b/scripts/smoke.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""End-to-end smoke test driving the real app in a browser. + +Engine vitest covers move logic; this covers the Svelte reactivity wiring that +vitest can't see — specifically the turn glow / interactivity advancing player +to player (the bug where `active` froze on North after move 1). + +Boots `vite` itself, drives chromium via Playwright, asserts a full N->S->E->W +round plays and each player's two boards light up in turn. Self-contained: + + python3 scripts/smoke.py + +Needs system chromium + the `playwright` python package. Exits non-zero on failure. +""" +import subprocess, sys, time, urllib.request, os, signal +from playwright.sync_api import sync_playwright + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +URL = "http://localhost:5177/duplicate/" +BID = ["NW", "NE", "SW", "SE"] +# (player, board to act on, from-square, to-square) — one legal opening per player. +ROUND = [("N", "NW", "e2", "e4"), ("S", "SW", "d2", "d4"), + ("E", "NE", "e7", "e5"), ("W", "SW", "d7", "d5")] +EXPECT_ACTIVE = {"N": {"NW", "NE"}, "S": {"SW", "SE"}, + "E": {"NE", "SE"}, "W": {"NW", "SW"}} + + +def wait_server(timeout=30): + for _ in range(timeout * 2): + try: + urllib.request.urlopen(URL, timeout=1); return True + except Exception: + time.sleep(0.5) + return False + + +def active_boards(pg): + return {BID[i] for i, bd in enumerate(pg.query_selector_all(".board")) + if "active" in (bd.get_attribute("class") or "")} + + +def piece_at(pg, board, sq): + bi = BID.index(board) + span = pg.locator(".board").nth(bi).locator(f'button[aria-label="{sq}"] span.pc') + return span.inner_text() if span.count() else None + + +def click(pg, board, sq): + pg.locator(".board").nth(BID.index(board)).locator(f'button[aria-label="{sq}"]').click() + pg.wait_for_timeout(80) + + +def main(): + vite = subprocess.Popen(["pnpm", "dev", "--port", "5177"], cwd=ROOT, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=os.setsid) + try: + assert wait_server(), "vite did not come up" + with sync_playwright() as p: + b = p.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"]) + pg = b.new_page(viewport={"width": 1400, "height": 1000}) + pg.goto(URL, wait_until="networkidle"); pg.wait_for_timeout(300) + + for player, board, frm, to in ROUND: + got = active_boards(pg) + assert got == EXPECT_ACTIVE[player], \ + f"{player}'s turn: expected active {EXPECT_ACTIVE[player]}, got {got}" + other = next(x for x in EXPECT_ACTIVE[player] if x != board) + click(pg, board, frm) + click(pg, board, to) + # move must apply IDENTICALLY on both of the player's boards + for bd in (board, other): + assert piece_at(pg, bd, frm) is None, f"{player} {bd} {frm} not vacated" + assert piece_at(pg, bd, to) is not None, f"{player} {bd} {to} empty after move" + print(f"OK {player}: {frm}{to} applied on {board}+{other}, turn advanced") + + assert active_boards(pg) == EXPECT_ACTIVE["N"], "did not cycle back to North" + print("OK full N->S->E->W round cycled back to North") + b.close() + print("\nSMOKE PASS") + finally: + os.killpg(os.getpgid(vite.pid), signal.SIGTERM) + + +if __name__ == "__main__": + try: + main() + except AssertionError as e: + print(f"\nSMOKE FAIL: {e}"); sys.exit(1) diff --git a/src/lib/Compass.svelte b/src/lib/Compass.svelte index cd4c50c..ae3a44e 100644 --- a/src/lib/Compass.svelte +++ b/src/lib/Compass.svelte @@ -15,7 +15,10 @@ const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE']; let view = $derived(gameStore.view); - let active = $derived(gameStore.activeBoards); + // Derive from the reactive `view`, NOT the store's #game getter: #game is + // deliberately not a $state proxy, so a $derived reading it never recomputes + // and the turn glow/interactivity froze on North after move 1. + let active = $derived(PLAYER_BOARDS[view.currentPlayer]); /** Ghost squares for a given board. */ function ghostsFor(id: BoardId): Square[] { diff --git a/src/lib/stores/game.svelte.ts b/src/lib/stores/game.svelte.ts index f9d6f7c..fb9d9a1 100644 --- a/src/lib/stores/game.svelte.ts +++ b/src/lib/stores/game.svelte.ts @@ -3,7 +3,6 @@ import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '. import { ghosts } from '../../engine/ghosts'; import { evaluateStatus } from '../../engine/endgame'; import { serialize, deserialize } from '../../engine/notation'; -import { PLAYER_BOARDS } from '../../engine/boards'; import type { BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus, } from '../../engine/types'; @@ -54,11 +53,6 @@ class GameStore { return this.scrubPly !== null; } - /** Which boards belong to the player to move (for the turn glow). */ - get activeBoards(): [BoardId, BoardId] { - return PLAYER_BOARDS[this.#game.currentPlayer]; - } - /** Grab a piece: must be the current player's turn and a live (non-scrub) view. */ select(square: Square): void { if (this.isScrubbing) return;