| 1 | // Tests for the boot scene's pure timing/geometry (the light's band math, the |
| 2 | // quote fade envelope, the staged cover's dwell guarantees). node test/bootscene.test.mjs |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { readFileSync } from "node:fs"; |
| 5 | import { BANDS, bandLight, quoteOpacity, finishWait, coverWait, validQuoteLib } from "../web/static/bootscene.js"; |
| 6 | |
| 7 | let failures = 0; |
| 8 | async function test(name, fn) { |
| 9 | try { |
| 10 | await fn(); |
| 11 | console.log("PASS " + name); |
| 12 | } catch (e) { |
| 13 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 14 | failures++; |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | const sum = (l) => l[0] + l[1] + l[2] + l[3]; |
| 19 | |
| 20 | test("bands cover the road in order: green, amber, red, eleven", () => { |
| 21 | assert.equal(BANDS.length, 5); |
| 22 | assert.equal(BANDS[0], 0); |
| 23 | assert.equal(BANDS[4], 1); |
| 24 | for (let i = 1; i < BANDS.length; i++) assert.ok(BANDS[i] > BANDS[i - 1]); |
| 25 | }); |
| 26 | |
| 27 | test("the soft frontier leads the level by a bounded margin", () => { |
| 28 | // The light fades out ~0.14 past the frontier instead of ending at a hard |
| 29 | // line. At level 0 that lead may kiss the very bottom of the road, but |
| 30 | // beyond it there must be nothing. |
| 31 | for (const u of [0.15, 0.2, 0.5, 0.9]) assert.equal(sum(bandLight(u, 0, 0)), 0); |
| 32 | }); |
| 33 | |
| 34 | test("the frontier gates the climb: light below it, none above", () => { |
| 35 | const l = 3; // frontier at 3/11 |
| 36 | assert.ok(bandLight(0.1, l, 0)[0] > 0, "green near the source"); |
| 37 | assert.equal(sum(bandLight(0.5, l, 0)), 0, "dark past the frontier"); |
| 38 | }); |
| 39 | |
| 40 | test("the zen climb can never reach the red", () => { |
| 41 | // The climb asymptotes at level 6.2 (frontier 0.564 + soft lead 0.14 → |
| 42 | // light tops out below u=0.71): the frontier's bleed may warm the bottom |
| 43 | // of the amber, but red (from 9/11 = 0.818) only ever arrives with the |
| 44 | // punch. |
| 45 | for (const u of [0.71, 0.8, 0.9, 0.95]) { |
| 46 | assert.equal(sum(bandLight(u, 6.2, 0)), 0, `light found at u=${u} during climb`); |
| 47 | } |
| 48 | }); |
| 49 | |
| 50 | test("colour belongs to height, not to level: green stays green at ten", () => { |
| 51 | const low = bandLight(0.2, 10, 0); |
| 52 | assert.ok(low[0] > 0, "the bottom is still green at level ten"); |
| 53 | assert.equal(low[1] + low[2] + low[3], 0, "no amber/red bleeding into the green band"); |
| 54 | const mid = bandLight(0.7, 10, 0); |
| 55 | assert.ok(mid[1] > 0, "amber lives above the green"); |
| 56 | const high = bandLight(0.88, 10, 0); |
| 57 | assert.ok(high[2] > 0, "red above the amber"); |
| 58 | }); |
| 59 | |
| 60 | test("the eleventh band lights only with elevenGlow", () => { |
| 61 | const u = 0.96; // inside the eleven band |
| 62 | assert.equal(bandLight(u, 10, 0)[3], 0, "dark before the punch lands"); |
| 63 | assert.ok(bandLight(u, 10, 1)[3] > 0, "lit at eleven"); |
| 64 | }); |
| 65 | |
| 66 | test("light is hottest near the unseen source", () => { |
| 67 | const near = sum(bandLight(0.05, 10, 0)); |
| 68 | const far = sum(bandLight(0.5, 10, 0)); |
| 69 | assert.ok(near > far, "intensity must fall with distance up the road"); |
| 70 | }); |
| 71 | |
| 72 | test("quote envelope: fade in, hold, fade out", () => { |
| 73 | const F = 2200, H = 7600; |
| 74 | assert.equal(quoteOpacity(0, F, H), 0); |
| 75 | assert.equal(quoteOpacity(F / 2, F, H), 0.5); |
| 76 | assert.equal(quoteOpacity(F, F, H), 1); |
| 77 | assert.equal(quoteOpacity(F + H / 2, F, H), 1); |
| 78 | assert.ok(Math.abs(quoteOpacity(F + H + F / 2, F, H) - 0.5) < 1e-9); |
| 79 | assert.equal(quoteOpacity(F + H + F, F, H), 0); |
| 80 | assert.equal(quoteOpacity(F + H + F + 500, F, H), 0, "stays 0 past the cycle"); |
| 81 | assert.equal(quoteOpacity(-100, F, H), 0, "never negative-age light"); |
| 82 | }); |
| 83 | |
| 84 | test("reading-time guarantee: a fresh quote delays the punch, an old one doesn't", () => { |
| 85 | assert.equal(finishWait(null, 4500), 0, "no quote on stage → punch immediately"); |
| 86 | assert.equal(finishWait(0, 4500), 4500, "a quote that JUST appeared gets its full read"); |
| 87 | assert.equal(finishWait(1000, 4500), 3500); |
| 88 | assert.equal(finishWait(4500, 4500), 0); |
| 89 | assert.equal(finishWait(60000, 4500), 0, "a long-displayed quote owes nothing"); |
| 90 | }); |
| 91 | |
| 92 | test("staged cover: nothing assembled → no dwell owed", () => { |
| 93 | assert.equal(coverWait(null, null), 0, "a fast boot fades before any stage enters"); |
| 94 | }); |
| 95 | |
| 96 | test("staged cover: an entered brand gets its fade plus a beat", () => { |
| 97 | assert.equal(coverWait(0, null), 850, "just entered: full fade (350) + dwell (500)"); |
| 98 | assert.equal(coverWait(600, null), 250); |
| 99 | assert.equal(coverWait(850, null), 0, "dwell spent — exactly when the quote's stage may open"); |
| 100 | assert.equal(coverWait(60000, null), 0); |
| 101 | }); |
| 102 | |
| 103 | test("staged cover: an entered quote gets its fade plus a read, subsuming the brand", () => { |
| 104 | assert.equal(coverWait(9999, 0), 3200, "just dealt: full fade (2200) + read (1000)"); |
| 105 | assert.equal(coverWait(9999, 2200), 1000, "fully faded in — the read is still owed"); |
| 106 | assert.equal(coverWait(9999, 3200), 0, "read spent — exactly when the smoke's stage may open"); |
| 107 | assert.equal(coverWait(null, 0), 3200, "the quote's guarantee stands on its own"); |
| 108 | }); |
| 109 | |
| 110 | test("validQuoteLib accepts only a real library", () => { |
| 111 | const good = Array.from({ length: 25 }, (_, i) => [`quote ${i}`, `who ${i}`]); |
| 112 | assert.ok(validQuoteLib(good)); |
| 113 | assert.ok(!validQuoteLib(null), "null"); |
| 114 | assert.ok(!validQuoteLib("[]"), "a string"); |
| 115 | assert.ok(!validQuoteLib([]), "empty"); |
| 116 | assert.ok(!validQuoteLib(good.slice(0, 10)), "too small to be the library"); |
| 117 | assert.ok(!validQuoteLib(Array.from({ length: 25 }, () => ["text only"])), "pairs missing an attribution"); |
| 118 | assert.ok(!validQuoteLib(Array.from({ length: 25 }, () => ["", "who"])), "empty quote text"); |
| 119 | }); |
| 120 | |
| 121 | test("the shipped library is valid, generous, and deduped", () => { |
| 122 | const lib = JSON.parse(readFileSync(new URL("../web/static/quotes.json", import.meta.url), "utf8")); |
| 123 | assert.ok(validQuoteLib(lib), "shape: an array of [text, attribution] pairs"); |
| 124 | assert.ok(lib.length >= 300, `to-a-human enormous: only ${lib.length} quotes`); |
| 125 | const texts = new Set(lib.map((q) => q[0])); |
| 126 | assert.equal(texts.size, lib.length, "no duplicate quote texts"); |
| 127 | for (const [text, attr] of lib) { |
| 128 | assert.ok(text.length <= 240, `fits the card: "${text.slice(0, 50)}…" is ${text.length} chars`); |
| 129 | assert.ok(attr.length <= 120, `attribution fits: "${attr.slice(0, 50)}…"`); |
| 130 | } |
| 131 | }); |
| 132 | |
| 133 | process.exit(failures ? 1 : 0); |
| 134 | |