mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75fe5b5703 | ||
|
|
b7552fbc64 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
- Shorten the hitch when the Radio directory loads from about 450 ms to about
|
||||
180 ms. A station's tags and categories are worked out once instead of for
|
||||
every category check (mazy1998, #1709).
|
||||
@@ -1,3 +0,0 @@
|
||||
- A still-image CCTV camera no longer keeps the scene rendering 60 times a
|
||||
second. Only a video feed holds continuous rendering; a still redraws when a
|
||||
new image arrives (mazy1998, #1710).
|
||||
@@ -144,35 +144,33 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
* the double-buffer canvas reference, throttled to PROJECTION_TEXTURE_SWAP_MS.
|
||||
*
|
||||
* @param {Object} record - Camera record with an initialized projection runtime.
|
||||
* @returns {boolean} Whether a new image was put on the plane.
|
||||
*/
|
||||
|
||||
function refreshProjectionTextures(record) {
|
||||
const runtime = record?.projection;
|
||||
if (!runtime || runtime.mode === 'video') return false;
|
||||
if (!runtime || runtime.mode === 'video') return;
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - parts.model.safeNumber(runtime.lastTextureSwapAt, 0) <
|
||||
PROJECTION_TEXTURE_SWAP_MS
|
||||
)
|
||||
return false;
|
||||
return;
|
||||
|
||||
const planeShowing = !!(runtime.planeEntity?.show && runtime.planeMaterial);
|
||||
if (!planeShowing) return false;
|
||||
if (!planeShowing) return;
|
||||
|
||||
// Only swap when the canvas content actually changed since the last swap.
|
||||
// Frames land every ~10 s but this runs at 1 Hz — swapping an UNCHANGED
|
||||
// canvas re-uploads the texture for nothing, and each material image
|
||||
// reassignment is a flash opportunity on the live plane (owner field test
|
||||
// 2026-07-04: intermittent white flashes on the monitor plane).
|
||||
if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return false;
|
||||
if (runtime.canvasStamp === runtime.lastSwappedCanvasStamp) return;
|
||||
|
||||
const buffer = paintNextProjectionBuffer(runtime);
|
||||
if (!buffer) return false;
|
||||
if (!buffer) return;
|
||||
runtime.lastTextureSwapAt = now;
|
||||
runtime.lastSwappedCanvasStamp = runtime.canvasStamp;
|
||||
runtime.planeMaterial.image = buffer;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -123,11 +123,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 =
|
||||
|
||||
export const PROJECTION_TEXTURE_SWAP_MS = 1000;
|
||||
|
||||
// A still-image projection changes only when a new still is swapped onto the
|
||||
// plane, so the scene renders for this long after a swap (Cesium binds the new
|
||||
// image over the next frames) and is otherwise left idle.
|
||||
export const PROJECTION_SWAP_RENDER_MS = 500;
|
||||
|
||||
// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink
|
||||
// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
PLANE_OUTLINE_COLOR,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_SWAP_RENDER_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createProjection({
|
||||
@@ -15,15 +14,7 @@ export function createProjection({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender = () => {},
|
||||
} = services.render;
|
||||
// Until this time (performance.now) the projection loop asks for a frame on
|
||||
// every tick: after a still is swapped onto the plane, and after an edge
|
||||
// that re-arms the loop (activation, projection shown).
|
||||
let renderUntil = 0;
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* Build the protected label associated with one active monitor plane.
|
||||
@@ -361,9 +352,12 @@ export function createProjection({
|
||||
}
|
||||
|
||||
function startProjectionLoop() {
|
||||
renderUntil = performance.now() + PROJECTION_SWAP_RENDER_MS;
|
||||
if (layerState._projectionRaf) return;
|
||||
if (!parts.model.projectionLoopIsNeeded()) return;
|
||||
// The armed projection loop uploads video textures / runs focus fades per
|
||||
// frame — the scene must render continuously while it runs. Released when
|
||||
// the tick self-stops. (perf wave 2)
|
||||
holdContinuousRender('cctv-projection');
|
||||
|
||||
const tick = () => {
|
||||
if (!layerState._viewer || !parts.model.projectionLoopIsNeeded()) {
|
||||
@@ -372,30 +366,15 @@ export function createProjection({
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const restyled = parts.rendering.refreshCctvFocusStyles(now);
|
||||
parts.rendering.refreshCctvFocusStyles(performance.now());
|
||||
|
||||
const active = parts.selection.getActiveRecord();
|
||||
let video = false;
|
||||
if (layerState._enabled && layerState._showProjection && active) {
|
||||
ensureProjectionRuntime(active);
|
||||
if (active.projection && !active.projection.video) {
|
||||
parts.frames.drawProjectionFrame(active);
|
||||
if (parts.frames.refreshProjectionTextures(active))
|
||||
renderUntil = now + PROJECTION_SWAP_RENDER_MS;
|
||||
} else video = Boolean(active.projection?.video);
|
||||
}
|
||||
|
||||
// Only a video feed changes the scene on every frame, so only video
|
||||
// holds the continuous loop. A still lands about every ten seconds and
|
||||
// a focus fade restyles icons every 80 ms; each asks for the frames it
|
||||
// needs. Holding for stills rendered the scene 60 times a second for as
|
||||
// long as a camera was active. (perf wave 2 held for both.)
|
||||
if (video) holdContinuousRender('cctv-projection');
|
||||
else {
|
||||
releaseContinuousRender('cctv-projection');
|
||||
if (restyled || now < renderUntil)
|
||||
governorRequestRender('cctv-projection');
|
||||
parts.frames.refreshProjectionTextures(active);
|
||||
}
|
||||
}
|
||||
|
||||
layerState._projectionRaf = requestAnimationFrame(tick);
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createProjection } from './projection.js';
|
||||
import { createFrames } from './frames.js';
|
||||
import {
|
||||
PROJECTION_SWAP_RENDER_MS,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/** The projection loop on a hand-cranked clock, with the render governor's calls recorded. */
|
||||
function harness(t, { video = false, showProjection = true } = {}) {
|
||||
let clock = 1000;
|
||||
const frames = [];
|
||||
const original = {
|
||||
now: performance.now,
|
||||
request: globalThis.requestAnimationFrame,
|
||||
cancel: globalThis.cancelAnimationFrame,
|
||||
};
|
||||
performance.now = () => clock;
|
||||
globalThis.requestAnimationFrame = (step) => frames.push(step);
|
||||
globalThis.cancelAnimationFrame = () => frames.splice(0);
|
||||
t.after(() => {
|
||||
performance.now = original.now;
|
||||
globalThis.requestAnimationFrame = original.request;
|
||||
globalThis.cancelAnimationFrame = original.cancel;
|
||||
});
|
||||
|
||||
const holds = new Set();
|
||||
const calls = { hold: 0, requests: 0, draws: 0 };
|
||||
const record = {
|
||||
camera: { id: 'cam-1' },
|
||||
projection: { video: video ? {} : null, mode: video ? 'video' : 'image' },
|
||||
};
|
||||
const scene = { needed: true, swap: false, restyled: false };
|
||||
const state = {
|
||||
_viewer: {},
|
||||
_enabled: true,
|
||||
_showProjection: showProjection,
|
||||
_projectionRaf: 0,
|
||||
_projectionEntities: [],
|
||||
};
|
||||
const projection = createProjection({
|
||||
state,
|
||||
services: {
|
||||
render: {
|
||||
holdContinuousRender(owner) {
|
||||
calls.hold++;
|
||||
holds.add(owner);
|
||||
},
|
||||
releaseContinuousRender: (owner) => holds.delete(owner),
|
||||
governorRequestRender: () => calls.requests++,
|
||||
},
|
||||
},
|
||||
parts: {
|
||||
model: { projectionLoopIsNeeded: () => scene.needed },
|
||||
rendering: { refreshCctvFocusStyles: () => scene.restyled },
|
||||
selection: { getActiveRecord: () => record },
|
||||
frames: {
|
||||
drawProjectionFrame: () => calls.draws++,
|
||||
refreshProjectionTextures() {
|
||||
const swapped = scene.swap;
|
||||
scene.swap = false;
|
||||
return swapped;
|
||||
},
|
||||
},
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return {
|
||||
projection,
|
||||
state,
|
||||
record,
|
||||
scene,
|
||||
holds,
|
||||
calls,
|
||||
/** Run `count` animation frames 16 ms apart; returns the frames requested in them. */
|
||||
run(count = 1) {
|
||||
const before = calls.requests;
|
||||
for (let i = 0; i < count; i++) {
|
||||
clock += 16;
|
||||
frames.shift()?.();
|
||||
}
|
||||
return calls.requests - before;
|
||||
},
|
||||
advance(ms) {
|
||||
clock += ms;
|
||||
},
|
||||
get queued() {
|
||||
return frames.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a still-image camera never holds the continuous render loop', (t) => {
|
||||
const h = harness(t);
|
||||
h.projection.startProjectionLoop();
|
||||
h.run(600);
|
||||
assert.equal(h.calls.hold, 0);
|
||||
assert.equal(h.holds.size, 0);
|
||||
assert.equal(h.calls.draws, 600, 'the loop still polls for a new still');
|
||||
assert.equal(h.queued, 1, 'and stays armed');
|
||||
});
|
||||
|
||||
test('a new still on the plane is rendered, then the scene goes idle again', (t) => {
|
||||
const h = harness(t);
|
||||
h.projection.startProjectionLoop();
|
||||
// Arming the loop (activation, projection shown) renders its first frames.
|
||||
assert.ok(h.run(5) === 5);
|
||||
h.advance(PROJECTION_SWAP_RENDER_MS);
|
||||
assert.equal(h.run(120), 0, 'an unchanged still asks for no frame');
|
||||
|
||||
h.scene.swap = true;
|
||||
assert.equal(h.run(1), 1, 'the tick that swaps the image asks for a frame');
|
||||
const settling = Math.floor(PROJECTION_SWAP_RENDER_MS / 16) - 2;
|
||||
assert.equal(h.run(settling), settling, 'and so do the ticks while it binds');
|
||||
h.advance(PROJECTION_SWAP_RENDER_MS);
|
||||
assert.equal(h.run(120), 0);
|
||||
assert.equal(h.holds.size, 0);
|
||||
});
|
||||
|
||||
test('activating another camera while the loop runs renders its first frames', (t) => {
|
||||
const h = harness(t);
|
||||
h.projection.startProjectionLoop();
|
||||
h.advance(PROJECTION_SWAP_RENDER_MS * 2);
|
||||
assert.equal(h.run(10), 0);
|
||||
h.projection.startProjectionLoop();
|
||||
assert.equal(h.run(10), 10);
|
||||
assert.equal(h.queued, 1, 'still one loop');
|
||||
});
|
||||
|
||||
test('a video camera holds continuous render until it stops being video', (t) => {
|
||||
const h = harness(t, { video: true });
|
||||
h.projection.startProjectionLoop();
|
||||
h.run(3);
|
||||
assert.deepEqual([...h.holds], ['cctv-projection']);
|
||||
assert.equal(h.calls.draws, 0, 'Cesium updates the video texture itself');
|
||||
// The stream failed over to stills.
|
||||
h.record.projection.video = null;
|
||||
h.run(1);
|
||||
assert.equal(h.holds.size, 0);
|
||||
h.record.projection.video = {};
|
||||
h.run(1);
|
||||
assert.deepEqual([...h.holds], ['cctv-projection']);
|
||||
h.projection.stopProjectionLoop();
|
||||
assert.equal(h.holds.size, 0);
|
||||
});
|
||||
|
||||
test('a focus fade asks for a frame only on the ticks that restyle an icon', (t) => {
|
||||
const h = harness(t, { showProjection: false });
|
||||
h.projection.startProjectionLoop();
|
||||
h.advance(PROJECTION_SWAP_RENDER_MS * 2);
|
||||
assert.equal(h.run(10), 0);
|
||||
h.scene.restyled = true;
|
||||
assert.equal(h.run(4), 4);
|
||||
h.scene.restyled = false;
|
||||
assert.equal(h.run(10), 0);
|
||||
assert.equal(h.holds.size, 0);
|
||||
});
|
||||
|
||||
test('the loop releases and stops when it has no work', (t) => {
|
||||
const h = harness(t, { video: true });
|
||||
h.projection.startProjectionLoop();
|
||||
h.run(2);
|
||||
h.scene.needed = false;
|
||||
h.run(1);
|
||||
assert.equal(h.holds.size, 0);
|
||||
assert.equal(h.state._projectionRaf, 0);
|
||||
assert.equal(h.queued, 0);
|
||||
});
|
||||
|
||||
test('refreshProjectionTextures reports whether it put a new image on the plane', (t) => {
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement: () => ({
|
||||
getContext: () => ({ clearRect() {}, drawImage() {} }),
|
||||
}),
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
});
|
||||
const frames = createFrames({
|
||||
state: {},
|
||||
services: {},
|
||||
parts: { model: { safeNumber: (value, fallback) => value ?? fallback } },
|
||||
source: {},
|
||||
});
|
||||
const runtime = {
|
||||
mode: 'image',
|
||||
canvas: {},
|
||||
canvasStamp: 1,
|
||||
planeEntity: { show: true },
|
||||
planeMaterial: { image: null },
|
||||
};
|
||||
const record = { projection: runtime };
|
||||
assert.equal(frames.refreshProjectionTextures(record), true);
|
||||
const first = runtime.planeMaterial.image;
|
||||
assert.ok(first);
|
||||
// Unchanged canvas: nothing to render.
|
||||
runtime.lastTextureSwapAt -= PROJECTION_TEXTURE_SWAP_MS;
|
||||
assert.equal(frames.refreshProjectionTextures(record), false);
|
||||
assert.equal(runtime.planeMaterial.image, first);
|
||||
runtime.canvasStamp = 2;
|
||||
assert.equal(frames.refreshProjectionTextures(record), true);
|
||||
assert.notEqual(runtime.planeMaterial.image, first);
|
||||
runtime.planeEntity.show = false;
|
||||
runtime.canvasStamp = 3;
|
||||
runtime.lastTextureSwapAt -= PROJECTION_TEXTURE_SWAP_MS;
|
||||
assert.equal(frames.refreshProjectionTextures(record), false);
|
||||
assert.equal(frames.refreshProjectionTextures({ projection: { mode: 'video' } }), false);
|
||||
});
|
||||
@@ -61,8 +61,8 @@ export function createRendering({
|
||||
!layerState._viewer ||
|
||||
!focusPassIsNeeded(target, layerState._activeFocusStyleCount)
|
||||
)
|
||||
return false;
|
||||
if (nowMs - layerState._lastFocusStyleAt < 80) return false;
|
||||
return;
|
||||
if (nowMs - layerState._lastFocusStyleAt < 80) return;
|
||||
layerState._lastFocusStyleAt = nowMs;
|
||||
const scene = layerState._viewer.scene;
|
||||
const camera = layerState._viewer.camera;
|
||||
@@ -82,8 +82,6 @@ export function createRendering({
|
||||
baseColorFor: cameraColorFor,
|
||||
});
|
||||
layerState._activeFocusStyleCount = result.activeCount;
|
||||
// Whether an icon was restyled, so the caller can ask for a frame.
|
||||
return result.writes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,42 +27,52 @@ export function createCategories({
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasTag(station, needles) {
|
||||
const tags = stationTags(station);
|
||||
return needles.some((needle) =>
|
||||
const tagsInclude = (tags, needles) =>
|
||||
needles.some((needle) =>
|
||||
tags.some((tag) => tag === needle || tag.includes(needle)),
|
||||
);
|
||||
|
||||
// A station's tags decide its categories, and neither changes while the
|
||||
// station record lives. Matching used to normalise every tag again for
|
||||
// every needle of every category of every station: about 0.9 s and a
|
||||
// 445 ms freeze per directory load of 750 stations, repeated for the active
|
||||
// filter on every horizon scan. Keyed by the record and its tags value, so
|
||||
// a record given new tags is read again.
|
||||
const membershipByStation = new WeakMap();
|
||||
|
||||
function stationMembership(station) {
|
||||
const keyed = station !== null && typeof station === 'object';
|
||||
const known = keyed ? membershipByStation.get(station) : null;
|
||||
if (known && known.source === station.tags) return known;
|
||||
const tags = stationTags(station);
|
||||
const genres = MUSIC_GENRES.filter(([genre]) =>
|
||||
tagsInclude(tags, [genre]),
|
||||
).map(([genre]) => genre);
|
||||
const categories = new Set(genres.map((genre) => `genre:${genre}`));
|
||||
for (const [id, needles] of Object.entries(CATEGORY_MATCHERS))
|
||||
if (tagsInclude(tags, needles)) categories.add(id);
|
||||
const sorted = categories.size > genres.length;
|
||||
if (genres.length > 0 || tagsInclude(tags, ['music', 'hits', 'songs']))
|
||||
categories.add('music');
|
||||
else if (!sorted) categories.add('other');
|
||||
const membership = { source: station?.tags, tags, genres, categories };
|
||||
if (keyed) membershipByStation.set(station, membership);
|
||||
return membership;
|
||||
}
|
||||
|
||||
function hasTag(station, needles) {
|
||||
return tagsInclude(stationMembership(station).tags, needles);
|
||||
}
|
||||
|
||||
function detectedGenres(station) {
|
||||
return MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map(
|
||||
([genre]) => genre,
|
||||
);
|
||||
return [...stationMembership(station).genres];
|
||||
}
|
||||
|
||||
/** Return whether a station belongs in a station-tag category. */
|
||||
|
||||
function stationMatchesRadioCategory(station, categoryId) {
|
||||
if (categoryId === 'all') return true;
|
||||
if (categoryId.startsWith('genre:')) {
|
||||
return detectedGenres(station).includes(
|
||||
categoryId.slice('genre:'.length),
|
||||
);
|
||||
}
|
||||
if (categoryId === 'music') {
|
||||
return (
|
||||
detectedGenres(station).length > 0 ||
|
||||
hasTag(station, ['music', 'hits', 'songs'])
|
||||
);
|
||||
}
|
||||
if (categoryId === 'other') {
|
||||
return (
|
||||
!Object.entries(CATEGORY_MATCHERS).some(([id]) =>
|
||||
stationMatchesRadioCategory(station, id),
|
||||
) && !stationMatchesRadioCategory(station, 'music')
|
||||
);
|
||||
}
|
||||
return hasTag(station, CATEGORY_MATCHERS[categoryId] || []);
|
||||
return stationMembership(station).categories.has(categoryId);
|
||||
}
|
||||
|
||||
/** Build canonical and detected-genre categories from station-level tags. */
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCategories } from './categories.js';
|
||||
import { CATEGORY_MATCHERS, MUSIC_GENRES } from './policy.js';
|
||||
|
||||
const categories = createCategories({
|
||||
state: {},
|
||||
services: {},
|
||||
parts: { model: { radioCategoryColor: (id) => `color:${id}` } },
|
||||
source: {},
|
||||
});
|
||||
|
||||
// Category matching as it was before memberships were remembered: every call
|
||||
// normalises the station's tags again. Counts and filters must stay equal.
|
||||
const normalize = (value) =>
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 80);
|
||||
const tagsOf = (station) =>
|
||||
(Array.isArray(station?.tags)
|
||||
? station.tags
|
||||
: String(station?.tags ?? '').split(',')
|
||||
)
|
||||
.map(normalize)
|
||||
.filter(Boolean);
|
||||
const hasTag = (station, needles) => {
|
||||
const tags = tagsOf(station);
|
||||
return needles.some((needle) =>
|
||||
tags.some((tag) => tag === needle || tag.includes(needle)),
|
||||
);
|
||||
};
|
||||
const genresOf = (station) =>
|
||||
MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map(
|
||||
([genre]) => genre,
|
||||
);
|
||||
function matchesByRescan(station, categoryId) {
|
||||
if (categoryId === 'all') return true;
|
||||
if (categoryId.startsWith('genre:'))
|
||||
return genresOf(station).includes(categoryId.slice('genre:'.length));
|
||||
if (categoryId === 'music')
|
||||
return (
|
||||
genresOf(station).length > 0 ||
|
||||
hasTag(station, ['music', 'hits', 'songs'])
|
||||
);
|
||||
if (categoryId === 'other')
|
||||
return (
|
||||
!Object.keys(CATEGORY_MATCHERS).some((id) =>
|
||||
matchesByRescan(station, id),
|
||||
) && !matchesByRescan(station, 'music')
|
||||
);
|
||||
return hasTag(station, CATEGORY_MATCHERS[categoryId] || []);
|
||||
}
|
||||
|
||||
const CATEGORY_IDS = [
|
||||
'all',
|
||||
...Object.keys(CATEGORY_MATCHERS),
|
||||
'music',
|
||||
...MUSIC_GENRES.map(([genre]) => `genre:${genre}`),
|
||||
'other',
|
||||
'genre:not-a-genre',
|
||||
'not-a-category',
|
||||
];
|
||||
|
||||
function seeded(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
/** Stations tagged the way the directory tags them, plus the odd shapes. */
|
||||
function directory(count = 1500) {
|
||||
const random = seeded(11);
|
||||
const vocabulary = [
|
||||
...MUSIC_GENRES.map(([genre]) => genre),
|
||||
...Object.values(CATEGORY_MATCHERS).flat(),
|
||||
'Hip-Hop',
|
||||
'R&B',
|
||||
'Top_Hits',
|
||||
'songs of praise',
|
||||
'community',
|
||||
'college',
|
||||
'varied',
|
||||
'Police Scanner',
|
||||
'FIREFIGHTERS',
|
||||
'metropolitan',
|
||||
'x'.repeat(100),
|
||||
'',
|
||||
' ',
|
||||
];
|
||||
const pick = () => vocabulary[Math.floor(random() * vocabulary.length)];
|
||||
const stations = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tags = Array.from({ length: Math.floor(random() * 6) }, pick);
|
||||
stations.push({
|
||||
id: `station-${i}`,
|
||||
tags: i % 5 === 0 ? tags.join(',') : i % 7 === 0 ? Object.freeze(tags) : tags,
|
||||
});
|
||||
}
|
||||
stations.push(
|
||||
{ id: 'no-tags' },
|
||||
{ id: 'null-tags', tags: null },
|
||||
{ id: 'number-tags', tags: 42 },
|
||||
Object.freeze({ id: 'frozen', tags: Object.freeze(['Jazz', 'News']) }),
|
||||
null,
|
||||
undefined,
|
||||
'pop',
|
||||
);
|
||||
return stations;
|
||||
}
|
||||
|
||||
test('remembered memberships give the counts and filters of a full rescan', () => {
|
||||
const stations = directory();
|
||||
for (const id of CATEGORY_IDS) {
|
||||
const expected = stations.filter((station) => matchesByRescan(station, id));
|
||||
assert.deepEqual(
|
||||
categories.filterRadioStations(stations, id),
|
||||
expected,
|
||||
`filter ${id}`,
|
||||
);
|
||||
for (const station of stations)
|
||||
assert.equal(
|
||||
categories.stationMatchesRadioCategory(station, id),
|
||||
matchesByRescan(station, id),
|
||||
);
|
||||
}
|
||||
const built = categories.buildRadioCategories(stations);
|
||||
assert.deepEqual(
|
||||
built.map(({ id, count }) => [id, count]),
|
||||
CATEGORY_IDS.filter(
|
||||
(id) =>
|
||||
!id.startsWith('genre:') ||
|
||||
stations.some((station) => matchesByRescan(station, id)),
|
||||
)
|
||||
.filter((id) => id !== 'not-a-category')
|
||||
.map((id) => [
|
||||
id,
|
||||
stations.filter((station) => matchesByRescan(station, id)).length,
|
||||
]),
|
||||
);
|
||||
assert.ok(built.find(({ id }) => id === 'other').count > 0);
|
||||
assert.ok(built.find(({ id }) => id === 'genre:hip hop').count > 0);
|
||||
for (const station of stations) {
|
||||
assert.deepEqual(categories.detectedGenres(station), genresOf(station));
|
||||
assert.deepEqual(categories.stationTags(station), tagsOf(station));
|
||||
}
|
||||
});
|
||||
|
||||
test('a station has its tags normalised once, however often it is matched', () => {
|
||||
let reads = 0;
|
||||
const tag = (text) => ({
|
||||
toString() {
|
||||
reads++;
|
||||
return text;
|
||||
},
|
||||
});
|
||||
const stations = [
|
||||
{ id: 'a', tags: [tag('Jazz'), tag('News')] },
|
||||
{ id: 'b', tags: [tag('community')] },
|
||||
];
|
||||
categories.buildRadioCategories(stations);
|
||||
for (const id of CATEGORY_IDS) categories.filterRadioStations(stations, id);
|
||||
// A horizon scan asks once per station for the active filter.
|
||||
for (let scan = 0; scan < 50; scan++)
|
||||
for (const station of stations)
|
||||
categories.stationMatchesRadioCategory(station, 'news');
|
||||
assert.equal(reads, 3, 'one normalisation per tag');
|
||||
assert.deepEqual(
|
||||
categories.filterRadioStations(stations, 'genre:jazz').map(({ id }) => id),
|
||||
['a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a station record given new tags is matched on the new tags', () => {
|
||||
const station = { id: 'a', tags: ['jazz'] };
|
||||
assert.equal(categories.stationMatchesRadioCategory(station, 'music'), true);
|
||||
assert.equal(categories.stationMatchesRadioCategory(station, 'news'), false);
|
||||
station.tags = ['news'];
|
||||
assert.equal(categories.stationMatchesRadioCategory(station, 'music'), false);
|
||||
assert.equal(categories.stationMatchesRadioCategory(station, 'news'), true);
|
||||
assert.deepEqual(categories.detectedGenres(station), []);
|
||||
station.tags = 'community';
|
||||
assert.equal(categories.stationMatchesRadioCategory(station, 'other'), true);
|
||||
});
|
||||
|
||||
test('callers cannot alter a remembered genre list', () => {
|
||||
const station = { id: 'a', tags: ['jazz', 'soul'] };
|
||||
categories.detectedGenres(station).push('rock');
|
||||
assert.deepEqual(categories.detectedGenres(station), ['jazz', 'soul']);
|
||||
assert.equal(
|
||||
categories.stationMatchesRadioCategory(station, 'genre:rock'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user