mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refreshCctvFocusStyles passes cameraColorFor as baseColorFor, but only refreshCoverageStyles was tested. The new rendering.test.mjs runs the focus pass through createRendering with stubbed focus services and checks that an idle video camera fades from violet, a still from cyan and the active camera from amber. It fails if the focus callback goes back to the old active/idle check. Also adds the missing semicolon on the policy.js import in cctv.test.mjs. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1839 lines
55 KiB
JavaScript
1839 lines
55 KiB
JavaScript
// src/data/cctv.test.mjs — CCTV v2 pure frustum geometry (computeFrustumGeometry).
|
||
//
|
||
// Locks the §2a math of docs/plans/2026-07-03-cctv-v2-design.md:
|
||
// - the far-cap (monitor plane) corners lie ON the plane through capCenter
|
||
// perpendicular to the frustum view axis (ε < 0.5 m) — this is the geometric
|
||
// invariant that welds the wireframe corner rays to the plane entity;
|
||
// - vFov = 2·atan(tan(hFov/2) / (16/9)) (same 16:9 derivation the projection
|
||
// frame used);
|
||
// - far-cap center + corners clamp to ≥ groundAlt + 2 m so a fabricated pitch
|
||
// (Austin's -24°) never buries the plane in the tiles (§6 risk);
|
||
// - the activation obstruction probe's range clamp (§9.1) shortens the
|
||
// effective range, never lengthens it.
|
||
//
|
||
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
|
||
// plain node:test.
|
||
import { test } from 'node:test';
|
||
// Preserve structural pins across component qualification and formatter wrapping.
|
||
function componentFunctionSource(fn) {
|
||
return fn
|
||
.toString()
|
||
.replace(/\blayerState\./g, '')
|
||
.replace(/\bparts\.\w+\./g, '')
|
||
.replace(/,\s*(?=\))/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.replace(/\(\s+/g, '(');
|
||
}
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import * as Cesium from 'cesium';
|
||
import cctvLayer, {
|
||
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
|
||
_createCctvProjectionPlaneForTest,
|
||
_extractPickedCameraIdForTest,
|
||
_updateCctvProjectionPlaneForTest,
|
||
_setCctvCoverageStateForTest,
|
||
_pushAmbientCardEntriesForTest,
|
||
_setCctvOverlayHostForTest,
|
||
activationProbeClampRange,
|
||
bindCctvWorldClickGesture,
|
||
clearProbeClampOnDeactivation,
|
||
computeFrustumGeometry,
|
||
cctvCycleIndex,
|
||
cctvEmptyClickDeselects,
|
||
cctvRecordNeedsActivation,
|
||
deactivateActiveCamera,
|
||
CCTV_FOCUS_RESULT,
|
||
FRUSTUM_GROUND_CLEARANCE_M,
|
||
CCTV_CALIBRATION_STORAGE_KEY_V2,
|
||
CCTV_CALIBRATION_STORAGE_KEY_V1,
|
||
readCalibrationStoreV2,
|
||
writeCalibrationStoreV2,
|
||
deriveCalBadge,
|
||
surfaceRegimeKey,
|
||
calibrationPatchMovesAnchor,
|
||
migrateRangeScaleForFloor,
|
||
cctvGeometryDrainPacing,
|
||
createGeometryProgressNotifier,
|
||
normalizeCoverageMode,
|
||
frameSignatureFromPixels,
|
||
focusCctvRecord,
|
||
hideCctvRecordVisuals,
|
||
materializeCctvActiveCoverageEntities,
|
||
materializeCctvVisibleCoverageEntities,
|
||
maybeAutoHop,
|
||
prioritizeActiveCctvGeometryRecord,
|
||
processCctvGeometryDrainBatch,
|
||
processCctvGeometryQueueBatch,
|
||
processGeometryBatch,
|
||
refreshCoverageStyles,
|
||
setCctvCardPresentationOptions,
|
||
setActiveCamera,
|
||
} from './cctv.js';
|
||
import {
|
||
CCTV_ACTIVATION_RESULT,
|
||
CCTV_FOCUS_REQUEST_EVENT,
|
||
activateCctvCameraFromWorldClick,
|
||
} from '../cctvFocusRequest.js';
|
||
import {
|
||
ACTIVE_CAMERA_COLOR,
|
||
IDLE_CAMERA_COLOR,
|
||
LIVE_CAMERA_COLOR,
|
||
} from '../layers/cctv/policy.js';
|
||
|
||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||
path.join(
|
||
path.dirname(fileURLToPath(import.meta.url)),
|
||
'..',
|
||
'ui',
|
||
'cctvPresentation.js',
|
||
),
|
||
'utf8',
|
||
);
|
||
|
||
const ASPECT = 16 / 9;
|
||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||
const GESTURE_TYPES = {
|
||
LEFT_DOWN: 'left-down',
|
||
MOUSE_MOVE: 'mouse-move',
|
||
LEFT_UP: 'left-up',
|
||
LEFT_CLICK: 'left-click',
|
||
};
|
||
|
||
function makeGestureHandler() {
|
||
const actions = new Map();
|
||
return {
|
||
setInputAction(callback, type) {
|
||
actions.set(type, callback);
|
||
},
|
||
fire(type, event) {
|
||
actions.get(type)?.(event);
|
||
},
|
||
};
|
||
}
|
||
// Same spherical earth radius projectPoint uses (R = 6371 km) so the
|
||
// lat/lon→metres conversion in the assertions matches the module's math.
|
||
const M_PER_DEG = (Math.PI / 180) * 6371000;
|
||
|
||
/** Local ENU metres of point b relative to point a ({lat, lon, alt}). */
|
||
function enu(a, b) {
|
||
return {
|
||
e: (b.lon - a.lon) * M_PER_DEG * Math.cos(toRad(a.lat)),
|
||
n: (b.lat - a.lat) * M_PER_DEG,
|
||
u: b.alt - a.alt,
|
||
};
|
||
}
|
||
|
||
function dot(v, w) {
|
||
return v.e * w.e + v.n * w.n + v.u * w.u;
|
||
}
|
||
|
||
function sub(v, w) {
|
||
return { e: v.e - w.e, n: v.n - w.n, u: v.u - w.u };
|
||
}
|
||
|
||
function mag(v) {
|
||
return Math.hypot(v.e, v.n, v.u);
|
||
}
|
||
|
||
/** Unit view-axis direction for a heading/pitch pose, in ENU. */
|
||
function viewDir(headingDeg, pitchDeg) {
|
||
const h = toRad(headingDeg);
|
||
const p = toRad(pitchDeg);
|
||
return {
|
||
e: Math.sin(h) * Math.cos(p),
|
||
n: Math.cos(h) * Math.cos(p),
|
||
u: Math.sin(p),
|
||
};
|
||
}
|
||
|
||
// A pose whose far cap sits well above ground (no clamping) so the raw plane
|
||
// math is observable: tall mount, shallow pitch.
|
||
const UNCLAMPED_CAMERA = {
|
||
lat: 30.2672,
|
||
lon: -97.7431,
|
||
headingDeg: 41,
|
||
pitchDeg: -5,
|
||
fovDeg: 56,
|
||
rangeM: 210,
|
||
mountHeightM: 100,
|
||
};
|
||
const UNCLAMPED_GROUND = 0;
|
||
|
||
// Austin's fabricated prior personality (design §1a): pitch -24° at 210 m puts
|
||
// the unclamped cap ~85 m below the mount — underground vs groundAlt 150.
|
||
const AUSTIN_FABRICATED_CAMERA = {
|
||
lat: 30.2672,
|
||
lon: -97.7431,
|
||
headingDeg: 41,
|
||
pitchDeg: -24,
|
||
fovDeg: 56,
|
||
rangeM: 210,
|
||
mountHeightM: 10,
|
||
};
|
||
const AUSTIN_GROUND = 150;
|
||
|
||
test('geometry drain coalesces 40 progress ticks and always publishes the final state', () => {
|
||
let nowMs = 0;
|
||
const state = { loaded: 0, total: 40, loading: true };
|
||
const coalesced = [];
|
||
const unthrottled = [];
|
||
let progressInvocations = 0;
|
||
const notifier = createGeometryProgressNotifier(
|
||
() => coalesced.push({ ...state }),
|
||
{ now: () => nowMs, intervalMs: 300, batchLimit: 10 },
|
||
);
|
||
|
||
const queue = Array.from({ length: state.total }, (_, index) => index + 1);
|
||
while (queue.length) {
|
||
processCctvGeometryQueueBatch({
|
||
queue,
|
||
batchSize: 1,
|
||
visit: (loaded) => {
|
||
state.loaded = loaded;
|
||
nowMs += 35;
|
||
unthrottled.push({ ...state });
|
||
},
|
||
progress: () => notifier.progress(),
|
||
complete: () => {
|
||
progressInvocations = coalesced.length;
|
||
state.loading = false;
|
||
notifier.finish();
|
||
},
|
||
});
|
||
}
|
||
|
||
assert.ok(
|
||
progressInvocations >= 4 && progressInvocations <= 6,
|
||
`expected about 4-6 progress callbacks, got ${progressInvocations}`,
|
||
);
|
||
assert.notEqual(progressInvocations, 40);
|
||
|
||
assert.equal(
|
||
coalesced.length,
|
||
progressInvocations + 1,
|
||
'queue completion must add one unconditional final notification',
|
||
);
|
||
assert.deepEqual(coalesced.at(-1), { ...unthrottled.at(-1), loading: false });
|
||
|
||
const productionDrain = processGeometryBatch.toString();
|
||
assert.match(productionDrain, /processCctvGeometryDrainBatch/);
|
||
assert.match(productionDrain, /_geoProgressNotifier\?\.progress\(\)/);
|
||
assert.match(productionDrain, /_geoProgressNotifier\?\.finish\(\)/);
|
||
assert.doesNotMatch(
|
||
productionDrain,
|
||
/if \(_geoLoading\) notifyListeners\(\)/,
|
||
);
|
||
});
|
||
|
||
test('lazy coverage inserts nothing at catalog init, then materializes only eligible records', () => {
|
||
const records = Array.from({ length: 100 }, (_, index) => ({
|
||
camera: { id: `cam-${index}` },
|
||
coverageEntities: [],
|
||
}));
|
||
let insertCount = 0;
|
||
const build = (record) =>
|
||
Array.from({ length: 5 }, (_, entityIndex) => {
|
||
insertCount += 1;
|
||
return { id: `${record.camera.id}-${entityIndex}`, show: false };
|
||
});
|
||
|
||
assert.equal(
|
||
insertCount,
|
||
0,
|
||
'building a 100-camera catalog must insert zero coverage entities',
|
||
);
|
||
assert.doesNotMatch(
|
||
componentFunctionSource(cctvLayer.init),
|
||
/buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/,
|
||
);
|
||
|
||
const activated = materializeCctvActiveCoverageEntities(records[42], build);
|
||
assert.equal(activated.length, 5);
|
||
assert.equal(insertCount, 5, 'activation builds only the active camera set');
|
||
assert.equal(
|
||
records.filter((record) => record.coverageEntities.length > 0).length,
|
||
1,
|
||
);
|
||
assert.match(
|
||
setActiveCamera.toString(),
|
||
/ensureActiveCoverageEntities\(record\)/,
|
||
);
|
||
|
||
const visibleIds = new Set([
|
||
...records.slice(0, 14).map((record) => record.camera.id),
|
||
records[42].camera.id,
|
||
]);
|
||
const coverageOn = materializeCctvVisibleCoverageEntities(
|
||
records,
|
||
visibleIds,
|
||
build,
|
||
);
|
||
assert.equal(coverageOn.length, 14 * 5);
|
||
assert.equal(
|
||
insertCount,
|
||
15 * 5,
|
||
'COVERAGE ON builds only remaining visible/eligible sets',
|
||
);
|
||
assert.equal(
|
||
records.filter((record) => record.coverageEntities.length > 0).length,
|
||
15,
|
||
);
|
||
assert.match(
|
||
componentFunctionSource(refreshCoverageStyles),
|
||
/ensureVisibleCoverageEntities\(_records, coverageVisible\)/,
|
||
);
|
||
assert.equal(
|
||
materializeCctvVisibleCoverageEntities(records, visibleIds, build).length,
|
||
0,
|
||
'materialization is idempotent',
|
||
);
|
||
});
|
||
|
||
test('default COVERAGE refresh materializes the active and visible camera frustums', () => {
|
||
const records = Array.from({ length: 20 }, (_, index) => ({
|
||
camera: {
|
||
...UNCLAMPED_CAMERA,
|
||
id: `cam-${index}`,
|
||
lon: UNCLAMPED_CAMERA.lon + index * 0.002,
|
||
groundElevationM: 0,
|
||
},
|
||
coverageEntities: [],
|
||
projection: null,
|
||
}));
|
||
const inserted = [];
|
||
const viewer = {
|
||
entities: {
|
||
add(entity) {
|
||
inserted.push(entity);
|
||
return entity;
|
||
},
|
||
},
|
||
};
|
||
const activeRecord = records.at(-1);
|
||
|
||
try {
|
||
_setCctvCoverageStateForTest({
|
||
viewer,
|
||
records,
|
||
activeCameraId: activeRecord.camera.id,
|
||
});
|
||
refreshCoverageStyles();
|
||
|
||
const materialized = records.filter(
|
||
(record) => record.coverageEntities.length > 0,
|
||
);
|
||
assert.equal(
|
||
inserted.length,
|
||
14 * 5,
|
||
'default COVERAGE ON builds the 14-camera visible cohort',
|
||
);
|
||
assert.equal(materialized.length, 14);
|
||
assert.equal(
|
||
activeRecord.coverageEntities.length,
|
||
5,
|
||
'the enable-time active camera is materialized',
|
||
);
|
||
assert.ok(
|
||
materialized.some((record) => record !== activeRecord),
|
||
'visible neighbors are materialized alongside the active camera',
|
||
);
|
||
|
||
const projectionOnly = {
|
||
...records[0],
|
||
camera: { ...records[0].camera, id: 'projection-only' },
|
||
coverageEntities: [],
|
||
projection: { planeEntity: { show: false } },
|
||
};
|
||
_setCctvCoverageStateForTest({
|
||
viewer,
|
||
records: [projectionOnly],
|
||
activeCameraId: projectionOnly.camera.id,
|
||
coverageMode: 'off',
|
||
showProjection: true,
|
||
});
|
||
refreshCoverageStyles();
|
||
assert.equal(projectionOnly.coverageEntities.length, 5);
|
||
assert.ok(
|
||
projectionOnly.coverageEntities.every((entity) => entity.show),
|
||
'COVERAGE OFF keeps the active projection frustum materialized and visible',
|
||
);
|
||
} finally {
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('camera icons are violet for video feeds, cyan for stills, amber when active', () => {
|
||
const record = (id, feedType) => ({
|
||
camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 },
|
||
billboard: {},
|
||
coverageEntities: [],
|
||
projection: null,
|
||
});
|
||
const idleVideo = record('idle-hls', 'hls');
|
||
const idleStill = record('idle-image', 'image');
|
||
const activeVideo = record('active-hls', 'hls');
|
||
const viewer = { entities: { add: (entity) => entity } };
|
||
|
||
try {
|
||
// Disabled keeps the active video camera from opening a real projection;
|
||
// icon colors are assigned either way.
|
||
_setCctvCoverageStateForTest({
|
||
viewer,
|
||
records: [idleVideo, idleStill, activeVideo],
|
||
activeCameraId: activeVideo.camera.id,
|
||
enabled: false,
|
||
coverageMode: 'off',
|
||
});
|
||
refreshCoverageStyles();
|
||
assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR);
|
||
assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR);
|
||
assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR);
|
||
} finally {
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('geometry drain pacing yields to tracked and cockpit camera ownership', () => {
|
||
assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 });
|
||
assert.deepEqual(
|
||
cctvGeometryDrainPacing({ trackedEntity: { id: 'flight-1' } }),
|
||
{ batchSize: 2, delayMs: 250 },
|
||
);
|
||
assert.deepEqual(cctvGeometryDrainPacing({ cockpitActive: true }), {
|
||
batchSize: 2,
|
||
delayMs: 250,
|
||
});
|
||
|
||
const active = { id: 'active' };
|
||
const queue = [{ id: 'near' }, { id: 'far' }, active];
|
||
assert.equal(prioritizeActiveCctvGeometryRecord(queue, active), true);
|
||
assert.equal(queue[0], active);
|
||
});
|
||
|
||
test('geometry drain rechecks pacing when tracking releases between batches', () => {
|
||
let trackedEntity = { id: 'flight-1' };
|
||
const queue = Array.from({ length: 10 }, (_, index) => index + 1);
|
||
const visited = [];
|
||
const runBatch = () =>
|
||
processCctvGeometryDrainBatch({
|
||
queue,
|
||
readOwnership: () => ({ trackedEntity, cockpitActive: false }),
|
||
visit: (record) => visited.push(record),
|
||
progress: () => {},
|
||
complete: () => {},
|
||
});
|
||
|
||
const trackedBatch = runBatch();
|
||
assert.deepEqual(trackedBatch, { hasMore: true, batchSize: 2, delayMs: 250 });
|
||
assert.deepEqual(visited, [1, 2]);
|
||
|
||
trackedEntity = null;
|
||
const untrackedBatch = runBatch();
|
||
assert.deepEqual(untrackedBatch, {
|
||
hasMore: true,
|
||
batchSize: 4,
|
||
delayMs: 120,
|
||
});
|
||
assert.deepEqual(visited, [1, 2, 3, 4, 5, 6]);
|
||
|
||
assert.match(
|
||
processGeometryBatch.toString(),
|
||
/processCctvGeometryDrainBatch/,
|
||
);
|
||
});
|
||
|
||
test('vFov formula: vFov = 2·atan(tan(hFov/2) / (16/9)); halfW/halfH follow', () => {
|
||
const g = computeFrustumGeometry(UNCLAMPED_CAMERA, UNCLAMPED_GROUND);
|
||
const expectedVFovRad = 2 * Math.atan(Math.tan(toRad(56) / 2) / ASPECT);
|
||
assert.ok(
|
||
Math.abs(toRad(g.vFovDeg) - expectedVFovRad) < 1e-9,
|
||
`vFovDeg=${g.vFovDeg}`,
|
||
);
|
||
assert.ok(
|
||
Math.abs(g.halfW - 210 * Math.tan(toRad(56) / 2)) < 1e-6,
|
||
`halfW=${g.halfW}`,
|
||
);
|
||
assert.ok(
|
||
Math.abs(g.halfH - 210 * Math.tan(expectedVFovRad / 2)) < 1e-6,
|
||
`halfH=${g.halfH}`,
|
||
);
|
||
});
|
||
|
||
test('mount altitude = groundAlt + mountHeightM', () => {
|
||
const g = computeFrustumGeometry(UNCLAMPED_CAMERA, UNCLAMPED_GROUND);
|
||
assert.equal(g.mount.alt, 100);
|
||
const g2 = computeFrustumGeometry(AUSTIN_FABRICATED_CAMERA, AUSTIN_GROUND);
|
||
assert.equal(g2.mount.alt, 160);
|
||
});
|
||
|
||
test('cap center sits rangeM along the view axis from the mount (ε < 0.5 m)', () => {
|
||
const g = computeFrustumGeometry(UNCLAMPED_CAMERA, UNCLAMPED_GROUND);
|
||
const d = viewDir(41, -5);
|
||
const cap = enu(g.mount, g.capCenter);
|
||
const expected = { e: d.e * 210, n: d.n * 210, u: d.u * 210 };
|
||
assert.ok(
|
||
mag(sub(cap, expected)) < 0.5,
|
||
`cap offset error ${mag(sub(cap, expected))} m`,
|
||
);
|
||
});
|
||
|
||
test('corner/plane coincidence: all 4 corners lie on the far-cap plane (ε < 0.5 m)', () => {
|
||
const g = computeFrustumGeometry(UNCLAMPED_CAMERA, UNCLAMPED_GROUND);
|
||
const d = viewDir(41, -5);
|
||
const capEnu = enu(g.mount, g.capCenter);
|
||
for (const key of ['tl', 'tr', 'br', 'bl']) {
|
||
const cornerEnu = enu(g.mount, g.corners[key]);
|
||
const offAxis = Math.abs(dot(sub(cornerEnu, capEnu), d));
|
||
assert.ok(
|
||
offAxis < 0.5,
|
||
`${key} is ${offAxis.toFixed(3)} m off the cap plane`,
|
||
);
|
||
// Each corner is the half-diagonal away from the cap center.
|
||
const diag = Math.hypot(g.halfW, g.halfH);
|
||
const dist = mag(sub(cornerEnu, capEnu));
|
||
assert.ok(
|
||
Math.abs(dist - diag) < 0.5,
|
||
`${key} corner-to-center ${dist} vs diag ${diag}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('cap rectangle spans 2·halfW × 2·halfH (ε < 0.5 m)', () => {
|
||
const g = computeFrustumGeometry(UNCLAMPED_CAMERA, UNCLAMPED_GROUND);
|
||
const m = g.mount;
|
||
const width = mag(sub(enu(m, g.corners.tr), enu(m, g.corners.tl)));
|
||
const height = mag(sub(enu(m, g.corners.tl), enu(m, g.corners.bl)));
|
||
assert.ok(
|
||
Math.abs(width - 2 * g.halfW) < 0.5,
|
||
`top width ${width} vs ${2 * g.halfW}`,
|
||
);
|
||
assert.ok(
|
||
Math.abs(height - 2 * g.halfH) < 0.5,
|
||
`left height ${height} vs ${2 * g.halfH}`,
|
||
);
|
||
// Bottom edge too — the rectangle is a rectangle, not a trapezoid.
|
||
const widthBottom = mag(sub(enu(m, g.corners.br), enu(m, g.corners.bl)));
|
||
assert.ok(
|
||
Math.abs(widthBottom - 2 * g.halfW) < 0.5,
|
||
`bottom width ${widthBottom}`,
|
||
);
|
||
});
|
||
|
||
test('unclamped pose: corners keep their true plane altitudes (no clamp applied)', () => {
|
||
const g = computeFrustumGeometry(UNCLAMPED_CAMERA, UNCLAMPED_GROUND);
|
||
const floor = UNCLAMPED_GROUND + FRUSTUM_GROUND_CLEARANCE_M;
|
||
for (const key of ['tl', 'tr', 'br', 'bl']) {
|
||
assert.ok(
|
||
g.corners[key].alt > floor + 1,
|
||
`${key} alt ${g.corners[key].alt}`,
|
||
);
|
||
}
|
||
assert.ok(
|
||
g.corners.tl.alt > g.corners.bl.alt,
|
||
'top corners sit above bottom corners',
|
||
);
|
||
});
|
||
|
||
test('ground clearance lifts the whole rectangle rigidly until its lowest support clears the floor', () => {
|
||
const g = computeFrustumGeometry(AUSTIN_FABRICATED_CAMERA, AUSTIN_GROUND);
|
||
const floor = AUSTIN_GROUND + FRUSTUM_GROUND_CLEARANCE_M;
|
||
const upVert = Math.cos(toRad(-24)) * g.halfH;
|
||
// The bottom row is the limiting support on flat ground: it lands exactly on
|
||
// the floor and the rest of the rectangle rides up with it, rigidly.
|
||
assert.ok(g.liftM > 0, 'a downward pitch needs a lift');
|
||
assert.equal(
|
||
g.liftLimitedBy[0],
|
||
'b',
|
||
`limited by a bottom support, got ${g.liftLimitedBy}`,
|
||
);
|
||
for (const key of ['bl', 'br']) {
|
||
assert.ok(
|
||
Math.abs(g.corners[key].alt - floor) < 1e-6,
|
||
`${key} alt ${g.corners[key].alt} vs floor ${floor}`,
|
||
);
|
||
}
|
||
assert.ok(
|
||
Math.abs(g.capCenter.alt - (floor + upVert)) < 1e-6,
|
||
'cap center sits one half-height above the floor',
|
||
);
|
||
for (const key of ['tl', 'tr']) {
|
||
assert.ok(
|
||
Math.abs(g.corners[key].alt - (floor + 2 * upVert)) < 1e-6,
|
||
`${key} alt ${g.corners[key].alt}`,
|
||
);
|
||
}
|
||
// Nothing on the rectangle is below the floor any more.
|
||
for (const key of ['tl', 'tr', 'bl', 'br'])
|
||
assert.ok(g.corners[key].alt >= floor - 1e-6, key);
|
||
});
|
||
|
||
test('measured ground under a far support raises the plane further, still rigidly', () => {
|
||
const flat = computeFrustumGeometry(AUSTIN_FABRICATED_CAMERA, AUSTIN_GROUND);
|
||
const rise = 30;
|
||
const lifted = computeFrustumGeometry(
|
||
AUSTIN_FABRICATED_CAMERA,
|
||
AUSTIN_GROUND,
|
||
null,
|
||
{
|
||
br: AUSTIN_GROUND + rise,
|
||
},
|
||
);
|
||
assert.ok(
|
||
Math.abs(lifted.liftM - (flat.liftM + rise)) < 1e-6,
|
||
'lift grows by exactly the rise',
|
||
);
|
||
assert.equal(lifted.liftLimitedBy, 'br');
|
||
assert.equal(lifted.footprintMeasured, true);
|
||
for (const key of ['tl', 'tr', 'bl', 'br']) {
|
||
assert.ok(
|
||
Math.abs(lifted.corners[key].alt - flat.corners[key].alt - rise) < 1e-6,
|
||
key,
|
||
);
|
||
}
|
||
assert.ok(
|
||
Math.abs(lifted.mount.alt - flat.mount.alt) < 1e-6,
|
||
'the mount does not move',
|
||
);
|
||
});
|
||
|
||
test('clamped pose keeps corner/plane coincidence and the rigid 2·halfW × 2·halfH span', () => {
|
||
// The wireframe corner rays must terminate exactly on the monitor plane's
|
||
// corners AT THE DEFAULT AUSTIN POSE — this is the case that diverged by
|
||
// ~47.5 m under per-corner clamping (owner field test 2026-07-04).
|
||
const g = computeFrustumGeometry(AUSTIN_FABRICATED_CAMERA, AUSTIN_GROUND);
|
||
const d = viewDir(41, -24);
|
||
const capEnu = enu(g.mount, g.capCenter);
|
||
const diag = Math.hypot(g.halfW, g.halfH);
|
||
for (const key of ['tl', 'tr', 'br', 'bl']) {
|
||
const cornerEnu = enu(g.mount, g.corners[key]);
|
||
const offAxis = Math.abs(dot(sub(cornerEnu, capEnu), d));
|
||
assert.ok(
|
||
offAxis < 0.5,
|
||
`${key} is ${offAxis.toFixed(3)} m off the cap plane`,
|
||
);
|
||
const dist = mag(sub(cornerEnu, capEnu));
|
||
assert.ok(
|
||
Math.abs(dist - diag) < 0.5,
|
||
`${key} corner-to-center ${dist} vs diag ${diag}`,
|
||
);
|
||
}
|
||
const width = mag(
|
||
sub(enu(g.mount, g.corners.tr), enu(g.mount, g.corners.tl)),
|
||
);
|
||
const height = mag(
|
||
sub(enu(g.mount, g.corners.tl), enu(g.mount, g.corners.bl)),
|
||
);
|
||
assert.ok(
|
||
Math.abs(width - 2 * g.halfW) < 0.5,
|
||
`width ${width} vs ${2 * g.halfW}`,
|
||
);
|
||
assert.ok(
|
||
Math.abs(height - 2 * g.halfH) < 0.5,
|
||
`height ${height} vs ${2 * g.halfH}`,
|
||
);
|
||
});
|
||
|
||
test('range override (activation probe clamp) shortens but never lengthens the range', () => {
|
||
const clamped = computeFrustumGeometry(
|
||
UNCLAMPED_CAMERA,
|
||
UNCLAMPED_GROUND,
|
||
100,
|
||
);
|
||
assert.equal(clamped.rangeM, 100);
|
||
// halfW scales with the effective range.
|
||
assert.ok(Math.abs(clamped.halfW - 100 * Math.tan(toRad(56) / 2)) < 1e-6);
|
||
const longer = computeFrustumGeometry(
|
||
UNCLAMPED_CAMERA,
|
||
UNCLAMPED_GROUND,
|
||
5000,
|
||
);
|
||
assert.equal(
|
||
longer.rangeM,
|
||
210,
|
||
'an override beyond the pose range is ignored',
|
||
);
|
||
const none = computeFrustumGeometry(UNCLAMPED_CAMERA, UNCLAMPED_GROUND, null);
|
||
assert.equal(none.rangeM, 210);
|
||
});
|
||
|
||
test('activation obstruction clamp uses the field-derived 12 m floor', () => {
|
||
assert.equal(activationProbeClampRange(210, 8), 12);
|
||
assert.equal(activationProbeClampRange(210, 150), 146);
|
||
assert.equal(activationProbeClampRange(210, 210), null);
|
||
assert.equal(activationProbeClampRange(210, 250), null);
|
||
});
|
||
|
||
test('deactivation clears the probe clamp and restores nominal frustum geometry', () => {
|
||
const record = {
|
||
camera: { ...UNCLAMPED_CAMERA },
|
||
probeClampRangeM: 105,
|
||
};
|
||
let rewrittenRange = null;
|
||
|
||
assert.equal(
|
||
clearProbeClampOnDeactivation(record, (deactivated) => {
|
||
rewrittenRange = computeFrustumGeometry(
|
||
deactivated.camera,
|
||
UNCLAMPED_GROUND,
|
||
deactivated.probeClampRangeM,
|
||
).rangeM;
|
||
}),
|
||
true,
|
||
);
|
||
assert.equal(record.probeClampRangeM, null);
|
||
assert.equal(rewrittenRange, 210);
|
||
});
|
||
|
||
test('CCTV disable hide sweep hides record visuals and destroys viewsheds without restyling', () => {
|
||
const viewshedA = { id: 'viewshed-a' };
|
||
const viewshedB = { id: 'viewshed-b' };
|
||
const records = [
|
||
{
|
||
camera: { id: 'a' },
|
||
activationDone: true,
|
||
probeClampRangeM: 105,
|
||
billboard: { color: 'keep-color', scale: 1.25 },
|
||
coverageEntities: [{ show: true }, { show: true }],
|
||
projection: { planeEntity: { show: true } },
|
||
viewshedPrimitive: viewshedA,
|
||
},
|
||
{
|
||
camera: { id: 'b' },
|
||
activationDone: true,
|
||
probeClampRangeM: 146,
|
||
billboard: { color: 'keep-color', scale: 1 },
|
||
projection: null,
|
||
viewshedPrimitive: viewshedB,
|
||
},
|
||
];
|
||
const destroyed = [];
|
||
|
||
hideCctvRecordVisuals(
|
||
records,
|
||
(record) => {
|
||
destroyed.push(record.camera.id);
|
||
record.viewshedPrimitive = null;
|
||
},
|
||
'a',
|
||
);
|
||
|
||
assert.deepEqual(
|
||
records.flatMap((record) =>
|
||
(record.coverageEntities || []).map((entity) => entity.show),
|
||
),
|
||
[false, false],
|
||
);
|
||
assert.equal(records[0].projection.planeEntity.show, false);
|
||
assert.deepEqual(destroyed, ['a', 'b']);
|
||
assert.equal(records[0].viewshedPrimitive, null);
|
||
assert.equal(records[1].viewshedPrimitive, null);
|
||
assert.deepEqual(
|
||
records.map((record) => record.probeClampRangeM),
|
||
[null, null],
|
||
);
|
||
assert.deepEqual(
|
||
records.map((record) => record.activationDone),
|
||
[false, true],
|
||
);
|
||
assert.deepEqual(
|
||
records.map((record) => record.billboard),
|
||
[
|
||
{ color: 'keep-color', scale: 1.25 },
|
||
{ color: 'keep-color', scale: 1 },
|
||
],
|
||
);
|
||
});
|
||
|
||
test('real active monitor plane owns one protected host label and no native label entity', () => {
|
||
const calls = [];
|
||
const overlayHost = {
|
||
setEntries: (...args) => calls.push(['entries', ...args]),
|
||
setVisible: (...args) => calls.push(['visible', ...args]),
|
||
clearSource: (...args) => calls.push(['clear', ...args]),
|
||
};
|
||
const viewer = { entities: new Cesium.EntityCollection() };
|
||
const record = {
|
||
camera: {
|
||
...UNCLAMPED_CAMERA,
|
||
id: 'runtime-projection',
|
||
name: 'Congress & 6th Monitor',
|
||
groundElevationM: UNCLAMPED_GROUND,
|
||
},
|
||
coverageEntities: [],
|
||
projection: null,
|
||
};
|
||
_setCctvOverlayHostForTest(overlayHost);
|
||
try {
|
||
const runtime = _createCctvProjectionPlaneForTest(viewer, record);
|
||
_setCctvCoverageStateForTest({
|
||
viewer,
|
||
records: [record],
|
||
activeCameraId: record.camera.id,
|
||
enabled: true,
|
||
coverageMode: 'off',
|
||
showProjection: true,
|
||
});
|
||
refreshCoverageStyles();
|
||
|
||
assert.ok(
|
||
runtime.planeEntity,
|
||
'runtime guard requires a real monitor-plane entity',
|
||
);
|
||
assert.equal(runtime.planeEntity.label, undefined);
|
||
assert.ok(
|
||
runtime.planeEntity.plane,
|
||
'monitor plane geometry remains native',
|
||
);
|
||
assert.ok(
|
||
viewer.entities.values.every((entity) => entity.label === undefined),
|
||
);
|
||
const publication = calls.find(
|
||
([type, sourceId]) =>
|
||
type === 'entries' && sourceId === 'cctv-projection',
|
||
);
|
||
assert.ok(
|
||
publication,
|
||
'active projection must publish its associated host label',
|
||
);
|
||
assert.deepEqual(publication[3], CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS);
|
||
const entry = publication[2][0];
|
||
assert.equal(entry.title, 'Congress & 6th Monitor');
|
||
assert.equal(entry.position(), runtime.labelPosition);
|
||
assert.equal(entry.protected, true);
|
||
assert.equal(entry.paintLane, 'selected');
|
||
assert.equal(runtime.planeEntity.show, true);
|
||
|
||
const cachedPosition = runtime.labelPosition;
|
||
record.frustumPositions.label = Cesium.Cartesian3.add(
|
||
record.frustumPositions.label,
|
||
new Cesium.Cartesian3(3, -2, 1),
|
||
new Cesium.Cartesian3(),
|
||
);
|
||
_updateCctvProjectionPlaneForTest(record);
|
||
assert.equal(
|
||
entry.position(),
|
||
cachedPosition,
|
||
'host getter retains the authoritative cache',
|
||
);
|
||
assert.ok(
|
||
Cesium.Cartesian3.equals(cachedPosition, record.frustumPositions.label),
|
||
'plane geometry updates rewrite that shared host-label cache',
|
||
);
|
||
|
||
_setCctvCoverageStateForTest({
|
||
viewer,
|
||
records: [record],
|
||
activeCameraId: null,
|
||
enabled: false,
|
||
coverageMode: 'off',
|
||
showProjection: false,
|
||
});
|
||
refreshCoverageStyles();
|
||
assert.equal(runtime.planeEntity.show, false);
|
||
assert.deepEqual(calls.slice(-2), [
|
||
['clear', 'cctv-projection'],
|
||
['visible', 'cctv-projection', false],
|
||
]);
|
||
} finally {
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
_setCctvOverlayHostForTest();
|
||
}
|
||
});
|
||
|
||
test('CCTV disable→enable defers the active-camera re-probe until its next activation', () => {
|
||
const record = {
|
||
camera: { id: 'active', rangeM: 210 },
|
||
activationDone: true,
|
||
probeClampRangeM: 105,
|
||
coverageEntities: [],
|
||
};
|
||
let probeCalls = 0;
|
||
const probe = () => {
|
||
probeCalls += 1;
|
||
record.probeClampRangeM = activationProbeClampRange(
|
||
record.camera.rangeM,
|
||
150,
|
||
);
|
||
};
|
||
const setActiveCamera = () => {
|
||
if (!cctvRecordNeedsActivation('active', 'active', record)) return;
|
||
probe();
|
||
record.activationDone = true;
|
||
};
|
||
|
||
hideCctvRecordVisuals([record], () => {}, 'active');
|
||
assert.equal(record.probeClampRangeM, null);
|
||
assert.equal(record.activationDone, false);
|
||
assert.equal(cctvRecordNeedsActivation('active', 'active', record), true);
|
||
assert.doesNotMatch(
|
||
componentFunctionSource(cctvLayer.enable),
|
||
/setActiveCamera|runActivationObstructionProbe|pickFromRay/,
|
||
'enable must restore nominal visuals without entering the activation probe path',
|
||
);
|
||
assert.equal(probeCalls, 0, 'enable performs zero obstruction probes');
|
||
|
||
setActiveCamera();
|
||
|
||
assert.equal(probeCalls, 1);
|
||
assert.equal(record.probeClampRangeM, 146);
|
||
assert.equal(cctvRecordNeedsActivation('active', 'active', record), false);
|
||
|
||
setActiveCamera();
|
||
assert.equal(
|
||
probeCalls,
|
||
1,
|
||
're-selecting the activated camera remains a no-op',
|
||
);
|
||
});
|
||
|
||
test('CCTV focus distinguishes tracking ownership from a missing active camera', () => {
|
||
let flyCalls = 0;
|
||
const viewer = {
|
||
trackedEntity: { id: 'tracked-flight' },
|
||
camera: {
|
||
flyToBoundingSphere() {
|
||
flyCalls += 1;
|
||
},
|
||
},
|
||
};
|
||
const record = {
|
||
camera: { rangeM: 210, headingDeg: 41 },
|
||
position: { x: 1, y: 2, z: 3 },
|
||
};
|
||
const originalDebug = console.debug;
|
||
console.debug = () => {};
|
||
try {
|
||
assert.equal(
|
||
focusCctvRecord(viewer, record, 1.9),
|
||
CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW,
|
||
);
|
||
} finally {
|
||
console.debug = originalDebug;
|
||
}
|
||
assert.equal(flyCalls, 0);
|
||
assert.equal(
|
||
focusCctvRecord(viewer, null, 1.9),
|
||
CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA,
|
||
);
|
||
});
|
||
|
||
test('CCTV focus reports when the camera flight starts', () => {
|
||
let flyCalls = 0;
|
||
const viewer = {
|
||
trackedEntity: null,
|
||
camera: {
|
||
flyToBoundingSphere() {
|
||
flyCalls += 1;
|
||
},
|
||
},
|
||
};
|
||
const record = {
|
||
camera: { rangeM: 210, headingDeg: 41 },
|
||
position: { x: 1, y: 2, z: 3 },
|
||
};
|
||
|
||
assert.equal(focusCctvRecord(viewer, record, 1.9), CCTV_FOCUS_RESULT.FOCUSED);
|
||
assert.equal(flyCalls, 1);
|
||
});
|
||
|
||
test('CCTV focus refuses camera flights while cockpit owns the view', () => {
|
||
const originalDocument = globalThis.document;
|
||
let flyCalls = 0;
|
||
const viewer = {
|
||
trackedEntity: null,
|
||
camera: {
|
||
flyToBoundingSphere() {
|
||
flyCalls += 1;
|
||
},
|
||
},
|
||
};
|
||
const record = {
|
||
camera: { rangeM: 210, headingDeg: 41 },
|
||
position: { x: 1, y: 2, z: 3 },
|
||
};
|
||
const originalDebug = console.debug;
|
||
console.debug = () => {};
|
||
globalThis.document = {
|
||
body: { classList: { contains: (name) => name === 'cockpit-mode' } },
|
||
};
|
||
|
||
try {
|
||
assert.equal(
|
||
focusCctvRecord(viewer, record, 1.9),
|
||
CCTV_FOCUS_RESULT.COCKPIT_ACTIVE,
|
||
);
|
||
} finally {
|
||
console.debug = originalDebug;
|
||
globalThis.document = originalDocument;
|
||
}
|
||
assert.equal(flyCalls, 0);
|
||
});
|
||
|
||
test('CCTV repeated in-world clicks dispatch focus only for the one real activation', () => {
|
||
const target = new EventTarget();
|
||
const activated = [];
|
||
const requests = [];
|
||
const results = [
|
||
CCTV_ACTIVATION_RESULT.ACTIVATED,
|
||
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||
];
|
||
target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) =>
|
||
requests.push(event.detail),
|
||
);
|
||
|
||
const activate = (cameraId) => {
|
||
activated.push(cameraId);
|
||
return results.shift();
|
||
};
|
||
assert.deepEqual(
|
||
[
|
||
activateCctvCameraFromWorldClick('atx-cam-3', activate, target),
|
||
activateCctvCameraFromWorldClick('atx-cam-3', activate, target),
|
||
activateCctvCameraFromWorldClick('atx-cam-3', activate, target),
|
||
],
|
||
[true, false, false],
|
||
);
|
||
|
||
assert.deepEqual(activated, ['atx-cam-3', 'atx-cam-3', 'atx-cam-3']);
|
||
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
||
assert.match(
|
||
componentFunctionSource(cctvLayer.init),
|
||
/bindCctvWorldClickGesture\(_clickHandler/,
|
||
);
|
||
assert.match(
|
||
componentFunctionSource(cctvLayer.init),
|
||
/_cctvOverlayHost\.hitTest/,
|
||
);
|
||
assert.match(
|
||
componentFunctionSource(cctvLayer.init),
|
||
/sourceId: CCTV_OVERLAY_SOURCE_ID/,
|
||
);
|
||
assert.match(
|
||
componentFunctionSource(cctvLayer.init),
|
||
/activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/,
|
||
);
|
||
assert.match(
|
||
componentFunctionSource(cctvLayer.init),
|
||
/activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/,
|
||
);
|
||
});
|
||
|
||
// ─── Empty-space deselection and stable null-active state ──────────────────
|
||
|
||
function makeDeselectViewer() {
|
||
return {
|
||
entities: new Cesium.EntityCollection(),
|
||
isDestroyed: () => false,
|
||
trackedEntity: { id: 'sibling-track-owner' },
|
||
camera: {
|
||
positionWC: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 4_000),
|
||
positionCartographic: Cesium.Cartographic.fromDegrees(
|
||
-97.7431,
|
||
30.2672,
|
||
4_000,
|
||
),
|
||
heading: 0.4,
|
||
pitch: -0.7,
|
||
roll: 0.1,
|
||
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||
flyToBoundingSphere() {
|
||
this.flyCalls = (this.flyCalls || 0) + 1;
|
||
},
|
||
},
|
||
scene: {
|
||
canvas: { clientWidth: 1200, clientHeight: 800 },
|
||
cartesianToCanvasCoordinates: () => undefined,
|
||
primitives: { add: (primitive) => primitive, remove: () => true },
|
||
requestRender() {},
|
||
},
|
||
};
|
||
}
|
||
|
||
function makeDeselectRecord(id, index = 0) {
|
||
return {
|
||
camera: {
|
||
...UNCLAMPED_CAMERA,
|
||
id,
|
||
name: `Camera ${id}`,
|
||
city: 'Austin',
|
||
lon: UNCLAMPED_CAMERA.lon + index * 0.002,
|
||
groundElevationM: UNCLAMPED_GROUND,
|
||
},
|
||
coverageEntities: [],
|
||
projection: null,
|
||
activationDone: true,
|
||
};
|
||
}
|
||
|
||
test('CCTV empty-click gate excludes every identified scene object, ADJUST, and null-active clicks', () => {
|
||
assert.equal(
|
||
cctvEmptyClickDeselects(null, { activeCameraId: 'cam-1' }),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
cctvEmptyClickDeselects(null, {
|
||
activeCameraId: 'cam-1',
|
||
calibrationMode: true,
|
||
}),
|
||
false,
|
||
);
|
||
assert.equal(cctvEmptyClickDeselects(null, { activeCameraId: null }), false);
|
||
assert.equal(
|
||
cctvEmptyClickDeselects(
|
||
{ id: 'registered-sibling' },
|
||
{
|
||
activeCameraId: 'cam-1',
|
||
},
|
||
),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
cctvEmptyClickDeselects(
|
||
{ id: { id: 'unregistered-local-entity' } },
|
||
{
|
||
activeCameraId: 'cam-1',
|
||
},
|
||
),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
cctvEmptyClickDeselects(
|
||
{ primitive: { id: 'unregistered-primitive' } },
|
||
{
|
||
activeCameraId: 'cam-1',
|
||
},
|
||
),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
cctvEmptyClickDeselects(
|
||
{ id: '' },
|
||
{
|
||
activeCameraId: 'cam-1',
|
||
},
|
||
),
|
||
false,
|
||
'an empty-string scene ID is still identified rather than empty space',
|
||
);
|
||
assert.equal(
|
||
cctvEmptyClickDeselects(
|
||
{ primitive: {} },
|
||
{
|
||
activeCameraId: 'cam-1',
|
||
},
|
||
),
|
||
true,
|
||
'an ID-less surface pick remains true empty space',
|
||
);
|
||
|
||
const initSource = componentFunctionSource(cctvLayer.init);
|
||
assert.ok(
|
||
initSource.indexOf('if (pickedId !== null) return') <
|
||
initSource.indexOf('_cctvOverlayHost.hitTest'),
|
||
'every identified sibling must win before an overlapping CCTV card hit test',
|
||
);
|
||
assert.match(initSource, /activeCameraId: _activeCameraId/);
|
||
assert.match(initSource, /calibrationMode: _calibrationMode/);
|
||
});
|
||
|
||
test('CCTV camera extraction requires layer ownership, not a colliding sibling ID', () => {
|
||
const billboard = { id: 'shared-id' };
|
||
const ownedCoverageEntity = {
|
||
id: 'cctv-shared-id-cap',
|
||
properties: { cctvCameraId: 'shared-id' },
|
||
};
|
||
const record = {
|
||
...makeDeselectRecord('shared-id'),
|
||
billboard,
|
||
coverageEntities: [ownedCoverageEntity],
|
||
};
|
||
_setCctvCoverageStateForTest({
|
||
records: [record],
|
||
activeCameraId: 'shared-id',
|
||
});
|
||
try {
|
||
assert.equal(
|
||
_extractPickedCameraIdForTest({ id: 'shared-id', primitive: billboard }),
|
||
'shared-id',
|
||
);
|
||
assert.equal(
|
||
_extractPickedCameraIdForTest({
|
||
id: 'shared-id',
|
||
primitive: { id: 'shared-id' },
|
||
}),
|
||
null,
|
||
'a sibling primitive with the same canonical ID cannot activate CCTV',
|
||
);
|
||
assert.equal(
|
||
_extractPickedCameraIdForTest({ id: { id: 'shared-id' } }),
|
||
null,
|
||
'a sibling Entity with the same canonical ID cannot activate CCTV',
|
||
);
|
||
assert.equal(
|
||
_extractPickedCameraIdForTest({ id: ownedCoverageEntity }),
|
||
'shared-id',
|
||
'an exact stored CCTV coverage Entity retains activation ownership',
|
||
);
|
||
assert.equal(
|
||
_extractPickedCameraIdForTest({
|
||
id: {
|
||
id: 'sibling-entity',
|
||
properties: { cctvCameraId: 'shared-id' },
|
||
},
|
||
}),
|
||
null,
|
||
'a sibling cannot impersonate CCTV by copying its property shape',
|
||
);
|
||
} finally {
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('CCTV deselect publishes one null state and leaves the complete camera pose unchanged', () => {
|
||
const viewer = makeDeselectViewer();
|
||
const record = {
|
||
...makeDeselectRecord('deselect-me'),
|
||
probeClampRangeM: 150,
|
||
};
|
||
const publications = [];
|
||
_setCctvOverlayHostForTest({
|
||
setEntries() {},
|
||
setVisible() {},
|
||
clearSource() {},
|
||
hitTest: () => null,
|
||
});
|
||
try {
|
||
const runtime = _createCctvProjectionPlaneForTest(viewer, record);
|
||
_setCctvCoverageStateForTest({
|
||
viewer,
|
||
records: [record],
|
||
activeCameraId: record.camera.id,
|
||
enabled: true,
|
||
coverageMode: 'off',
|
||
showProjection: true,
|
||
});
|
||
refreshCoverageStyles();
|
||
const unsubscribe = cctvLayer.subscribe((state) =>
|
||
publications.push(state),
|
||
);
|
||
const baselinePublications = publications.length;
|
||
const pose = {
|
||
positionWC: Cesium.Cartesian3.clone(viewer.camera.positionWC),
|
||
positionCartographic: Cesium.Cartographic.clone(
|
||
viewer.camera.positionCartographic,
|
||
),
|
||
heading: viewer.camera.heading,
|
||
pitch: viewer.camera.pitch,
|
||
roll: viewer.camera.roll,
|
||
transform: Cesium.Matrix4.clone(viewer.camera.transform),
|
||
trackedEntity: viewer.trackedEntity,
|
||
};
|
||
|
||
assert.equal(deactivateActiveCamera(), true);
|
||
assert.equal(
|
||
deactivateActiveCamera(),
|
||
false,
|
||
'repeat deselect is idempotent',
|
||
);
|
||
assert.equal(publications.length, baselinePublications + 1);
|
||
assert.equal(publications.at(-1).activeCameraId, null);
|
||
assert.equal(publications.at(-1).activeCamera, null);
|
||
assert.equal(publications.at(-1).enabled, true);
|
||
assert.equal(runtime.planeEntity.show, false);
|
||
assert.equal(record.probeClampRangeM, null);
|
||
assert.equal(record.activationDone, false);
|
||
assert.deepEqual(viewer.camera.positionWC, pose.positionWC);
|
||
assert.deepEqual(
|
||
viewer.camera.positionCartographic,
|
||
pose.positionCartographic,
|
||
);
|
||
assert.equal(viewer.camera.heading, pose.heading);
|
||
assert.equal(viewer.camera.pitch, pose.pitch);
|
||
assert.equal(viewer.camera.roll, pose.roll);
|
||
assert.deepEqual(viewer.camera.transform, pose.transform);
|
||
assert.equal(viewer.trackedEntity, pose.trackedEntity);
|
||
assert.equal(viewer.camera.flyCalls || 0, 0);
|
||
unsubscribe();
|
||
} finally {
|
||
_setCctvOverlayHostForTest();
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay honest', () => {
|
||
const viewer = makeDeselectViewer();
|
||
const records = Array.from({ length: 3 }, (_, index) =>
|
||
makeDeselectRecord(`cam-${index}`, index),
|
||
);
|
||
_setCctvOverlayHostForTest({
|
||
setEntries() {},
|
||
setVisible() {},
|
||
clearSource() {},
|
||
});
|
||
try {
|
||
_setCctvCoverageStateForTest({
|
||
viewer,
|
||
records,
|
||
activeCameraId: records[0].camera.id,
|
||
enabled: true,
|
||
coverageMode: 'on',
|
||
});
|
||
cctvLayer.setParams({ autoHop: true, autoHopSec: 8 });
|
||
assert.equal(deactivateActiveCamera(), true);
|
||
refreshCoverageStyles();
|
||
assert.equal(cctvLayer.getUIState().activeCameraId, null);
|
||
assert.ok(
|
||
records.every((record) =>
|
||
record.coverageEntities.every((entity) => !entity.show),
|
||
),
|
||
);
|
||
maybeAutoHop(1_000_000);
|
||
assert.equal(
|
||
cctvLayer.getUIState().activeCameraId,
|
||
null,
|
||
'elapsed auto-hop stays suspended',
|
||
);
|
||
|
||
assert.equal(cctvCycleIndex(-1, 1, records.length), 0);
|
||
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
|
||
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
|
||
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
|
||
|
||
const renderer = CCTV_PRESENTATION_SOURCE.match(
|
||
/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
|
||
);
|
||
assert.ok(renderer, '_renderCctvState is missing');
|
||
assert.match(
|
||
renderer[0],
|
||
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
|
||
);
|
||
assert.match(
|
||
renderer[0],
|
||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
|
||
);
|
||
} finally {
|
||
cctvLayer.setParams({ autoHop: false });
|
||
_setCctvOverlayHostForTest();
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
|
||
const calls = [];
|
||
_setCctvOverlayHostForTest({
|
||
clearSource(sourceId) {
|
||
calls.push(['clear', sourceId]);
|
||
},
|
||
setVisible(sourceId, visible) {
|
||
calls.push(['visible', sourceId, visible]);
|
||
},
|
||
});
|
||
try {
|
||
_setCctvCoverageStateForTest({ enabled: true });
|
||
cctvLayer.disable();
|
||
assert.deepEqual(calls, [
|
||
['clear', 'cctv'],
|
||
['visible', 'cctv', false],
|
||
['clear', 'cctv-projection'],
|
||
['visible', 'cctv-projection', false],
|
||
]);
|
||
} finally {
|
||
_setCctvOverlayHostForTest();
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('pristine module default publishes no active-camera card (shipped behavior needs no setter call)', () => {
|
||
// Deliberately never calls setCardPresentationOptions: this test must run
|
||
// before any test that does, so a flipped `_activeCameraCardEnabled`
|
||
// default (the P4 round-1 product inversion) fails here even though every
|
||
// explicit-option test still passes.
|
||
const publications = [];
|
||
const activeRecord = {
|
||
camera: { id: 'default-active-camera', name: 'DEFAULT ACTIVE' },
|
||
position: { x: 1, y: 2, z: 3 },
|
||
};
|
||
_setCctvOverlayHostForTest({
|
||
setEntries(sourceId, entries) {
|
||
publications.push({ sourceId, entries });
|
||
},
|
||
});
|
||
try {
|
||
_setCctvCoverageStateForTest({
|
||
records: [activeRecord],
|
||
activeCameraId: activeRecord.camera.id,
|
||
enabled: true,
|
||
});
|
||
_pushAmbientCardEntriesForTest();
|
||
assert.deepEqual(
|
||
publications.at(-1),
|
||
{ sourceId: 'cctv', entries: [] },
|
||
'active camera must not publish a host card under the untouched default',
|
||
);
|
||
} finally {
|
||
_setCctvOverlayHostForTest();
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('active CCTV camera is absent from host by default and protected only when opted in', () => {
|
||
const publications = [];
|
||
const activeRecord = {
|
||
camera: { id: 'active-camera', name: 'ACTIVE CAMERA' },
|
||
position: { x: 1, y: 2, z: 3 },
|
||
};
|
||
_setCctvOverlayHostForTest({
|
||
setEntries(sourceId, entries) {
|
||
publications.push({ sourceId, entries });
|
||
},
|
||
});
|
||
try {
|
||
_setCctvCoverageStateForTest({
|
||
records: [activeRecord],
|
||
activeCameraId: activeRecord.camera.id,
|
||
enabled: true,
|
||
});
|
||
|
||
assert.deepEqual(
|
||
cctvLayer.setCardPresentationOptions({ activeCameraCardEnabled: false }),
|
||
{ activeCameraCardEnabled: false },
|
||
);
|
||
assert.deepEqual(publications.at(-1), { sourceId: 'cctv', entries: [] });
|
||
|
||
assert.deepEqual(
|
||
cctvLayer.setCardPresentationOptions({ activeCameraCardEnabled: true }),
|
||
{ activeCameraCardEnabled: true },
|
||
);
|
||
const activeEntry = publications
|
||
.at(-1)
|
||
.entries.find(({ id }) => id === activeRecord.camera.id);
|
||
assert.ok(
|
||
activeEntry,
|
||
'opt-in republishes the active camera into the host',
|
||
);
|
||
assert.equal(activeEntry.active, true);
|
||
assert.equal(activeEntry.protected, true);
|
||
} finally {
|
||
setCctvCardPresentationOptions({ activeCameraCardEnabled: false });
|
||
_setCctvOverlayHostForTest();
|
||
_setCctvCoverageStateForTest({ enabled: false });
|
||
}
|
||
});
|
||
|
||
test('CCTV drag-then-release over a camera is inert, while a clean tap activates and dispatches', () => {
|
||
let timeMs = 0;
|
||
let activationCalls = 0;
|
||
const handler = makeGestureHandler();
|
||
const target = new EventTarget();
|
||
const requests = [];
|
||
target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) =>
|
||
requests.push(event.detail),
|
||
);
|
||
bindCctvWorldClickGesture(
|
||
handler,
|
||
() => {
|
||
activateCctvCameraFromWorldClick(
|
||
'atx-cam-3',
|
||
() => {
|
||
activationCalls += 1;
|
||
return CCTV_ACTIVATION_RESULT.ACTIVATED;
|
||
},
|
||
target,
|
||
);
|
||
},
|
||
{
|
||
now: () => timeMs,
|
||
eventTypes: GESTURE_TYPES,
|
||
},
|
||
);
|
||
|
||
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
|
||
timeMs = 20;
|
||
handler.fire(GESTURE_TYPES.MOUSE_MOVE, { endPosition: { x: 14, y: 10 } });
|
||
timeMs = 40;
|
||
handler.fire(GESTURE_TYPES.MOUSE_MOVE, { endPosition: { x: 10, y: 10 } });
|
||
timeMs = 60;
|
||
handler.fire(GESTURE_TYPES.LEFT_UP, { position: { x: 10, y: 10 } });
|
||
handler.fire(GESTURE_TYPES.LEFT_CLICK, { position: { x: 10, y: 10 } });
|
||
assert.equal(activationCalls, 0);
|
||
assert.deepEqual(requests, []);
|
||
|
||
timeMs = 100;
|
||
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
|
||
timeMs = 180;
|
||
handler.fire(GESTURE_TYPES.LEFT_UP, { position: { x: 11, y: 11 } });
|
||
handler.fire(GESTURE_TYPES.LEFT_CLICK, { position: { x: 11, y: 11 } });
|
||
assert.equal(activationCalls, 1);
|
||
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
||
});
|
||
|
||
test('CCTV auto-hop remains activation-only and never dispatches a focus request', () => {
|
||
assert.doesNotMatch(
|
||
maybeAutoHop.toString(),
|
||
/activateCctvCameraFromWorldClick|gev:cctv-request-focus|dispatchEvent/,
|
||
);
|
||
});
|
||
|
||
test('heading wrap: heading 350° produces a symmetric cap (left/right corners equidistant)', () => {
|
||
const camera = { ...UNCLAMPED_CAMERA, headingDeg: 350 };
|
||
const g = computeFrustumGeometry(camera, UNCLAMPED_GROUND);
|
||
const m = g.mount;
|
||
const dTL = mag(enu(m, g.corners.tl));
|
||
const dTR = mag(enu(m, g.corners.tr));
|
||
assert.ok(Math.abs(dTL - dTR) < 0.5, `tl ${dTL} vs tr ${dTR}`);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task 5 — calibration v2 store + CAL badge (design §3b/§3c as amended by the
|
||
// LOCKED owner decisions §9.2/§9.3: wipe-clean v2 store, panel-only badge).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Minimal in-memory localStorage stand-in for pure store-IO unit tests. */
|
||
function fakeStorage(seed = {}) {
|
||
const map = new Map(Object.entries(seed));
|
||
return {
|
||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||
setItem: (key, value) => {
|
||
map.set(key, String(value));
|
||
},
|
||
removeItem: (key) => {
|
||
map.delete(key);
|
||
},
|
||
_dump: () => Object.fromEntries(map.entries()),
|
||
};
|
||
}
|
||
|
||
test('calibration v2 round-trip: writeCalibrationStoreV2 → readCalibrationStoreV2 preserves values/source/savedAt', () => {
|
||
const storage = fakeStorage();
|
||
const savedAt = 1782800000000;
|
||
const entry = new Map([
|
||
[
|
||
'austin-42',
|
||
{
|
||
values: {
|
||
offsetNorthM: 12.5,
|
||
offsetEastM: -3.0,
|
||
headingDeg: 41.0,
|
||
pitchDeg: 4.0,
|
||
fovDeg: -8.0,
|
||
rangeScale: 1.35,
|
||
heightM: 6.0,
|
||
},
|
||
source: 'manual',
|
||
savedAt,
|
||
},
|
||
],
|
||
]);
|
||
writeCalibrationStoreV2(entry, storage);
|
||
|
||
const raw = JSON.parse(storage.getItem(CCTV_CALIBRATION_STORAGE_KEY_V2));
|
||
assert.equal(raw['austin-42'].source, 'manual');
|
||
assert.equal(raw['austin-42'].savedAt, savedAt);
|
||
assert.equal(raw['austin-42'].values.offsetNorthM, 12.5);
|
||
|
||
const restored = readCalibrationStoreV2(storage);
|
||
assert.ok(restored instanceof Map);
|
||
const cam = restored.get('austin-42');
|
||
assert.equal(cam.source, 'manual');
|
||
assert.equal(cam.savedAt, savedAt);
|
||
assert.deepEqual(cam.values, entry.get('austin-42').values);
|
||
});
|
||
|
||
test('calibration v2: removing an entry (reset) then re-writing produces an empty store', () => {
|
||
const storage = fakeStorage();
|
||
const entry = new Map([
|
||
[
|
||
'sf-market-5th',
|
||
{
|
||
values: {
|
||
offsetNorthM: 5,
|
||
offsetEastM: 0,
|
||
headingDeg: 0,
|
||
pitchDeg: 0,
|
||
fovDeg: 0,
|
||
rangeScale: 1,
|
||
heightM: 0,
|
||
},
|
||
source: 'manual',
|
||
savedAt: 1000,
|
||
},
|
||
],
|
||
]);
|
||
writeCalibrationStoreV2(entry, storage);
|
||
assert.ok(readCalibrationStoreV2(storage).has('sf-market-5th'));
|
||
|
||
// Reset removes the entry from the map, then persists the now-empty map —
|
||
// this is the shape setParams({calibration:{reset:true}}) drives.
|
||
entry.delete('sf-market-5th');
|
||
writeCalibrationStoreV2(entry, storage);
|
||
|
||
const restored = readCalibrationStoreV2(storage);
|
||
assert.equal(
|
||
restored.size,
|
||
0,
|
||
'reset entry must be gone, base pose restored',
|
||
);
|
||
});
|
||
|
||
test('calibration v2: malformed/partial entries are dropped defensively', () => {
|
||
const storage = fakeStorage({
|
||
[CCTV_CALIBRATION_STORAGE_KEY_V2]: JSON.stringify({
|
||
'ok-cam': {
|
||
values: {
|
||
offsetNorthM: 1,
|
||
offsetEastM: 2,
|
||
headingDeg: 3,
|
||
pitchDeg: 4,
|
||
fovDeg: 5,
|
||
rangeScale: 1.1,
|
||
heightM: 6,
|
||
},
|
||
source: 'manual',
|
||
savedAt: 42,
|
||
},
|
||
'no-values': { source: 'manual', savedAt: 42 },
|
||
junk: 'not-an-object',
|
||
}),
|
||
});
|
||
const restored = readCalibrationStoreV2(storage);
|
||
assert.ok(restored.has('ok-cam'));
|
||
assert.equal(restored.get('ok-cam').values.offsetNorthM, 1);
|
||
});
|
||
|
||
test('calibration v2: a corrupt v1 key never leaks into the v2 store (v1 is dead data, never read)', () => {
|
||
const storage = fakeStorage({
|
||
[CCTV_CALIBRATION_STORAGE_KEY_V1]: JSON.stringify({
|
||
'austin-42': {
|
||
offsetNorthM: 999,
|
||
offsetEastM: 999,
|
||
headingDeg: 999,
|
||
pitchDeg: 0,
|
||
fovDeg: 0,
|
||
rangeScale: 1,
|
||
heightM: 0,
|
||
},
|
||
}),
|
||
});
|
||
// v2 key is untouched/empty — v1's presence must have zero effect.
|
||
const restored = readCalibrationStoreV2(storage);
|
||
assert.equal(
|
||
restored.size,
|
||
0,
|
||
'v2 store must start empty — no legacy import (owner decision #3, §9.3)',
|
||
);
|
||
assert.ok(!restored.has('austin-42'));
|
||
});
|
||
|
||
test('deriveCalBadge: CALIBRATED when the camera carries a manual v2 calibration', () => {
|
||
const camera = { calSource: 'manual', poseSource: 'curated' };
|
||
// Manual calibration wins over curated — a human explicitly tuned this pose.
|
||
assert.equal(deriveCalBadge(camera), 'calibrated');
|
||
});
|
||
|
||
test('deriveCalBadge: CURATED for a hand-authored catalog prior with no manual save', () => {
|
||
const camera = { calSource: null, poseSource: 'curated' };
|
||
assert.equal(deriveCalBadge(camera), 'curated');
|
||
});
|
||
|
||
test('deriveCalBadge: RAW PRIOR for everything else (all Austin Open Data today)', () => {
|
||
const camera = { calSource: null, poseSource: null };
|
||
assert.equal(deriveCalBadge(camera), 'raw-prior');
|
||
assert.equal(deriveCalBadge({}), 'raw-prior');
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Task 5 (height-datum fix): regime-aware ground resolution pure helpers.
|
||
// docs/superpowers/specs/2026-07-05-entity-height-datum-design.md §2.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('surfaceRegimeKey: globe hidden (photoreal) → google-3d; globe visible → terrain-globe', () => {
|
||
assert.equal(surfaceRegimeKey(false), 'google-3d');
|
||
assert.equal(surfaceRegimeKey(true), 'terrain-globe');
|
||
});
|
||
|
||
test('surfaceRegimeKey: unknown globe state (no viewer / no scene) defaults to terrain-globe (never samples)', () => {
|
||
// Only an explicit globe.show === false means the visible surface is the
|
||
// Google tileset. undefined/null (torn-down viewer) must fall to the
|
||
// regime that takes ZERO scene queries.
|
||
assert.equal(surfaceRegimeKey(undefined), 'terrain-globe');
|
||
assert.equal(surfaceRegimeKey(null), 'terrain-globe');
|
||
});
|
||
|
||
test('calibrationPatchMovesAnchor: only north/east translation re-resolves ground', () => {
|
||
assert.equal(calibrationPatchMovesAnchor({ offsetNorthM: 10 }), true);
|
||
assert.equal(calibrationPatchMovesAnchor({ offsetEastM: -5 }), true);
|
||
assert.equal(calibrationPatchMovesAnchor({ headingDeg: 20 }), false);
|
||
assert.equal(calibrationPatchMovesAnchor({ pitchDeg: -2, fovDeg: 5 }), false);
|
||
assert.equal(
|
||
calibrationPatchMovesAnchor({ rangeScale: 1.2, heightM: 8 }),
|
||
false,
|
||
);
|
||
assert.equal(calibrationPatchMovesAnchor(null), false);
|
||
});
|
||
|
||
// Coverage tri-state (viewshed design §3b): normalizeCoverageMode
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('normalizeCoverageMode: passes through the three valid modes', () => {
|
||
assert.equal(normalizeCoverageMode('off', 'on'), 'off');
|
||
assert.equal(normalizeCoverageMode('on', 'off'), 'on');
|
||
assert.equal(normalizeCoverageMode('viewshed', 'off'), 'viewshed');
|
||
});
|
||
|
||
test('normalizeCoverageMode: boolean back-compat (showCoverage semantics)', () => {
|
||
assert.equal(normalizeCoverageMode(true, 'off'), 'on');
|
||
assert.equal(normalizeCoverageMode(false, 'viewshed'), 'off');
|
||
});
|
||
|
||
test('normalizeCoverageMode: garbage keeps the current mode', () => {
|
||
assert.equal(normalizeCoverageMode('sideways', 'viewshed'), 'viewshed');
|
||
assert.equal(normalizeCoverageMode(undefined, 'on'), 'on');
|
||
assert.equal(normalizeCoverageMode(null, 'off'), 'off');
|
||
assert.equal(normalizeCoverageMode(3, 'on'), 'on');
|
||
});
|
||
|
||
// Unchanged-frame signature (white-flash fix, owner field test 2026-07-30)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Builds an RGBA buffer from [r,g,b] triples. */
|
||
function rgba(triples) {
|
||
const out = new Uint8ClampedArray(triples.length * 4);
|
||
triples.forEach(([r, g, b], i) => {
|
||
out[i * 4] = r;
|
||
out[i * 4 + 1] = g;
|
||
out[i * 4 + 2] = b;
|
||
out[i * 4 + 3] = 255;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
test('frameSignatureFromPixels: identical pixels hash identically', () => {
|
||
const a = rgba([
|
||
[1, 2, 3],
|
||
[250, 128, 7],
|
||
[0, 0, 0],
|
||
]);
|
||
const b = rgba([
|
||
[1, 2, 3],
|
||
[250, 128, 7],
|
||
[0, 0, 0],
|
||
]);
|
||
assert.equal(frameSignatureFromPixels(a), frameSignatureFromPixels(b));
|
||
});
|
||
|
||
test('frameSignatureFromPixels: a single changed channel changes the hash', () => {
|
||
const base = rgba([
|
||
[10, 20, 30],
|
||
[40, 50, 60],
|
||
]);
|
||
const tweak = rgba([
|
||
[10, 20, 31],
|
||
[40, 50, 60],
|
||
]);
|
||
assert.notEqual(
|
||
frameSignatureFromPixels(base),
|
||
frameSignatureFromPixels(tweak),
|
||
);
|
||
});
|
||
|
||
test('frameSignatureFromPixels: order matters (a swap is not a collision)', () => {
|
||
const ab = rgba([
|
||
[1, 2, 3],
|
||
[9, 8, 7],
|
||
]);
|
||
const ba = rgba([
|
||
[9, 8, 7],
|
||
[1, 2, 3],
|
||
]);
|
||
assert.notEqual(frameSignatureFromPixels(ab), frameSignatureFromPixels(ba));
|
||
});
|
||
|
||
test('frameSignatureFromPixels: alpha is deliberately ignored', () => {
|
||
const opaque = new Uint8ClampedArray([5, 6, 7, 255]);
|
||
const clear = new Uint8ClampedArray([5, 6, 7, 0]);
|
||
assert.equal(
|
||
frameSignatureFromPixels(opaque),
|
||
frameSignatureFromPixels(clear),
|
||
);
|
||
});
|
||
|
||
test('frameSignatureFromPixels: returns an unsigned 32-bit value', () => {
|
||
const sig = frameSignatureFromPixels(
|
||
rgba([
|
||
[255, 255, 255],
|
||
[0, 0, 0],
|
||
]),
|
||
);
|
||
assert.ok(
|
||
Number.isInteger(sig) && sig >= 0 && sig <= 0xffffffff,
|
||
`got ${sig}`,
|
||
);
|
||
});
|
||
|
||
test('frameSignatureFromPixels: empty or junk input yields null (always redraw)', () => {
|
||
assert.equal(frameSignatureFromPixels(new Uint8ClampedArray(0)), null);
|
||
assert.equal(frameSignatureFromPixels(null), null);
|
||
assert.equal(frameSignatureFromPixels(undefined), null);
|
||
assert.equal(frameSignatureFromPixels({}), null);
|
||
});
|
||
|
||
test('a saved rangeScale keeps its effective range when the catalog range floor drops', () => {
|
||
const saved = {
|
||
offsetNorthM: 0,
|
||
offsetEastM: 0,
|
||
headingDeg: 0,
|
||
pitchDeg: 0,
|
||
fovDeg: 0,
|
||
rangeScale: 1.5,
|
||
heightM: 0,
|
||
};
|
||
// A 145 m pack range used to be inflated to 220 m: 220 × 1.5 = 330 m. The
|
||
// migrated multiplier reproduces that on the honest 145 m base.
|
||
const migrated = migrateRangeScaleForFloor(saved, 145);
|
||
assert.ok(
|
||
Math.abs(145 * migrated.rangeScale - 330) < 1,
|
||
`${145 * migrated.rangeScale}`,
|
||
);
|
||
// A pack range at or above the old floor was never inflated: unchanged.
|
||
assert.equal(migrateRangeScaleForFloor(saved, 250).rangeScale, 1.5);
|
||
assert.equal(migrateRangeScaleForFloor(saved, 220).rangeScale, 1.5);
|
||
// The 3× ceiling still applies.
|
||
assert.equal(
|
||
migrateRangeScaleForFloor({ ...saved, rangeScale: 3 }, 145).rangeScale,
|
||
3,
|
||
);
|
||
});
|
||
|
||
test('the footprint lift is capped so a tower under the far edge cannot launch the plane', () => {
|
||
// Rises are placed under a BOTTOM support: the top row already sits one
|
||
// plane-height higher, so a rise there is absorbed by the tilt first.
|
||
const flat = computeFrustumGeometry(AUSTIN_FABRICATED_CAMERA, AUSTIN_GROUND);
|
||
const tower = computeFrustumGeometry(
|
||
AUSTIN_FABRICATED_CAMERA,
|
||
AUSTIN_GROUND,
|
||
null,
|
||
{
|
||
br: AUSTIN_GROUND + 240,
|
||
},
|
||
);
|
||
assert.equal(tower.liftCapped, true);
|
||
assert.ok(
|
||
Math.abs(tower.liftM - (flat.liftM + 60)) < 1e-6,
|
||
`lift ${tower.liftM} vs ${flat.liftM}`,
|
||
);
|
||
const hill = computeFrustumGeometry(
|
||
AUSTIN_FABRICATED_CAMERA,
|
||
AUSTIN_GROUND,
|
||
null,
|
||
{
|
||
br: AUSTIN_GROUND + 30,
|
||
},
|
||
);
|
||
assert.equal(hill.liftCapped, false);
|
||
assert.ok(Math.abs(hill.liftM - (flat.liftM + 30)) < 1e-6);
|
||
});
|