mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The first formal release. - One-click install via Pinokio; keyless boot lands on a live Esri World Imagery satellite globe with keyless terrain, with automatic OSM fallback and graceful degradation when terrain is unavailable. - Provider Settings (the POWER UP panel): add, replace, or remove API keys inside the app; credential files made owner-only before any secret is written; external keys shown read-only; the panel refuses shared or proxied servers. - Keyless capability responses for the optional HUD summary and place search. - Aircraft-identity voice answers cover operator, type, and route, and say so plainly when enrichment is unavailable. - README rewritten keyless-first; concise 0.1.0 changelog section; CI workflow included. Gates: 2,672 unit tests pass / 0 fail, build clean, tracking regression 108/108, map-source tray QA pass, setup doctor ready.
293 lines
13 KiB
JavaScript
293 lines
13 KiB
JavaScript
// src/data/cctvCards.test.mjs
|
|
// Pure-helper tests for the ambient CCTV thumbnail-card overlay: greedy
|
|
// declutter, the no-flicker frame-persistence rule, retry pacing, and the
|
|
// bounded thumbnail-cache prune. Rendering geometry (placeCard/cardAlpha) is
|
|
// shared with firmsLabels.js and covered by firmsLabels.test.mjs; the radial
|
|
// keyhole edge fade is covered by the celestialRing suite.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import {
|
|
CCTV_CARD_FADE_END_M,
|
|
CCTV_CARD_FADE_START_M,
|
|
CCTV_CARD_FETCH_BURST_LIMIT,
|
|
CCTV_CARD_FETCH_BURST_SPACING_MS,
|
|
CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
|
CCTV_CARD_MIN_SEP_PX,
|
|
CCTV_CARD_SCALE_AT_MID,
|
|
CCTV_CARD_SCALE_FULL_M,
|
|
CCTV_CARD_SCALE_MID_M,
|
|
CCTV_CARD_SCALE_MIN,
|
|
CCTV_FRAME_CACHE_MAX,
|
|
applyFrameResult,
|
|
cardFetchPolicy,
|
|
cardScaleForAltitude,
|
|
createCctvThumbnailOverlayEntry,
|
|
createFrameSlot,
|
|
declutterCctvCards,
|
|
frameFetchDue,
|
|
frameRetryDelayMs,
|
|
isCctvCardAnchorSafe,
|
|
planFrameCachePrune,
|
|
} from './cctvCards.js';
|
|
|
|
test('isCctvCardAnchorSafe protects the top HUD band for ambient cards', () => {
|
|
assert.equal(isCctvCardAnchorSafe({ sy: 100, viewH: 1000 }), false);
|
|
assert.equal(isCctvCardAnchorSafe({ sy: 150, viewH: 1000 }), true);
|
|
assert.equal(isCctvCardAnchorSafe({ sy: 100, viewH: 1000, pinned: true }), true);
|
|
assert.equal(isCctvCardAnchorSafe({ sy: NaN, viewH: 1000 }), false);
|
|
});
|
|
|
|
// ─── declutterCctvCards ──────────────────────────────────────────────────────
|
|
|
|
test('declutterCctvCards: nearest-first greedy accept with min separation', () => {
|
|
const kept = declutterCctvCards([
|
|
{ id: 'far', sx: 100, sy: 100, distanceKm: 5 },
|
|
{ id: 'near', sx: 110, sy: 110, distanceKm: 1 }, // wins the overlap: nearer
|
|
{ id: 'clear', sx: 400, sy: 400, distanceKm: 9 },
|
|
], { minSepPx: 130 });
|
|
assert.deepEqual(kept, ['near', 'clear']);
|
|
});
|
|
|
|
test('declutterCctvCards: separation is radial (Euclidean), matching the vessel pass', () => {
|
|
// 100px apart on one axis: inside a 130px separation, outside a 90px one.
|
|
const candidates = [
|
|
{ id: 'a', sx: 0, sy: 0, distanceKm: 1 },
|
|
{ id: 'b', sx: 100, sy: 0, distanceKm: 2 },
|
|
];
|
|
assert.deepEqual(declutterCctvCards(candidates, { minSepPx: 130 }), ['a']);
|
|
assert.deepEqual(declutterCctvCards(candidates, { minSepPx: 90 }), ['a', 'b']);
|
|
});
|
|
|
|
test('declutterCctvCards: respects the limit and drops malformed rows', () => {
|
|
const kept = declutterCctvCards([
|
|
{ id: 'a', sx: 0, sy: 0, distanceKm: 1 },
|
|
{ id: 'b', sx: 500, sy: 0, distanceKm: 2 },
|
|
{ id: 'c', sx: 1000, sy: 0, distanceKm: 3 },
|
|
{ id: '', sx: 200, sy: 200, distanceKm: 0 },
|
|
{ id: 'nan', sx: NaN, sy: 0, distanceKm: 0 },
|
|
null,
|
|
], { minSepPx: 130, limit: 2 });
|
|
assert.deepEqual(kept, ['a', 'b']);
|
|
});
|
|
|
|
|
|
test('thumbnail entry carries the exact shipped contract and stable frame-slot reference', () => {
|
|
const frameSlot = createFrameSlot();
|
|
const entry = createCctvThumbnailOverlayEntry({
|
|
id: 'cam-a',
|
|
position: { x: 1, y: 2, z: 3 },
|
|
title: 'Main & 5th',
|
|
frameSlot,
|
|
rank: 2,
|
|
});
|
|
assert.equal(entry.variant, 'thumbnail');
|
|
assert.equal(entry.collisionGroup, 'ambient-card');
|
|
assert.equal(entry.paintLane, 'thumbnail');
|
|
assert.equal(entry.interactive, true);
|
|
assert.equal(entry.image, frameSlot, 'host receives the source-owned stable slot by reference');
|
|
assert.equal(entry.thumbnailWidth, 96);
|
|
assert.equal(entry.thumbnailHeight, 54);
|
|
assert.equal(entry.thumbnailPadX, 4);
|
|
assert.equal(entry.thumbnailTitleHeight, 13);
|
|
assert.equal(entry.requireImage, true);
|
|
assert.equal(entry.edgeFade, 'keyhole');
|
|
assert.equal(entry.verticalOnly, true);
|
|
assert.equal(entry.gapPx, 22);
|
|
assert.equal(entry.leaderOffsetPx, 16);
|
|
// CCTV shipped as a stateless per-frame rebuild with an anchor-separation
|
|
// pass. Both are opt-ins on the shared host: without them the arbiter's
|
|
// min-lifetime/cooldown/sticky-corner hysteresis makes cards stick below
|
|
// their icon and take ~1.5 s to return, and rectangle overlap alone lets them
|
|
// stack about twice as densely as shipped.
|
|
assert.equal(entry.stateless, true);
|
|
assert.equal(entry.minAnchorSeparationPx, CCTV_CARD_MIN_SEP_PX);
|
|
});
|
|
|
|
test('active CCTV thumbnail is protected while ambient and pinned policies stay distinct', () => {
|
|
const base = { id: 'cam-a', position: { x: 1, y: 2, z: 3 }, title: 'A', frameSlot: createFrameSlot() };
|
|
const ambient = createCctvThumbnailOverlayEntry(base);
|
|
const pinned = createCctvThumbnailOverlayEntry({ ...base, pinned: true });
|
|
const active = createCctvThumbnailOverlayEntry({ ...base, active: true });
|
|
assert.equal(ambient.protected, false);
|
|
assert.equal(ambient.pinned, false);
|
|
assert.equal(pinned.pinned, true);
|
|
assert.equal(active.protected, true);
|
|
assert.ok(active.priority > pinned.priority && pinned.priority > ambient.priority);
|
|
});
|
|
|
|
test('CCTV card module cannot resurrect a canvas, projection, listener, or private hit store', () => {
|
|
const source = readFileSync(new URL('./cctvCards.js', import.meta.url), 'utf8');
|
|
const forbidden = [
|
|
/createElement\(\s*['"]canvas['"]\s*\)/,
|
|
/postRender\.addEventListener/,
|
|
/cartesianToCanvasCoordinates/,
|
|
/SceneTransforms/,
|
|
/hitTestCctvCard/,
|
|
/setCctvCardHitRects/,
|
|
];
|
|
for (const pattern of forbidden) assert.doesNotMatch(source, pattern);
|
|
});
|
|
|
|
// ─── cardScaleForAltitude — the owner-decided altitude curve (finding 5) ────
|
|
|
|
test('cardScaleForAltitude: full size and opacity at or below 1,800 m', () => {
|
|
assert.deepEqual(cardScaleForAltitude(0), { scale: 1, alpha: 1 });
|
|
assert.deepEqual(cardScaleForAltitude(CCTV_CARD_SCALE_FULL_M), { scale: 1, alpha: 1 });
|
|
assert.deepEqual(cardScaleForAltitude(NaN), { scale: 1, alpha: 1 });
|
|
});
|
|
|
|
test('cardScaleForAltitude: hits the owner-decided waypoints', () => {
|
|
const mid = cardScaleForAltitude(CCTV_CARD_SCALE_MID_M);
|
|
assert.ok(Math.abs(mid.scale - CCTV_CARD_SCALE_AT_MID) < 1e-9, 'scale ~0.45 at 6,000 m');
|
|
assert.equal(mid.alpha, 1);
|
|
const fadeStart = cardScaleForAltitude(CCTV_CARD_FADE_START_M);
|
|
assert.equal(fadeStart.alpha, 1, 'still opaque at 7,500 m');
|
|
assert.ok(fadeStart.scale < CCTV_CARD_SCALE_AT_MID, 'keeps shrinking slightly past 6,000 m');
|
|
const midFade = cardScaleForAltitude((CCTV_CARD_FADE_START_M + CCTV_CARD_FADE_END_M) / 2);
|
|
assert.ok(Math.abs(midFade.alpha - 0.5) < 1e-9, 'linear fade midpoint');
|
|
});
|
|
|
|
test('cardScaleForAltitude: fully hidden at and above the 9,500 m ceiling', () => {
|
|
assert.equal(cardScaleForAltitude(CCTV_CARD_FADE_END_M).alpha, 0);
|
|
assert.equal(cardScaleForAltitude(50_000).alpha, 0);
|
|
assert.equal(cardScaleForAltitude(50_000).scale, CCTV_CARD_SCALE_MIN);
|
|
});
|
|
|
|
test('cardScaleForAltitude: scale and alpha are monotonic non-increasing', () => {
|
|
let prev = cardScaleForAltitude(0);
|
|
for (let h = 100; h <= 12_000; h += 100) {
|
|
const cur = cardScaleForAltitude(h);
|
|
assert.ok(cur.scale <= prev.scale + 1e-12, `scale rises at ${h} m`);
|
|
assert.ok(cur.alpha <= prev.alpha + 1e-12, `alpha rises at ${h} m`);
|
|
assert.ok(cur.scale >= CCTV_CARD_SCALE_MIN - 1e-12);
|
|
prev = cur;
|
|
}
|
|
});
|
|
|
|
// ─── cardFetchPolicy — cold-fill burst pacing (finding 3) ───────────────────
|
|
|
|
test('cardFetchPolicy: cold fill bursts up to 4 in flight at 250 ms spacing', () => {
|
|
assert.deepEqual(
|
|
cardFetchPolicy({ coldFill: true, inFlight: 0, sinceLastLaunchMs: CCTV_CARD_FETCH_BURST_SPACING_MS }),
|
|
{ mode: 'burst', launch: true }
|
|
);
|
|
assert.deepEqual(
|
|
cardFetchPolicy({ coldFill: true, inFlight: CCTV_CARD_FETCH_BURST_LIMIT - 1, sinceLastLaunchMs: 260 }),
|
|
{ mode: 'burst', launch: true }
|
|
);
|
|
// Blocked at the concurrency cap and inside the launch spacing.
|
|
assert.equal(cardFetchPolicy({ coldFill: true, inFlight: CCTV_CARD_FETCH_BURST_LIMIT, sinceLastLaunchMs: 9_999 }).launch, false);
|
|
assert.equal(cardFetchPolicy({ coldFill: true, inFlight: 0, sinceLastLaunchMs: CCTV_CARD_FETCH_BURST_SPACING_MS - 1 }).launch, false);
|
|
});
|
|
|
|
test('cardFetchPolicy: steady state keeps the 1-fetch/s single-flight gate', () => {
|
|
assert.deepEqual(
|
|
cardFetchPolicy({ coldFill: false, inFlight: 0, sinceLastLaunchMs: CCTV_CARD_FETCH_STEADY_SPACING_MS }),
|
|
{ mode: 'steady', launch: true }
|
|
);
|
|
// An in-flight fetch blocks the tick; so does the 1 s spacing.
|
|
assert.equal(cardFetchPolicy({ coldFill: false, inFlight: 1, sinceLastLaunchMs: 9_999 }).launch, false);
|
|
assert.equal(cardFetchPolicy({ coldFill: false, inFlight: 0, sinceLastLaunchMs: 999 }).launch, false);
|
|
// First-ever launch (no prior fetch) is immediate.
|
|
assert.equal(cardFetchPolicy({ coldFill: false, inFlight: 0 }).launch, true);
|
|
});
|
|
|
|
// ─── applyFrameResult — the no-flicker persistence rule ─────────────────────
|
|
|
|
test('applyFrameResult: success replaces the frame and stamps it', () => {
|
|
const slot = createFrameSlot();
|
|
const next = applyFrameResult(slot, { ok: true, frame: 'FRAME-1' }, 5_000);
|
|
assert.deepEqual(next, { frame: 'FRAME-1', stamp: 5_000, failCount: 0, lastAttemptAt: 5_000 });
|
|
});
|
|
|
|
test('applyFrameResult: a FAILED fetch persists the drawn frame and stamp', () => {
|
|
const drawn = applyFrameResult(createFrameSlot(), { ok: true, frame: 'FRAME-1' }, 5_000);
|
|
const afterFail = applyFrameResult(drawn, { ok: false }, 65_000);
|
|
// The owner's zero-flicker rule: the card keeps rendering FRAME-1.
|
|
assert.equal(afterFail.frame, 'FRAME-1');
|
|
assert.equal(afterFail.stamp, 5_000);
|
|
assert.equal(afterFail.failCount, 1);
|
|
assert.equal(afterFail.lastAttemptAt, 65_000);
|
|
// A later success replaces it and clears the failure streak.
|
|
const recovered = applyFrameResult(afterFail, { ok: true, frame: 'FRAME-2' }, 130_000);
|
|
assert.equal(recovered.frame, 'FRAME-2');
|
|
assert.equal(recovered.stamp, 130_000);
|
|
assert.equal(recovered.failCount, 0);
|
|
});
|
|
|
|
test('applyFrameResult: an ok result without a frame counts as a failure', () => {
|
|
const drawn = applyFrameResult(createFrameSlot(), { ok: true, frame: 'FRAME-1' }, 5_000);
|
|
const next = applyFrameResult(drawn, { ok: true }, 9_000);
|
|
assert.equal(next.frame, 'FRAME-1');
|
|
assert.equal(next.failCount, 1);
|
|
});
|
|
|
|
// ─── frameRetryDelayMs / frameFetchDue ──────────────────────────────────────
|
|
|
|
test('frameRetryDelayMs: doubles per failure, capped at 5 minutes', () => {
|
|
assert.equal(frameRetryDelayMs(0), 15_000);
|
|
assert.equal(frameRetryDelayMs(1), 30_000);
|
|
assert.equal(frameRetryDelayMs(2), 60_000);
|
|
assert.equal(frameRetryDelayMs(10), 300_000);
|
|
});
|
|
|
|
test('frameFetchDue: an untouched slot is due immediately', () => {
|
|
assert.equal(frameFetchDue(createFrameSlot(), 300_000, 1_000), true);
|
|
assert.equal(frameFetchDue(null, 300_000, 1_000), false);
|
|
});
|
|
|
|
test('frameFetchDue: a fresh frame is not due until its cadence elapses', () => {
|
|
const slot = applyFrameResult(createFrameSlot(), { ok: true, frame: 'F' }, 10_000);
|
|
assert.equal(frameFetchDue(slot, 300_000, 10_000 + 299_999), false);
|
|
assert.equal(frameFetchDue(slot, 300_000, 10_000 + 300_000), true);
|
|
});
|
|
|
|
test('frameFetchDue: failures back off instead of hammering the source', () => {
|
|
let slot = applyFrameResult(createFrameSlot(), { ok: false }, 10_000);
|
|
// First retry waits the doubled delay (failCount=1 -> 30s).
|
|
assert.equal(frameFetchDue(slot, 300_000, 10_000 + 29_999), false);
|
|
assert.equal(frameFetchDue(slot, 300_000, 10_000 + 30_000), true);
|
|
// A slot with a PERSISTED frame still backs off between failed refreshes.
|
|
slot = applyFrameResult(createFrameSlot(), { ok: true, frame: 'F' }, 0);
|
|
slot = applyFrameResult(slot, { ok: false }, 300_000);
|
|
assert.equal(frameFetchDue(slot, 300_000, 300_000 + 20_000), false);
|
|
assert.equal(frameFetchDue(slot, 300_000, 300_000 + 30_000), true);
|
|
});
|
|
|
|
// ─── planFrameCachePrune ────────────────────────────────────────────────────
|
|
|
|
test('planFrameCachePrune: never drops live card ids (grace included)', () => {
|
|
const slots = [
|
|
{ id: 'live-1', stamp: 10 },
|
|
{ id: 'graced', stamp: 5 },
|
|
{ id: 'old', stamp: 1 },
|
|
];
|
|
const drops = planFrameCachePrune(slots, ['live-1', 'graced'], 2);
|
|
assert.deepEqual(drops, ['old']);
|
|
});
|
|
|
|
test('planFrameCachePrune: spare slots kept newest-first up to the cap', () => {
|
|
const slots = [
|
|
{ id: 'live', stamp: 100 },
|
|
{ id: 'spare-new', stamp: 90 },
|
|
{ id: 'spare-mid', stamp: 50 },
|
|
{ id: 'spare-old', stamp: 10 },
|
|
];
|
|
const drops = planFrameCachePrune(slots, ['live'], 3);
|
|
assert.deepEqual(drops.sort(), ['spare-old']);
|
|
assert.deepEqual(planFrameCachePrune(slots, ['live'], 1).sort(),
|
|
['spare-mid', 'spare-new', 'spare-old']);
|
|
});
|
|
|
|
test('planFrameCachePrune: default cap is the exported cache bound', () => {
|
|
const slots = Array.from({ length: CCTV_FRAME_CACHE_MAX + 5 }, (_, i) => ({
|
|
id: `cam-${String(i).padStart(3, '0')}`,
|
|
stamp: i,
|
|
}));
|
|
const drops = planFrameCachePrune(slots, []);
|
|
assert.equal(drops.length, 5);
|
|
// Oldest stamps go first.
|
|
assert.deepEqual(drops.sort(), ['cam-000', 'cam-001', 'cam-002', 'cam-003', 'cam-004']);
|
|
});
|