mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-16 20:20:00 +00:00
* 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
170 lines
9.7 KiB
TypeScript
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 });
|
|
}
|