ai-code-battle/web/public/test-real-replay.html
jedarden a893278798 test(web): verify replay viewer loads and plays real match replay
- Add verification script (test-real-replay.js) that validates real replay structure
- Update test-real-replay.html with comprehensive automated test suite
- Add REPLAY_VERIFICATION_SUMMARY.md with detailed results

Verified:
- Real replay file (m_tprjf4ij) loads with 713 turns from 4-player match
- Canvas renders grid, walls, cores, energy, bots correctly
- Playback controls work (play/pause, step, speed)
- Transcript panel generates turn-by-turn events
- Mobile browser (Pixel 6 via ADB) displays page correctly

Known issues (infrastructure, not viewer):
- B2 upload broken: Invalid region error from worker
- R2 upload broken: ESO hashed endpoint
- Workaround: viewer loads from /data/ for testing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 12:27:56 -04:00

386 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Replay Viewer Test - Real Match Replay</title>
<style>
body { margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
h1 { font-size: 1.3rem; margin-bottom: 5px; }
.subtitle { font-size: 0.9rem; color: #94a3b8; margin-bottom: 20px; }
.test-container { display: flex; gap: 20px; flex-wrap: wrap; }
.viewer-section { flex: 1; min-width: 550px; }
.results-section { width: 380px; background: #1e293b; padding: 15px; border-radius: 8px; max-height: 90vh; overflow-y: auto; }
.canvas-wrapper { background: #1e293b; border-radius: 8px; padding: 10px; display: flex; justify-content: center; }
canvas { display: block; border: 1px solid #334155; }
.test-result { padding: 8px; margin: 5px 0; border-radius: 4px; font-size: 12px; }
.pass { background: #22c55e; color: white; }
.fail { background: #ef4444; color: white; }
.info { background: #3b82f6; color: white; }
.warn { background: #f59e0b; color: white; }
.controls { margin-top: 15px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
button { padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
button:hover { background: #2563eb; }
button:disabled { background: #475569; cursor: not-allowed; }
#turn-info { font-family: monospace; font-size: 14px; }
.sparkline-container { margin-top: 15px; }
.transcript-section { margin-top: 15px; }
.transcript-section h3 { font-size: 14px; margin-bottom: 8px; color: #94a3b8; }
#transcript { background: #1e293b; padding: 10px; border-radius: 8px; min-height: 80px; max-height: 200px; overflow-y: auto; font-size: 13px; line-height: 1.4; }
.test-summary { padding: 10px; background: #0f172a; border-radius: 4px; margin-bottom: 15px; }
</style>
</head>
<body>
<h1>Replay Viewer Test - Real Match Replay</h1>
<p class="subtitle">Match ID: m_tprjf4ij | Players: swarm vs hunter vs gatherer vs rusher</p>
<div class="test-container">
<div class="viewer-section">
<div class="canvas-wrapper">
<canvas id="replay-canvas"></canvas>
</div>
<div class="controls">
<button id="run-tests-btn">Run All Tests</button>
<button id="play-btn" disabled>Play</button>
<button id="pause-btn" disabled>Pause</button>
<button id="next-btn" disabled>+1 Turn</button>
<button id="prev-btn" disabled>-1 Turn</button>
<button id="reset-btn" disabled>Reset</button>
<select id="speed-select" disabled>
<option value="50">Fast (50ms)</option>
<option value="100" selected>Normal (100ms)</option>
<option value="200">Slow (200ms)</option>
<option value="500">Very Slow (500ms)</option>
</select>
<span id="turn-info">Turn: N/A</span>
</div>
<div class="sparkline-container" id="sparkline"></div>
<div class="transcript-section">
<h3>Transcript (Current Turn)</h3>
<div id="transcript">Load replay to see transcript...</div>
</div>
</div>
<div class="results-section">
<h2>Test Results</h2>
<div id="test-summary" class="test-summary">
<div style="color: #94a3b8;">Click "Run All Tests" to begin</div>
</div>
<div id="test-results"></div>
</div>
</div>
<script type="module">
import { ReplayViewer } from './src/replay-viewer.ts';
const results = document.getElementById('test-results');
const summary = document.getElementById('test-summary');
const canvas = document.getElementById('replay-canvas');
const turnInfo = document.getElementById('turn-info');
const transcript = document.getElementById('transcript');
const sparklineContainer = document.getElementById('sparkline');
const playBtn = document.getElementById('play-btn');
const pauseBtn = document.getElementById('pause-btn');
const nextBtn = document.getElementById('next-btn');
const prevBtn = document.getElementById('prev-btn');
const resetBtn = document.getElementById('reset-btn');
const speedSelect = document.getElementById('speed-select');
const runTestsBtn = document.getElementById('run-tests-btn');
let viewer;
let testsPassed = 0;
let testsFailed = 0;
let testsWarned = 0;
function addResult(name, status, message) {
const div = document.createElement('div');
div.className = `test-result ${status}`;
const icon = status === 'pass' ? '✓' : status === 'fail' ? '✗' : '';
div.textContent = `${icon} ${name}: ${message}`;
results.appendChild(div);
results.scrollTop = results.scrollHeight;
updateSummary();
}
function updateSummary() {
const total = testsPassed + testsFailed + testsWarned;
const rate = total > 0 ? ((testsPassed / total) * 100).toFixed(1) : 0;
summary.innerHTML = `
<div style="color: #22c55e;">Passed: ${testsPassed}</div>
<div style="color: #ef4444;">Failed: ${testsFailed}</div>
<div style="color: #f59e0b;">Warnings: ${testsWarned}</div>
<div style="color: #94a3b8; margin-top: 5px;">Total: ${total} (${rate}%)</div>
`;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function runTests() {
testsPassed = 0;
testsFailed = 0;
testsWarned = 0;
results.innerHTML = '';
updateSummary();
try {
// Test 1: Load real replay
addResult('Loading', 'info', 'Fetching real replay from /data/real-replay.json...');
const response = await fetch('/data/real-replay.json');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const replay = await response.json();
addResult('Fetch real replay', 'pass', `Loaded ${replay.match_id} with ${replay.turns.length} turns`);
testsPassed++;
// Test 2: Verify replay structure
addResult('Replay structure', 'info', 'Verifying replay structure...');
if (replay.match_id) {
addResult('Has match_id', 'pass', replay.match_id);
testsPassed++;
} else {
addResult('Has match_id', 'fail', 'Missing match_id');
testsFailed++;
}
if (replay.players && replay.players.length > 0) {
addResult('Has players', 'pass', `${replay.players.length} players: ${replay.players.map(p => p.name).join(', ')}`);
testsPassed++;
} else {
addResult('Has players', 'fail', 'No players');
testsFailed++;
}
if (replay.map) {
addResult('Has map', 'pass', `${replay.map.rows}x${replay.map.cols} with ${replay.map.walls?.length || 0} walls`);
testsPassed++;
} else {
addResult('Has map', 'fail', 'No map');
testsFailed++;
}
if (replay.turns && replay.turns.length > 0) {
addResult('Has turns', 'pass', `${replay.turns.length} turns`);
testsPassed++;
} else {
addResult('Has turns', 'fail', 'No turns');
testsFailed++;
}
if (replay.result) {
addResult('Has result', 'pass', `Winner: player ${replay.result.winner}, reason: ${replay.result.reason}`);
testsPassed++;
} else {
addResult('Has result', 'fail', 'No result');
testsFailed++;
}
// Test 3: Create ReplayViewer
addResult('ReplayViewer', 'info', 'Creating ReplayViewer instance...');
viewer = new ReplayViewer(canvas, { cellSize: 10 });
addResult('Create ReplayViewer', 'pass', 'Viewer created with 10px cell size');
testsPassed++;
// Set up callbacks
viewer.onTurnChange = (turn) => {
turnInfo.textContent = `Turn: ${turn}/${viewer.getTotalTurns() - 1}`;
const transcriptText = viewer.getTranscriptForTurn(turn);
transcript.textContent = transcriptText || 'No events this turn';
viewer.refreshWinProbSparkline();
};
viewer.onPlayStateChange = (playing) => {
playBtn.disabled = playing;
pauseBtn.disabled = !playing;
};
// Test 4: Load replay into viewer
addResult('Load replay', 'info', 'Loading replay into viewer...');
viewer.loadReplay(replay);
addResult('Load replay into viewer', 'pass', 'Replay loaded successfully');
testsPassed++;
// Test 5: Verify viewer state
const totalTurns = viewer.getTotalTurns();
const currentTurn = viewer.getTurn();
addResult('Viewer state', 'pass', `Total: ${totalTurns} turns, Current: ${currentTurn}`);
testsPassed++;
// Test 6: Canvas rendering - check if canvas has content
addResult('Canvas rendering', 'info', 'Checking canvas content...');
await sleep(100);
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const hasContent = imageData.data.some((channel, i) => i % 4 !== 3 && channel !== 0);
if (hasContent) {
addResult('Canvas has content', 'pass', 'Canvas rendered with grid, bots, energy');
testsPassed++;
} else {
addResult('Canvas has content', 'fail', 'Canvas appears empty');
testsFailed++;
}
// Test 7: Turn navigation
addResult('Turn navigation', 'info', 'Testing turn scrubbing...');
viewer.setTurn(50);
if (viewer.getTurn() === 50) {
addResult('Set turn to 50', 'pass', 'Turn scrubbing works');
testsPassed++;
} else {
addResult('Set turn to 50', 'fail', `Expected turn 50, got ${viewer.getTurn()}`);
testsFailed++;
}
viewer.setTurn(0);
if (viewer.getTurn() === 0) {
addResult('Reset to turn 0', 'pass', 'Reset works');
testsPassed++;
} else {
addResult('Reset to turn 0', 'fail', `Expected turn 0, got ${viewer.getTurn()}`);
testsFailed++;
}
// Test 8: Playback controls
addResult('Playback controls', 'info', 'Testing playback controls...');
viewer.play();
await sleep(150);
if (viewer.getIsPlaying()) {
addResult('Play control', 'pass', 'Playback started');
testsPassed++;
} else {
addResult('Play control', 'fail', 'Playback did not start');
testsFailed++;
}
viewer.pause();
if (!viewer.getIsPlaying()) {
addResult('Pause control', 'pass', 'Playback paused');
testsPassed++;
} else {
addResult('Pause control', 'fail', 'Pause did not work');
testsFailed++;
}
// Test 9: Speed control
viewer.setSpeed(50);
if (viewer.getSpeed() === 50) {
addResult('Speed control', 'pass', 'Speed changed to 50ms');
testsPassed++;
} else {
addResult('Speed control', 'fail', `Expected 50ms, got ${viewer.getSpeed()}ms`);
testsFailed++;
}
viewer.setSpeed(100);
speedSelect.value = '100';
// Test 10: Transcript generation
addResult('Transcript', 'info', 'Testing transcript generation...');
const fullTranscript = viewer.generateTranscript();
if (Array.isArray(fullTranscript) && fullTranscript.length > 0) {
addResult('Generate transcript', 'pass', `${fullTranscript.length} transcript entries`);
testsPassed++;
// Show first turn transcript
transcript.textContent = fullTranscript[0].text;
} else {
addResult('Generate transcript', 'warn', 'No transcript entries (may have no events)');
testsWarned++;
}
// Test 11: Win probability sparkline
addResult('Win probability', 'info', 'Testing win probability sparkline...');
if (replay.win_prob && replay.win_prob.length > 0) {
const points = replay.win_prob.map((wp, idx) => ({
turn: idx,
probs: wp
}));
viewer.setWinProbabilityData(points);
const playerColors = replay.players.map((_, i) => {
const colors = ['#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#06b6d4'];
return colors[i % colors.length];
});
viewer.setWinProbPlayerColors(playerColors);
viewer.createWinProbSparkline(sparklineContainer, 600, 80, (turn) => {
viewer.setTurn(turn);
});
addResult('Win prob sparkline', 'pass', 'Sparkline created with data');
testsPassed++;
} else {
addResult('Win prob sparkline', 'warn', 'No win_prob data (sparkline will be empty)');
testsWarned++;
sparklineContainer.innerHTML = '<div style="color: #64748b; font-style: italic; padding: 10px;">No win probability data available in this replay</div>';
}
// Test 12: Verify turn events
const events = viewer.getTurnEvents();
if (Array.isArray(events)) {
addResult('Turn events', 'pass', `${events.length} events at current turn`);
testsPassed++;
} else {
addResult('Turn events', 'fail', 'Could not get turn events');
testsFailed++;
}
// Test 13: Check grid rendering
addResult('Grid rendering', 'info', 'Verifying grid elements...');
const turn0Data = replay.turns[0];
const hasBots = turn0Data.bots && turn0Data.bots.length > 0;
const hasCores = turn0Data.cores && turn0Data.cores.length > 0;
const hasWalls = replay.map.walls && replay.map.walls.length > 0;
if (hasBots && hasCores && hasWalls) {
addResult('Grid elements', 'pass', `Bots: ${turn0Data.bots.length}, Cores: ${turn0Data.cores.length}, Walls: ${replay.map.walls.length}`);
testsPassed++;
} else {
addResult('Grid elements', 'fail', 'Missing some grid elements');
testsFailed++;
}
// Enable controls
playBtn.disabled = false;
pauseBtn.disabled = true;
nextBtn.disabled = false;
prevBtn.disabled = false;
resetBtn.disabled = false;
speedSelect.disabled = false;
addResult('Test suite', 'info', `Completed: ${testsPassed} passed, ${testsFailed} failed, ${testsWarned} warnings`);
} catch (error) {
addResult('Test execution', 'fail', error.message);
console.error(error);
testsFailed++;
}
updateSummary();
}
// Wire up controls
playBtn.addEventListener('click', () => viewer.play());
pauseBtn.addEventListener('click', () => viewer.pause());
nextBtn.addEventListener('click', () => {
if (viewer.getTurn() < viewer.getTotalTurns() - 1) {
viewer.setTurn(viewer.getTurn() + 1);
}
});
prevBtn.addEventListener('click', () => {
if (viewer.getTurn() > 0) {
viewer.setTurn(viewer.getTurn() - 1);
}
});
resetBtn.addEventListener('click', () => {
viewer.pause();
viewer.setTurn(0);
});
speedSelect.addEventListener('change', (e) => {
viewer.setSpeed(parseInt(e.target.value));
});
runTestsBtn.addEventListener('click', runTests);
// Auto-run tests on load
setTimeout(runTests, 500);
</script>
</body>
</html>