mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
340 lines
14 KiB
JavaScript
340 lines
14 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
planSplitFlap,
|
|
visibleGlyphs,
|
|
FLAP_CHAR_MS,
|
|
FLAP_STAGGER_MS,
|
|
FLAP_MAX_TOTAL_MS,
|
|
FLAP_TURN_RATIO,
|
|
SPLIT_FLAP_ENABLED,
|
|
} from './splitFlap.js';
|
|
|
|
const settledText = (plan) => plan.cells.map((cell) => cell.to).join('');
|
|
const flapping = (plan) => plan.cells.filter((cell) => cell.changed);
|
|
|
|
test('identical text plans no work at all — the tickers must not restart it', () => {
|
|
const plan = planSplitFlap('LOAD COMPLETE', 'LOAD COMPLETE');
|
|
assert.equal(plan.changedCount, 0);
|
|
assert.equal(plan.cells.length, 0);
|
|
assert.equal(plan.durationMs, 0);
|
|
assert.equal(plan.firstChanged, -1);
|
|
});
|
|
|
|
test('a settled cell keeps its glyph and never animates', () => {
|
|
// "LOAD" survives the transition, so those four cells hold still — the way
|
|
// a real split-flap cell that already shows the right letter does not move.
|
|
const plan = planSplitFlap('LOADING LIVE DATA', 'LOAD COMPLETE');
|
|
const held = plan.cells.slice(0, 4);
|
|
assert.deepEqual(held.map((cell) => cell.to), ['L', 'O', 'A', 'D']);
|
|
assert.ok(held.every((cell) => cell.changed === false));
|
|
assert.ok(held.every((cell) => cell.delayMs === 0));
|
|
// "LOADING" still has its I where "LOAD COMPLETE" has a space, so column 4
|
|
// is the first that has to move.
|
|
assert.equal(plan.firstChanged, 4);
|
|
assert.equal(plan.cells[4].delayMs, 0);
|
|
});
|
|
|
|
test('the concatenated cells are exactly the target string — DOM text stays the truth', () => {
|
|
const cases = [
|
|
['LOADING LIVE DATA', 'LOAD COMPLETE'],
|
|
['loading frames', 'camera grid ready'],
|
|
['', 'LOAD FAILED'],
|
|
['TURNING OFF LIVE DATA', ''],
|
|
['LIVE · TomTom flow · 87% cov', 'SIMULATED — add TomTom key for live'],
|
|
];
|
|
for (const [from, to] of cases) {
|
|
assert.equal(settledText(planSplitFlap(from, to)), to, `${from} -> ${to}`);
|
|
}
|
|
});
|
|
|
|
test('the cascade sweeps left to right, rebased on the first changed column', () => {
|
|
const plan = planSplitFlap('AAAA', 'BBBB');
|
|
assert.deepEqual(plan.cells.map((cell) => cell.delayMs), [
|
|
0,
|
|
FLAP_STAGGER_MS,
|
|
FLAP_STAGGER_MS * 2,
|
|
FLAP_STAGGER_MS * 3,
|
|
]);
|
|
// Rebasing matters: a stable head must not idle through untouched columns.
|
|
const rebased = planSplitFlap('LOAD XX', 'LOAD YY');
|
|
assert.equal(rebased.firstChanged, 5);
|
|
assert.equal(rebased.cells[5].delayMs, 0);
|
|
assert.equal(rebased.cells[6].delayMs, FLAP_STAGGER_MS);
|
|
});
|
|
|
|
test('every real chip transition finishes well under a second', () => {
|
|
const transitions = [
|
|
['LOADING LIVE DATA', 'LOAD COMPLETE'],
|
|
['LOADING LIVE DATA', 'LOAD FAILED'],
|
|
['REFRESHING LIVE DATA', 'LOAD CANCELLED'],
|
|
['TURNING OFF LIVE DATA', 'LIVE DATA OFF'],
|
|
['loading frames', 'camera grid ready'],
|
|
['syncing road network', 'LIVE · TomTom flow · 100% cov'],
|
|
['SIMULATED — traffic service unreachable', 'LIVE · TomTom flow · 42% cov'],
|
|
];
|
|
for (const [from, to] of transitions) {
|
|
const plan = planSplitFlap(from, to);
|
|
assert.ok(
|
|
plan.durationMs <= FLAP_MAX_TOTAL_MS,
|
|
`${from} -> ${to} ran ${plan.durationMs}ms, over the ${FLAP_MAX_TOTAL_MS}ms budget`,
|
|
);
|
|
assert.ok(plan.durationMs > 0);
|
|
}
|
|
});
|
|
|
|
test('long labels compress their stagger instead of running past the budget', () => {
|
|
const long = planSplitFlap('x'.repeat(80), 'y'.repeat(80));
|
|
assert.ok(long.durationMs <= FLAP_MAX_TOTAL_MS);
|
|
assert.ok(long.staggerMs < FLAP_STAGGER_MS, 'stagger should compress');
|
|
// Still a cascade, not a single simultaneous snap.
|
|
assert.ok(long.staggerMs > 0);
|
|
assert.ok(long.cells.at(-1).delayMs > long.cells[0].delayMs);
|
|
});
|
|
|
|
test('a short label keeps the full, unhurried stagger', () => {
|
|
const plan = planSplitFlap('AB', 'CD');
|
|
assert.equal(plan.staggerMs, FLAP_STAGGER_MS);
|
|
assert.equal(plan.durationMs, FLAP_STAGGER_MS + FLAP_CHAR_MS);
|
|
});
|
|
|
|
test('a single changed character has no stagger and takes exactly one char time', () => {
|
|
const plan = planSplitFlap('LOAD COMPLETE', 'LOAD COMPLETEX');
|
|
assert.equal(plan.changedCount, 1);
|
|
assert.equal(plan.staggerMs, 0);
|
|
assert.equal(plan.durationMs, FLAP_CHAR_MS);
|
|
});
|
|
|
|
test('a shrinking label marks its surplus columns vacating', () => {
|
|
const plan = planSplitFlap('TURNING OFF LIVE DATA', 'LIVE DATA OFF');
|
|
const vacating = plan.cells.filter((cell) => cell.vacating);
|
|
assert.equal(vacating.length, 'TURNING OFF LIVE DATA'.length - 'LIVE DATA OFF'.length);
|
|
// Vacating cells carry an outgoing glyph but contribute nothing to the text;
|
|
// their width is zero, and the container width transition covers the shrink.
|
|
assert.ok(vacating.every((cell) => cell.to === '' && cell.from !== ''));
|
|
assert.equal(settledText(plan), 'LIVE DATA OFF');
|
|
});
|
|
|
|
test('a growing label flaps its new columns in with no outgoing glyph', () => {
|
|
const plan = planSplitFlap('LOAD', 'LOAD COMPLETE');
|
|
const grown = plan.cells.slice(4);
|
|
assert.ok(grown.every((cell) => cell.from === '' && cell.changed));
|
|
assert.ok(grown.every((cell) => cell.vacating === false));
|
|
assert.equal(settledText(plan), 'LOAD COMPLETE');
|
|
});
|
|
|
|
test('cell width covers the longer of the two strings', () => {
|
|
assert.equal(planSplitFlap('ABC', 'AB').cells.length, 3);
|
|
assert.equal(planSplitFlap('AB', 'ABC').cells.length, 3);
|
|
});
|
|
|
|
test('multi-byte separators stay one cell, never split into surrogate halves', () => {
|
|
const plan = planSplitFlap('A · B', 'A · C');
|
|
assert.ok(plan.cells.every((cell) => Array.from(cell.to).length <= 1));
|
|
assert.equal(settledText(plan), 'A · C');
|
|
// An astral character must occupy exactly one flap cell.
|
|
const astral = planSplitFlap('', '\u{1F6EB}');
|
|
assert.equal(astral.cells.length, 1);
|
|
assert.equal(astral.cells[0].to, '\u{1F6EB}');
|
|
});
|
|
|
|
test('null and undefined are treated as empty, never stringified into the chip', () => {
|
|
assert.equal(settledText(planSplitFlap(null, 'READY')), 'READY');
|
|
assert.equal(planSplitFlap(undefined, undefined).changedCount, 0);
|
|
assert.equal(settledText(planSplitFlap('READY', null)), '');
|
|
});
|
|
|
|
test('option overrides drive both the stagger and the budget', () => {
|
|
const plan = planSplitFlap('AAAA', 'BBBB', { charMs: 100, staggerMs: 10, maxTotalMs: 1000 });
|
|
assert.deepEqual(plan.cells.map((cell) => cell.delayMs), [0, 10, 20, 30]);
|
|
assert.equal(plan.durationMs, 130);
|
|
// Invalid overrides fall back to the defaults rather than producing NaN.
|
|
const guarded = planSplitFlap('AB', 'CD', { charMs: 0, staggerMs: -5, maxTotalMs: NaN });
|
|
assert.equal(guarded.durationMs, FLAP_STAGGER_MS + FLAP_CHAR_MS);
|
|
});
|
|
|
|
test('delays are whole milliseconds so the CSS custom property stays clean', () => {
|
|
const plan = planSplitFlap('x'.repeat(37), 'y'.repeat(37));
|
|
assert.ok(plan.cells.every((cell) => Number.isInteger(cell.delayMs)));
|
|
assert.ok(Number.isInteger(plan.durationMs));
|
|
});
|
|
|
|
test('no cell is ever scheduled to start after the cascade has ended', () => {
|
|
const plan = planSplitFlap('syncing road network', 'LIVE · TomTom flow · 100% cov');
|
|
for (const cell of flapping(plan)) {
|
|
assert.ok(
|
|
cell.delayMs + FLAP_CHAR_MS <= plan.durationMs + 1,
|
|
`cell ${cell.index} lands after the reported duration`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the feature ships enabled behind a single flippable constant', () => {
|
|
assert.equal(typeof SPLIT_FLAP_ENABLED, 'boolean');
|
|
assert.equal(SPLIT_FLAP_ENABLED, true);
|
|
});
|
|
|
|
// ── Interrupted cascades: only what was visible may flap away ──────────────
|
|
|
|
test('at the start of a cascade the board still reads the old label', () => {
|
|
const plan = planSplitFlap('LOADING LIVE DATA', 'LOAD COMPLETE');
|
|
assert.equal(visibleGlyphs(plan, 0), 'LOADING LIVE DATA');
|
|
});
|
|
|
|
test('once the cascade is over the board reads the new label', () => {
|
|
const plan = planSplitFlap('LOADING LIVE DATA', 'LOAD COMPLETE');
|
|
const landed = visibleGlyphs(plan, plan.durationMs + 1000);
|
|
// The cleared columns are still ON the board as blanks until settlement
|
|
// strips them — that is what stops columns renumbering mid-flight.
|
|
assert.equal(landed.trimEnd(), 'LOAD COMPLETE');
|
|
assert.equal(landed.length, 'LOADING LIVE DATA'.length);
|
|
});
|
|
|
|
test('a column turns over at its own delay, not the cascade start', () => {
|
|
const plan = planSplitFlap('AAAA', 'BBBB');
|
|
const turn = FLAP_CHAR_MS * FLAP_TURN_RATIO;
|
|
// Just after column 0 turns, only column 0 has changed.
|
|
assert.equal(visibleGlyphs(plan, turn + 1), 'BAAA');
|
|
// Just after column 2's delay + turn, three columns have.
|
|
assert.equal(visibleGlyphs(plan, FLAP_STAGGER_MS * 2 + turn + 1), 'BBBA');
|
|
});
|
|
|
|
test('an interrupted cascade never flaps away a glyph that was never on screen', () => {
|
|
// A -> B interrupted mid-stagger by C. This is the ghost-glyph pin.
|
|
const A = 'LOADING LIVE DATA';
|
|
const B = 'LOAD COMPLETE';
|
|
const C = 'LOAD FAILED';
|
|
const first = planSplitFlap(A, B);
|
|
const interruptAt = 150; // mid-stagger: some columns turned, others have not
|
|
|
|
const displayed = visibleGlyphs(first, interruptAt);
|
|
const aChars = Array.from(A);
|
|
const bChars = Array.from(B);
|
|
const shown = Array.from(displayed);
|
|
|
|
// Every visible glyph is either the old label's or the new one's, per column.
|
|
// A column the new label does not reach reads as a reserved blank.
|
|
shown.forEach((glyph, index) => {
|
|
const candidates = [aChars[index] || ' ', bChars[index] || ' '];
|
|
assert.ok(
|
|
candidates.includes(glyph),
|
|
`column ${index} shows ${JSON.stringify(glyph)}, which is neither `
|
|
+ `${JSON.stringify(candidates[0])} nor ${JSON.stringify(candidates[1])}`,
|
|
);
|
|
});
|
|
|
|
// The interrupting cascade flaps away exactly those visible glyphs.
|
|
const second = planSplitFlap(displayed, C);
|
|
second.cells.forEach((cell) => {
|
|
assert.equal(
|
|
cell.from,
|
|
shown[cell.index] ?? '',
|
|
`column ${cell.index} would flap away a glyph that was not on screen`,
|
|
);
|
|
});
|
|
|
|
// And this genuinely differs from the naive "flap away the pending target"
|
|
// approach — otherwise the pin would pass without the fix.
|
|
const naive = planSplitFlap(B, C);
|
|
const naiveFrom = naive.cells.map((cell) => cell.from).join('');
|
|
const honestFrom = second.cells.map((cell) => cell.from).join('');
|
|
assert.notEqual(honestFrom, naiveFrom);
|
|
assert.equal(honestFrom, displayed);
|
|
});
|
|
|
|
test('interrupting before anything turned flaps away the ORIGINAL label', () => {
|
|
const first = planSplitFlap('LOADING LIVE DATA', 'LOAD COMPLETE');
|
|
// Nothing has reached its turn point yet.
|
|
const displayed = visibleGlyphs(first, 0);
|
|
const second = planSplitFlap(displayed, 'LOAD FAILED');
|
|
assert.equal(second.cells.map((cell) => cell.from).join(''), 'LOADING LIVE DATA');
|
|
});
|
|
|
|
test('interrupting after the cascade landed flaps away the settled label', () => {
|
|
const first = planSplitFlap('LOADING LIVE DATA', 'LOAD COMPLETE');
|
|
const displayed = visibleGlyphs(first, 10_000);
|
|
assert.equal(displayed.trimEnd(), 'LOAD COMPLETE');
|
|
const second = planSplitFlap(displayed, 'LOAD FAILED');
|
|
assert.equal(second.cells.map((cell) => cell.from).join(''), displayed);
|
|
});
|
|
|
|
test('a shrinking cascade shows its surplus glyphs until each column turns', () => {
|
|
const plan = planSplitFlap('TURNING OFF LIVE DATA', 'LIVE DATA OFF');
|
|
assert.equal(visibleGlyphs(plan, 0), 'TURNING OFF LIVE DATA');
|
|
// Mid-cascade the tail columns still carry the old glyphs.
|
|
assert.ok(visibleGlyphs(plan, 120).trimEnd().length > 'LIVE DATA OFF'.length);
|
|
assert.equal(visibleGlyphs(plan, plan.durationMs + 100).trimEnd(), 'LIVE DATA OFF');
|
|
});
|
|
|
|
// ── Positional truth: columns never renumber mid-cascade ──────────────────
|
|
|
|
test('a cleared column holds its place instead of letting later glyphs slide left', () => {
|
|
// The revert-proof pin. If a cleared column collapsed to nothing, this
|
|
// sampling would catch "ABCD" -> "ABD" and D sitting in column 2.
|
|
const plan = planSplitFlap('ABCD', 'AB');
|
|
const samples = [];
|
|
for (let t = 0; t <= plan.durationMs + 50; t += 5) samples.push(visibleGlyphs(plan, t));
|
|
|
|
for (const sample of samples) {
|
|
const columns = Array.from(sample);
|
|
assert.equal(
|
|
columns.length,
|
|
plan.cells.length,
|
|
`board width changed mid-cascade: ${JSON.stringify(sample)}`,
|
|
);
|
|
assert.notEqual(columns[2], 'D', `D slid into column 2: ${JSON.stringify(sample)}`);
|
|
if (columns.includes('D')) {
|
|
assert.equal(columns[3], 'D', `D left column 3: ${JSON.stringify(sample)}`);
|
|
}
|
|
if (columns.includes('C')) {
|
|
assert.equal(columns[2], 'C', `C left column 2: ${JSON.stringify(sample)}`);
|
|
}
|
|
}
|
|
|
|
assert.equal(samples[0], 'ABCD');
|
|
assert.equal(samples.at(-1), 'AB ');
|
|
});
|
|
|
|
test('every column keeps its index for the whole cascade, growing or shrinking', () => {
|
|
for (const [from, to] of [
|
|
['LOADING LIVE DATA', 'LOAD COMPLETE'],
|
|
['LOAD', 'LOAD COMPLETE'],
|
|
['TURNING OFF LIVE DATA', 'LIVE DATA OFF'],
|
|
['', 'LOAD FAILED'],
|
|
]) {
|
|
const plan = planSplitFlap(from, to);
|
|
const widths = new Set();
|
|
for (let t = 0; t <= plan.durationMs + 50; t += 7) {
|
|
widths.add(Array.from(visibleGlyphs(plan, t)).length);
|
|
}
|
|
assert.deepEqual(
|
|
[...widths],
|
|
[plan.cells.length],
|
|
`${from} -> ${to} changed column count mid-cascade`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('an interrupt mid-shrink flaps away the blanks and glyphs actually on screen', () => {
|
|
const first = planSplitFlap('ABCD', 'AB');
|
|
const turn = FLAP_CHAR_MS * FLAP_TURN_RATIO;
|
|
// Sample after column 2 turns to a blank but before column 3 turns.
|
|
const at = first.cells[2].delayMs + turn + 1;
|
|
assert.ok(at < first.cells[3].delayMs + turn, 'sample must sit between the two turns');
|
|
const displayed = visibleGlyphs(first, at);
|
|
assert.equal(displayed, 'AB D');
|
|
const second = planSplitFlap(displayed, 'ABXY');
|
|
assert.equal(second.cells[2].from, ' ');
|
|
assert.equal(second.cells[3].from, 'D');
|
|
});
|
|
|
|
test('visibleGlyphs is defensive about junk input', () => {
|
|
assert.equal(visibleGlyphs(null, 100), '');
|
|
assert.equal(visibleGlyphs({ cells: [] }, 100), '');
|
|
const plan = planSplitFlap('AB', 'CD');
|
|
// Negative and non-finite elapsed times read as "nothing has turned yet".
|
|
assert.equal(visibleGlyphs(plan, -500), 'AB');
|
|
assert.equal(visibleGlyphs(plan, NaN), 'AB');
|
|
});
|