fix(ui): advance turn glow/interactivity to the player to move
`active` was `$derived(gameStore.activeBoards)`, and that getter read the non-`$state` `#game`. The $derived captured no reactive dependency, so the glowing/interactive boards froze on North's NW+NE after move 1: the next player couldn't act, clicks on their boards did nothing, and selections fired on unexpected boards — the reported "turns", "what piece is selected", and "mirrored movements" symptoms were all this one stale-derived bug. Fix: derive `active` from the reactive `view.currentPlayer`; remove the footgun `activeBoards` getter so nothing re-reads `#game` reactively. Add scripts/smoke.py: Playwright end-to-end test driving the real app through a full N->S->E->W round (engine vitest can't see Svelte reactivity wiring). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,3 +29,6 @@ dist-ssr
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
GITEA_API.md
|
GITEA_API.md
|
||||||
|
|
||||||
|
# Backups (global safety rule keeps these locally; never commit them)
|
||||||
|
.backup/
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -15,7 +15,10 @@
|
|||||||
const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
|
const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
|
||||||
|
|
||||||
let view = $derived(gameStore.view);
|
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. */
|
/** Ghost squares for a given board. */
|
||||||
function ghostsFor(id: BoardId): Square[] {
|
function ghostsFor(id: BoardId): Square[] {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '.
|
|||||||
import { ghosts } from '../../engine/ghosts';
|
import { ghosts } from '../../engine/ghosts';
|
||||||
import { evaluateStatus } from '../../engine/endgame';
|
import { evaluateStatus } from '../../engine/endgame';
|
||||||
import { serialize, deserialize } from '../../engine/notation';
|
import { serialize, deserialize } from '../../engine/notation';
|
||||||
import { PLAYER_BOARDS } from '../../engine/boards';
|
|
||||||
import type {
|
import type {
|
||||||
BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus,
|
BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus,
|
||||||
} from '../../engine/types';
|
} from '../../engine/types';
|
||||||
@@ -54,11 +53,6 @@ class GameStore {
|
|||||||
return this.scrubPly !== null;
|
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. */
|
/** Grab a piece: must be the current player's turn and a live (non-scrub) view. */
|
||||||
select(square: Square): void {
|
select(square: Square): void {
|
||||||
if (this.isScrubbing) return;
|
if (this.isScrubbing) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user