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>
232 lines
5.1 KiB
TypeScript
232 lines
5.1 KiB
TypeScript
/**
|
|
* Animation tracker - monitors animation completion and timing
|
|
*/
|
|
|
|
import { Page } from '@playwright/test';
|
|
import { TIMING } from '../utils/timing';
|
|
|
|
/**
|
|
* Animation event
|
|
*/
|
|
export interface AnimationEvent {
|
|
type: 'start' | 'complete' | 'stall';
|
|
animationType?: string;
|
|
duration?: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* AnimationTracker - tracks animation states
|
|
*/
|
|
export class AnimationTracker {
|
|
private events: AnimationEvent[] = [];
|
|
private animationStartTime: number | null = null;
|
|
|
|
constructor(private page: Page) {}
|
|
|
|
/**
|
|
* Record animation start
|
|
*/
|
|
recordStart(type?: string): void {
|
|
this.animationStartTime = Date.now();
|
|
this.events.push({
|
|
type: 'start',
|
|
animationType: type,
|
|
timestamp: this.animationStartTime,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Record animation complete
|
|
*/
|
|
recordComplete(type?: string): void {
|
|
const now = Date.now();
|
|
const duration = this.animationStartTime
|
|
? now - this.animationStartTime
|
|
: undefined;
|
|
|
|
this.events.push({
|
|
type: 'complete',
|
|
animationType: type,
|
|
duration,
|
|
timestamp: now,
|
|
});
|
|
|
|
this.animationStartTime = null;
|
|
}
|
|
|
|
/**
|
|
* Record animation stall
|
|
*/
|
|
recordStall(type?: string): void {
|
|
const now = Date.now();
|
|
const duration = this.animationStartTime
|
|
? now - this.animationStartTime
|
|
: undefined;
|
|
|
|
this.events.push({
|
|
type: 'stall',
|
|
animationType: type,
|
|
duration,
|
|
timestamp: now,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if animation queue is animating
|
|
*/
|
|
async isAnimating(): Promise<boolean> {
|
|
try {
|
|
return await this.page.evaluate(() => {
|
|
const game = (window as any).game;
|
|
return game?.animationQueue?.isAnimating() ?? false;
|
|
});
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get animation queue length
|
|
*/
|
|
async getQueueLength(): Promise<number> {
|
|
try {
|
|
return await this.page.evaluate(() => {
|
|
const game = (window as any).game;
|
|
return game?.animationQueue?.queue?.length ?? 0;
|
|
});
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for animation to complete with tracking
|
|
*/
|
|
async waitForAnimation(
|
|
type: string,
|
|
timeoutMs: number = 5000
|
|
): Promise<{ completed: boolean; duration: number }> {
|
|
this.recordStart(type);
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
await this.page.waitForFunction(
|
|
() => {
|
|
const game = (window as any).game;
|
|
if (!game?.animationQueue) return true;
|
|
return !game.animationQueue.isAnimating();
|
|
},
|
|
{ timeout: timeoutMs }
|
|
);
|
|
|
|
const duration = Date.now() - startTime;
|
|
this.recordComplete(type);
|
|
return { completed: true, duration };
|
|
} catch {
|
|
const duration = Date.now() - startTime;
|
|
this.recordStall(type);
|
|
return { completed: false, duration };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for specific animation type by watching DOM changes
|
|
*/
|
|
async waitForFlipAnimation(timeoutMs: number = 2000): Promise<boolean> {
|
|
return this.waitForAnimationClass('flipping', timeoutMs);
|
|
}
|
|
|
|
async waitForSwapAnimation(timeoutMs: number = 3000): Promise<boolean> {
|
|
return this.waitForAnimationClass('swap-animation', timeoutMs);
|
|
}
|
|
|
|
/**
|
|
* Wait for animation class to appear and disappear
|
|
*/
|
|
private async waitForAnimationClass(
|
|
className: string,
|
|
timeoutMs: number
|
|
): Promise<boolean> {
|
|
try {
|
|
// Wait for class to appear
|
|
await this.page.waitForSelector(`.${className}`, {
|
|
state: 'attached',
|
|
timeout: timeoutMs / 2,
|
|
});
|
|
|
|
// Wait for class to disappear (animation complete)
|
|
await this.page.waitForSelector(`.${className}`, {
|
|
state: 'detached',
|
|
timeout: timeoutMs / 2,
|
|
});
|
|
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get animation events
|
|
*/
|
|
getEvents(): AnimationEvent[] {
|
|
return [...this.events];
|
|
}
|
|
|
|
/**
|
|
* Get stall events
|
|
*/
|
|
getStalls(): AnimationEvent[] {
|
|
return this.events.filter(e => e.type === 'stall');
|
|
}
|
|
|
|
/**
|
|
* Get average animation duration by type
|
|
*/
|
|
getAverageDuration(type?: string): number | null {
|
|
const completed = this.events.filter(e =>
|
|
e.type === 'complete' &&
|
|
e.duration !== undefined &&
|
|
(!type || e.animationType === type)
|
|
);
|
|
|
|
if (completed.length === 0) return null;
|
|
|
|
const total = completed.reduce((sum, e) => sum + (e.duration || 0), 0);
|
|
return total / completed.length;
|
|
}
|
|
|
|
/**
|
|
* Check if animations are within expected timing
|
|
*/
|
|
validateTiming(
|
|
type: string,
|
|
expectedMs: number,
|
|
tolerancePercent: number = 50
|
|
): { valid: boolean; actual: number | null } {
|
|
const avgDuration = this.getAverageDuration(type);
|
|
|
|
if (avgDuration === null) {
|
|
return { valid: true, actual: null };
|
|
}
|
|
|
|
const tolerance = expectedMs * (tolerancePercent / 100);
|
|
const minOk = expectedMs - tolerance;
|
|
const maxOk = expectedMs + tolerance;
|
|
|
|
return {
|
|
valid: avgDuration >= minOk && avgDuration <= maxOk,
|
|
actual: avgDuration,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear tracked events
|
|
*/
|
|
clear(): void {
|
|
this.events = [];
|
|
this.animationStartTime = null;
|
|
}
|
|
}
|