Files
CartoLite-Server/web/e2e/customization.spec.ts
n30nex d5bd53a29c CartoLite Server 0.6.0: cinematic Map and Netgraph (#17)
* Build cinematic display controls and shared GPU packet effects

* Add scoped Follow and adaptive scene hierarchy

* Add explicit sound preview and streamline map chrome

* Add Netgraph live director and cinematic acceptance

* Refine cinematic focus and renderer lifecycle

* Fix menu history races and Follow inspection

* Prepare Worldwide 0.6.0 cinematic release candidate

* Fix mobile layer positioning and verify composited packet colors

* Validate each visual scene independently and retain bounded CI evidence

* Restore worldwide grouping and adapt GPU effects to frame cost

* Bound GPU presentation cost without dropping packet motion

* Keep dock controls reachable and avoid redundant backdrop rendering

* Keep phone legends and touch targets clear of the dock

* Keep Netgraph Follow scoped, readable and steady

* Retain mobile reset and bound dense-scene presentation cost

* Keep traffic controls and node details legible in landscape

* Focus Finder within its opening gesture to prevent menu races

* Frame Follow above its controls and release owned graphics contexts

* Verify map dismissal after the camera gesture settles

* Pack shared route attributes once per segment

* Keep map attribution accessible above the mobile dock

* Remove inactive map effect layers and sample packet lifetimes deterministically

* Synchronize dense-scene fallback even when packet animation is idle

* Verify effect-layer ownership with complete map test doubles
2026-09-27 02:55:30 -04:00

170 lines
9.7 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
import type { StateV2 } from '../src/types';
import { openMapOptions } from './mapControls';
test('map options are compact, persistent, and preserve live layers through style changes', async ({ page }, testInfo) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await fixture(page);
await page.goto('/');
const map = page.locator('#map');
await expect(map).toHaveAttribute('data-render-state', 'idle');
await expect(page.locator('#layers-panel')).toBeHidden();
await expect(page.locator('#find-button')).toBeVisible();
await openMapOptions(page);
await page.locator('#basemap-style').selectOption('light');
await expect(map).toHaveAttribute('data-basemap-style', 'light');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.locator('#interface-theme').selectOption('dark');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.locator('#node-labels-button').click();
await page.locator('#map-labels-button').click();
await page.locator('#roads-button').click();
await page.locator('.display-advanced > summary').click();
await page.locator('#route-opacity').press('Home');
for (let step = 0; step < 6; step++) await page.locator('#route-opacity').press('ArrowRight');
if (await page.locator('#hillshade-button').getAttribute('aria-pressed') !== 'true') await page.locator('#hillshade-button').click();
await page.locator('#terrain-relief').press('Home');
for (let step = 0; step < 8; step++) await page.locator('#terrain-relief').press('ArrowRight');
await page.locator('#basemap-style').selectOption('streets');
await expect(map).toHaveAttribute('data-roads-visible', 'false');
await expect(map).toHaveAttribute('data-node-labels', 'false');
await expect(map).toHaveAttribute('data-map-labels', 'false');
await expect(map).toHaveAttribute('data-route-opacity', '0.5');
await page.locator('#live-packets-button').click();
await expect(page.locator('#packet-canvas')).toBeHidden();
await page.keyboard.press('Escape');
await expect(page.locator('#layers-summary')).toBeFocused();
await expect(page.locator('#layers-panel')).toBeHidden();
await page.reload();
await expect(map).toHaveAttribute('data-render-state', 'idle');
await expect(map).toHaveAttribute('data-basemap-style', 'streets');
await expect(map).toHaveAttribute('data-node-labels', 'false');
await expect(page.locator('#packet-canvas')).toBeHidden();
await openMapOptions(page);
await expect(page.locator('#terrain-relief')).toHaveValue('40');
const bounds = await page.locator('#layers-panel').boundingBox();
const size = page.viewportSize()!;
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(size.height);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(size.width);
await page.screenshot({ path: testInfo.outputPath('map-options-streets.png') });
await page.locator('#reset-appearance').click();
await expect(map).toHaveAttribute('data-basemap-style', 'dark');
await expect(map).toHaveAttribute('data-node-labels', 'true');
await expect(page.locator('#packet-canvas')).toBeVisible();
await page.locator('#layers-close').click();
await expect(page.locator('#layers-panel')).toBeHidden();
expect(errors).toEqual([]);
});
test('Live Follow holds its activity card for ten seconds and pauses when the user takes control', async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await fixture(page);
await page.goto('/');
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
await expect(page.locator('html')).toHaveAttribute('data-fixture-stream', 'ready');
await page.clock.install();
await page.clock.pauseAt(new Date(Date.now() + 1000));
await page.locator('#follow-button').click();
await emit(page, 1, 'Text');
await expect(page.locator('#follow-title')).toHaveText('Valley Fixture → Ridge Fixture');
await expect(page.locator('#follow-detail')).toHaveText('Text · 2 confirmed hops');
await expect(page.locator('#follow-countdown')).toHaveText('10s');
await page.clock.fastForward(4000);
await emit(page, 2, 'Advert');
await expect(page.locator('#follow-detail')).toHaveText('Text · 2 confirmed hops');
await expect(page.locator('#follow-countdown')).toHaveText('6s');
await page.clock.fastForward(5000);
await expect(page.locator('#follow-detail')).toHaveText('Text · 2 confirmed hops');
await expect(page.locator('#follow-countdown')).toHaveText('1s');
await page.clock.fastForward(1000);
await expect(page.locator('#follow-detail')).toHaveText('Advert · 2 confirmed hops');
await expect(page.locator('#follow-countdown')).toHaveText('10s');
await page.clock.runFor(80);
await page.screenshot({ path: testInfo.outputPath('live-follow-card.png') });
await page.locator('#follow-pause').click();
await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'held');
await emit(page, 3, 'Trace');
await page.clock.fastForward(11_000);
await expect(page.locator('#follow-detail')).toHaveText('Advert · 2 confirmed hops');
await page.locator('#follow-pause').click();
await emit(page, 4, 'Trace');
await page.locator('#follow-next').click();
await expect(page.locator('#follow-detail')).toHaveText('Trace · 2 confirmed hops');
// Native map gestures need their animation callbacks after the timed assertions.
await page.clock.resume();
if (testInfo.project.name === 'desktop') {
await page.mouse.move(1000, 450);
await page.mouse.down();
await page.mouse.move(1100, 460, { steps: 4 });
await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'paused');
await page.mouse.up();
}
await page.locator('#find-button').click();
await page.locator('#node-search').fill('Summit');
await page.locator('.node-search-result').first().click();
// Pause before fresh events, with headroom for the software renderer's command round trip.
await page.clock.pauseAt(new Date(await page.evaluate(() => Date.now() + 10_000)));
await page.locator('#follow-button').click();
await emit(page, 5, 'Text');
await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'following');
await expect(page.locator('.node-inspector')).toBeHidden();
// A new neighbour is a feed update, not a manual selection or a pause request.
await page.evaluate(() => window.dispatchEvent(new CustomEvent('fixture-packet', { detail: {
seq: 6, id: 'fixture-new-neighbour', at: Date.now(), mode: 'route', payloadType: 'Advert',
segments: [{ routeId: 'new-neighbour', fromId: 'summit', toId: 'meadow' }],
} })));
await page.clock.runFor(1000); // Flush frame batching and coalesced route hydration under virtual time.
await expect(page.locator('#map')).toHaveAttribute('data-neighbor-route-count', '3');
await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'following');
await page.clock.fastForward(10_000);
await page.clock.fastForward(10_000);
await expect(page.locator('#follow-card')).toHaveAttribute('data-state', 'following');
await expect(page.locator('#follow-title')).toHaveText('Waiting for activity');
await expect(page.locator('#follow-countdown')).toHaveText('');
await expect(page.locator('#follow-card')).not.toHaveAttribute('data-packet-at', /.+/);
await expect(page.locator('#map')).toHaveAttribute('data-follow-feature-count', '0');
await page.locator('#follow-close').click();
await expect(page.locator('#follow-card')).toBeHidden();
});
async function fixture(page: Page): Promise<void> {
const now = Date.now();
const nodes = [
{ id: 'valley', label: 'Valley Fixture', lng: -80.4, lat: 43.5 },
{ id: 'summit', label: 'Summit Fixture', lng: -80.2, lat: 43.65 },
{ id: 'ridge', label: 'Ridge Fixture', lng: -80.0, lat: 43.55 },
{ id: 'meadow', label: 'Meadow Fixture', lng: -80.3, lat: 43.65 },
].map((node) => ({ ...node, role: 'repeater' as const, observer: false, lastSeen: now }));
const state: StateV2 = {
schemaVersion: 2, bootId: 'customization-fixture', seq: 0, serverTime: now,
status: { feed: 'connected', activity: 'active', dropped: 0, version: 'test', gitSha: 'synthetic' },
map: { center: [-80.2, 43.6], zoom: 7 }, nodes,
routes: nodes.slice(1).map((node, index) => ({ id: `hop-${index}`, fromId: nodes[index]!.id, toId: node.id, packetCount: 1, lastHeard: now, intensity: 1, lastKind: 'Text', traffic: 1 })),
};
await page.route('**/api/state', (route) => route.fulfill({ json: state }));
await page.addInitScript(() => {
class FixtureStream extends EventTarget {
static CLOSED = 2;
readyState = 1;
onopen: ((event: Event) => void) | null = null;
private receive = (event: Event) => this.dispatchEvent(new MessageEvent('packet', { data: JSON.stringify((event as CustomEvent).detail) }));
constructor() {
super();
window.addEventListener('fixture-packet', this.receive);
setTimeout(() => { this.onopen?.(new Event('open')); document.documentElement.dataset.fixtureStream = 'ready'; }, 0);
}
close(): void { this.readyState = 2; window.removeEventListener('fixture-packet', this.receive); }
}
Object.defineProperty(window, 'EventSource', { value: FixtureStream });
});
}
async function emit(page: Page, seq: number, payloadType: string): Promise<void> {
await page.evaluate(({ seq, payloadType }) => window.dispatchEvent(new CustomEvent('fixture-packet', { detail: {
seq, id: `fixture-${seq}`, at: Date.now(), mode: 'route', payloadType,
segments: [{ routeId: 'hop-0', fromId: 'valley', toId: 'summit' }, { routeId: 'hop-1', fromId: 'summit', toId: 'ridge' }],
} })), { seq, payloadType });
}