mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-22 15:21:40 +00:00
819 lines
41 KiB
TypeScript
819 lines
41 KiB
TypeScript
import { openMapOptions } from './mapControls';
|
|
import { expect, test, type Locator, type Page } from '@playwright/test';
|
|
import { DESTINATION_BLOOM_MS, routeDuration } from '../src/packetAnimator';
|
|
import { NEIGHBOR_ROUTE_RECENT_MS } from '../src/routeFocus';
|
|
import type { StateV2 } from '../src/types';
|
|
|
|
test('renders the live route map and privacy-safe state', async ({ page }, testInfo) => {
|
|
// This full journey includes terrain downloads, camera transitions, audio,
|
|
// and region loading. Keep each assertion's own deadline unchanged.
|
|
test.slow();
|
|
const mobile = isMobileProject(testInfo.project.name);
|
|
await instrumentAudioContext(page);
|
|
const mapStyleErrors = captureMapStyleErrors(page);
|
|
const consoleErrors = captureConsoleErrors(page);
|
|
const cartoResponses = { tileJSON: 0, vector: 0, glyph: 0 };
|
|
const terrainResponses = { tileJSON: 0, dem: 0 };
|
|
const rasterRequests: string[] = [];
|
|
page.on('request', (request) => {
|
|
if (/basemaps\.cartocdn\.com\/.+\.(?:png|jpg)(?:\?|$)|dark_all/i.test(request.url())) rasterRequests.push(request.url());
|
|
});
|
|
page.on('response', (response) => {
|
|
if (!response.ok()) return;
|
|
const url = response.url();
|
|
if (url.includes('/vector/carto.streets/v1/tiles.json')) cartoResponses.tileJSON += 1;
|
|
else if (url.includes('/vectortiles/carto.streets/') && url.includes('.mvt')) cartoResponses.vector += 1;
|
|
else if (url.includes('/fonts/') && url.includes('.pbf')) cartoResponses.glyph += 1;
|
|
else if (url.includes('tiles.mapterhorn.com/tilejson.json')) terrainResponses.tileJSON += 1;
|
|
else if (url.includes('tiles.mapterhorn.com/') && !url.includes('/tilejson.json')) terrainResponses.dem += 1;
|
|
});
|
|
const stateResponse = page.waitForResponse((response) => response.url().endsWith('/api/state') && response.ok());
|
|
await page.goto('/');
|
|
const response = await stateResponse;
|
|
const state = await response.json() as Record<string, unknown>;
|
|
|
|
await expect(page.locator('#map .maplibregl-canvas')).toBeVisible();
|
|
await expect(page.locator('.map-grade')).toBeVisible();
|
|
await expect(page.locator('.map-grade')).toHaveCSS('pointer-events', 'none');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle', { timeout: 10_000 });
|
|
expect(mapStyleErrors, 'MapLibre should accept every installed layer expression').toEqual([]);
|
|
await expect.poll(() => cartoResponses.tileJSON, { message: 'CARTO vector TileJSON should load' }).toBeGreaterThan(0);
|
|
await expect.poll(() => cartoResponses.vector, { message: 'CARTO vector PBF tiles should load' }).toBeGreaterThan(0);
|
|
await expect.poll(() => cartoResponses.glyph, { message: 'CARTO glyph PBFs should load' }).toBeGreaterThan(0);
|
|
expect(rasterRequests, 'the vector-only release must not request a raster basemap').toEqual([]);
|
|
await expect(page.locator('#route-canvas')).toHaveCount(0);
|
|
await expect(page.locator('#map')).toHaveAttribute('data-route-renderer', 'maplibre-webgl');
|
|
await expect(page.locator('#packet-canvas')).toBeVisible();
|
|
await expect(page.locator('#traffic-meter i')).toHaveCount(5);
|
|
expect(await page.locator('#map-grade').evaluate((element) => {
|
|
const overlay = getComputedStyle(element, '::before');
|
|
return { animationName: overlay.animationName, backgroundImage: overlay.backgroundImage };
|
|
}), 'live traffic must not add a full-map colour pulse overlay').toEqual({
|
|
animationName: 'none',
|
|
backgroundImage: 'none',
|
|
});
|
|
await expect(page.locator('#status-text')).not.toHaveText('Starting…');
|
|
const routeWindow = page.locator('#route-window');
|
|
const layersSummary = page.locator('#layers-summary');
|
|
if (mobile) {
|
|
await expect(layersSummary).toBeVisible();
|
|
await expect(page.locator('#layers-disclosure')).not.toHaveAttribute('open', '');
|
|
await expect(routeWindow).toBeHidden();
|
|
await layersSummary.click();
|
|
} else {
|
|
await expect(layersSummary).toBeVisible();
|
|
await expect(page.locator('#layers-panel')).toBeHidden();
|
|
}
|
|
await openMapOptions(page);
|
|
await expect(routeWindow).toBeVisible();
|
|
await expect(routeWindow).toHaveValue('auto');
|
|
const autoRouteWindow = routeWindow.locator('option[value="auto"]');
|
|
await expect(autoRouteWindow).toHaveText(/^Auto · (?:15m|1h|6h|24h)$/);
|
|
await openMapOptions(page);
|
|
await routeWindow.selectOption('15m');
|
|
await expect(routeWindow).toHaveValue('15m');
|
|
await expect(autoRouteWindow).toHaveText(/^Auto · (?:15m|1h|6h|24h)$/);
|
|
await openMapOptions(page);
|
|
await routeWindow.selectOption('auto');
|
|
await expect(routeWindow).toHaveValue('auto');
|
|
const aboutDialog = page.locator('#about-dialog');
|
|
await page.locator('#about-button').click();
|
|
await expect(aboutDialog).toBeVisible();
|
|
await expect(aboutDialog).toContainText('No public keys, packet payloads, message text, raw paths, or visitor analytics');
|
|
await page.locator('#about-close').click();
|
|
await expect(aboutDialog).toBeHidden();
|
|
if (mobile) await openLayers(page);
|
|
const nodeLegend = page.locator('#legend-items');
|
|
await expect(nodeLegend).toContainText('Repeater');
|
|
await expect(nodeLegend).toContainText('Companion');
|
|
await expect(nodeLegend).toContainText('Room');
|
|
await expect(nodeLegend).not.toContainText('RF route');
|
|
const routeLegend = page.locator('#route-legend');
|
|
await expect(routeLegend).toBeHidden();
|
|
await expect(routeLegend).toHaveAttribute('aria-label', 'Route colors show the latest packet type');
|
|
await expect(routeLegend.locator('.route-legend-item')).toHaveCount(5);
|
|
if (mobile) {
|
|
const targetSizes = await page.locator('#about-button, .control-button:visible, #route-window, #legend-toggle').evaluateAll((elements) => elements.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return { width: bounds.width, height: bounds.height };
|
|
}));
|
|
for (const size of targetSizes) {
|
|
expect(size.width, 'mobile control target width').toBeGreaterThanOrEqual(44);
|
|
expect(size.height, 'mobile control target height').toBeGreaterThanOrEqual(44);
|
|
}
|
|
const overflow = await page.locator('#topbar, .controls, .layers-panel').evaluateAll((elements) => elements.some((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return bounds.left < -0.5 || bounds.right > window.innerWidth + 0.5 || bounds.top < -0.5 || bounds.bottom > window.innerHeight + 0.5;
|
|
}));
|
|
expect(overflow, 'mobile controls should stay inside portrait and landscape viewports').toBe(false);
|
|
await expect(page.locator('#legend-toggle')).toBeVisible();
|
|
await expect(page.locator('#legend')).toHaveAttribute('data-collapsed', 'true');
|
|
await expect(page.locator('#legend-toggle')).toHaveAttribute('aria-expanded', 'false');
|
|
await expect(page.locator('#legend-items')).toBeHidden();
|
|
await page.locator('#legend-toggle').click();
|
|
await expect(page.locator('#legend-toggle')).toHaveAttribute('aria-expanded', 'true');
|
|
await expect(page.locator('#legend-toggle')).toHaveAttribute('aria-label', 'Hide map legend');
|
|
await expect(page.locator('#legend-items')).toBeVisible();
|
|
await page.locator('#legend-toggle').click();
|
|
await openLayers(page);
|
|
} else {
|
|
await expect(page.locator('#legend-items')).toBeVisible();
|
|
}
|
|
const routesButton = page.locator('#routes-button');
|
|
await openMapOptions(page);
|
|
await expect(routesButton).toBeVisible();
|
|
await expect(routesButton).toHaveAttribute('aria-pressed', 'false');
|
|
await expect(routesButton).toHaveAttribute('title', 'Show routes');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-routes-visible', 'false');
|
|
const heatmapButton = page.locator('#heatmap-button');
|
|
await openMapOptions(page);
|
|
await expect(heatmapButton).toBeVisible();
|
|
await expect(heatmapButton).toHaveAttribute('aria-label', 'Heatmap');
|
|
await expect(heatmapButton).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(heatmapButton).toHaveAttribute('title', 'Hide heatmap');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-heatmap-visible', 'true');
|
|
const clustersButton = page.locator('#clusters-button');
|
|
const hillshadeButton = page.locator('#hillshade-button');
|
|
const terrainButton = page.locator('#terrain-button');
|
|
await expect(clustersButton).toHaveAttribute('aria-pressed', 'true');
|
|
await openMapOptions(page);
|
|
await clustersButton.click();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-clusters-visible', 'false');
|
|
await expect(clustersButton).toHaveAttribute('title', 'Show clusters');
|
|
await openMapOptions(page);
|
|
await clustersButton.click();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-clusters-visible', 'true');
|
|
await expect(hillshadeButton).toHaveAttribute('aria-pressed', 'false');
|
|
await openMapOptions(page);
|
|
await hillshadeButton.click();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-hillshade-visible', 'true');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-terrain-ready', 'true');
|
|
await expect.poll(() => terrainResponses.tileJSON, { message: 'terrain TileJSON should load' }).toBeGreaterThan(0);
|
|
await expect.poll(() => terrainResponses.dem, { message: 'terrain elevation tiles should load' }).toBeGreaterThan(0);
|
|
await expect(page.locator('.maplibregl-ctrl-attrib-inner')).toContainText('Mapterhorn');
|
|
await openMapOptions(page);
|
|
await terrainButton.click();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-terrain3d', 'true');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-camera-pitch', '52');
|
|
await openMapOptions(page);
|
|
await terrainButton.click();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-terrain3d', 'false');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-camera-pitch', '0');
|
|
const soundButton = page.locator('#sound-button');
|
|
const soundPanel = page.locator('#sound-panel');
|
|
const soundToggle = page.locator('#sound-toggle');
|
|
await expect(soundButton).toBeVisible();
|
|
await expect(soundButton).toHaveAttribute('aria-label', 'Sound');
|
|
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
|
await expect(soundButton).toHaveAttribute('title', 'Sound off — Aurora · 80%');
|
|
await expect(page.locator('#sound-state')).toHaveText('Off');
|
|
await soundButton.click();
|
|
await expect(soundPanel).toBeVisible();
|
|
await expect(page.locator('#sound-panel-state')).toHaveText('Off');
|
|
await expect(page.locator('#sound-volume-output')).toHaveText('80%');
|
|
await expect(page.locator('#sound-scene')).toHaveValue('aurora');
|
|
await page.locator('#sound-scene').selectOption('wood');
|
|
await expect(soundButton).toHaveAttribute('title', 'Sound off — Wood · 80%');
|
|
await soundToggle.click();
|
|
await expect(soundButton).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(soundButton).toHaveAttribute('title', 'Sound on — Wood · 80% · visible live hops only');
|
|
await expect(page.locator('#sound-panel-state')).toHaveText('On');
|
|
await expect(soundButton).toHaveClass(/sounding/, { timeout: 15_000 });
|
|
const audioCounts = await page.evaluate(() => ({
|
|
oscillators: (window as unknown as { __cartoliteOscillators: number }).__cartoliteOscillators,
|
|
unlocks: (window as unknown as { __cartoliteAudioUnlocks: number }).__cartoliteAudioUnlocks,
|
|
scheduled: Number(document.getElementById('sound-activity')?.dataset.scheduled ?? 0)
|
|
}));
|
|
expect(audioCounts.unlocks, 'mobile-safe silent audio unlock should run inside the tap').toBe(1);
|
|
expect(audioCounts.scheduled).toBeGreaterThan(0);
|
|
expect(audioCounts.oscillators, 'one oscillator should be created for every scheduled visible hop').toBe(audioCounts.scheduled);
|
|
await page.locator('#sound-volume').evaluate((element) => {
|
|
const input = element as HTMLInputElement;
|
|
input.value = '65';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
await expect(page.locator('#sound-volume-output')).toHaveText('65%');
|
|
await soundToggle.click();
|
|
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
|
await expect(soundButton).toHaveAttribute('title', 'Sound off — Wood · 65%');
|
|
await expect(page.locator('#sound-panel-state')).toHaveText('Off');
|
|
await soundButton.click();
|
|
await expect(soundPanel).toBeHidden();
|
|
if (mobile) await openLayers(page);
|
|
|
|
await openMapOptions(page);
|
|
await routesButton.click();
|
|
await expect(routesButton).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(routeLegend).toBeVisible();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-routes-visible', 'true');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-route-representation', 'individual-routes');
|
|
await expect.poll(() => page.locator('#map').getAttribute('data-eligible-routes').then(Number)).toBeGreaterThan(0);
|
|
await expect(page.locator('#map')).toHaveAttribute('data-trunk-representations-loaded', '');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
|
|
if (mobile) await openLayers(page);
|
|
await openMapOptions(page);
|
|
await routesButton.click();
|
|
await expect(routesButton).toHaveAttribute('aria-pressed', 'false');
|
|
await expect(routesButton).toHaveAttribute('title', 'Show routes');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-routes-visible', 'false');
|
|
await expect(routeLegend).toBeHidden();
|
|
await expect(routeLegend.locator('.route-legend-item')).toHaveCount(5);
|
|
await expect(heatmapButton).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-heatmap-visible', 'true');
|
|
await expect.poll(() => canvasHasPixels(page.locator('#packet-canvas')), { message: 'packet animation canvas should receive a live frame while routes are hidden', timeout: 15_000 }).toBe(true);
|
|
await openMapOptions(page);
|
|
await heatmapButton.click();
|
|
await expect(heatmapButton).toHaveAttribute('aria-pressed', 'false');
|
|
await expect(heatmapButton).toHaveAttribute('title', 'Show heatmap');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-heatmap-visible', 'false');
|
|
await expect(routesButton).toHaveAttribute('aria-pressed', 'false');
|
|
await openMapOptions(page);
|
|
await routesButton.click();
|
|
await expect(routesButton).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-routes-visible', 'true');
|
|
|
|
await openMapOptions(page);
|
|
await heatmapButton.click();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-heatmap-visible', 'true');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
|
|
await page.screenshot({ path: testInfo.outputPath('cartolite-overlays.png'), fullPage: true });
|
|
await expect(routesButton).toHaveAttribute('aria-pressed', 'true');
|
|
await openMapOptions(page);
|
|
await heatmapButton.click();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-heatmap-visible', 'false');
|
|
expect(mapStyleErrors, 'route styling should remain MapLibre-valid after data and layer visibility changes').toEqual([]);
|
|
expect(consoleErrors, 'browser should not emit console errors').toEqual([]);
|
|
expect(state.schemaVersion).toBe(2);
|
|
|
|
const serialized = JSON.stringify(state).toLowerCase();
|
|
for (const forbidden of ['packet_hash', 'raw_payload', 'raw_path', 'public_key', 'observer_public_key', 'resolver_reason', 'message_text']) {
|
|
expect(serialized, `public state contains ${forbidden}`).not.toContain(forbidden);
|
|
}
|
|
|
|
await page.screenshot({ path: testInfo.outputPath('cartolite.png'), fullPage: true });
|
|
});
|
|
|
|
test('keeps the map primary with reduced motion and releases live follow on drag', async ({ page }, testInfo) => {
|
|
const mobile = isMobileProject(testInfo.project.name);
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
await page.goto('/');
|
|
await expect(page.locator('#map')).toBeVisible();
|
|
await expect(page.locator('#map')).toHaveAttribute('data-follow-dwell-ms', '10000');
|
|
await expect(page.locator('#packet-canvas')).toHaveAttribute('data-motion-mode', 'static');
|
|
await expect(page.locator('#follow-button')).toBeVisible();
|
|
if (mobile) await openLayers(page);
|
|
await openMapOptions(page);
|
|
await expect(page.locator('#routes-button')).toBeVisible();
|
|
await expect(page.locator('#routes-button')).toHaveAttribute('aria-pressed', 'false');
|
|
await openMapOptions(page);
|
|
await page.locator('#routes-button').click();
|
|
await expect(page.locator('#routes-button')).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(page.locator('#reset-button')).toBeVisible();
|
|
await page.locator('#follow-button').click();
|
|
await expect(page.locator('#follow-button')).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(page.locator('#follow-button')).toHaveClass(/selected/);
|
|
await expect(page.locator('#follow-button')).toHaveAttribute('title', 'Stop following live packets');
|
|
const mapCanvas = page.locator('#map .maplibregl-canvas');
|
|
const box = await mapCanvas.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
if (!box) return;
|
|
if (!mobile) {
|
|
await page.mouse.move(box.x + box.width * 0.54, box.y + box.height * 0.54);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width * 0.64, box.y + box.height * 0.58, { steps: 4 });
|
|
await page.mouse.up();
|
|
await expect(page.locator('#follow-button')).toHaveAttribute('aria-pressed', 'false');
|
|
await expect(page.locator('#follow-button')).not.toHaveClass(/selected/);
|
|
await expect(page.locator('#follow-button')).toHaveAttribute('title', 'Follow live packets');
|
|
await page.locator('#follow-button').click();
|
|
await expect(page.locator('#follow-button')).toHaveAttribute('aria-pressed', 'true');
|
|
}
|
|
await page.locator('#reset-button').click();
|
|
await expect(page.locator('#follow-button')).toHaveAttribute('aria-pressed', 'false');
|
|
await expect.poll(() => canvasHasPixels(page.locator('#packet-canvas')), {
|
|
message: 'reduced motion should render a restrained static traffic cue',
|
|
timeout: 15_000
|
|
}).toBe(true);
|
|
});
|
|
|
|
test('keeps mobile awake and refreshes live state after the page resumes', async ({ page }, testInfo) => {
|
|
test.skip(!isMobileProject(testInfo.project.name), 'mobile lifecycle behavior');
|
|
const consoleErrors = captureConsoleErrors(page);
|
|
const requests = { state: 0, events: 0 };
|
|
page.on('request', (request) => {
|
|
const path = new URL(request.url()).pathname;
|
|
if (path === '/api/state') requests.state += 1;
|
|
if (path === '/api/events') requests.events += 1;
|
|
});
|
|
await page.addInitScript(() => {
|
|
const lifecycle = { hidden: false, requests: 0, releases: 0 };
|
|
Object.defineProperty(window, '__cartoliteLifecycle', { configurable: true, value: lifecycle });
|
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => lifecycle.hidden });
|
|
Object.defineProperty(document, 'visibilityState', {
|
|
configurable: true,
|
|
get: () => lifecycle.hidden ? 'hidden' : 'visible'
|
|
});
|
|
Object.defineProperty(window, '__setCartoliteHidden', {
|
|
configurable: true,
|
|
value(hidden: boolean) {
|
|
lifecycle.hidden = hidden;
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
}
|
|
});
|
|
Object.defineProperty(navigator, 'wakeLock', {
|
|
configurable: true,
|
|
value: {
|
|
async request(type: string) {
|
|
if (type !== 'screen') throw new TypeError('unsupported wake lock');
|
|
lifecycle.requests += 1;
|
|
const sentinel = new EventTarget() as EventTarget & { released: boolean; release(): Promise<void> };
|
|
sentinel.released = false;
|
|
sentinel.release = async () => {
|
|
if (sentinel.released) return;
|
|
sentinel.released = true;
|
|
lifecycle.releases += 1;
|
|
sentinel.dispatchEvent(new Event('release'));
|
|
};
|
|
return sentinel;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
await page.goto('/');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle', { timeout: 10_000 });
|
|
await expect(page.locator('#app')).toHaveAttribute('data-screen-awake', 'true');
|
|
await expect.poll(() => requests.state).toBe(1);
|
|
await expect.poll(() => requests.events).toBeGreaterThanOrEqual(1);
|
|
|
|
await page.evaluate(() => (window as unknown as { __setCartoliteHidden(hidden: boolean): void }).__setCartoliteHidden(true));
|
|
await expect(page.locator('#app')).toHaveAttribute('data-screen-awake', 'false');
|
|
await page.evaluate(() => (window as unknown as { __setCartoliteHidden(hidden: boolean): void }).__setCartoliteHidden(false));
|
|
|
|
await expect(page.locator('#app')).toHaveAttribute('data-screen-awake', 'true');
|
|
await expect.poll(() => requests.state, { message: 'resume must fetch a fresh state snapshot' }).toBeGreaterThanOrEqual(2);
|
|
await expect.poll(() => requests.events, { message: 'resume must replace the frozen event stream' }).toBeGreaterThanOrEqual(2);
|
|
const lifecycle = await page.evaluate(() => (window as unknown as {
|
|
__cartoliteLifecycle: { requests: number; releases: number };
|
|
}).__cartoliteLifecycle);
|
|
expect(lifecycle).toEqual({ hidden: false, requests: 2, releases: 1 });
|
|
expect(consoleErrors).toEqual([]);
|
|
});
|
|
|
|
test('keeps a recent packet trail after stable routes are hidden', async ({ page }, testInfo) => {
|
|
test.skip(isMobileProject(testInfo.project.name), 'trail lifetime is covered once on desktop');
|
|
const now = Date.now();
|
|
const from = { id: 'a', label: 'Alpha', lat: 43.45, lng: -80.42 };
|
|
const to = { id: 'b', label: 'Bravo', lat: 43.5, lng: -80.28 };
|
|
const state: StateV2 = {
|
|
schemaVersion: 2,
|
|
bootId: 'trail-smoke',
|
|
seq: 0,
|
|
serverTime: now,
|
|
status: { feed: 'connected', activity: 'active', lastPacketAt: now, dropped: 0, version: 'test', gitSha: 'trail' },
|
|
map: { center: [-80.35, 43.45], zoom: 8.25 },
|
|
nodes: [
|
|
{ ...from, role: 'repeater', observer: false, lastSeen: now },
|
|
{ ...to, role: 'companion', observer: false, lastSeen: now }
|
|
],
|
|
routes: [{ id: 'route-a-b', fromId: from.id, toId: to.id, packetCount: 1, lastHeard: now, intensity: 1, lastKind: 'Text', traffic: 1 }]
|
|
};
|
|
const packet = {
|
|
seq: 1,
|
|
id: 'trail-packet',
|
|
at: now,
|
|
payloadType: 'Text',
|
|
mode: 'route' as const,
|
|
segments: [{ routeId: 'route-a-b', fromId: from.id, toId: to.id }]
|
|
};
|
|
|
|
await page.route('**/api/state', (route) => route.fulfill({ json: state }));
|
|
let eventStreamRequested = false;
|
|
await page.route('**/api/events**', (route) => {
|
|
eventStreamRequested = true;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: `retry: 60000\n\nevent: hello\ndata: ${JSON.stringify({ seq: 0, bootId: state.bootId })}\n\nid: 1\nevent: packet\ndata: ${JSON.stringify(packet)}\n\n`
|
|
});
|
|
});
|
|
|
|
await page.goto('/');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
|
|
await expect.poll(() => eventStreamRequested, { message: 'mock event stream should receive the query-bearing SSE request' }).toBe(true);
|
|
await expect(page.locator('#map')).toHaveAttribute('data-routes-visible', 'false');
|
|
const packetCanvas = page.locator('#packet-canvas');
|
|
await expect.poll(() => canvasHasPixels(packetCanvas), { timeout: 5_000 }).toBe(true);
|
|
await expect(packetCanvas).toHaveAttribute('data-last-packet-kind', 'Text');
|
|
await expect(packetCanvas).toHaveAttribute('data-last-signature', 'orbit');
|
|
await expect.poll(() => packetCanvas.getAttribute('data-wakes-scheduled').then(Number)).toBeGreaterThan(0);
|
|
await expect(page.locator('#app')).toHaveAttribute('data-traffic-kind', 'text');
|
|
const afterglowWindow = routeDuration([{ routeId: 'route-a-b', from, to }]) + DESTINATION_BLOOM_MS + 600;
|
|
await page.waitForTimeout(afterglowWindow);
|
|
await expect.poll(() => canvasHasPixels(packetCanvas), { message: '45-second trail should outlive the moving comet and afterglow', timeout: 2_000 }).toBe(true);
|
|
await page.waitForTimeout(Math.max(0, 15_500 - afterglowWindow));
|
|
await expect.poll(() => canvasHasPixels(packetCanvas), { message: 'recent packet trail should remain visible beyond the former 15-second lifetime', timeout: 2_000 }).toBe(true);
|
|
});
|
|
|
|
test('focuses recent route neighbors and clears selection on the map', async ({ page }, testInfo) => {
|
|
// This multi-step inspection journey also captures rendered evidence.
|
|
// Individual assertions retain their existing deadlines.
|
|
test.slow();
|
|
const mobile = isMobileProject(testInfo.project.name);
|
|
const now = Date.now();
|
|
const center: [number, number] = [-80.35, 43.45];
|
|
const alpha = { id: 'a', label: 'Alpha', lng: center[0], lat: center[1] };
|
|
const bravo = { id: 'b', label: 'Bravo', lng: -80.1, lat: 43.45 };
|
|
const charlie = { id: 'c', label: 'Charlie', lng: -80.35, lat: 43.6 };
|
|
const delta = { id: 'd', label: 'Delta', lng: -80.6, lat: 43.3 };
|
|
const state: StateV2 = {
|
|
schemaVersion: 2,
|
|
bootId: 'neighbor-smoke',
|
|
seq: 0,
|
|
serverTime: now,
|
|
status: { feed: 'connected', activity: 'active', lastPacketAt: now, dropped: 0, version: 'test', gitSha: 'neighbor' },
|
|
map: { center, zoom: 8.25 },
|
|
nodes: [
|
|
{ ...alpha, role: 'repeater', observer: false, lastSeen: now },
|
|
{ ...bravo, role: 'companion', observer: false, lastSeen: now },
|
|
{ ...charlie, role: 'room_server', observer: false, lastSeen: now },
|
|
{ ...delta, role: 'sensor', observer: false, lastSeen: now }
|
|
],
|
|
routes: [
|
|
{ id: 'a-b', fromId: alpha.id, toId: bravo.id, packetCount: 12, lastHeard: now, intensity: 3, lastKind: 'Text', traffic: 12 },
|
|
{ id: 'a-c', fromId: alpha.id, toId: charlie.id, packetCount: 7, lastHeard: now - NEIGHBOR_ROUTE_RECENT_MS + 60 * 60_000, intensity: 2, lastKind: 'Trace', traffic: 7 },
|
|
{ id: 'a-d', fromId: alpha.id, toId: delta.id, packetCount: 3, lastHeard: now - NEIGHBOR_ROUTE_RECENT_MS - 60 * 60_000, intensity: 1, lastKind: 'Advert', traffic: 3 },
|
|
{ id: 'b-c', fromId: bravo.id, toId: charlie.id, packetCount: 5, lastHeard: now, intensity: 2, lastKind: 'ACK', traffic: 5 }
|
|
]
|
|
};
|
|
|
|
await page.route('**/api/state', (route) => route.fulfill({ json: state }));
|
|
await page.route('**/api/events**', (route) => route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: `retry: 60000\n\nevent: hello\ndata: ${JSON.stringify({ seq: 0, bootId: state.bootId })}\n\n`
|
|
}));
|
|
await page.addInitScript(({ key, view }) => {
|
|
window.localStorage.setItem(key, JSON.stringify(view));
|
|
}, { key: `cartolite-server:view:v1:${mobile ? 'mobile' : 'desktop'}`, view: { center, zoom: state.map.zoom } });
|
|
|
|
await page.goto('/');
|
|
const map = page.locator('#map');
|
|
const canvas = page.locator('#map .maplibregl-canvas');
|
|
const tooltip = page.locator('#tooltip');
|
|
const focusChip = page.locator('#focus-chip');
|
|
await expect(map).toHaveAttribute('data-render-state', 'idle');
|
|
const routesButton = page.locator('#routes-button');
|
|
if (mobile) await openLayers(page);
|
|
await openMapOptions(page);
|
|
await routesButton.click();
|
|
await expect(map).toHaveAttribute('data-routes-visible', 'true');
|
|
if (mobile) await closeLayers(page);
|
|
const box = await canvas.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
if (!box) return;
|
|
const alphaPoint = projectToViewport(alpha, center, state.map.zoom, box);
|
|
const bravoPoint = projectToViewport(bravo, center, state.map.zoom, box);
|
|
const charliePoint = projectToViewport(charlie, center, state.map.zoom, box);
|
|
const deltaPoint = projectToViewport(delta, center, state.map.zoom, box);
|
|
|
|
await clickPoint(page, { x: alphaPoint.x + (mobile ? 12 : 0), y: alphaPoint.y }, mobile);
|
|
await expect(map).toHaveAttribute('data-selected-node-id', 'a');
|
|
const inspector = mobile ? page.locator('#node-inspector-sheet') : page.locator('.node-inspector-popup');
|
|
await expect(inspector).toBeVisible();
|
|
await expect(inspector).toContainText('Alpha');
|
|
await expect(inspector.locator('.neighbor-row')).toHaveCount(1);
|
|
await expect(inspector.locator('.neighbor-row').first()).toContainText('Bravo');
|
|
await expect(map).toHaveAttribute('data-neighbor-route-count', '1');
|
|
await expect(map).toHaveAttribute('data-focused-route-count', '1');
|
|
await expect(focusChip).toBeVisible();
|
|
await expect(focusChip).toContainText('Alpha · 1 neighbor');
|
|
if (mobile) await openLayers(page);
|
|
await openMapOptions(page);
|
|
await page.locator('#route-window').selectOption('24h');
|
|
if (mobile) await closeLayers(page);
|
|
await expect(map).toHaveAttribute('data-neighbor-route-count', '2');
|
|
await expect(map).toHaveAttribute('data-focused-route-count', '2');
|
|
await expect(inspector.locator('.neighbor-row')).toHaveCount(2);
|
|
await expect(inspector.locator('.neighbor-row strong')).toHaveText(['Bravo', 'Charlie']);
|
|
await expect(map).toHaveAttribute('data-render-state', 'idle');
|
|
await expect(focusChip).toContainText('Alpha · 2 neighbors');
|
|
await expect(page.locator('#legend')).toHaveAttribute('data-focused', 'true');
|
|
await expect(page.locator('#legend-items')).toBeHidden();
|
|
await expect(page.locator('#legend-toggle')).toBeHidden();
|
|
|
|
await inspectRoute(page, alphaPoint, bravoPoint, mobile);
|
|
await expect(map).toHaveAttribute('data-hovered-route-id', 'a-b');
|
|
await expect(tooltip).toHaveAttribute('data-kind', 'route');
|
|
await expect(tooltip).toContainText('Alpha ↔ Bravo');
|
|
await expect(tooltip).toContainText('12 packets');
|
|
await expectTooltipInsideViewport(tooltip, page);
|
|
await page.screenshot({ path: testInfo.outputPath(`cartolite-neighbors-focus-${testInfo.project.name}.png`) });
|
|
|
|
if (!mobile) {
|
|
await hoverMidpoint(page, bravoPoint, charliePoint);
|
|
await expect(tooltip).toBeHidden();
|
|
await expect(map).toHaveAttribute('data-hovered-route-id', '');
|
|
await hoverMidpoint(page, alphaPoint, deltaPoint);
|
|
await expect(tooltip).toBeHidden();
|
|
}
|
|
|
|
if (mobile) await openLayers(page);
|
|
await openMapOptions(page);
|
|
await routesButton.click();
|
|
await expect(map).toHaveAttribute('data-routes-visible', 'false');
|
|
await expect(map).toHaveAttribute('data-selected-node-id', 'a');
|
|
await expect(map).toHaveAttribute('data-hovered-route-id', '');
|
|
await expect(map).toHaveAttribute('data-render-state', 'idle');
|
|
await expect(tooltip).toBeHidden();
|
|
await expect(focusChip).toContainText('Alpha · 2 neighbors');
|
|
await expect(page.locator('#legend')).toHaveAttribute('data-focused', 'true');
|
|
|
|
if (mobile) await openLayers(page);
|
|
await openMapOptions(page);
|
|
await routesButton.click();
|
|
await expect(map).toHaveAttribute('data-routes-visible', 'true');
|
|
await expect(map).toHaveAttribute('data-render-state', 'idle');
|
|
if (mobile) await closeLayers(page);
|
|
await inspectRoute(page, alphaPoint, charliePoint, mobile);
|
|
await expect(map).toHaveAttribute('data-hovered-route-id', 'a-c');
|
|
await expect(tooltip).toHaveAttribute('data-kind', 'route');
|
|
await expect(tooltip).toContainText('Alpha ↔ Charlie');
|
|
if (mobile) await openLayers(page);
|
|
await openMapOptions(page);
|
|
await page.locator('#route-window').selectOption('15m');
|
|
await expect(map).toHaveAttribute('data-hovered-route-id', '');
|
|
await expect(tooltip).toBeHidden();
|
|
await openMapOptions(page);
|
|
await page.locator('#route-window').selectOption('24h');
|
|
if (mobile) await closeLayers(page);
|
|
|
|
await clickPoint(page, bravoPoint, mobile);
|
|
await expect(map).toHaveAttribute('data-selected-node-id', 'b');
|
|
await expect(inspector).toContainText('Bravo');
|
|
await expect(map).toHaveAttribute('data-neighbor-route-count', '2');
|
|
await expect(map).toHaveAttribute('data-focused-route-count', '2');
|
|
await expect(map).toHaveAttribute('data-render-state', 'idle');
|
|
await expect(focusChip).toContainText('Bravo · 2 neighbors');
|
|
await inspectRoute(page, bravoPoint, charliePoint, mobile);
|
|
await expect(map).toHaveAttribute('data-hovered-route-id', 'b-c');
|
|
await expect(tooltip).toHaveAttribute('data-kind', 'route');
|
|
await expect(tooltip).toContainText('Bravo ↔ Charlie');
|
|
if (!mobile) {
|
|
await hoverMidpoint(page, alphaPoint, charliePoint);
|
|
await expect(tooltip).toBeHidden();
|
|
}
|
|
|
|
await page.mouse.move(box.x + 30, box.y + 100);
|
|
await page.mouse.wheel(0, -120);
|
|
await expect(map).toHaveAttribute('data-selected-node-id', 'b');
|
|
await expect(inspector).toBeVisible();
|
|
await expect(inspector).toContainText('Bravo');
|
|
|
|
await clickPoint(page, {
|
|
x: box.x + box.width * 0.84,
|
|
y: box.y + box.height * (mobile ? 0.30 : 0.82),
|
|
}, mobile);
|
|
await expect(map).toHaveAttribute('data-selected-node-id', '');
|
|
await expect(map).toHaveAttribute('data-neighbor-route-count', '0');
|
|
await expect(map).toHaveAttribute('data-focused-route-count', '0');
|
|
await expect(map).toHaveAttribute('data-render-state', 'idle');
|
|
await expect(tooltip).toBeHidden();
|
|
await expect(focusChip).toBeHidden();
|
|
await expect(page.locator('#legend')).toHaveAttribute('data-focused', 'false');
|
|
if (mobile) {
|
|
await expect(page.locator('#legend-toggle')).toBeVisible();
|
|
await expect(page.locator('#legend')).toHaveAttribute('data-collapsed', 'true');
|
|
await expect(page.locator('#legend-items')).toBeHidden();
|
|
} else {
|
|
await expect(page.locator('#legend-items')).toBeVisible();
|
|
}
|
|
|
|
if (mobile) await openLayers(page);
|
|
await page.locator('#find-button').click();
|
|
await expect(page.locator('#find-panel')).toBeVisible();
|
|
await page.locator('#node-search').fill('alpha');
|
|
await expect(page.locator('.node-search-result')).toHaveCount(1);
|
|
await expect(page.locator('.node-search-result')).toContainText('Alpha');
|
|
await page.locator('.node-search-result').click();
|
|
await expect(map).toHaveAttribute('data-selected-node-id', 'a');
|
|
await expect(inspector).toBeVisible();
|
|
await page.keyboard.press('Escape');
|
|
await expect(map).toHaveAttribute('data-selected-node-id', '');
|
|
await expect(inspector).toBeHidden();
|
|
await page.screenshot({ path: testInfo.outputPath('cartolite-neighbors.png') });
|
|
});
|
|
|
|
test('restores remembered sound as Tap to Resume without autoplay', async ({ page }, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop', 'the browser autoplay contract is identical across viewports');
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem('cartolite:sound:v1', JSON.stringify({ enabled: true, volume: 0.63 }));
|
|
const native = window.AudioContext;
|
|
const state = { constructions: 0 };
|
|
Object.defineProperty(window, '__cartoliteAudioConstruction', { configurable: true, value: state });
|
|
Object.defineProperty(window, 'AudioContext', {
|
|
configurable: true,
|
|
value: new Proxy(native, {
|
|
construct(target, argumentsList) {
|
|
state.constructions += 1;
|
|
return Reflect.construct(target, argumentsList);
|
|
}
|
|
})
|
|
});
|
|
});
|
|
|
|
await page.goto('/');
|
|
await expect(page.locator('#sound-state')).toHaveText('Tap to Resume');
|
|
await expect(page.locator('#sound-button')).toHaveAttribute('aria-pressed', 'false');
|
|
expect(await audioConstructionCount(page)).toBe(0);
|
|
await page.locator('#sound-button').click();
|
|
await expect(page.locator('#sound-panel-state')).toHaveText('Tap to Resume');
|
|
await expect(page.locator('#sound-volume-output')).toHaveText('63%');
|
|
expect(await audioConstructionCount(page)).toBe(0);
|
|
await page.locator('#sound-toggle').click();
|
|
await expect(page.locator('#sound-panel-state')).toHaveText('On');
|
|
expect(await audioConstructionCount(page)).toBe(1);
|
|
});
|
|
|
|
test('re-homes when the saved viewport has no current activity', async ({ page }, testInfo) => {
|
|
test.skip(testInfo.project.name !== 'desktop', 'desktop and mobile key separation is covered by unit tests');
|
|
const now = Date.now();
|
|
const state: StateV2 = {
|
|
schemaVersion: 2,
|
|
bootId: 'saved-view-smoke',
|
|
seq: 0,
|
|
serverTime: now,
|
|
status: { feed: 'connected', activity: 'active', lastPacketAt: now, dropped: 0, version: 'test', gitSha: 'saved-view' },
|
|
map: { center: [151.21, -33.87], zoom: 8 },
|
|
nodes: [{ id: 'sydney', label: 'Sydney', role: 'repeater', observer: false, lat: -33.87, lng: 151.21, lastSeen: now }],
|
|
routes: []
|
|
};
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem('cartolite-server:view:v1:desktop', JSON.stringify({ center: [-0.13, 51.51], zoom: 12 }));
|
|
});
|
|
await page.route('**/api/state', (route) => route.fulfill({ json: state }));
|
|
await page.route('**/api/events**', (route) => route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: `retry: 60000\n\nevent: hello\ndata: ${JSON.stringify({ seq: 0, bootId: state.bootId })}\n\n`
|
|
}));
|
|
|
|
await page.goto('/');
|
|
await expect(page.locator('#map')).toHaveAttribute('data-view-source', 'home-no-activity');
|
|
});
|
|
|
|
async function canvasHasPixels(canvas: Locator): Promise<boolean> {
|
|
return canvas.evaluate((node) => {
|
|
const element = node as HTMLCanvasElement;
|
|
const context = element.getContext('2d');
|
|
if (!context || element.width === 0 || element.height === 0) return false;
|
|
const pixels = context.getImageData(0, 0, element.width, element.height).data;
|
|
for (let index = 3; index < pixels.length; index += 4) {
|
|
if (pixels[index] !== 0) return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
function captureMapStyleErrors(page: Page): string[] {
|
|
const errors: string[] = [];
|
|
page.on('console', (message) => {
|
|
const text = message.text();
|
|
if (message.type() === 'error' && text.includes('layers.') && text.includes('.paint.')) errors.push(text);
|
|
});
|
|
return errors;
|
|
}
|
|
|
|
function captureConsoleErrors(page: Page): string[] {
|
|
const errors: string[] = [];
|
|
page.on('console', (message) => {
|
|
const text = message.text();
|
|
// Cloudflare may inject its optional beacon after the origin response. The
|
|
// app deliberately blocks it because visitor analytics are out of scope.
|
|
if (message.type() === 'error' && !text.includes('static.cloudflareinsights.com/beacon.min.js')) errors.push(text);
|
|
});
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
return errors;
|
|
}
|
|
|
|
async function instrumentAudioContext(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
const state = { count: 0, unlocks: 0 };
|
|
Object.defineProperty(window, '__cartoliteOscillators', {
|
|
configurable: true,
|
|
get: () => state.count
|
|
});
|
|
Object.defineProperty(window, '__cartoliteAudioUnlocks', {
|
|
configurable: true,
|
|
get: () => state.unlocks
|
|
});
|
|
const original = window.AudioContext.prototype.createOscillator;
|
|
window.AudioContext.prototype.createOscillator = function createInstrumentedOscillator(): OscillatorNode {
|
|
state.count += 1;
|
|
return original.call(this);
|
|
};
|
|
const originalBufferStart = AudioBufferSourceNode.prototype.start;
|
|
AudioBufferSourceNode.prototype.start = function startInstrumentedBuffer(...args: Parameters<AudioBufferSourceNode['start']>): void {
|
|
state.unlocks += 1;
|
|
originalBufferStart.apply(this, args);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function audioConstructionCount(page: Page): Promise<number> {
|
|
return page.evaluate(() => (window as unknown as {
|
|
__cartoliteAudioConstruction: { constructions: number };
|
|
}).__cartoliteAudioConstruction.constructions);
|
|
}
|
|
|
|
async function openLayers(page: Page): Promise<void> {
|
|
const disclosure = page.locator('#layers-disclosure');
|
|
if (!await disclosure.evaluate((element) => element.hasAttribute('open'))) {
|
|
await page.locator('#layers-summary').click();
|
|
}
|
|
await expect(disclosure).toHaveAttribute('open', '');
|
|
}
|
|
|
|
async function closeLayers(page: Page): Promise<void> {
|
|
const disclosure = page.locator('#layers-disclosure');
|
|
if (await disclosure.evaluate((element) => element.hasAttribute('open'))) {
|
|
await page.locator('#layers-summary').click();
|
|
}
|
|
await expect(disclosure).not.toHaveAttribute('open', '');
|
|
}
|
|
|
|
function isMobileProject(projectName: string): boolean {
|
|
return projectName.startsWith('mobile');
|
|
}
|
|
|
|
interface ViewportBox {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface ViewportPoint {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
function projectToViewport(
|
|
endpoint: { lng: number; lat: number },
|
|
center: [number, number],
|
|
zoom: number,
|
|
box: ViewportBox
|
|
): ViewportPoint {
|
|
const worldSize = 512 * (2 ** zoom);
|
|
const projected = mercator(endpoint.lng, endpoint.lat);
|
|
const projectedCenter = mercator(center[0], center[1]);
|
|
return {
|
|
x: box.x + box.width / 2 + (projected.x - projectedCenter.x) * worldSize,
|
|
y: box.y + box.height / 2 + (projected.y - projectedCenter.y) * worldSize
|
|
};
|
|
}
|
|
|
|
function mercator(lng: number, lat: number): ViewportPoint {
|
|
const sin = Math.sin(lat * Math.PI / 180);
|
|
return {
|
|
x: (lng + 180) / 360,
|
|
y: 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)
|
|
};
|
|
}
|
|
|
|
async function hoverMidpoint(page: Page, from: ViewportPoint, to: ViewportPoint): Promise<void> {
|
|
await page.mouse.move((from.x + to.x) / 2, (from.y + to.y) / 2, { steps: 4 });
|
|
}
|
|
|
|
async function inspectRoute(page: Page, from: ViewportPoint, to: ViewportPoint, mobile: boolean): Promise<void> {
|
|
const x = (from.x + to.x) / 2;
|
|
const y = (from.y + to.y) / 2;
|
|
if (mobile) {
|
|
await page.touchscreen.tap(x, y);
|
|
// Prove the tap stays pinned after Chromium's delayed synthetic mouseleave.
|
|
await page.waitForTimeout(550);
|
|
return;
|
|
}
|
|
await page.mouse.move(x, y, { steps: 4 });
|
|
}
|
|
|
|
async function clickPoint(page: Page, point: ViewportPoint, mobile: boolean): Promise<void> {
|
|
if (mobile) {
|
|
await page.touchscreen.tap(point.x, point.y);
|
|
return;
|
|
}
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
async function expectTooltipInsideViewport(tooltip: Locator, page: Page): Promise<void> {
|
|
const box = await tooltip.boundingBox();
|
|
const viewport = page.viewportSize();
|
|
expect(box).not.toBeNull();
|
|
expect(viewport).not.toBeNull();
|
|
if (!box || !viewport) return;
|
|
expect(box.x).toBeGreaterThanOrEqual(7);
|
|
expect(box.y).toBeGreaterThanOrEqual(7);
|
|
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width - 7);
|
|
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height - 7);
|
|
}
|