golfgame/tests/e2e/health/animation-tracker.ts
Aaron D. Lee 6950769bc3 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>
2026-01-29 18:33:28 -05:00

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;
}
}