chore: consolidate documentation and clean repository
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -1,46 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { dismissFinished, ensurePaused, ensureRunning, openPausedCase } from './helpers';
|
||||
|
||||
test.describe('controls', () => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openPausedCase(page, 0, '1');
|
||||
});
|
||||
|
||||
test('seek next/prev and jump cases, speed, presentation', async ({ page }) => {
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('1/12');
|
||||
|
||||
await page.getByTestId('demo-next').click();
|
||||
await ensurePaused(page);
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('2/12');
|
||||
|
||||
await page.getByTestId('demo-prev').click();
|
||||
await ensurePaused(page);
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('1/12');
|
||||
|
||||
await page.getByTestId('demo-case-8').click();
|
||||
await ensurePaused(page);
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('9/12');
|
||||
|
||||
await page.getByTestId('demo-case-0').click();
|
||||
await ensurePaused(page);
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('1/12');
|
||||
|
||||
for (const speed of ['0.5', '1', '1.5', '2'] as const) {
|
||||
await page.getByTestId(`demo-speed-${speed}`).click();
|
||||
await expect(page.getByTestId(`demo-speed-${speed}`)).toHaveClass(/active/);
|
||||
}
|
||||
|
||||
await page.getByTestId('demo-presentation').click();
|
||||
await expect(page.getByTestId('demo-hud')).toBeHidden();
|
||||
await expect(page.locator('.presentation-exit')).toBeVisible();
|
||||
|
||||
await page.locator('.presentation-exit').click();
|
||||
await expect(page.getByTestId('demo-hud')).toBeVisible();
|
||||
await expect(page.getByTestId('demo-presentation')).toBeVisible();
|
||||
|
||||
await page.goto('/?debug=1');
|
||||
await ensureRunning(page);
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ensureRunning } from './helpers';
|
||||
|
||||
/**
|
||||
* Stage 0 — mobile fallback & WebGL context resilience.
|
||||
*
|
||||
* Covers:
|
||||
* - 390×844 viewport: honest SVG fallback (no false "WebGL not available");
|
||||
* - UI never controls an invisible canvas on the fallback path;
|
||||
* - WebGL truly unavailable → same honest fallback;
|
||||
* - forced context loss → SVG fallback; context restore → one safe 3D retry;
|
||||
* - prototype mode query params work and keep Auto Cam toggle truthful.
|
||||
*/
|
||||
|
||||
const MOBILE_UA =
|
||||
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
/** Stage 2 §16: fallback tests emulate a WEAK device (capable phones get Mobile Low 3D). */
|
||||
async function stubWeakDevice(page: import('@playwright/test').Page) {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { value: 4, configurable: true });
|
||||
Object.defineProperty(navigator, 'deviceMemory', { value: 2, configurable: true });
|
||||
});
|
||||
}
|
||||
|
||||
function collectErrors(page: import('@playwright/test').Page) {
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
return { pageErrors, consoleErrors };
|
||||
}
|
||||
|
||||
test.describe('mobile fallback (390x844)', () => {
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 3,
|
||||
userAgent: MOBILE_UA,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
|
||||
test('shows honest SVG fallback, no false WebGL error, UI stays functional', async ({ page }) => {
|
||||
const { pageErrors, consoleErrors } = collectErrors(page);
|
||||
await stubWeakDevice(page);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// SVG fallback visible — never a blank black area
|
||||
const fallback = page.getByTestId('main-svg-fallback');
|
||||
await expect(fallback).toBeVisible({ timeout: 30_000 });
|
||||
await expect(fallback.locator('svg.sorter-svg')).toBeVisible();
|
||||
|
||||
// Honest lightweight-mode badge, in Russian, no false claims
|
||||
const badge = page.getByTestId('fallback-reason');
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toContainText('Облегчённый режим');
|
||||
|
||||
// The old misleading message must be gone (both languages)
|
||||
await expect(page.locator('text=WebGL not available')).toHaveCount(0);
|
||||
await expect(page.locator('text=Please use a modern browser')).toHaveCount(0);
|
||||
|
||||
// No invisible canvas is being "controlled" — there is no canvas at all
|
||||
await expect(page.locator('canvas')).toHaveCount(0);
|
||||
|
||||
// Auto-camera toggle must not claim hidden 3D works
|
||||
await expect(page.locator('.auto-camera-toggle')).toHaveCount(0);
|
||||
|
||||
// Play/pause drive the visible SVG scene (same playback state).
|
||||
// Stage 2B autostart may already be running — ensureRunning is idempotent.
|
||||
await ensureRunning(page);
|
||||
await expect(page.getByTestId('demo-status')).not.toHaveText('FINISHED');
|
||||
await page.getByTestId('demo-pause').click();
|
||||
await expect(page.getByTestId('demo-play')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Documentation via AppNav only (duplicate CTA removed)
|
||||
await expect(page.getByTestId('fallback-docs-link')).toHaveCount(0);
|
||||
await expect(page.getByTestId('main-docs-link')).toHaveCount(0);
|
||||
await page.getByTestId('nav-documentation').click();
|
||||
await expect(page).toHaveURL(/\/documentation/);
|
||||
|
||||
// Stage 0 artifact
|
||||
await page.goto('/');
|
||||
await expect(fallback).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: 'docs/stage0_premium_3d/screenshots/04-mobile-svg-fallback.png' });
|
||||
|
||||
expect(
|
||||
pageErrors,
|
||||
`pageerrors: ${pageErrors.join('; ')}`,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
consoleErrors,
|
||||
`console errors: ${consoleErrors.join('; ')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('webgl unavailable', () => {
|
||||
test('desktop without WebGL gets the same honest fallback', async ({ page }) => {
|
||||
const { pageErrors } = collectErrors(page);
|
||||
await page.addInitScript(() => {
|
||||
const orig = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, type: string, ...rest: unknown[]) {
|
||||
if (type.includes('webgl')) return null;
|
||||
return orig.call(this, type, ...rest) as RenderingContext | null;
|
||||
} as typeof HTMLCanvasElement.prototype.getContext;
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
const fallback = page.getByTestId('main-svg-fallback');
|
||||
await expect(fallback).toBeVisible({ timeout: 30_000 });
|
||||
await expect(fallback.locator('svg.sorter-svg')).toBeVisible();
|
||||
await expect(page.getByTestId('fallback-reason')).toContainText('3D недоступно');
|
||||
await expect(page.locator('canvas')).toHaveCount(0);
|
||||
expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('webgl context loss and recovery', () => {
|
||||
test('context loss shows fallback; restore performs one safe retry', async ({ page }) => {
|
||||
const { pageErrors } = collectErrors(page);
|
||||
await page.goto('/?perf=1');
|
||||
const canvas = page.locator('canvas');
|
||||
await expect(canvas).toBeVisible({ timeout: 60_000 });
|
||||
// Renderer fully initialized (perf collector live) before forcing loss
|
||||
await page.waitForFunction(() => typeof (window as never as { __PERF_SNAPSHOT__?: unknown }).__PERF_SNAPSHOT__ === 'object', null, { timeout: 15_000 });
|
||||
|
||||
// Force a real GPU context loss through the extension on the same context.
|
||||
// NB: after loseContext() getExtension() returns null on the lost context,
|
||||
// so the extension handle is captured up front and reused for restore.
|
||||
await page.evaluate(() => {
|
||||
const c = document.querySelector('canvas');
|
||||
if (!c) throw new Error('no canvas');
|
||||
const gl = (c.getContext('webgl2') ?? c.getContext('webgl')) as WebGLRenderingContext | null;
|
||||
if (!gl) throw new Error('no webgl context');
|
||||
const ext = gl.getExtension('WEBGL_lose_context');
|
||||
if (!ext) throw new Error('WEBGL_lose_context unavailable');
|
||||
(window as unknown as { __loseExt: WEBGL_lose_context }).__loseExt = ext;
|
||||
ext.loseContext();
|
||||
});
|
||||
|
||||
// Fallback appears with the context-loss badge
|
||||
const fallback = page.getByTestId('main-svg-fallback');
|
||||
await expect(fallback).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId('fallback-reason')).toContainText('3D-сцена прервана');
|
||||
await page.screenshot({ path: 'docs/stage0_premium_3d/screenshots/07-context-loss-fallback.png' });
|
||||
|
||||
// Canvas stays mounted (hidden) so a real restore can arrive.
|
||||
await expect(page.locator('canvas')).toHaveCount(1);
|
||||
|
||||
// Restore the context — the app performs its single safe retry and 3D returns.
|
||||
await page.evaluate(() => {
|
||||
const ext = (window as unknown as { __loseExt?: WEBGL_lose_context }).__loseExt;
|
||||
if (!ext) throw new Error('extension handle missing');
|
||||
ext.restoreContext();
|
||||
});
|
||||
|
||||
await expect(canvas.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(fallback).toBeHidden();
|
||||
|
||||
// A second loss after the consumed retry must park the fallback forever
|
||||
// (canvas unmounted, no error loop).
|
||||
await page.evaluate(() => {
|
||||
const c = document.querySelector('canvas');
|
||||
if (!c) return;
|
||||
const gl = (c.getContext('webgl2') ?? c.getContext('webgl')) as WebGLRenderingContext | null;
|
||||
gl?.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
});
|
||||
await expect(fallback).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('canvas')).toHaveCount(0);
|
||||
|
||||
// No uncaught errors may escape the boundary
|
||||
expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('stage2 mobile capability policy', () => {
|
||||
test('capable phone gets Mobile Low 3D (no SVG fallback)', async ({ page }) => {
|
||||
const { pageErrors } = collectErrors(page);
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { value: 8, configurable: true });
|
||||
Object.defineProperty(navigator, 'deviceMemory', { value: 8, configurable: true });
|
||||
});
|
||||
// prefer3DByDefault requires width >= 640; narrow 390px uses SVG by design.
|
||||
await page.setViewportSize({ width: 800, height: 600 });
|
||||
await page.goto('/?quality=low');
|
||||
// Mobile Low mounts the real 3D canvas at low quality
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByTestId('main-svg-fallback')).toHaveCount(0);
|
||||
await ensureRunning(page);
|
||||
await page.waitForTimeout(4000);
|
||||
expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('Telegram WebView is forced to SVG until real-device verification', async ({ page }) => {
|
||||
const { pageErrors } = collectErrors(page);
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { value: 8, configurable: true });
|
||||
Object.defineProperty(navigator, 'deviceMemory', { value: 8, configurable: true });
|
||||
// iOS-style Telegram WebView marker
|
||||
(window as unknown as { TelegramWebviewProxy?: unknown }).TelegramWebviewProxy = { postEvent: () => undefined };
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
const fallback = page.getByTestId('main-svg-fallback');
|
||||
await expect(fallback).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('canvas')).toHaveCount(0);
|
||||
expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('stage0 prototype mode', () => {
|
||||
test('query params enable cinematic scene; auto cam toggle is truthful', async ({ page }) => {
|
||||
const { pageErrors, consoleErrors } = collectErrors(page);
|
||||
await page.goto('/?stage0=1&shadows=1&camera=1&post=0&perf=1');
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
await expect(canvas).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Perf collector active in prototype mode with perf=1
|
||||
await page.waitForFunction(() => typeof (window as never as { __PERF_SNAPSHOT__?: unknown }).__PERF_SNAPSHOT__ === 'object', null, { timeout: 15_000 });
|
||||
|
||||
// Auto Cam toggle starts ON and reflects real state after click
|
||||
const toggle = page.locator('.auto-camera-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
await expect(toggle).toContainText('Auto Cam: ON');
|
||||
await toggle.click();
|
||||
await expect(toggle).toContainText('Auto Cam: OFF');
|
||||
await toggle.click();
|
||||
await expect(toggle).toContainText('Auto Cam: ON');
|
||||
|
||||
await ensureRunning(page);
|
||||
await page.waitForTimeout(4000);
|
||||
expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]);
|
||||
expect(consoleErrors, `console errors: ${consoleErrors.join('; ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('manual shot override keeps scene alive while paused', async ({ page }) => {
|
||||
const { pageErrors } = collectErrors(page);
|
||||
await page.goto('/?stage0=1&shadows=1&camera=1&shot=inspection&perf=1');
|
||||
const canvas = page.locator('canvas');
|
||||
await expect(canvas).toBeVisible({ timeout: 60_000 });
|
||||
// Paused playback + manual shot: camera controller must not crash
|
||||
await page.waitForTimeout(3000);
|
||||
expect(pageErrors, `pageerrors: ${pageErrors.join('; ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { dismissFinished, openPausedCase, ensureRunning } from './helpers';
|
||||
|
||||
/**
|
||||
* Production smoke — manual only:
|
||||
* PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 EXPECTED_COMMIT=4fcce5b npm run test:e2e:production
|
||||
*/
|
||||
test.describe('production smoke @production', () => {
|
||||
test('version, routes, controls, safety, webgl', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const expectedCommit = process.env.EXPECTED_COMMIT?.trim();
|
||||
const pageErrors: string[] = [];
|
||||
const failed: string[] = [];
|
||||
page.on('pageerror', (e) => pageErrors.push(String(e)));
|
||||
page.on('response', (r) => {
|
||||
if (r.status() >= 400 && r.url().includes(new URL(page.url()).host)) {
|
||||
failed.push(`${r.status()} ${r.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/?debug=1');
|
||||
const html = await page.content();
|
||||
const bundle = html.match(/index-[A-Za-z0-9_-]+\.js/)?.[0] ?? '';
|
||||
expect(bundle).toMatch(/^index-/);
|
||||
expect(bundle).not.toBe('index-CCdZzxPJ.js');
|
||||
|
||||
const version = await page.evaluate(async () => {
|
||||
const r = await fetch('/version.json', { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
});
|
||||
expect(version, 'version.json missing').toBeTruthy();
|
||||
expect(version.commit).toMatch(/^[0-9a-f]{7,40}$/i);
|
||||
if (expectedCommit) {
|
||||
expect(version.commit).toBe(expectedCommit);
|
||||
}
|
||||
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
|
||||
const webgl = await page.evaluate(() => {
|
||||
const c = document.createElement('canvas');
|
||||
const gl = c.getContext('webgl2') || c.getContext('webgl');
|
||||
if (!gl) return { ok: false, renderer: null as string | null };
|
||||
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
ok: true,
|
||||
renderer: dbg
|
||||
? (gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) as string)
|
||||
: (gl.getParameter(gl.RENDERER) as string),
|
||||
};
|
||||
});
|
||||
expect(webgl.ok).toBe(true);
|
||||
|
||||
await expect(page.getByTestId('demo-play').or(page.getByTestId('demo-pause'))).toBeVisible();
|
||||
await expect(page.getByTestId('demo-case-11')).toBeVisible();
|
||||
|
||||
await dismissFinished(page);
|
||||
await page.getByTestId('demo-case-0').click({ force: true });
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('1/12');
|
||||
|
||||
// Jam / E-stop via shared helper (avoids finished-overlay races)
|
||||
await openPausedCase(page, 10, '1');
|
||||
await ensureRunning(page);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const status = (await page.getByTestId('demo-status').textContent()) ?? '';
|
||||
const command = (await page.getByTestId('demo-command').textContent()) ?? '';
|
||||
const warn =
|
||||
(await page.locator('.warning-text').first().textContent().catch(() => '')) ?? '';
|
||||
return `${status} ${command} ${warn}`;
|
||||
}, { timeout: 30_000 })
|
||||
.toMatch(/FAULT|JAM/i);
|
||||
|
||||
await openPausedCase(page, 11, '1');
|
||||
await ensureRunning(page);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const status = (await page.getByTestId('demo-status').textContent()) ?? '';
|
||||
const command = (await page.getByTestId('demo-command').textContent()) ?? '';
|
||||
return `${status} ${command}`;
|
||||
}, { timeout: 30_000 })
|
||||
.toMatch(/EMERGENCY/i);
|
||||
|
||||
await dismissFinished(page);
|
||||
const stop = page.getByTestId('demo-stop');
|
||||
if (await stop.isVisible().catch(() => false)) {
|
||||
await stop.click({ force: true });
|
||||
}
|
||||
await page.getByTestId('demo-case-0').click({ force: true });
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('1/12');
|
||||
|
||||
await page.goto('/documentation');
|
||||
await expect(page).toHaveURL(/\/documentation\/?$/);
|
||||
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||
|
||||
await page.goto('/details');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
|
||||
await page.goto('/?debug=1');
|
||||
const version2 = await page.evaluate(async () => {
|
||||
const r = await fetch('/version.json', { cache: 'no-store' });
|
||||
return r.ok ? r.json() : null;
|
||||
});
|
||||
expect(version2?.commit).toBe(version.commit);
|
||||
|
||||
expect(pageErrors, pageErrors.join('\n')).toEqual([]);
|
||||
const critical = failed.filter((f) => !f.includes('favicon'));
|
||||
expect(critical, critical.join('\n')).toEqual([]);
|
||||
|
||||
test.info().annotations.push({
|
||||
type: 'webgl-renderer',
|
||||
description: webgl.renderer ?? 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Stage 1 e2e — real 3D models integration.
|
||||
*
|
||||
* Covers (Stage 1 §25):
|
||||
* - `/` renders real official models by default (STL requests fire)
|
||||
* - missing/failed asset falls back to procedural (error boundary path)
|
||||
* - `/details` renders the same real asset at true physical scale
|
||||
* - mobile SVG fallback never downloads heavy real-model assets
|
||||
* - verification mode (?stage1=1&verify=real-models) shows overlays
|
||||
* - no console errors throughout
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { ensureRunning } from './helpers';
|
||||
|
||||
const MODEL_RE = /\/models\/.*\.stl$/;
|
||||
|
||||
function watchConsole(page: Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`));
|
||||
return errors;
|
||||
}
|
||||
|
||||
test.describe('Stage 1 real models', () => {
|
||||
test('main scene downloads official STL models and renders 3D by default', async ({ page }) => {
|
||||
const errors = watchConsole(page);
|
||||
const modelRequests: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (MODEL_RE.test(req.url())) modelRequests.push(req.url());
|
||||
});
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('canvas', { timeout: 30000 });
|
||||
await ensureRunning(page);
|
||||
|
||||
// Playlist starts with SKU-001 (box-300.stl, preloaded); let playback settle.
|
||||
await page.waitForTimeout(9000);
|
||||
|
||||
expect(modelRequests.length, 'official STL assets must be requested').toBeGreaterThan(0);
|
||||
expect(modelRequests.some((u) => u.includes('box-300.stl'))).toBe(true);
|
||||
expect(errors.filter((e) => !e.includes('favicon'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('failed asset load degrades to procedural fallback without crashing the scene', async ({ page }) => {
|
||||
const errors = watchConsole(page);
|
||||
// Simulate a corrupt/missing runtime asset for the bottle.
|
||||
await page.route('**/models/bottle.stl', (route) => route.abort());
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('canvas', { timeout: 30000 });
|
||||
await ensureRunning(page);
|
||||
|
||||
// Play through several cases incl. the bottle case; scene must stay alive.
|
||||
await page.waitForTimeout(15000);
|
||||
expect(await page.locator('canvas').count()).toBeGreaterThan(0);
|
||||
// Route abort produces a network-level console error from the loader retry,
|
||||
// but the error boundary must prevent a scene crash (no pageerror).
|
||||
expect(errors.filter((e) => e.startsWith('pageerror'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('removed /details route redirects to simulation (true scale covered on /)', async ({ page }) => {
|
||||
await page.goto('/details', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await page.waitForSelector('canvas', { timeout: 30000 });
|
||||
expect(await page.locator('canvas').count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mobile SVG fallback does not download real-model assets', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors = watchConsole(page);
|
||||
const modelRequests: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (MODEL_RE.test(req.url())) modelRequests.push(req.url());
|
||||
});
|
||||
// Stage 2 §16: fallback path is for weak devices — stub low capability.
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { value: 4, configurable: true });
|
||||
Object.defineProperty(navigator, 'deviceMemory', { value: 2, configurable: true });
|
||||
});
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('[data-testid="main-svg-fallback"]', { timeout: 30000 });
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
expect(await page.locator('canvas').count()).toBe(0);
|
||||
expect(modelRequests, 'no heavy real-model downloads in SVG fallback').toEqual([]);
|
||||
expect(errors.filter((e) => !e.includes('favicon'))).toEqual([]);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('verification mode shows bounding box and info card for the current SKU', async ({ page }) => {
|
||||
const errors = watchConsole(page);
|
||||
await page.goto('/?stage1=1&verify=real-models&sku=SKU-001', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('canvas', { timeout: 30000 });
|
||||
await ensureRunning(page);
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
const card = page.locator('text=STAGE1 VERIFY · SKU-001');
|
||||
await expect(card).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator('text=REAL (official)')).toBeVisible();
|
||||
await expect(page.locator('text=Measured x/y/z mm')).toBeVisible();
|
||||
expect(errors.filter((e) => !e.includes('favicon'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('verification mode marks SKU-011 as honest fallback', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const errors = watchConsole(page);
|
||||
await page.goto('/?stage1=1&verify=real-models&sku=SKU-011', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('canvas', { timeout: 30000 });
|
||||
await ensureRunning(page);
|
||||
|
||||
// SKU-011 is the 7th playlist case — seek forward deterministically.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await page.locator('[data-testid="demo-next"]').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
const card = page.locator('text=STAGE1 VERIFY · SKU-011');
|
||||
await expect(card).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.locator('text=FALLBACK · NO_EXACT_OFFICIAL_MODEL')).toBeVisible();
|
||||
expect(errors.filter((e) => !e.includes('favicon'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test';
|
||||
|
||||
type Box = { x: number; y: number; width: number; height: number };
|
||||
|
||||
async function boxOf(locator: Locator): Promise<Box> {
|
||||
const box = await locator.boundingBox();
|
||||
expect(box, `missing box for ${locator}`).toBeTruthy();
|
||||
return box as Box;
|
||||
}
|
||||
|
||||
function overlaps(a: Box, b: Box, pad = 0): boolean {
|
||||
return !(
|
||||
a.x + a.width + pad <= b.x ||
|
||||
b.x + b.width + pad <= a.x ||
|
||||
a.y + a.height + pad <= b.y ||
|
||||
b.y + b.height + pad <= a.y
|
||||
);
|
||||
}
|
||||
|
||||
async function noHorizontalOverflow(page: Page) {
|
||||
const metrics = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
}));
|
||||
expect(metrics.scrollWidth, 'documentElement horizontal overflow').toBeLessThanOrEqual(
|
||||
metrics.clientWidth + 1,
|
||||
);
|
||||
expect(metrics.bodyScrollWidth, 'body horizontal overflow').toBeLessThanOrEqual(
|
||||
metrics.clientWidth + 1,
|
||||
);
|
||||
}
|
||||
|
||||
async function insideViewport(page: Page, locator: Locator) {
|
||||
const vp = page.viewportSize();
|
||||
expect(vp).toBeTruthy();
|
||||
const box = await boxOf(locator);
|
||||
expect(box.x).toBeGreaterThanOrEqual(-1);
|
||||
expect(box.y).toBeGreaterThanOrEqual(-1);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual((vp as { width: number }).width + 1);
|
||||
expect(box.y + box.height).toBeLessThanOrEqual((vp as { height: number }).height + 1);
|
||||
}
|
||||
|
||||
test.describe('responsive layout bbox', () => {
|
||||
test('desktop home: no control overlaps, no docs CTA', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/?playback=paused');
|
||||
await expect(page.getByTestId('app-nav')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('main-docs-link')).toHaveCount(0);
|
||||
await expect(page.getByTestId('fallback-docs-link')).toHaveCount(0);
|
||||
|
||||
const play = page.getByTestId('demo-play').or(page.getByTestId('demo-pause'));
|
||||
const present = page.getByTestId('demo-presentation');
|
||||
await expect(play).toBeVisible();
|
||||
await expect(present).toBeVisible();
|
||||
|
||||
const playBox = await boxOf(play);
|
||||
const presentBox = await boxOf(present);
|
||||
expect(overlaps(playBox, presentBox)).toBe(false);
|
||||
|
||||
const autoCam = page.getByTestId('auto-camera-toggle');
|
||||
if ((await autoCam.count()) > 0 && (await autoCam.isVisible())) {
|
||||
const autoBox = await boxOf(autoCam);
|
||||
expect(overlaps(playBox, autoBox)).toBe(false);
|
||||
expect(overlaps(presentBox, autoBox)).toBe(false);
|
||||
await insideViewport(page, autoCam);
|
||||
}
|
||||
|
||||
await insideViewport(page, play);
|
||||
await insideViewport(page, present);
|
||||
await noHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test('mobile home: sequential flow without overlaps', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { value: 4, configurable: true });
|
||||
Object.defineProperty(navigator, 'deviceMemory', { value: 2, configurable: true });
|
||||
});
|
||||
await page.goto('/?playback=paused');
|
||||
|
||||
await expect(page.getByTestId('main-mobile-stack')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('main-docs-link')).toHaveCount(0);
|
||||
await expect(page.getByTestId('fallback-docs-link')).toHaveCount(0);
|
||||
|
||||
const nav = page.getByTestId('app-nav');
|
||||
const hud = page.getByTestId('demo-hud');
|
||||
const banner = page.getByTestId('fallback-reason');
|
||||
const diagram = page.getByTestId('fallback-diagram');
|
||||
const controls = page.getByTestId('main-controls');
|
||||
const play = page.getByTestId('demo-play').or(page.getByTestId('demo-pause'));
|
||||
const present = page.getByTestId('demo-presentation');
|
||||
|
||||
await expect(nav).toBeVisible();
|
||||
await expect(hud).toBeVisible();
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(diagram).toBeVisible();
|
||||
await expect(controls).toBeVisible();
|
||||
await expect(play).toBeVisible();
|
||||
await expect(present).toBeVisible();
|
||||
|
||||
const navBox = await boxOf(nav);
|
||||
const hudBox = await boxOf(hud);
|
||||
const bannerBox = await boxOf(banner);
|
||||
const diagramBox = await boxOf(diagram);
|
||||
const controlsBox = await boxOf(controls);
|
||||
const playBox = await boxOf(play);
|
||||
const presentBox = await boxOf(present);
|
||||
|
||||
expect(overlaps(navBox, hudBox)).toBe(false);
|
||||
expect(overlaps(hudBox, bannerBox)).toBe(false);
|
||||
expect(overlaps(diagramBox, controlsBox)).toBe(false);
|
||||
expect(overlaps(playBox, presentBox)).toBe(false);
|
||||
|
||||
expect(navBox.y + navBox.height).toBeLessThanOrEqual(hudBox.y + 1);
|
||||
expect(hudBox.y + hudBox.height).toBeLessThanOrEqual(bannerBox.y + 1);
|
||||
expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(diagramBox.y + 8);
|
||||
expect(diagramBox.y + diagramBox.height).toBeLessThanOrEqual(controlsBox.y + 8);
|
||||
|
||||
for (const loc of [nav, hud, play, present]) {
|
||||
const b = await boxOf(loc);
|
||||
// Tap target: at least one axis ≥ 40 (full-width buttons may be short in one dimension)
|
||||
expect(Math.max(b.width, b.height), 'min interactive size').toBeGreaterThanOrEqual(40);
|
||||
expect(Math.min(b.width, b.height), 'min interactive thickness').toBeGreaterThanOrEqual(40);
|
||||
expect(b.x).toBeGreaterThanOrEqual(-1);
|
||||
expect(b.x + b.width).toBeLessThanOrEqual(391);
|
||||
}
|
||||
|
||||
// Empty gap check: banner→diagram gap should be modest (not the old ~210px pad)
|
||||
expect(diagramBox.y - (bannerBox.y + bannerBox.height)).toBeLessThan(48);
|
||||
|
||||
await noHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test('mobile documentation: single column + readable table wrapper', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/documentation');
|
||||
await expect(page.getByTestId('documentation-page')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.getByTestId('docs-toc-desktop')).toBeHidden();
|
||||
await expect(page.getByTestId('docs-toc-mobile')).toBeVisible();
|
||||
|
||||
const fontSize = await page
|
||||
.locator('.docs-section p')
|
||||
.first()
|
||||
.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
||||
expect(fontSize).toBeGreaterThanOrEqual(15);
|
||||
|
||||
const layoutDisplay = await page.locator('.docs-layout').evaluate((el) => getComputedStyle(el).display);
|
||||
expect(layoutDisplay).toBe('flex');
|
||||
|
||||
const scroll = page.getByTestId('docs-table-scroll');
|
||||
await expect(scroll).toBeVisible();
|
||||
const overflowX = await scroll.evaluate((el) => getComputedStyle(el).overflowX);
|
||||
expect(['auto', 'scroll']).toContain(overflowX);
|
||||
|
||||
await noHorizontalOverflow(page);
|
||||
});
|
||||
});
|
||||
@@ -4,20 +4,20 @@ test.describe('routes', () => {
|
||||
test('two-page UI: simulation, documentation, unknown redirect', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByTestId('app-nav')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('nav-simulation')).toHaveClass(/active/);
|
||||
await expect(page.getByTestId('app-nav').first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('nav-simulation').first()).toHaveClass(/active/);
|
||||
|
||||
await page.getByTestId('nav-documentation').click();
|
||||
await page.getByTestId('nav-documentation').first().click();
|
||||
await expect(page).toHaveURL(/\/documentation\/?$/);
|
||||
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||
await expect(page.getByTestId('docs-production-status')).toContainText('DATA_ACQUISITION_PACK_READY');
|
||||
await expect(page.getByTestId('nav-documentation')).toHaveClass(/active/);
|
||||
await expect(page.getByTestId('nav-documentation').first()).toHaveClass(/active/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/documentation\/?$/);
|
||||
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||
|
||||
await page.getByTestId('nav-simulation').click();
|
||||
await page.getByTestId('nav-simulation').first().click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByTestId('demo-hud')).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ensureRunning, openPausedCase } from './helpers';
|
||||
|
||||
test.describe('safety', () => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
test('jam case shows FAULT in status or command', async ({ page }) => {
|
||||
await openPausedCase(page, 10, '1');
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('11/12');
|
||||
await ensureRunning(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const status = (await page.getByTestId('demo-status').textContent()) ?? '';
|
||||
const command = (await page.getByTestId('demo-command').textContent()) ?? '';
|
||||
return `${status} ${command}`;
|
||||
},
|
||||
{ timeout: 20_000, intervals: [50, 100, 100] },
|
||||
)
|
||||
.toMatch(/FAULT/i);
|
||||
});
|
||||
|
||||
test('emergency case shows EMERGENCY in status or command', async ({ page }) => {
|
||||
await openPausedCase(page, 11, '1');
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('12/12');
|
||||
await ensureRunning(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const status = (await page.getByTestId('demo-status').textContent()) ?? '';
|
||||
const command = (await page.getByTestId('demo-command').textContent()) ?? '';
|
||||
return `${status} ${command}`;
|
||||
},
|
||||
{ timeout: 20_000, intervals: [50, 100, 100] },
|
||||
)
|
||||
.toMatch(/EMERGENCY/i);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.setTimeout(90_000);
|
||||
|
||||
test.describe('Stage 2C layout + measurement panel', () => {
|
||||
test('default / has no Measurement panel and canvas fills viewport height', async ({ page }) => {
|
||||
await page.goto('/?playback=paused');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForFunction(() => !document.body.innerText.includes('Loading 3D'), {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator('.cv-overlay')).toHaveCount(0);
|
||||
await expect(page.getByTestId('measurement-compact')).toHaveCount(0);
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const vh = window.innerHeight;
|
||||
const canvas = document.querySelector('canvas');
|
||||
const svg = document.querySelector('[data-testid="main-svg-fallback"]');
|
||||
const scene = canvas ?? svg;
|
||||
if (!scene) return { ok: false as const, reason: 'no scene' };
|
||||
const r = scene.getBoundingClientRect();
|
||||
const heightRatio = r.height / vh;
|
||||
const emptyBelow = Math.max(0, vh - (r.top + r.height));
|
||||
const emptyRatio = emptyBelow / vh;
|
||||
return {
|
||||
ok: true as const,
|
||||
heightRatio,
|
||||
emptyRatio,
|
||||
sceneTop: r.top,
|
||||
sceneHeight: r.height,
|
||||
vh,
|
||||
};
|
||||
});
|
||||
|
||||
expect(metrics.ok).toBe(true);
|
||||
if (!metrics.ok) return;
|
||||
expect(metrics.heightRatio).toBeGreaterThanOrEqual(0.85);
|
||||
expect(metrics.emptyRatio).toBeLessThanOrEqual(0.2);
|
||||
});
|
||||
|
||||
test('full Measurement panel only with debug=1&measurement=full', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto('/?debug=1&playback=paused');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.locator('.cv-overlay:not(.cv-overlay-compact)')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
test.setTimeout(90_000);
|
||||
|
||||
async function waitScene(page: Page) {
|
||||
await page.goto('/?debug=1&playback=paused');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await expect(page.getByTestId('demo-hud')).toBeVisible();
|
||||
// seek pins case 0 and starts running — pause again for deterministic HUD snaps
|
||||
await page.getByTestId('demo-case-0').click();
|
||||
const pause = page.getByTestId('demo-pause');
|
||||
if (await pause.isVisible().catch(() => false)) await pause.click();
|
||||
await page.getByTestId('demo-speed-2').click();
|
||||
}
|
||||
|
||||
async function dismissFinishedIfNeeded(page: Page) {
|
||||
const finished = page.getByTestId('demo-finished');
|
||||
if (await finished.isVisible().catch(() => false)) {
|
||||
await finished.getByRole('button', { name: /Replay/i }).click();
|
||||
await expect(finished).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function jumpCase(page: Page, index: number) {
|
||||
await dismissFinishedIfNeeded(page);
|
||||
await page.getByTestId(`demo-case-${index}`).click();
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText(`${index + 1}/12`, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureRunning(page: Page) {
|
||||
await dismissFinishedIfNeeded(page);
|
||||
const pause = page.getByTestId('demo-pause');
|
||||
if (await pause.isVisible().catch(() => false)) return;
|
||||
await page.getByTestId('demo-play').click();
|
||||
}
|
||||
|
||||
async function snapHud(page: Page, name: string) {
|
||||
await dismissFinishedIfNeeded(page);
|
||||
const hud = page.getByTestId('demo-hud');
|
||||
await expect(hud).toBeVisible();
|
||||
await expect(hud).toHaveScreenshot(name, {
|
||||
threshold: 0.35,
|
||||
maxDiffPixelRatio: 0.08,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('visual regression', () => {
|
||||
test('01 home idle HUD', async ({ page }) => {
|
||||
await waitScene(page);
|
||||
// Pause for a stable idle-ish HUD
|
||||
const pause = page.getByTestId('demo-pause');
|
||||
if (await pause.isVisible().catch(() => false)) {
|
||||
await pause.click();
|
||||
}
|
||||
await expect(page.getByTestId('demo-case-label')).toHaveText('1/12');
|
||||
await snapHud(page, '01-home-idle-hud.png');
|
||||
});
|
||||
|
||||
test('02 class B after seek case 0', async ({ page }) => {
|
||||
await waitScene(page);
|
||||
await jumpCase(page, 0);
|
||||
await ensureRunning(page);
|
||||
await expect(page.getByTestId('demo-category')).toHaveText('B', { timeout: 25_000 });
|
||||
await page.getByTestId('demo-pause').click().catch(() => undefined);
|
||||
await snapHud(page, '02-class-b-hud.png');
|
||||
});
|
||||
|
||||
test('03 class C oversized case 2', async ({ page }) => {
|
||||
await waitScene(page);
|
||||
await jumpCase(page, 2);
|
||||
await ensureRunning(page);
|
||||
await expect(page.getByTestId('demo-category')).toHaveText('C', { timeout: 25_000 });
|
||||
await page.getByTestId('demo-pause').click().catch(() => undefined);
|
||||
await snapHud(page, '03-class-c-hud.png');
|
||||
});
|
||||
|
||||
test('04 class D plate case 4', async ({ page }) => {
|
||||
await waitScene(page);
|
||||
await jumpCase(page, 4);
|
||||
await ensureRunning(page);
|
||||
await expect(page.getByTestId('demo-category')).toHaveText('D', { timeout: 25_000 });
|
||||
await page.getByTestId('demo-pause').click().catch(() => undefined);
|
||||
await snapHud(page, '04-class-d-hud.png');
|
||||
});
|
||||
|
||||
test('05 jam fault HUD', async ({ page }) => {
|
||||
await waitScene(page);
|
||||
await jumpCase(page, 9);
|
||||
await ensureRunning(page);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const status = await page.getByTestId('demo-status').innerText();
|
||||
const cmd = await page.getByTestId('demo-command').innerText();
|
||||
return `${status} ${cmd}`;
|
||||
}, { timeout: 30_000 })
|
||||
.toMatch(/FAULT|JAM/i);
|
||||
await page.getByTestId('demo-pause').click().catch(() => undefined);
|
||||
await snapHud(page, '05-jam-fault-hud.png');
|
||||
});
|
||||
|
||||
test('06 emergency stop HUD', async ({ page }) => {
|
||||
await waitScene(page);
|
||||
await jumpCase(page, 10);
|
||||
await ensureRunning(page);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const status = await page.getByTestId('demo-status').innerText();
|
||||
const cmd = await page.getByTestId('demo-command').innerText();
|
||||
return `${status} ${cmd}`;
|
||||
}, { timeout: 30_000 })
|
||||
.toMatch(/EMERGENCY/i);
|
||||
await page.getByTestId('demo-pause').click().catch(() => undefined);
|
||||
await snapHud(page, '06-emergency-hud.png');
|
||||
});
|
||||
|
||||
test('07 presentation mode hides HUD', async ({ page }) => {
|
||||
await waitScene(page);
|
||||
await dismissFinishedIfNeeded(page);
|
||||
await page.getByTestId('demo-presentation').click();
|
||||
await expect(page.getByTestId('demo-hud')).toHaveCount(0);
|
||||
await expect(page.locator('.main-page')).toHaveScreenshot('07-presentation-mode.png', {
|
||||
threshold: 0.4,
|
||||
maxDiffPixelRatio: 0.12,
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
test('08 documentation page', async ({ page }) => {
|
||||
await page.goto('/documentation');
|
||||
await expect(page.getByTestId('documentation-page')).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
await expect(page).toHaveScreenshot('08-documentation-page.png', {
|
||||
threshold: 0.4,
|
||||
maxDiffPixelRatio: 0.12,
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
test('09 recovery after jam stop', async ({ page }) => {
|
||||
await waitScene(page);
|
||||
await jumpCase(page, 9);
|
||||
await ensureRunning(page);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const status = await page.getByTestId('demo-status').innerText();
|
||||
const cmd = await page.getByTestId('demo-command').innerText();
|
||||
return `${status} ${cmd}`;
|
||||
}, { timeout: 30_000 })
|
||||
.toMatch(/FAULT|JAM/i);
|
||||
// Stop resets toward recovery
|
||||
const stop = page.getByTestId('demo-stop');
|
||||
if (await stop.isVisible().catch(() => false)) {
|
||||
await stop.click({ force: true });
|
||||
} else {
|
||||
await dismissFinishedIfNeeded(page);
|
||||
await page.getByTestId('demo-case-0').click();
|
||||
}
|
||||
await expect(page.getByTestId('demo-case-label')).toBeVisible();
|
||||
await snapHud(page, '09-recovery-hud.png');
|
||||
});
|
||||
|
||||
test('10 perf overlay engineering snapshot', async ({ page }) => {
|
||||
await page.goto('/?perf=1&quality=demo');
|
||||
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
const overlay = page.locator('.perf-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 15_000 });
|
||||
await expect(overlay).toHaveScreenshot('10-perf-overlay.png', {
|
||||
threshold: 0.4,
|
||||
maxDiffPixelRatio: 0.15,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |