mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Traffic roads come from OpenFreeMap vector tiles, or TomTom flow tiles with a key (Hybrid by default). Military areas use OpenFreeMap polygons with a bundled worldwide name index; wide views show named points. ALPR reads an hourly OpenStreetMap extract. Cockpit place context and country, state and US county outlines use bundled Natural Earth and US Census data. Overpass is used only when an operator configures their own endpoint. Also: camera arrivals no longer land below ground, keyless terrain retries on 429, bounded relation geometry for configured Overpass, one shared OpenStreetMap credit, and separate tile and API transports for embedders. Co-authored-by: theGaryLarson <[email protected]> Co-authored-by: Milan Khanal <[email protected]>
551 lines
22 KiB
JavaScript
551 lines
22 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
ALLOCATION_ELASTIC,
|
||
ALLOCATION_WEIGHTED,
|
||
LabelArbiter,
|
||
allocateLayerQuotas,
|
||
} from './labelArbiter.js';
|
||
|
||
function objectQuotas(map) {
|
||
return Object.fromEntries(Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)));
|
||
}
|
||
|
||
function candidate(layerId, index, x = index * 30) {
|
||
return {
|
||
key: `${layerId}:${index}`,
|
||
layerId,
|
||
sourceId: index,
|
||
priority: 0,
|
||
centerDistance: Math.abs(x),
|
||
keyholeAlpha: 1,
|
||
placements: [{ corner: 'NE', rect: { x, y: layerId === 'flights' ? 0 : 80, w: 20, h: 12 } }],
|
||
};
|
||
}
|
||
|
||
function spatialCandidate(index, x, y) {
|
||
return {
|
||
key: `satellites:${index}`,
|
||
layerId: 'satellites',
|
||
priority: 25,
|
||
centerDistance: Math.hypot(x, y),
|
||
keyholeAlpha: 1,
|
||
screenX: x,
|
||
screenY: y,
|
||
placements: [{ corner: 'NE', rect: { x: x * 2, y: y * 2, w: 10, h: 8 } }],
|
||
};
|
||
}
|
||
|
||
test('Elastic gives one layer full capacity and two layers an equal entitlement', () => {
|
||
assert.deepEqual(objectQuotas(allocateLayerQuotas({ cctv: 200 }, 90, ALLOCATION_ELASTIC)), { cctv: 90 });
|
||
assert.deepEqual(
|
||
objectQuotas(allocateLayerQuotas({ cctv: 200, traffic: 200 }, 90, ALLOCATION_ELASTIC)),
|
||
{ cctv: 45, traffic: 45 },
|
||
);
|
||
});
|
||
|
||
test('Elastic redistributes unused capacity', () => {
|
||
assert.deepEqual(
|
||
objectQuotas(allocateLayerQuotas({ cctv: 200, traffic: 20 }, 90, ALLOCATION_ELASTIC)),
|
||
{ cctv: 70, traffic: 20 },
|
||
);
|
||
});
|
||
|
||
test('Weighted is deterministic, representative, and work-conserving', () => {
|
||
const first = allocateLayerQuotas({ flights: 1000, satellites: 100 }, 28, ALLOCATION_WEIGHTED);
|
||
const reverse = allocateLayerQuotas(new Map([['satellites', 100], ['flights', 1000]]), 28, ALLOCATION_WEIGHTED);
|
||
assert.deepEqual(objectQuotas(first), objectQuotas(reverse));
|
||
assert.equal(Array.from(first.values()).reduce((sum, value) => sum + value, 0), 28);
|
||
assert.ok(first.get('flights') > 0);
|
||
assert.ok(first.get('satellites') > 0);
|
||
assert.ok(first.get('flights') > first.get('satellites'));
|
||
});
|
||
|
||
test('arbiter is registration-order independent and represents both layers', () => {
|
||
const objects = [
|
||
...Array.from({ length: 20 }, (_, index) => candidate('flights', index)),
|
||
...Array.from({ length: 20 }, (_, index) => candidate('satellites', index)),
|
||
];
|
||
const a = new LabelArbiter();
|
||
const b = new LabelArbiter();
|
||
a.solve(objects, { capacity: 10, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||
b.solve(objects.slice().reverse(), { capacity: 10, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||
assert.deepEqual(Array.from(a.selectedKeys).sort(), Array.from(b.selectedKeys).sort());
|
||
assert.equal(a.diagnostics().labelsByLayer.flights, 5);
|
||
assert.equal(a.diagnostics().labelsByLayer.satellites, 5);
|
||
});
|
||
|
||
test('unchanged solves and policy toggles preserve winning identities', () => {
|
||
const objects = [
|
||
...Array.from({ length: 12 }, (_, index) => candidate('flights', index)),
|
||
...Array.from({ length: 6 }, (_, index) => candidate('satellites', index)),
|
||
];
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve(objects, { capacity: 8, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||
const first = new Set(arbiter.selectedKeys);
|
||
arbiter.solve(objects, { capacity: 8, strategy: ALLOCATION_ELASTIC, now: 1125 });
|
||
assert.deepEqual(arbiter.selectedKeys, first);
|
||
arbiter.solve(objects, { capacity: 8, strategy: ALLOCATION_WEIGHTED, now: 1250 });
|
||
const retained = Array.from(first).filter((key) => arbiter.selectedKeys.has(key));
|
||
assert.ok(retained.length >= 6);
|
||
});
|
||
|
||
test('incumbents satisfying the layer target skip spatial queue construction', () => {
|
||
const objects = Array.from({ length: 12 }, (_, index) => candidate('flights', index));
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve(objects, { capacity: 6, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||
assert.ok(arbiter.diagnostics().spatialQueueBuildCount > 0);
|
||
|
||
arbiter.solve(objects, {
|
||
capacity: 6,
|
||
strategy: ALLOCATION_ELASTIC,
|
||
now: 1125,
|
||
preserveIncumbents: true,
|
||
});
|
||
assert.equal(arbiter.diagnostics().spatialQueueBuildCount, 0);
|
||
});
|
||
|
||
test('candidate anchors are cached once as scalar coordinates for queue scans', () => {
|
||
const objects = [candidate('flights', 1, 40), candidate('flights', 2, 100)];
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve(objects, { capacity: 1, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||
assert.deepEqual(
|
||
objects.map((item) => [item._anchorX, item._anchorY]),
|
||
[[50, 6], [110, 6]],
|
||
);
|
||
});
|
||
|
||
test('increasing capacity retains the entire eligible cohort', () => {
|
||
const objects = [
|
||
...Array.from({ length: 30 }, (_, index) => candidate('flights', index)),
|
||
...Array.from({ length: 30 }, (_, index) => candidate('satellites', index)),
|
||
];
|
||
const arbiter = new LabelArbiter();
|
||
let previous = new Set();
|
||
for (const [capacity, now] of [[14, 1000], [21, 1125], [28, 1250]]) {
|
||
arbiter.solve(objects, {
|
||
capacity,
|
||
strategy: ALLOCATION_ELASTIC,
|
||
now,
|
||
preserveIncumbents: true,
|
||
});
|
||
for (const key of previous) assert.ok(arbiter.selectedKeys.has(key));
|
||
previous = new Set(arbiter.selectedKeys);
|
||
}
|
||
});
|
||
|
||
test('collision placement never accepts overlapping cards', () => {
|
||
const objects = Array.from({ length: 5 }, (_, index) => ({
|
||
...candidate('cctv', index, 0),
|
||
placements: [{ corner: 'NE', rect: { x: 0, y: 0, w: 50, h: 20 } }],
|
||
}));
|
||
const arbiter = new LabelArbiter();
|
||
const result = arbiter.solve(objects, { capacity: 5, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||
assert.equal(result.selectedCount, 1);
|
||
});
|
||
|
||
test('saturated vertical-only candidates are dismissed without repeated spread-queue revisits', () => {
|
||
const objects = Array.from({ length: 18 }, (_, index) => ({
|
||
key: `firms:${index}`,
|
||
layerId: 'firms',
|
||
sourceId: index,
|
||
priority: 18 - index,
|
||
centerDistance: index,
|
||
keyholeAlpha: 1,
|
||
screenX: 100,
|
||
screenY: 100,
|
||
placements: [
|
||
{ corner: 'above', rect: { x: 60, y: 50, w: 80, h: 30 } },
|
||
{ corner: 'below', rect: { x: 60, y: 120, w: 80, h: 30 } },
|
||
],
|
||
}));
|
||
const arbiter = new LabelArbiter();
|
||
const result = arbiter.solve(objects, {
|
||
capacity: objects.length,
|
||
demandByLayer: { firms: objects.length },
|
||
now: 1000,
|
||
preserveIncumbents: false,
|
||
});
|
||
assert.equal(result.selectedCount, 2);
|
||
assert.ok(result.spatialQueueNextCount <= 4, `next() calls: ${result.spatialQueueNextCount}`);
|
||
});
|
||
|
||
test('first equal-priority solve distributes labels across the candidate field', () => {
|
||
const objects = [
|
||
spatialCandidate('center', 0, 0),
|
||
spatialCandidate('nw', -100, -100),
|
||
spatialCandidate('ne', 100, -100),
|
||
spatialCandidate('sw', -100, 100),
|
||
spatialCandidate('se', 100, 100),
|
||
spatialCandidate('near-1', -10, -10),
|
||
spatialCandidate('near-2', 10, -10),
|
||
spatialCandidate('near-3', -10, 10),
|
||
spatialCandidate('near-4', 10, 10),
|
||
];
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve(objects, { capacity: 4, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||
const selected = objects.filter((item) => arbiter.selectedKeys.has(item.key));
|
||
const outerCount = selected.filter((item) => item.centerDistance > 100).length;
|
||
const xValues = selected.map((item) => item.screenX);
|
||
const yValues = selected.map((item) => item.screenY);
|
||
assert.ok(outerCount >= 3);
|
||
assert.ok(Math.max(...xValues) - Math.min(...xValues) >= 200);
|
||
assert.ok(Math.max(...yValues) - Math.min(...yValues) >= 200);
|
||
});
|
||
|
||
test('semantic priority outranks spatial separation for new labels', () => {
|
||
const highPriority = { ...spatialCandidate('priority', 0, 0), priority: 120 };
|
||
const farCandidate = spatialCandidate('far', 500, 500);
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve([farCandidate, highPriority], { capacity: 1, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||
assert.deepEqual(arbiter.selectedKeys, new Set([highPriority.key]));
|
||
});
|
||
|
||
test('fading entries remain renderable for the transition window', () => {
|
||
const arbiter = new LabelArbiter();
|
||
const one = [candidate('flights', 1)];
|
||
arbiter.solve(one, { capacity: 1, now: 1000 });
|
||
const pooled = [{}];
|
||
const rendered = arbiter.renderEntries(one, 1150, pooled);
|
||
assert.equal(rendered, pooled);
|
||
assert.equal(rendered[0], pooled[0]);
|
||
assert.equal(rendered[0].temporalAlpha, 1);
|
||
arbiter.solve([], { capacity: 0, now: 1200 });
|
||
assert.ok(arbiter.renderEntries([], 1300)[0].temporalAlpha > 0);
|
||
assert.equal(arbiter.renderEntries([], 1501).length, 0);
|
||
});
|
||
|
||
test('renderEntries reuses output without filter, slice, or sort allocation helpers', () => {
|
||
const arbiter = new LabelArbiter();
|
||
const objects = [candidate('flights', 1), candidate('flights', 2, 80)];
|
||
arbiter.solve(objects, { capacity: 2, now: 1000 });
|
||
const current = new Map(objects.map((item) => [item.key, item]));
|
||
const pooled = [{}, {}];
|
||
const originals = {
|
||
filter: Array.prototype.filter,
|
||
slice: Array.prototype.slice,
|
||
sort: Array.prototype.sort,
|
||
};
|
||
const calls = { filter: 0, slice: 0, sort: 0 };
|
||
for (const name of Object.keys(originals)) {
|
||
Array.prototype[name] = function countRenderAllocationHelper(...args) {
|
||
calls[name]++;
|
||
return originals[name].apply(this, args);
|
||
};
|
||
}
|
||
let rendered;
|
||
try {
|
||
rendered = arbiter.renderEntries(current, 1200, pooled);
|
||
} finally {
|
||
Object.assign(Array.prototype, originals);
|
||
}
|
||
assert.equal(rendered, pooled);
|
||
assert.equal(rendered[0], pooled[0]);
|
||
assert.equal(rendered[1], pooled[1]);
|
||
assert.deepEqual(calls, { filter: 0, slice: 0, sort: 0 });
|
||
});
|
||
|
||
test('moving cohorts re-solve with cohort-independent allocation helpers', () => {
|
||
const movingCohort = (size, frame) => Array.from({ length: size }, (_, index) => {
|
||
const x = ((index * 37 + frame * 3) % 700) + 10;
|
||
const y = ((index * 53 + frame * 5) % 500) + 10;
|
||
return {
|
||
key: `flights:${index}`,
|
||
layerId: 'flights',
|
||
sourceId: index,
|
||
priority: index % 4,
|
||
centerDistance: Math.hypot(x - 350, y - 250),
|
||
keyholeAlpha: 1,
|
||
screenX: x,
|
||
screenY: y,
|
||
placements: [
|
||
{ corner: 'above', rect: { x, y, w: 48, h: 16 } },
|
||
{ corner: 'below', rect: { x, y: y + 24, w: 48, h: 16 } },
|
||
],
|
||
};
|
||
});
|
||
|
||
const originals = {
|
||
filter: Array.prototype.filter,
|
||
slice: Array.prototype.slice,
|
||
sort: Array.prototype.sort,
|
||
push: Array.prototype.push,
|
||
};
|
||
const measure = (size) => {
|
||
const arbiter = new LabelArbiter();
|
||
for (let frame = 0; frame < 6; frame++) {
|
||
arbiter.solve(movingCohort(size, frame), { capacity: size, now: 1000 + frame * 130 });
|
||
}
|
||
const calls = { filter: 0, slice: 0, sort: 0, push: 0 };
|
||
for (const name of Object.keys(originals)) {
|
||
Array.prototype[name] = function countSolveAllocationHelper(...args) {
|
||
calls[name]++;
|
||
return originals[name].apply(this, args);
|
||
};
|
||
}
|
||
try {
|
||
arbiter.solve(movingCohort(size, 6), { capacity: size, now: 1780 });
|
||
} finally {
|
||
Object.assign(Array.prototype, originals);
|
||
}
|
||
return calls;
|
||
};
|
||
|
||
const small = measure(12);
|
||
const large = measure(60);
|
||
// The remaining helpers are layer-scale (quota allocation), so a five-fold
|
||
// larger cohort must not add a single per-candidate array operation.
|
||
assert.deepEqual(large, small);
|
||
assert.ok(small.slice + small.push === 0);
|
||
});
|
||
|
||
test('selected-key membership is republished in place across solves', () => {
|
||
const arbiter = new LabelArbiter();
|
||
const objects = Array.from({ length: 6 }, (_, index) => candidate('flights', index));
|
||
arbiter.solve(objects, { capacity: 4, now: 1000 });
|
||
const keys = arbiter.selectedKeys;
|
||
const first = new Set(keys);
|
||
arbiter.solve(objects.slice(0, 3), { capacity: 2, now: 1400 });
|
||
assert.equal(arbiter.selectedKeys, keys);
|
||
assert.equal(keys.size, 2);
|
||
for (const key of keys) assert.ok(objects.slice(0, 3).some((item) => item.key === key));
|
||
assert.ok(first.size > keys.size);
|
||
});
|
||
|
||
test('a stateless candidate keeps no corner, no cooldown, and no fades', () => {
|
||
// Sources that shipped as a per-frame rebuild (CCTV thumbnails) opt out of the
|
||
// arbiter's statefulness. Everything the default path remembers, this path must
|
||
// forget: the sticky corner that pinned cards below their icon mid-screen, the
|
||
// 1.5 s re-entry cooldown that delayed their return after a sweep, and the
|
||
// enter/exit ramps that a hard-cliff source never had.
|
||
const make = (key, overrides = {}) => ({
|
||
key,
|
||
layerId: 'cctv',
|
||
sourceId: key,
|
||
priority: 1,
|
||
centerDistance: 0,
|
||
keyholeAlpha: 1,
|
||
screenX: 130,
|
||
screenY: 110,
|
||
stateless: true,
|
||
placements: [
|
||
{ corner: 'above', rect: { x: 100, y: 100, w: 60, h: 20 } },
|
||
{ corner: 'below', rect: { x: 100, y: 400, w: 60, h: 20 } },
|
||
],
|
||
...overrides,
|
||
});
|
||
const blocker = make('cctv:blocker', {
|
||
priority: 10,
|
||
stateless: false,
|
||
placements: [{ corner: 'above', rect: { x: 100, y: 100, w: 60, h: 20 } }],
|
||
});
|
||
const mover = make('cctv:mover');
|
||
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve([blocker, mover], { capacity: 2, now: 1000 });
|
||
assert.equal(arbiter.states.get('cctv:mover').corner, 'below',
|
||
'it took the free corner while the preferred one was blocked');
|
||
const paint = arbiter.renderEntries(new Map([[blocker.key, blocker], [mover.key, mover]]), 1000, []);
|
||
assert.equal(paint.find((entry) => entry.candidate.key === mover.key).placement.corner, 'below',
|
||
'painting honors this solve’s collision-free corner even without sticky placement');
|
||
|
||
// Corner is re-decided from geometry, NOT remembered: with the blocker gone the
|
||
// card returns to its preferred placement immediately.
|
||
arbiter.solve([mover], { capacity: 1, now: 1100 });
|
||
assert.equal(arbiter.states.get('cctv:mover').corner, 'above',
|
||
'a stateless candidate re-decides its corner instead of sticking');
|
||
|
||
// Evicted, then re-offered on the very next solve: no cooldown may block it.
|
||
arbiter.solve([], { capacity: 0, now: 1200 });
|
||
assert.equal(arbiter.states.get('cctv:mover').cooldownUntil, 0,
|
||
'eviction stamps no re-entry cooldown on a stateless candidate');
|
||
arbiter.solve([mover], { capacity: 1, now: 1220 });
|
||
const rendered = arbiter.renderEntries(new Map([[mover.key, mover]]), 1220, []);
|
||
const entry = rendered.find((item) => item.candidate.key === 'cctv:mover');
|
||
assert.ok(entry, 'the card returns on the next solve rather than waiting out a cooldown');
|
||
assert.equal(entry.temporalAlpha, 1,
|
||
'it paints at full alpha on arrival — a hard cliff, not a 150 ms ramp');
|
||
|
||
// And it leaves on a cliff too, with no fade-out tail.
|
||
arbiter.solve([], { capacity: 0, now: 1300 });
|
||
const afterExit = arbiter.renderEntries(new Map(), 1310, []);
|
||
assert.equal(afterExit.find((item) => item.candidate.key === 'cctv:mover'), undefined,
|
||
'a stateless card disappears on eviction instead of fading out');
|
||
});
|
||
|
||
test('a re-solve retries the remembered corner before the first listed placement', () => {
|
||
const blocker = {
|
||
key: 'flights:blocker',
|
||
layerId: 'flights',
|
||
sourceId: 'blocker',
|
||
priority: 10,
|
||
centerDistance: 0,
|
||
keyholeAlpha: 1,
|
||
screenX: 130,
|
||
screenY: 110,
|
||
placements: [{ corner: 'above', rect: { x: 100, y: 100, w: 60, h: 20 } }],
|
||
};
|
||
const mover = {
|
||
key: 'flights:mover',
|
||
layerId: 'flights',
|
||
sourceId: 'mover',
|
||
priority: 1,
|
||
centerDistance: 10,
|
||
keyholeAlpha: 1,
|
||
screenX: 130,
|
||
screenY: 110,
|
||
placements: [
|
||
{ corner: 'above', rect: { x: 100, y: 100, w: 60, h: 20 } },
|
||
{ corner: 'below', rect: { x: 100, y: 400, w: 60, h: 20 } },
|
||
],
|
||
};
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve([blocker, mover], { capacity: 2, now: 1000 });
|
||
assert.equal(arbiter.states.get('flights:mover').corner, 'below');
|
||
|
||
// The blocker is gone, so the first listed placement is free again. Sticky
|
||
// corners are tried FIRST regardless of list order, otherwise a label hops
|
||
// back and forth every time its neighbour comes and goes.
|
||
arbiter.solve([mover], { capacity: 1, now: 4000 });
|
||
assert.equal(arbiter.states.get('flights:mover').corner, 'below');
|
||
});
|
||
|
||
test('candidate ordering is stable when the comparator reports a true tie', () => {
|
||
// Canonically equivalent but distinct strings collate to 0, which is the only
|
||
// way candidateCompare ties two different identities all the way down.
|
||
const tied = (key, x) => ({
|
||
key,
|
||
layerId: 'flights',
|
||
sourceId: key,
|
||
priority: 3,
|
||
centerDistance: 42,
|
||
keyholeAlpha: 1,
|
||
screenX: x,
|
||
screenY: 0,
|
||
placements: [{ corner: 'above', rect: { x, y: 0, w: 20, h: 12 } }],
|
||
});
|
||
const first = tied('flights:é', 0);
|
||
const second = tied('flights:é', 400);
|
||
assert.equal(first.key.localeCompare(second.key), 0);
|
||
assert.notEqual(first.key, second.key);
|
||
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve([first, second], { capacity: 1, now: 1000 });
|
||
assert.deepEqual(Array.from(arbiter.selectedKeys), [first.key]);
|
||
|
||
const reversed = new LabelArbiter();
|
||
reversed.solve([second, first], { capacity: 1, now: 1000 });
|
||
assert.deepEqual(Array.from(reversed.selectedKeys), [second.key]);
|
||
});
|
||
|
||
test('each solve starts from an empty collision field', () => {
|
||
const cohort = Array.from({ length: 5 }, (_, index) => candidate('flights', index, index * 200));
|
||
const arbiter = new LabelArbiter();
|
||
const first = arbiter.solve(cohort, { capacity: 5, now: 1000, preserveIncumbents: false });
|
||
assert.equal(first.selectedCount, 5);
|
||
// Identical geometry one solve later: the reused spatial hash must not still
|
||
// be holding the previous solve's rectangles.
|
||
const second = arbiter.solve(cohort, { capacity: 5, now: 5000, preserveIncumbents: false });
|
||
assert.equal(second.selectedCount, 5);
|
||
});
|
||
|
||
test('the pooled state list is never handed out to callers', () => {
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve([candidate('flights', 1), candidate('flights', 2, 200)], { capacity: 2, now: 1000 });
|
||
assert.equal(arbiter.activeStateCount(), 2);
|
||
assert.equal(typeof arbiter.activeStates, 'undefined');
|
||
const state = arbiter.activeStateAt(0);
|
||
assert.ok(state?.key);
|
||
assert.equal(arbiter.activeStateAt(99), undefined);
|
||
// Reading twice must not disturb the arbiter's own view of its states.
|
||
assert.equal(arbiter.activeStateCount(), arbiter.states.size);
|
||
});
|
||
|
||
test('the pooled state list is invalidated when states are dropped', () => {
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve([candidate('flights', 1)], { capacity: 1, now: 1000 });
|
||
assert.equal(arbiter.activeStateCount(), 1);
|
||
assert.equal(arbiter.activeStateCount(), arbiter.states.size);
|
||
|
||
arbiter.solve([], { capacity: 0, now: 1200 });
|
||
assert.equal(arbiter.activeStateCount(), arbiter.states.size);
|
||
|
||
arbiter.solve([], { capacity: 0, now: 5000 });
|
||
assert.equal(arbiter.states.size, 0);
|
||
assert.equal(arbiter.activeStateCount(), 0);
|
||
});
|
||
|
||
test('uncapped demand drives quotas while the solve input remains bounded', () => {
|
||
const objects = [
|
||
...Array.from({ length: 12 }, (_, index) => candidate('flights', index)),
|
||
...Array.from({ length: 12 }, (_, index) => candidate('satellites', index, 500 + index * 30)),
|
||
];
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve(objects, {
|
||
capacity: 20,
|
||
strategy: ALLOCATION_WEIGHTED,
|
||
demandByLayer: { flights: 12000, satellites: 120 },
|
||
now: 1000,
|
||
});
|
||
const diagnostics = arbiter.diagnostics();
|
||
assert.deepEqual(diagnostics.demand, { flights: 12000, satellites: 120 });
|
||
assert.ok(diagnostics.quotas.flights > diagnostics.quotas.satellites);
|
||
assert.equal(diagnostics.capacity, 20);
|
||
});
|
||
|
||
test('live identities expose selected and temporal fading membership by layer/source', () => {
|
||
const arbiter = new LabelArbiter();
|
||
const one = [candidate('flights', 42)];
|
||
arbiter.solve(one, { capacity: 1, now: 1000 });
|
||
assert.deepEqual(arbiter.liveIdentities({ includeFading: false, now: 1100 }), new Map([
|
||
['flights', new Set([42])],
|
||
]));
|
||
arbiter.solve([], { capacity: 0, now: 1200 });
|
||
assert.equal(arbiter.liveIdentities({ includeFading: false, now: 1250 }).size, 0);
|
||
assert.deepEqual(arbiter.liveIdentities({ includeFading: true, now: 1250 }), new Map([
|
||
['flights', new Set([42])],
|
||
]));
|
||
assert.equal(arbiter.liveIdentities({ includeFading: true, now: 1501 }).size, 0);
|
||
});
|
||
|
||
|
||
test('stateless labels yield occupied slots and re-rank by area on each solve', () => {
|
||
const make = (key, x, priority) => ({ key, layerId: 'installations', sourceId: key,
|
||
priority, keyholeAlpha: 1, stateless: true, screenX: x, screenY: 100,
|
||
placements: [{ corner: 'above', rect: { x, y: 100, w: 100, h: 20 } }],
|
||
});
|
||
const large = make('large', 100, 100), small = make('small', 300, 10);
|
||
const arbiter = new LabelArbiter();
|
||
arbiter.solve([large, small], { capacity: 2, now: 1000 });
|
||
assert.equal(arbiter.selectedKeys.size, 2);
|
||
small.placements[0].rect.x = 100;
|
||
arbiter.solve([large, small], { capacity: 2, now: 1016 });
|
||
assert.deepEqual([...arbiter.selectedKeys], ['large'], 'incumbency never forces a stateless overlap');
|
||
const larger = make('larger', 100, 1000);
|
||
arbiter.solve([large, small, larger], { capacity: 2, now: 1032 });
|
||
assert.deepEqual([...arbiter.selectedKeys], ['larger'], 'area priority outranks a smaller incumbent');
|
||
});
|
||
|
||
|
||
test('a source ceiling bounds shared-domain winners and exit fades without evicting incumbents', () => {
|
||
const arbiter = new LabelArbiter();
|
||
const sites = Array.from({ length: 40 }, (_, i) => candidate('installations', i));
|
||
const centers = Array.from({ length: 30 }, (_, i) => candidate('flights', i));
|
||
const limits = new Map([['installations', 24]]);
|
||
const solve = (list, now) => arbiter.solve(list, { capacity: 54, limitsByLayer: limits, now });
|
||
solve([...sites, ...centers], 0);
|
||
const first = new Set(arbiter.selectedKeys);
|
||
assert.equal([...first].filter(key => key.startsWith('installations:')).length, 24);
|
||
for (let i = 1; i <= 10; i++) {
|
||
for (const site of sites) site.placements[0].rect.x += 0.25;
|
||
solve([...sites, ...centers], i * 16);
|
||
assert.deepEqual(arbiter.selectedKeys, first);
|
||
}
|
||
const replacements = sites.map((site, i) => ({ ...site, key: `installations:new${i}` }));
|
||
const current = [...replacements, ...centers];
|
||
solve(current, 2000);
|
||
const rendered = arbiter.renderEntries(new Map(current.map(c => [c.key, c])), 2100, [], limits);
|
||
assert.equal(rendered.filter(r => r.candidate.layerId === 'installations').length, 24);
|
||
assert.equal(rendered.filter(r => r.candidate.layerId === 'flights').length, 30);
|
||
});
|