Version 2.0.0: Animation fixes, timing improvements, and E2E test suite
Animation fixes: - Fix held card positioning bug (was appearing at bottom of page) - Fix discard pile blank/white flash on turn transitions - Fix blank card at round end by skipping animations during round_over/game_over - Set card content before triggering flip animation to prevent flash - Center suit symbol on 10 cards Timing improvements: - Reduce post-discard delay from 700ms to 500ms - Reduce post-swap delay from 1800ms to 1000ms - Speed up swap flip animation from 1150ms to 550ms - Reduce CPU initial thinking delay from 150-250ms to 80-150ms - Pause now happens after swap completes (showing result) instead of before E2E test suite: - Add Playwright-based test bot that plays full games - State parser extracts game state from DOM for validation - AI brain ports decision logic for automated play - Freeze detector monitors for UI hangs - Visual validator checks CSS states - Full game, stress, and visual test specs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
tests/e2e/utils/index.ts
Normal file
12
tests/e2e/utils/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
TIMING,
|
||||
waitForAnimations,
|
||||
waitForWebSocket,
|
||||
safeWait,
|
||||
} from './timing';
|
||||
export {
|
||||
SELECTORS,
|
||||
playerCardSelector,
|
||||
clickableCardSelector,
|
||||
opponentCardSelector,
|
||||
} from './selectors';
|
||||
157
tests/e2e/utils/selectors.ts
Normal file
157
tests/e2e/utils/selectors.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* DOM selector constants for the Golf game
|
||||
* Extracted from client/index.html and client/app.js
|
||||
*/
|
||||
|
||||
export const SELECTORS = {
|
||||
// Screens
|
||||
screens: {
|
||||
lobby: '#lobby-screen',
|
||||
waiting: '#waiting-screen',
|
||||
game: '#game-screen',
|
||||
rules: '#rules-screen',
|
||||
},
|
||||
|
||||
// Lobby elements
|
||||
lobby: {
|
||||
playerNameInput: '#player-name',
|
||||
roomCodeInput: '#room-code',
|
||||
createRoomBtn: '#create-room-btn',
|
||||
joinRoomBtn: '#join-room-btn',
|
||||
error: '#lobby-error',
|
||||
},
|
||||
|
||||
// Waiting room elements
|
||||
waiting: {
|
||||
roomCode: '#display-room-code',
|
||||
copyCodeBtn: '#copy-room-code',
|
||||
shareBtn: '#share-room-link',
|
||||
playersList: '#players-list',
|
||||
hostSettings: '#host-settings',
|
||||
startGameBtn: '#start-game-btn',
|
||||
leaveRoomBtn: '#leave-room-btn',
|
||||
addCpuBtn: '#add-cpu-btn',
|
||||
removeCpuBtn: '#remove-cpu-btn',
|
||||
cpuModal: '#cpu-select-modal',
|
||||
cpuProfilesGrid: '#cpu-profiles-grid',
|
||||
cancelCpuBtn: '#cancel-cpu-btn',
|
||||
addSelectedCpusBtn: '#add-selected-cpus-btn',
|
||||
// Settings
|
||||
numDecks: '#num-decks',
|
||||
numRounds: '#num-rounds',
|
||||
initialFlips: '#initial-flips',
|
||||
flipMode: '#flip-mode',
|
||||
knockPenalty: '#knock-penalty',
|
||||
},
|
||||
|
||||
// Game screen elements
|
||||
game: {
|
||||
// Header
|
||||
currentRound: '#current-round',
|
||||
totalRounds: '#total-rounds',
|
||||
statusMessage: '#status-message',
|
||||
finalTurnBadge: '#final-turn-badge',
|
||||
muteBtn: '#mute-btn',
|
||||
leaveGameBtn: '#leave-game-btn',
|
||||
activeRulesBar: '#active-rules-bar',
|
||||
|
||||
// Table
|
||||
opponentsRow: '#opponents-row',
|
||||
playerArea: '.player-area',
|
||||
playerCards: '#player-cards',
|
||||
playerHeader: '#player-header',
|
||||
yourScore: '#your-score',
|
||||
|
||||
// Deck and discard
|
||||
deckArea: '.deck-area',
|
||||
deck: '#deck',
|
||||
discard: '#discard',
|
||||
discardContent: '#discard-content',
|
||||
discardBtn: '#discard-btn',
|
||||
skipFlipBtn: '#skip-flip-btn',
|
||||
knockEarlyBtn: '#knock-early-btn',
|
||||
|
||||
// Held card
|
||||
heldCardSlot: '#held-card-slot',
|
||||
heldCardDisplay: '#held-card-display',
|
||||
heldCardFloating: '#held-card-floating',
|
||||
heldCardFloatingContent: '#held-card-floating-content',
|
||||
|
||||
// Scoreboard
|
||||
scoreboard: '#scoreboard',
|
||||
scoreTable: '#score-table tbody',
|
||||
standingsList: '#standings-list',
|
||||
nextRoundBtn: '#next-round-btn',
|
||||
newGameBtn: '#new-game-btn',
|
||||
gameButtons: '#game-buttons',
|
||||
|
||||
// Card layer for animations
|
||||
cardLayer: '#card-layer',
|
||||
},
|
||||
|
||||
// Card-related selectors
|
||||
cards: {
|
||||
// Player's own cards (0-5)
|
||||
playerCard: (index: number) => `#player-cards .card:nth-child(${index + 1})`,
|
||||
playerCardSlot: (index: number) => `#player-cards .card-slot:nth-child(${index + 1})`,
|
||||
|
||||
// Opponent cards
|
||||
opponentArea: (index: number) => `.opponent-area:nth-child(${index + 1})`,
|
||||
opponentCard: (oppIndex: number, cardIndex: number) =>
|
||||
`.opponent-area:nth-child(${oppIndex + 1}) .card-grid .card:nth-child(${cardIndex + 1})`,
|
||||
|
||||
// Card states
|
||||
faceUp: '.card-front',
|
||||
faceDown: '.card-back',
|
||||
clickable: '.clickable',
|
||||
selected: '.selected',
|
||||
},
|
||||
|
||||
// CSS classes for state detection
|
||||
classes: {
|
||||
active: 'active',
|
||||
hidden: 'hidden',
|
||||
clickable: 'clickable',
|
||||
selected: 'selected',
|
||||
faceUp: 'card-front',
|
||||
faceDown: 'card-back',
|
||||
red: 'red',
|
||||
black: 'black',
|
||||
joker: 'joker',
|
||||
currentTurn: 'current-turn',
|
||||
roundWinner: 'round-winner',
|
||||
yourTurnToDraw: 'your-turn-to-draw',
|
||||
hasCard: 'has-card',
|
||||
pickedUp: 'picked-up',
|
||||
disabled: 'disabled',
|
||||
},
|
||||
|
||||
// Animation-related
|
||||
animations: {
|
||||
swapAnimation: '#swap-animation',
|
||||
swapCardFromHand: '#swap-card-from-hand',
|
||||
animCard: '.anim-card',
|
||||
realCard: '.real-card',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a selector for a card in the player's grid
|
||||
*/
|
||||
export function playerCardSelector(position: number): string {
|
||||
return SELECTORS.cards.playerCard(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a selector for a clickable card
|
||||
*/
|
||||
export function clickableCardSelector(position: number): string {
|
||||
return `${SELECTORS.cards.playerCard(position)}.${SELECTORS.classes.clickable}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a selector for an opponent's card
|
||||
*/
|
||||
export function opponentCardSelector(opponentIndex: number, cardPosition: number): string {
|
||||
return SELECTORS.cards.opponentCard(opponentIndex, cardPosition);
|
||||
}
|
||||
71
tests/e2e/utils/timing.ts
Normal file
71
tests/e2e/utils/timing.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Animation timing constants from animation-queue.js
|
||||
* Used to wait for animations to complete before asserting state
|
||||
*/
|
||||
export const TIMING = {
|
||||
// Core animation durations (from CSS/animation-queue.js)
|
||||
flipDuration: 540,
|
||||
moveDuration: 270,
|
||||
pauseAfterFlip: 144,
|
||||
pauseAfterDiscard: 550,
|
||||
pauseBeforeNewCard: 150,
|
||||
pauseAfterSwapComplete: 400,
|
||||
pauseBetweenAnimations: 90,
|
||||
|
||||
// Derived waits for test actions
|
||||
get flipComplete() {
|
||||
return this.flipDuration + this.pauseAfterFlip + 100;
|
||||
},
|
||||
get swapComplete() {
|
||||
return this.flipDuration + this.pauseAfterFlip + this.moveDuration +
|
||||
this.pauseAfterDiscard + this.pauseBeforeNewCard +
|
||||
this.moveDuration + this.pauseAfterSwapComplete + 200;
|
||||
},
|
||||
get drawComplete() {
|
||||
return this.moveDuration + this.pauseBeforeNewCard + 100;
|
||||
},
|
||||
|
||||
// Safety margins for network/processing
|
||||
networkBuffer: 200,
|
||||
safetyMargin: 300,
|
||||
|
||||
// Longer waits
|
||||
turnTransition: 500,
|
||||
cpuThinkingMin: 400,
|
||||
cpuThinkingMax: 1200,
|
||||
roundOverDelay: 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for animation queue to drain
|
||||
*/
|
||||
export async function waitForAnimations(page: import('@playwright/test').Page, timeout = 5000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const game = (window as any).game;
|
||||
if (!game?.animationQueue) return true;
|
||||
return !game.animationQueue.isAnimating();
|
||||
},
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for WebSocket to be ready
|
||||
*/
|
||||
export async function waitForWebSocket(page: import('@playwright/test').Page, timeout = 5000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const game = (window as any).game;
|
||||
return game?.ws?.readyState === WebSocket.OPEN;
|
||||
},
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait a fixed time plus safety margin
|
||||
*/
|
||||
export function safeWait(duration: number): number {
|
||||
return duration + TIMING.safetyMargin;
|
||||
}
|
||||
Reference in New Issue
Block a user