mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Associate expanded cable and landing entities through a copied source index, retain one reference stem per feature, and cover duplicate IDs, bundled segments and frozen provider inputs with real Cesium regressions.
2006 lines
63 KiB
JavaScript
2006 lines
63 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import * as Cesium from 'cesium';
|
|
import { MAP_STACKS } from '../mapStackController.js';
|
|
import {
|
|
CABLE_LABEL_DEPTH_DECISION,
|
|
CABLE_OVERLAY_COLLISION_CAPACITY,
|
|
CABLE_OVERLAY_SOURCE_ID,
|
|
CABLE_REFERENCE_LABEL_WINNER_CAP,
|
|
CABLE_STEM_TIP_EPSILON_M,
|
|
CABLE_SWEEP_MOTION_EPSILON_M,
|
|
CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
|
|
applyTranslucentMarkerBlend,
|
|
cableClassificationTypeForScene,
|
|
cableClassificationTypeForStack,
|
|
cableReferencePriority,
|
|
createCableOverlayEntry,
|
|
createCableOverlayPublisher,
|
|
createCableReferenceSweepGate,
|
|
createTeleGeographySubmarineCableLayer,
|
|
selectCableReferenceLabelWinners,
|
|
updateCableReferenceStem,
|
|
} from './telegeographySubmarineCables.js';
|
|
|
|
test('cable reference winners remain nearest-first and hard-capped', () => {
|
|
const records = Array.from({ length: 300 }, (_, index) => ({
|
|
entity: { id: `reference-${index}` },
|
|
label: `Reference ${index}`,
|
|
visible: true,
|
|
distanceM: 1000 + index,
|
|
}));
|
|
|
|
let winners = selectCableReferenceLabelWinners(records);
|
|
assert.equal(winners.length, CABLE_REFERENCE_LABEL_WINNER_CAP);
|
|
assert.equal(winners[0], records[0]);
|
|
assert.equal(winners.at(-1), records[CABLE_REFERENCE_LABEL_WINNER_CAP - 1]);
|
|
|
|
for (let index = 0; index < 80; index++) records[index].visible = false;
|
|
winners = selectCableReferenceLabelWinners(records);
|
|
assert.equal(winners.length, CABLE_REFERENCE_LABEL_WINNER_CAP);
|
|
assert.equal(
|
|
winners[0],
|
|
records[80],
|
|
'hidden references release their slots to the next nearest',
|
|
);
|
|
|
|
records[90].distanceM = 10_000_000; // beyond the 9,000 km reference range
|
|
winners = selectCableReferenceLabelWinners(records);
|
|
assert.ok(
|
|
!winners.includes(records[90]),
|
|
'out-of-range references never reach the host',
|
|
);
|
|
});
|
|
|
|
test('cable overlay entries satisfy the shared presentation contract', () => {
|
|
const tip = Cesium.Cartesian3.fromDegrees(-40, 35, 2500);
|
|
const record = {
|
|
id: 'cable-reference-0-atlantic-1',
|
|
kind: 'cable',
|
|
label: 'Atlantic Crossing-1',
|
|
tip,
|
|
distanceM: 400_000,
|
|
};
|
|
const entry = createCableOverlayEntry(record);
|
|
|
|
assert.equal(entry.id, 'cable-reference-0-atlantic-1');
|
|
assert.equal(
|
|
entry.position,
|
|
tip,
|
|
'entry stays attached to the mutable stem-tip Cartesian',
|
|
);
|
|
assert.equal(entry.variant, 'label');
|
|
assert.equal(entry.title, 'Atlantic Crossing-1');
|
|
assert.equal(entry.accent, '#39d5ff');
|
|
assert.equal(entry.collisionGroup, 'ambient-label');
|
|
assert.equal(entry.paintLane, 'ambient-label');
|
|
assert.equal(
|
|
entry.interactive,
|
|
false,
|
|
'point/stem/line picking remains Cesium-native',
|
|
);
|
|
assert.equal(entry.maxDistance, 9_000_000);
|
|
assert.equal(entry.distanceFadeStartRatio, 0.7);
|
|
assert.deepEqual(
|
|
entry.distanceScale,
|
|
{
|
|
near: 250_000,
|
|
nearValue: 1,
|
|
far: 9_000_000,
|
|
farValue: 0.62,
|
|
},
|
|
'former native scaleByDistance curve is preserved',
|
|
);
|
|
assert.equal(entry.edgeFade, 'keyhole');
|
|
assert.equal(entry.horizonCull, true);
|
|
assert.equal(entry.terrainOcclusion, false);
|
|
assert.equal(entry.verticalOnly, true);
|
|
assert.equal(entry.placement, 'above');
|
|
|
|
const landing = createCableOverlayEntry({
|
|
id: 'landing-point-reference-1-lisbon',
|
|
kind: 'landing-point',
|
|
label: 'Lisbon, Portugal',
|
|
tip,
|
|
distanceM: 100_000,
|
|
});
|
|
assert.equal(
|
|
landing.accent,
|
|
'#8fffd2',
|
|
'landing points keep their mint accent',
|
|
);
|
|
assert.equal(landing.interactive, false);
|
|
});
|
|
|
|
test('cable reference priority is nearest-first and sweep-stable', () => {
|
|
assert.ok(cableReferencePriority(1_000) > cableReferencePriority(1_000_000));
|
|
assert.ok(
|
|
cableReferencePriority(1_000_000) > cableReferencePriority(8_000_000),
|
|
);
|
|
assert.equal(
|
|
cableReferencePriority(500_000),
|
|
cableReferencePriority(500_000 + 10_000),
|
|
'50 km quantization keeps slow camera drift from reshuffling ranks',
|
|
);
|
|
assert.equal(
|
|
cableReferencePriority(undefined),
|
|
cableReferencePriority(9_000_000),
|
|
'missing distances rank like the range edge instead of jumping the queue',
|
|
);
|
|
});
|
|
|
|
test('cable overlay publisher owns the production show/publish/hide lifecycle', () => {
|
|
const calls = [];
|
|
const publisher = createCableOverlayPublisher({
|
|
host: {
|
|
setVisible: (...args) => calls.push(['visible', ...args]),
|
|
setEntries: (...args) => calls.push(['entries', ...args]),
|
|
clearSource: (...args) => calls.push(['clear', ...args]),
|
|
},
|
|
});
|
|
|
|
publisher.publish([{ id: 'ignored-before-show' }]);
|
|
publisher.show();
|
|
publisher.show();
|
|
publisher.publish([{ id: 'cable-1' }]);
|
|
publisher.hide();
|
|
publisher.publish([{ id: 'dropped-while-hidden' }]);
|
|
publisher.hide();
|
|
|
|
assert.deepEqual(calls[0], ['visible', CABLE_OVERLAY_SOURCE_ID, true]);
|
|
assert.deepEqual(calls[1].slice(0, 3), [
|
|
'entries',
|
|
CABLE_OVERLAY_SOURCE_ID,
|
|
[{ id: 'cable-1' }],
|
|
]);
|
|
assert.deepEqual(calls[1][3], {
|
|
cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP,
|
|
collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY,
|
|
moving: false,
|
|
});
|
|
assert.deepEqual(calls[2], ['clear', CABLE_OVERLAY_SOURCE_ID]);
|
|
assert.deepEqual(calls[3], ['visible', CABLE_OVERLAY_SOURCE_ID, false]);
|
|
assert.equal(
|
|
calls.length,
|
|
4,
|
|
'hidden publishers drop late publishes; repeat hide is a no-op',
|
|
);
|
|
|
|
// The production teardown (layer.destroy) is hide(): the publisher stays
|
|
// reusable for the layer's supported re-init-after-destroy contract. There
|
|
// is intentionally no permanent-destroy method to test.
|
|
assert.equal(publisher.destroy, undefined);
|
|
publisher.show();
|
|
publisher.publish([{ id: 'cable-2' }]);
|
|
assert.deepEqual(calls[4], ['visible', CABLE_OVERLAY_SOURCE_ID, true]);
|
|
assert.deepEqual(calls[5].slice(0, 3), [
|
|
'entries',
|
|
CABLE_OVERLAY_SOURCE_ID,
|
|
[{ id: 'cable-2' }],
|
|
]);
|
|
});
|
|
|
|
test('cable ground lines classify against exactly the active surface on every stack', () => {
|
|
// Photorealistic tiles render with the globe hidden — 3D-tile pass only.
|
|
assert.equal(
|
|
cableClassificationTypeForStack('photoreal'),
|
|
Cesium.ClassificationType.CESIUM_3D_TILE,
|
|
);
|
|
// Every globe stack renders imagery on the shown globe — terrain pass only.
|
|
for (const stackId of ['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']) {
|
|
assert.equal(
|
|
cableClassificationTypeForStack(stackId),
|
|
Cesium.ClassificationType.TERRAIN,
|
|
`${stackId} must classify against terrain so cables stay visible there`,
|
|
);
|
|
}
|
|
// Unknown stack → BOTH: visible on every surface, the shipped fallback.
|
|
// A TRUTHY unknown id must reach it too — "not photoreal" is not the same
|
|
// claim as "renders on the globe", and asserting TERRAIN for an id this
|
|
// module has never heard of would hide the cables on a future 3D-tile
|
|
// stack instead of degrading to the documented safe behavior.
|
|
assert.equal(
|
|
cableClassificationTypeForStack('some-future-stack'),
|
|
Cesium.ClassificationType.BOTH,
|
|
);
|
|
assert.equal(
|
|
cableClassificationTypeForStack('photoreal-v2'),
|
|
Cesium.ClassificationType.BOTH,
|
|
);
|
|
assert.equal(
|
|
cableClassificationTypeForStack(''),
|
|
Cesium.ClassificationType.BOTH,
|
|
);
|
|
assert.equal(
|
|
cableClassificationTypeForStack(undefined),
|
|
Cesium.ClassificationType.BOTH,
|
|
);
|
|
assert.equal(
|
|
cableClassificationTypeForStack(null),
|
|
Cesium.ClassificationType.BOTH,
|
|
);
|
|
|
|
// Every id that actually ships must be a KNOWN id: a stack added to
|
|
// MAP_STACKS without a mapping here silently loses its halved command set,
|
|
// so the omission fails loudly instead of degrading quietly.
|
|
for (const stack of MAP_STACKS) {
|
|
assert.notEqual(
|
|
cableClassificationTypeForStack(stack.id),
|
|
Cesium.ClassificationType.BOTH,
|
|
`MAP_STACKS id '${stack.id}' has no cable classification mapping`,
|
|
);
|
|
}
|
|
|
|
// Boot fires no stack event; the initial value derives from live scene
|
|
// state exactly like the height-datum listeners: photoreal ⇔ globe hidden.
|
|
assert.equal(
|
|
cableClassificationTypeForScene({ globe: { show: false } }),
|
|
Cesium.ClassificationType.CESIUM_3D_TILE,
|
|
);
|
|
assert.equal(
|
|
cableClassificationTypeForScene({ globe: { show: true } }),
|
|
Cesium.ClassificationType.TERRAIN,
|
|
);
|
|
assert.equal(
|
|
cableClassificationTypeForScene(null),
|
|
Cesium.ClassificationType.BOTH,
|
|
);
|
|
});
|
|
|
|
test('a map-stack change re-classifies every cable line once, and destroy detaches the listener', async () => {
|
|
const listeners = new Map();
|
|
const eventTarget = {
|
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
|
removeEventListener: (name, fn) => {
|
|
if (listeners.get(name) === fn) listeners.delete(name);
|
|
},
|
|
};
|
|
const env = await createRealCableLayerHarness({
|
|
mapStackEventTarget: eventTarget,
|
|
});
|
|
const cableSource = env.dataSources.find((ds) =>
|
|
/Submarine Cables/.test(ds.name || ''),
|
|
);
|
|
const classificationOf = (entity) =>
|
|
entity.polyline.classificationType.getValue();
|
|
|
|
// The harness scene has no globe → BOTH at init (safe unknown fallback).
|
|
const cableEntity = cableSource.entities.values[0];
|
|
assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.BOTH);
|
|
const listener = listeners.get('gev:map-stack-changed');
|
|
assert.equal(
|
|
typeof listener,
|
|
'function',
|
|
'init must subscribe to the stack event',
|
|
);
|
|
|
|
listener({ detail: { activeId: 'photoreal', status: 'ready' } });
|
|
assert.equal(
|
|
classificationOf(cableEntity),
|
|
Cesium.ClassificationType.CESIUM_3D_TILE,
|
|
);
|
|
|
|
listener({ detail: { activeId: 'osm', status: 'ready' } });
|
|
assert.equal(
|
|
classificationOf(cableEntity),
|
|
Cesium.ClassificationType.TERRAIN,
|
|
);
|
|
|
|
env.layer.destroy(env.viewer);
|
|
assert.equal(
|
|
listeners.has('gev:map-stack-changed'),
|
|
false,
|
|
'destroy must detach the stack listener',
|
|
);
|
|
});
|
|
|
|
test('marker collections blend in a single translucent pass behind a loud shape invariant', async () => {
|
|
// Helper contract on a REAL Cesium cluster shape.
|
|
const ds = new Cesium.CustomDataSource('blend-probe');
|
|
let result = applyTranslucentMarkerBlend(ds);
|
|
assert.deepEqual(
|
|
result,
|
|
{ applied: 0, pending: 2, invariantFailed: false },
|
|
'pre-visualizer clusters are pending, never a shape failure',
|
|
);
|
|
|
|
ds.clustering._billboardCollection = new Cesium.BillboardCollection();
|
|
ds.clustering._pointCollection = new Cesium.PointPrimitiveCollection();
|
|
result = applyTranslucentMarkerBlend(ds);
|
|
assert.deepEqual(result, { applied: 2, pending: 0, invariantFailed: false });
|
|
assert.equal(
|
|
ds.clustering._billboardCollection.blendOption,
|
|
Cesium.BlendOption.TRANSLUCENT,
|
|
);
|
|
assert.equal(
|
|
ds.clustering._pointCollection.blendOption,
|
|
Cesium.BlendOption.TRANSLUCENT,
|
|
);
|
|
|
|
// Shape drift fails LOUDLY-detectably instead of applying blindly: a
|
|
// renamed field or a foreign object type must never be touched.
|
|
assert.equal(
|
|
applyTranslucentMarkerBlend({ clustering: {} }).invariantFailed,
|
|
true,
|
|
'missing private fields mean Cesium changed shape',
|
|
);
|
|
const wrongType = new Cesium.CustomDataSource('wrong-type');
|
|
wrongType.clustering._billboardCollection = { blendOption: 0 };
|
|
const wrongResult = applyTranslucentMarkerBlend(wrongType);
|
|
assert.equal(wrongResult.invariantFailed, true);
|
|
assert.equal(
|
|
wrongType.clustering._billboardCollection.blendOption,
|
|
0,
|
|
'foreign objects are left untouched',
|
|
);
|
|
assert.equal(applyTranslucentMarkerBlend(null).invariantFailed, true);
|
|
|
|
// ATOMICITY: a shape failure on the SECOND collection must not leave the
|
|
// FIRST one already forced. Validation of every field/type completes before
|
|
// the first assignment, so a failed probe applies nothing at all — that is
|
|
// what "leaves Cesium's default blend untouched" has to mean.
|
|
const partial = new Cesium.CustomDataSource('partial-shape');
|
|
partial.clustering._billboardCollection = new Cesium.BillboardCollection();
|
|
partial.clustering._pointCollection = { blendOption: 0 }; // renamed/reshaped
|
|
const partialResult = applyTranslucentMarkerBlend(partial);
|
|
assert.equal(partialResult.invariantFailed, true);
|
|
assert.equal(
|
|
partialResult.applied,
|
|
0,
|
|
'a failed probe reports zero applications',
|
|
);
|
|
assert.equal(
|
|
partial.clustering._billboardCollection.blendOption,
|
|
Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT,
|
|
"the valid first collection keeps Cesium's default blend when the second fails",
|
|
);
|
|
assert.equal(
|
|
partial.clustering._pointCollection.blendOption,
|
|
0,
|
|
'the malformed collection is never touched',
|
|
);
|
|
|
|
// Real-layer wiring: once the visualizers have created the collections,
|
|
// the next sweep applies the single-pass blend to both marker sources.
|
|
const env = await createRealCableLayerHarness();
|
|
env.raiseSweep();
|
|
const landingSource = env.dataSources.find((source) =>
|
|
/Landing Points/.test(source.name || ''),
|
|
);
|
|
const referenceSource = env.dataSources.find((source) =>
|
|
/References/.test(source.name || ''),
|
|
);
|
|
landingSource.clustering._billboardCollection =
|
|
new Cesium.BillboardCollection();
|
|
landingSource.clustering._pointCollection =
|
|
new Cesium.PointPrimitiveCollection();
|
|
referenceSource.clustering._billboardCollection =
|
|
new Cesium.BillboardCollection();
|
|
referenceSource.clustering._pointCollection =
|
|
new Cesium.PointPrimitiveCollection();
|
|
for (const fn of env.listeners.moveEnd) fn();
|
|
env.raiseSweep();
|
|
assert.equal(
|
|
landingSource.clustering._billboardCollection.blendOption,
|
|
Cesium.BlendOption.TRANSLUCENT,
|
|
'landing billboards blend in one pass after the sweep',
|
|
);
|
|
assert.equal(
|
|
referenceSource.clustering._pointCollection.blendOption,
|
|
Cesium.BlendOption.TRANSLUCENT,
|
|
'reference points blend in one pass after the sweep',
|
|
);
|
|
env.layer.destroy(env.viewer);
|
|
|
|
// The same atomicity holds ACROSS the two marker sources: the layer probes
|
|
// both before committing either, so a reshaped reference source can never
|
|
// leave the landing source already forced to TRANSLUCENT.
|
|
const failEnv = await createRealCableLayerHarness();
|
|
const failLanding = failEnv.dataSources.find((source) =>
|
|
/Landing Points/.test(source.name || ''),
|
|
);
|
|
const failReference = failEnv.dataSources.find((source) =>
|
|
/References/.test(source.name || ''),
|
|
);
|
|
failLanding.clustering._billboardCollection =
|
|
new Cesium.BillboardCollection();
|
|
failLanding.clustering._pointCollection =
|
|
new Cesium.PointPrimitiveCollection();
|
|
delete failReference.clustering._pointCollection; // Cesium reshaped
|
|
failEnv.raiseSweep();
|
|
assert.equal(
|
|
failLanding.clustering._billboardCollection.blendOption,
|
|
Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT,
|
|
'a reference-source shape failure leaves the landing source on the default blend',
|
|
);
|
|
failEnv.layer.destroy(failEnv.viewer);
|
|
});
|
|
|
|
test('cable reference sweep gate is dirty-only — no timer path', () => {
|
|
const gate = createCableReferenceSweepGate();
|
|
assert.equal(gate.shouldRun(), true, 'initial dirtiness covers enable/load');
|
|
// However many frames render, an un-dirtied gate never runs the sweep —
|
|
// the former 500 ms timer path is gone.
|
|
for (let frame = 0; frame < 200; frame++) {
|
|
assert.equal(gate.shouldRun(), false);
|
|
}
|
|
gate.markDirty();
|
|
assert.equal(gate.shouldRun(), true, 'moveEnd dirtiness runs exactly once');
|
|
assert.equal(gate.shouldRun(), false);
|
|
gate.reset();
|
|
assert.equal(gate.shouldRun(), true, 'reset restores the initial dirtiness');
|
|
});
|
|
|
|
test('the sweep gate falls back to motion probes for cameras that never emit moveEnd', () => {
|
|
let clock = 0;
|
|
const frameMs = 1000 / 60;
|
|
const origin = Cesium.Cartesian3.fromDegrees(-40, 35, 4_500_000);
|
|
const camera = { positionWC: Cesium.Cartesian3.clone(origin) };
|
|
const gate = createCableReferenceSweepGate({ now: () => clock });
|
|
const runFrames = (count, step = () => {}) => {
|
|
let sweeps = 0;
|
|
for (let frame = 0; frame < count; frame++) {
|
|
clock += frameMs;
|
|
step(frame);
|
|
if (gate.shouldRun(camera)) sweeps++;
|
|
}
|
|
return sweeps;
|
|
};
|
|
|
|
assert.equal(
|
|
gate.shouldRun(camera),
|
|
true,
|
|
'initial dirtiness still covers enable/load',
|
|
);
|
|
|
|
// PARKED: 100 s of frames on a camera that never moves must cost ZERO
|
|
// sweeps — the fallback is motion-aware, not a reinstated timer.
|
|
assert.equal(
|
|
runFrames(6000),
|
|
0,
|
|
'a parked camera costs zero sweeps however long frames flow',
|
|
);
|
|
|
|
// Sub-epsilon jitter is not motion either: the probe compares against the
|
|
// last SWEPT position, so numeric noise can never accumulate into a sweep.
|
|
const jitter = runFrames(6000, (frame) => {
|
|
camera.positionWC = Cesium.Cartesian3.add(
|
|
origin,
|
|
new Cesium.Cartesian3(
|
|
0,
|
|
0,
|
|
(frame % 2) * (CABLE_SWEEP_MOTION_EPSILON_M / 10),
|
|
),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
});
|
|
assert.equal(jitter, 0, 'sub-epsilon camera jitter never earns a sweep');
|
|
|
|
// TRACKING: a follow camera advances every frame and never emits moveEnd.
|
|
// The sweep must land inside one probe window instead of starving forever.
|
|
const timedGate = createCableReferenceSweepGate({ now: () => clock });
|
|
const timedCamera = { positionWC: Cesium.Cartesian3.clone(origin) };
|
|
assert.equal(timedGate.shouldRun(timedCamera), true, 'the gate starts dirty');
|
|
const trackingStart = clock;
|
|
let sweepAt = null;
|
|
for (let frame = 0; frame < 600 && sweepAt === null; frame++) {
|
|
clock += frameMs;
|
|
timedCamera.positionWC = Cesium.Cartesian3.add(
|
|
origin,
|
|
new Cesium.Cartesian3(0, 0, 12 * (frame + 1)), // ~0.7 km/s: a tracked jet
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
if (timedGate.shouldRun(timedCamera)) sweepAt = clock;
|
|
}
|
|
assert.notEqual(
|
|
sweepAt,
|
|
null,
|
|
'a continuously moving camera must not starve the sweep',
|
|
);
|
|
assert.ok(
|
|
sweepAt - trackingStart <= CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS + frameMs,
|
|
`the fallback sweep must land inside one probe window (landed at ${sweepAt - trackingStart} ms)`,
|
|
);
|
|
assert.ok(
|
|
sweepAt - trackingStart >= CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
|
|
'and never sooner — the probe stays bounded to one sample per window',
|
|
);
|
|
|
|
// moveEnd still wins immediately: an event-dirtied gate does not wait for
|
|
// the probe window it is sitting inside.
|
|
timedGate.markDirty();
|
|
assert.equal(
|
|
timedGate.shouldRun(timedCamera),
|
|
true,
|
|
'moveEnd dirtiness bypasses the probe window',
|
|
);
|
|
assert.equal(
|
|
timedGate.shouldRun(timedCamera),
|
|
false,
|
|
'and still runs exactly once',
|
|
);
|
|
|
|
// A gate called without a camera keeps the pure dirty-only contract.
|
|
const cameraless = createCableReferenceSweepGate({ now: () => clock });
|
|
assert.equal(cameraless.shouldRun(), true);
|
|
clock += CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS * 10;
|
|
assert.equal(
|
|
cameraless.shouldRun(),
|
|
false,
|
|
'no camera state means no motion fallback',
|
|
);
|
|
});
|
|
|
|
function makeStemRecord(lon = -40, lat = 35) {
|
|
const base = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
|
const tip = Cesium.Cartesian3.fromDegrees(lon, lat, 2500);
|
|
const stemPositionBuffers = [
|
|
[base, tip],
|
|
[base, tip],
|
|
];
|
|
const setCalls = { position: 0, polyline: 0, polylineArrays: [] };
|
|
return {
|
|
record: {
|
|
entity: {
|
|
position: {
|
|
setValue: () => {
|
|
setCalls.position++;
|
|
},
|
|
},
|
|
polyline: {
|
|
positions: {
|
|
setValue: (value) => {
|
|
setCalls.polyline++;
|
|
setCalls.polylineArrays.push(value);
|
|
},
|
|
},
|
|
},
|
|
},
|
|
base,
|
|
tip,
|
|
nextTip: Cesium.Cartesian3.clone(tip),
|
|
stemPositionBuffers,
|
|
stemPositionBufferIndex: 0,
|
|
reference: { lon, lat },
|
|
},
|
|
setCalls,
|
|
stemPositionBuffers,
|
|
};
|
|
}
|
|
|
|
test('staticized stems redefine constants only on real tip changes, alternating buffers', () => {
|
|
const { record, setCalls, stemPositionBuffers } = makeStemRecord();
|
|
const canvasHeight = 900;
|
|
const fov = Math.PI / 3;
|
|
const camera = Cesium.Cartesian3.fromDegrees(-40, 35, 500_000);
|
|
|
|
assert.equal(
|
|
updateCableReferenceStem(record, camera, canvasHeight, fov),
|
|
true,
|
|
);
|
|
assert.equal(setCalls.position, 1);
|
|
assert.equal(setCalls.polyline, 1);
|
|
assert.equal(
|
|
setCalls.polylineArrays[0],
|
|
stemPositionBuffers[1],
|
|
'first real update selects the alternate buffer',
|
|
);
|
|
|
|
// Sub-epsilon camera noise must not redefine anything.
|
|
const jitter = Cesium.Cartesian3.add(
|
|
camera,
|
|
new Cesium.Cartesian3(CABLE_STEM_TIP_EPSILON_M / 10, 0, 0),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
assert.equal(
|
|
updateCableReferenceStem(record, jitter, canvasHeight, fov),
|
|
false,
|
|
);
|
|
assert.equal(setCalls.position, 1);
|
|
assert.equal(setCalls.polyline, 1);
|
|
|
|
// A real camera move flips back to the first buffer: steady state allocates
|
|
// no arrays beyond the two preallocated ones.
|
|
const moved = Cesium.Cartesian3.add(
|
|
camera,
|
|
new Cesium.Cartesian3(0, 0, 400_000),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
assert.equal(
|
|
updateCableReferenceStem(record, moved, canvasHeight, fov),
|
|
true,
|
|
);
|
|
assert.equal(setCalls.polylineArrays[1], stemPositionBuffers[0]);
|
|
assert.equal(new Set(setCalls.polylineArrays).size, 2);
|
|
|
|
// Stem height honors the shipped 700 m..85 km clamp.
|
|
const tipCarto = Cesium.Cartographic.fromCartesian(record.tip);
|
|
assert.ok(tipCarto.height >= 700 && tipCarto.height <= 85_000);
|
|
});
|
|
|
|
test('the depth decision is the dated Option-2 host migration, superseding Option 1', () => {
|
|
assert.deepEqual(CABLE_LABEL_DEPTH_DECISION, {
|
|
option: 2,
|
|
decidedAt: '2026-08-18',
|
|
supersedes: '2026-08-02',
|
|
depthTested: false,
|
|
});
|
|
});
|
|
|
|
test('cables create no native labels and no per-frame geometry callbacks', () => {
|
|
const source = [
|
|
'../../data/telegeographySubmarineCables.js',
|
|
'../../app/layers/submarineCables.js',
|
|
'../../app/layers/overlayHost.js',
|
|
'rendering.js',
|
|
'overlay.js',
|
|
'surface.js',
|
|
'ingestion.js',
|
|
'interaction.js',
|
|
'geometry.js',
|
|
'lifecycle.js',
|
|
]
|
|
.map((file) =>
|
|
readFileSync(
|
|
new URL('../layers/submarineCables/' + file, import.meta.url),
|
|
'utf8',
|
|
),
|
|
)
|
|
.join('\n');
|
|
assert.doesNotMatch(
|
|
source,
|
|
/new Cesium\.LabelGraphics/,
|
|
'reverting to native labels must fail this pin',
|
|
);
|
|
assert.doesNotMatch(
|
|
source,
|
|
/new Cesium\.CallbackProperty/,
|
|
'reverting to per-frame stem callbacks must fail this pin',
|
|
);
|
|
assert.match(
|
|
source,
|
|
/setOverlayEntries/,
|
|
'labels must flow through the shared world-overlay host',
|
|
);
|
|
assert.match(source, /record\.entity\.position\.setValue\(record\.tip\)/);
|
|
assert.match(
|
|
source,
|
|
/const stemPositionBuffers = \[\s*\[base, tip\],\s*\[base, tip\],?\s*\]/,
|
|
);
|
|
assert.match(
|
|
source,
|
|
/record\.entity\.polyline\.positions\.setValue\(stemPositions\)/,
|
|
);
|
|
});
|
|
|
|
const CABLE_FIXTURE = {
|
|
type: 'FeatureCollection',
|
|
features: [
|
|
{
|
|
type: 'Feature',
|
|
id: 'ac1',
|
|
properties: { id: 'ac1', name: 'Atlantic Crossing-1' },
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: [
|
|
[-40, 35],
|
|
[-30, 40],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
// Node has no `document`, so Point features (whose GeoJSON default marker
|
|
// needs a canvas PinBuilder) cannot load here. Landing references only need
|
|
// `properties.coordinates`, so a line geometry exercises the same path.
|
|
const LANDING_FIXTURE = {
|
|
type: 'FeatureCollection',
|
|
features: [
|
|
{
|
|
type: 'Feature',
|
|
id: 'lisbon',
|
|
properties: {
|
|
id: 'lisbon',
|
|
name: 'Lisbon, Portugal',
|
|
coordinates: [-9.1, 38.7],
|
|
},
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: [
|
|
[-9.1, 38.7],
|
|
[-9.05, 38.72],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
function fixtureForUrl(url) {
|
|
return String(url).includes('landing-point')
|
|
? LANDING_FIXTURE
|
|
: CABLE_FIXTURE;
|
|
}
|
|
|
|
function makeStubViewer(
|
|
listeners,
|
|
dataSources,
|
|
addCalls = { count: 0 },
|
|
addControl = null,
|
|
) {
|
|
return {
|
|
dataSources: {
|
|
// Mirror Cesium's real DataSourceCollection.add(): the returned promise
|
|
// resolves — and the collection mutates — on a DEFERRED tick, never in
|
|
// the caller's synchronous frame. A remove() issued before the deferred
|
|
// add settles therefore removes NOTHING and the add still materializes
|
|
// afterwards (empirically verified against Cesium). Tests may hold the
|
|
// settle window open via addControl to abort inside it, and may mark an
|
|
// add rejected-AFTER-push: Cesium pushes into the collection BEFORE
|
|
// raising dataSourceAdded, so a throwing listener rejects the promise
|
|
// after the mutation already landed.
|
|
add: (source) => {
|
|
const callIndex = addCalls.count;
|
|
addCalls.count += 1;
|
|
return new Promise((resolve, reject) => {
|
|
const settle = () => {
|
|
dataSources.push(source);
|
|
if (addControl?.rejectAfterPush?.has(callIndex)) {
|
|
reject(
|
|
new Error(
|
|
'dataSourceAdded listener threw after the push landed',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
resolve(source);
|
|
};
|
|
if (addControl?.held) addControl.pending.push(settle);
|
|
else Promise.resolve().then(settle);
|
|
});
|
|
},
|
|
remove: (source) => {
|
|
const index = dataSources.indexOf(source);
|
|
if (index >= 0) dataSources.splice(index, 1);
|
|
return index >= 0;
|
|
},
|
|
},
|
|
camera: {
|
|
positionWC: Cesium.Cartesian3.fromDegrees(-40, 35, 4_500_000),
|
|
frustum: { fov: Math.PI / 3 },
|
|
moveEnd: {
|
|
addEventListener: (fn) => {
|
|
listeners.moveEnd.add(fn);
|
|
return () => listeners.moveEnd.delete(fn);
|
|
},
|
|
},
|
|
heading: 0,
|
|
cancelFlight() {},
|
|
flyTo() {},
|
|
},
|
|
scene: {
|
|
canvas: { clientHeight: 900 },
|
|
requestRender() {},
|
|
pick() {
|
|
return null;
|
|
},
|
|
preRender: {
|
|
addEventListener: (fn) => {
|
|
listeners.preRender.add(fn);
|
|
return () => listeners.preRender.delete(fn);
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Harness whose fetches stay PENDING until the test releases them, honoring
|
|
* the abort signal like real fetch (reject with AbortError on abort). Load
|
|
* lifecycle races are sequenced deterministically through it.
|
|
*/
|
|
function createDeferredCableLayerHarness() {
|
|
const hostCalls = [];
|
|
const listeners = { preRender: new Set(), moveEnd: new Set() };
|
|
const dataSources = [];
|
|
const addCalls = { count: 0 };
|
|
const addControl = { held: false, pending: [], rejectAfterPush: new Set() };
|
|
const pendingFetches = [];
|
|
const abortError = () =>
|
|
Object.assign(new Error('The operation was aborted'), {
|
|
name: 'AbortError',
|
|
});
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (url, options = {}) =>
|
|
new Promise((resolve, reject) => {
|
|
const entry = {
|
|
url: String(url),
|
|
settled: false,
|
|
release() {
|
|
if (entry.settled) return;
|
|
entry.settled = true;
|
|
resolve({ ok: true, json: async () => fixtureForUrl(url) });
|
|
},
|
|
};
|
|
const signal = options?.signal;
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
entry.settled = true;
|
|
reject(abortError());
|
|
return;
|
|
}
|
|
signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
if (entry.settled) return;
|
|
entry.settled = true;
|
|
reject(abortError());
|
|
},
|
|
{ once: true },
|
|
);
|
|
}
|
|
pendingFetches.push(entry);
|
|
});
|
|
|
|
const layer = createTeleGeographySubmarineCableLayer({
|
|
overlayHost: {
|
|
setVisible: (...args) => hostCalls.push(['visible', ...args]),
|
|
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
|
clearSource: (...args) => hostCalls.push(['clear', ...args]),
|
|
},
|
|
screenSpaceEventHandlerFactory: () => ({
|
|
setInputAction() {},
|
|
destroy() {},
|
|
}),
|
|
});
|
|
const viewer = makeStubViewer(listeners, dataSources, addCalls, addControl);
|
|
layer.init(viewer);
|
|
|
|
return {
|
|
layer,
|
|
viewer,
|
|
hostCalls,
|
|
dataSources,
|
|
addCalls,
|
|
listeners,
|
|
pendingFetches,
|
|
raiseSweep() {
|
|
for (const fn of listeners.preRender) fn();
|
|
},
|
|
releaseAll() {
|
|
for (const entry of [...pendingFetches]) entry.release();
|
|
pendingFetches.length = 0;
|
|
},
|
|
/** Park every subsequent dataSources.add in its deferred settle window. */
|
|
holdAdds() {
|
|
addControl.held = true;
|
|
},
|
|
/** Make the Nth add (0-based) push into the collection, THEN reject. */
|
|
failAddAfterPush(callIndex) {
|
|
addControl.rejectAfterPush.add(callIndex);
|
|
},
|
|
/** Let the held adds mutate the collection (the deferred settle lands). */
|
|
releaseHeldAdds() {
|
|
addControl.held = false;
|
|
const pending = addControl.pending.splice(0);
|
|
for (const settle of pending) settle();
|
|
},
|
|
async settle() {
|
|
// Drain the microtask/timer interleave the load path spans.
|
|
for (let i = 0; i < 8; i++)
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
},
|
|
cleanup() {
|
|
globalThis.fetch = originalFetch;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gate Cesium's GeoJsonDataSource.load: each call runs the REAL load, then
|
|
* parks its resolution behind a test-owned gate. Opening gate N and aborting
|
|
* in the same synchronous frame lands the abort exactly at the caller's
|
|
* post-await ownership check for that boundary.
|
|
*/
|
|
function installGeoJsonLoadGate() {
|
|
const original = Cesium.GeoJsonDataSource.load;
|
|
const calls = [];
|
|
const gates = [];
|
|
Cesium.GeoJsonDataSource.load = function gatedLoad(...args) {
|
|
calls.push(args);
|
|
let open;
|
|
const opened = new Promise((resolve) => {
|
|
open = resolve;
|
|
});
|
|
gates.push({ open });
|
|
return original
|
|
.apply(Cesium.GeoJsonDataSource, args)
|
|
.then((result) => opened.then(() => result));
|
|
};
|
|
return {
|
|
calls,
|
|
open(index) {
|
|
gates[index]?.open();
|
|
},
|
|
/**
|
|
* Open every gate, including ones created by continuations the opening
|
|
* itself unblocks (a load calls the landing build only after the cable
|
|
* gate opens), until no new gates appear.
|
|
*/
|
|
async drainOpen(settle) {
|
|
let opened = 0;
|
|
while (opened < gates.length) {
|
|
for (; opened < gates.length; opened++) gates[opened].open();
|
|
await settle();
|
|
}
|
|
},
|
|
restore() {
|
|
Cesium.GeoJsonDataSource.load = original;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('an abort landing at the post-fetch boundary stops the stale load before GeoJson work', async () => {
|
|
const env = createDeferredCableLayerHarness();
|
|
const geojson = installGeoJsonLoadGate();
|
|
try {
|
|
env.layer.enable(env.viewer);
|
|
// Release A's fetches and abort in the SAME frame: A's continuation runs
|
|
// with ownership already lost, and the post-fetch owns() check is the guard.
|
|
env.releaseAll();
|
|
env.layer.disable();
|
|
env.layer.enable(env.viewer);
|
|
await env.settle();
|
|
assert.equal(
|
|
geojson.calls.length,
|
|
0,
|
|
'stale load A must not reach GeoJsonDataSource.load',
|
|
);
|
|
|
|
env.releaseAll();
|
|
await env.settle();
|
|
await geojson.drainOpen(() => env.settle());
|
|
assert.equal(
|
|
geojson.calls.length,
|
|
2,
|
|
'load B alone performs the two GeoJson loads',
|
|
);
|
|
assert.equal(env.addCalls.count, 3);
|
|
assert.equal(env.dataSources.length, 3);
|
|
assert.equal(env.layer.getStats().count, 2);
|
|
} finally {
|
|
geojson.restore();
|
|
env.layer.destroy(env.viewer);
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
test('an abort landing at the post-cable-GeoJson boundary stops the stale load before the landing build', async () => {
|
|
const env = createDeferredCableLayerHarness();
|
|
const geojson = installGeoJsonLoadGate();
|
|
try {
|
|
env.layer.enable(env.viewer);
|
|
env.releaseAll();
|
|
await env.settle();
|
|
assert.equal(env.pendingFetches.length, 0);
|
|
assert.equal(
|
|
geojson.calls.length,
|
|
1,
|
|
'load A is parked on the gated cable GeoJson build',
|
|
);
|
|
|
|
// Open the cable gate and abort in the same frame: A resumes at the
|
|
// post-cable owns() check with ownership lost.
|
|
geojson.open(0);
|
|
env.layer.disable();
|
|
env.layer.enable(env.viewer);
|
|
await env.settle();
|
|
// A already cached the fetched JSON, so load B skips the network and goes
|
|
// straight to its own CABLE build; the only new GeoJson call must be that
|
|
// one — a landing build here would mean stale A ran on past its check.
|
|
assert.equal(
|
|
env.pendingFetches.length,
|
|
0,
|
|
'load B rebuilds from the cached JSON without refetching',
|
|
);
|
|
assert.equal(
|
|
geojson.calls.length,
|
|
2,
|
|
'stale load A must not start the landing GeoJson build',
|
|
);
|
|
assert.ok(
|
|
Cesium.Color.equals(
|
|
geojson.calls[1][1].stroke,
|
|
geojson.calls[0][1].stroke,
|
|
),
|
|
"the second GeoJson call is load B's cable build, not A's landing build",
|
|
);
|
|
|
|
env.releaseAll();
|
|
await env.settle();
|
|
await geojson.drainOpen(() => env.settle());
|
|
assert.equal(
|
|
geojson.calls.length,
|
|
3,
|
|
'load B adds its own two GeoJson builds',
|
|
);
|
|
assert.equal(env.addCalls.count, 3);
|
|
assert.equal(env.dataSources.length, 3);
|
|
assert.equal(env.layer.getStats().count, 2);
|
|
} finally {
|
|
geojson.restore();
|
|
env.layer.destroy(env.viewer);
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
test('an abort landing at the pre-add boundary stops the stale load before any dataSources.add', async () => {
|
|
const env = createDeferredCableLayerHarness();
|
|
const geojson = installGeoJsonLoadGate();
|
|
try {
|
|
env.layer.enable(env.viewer);
|
|
env.releaseAll();
|
|
await env.settle();
|
|
geojson.open(0);
|
|
await env.settle();
|
|
assert.equal(
|
|
geojson.calls.length,
|
|
2,
|
|
'load A is parked on the gated landing GeoJson build',
|
|
);
|
|
|
|
// Open the landing gate and abort in the same frame: A resumes at the
|
|
// pre-add owns() check with ownership lost.
|
|
geojson.open(1);
|
|
env.layer.disable();
|
|
env.layer.enable(env.viewer);
|
|
await env.settle();
|
|
assert.equal(
|
|
env.addCalls.count,
|
|
0,
|
|
'stale load A must never call dataSources.add',
|
|
);
|
|
|
|
env.releaseAll();
|
|
await env.settle();
|
|
await geojson.drainOpen(() => env.settle());
|
|
assert.equal(env.addCalls.count, 3, 'load B alone adds the trio');
|
|
assert.equal(env.dataSources.length, 3);
|
|
assert.equal(env.layer.getStats().count, 2);
|
|
} finally {
|
|
geojson.restore();
|
|
env.layer.destroy(env.viewer);
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
test('a destroy inside the deferred add window leaves zero post-destroy data sources', async () => {
|
|
const env = createDeferredCableLayerHarness();
|
|
try {
|
|
env.layer.enable(env.viewer);
|
|
env.releaseAll();
|
|
env.holdAdds();
|
|
// Let the load run until all three adds have been CALLED but none has
|
|
// settled — Cesium mutates the collection on a deferred tick, so this
|
|
// window is real, and a remove() issued inside it removes nothing.
|
|
const deadline = Date.now() + 2_000;
|
|
while (env.addCalls.count < 3 && Date.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
assert.equal(
|
|
env.addCalls.count,
|
|
3,
|
|
'all three adds are in their deferred window',
|
|
);
|
|
assert.equal(
|
|
env.dataSources.length,
|
|
0,
|
|
'nothing has settled into the collection yet',
|
|
);
|
|
|
|
// Destroy lands in the window: it can remove nothing, so the stale
|
|
// load's own post-add ownership check must compensate once the deferred
|
|
// adds settle.
|
|
env.layer.destroy(env.viewer);
|
|
env.releaseHeldAdds();
|
|
await env.settle();
|
|
assert.equal(
|
|
env.dataSources.length,
|
|
0,
|
|
'the deferred adds settled post-destroy and must be rolled back by their own load',
|
|
);
|
|
|
|
// The layer still honors its re-init contract afterwards.
|
|
env.layer.enable(env.viewer);
|
|
env.releaseAll();
|
|
await env.settle();
|
|
assert.equal(
|
|
env.dataSources.length,
|
|
3,
|
|
're-enable after the raced destroy loads cleanly',
|
|
);
|
|
assert.equal(env.layer.getStats().count, 2);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
test('a destroy in the deferred window with a rejected-after-push add still leaves zero sources', async () => {
|
|
const env = createDeferredCableLayerHarness();
|
|
try {
|
|
env.layer.enable(env.viewer);
|
|
env.releaseAll();
|
|
env.holdAdds();
|
|
// Cesium pushes the source into the collection BEFORE raising
|
|
// dataSourceAdded, so a throwing listener rejects the add promise AFTER
|
|
// its mutation landed. Make add #1 exactly that: pushed, then rejected;
|
|
// #2/#3 fulfill normally.
|
|
env.failAddAfterPush(0);
|
|
const deadline = Date.now() + 2_000;
|
|
while (env.addCalls.count < 3 && Date.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
assert.equal(
|
|
env.addCalls.count,
|
|
3,
|
|
'all three adds are in their deferred window',
|
|
);
|
|
assert.equal(
|
|
env.dataSources.length,
|
|
0,
|
|
'nothing has settled into the collection yet',
|
|
);
|
|
|
|
// Destroy lands in the window; the adds then settle as
|
|
// [rejected-after-push, fulfilled, fulfilled]. Compensation that removes
|
|
// only FULFILLED adds would strand the rejected-but-pushed source.
|
|
env.layer.destroy(env.viewer);
|
|
env.releaseHeldAdds();
|
|
await env.settle();
|
|
assert.equal(
|
|
env.dataSources.length,
|
|
0,
|
|
'a rejected add still landed its push, so compensation must remove it too',
|
|
);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
test('a direct destroy of a published layer clears and hides its host source', async () => {
|
|
const env = createDeferredCableLayerHarness();
|
|
try {
|
|
env.layer.enable(env.viewer);
|
|
env.releaseAll();
|
|
await env.settle();
|
|
assert.equal(env.dataSources.length, 3, 'layer is fully loaded');
|
|
// Drive the sweep through the layer's real preRender listener so the
|
|
// host actually holds published cable entries.
|
|
env.raiseSweep();
|
|
assert.ok(
|
|
env.hostCalls.some(([type]) => type === 'entries'),
|
|
'sweep must publish before the direct destroy',
|
|
);
|
|
env.hostCalls.length = 0;
|
|
|
|
// Destroy DIRECTLY — no disable() first. The publisher teardown must
|
|
// still clear the published source and hide it; skipping it would leave
|
|
// a visible overlay source with orphan labels.
|
|
env.layer.destroy(env.viewer);
|
|
assert.ok(
|
|
env.hostCalls.some(
|
|
([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID,
|
|
),
|
|
'direct destroy must clear the published host source',
|
|
);
|
|
assert.deepEqual(
|
|
env.hostCalls.at(-1),
|
|
['visible', CABLE_OVERLAY_SOURCE_ID, false],
|
|
'direct destroy must hide the host source after clearing it',
|
|
);
|
|
} finally {
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
test("a stale aborted load never clobbers the next load's lifecycle (A/B/C toggle race)", async () => {
|
|
const env = createDeferredCableLayerHarness();
|
|
try {
|
|
// A: enable starts a load whose fetches stay pending.
|
|
env.layer.enable(env.viewer);
|
|
assert.equal(
|
|
env.pendingFetches.length,
|
|
2,
|
|
'load A has two fetches in flight',
|
|
);
|
|
|
|
// Abort A and immediately start B before A\'s rejection lands.
|
|
env.layer.disable();
|
|
env.layer.enable(env.viewer);
|
|
await env.settle(); // A\'s stale catch/finally runs here, after B began.
|
|
|
|
// C: with A\'s stale completion having cleared nothing it does not own,
|
|
// the layer still reports a load in flight, so update() must not start a
|
|
// third load.
|
|
void env.layer.update(env.viewer);
|
|
await env.settle();
|
|
const inFlight = env.pendingFetches.filter(
|
|
(entry) => !entry.settled,
|
|
).length;
|
|
assert.equal(
|
|
inFlight,
|
|
2,
|
|
"only load B's two fetches remain in flight — no duplicate load C",
|
|
);
|
|
|
|
// Releasing everything must produce exactly one set of scene adds.
|
|
env.releaseAll();
|
|
await env.settle();
|
|
assert.equal(
|
|
env.addCalls.count,
|
|
3,
|
|
'exactly one cable/landing/reference data-source trio is added',
|
|
);
|
|
assert.equal(env.dataSources.length, 3);
|
|
const stats = env.layer.getStats();
|
|
assert.equal(stats.error, null);
|
|
assert.equal(stats.count, 2, 'one cable + one landing point loaded once');
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
test('destroy mid-load stays clean and a re-enabled layer reloads exactly once', async () => {
|
|
const env = createDeferredCableLayerHarness();
|
|
try {
|
|
// Destroy while load A\'s fetches are still in flight.
|
|
env.layer.enable(env.viewer);
|
|
assert.equal(env.pendingFetches.length, 2);
|
|
env.layer.destroy(env.viewer);
|
|
|
|
// Legacy contract: the layer supports re-init after destroy. Enable D
|
|
// before A\'s aborted rejection lands.
|
|
env.layer.enable(env.viewer);
|
|
await env.settle(); // A\'s stale finally runs here.
|
|
|
|
// A\'s stale completion must not have cleared D\'s in-flight lifecycle:
|
|
// update() must not start a duplicate load E.
|
|
void env.layer.update(env.viewer);
|
|
await env.settle();
|
|
const inFlight = env.pendingFetches.filter(
|
|
(entry) => !entry.settled,
|
|
).length;
|
|
assert.equal(inFlight, 2, "only load D's two fetches remain in flight");
|
|
|
|
env.releaseAll();
|
|
await env.settle();
|
|
assert.equal(
|
|
env.addCalls.count,
|
|
3,
|
|
'the destroyed load added nothing; the re-enable added once',
|
|
);
|
|
assert.equal(env.dataSources.length, 3);
|
|
assert.equal(env.layer.getStats().count, 2);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
env.cleanup();
|
|
}
|
|
});
|
|
|
|
async function createRealCableLayerHarness({
|
|
source,
|
|
mapStackEventTarget = null,
|
|
} = {}) {
|
|
const hostCalls = [];
|
|
// The sweep gate's motion-fallback clock is FROZEN here and advanced only by
|
|
// the tests that exercise it, so every other harness test observes the pure
|
|
// event-dirty behavior with no wall-clock coupling.
|
|
let clockMs = 0;
|
|
const layer = createTeleGeographySubmarineCableLayer({
|
|
overlayHost: {
|
|
setVisible: (...args) => hostCalls.push(['visible', ...args]),
|
|
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
|
clearSource: (...args) => hostCalls.push(['clear', ...args]),
|
|
},
|
|
screenSpaceEventHandlerFactory: () => ({
|
|
setInputAction() {},
|
|
destroy() {},
|
|
}),
|
|
...(source ? { source } : {}),
|
|
mapStackEventTarget,
|
|
sweepClock: () => clockMs,
|
|
});
|
|
|
|
const listeners = { preRender: new Set(), moveEnd: new Set() };
|
|
const dataSources = [];
|
|
const viewer = {
|
|
dataSources: {
|
|
add: (source) => {
|
|
dataSources.push(source);
|
|
return Promise.resolve(source);
|
|
},
|
|
remove: (source) => {
|
|
const index = dataSources.indexOf(source);
|
|
if (index >= 0) dataSources.splice(index, 1);
|
|
return true;
|
|
},
|
|
},
|
|
camera: {
|
|
positionWC: Cesium.Cartesian3.fromDegrees(-40, 35, 4_500_000),
|
|
frustum: { fov: Math.PI / 3 },
|
|
moveEnd: {
|
|
addEventListener: (fn) => {
|
|
listeners.moveEnd.add(fn);
|
|
return () => listeners.moveEnd.delete(fn);
|
|
},
|
|
},
|
|
heading: 0,
|
|
cancelFlight() {},
|
|
flyTo() {},
|
|
},
|
|
scene: {
|
|
canvas: { clientHeight: 900 },
|
|
requestRender() {},
|
|
pick() {
|
|
return null;
|
|
},
|
|
preRender: {
|
|
addEventListener: (fn) => {
|
|
listeners.preRender.add(fn);
|
|
return () => listeners.preRender.delete(fn);
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
// Node has no `document`, so Point features (whose GeoJSON default marker
|
|
// needs a canvas PinBuilder) cannot load here. Landing references only need
|
|
// `properties.coordinates`, so a line geometry exercises the same path.
|
|
globalThis.fetch = async (url) => ({
|
|
ok: true,
|
|
json: async () =>
|
|
String(url).includes('landing-point')
|
|
? {
|
|
type: 'FeatureCollection',
|
|
features: [
|
|
{
|
|
type: 'Feature',
|
|
id: 'lisbon',
|
|
properties: {
|
|
id: 'lisbon',
|
|
name: 'Lisbon, Portugal',
|
|
coordinates: [-9.1, 38.7],
|
|
},
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: [
|
|
[-9.1, 38.7],
|
|
[-9.05, 38.72],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
: {
|
|
type: 'FeatureCollection',
|
|
features: [
|
|
{
|
|
type: 'Feature',
|
|
id: 'ac1',
|
|
properties: { id: 'ac1', name: 'Atlantic Crossing-1' },
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: [
|
|
[-40, 35],
|
|
[-30, 40],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
try {
|
|
layer.init(viewer);
|
|
layer.enable(viewer);
|
|
// enable() fires load() without awaiting it (legacy contract); poll the
|
|
// layer's own stats until the load settles.
|
|
const deadline = Date.now() + 2_000;
|
|
while (Date.now() < deadline) {
|
|
const stats = layer.getStats();
|
|
if (stats.count > 0 || stats.error) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
assert.equal(layer.getStats().error, null, 'harness load must succeed');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
const raiseSweep = () => {
|
|
for (const fn of listeners.preRender) fn();
|
|
};
|
|
const advanceClock = (ms) => {
|
|
clockMs += ms;
|
|
};
|
|
return {
|
|
layer,
|
|
viewer,
|
|
hostCalls,
|
|
dataSources,
|
|
raiseSweep,
|
|
listeners,
|
|
advanceClock,
|
|
};
|
|
}
|
|
|
|
test('a real enabled cable layer publishes host entries and has zero native labels', async () => {
|
|
const env = await createRealCableLayerHarness();
|
|
env.raiseSweep();
|
|
|
|
const publishes = env.hostCalls.filter(([type]) => type === 'entries');
|
|
assert.ok(publishes.length > 0, 'sweep must publish to the shared host');
|
|
const [, sourceId, entries, options] = publishes.at(-1);
|
|
assert.equal(sourceId, CABLE_OVERLAY_SOURCE_ID);
|
|
assert.equal(entries.length, 2, 'one cable + one landing point reference');
|
|
assert.ok(entries.every((entry) => entry.variant === 'label'));
|
|
assert.deepEqual(options, {
|
|
cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP,
|
|
collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY,
|
|
moving: false,
|
|
});
|
|
|
|
const referenceSource = env.dataSources.find((ds) =>
|
|
/References/.test(ds.name || ''),
|
|
);
|
|
assert.ok(referenceSource, 'reference data source must exist');
|
|
const referenceEntities = referenceSource.entities.values;
|
|
assert.ok(referenceEntities.length > 0);
|
|
assert.ok(
|
|
referenceEntities.every((entity) => entity.label === undefined),
|
|
'no reference entity may carry a native LabelGraphics',
|
|
);
|
|
assert.ok(
|
|
referenceEntities.every(
|
|
(entity) =>
|
|
entity.position?.isConstant === true &&
|
|
entity.polyline?.positions?.isConstant === true,
|
|
),
|
|
'stem properties must be constants, never per-frame callbacks',
|
|
);
|
|
|
|
const stats = env.layer.getStats();
|
|
assert.equal(stats.referenceLabelCount, 2);
|
|
|
|
env.layer.disable();
|
|
assert.ok(
|
|
env.hostCalls.some(
|
|
([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID,
|
|
),
|
|
'disable must clear the host source',
|
|
);
|
|
assert.deepEqual(
|
|
env.hostCalls.at(-1),
|
|
['visible', CABLE_OVERLAY_SOURCE_ID, false],
|
|
'disable must hide the host source after clearing it',
|
|
);
|
|
|
|
env.layer.destroy(env.viewer);
|
|
assert.equal(
|
|
env.dataSources.length,
|
|
0,
|
|
'destroy must remove every data source',
|
|
);
|
|
});
|
|
|
|
test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async () => {
|
|
const env = await createRealCableLayerHarness();
|
|
env.raiseSweep(); // initial sizing at the parked camera
|
|
|
|
const referenceSource = env.dataSources.find((ds) =>
|
|
/References/.test(ds.name || ''),
|
|
);
|
|
const entity = referenceSource.entities.values[0];
|
|
let stemRebuilds = 0;
|
|
const originalSet = entity.polyline.positions.setValue.bind(
|
|
entity.polyline.positions,
|
|
);
|
|
entity.polyline.positions.setValue = (...args) => {
|
|
stemRebuilds++;
|
|
return originalSet(...args);
|
|
};
|
|
|
|
// Mid-drag: the camera moves every tick and frames keep rendering, but no
|
|
// moveEnd fires. However long this goes on, the stem constants must never
|
|
// be redefined — each redefinition rebuilt the whole batched stem
|
|
// primitive, and its unready replacement frames were the felt hitch.
|
|
const camera = env.viewer.camera.positionWC;
|
|
for (let tick = 0; tick < 30; tick++) {
|
|
env.viewer.camera.positionWC = Cesium.Cartesian3.add(
|
|
camera,
|
|
new Cesium.Cartesian3(0, 0, 100_000 * (tick + 1)),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
env.raiseSweep(); // preRender fires every frame during a drag
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
assert.equal(
|
|
stemRebuilds,
|
|
0,
|
|
'render ticks without moveEnd must never redefine a stem',
|
|
);
|
|
|
|
// The gesture ends close over the cable's reference point (inside the
|
|
// 700 m..85 km stem clamp band, so the re-size is a real tip change):
|
|
// one moveEnd, one sweep, exactly one redefinition.
|
|
env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
-35,
|
|
37.5,
|
|
400_000,
|
|
);
|
|
for (const fn of env.listeners.moveEnd) fn();
|
|
env.raiseSweep();
|
|
assert.equal(stemRebuilds, 1, 'moveEnd re-sizes the stem exactly once');
|
|
env.raiseSweep();
|
|
assert.equal(stemRebuilds, 1, 'the next frame does not re-run the sweep');
|
|
|
|
env.layer.destroy(env.viewer);
|
|
});
|
|
|
|
test('a tracked camera that never emits moveEnd is swept by the motion fallback', async () => {
|
|
const env = await createRealCableLayerHarness();
|
|
// Tracking hands over a mid-altitude follow camera. 400 km keeps the stem
|
|
// inside the 700 m..85 km clamp band, so a sweep is observable as a real
|
|
// tip re-size (at the harness's 4,500 km parking altitude every stem sits
|
|
// pinned at the 85 km clamp and no sweep could be distinguished).
|
|
env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
-35,
|
|
37.5,
|
|
400_000,
|
|
);
|
|
env.raiseSweep(); // initial sizing at the tracking handoff
|
|
|
|
const referenceSource = env.dataSources.find((ds) =>
|
|
/References/.test(ds.name || ''),
|
|
);
|
|
const entity = referenceSource.entities.values[0];
|
|
let stemRebuilds = 0;
|
|
const originalSet = entity.polyline.positions.setValue.bind(
|
|
entity.polyline.positions,
|
|
);
|
|
entity.polyline.positions.setValue = (...args) => {
|
|
stemRebuilds++;
|
|
return originalSet(...args);
|
|
};
|
|
|
|
// Entity tracking / orbit: the follow camera advances every frame and
|
|
// moveEnd NEVER fires. Frames are simulated at 60 Hz on the harness clock.
|
|
const origin = env.viewer.camera.positionWC;
|
|
const frameMs = 1000 / 60;
|
|
const trackFrames = (from, to) => {
|
|
for (let frame = from; frame < to; frame++) {
|
|
env.advanceClock(frameMs);
|
|
env.viewer.camera.positionWC = Cesium.Cartesian3.add(
|
|
origin,
|
|
new Cesium.Cartesian3(0, 0, 200 * (frame + 1)),
|
|
new Cesium.Cartesian3(),
|
|
);
|
|
env.raiseSweep();
|
|
}
|
|
};
|
|
|
|
// Inside the first probe window the sweep stays event-dirty-only, so the
|
|
// batched stem primitive is not rebuilt mid-motion (the shipped tradeoff).
|
|
trackFrames(0, 60);
|
|
assert.equal(
|
|
stemRebuilds,
|
|
0,
|
|
'inside the probe window a tracked camera still costs no rebuilds',
|
|
);
|
|
|
|
// Past the window the fallback fires — exactly once, not per frame.
|
|
trackFrames(60, 130);
|
|
assert.equal(
|
|
stemRebuilds,
|
|
1,
|
|
'the tracked camera earns exactly one fallback sweep per window',
|
|
);
|
|
|
|
// Tracking stops but frames keep rendering. The motion accumulated since
|
|
// the last fallback sweep settles in exactly ONE further sweep...
|
|
const parkFrames = (count) => {
|
|
for (let frame = 0; frame < count; frame++) {
|
|
env.advanceClock(frameMs);
|
|
env.raiseSweep();
|
|
}
|
|
};
|
|
parkFrames(240); // 4 s parked: two full probe windows
|
|
assert.equal(
|
|
stemRebuilds,
|
|
2,
|
|
'a stopped camera settles its residual motion in one further sweep',
|
|
);
|
|
|
|
// ...and the parked camera then costs zero sweeps for as long as it sits.
|
|
parkFrames(3600); // 60 s parked
|
|
assert.equal(
|
|
stemRebuilds,
|
|
2,
|
|
'a settled parked camera never spends another sweep',
|
|
);
|
|
|
|
env.layer.destroy(env.viewer);
|
|
});
|
|
|
|
test('an unchanged cohort never republishes, so a parked camera stays governor-idle', async () => {
|
|
const env = await createRealCableLayerHarness();
|
|
const publishCount = () =>
|
|
env.hostCalls.filter(([type]) => type === 'entries').length;
|
|
|
|
env.raiseSweep();
|
|
assert.equal(publishCount(), 1, 'first sweep publishes the cohort');
|
|
|
|
// moveEnd with an unchanged camera re-arms the sweep; the identical winner
|
|
// set must NOT reach the host again (each publish costs a requested render).
|
|
for (const fn of env.listeners.moveEnd) fn();
|
|
env.raiseSweep();
|
|
for (const fn of env.listeners.moveEnd) fn();
|
|
env.raiseSweep();
|
|
assert.equal(publishCount(), 1, 'identical cohorts are skipped');
|
|
|
|
// A real camera move changes distances beyond the 50 km priority quantum,
|
|
// so the next sweep publishes again.
|
|
env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
|
-40,
|
|
35,
|
|
6_500_000,
|
|
);
|
|
for (const fn of env.listeners.moveEnd) fn();
|
|
env.raiseSweep();
|
|
assert.equal(publishCount(), 2, 'a changed cohort publishes');
|
|
|
|
// disable → enable must force a republish even when the cohort is identical
|
|
// (the hide cleared the host source).
|
|
env.layer.disable();
|
|
env.layer.enable(env.viewer);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
env.raiseSweep();
|
|
assert.equal(
|
|
publishCount(),
|
|
3,
|
|
're-enable republishes after the hide cleared the source',
|
|
);
|
|
|
|
env.layer.destroy(env.viewer);
|
|
});
|
|
|
|
test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => {
|
|
const env = await createRealCableLayerHarness();
|
|
assert.equal(
|
|
env.dataSources.length,
|
|
3,
|
|
'cable + landing + reference sources while on',
|
|
);
|
|
const before = env.layer.getStats().count;
|
|
assert.equal(before, 2);
|
|
|
|
// Any fetch after this point is a bug: the rebuild must come from the cache.
|
|
const originalFetch = globalThis.fetch;
|
|
let fetches = 0;
|
|
globalThis.fetch = async () => {
|
|
fetches += 1;
|
|
throw new Error('unexpected refetch');
|
|
};
|
|
try {
|
|
env.layer.disable();
|
|
assert.equal(
|
|
env.dataSources.length,
|
|
0,
|
|
'hidden entities are not free; disable must remove the sources',
|
|
);
|
|
|
|
env.layer.enable(env.viewer);
|
|
const deadline = Date.now() + 2_000;
|
|
while (Date.now() < deadline && env.dataSources.length < 3) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
assert.equal(fetches, 0, 'the rebuild must not touch the network');
|
|
assert.equal(env.layer.getStats().error, null);
|
|
assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio');
|
|
assert.equal(env.layer.getStats().count, before);
|
|
const referenceSource = env.dataSources.find((ds) =>
|
|
/References/.test(ds.name || ''),
|
|
);
|
|
assert.ok(
|
|
referenceSource?.entities.values.length > 0,
|
|
'reference stems are rebuilt',
|
|
);
|
|
|
|
env.layer.destroy(env.viewer);
|
|
assert.equal(env.dataSources.length, 0);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test('another GeoJSON source renders with its own label and rebuilds from accepted cache', async () => {
|
|
let reads = 0;
|
|
const source = {
|
|
label: 'Test cable directory',
|
|
async fetch(signal) {
|
|
signal.throwIfAborted();
|
|
reads += 1;
|
|
return { cables: CABLE_FIXTURE, landingPoints: LANDING_FIXTURE };
|
|
},
|
|
};
|
|
const env = await createRealCableLayerHarness({ source });
|
|
try {
|
|
assert.equal(env.layer.source, source.label);
|
|
assert.equal(reads, 1);
|
|
assert.equal(env.dataSources.length, 3);
|
|
assert.ok(
|
|
env.dataSources.every((item) => item.name.startsWith(source.label)),
|
|
);
|
|
env.layer.disable();
|
|
assert.equal(env.dataSources.length, 0);
|
|
env.layer.enable(env.viewer);
|
|
for (let i = 0; i < 8; i++)
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(env.layer.getStats().count, 2);
|
|
assert.equal(env.dataSources.length, 3);
|
|
assert.equal(reads, 1);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
}
|
|
});
|
|
|
|
function multipartCableFixture() {
|
|
return [
|
|
{
|
|
type: 'Feature',
|
|
properties: {
|
|
id: 'alpha',
|
|
name: 'Alpha cable',
|
|
color: '#ff0000',
|
|
coordinates: [-40, 35],
|
|
},
|
|
geometry: {
|
|
type: 'MultiLineString',
|
|
coordinates: [
|
|
[
|
|
[-40, 35],
|
|
[-39, 36],
|
|
],
|
|
[
|
|
[-38, 37],
|
|
[-37, 38],
|
|
],
|
|
[
|
|
[-36, 39],
|
|
[-35, 40],
|
|
],
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: 'Feature',
|
|
properties: {
|
|
id: 'beta',
|
|
name: 'Beta cable',
|
|
color: '#0000ff',
|
|
coordinates: [140, -35],
|
|
},
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: [
|
|
[140, -35],
|
|
[141, -34],
|
|
],
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function geometrySource(cables, landingPoints = []) {
|
|
return {
|
|
label: 'Multipart test source',
|
|
async fetch() {
|
|
return {
|
|
cables: { type: 'FeatureCollection', features: cables },
|
|
landingPoints: { type: 'FeatureCollection', features: landingPoints },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
test('multipart association accepts frozen provider collections without modifying properties', async () => {
|
|
const payload = {
|
|
cables: { type: 'FeatureCollection', features: multipartCableFixture() },
|
|
landingPoints: {
|
|
type: 'FeatureCollection',
|
|
features: multipartCableFixture(),
|
|
},
|
|
};
|
|
payload.cables.features[0].properties.__gevSourceFeatureIndex =
|
|
'provider value';
|
|
const before = structuredClone(payload);
|
|
const freeze = (value) => {
|
|
if (value && typeof value === 'object') {
|
|
for (const child of Object.values(value)) freeze(child);
|
|
Object.freeze(value);
|
|
}
|
|
return value;
|
|
};
|
|
freeze(payload);
|
|
const env = await createRealCableLayerHarness({
|
|
source: { label: 'Frozen multipart source', fetch: async () => payload },
|
|
});
|
|
try {
|
|
for (const dataSource of env.dataSources.slice(0, 2)) {
|
|
assert.deepEqual(
|
|
dataSource.entities.values.map(
|
|
(entity) => entity.__gevTeleGeography?.label,
|
|
),
|
|
['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'],
|
|
);
|
|
}
|
|
assert.equal(env.dataSources[2].entities.values.length, 4);
|
|
assert.deepEqual(payload, before);
|
|
const entity = env.dataSources[0].entities.values[0];
|
|
assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0);
|
|
assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
}
|
|
});
|
|
|
|
test('multipart cables retain their own styling, picking and culling on every segment', async () => {
|
|
const features = multipartCableFixture();
|
|
const before = JSON.stringify(features);
|
|
const env = await createRealCableLayerHarness({
|
|
source: geometrySource(features),
|
|
});
|
|
try {
|
|
const entities = env.dataSources[0].entities.values;
|
|
assert.equal(entities.length, 4);
|
|
for (const [index, entity] of entities.entries()) {
|
|
const feature = features[index < 3 ? 0 : 1];
|
|
const info = entity.__gevTeleGeography;
|
|
assert.equal(info?.label, feature.properties.name, entity.id);
|
|
assert.deepEqual(info.reference, {
|
|
lon: feature.properties.coordinates[0],
|
|
lat: feature.properties.coordinates[1],
|
|
});
|
|
assert.deepEqual(
|
|
entity.polyline.material.color.getValue(),
|
|
Cesium.Color.fromCssColorString(feature.properties.color).withAlpha(
|
|
0.92,
|
|
),
|
|
);
|
|
assert.equal(entity.polyline.width.getValue(), 2.5);
|
|
}
|
|
env.raiseSweep();
|
|
assert.deepEqual(
|
|
entities.map((entity) => entity.show),
|
|
[true, true, true, false],
|
|
);
|
|
assert.equal(
|
|
env.dataSources[2].entities.values.length,
|
|
2,
|
|
'one reference per feature',
|
|
);
|
|
assert.equal(
|
|
JSON.stringify(features),
|
|
before,
|
|
'source features stay unchanged',
|
|
);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
}
|
|
});
|
|
|
|
test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => {
|
|
const features = multipartCableFixture();
|
|
features[1].id = 'alpha_2';
|
|
delete features[1].properties.id;
|
|
const generated = structuredClone(features[1]);
|
|
delete generated.id;
|
|
generated.properties.name = 'Generated cable';
|
|
features.push(generated);
|
|
const repeated = structuredClone(features[1]);
|
|
repeated.properties.id = 'alpha';
|
|
repeated.properties.name = 'Repeated ID cable';
|
|
features.push(repeated);
|
|
const env = await createRealCableLayerHarness({
|
|
source: geometrySource(features),
|
|
});
|
|
try {
|
|
const entities = env.dataSources[0].entities.values;
|
|
assert.deepEqual(
|
|
entities.map((entity) => entity.id),
|
|
['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'],
|
|
);
|
|
assert.deepEqual(
|
|
entities.map((entity) => entity.__gevTeleGeography?.label),
|
|
[
|
|
'Alpha cable',
|
|
'Alpha cable',
|
|
'Alpha cable',
|
|
'Beta cable',
|
|
'Generated cable',
|
|
'Repeated ID cable',
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
env.dataSources[2].entities.values.map(
|
|
(entity) => entity.__gevTeleGeography.featureId,
|
|
),
|
|
['alpha', 'alpha_2', 'cable-2', 'alpha'],
|
|
);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
}
|
|
});
|
|
|
|
test('multipart landing geometry keeps every pick with one reference per feature', async () => {
|
|
const features = multipartCableFixture();
|
|
const env = await createRealCableLayerHarness({
|
|
source: geometrySource([], features),
|
|
});
|
|
try {
|
|
const entities = env.dataSources[1].entities.values;
|
|
assert.deepEqual(
|
|
entities.map((entity) => entity.__gevTeleGeography?.label),
|
|
['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'],
|
|
);
|
|
assert.ok(
|
|
entities.every(
|
|
(entity) => entity.__gevTeleGeography.kind === 'landing-point',
|
|
),
|
|
);
|
|
assert.equal(env.dataSources[2].entities.values.length, 2);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
}
|
|
});
|
|
|
|
test('every bundled cable segment is associated with its source feature', async () => {
|
|
const { features } = JSON.parse(
|
|
readFileSync(
|
|
new URL(
|
|
'./local_data/telegeography_submarine_cables/cable-geo.json',
|
|
import.meta.url,
|
|
),
|
|
'utf8',
|
|
),
|
|
);
|
|
// Some bundled features share a cable ID but have different references.
|
|
const expandedFeatures = features.flatMap((feature) =>
|
|
feature.geometry.coordinates.map(() => feature),
|
|
);
|
|
assert.ok(expandedFeatures.length > features.length);
|
|
const env = await createRealCableLayerHarness({
|
|
source: geometrySource(features),
|
|
});
|
|
try {
|
|
const entities = env.dataSources[0].entities.values;
|
|
assert.equal(entities.length, expandedFeatures.length);
|
|
for (const [index, entity] of entities.entries()) {
|
|
const feature = expandedFeatures[index];
|
|
assert.equal(
|
|
entity.__gevTeleGeography?.label,
|
|
feature.properties.name,
|
|
entity.id,
|
|
);
|
|
assert.deepEqual(entity.__gevTeleGeography.reference, {
|
|
lon: feature.properties.coordinates[0],
|
|
lat: feature.properties.coordinates[1],
|
|
});
|
|
}
|
|
assert.equal(env.dataSources[2].entities.values.length, features.length);
|
|
} finally {
|
|
env.layer.destroy(env.viewer);
|
|
}
|
|
});
|