From e32b4172e4c197cf77294ff4cc69204f64445c9c Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 14 Oct 2026 16:36:27 +0200 Subject: [PATCH] swarm-ui: test the rain arithmetic and the swarm name loader The rain's column maths and fade colour move from MatrixRain into shell/rain.ts, and the request-once swarm name loader from useSwarmName into shell/swarm-name.ts. Both are plain functions with their input passed in, so tests cover them; the components keep the DOM and canvas work. --- .../swarm-ui/src/shell/MatrixRain.tsx | 34 ++++----- frontend/packages/swarm-ui/src/shell/rain.ts | 61 ++++++++++++++++ .../packages/swarm-ui/src/shell/swarm-name.ts | 28 ++++++++ .../swarm-ui/src/shell/useSwarmName.ts | 27 ++------ frontend/packages/swarm-ui/test/rain.test.mjs | 69 +++++++++++++++++++ .../swarm-ui/test/swarm-name.test.mjs | 60 ++++++++++++++++ 6 files changed, 237 insertions(+), 42 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/shell/rain.ts create mode 100644 frontend/packages/swarm-ui/src/shell/swarm-name.ts create mode 100644 frontend/packages/swarm-ui/test/rain.test.mjs create mode 100644 frontend/packages/swarm-ui/test/swarm-name.test.mjs diff --git a/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx b/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx index 7a21b4b2..95c37298 100644 --- a/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx +++ b/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx @@ -7,10 +7,16 @@ // `prefers-reduced-motion`. The same effect as the hive dashboard's home // page, which is plain JS over a fixed canvas. import { useEffect, useRef } from "preact/hooks"; +import { + CELL, + columnsFor, + fadeColorOf, + glyphFor, + startDrops, + stepDrops, +} from "./rain.js"; import "./MatrixRain.css"; -const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦"; -const CELL = 16; // glyph cell size, in px const STEP_MS = 55; // `getComputedStyle` on a custom property returns its declared value @@ -42,15 +48,7 @@ export function MatrixRain() { let fadeColor = ""; function resolveColors() { glyphColor = resolveColor("--green", "rgb(166, 227, 161)"); - // The fade trail is the background at low alpha. Its channels are - // pulled out numerically so the trail fades whatever spacing - // rgb()/rgba() comes back in, and never fills solid. - const bg = resolveColor("--bg", "rgb(30, 30, 46)").match( - /(\d+)[^\d]+(\d+)[^\d]+(\d+)/, - ); - fadeColor = bg - ? `rgba(${bg[1]}, ${bg[2]}, ${bg[3]}, 0.09)` - : "rgba(30, 30, 46, 0.09)"; + fadeColor = fadeColorOf(resolveColor("--bg", "rgb(30, 30, 46)")); } resolveColors(); const themeWatch = new MutationObserver(resolveColors); @@ -65,10 +63,8 @@ export function MatrixRain() { if (!canvas || !ctx) return; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; - cols = Math.max(1, Math.ceil(canvas.width / CELL)); - drops = Array.from({ length: cols }, () => - Math.floor(Math.random() * -40), - ); + cols = columnsFor(canvas.width); + drops = startDrops(cols, Math.random); ctx.font = `${CELL}px monospace`; // a canvas resize resets the font } @@ -78,13 +74,9 @@ export function MatrixRain() { ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = glyphColor; for (let i = 0; i < cols; i++) { - const ch = GLYPHS[Math.floor(Math.random() * GLYPHS.length)] ?? "0"; - ctx.fillText(ch, i * CELL, (drops[i] ?? 0) * CELL); - if ((drops[i] ?? 0) * CELL > canvas.height && Math.random() > 0.975) { - drops[i] = 0; - } - drops[i] = (drops[i] ?? 0) + 1; + ctx.fillText(glyphFor(Math.random), i * CELL, (drops[i] ?? 0) * CELL); } + drops = stepDrops(drops, canvas.height, Math.random); } let timer: number | null = null; diff --git a/frontend/packages/swarm-ui/src/shell/rain.ts b/frontend/packages/swarm-ui/src/shell/rain.ts new file mode 100644 index 00000000..12525b8a --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/rain.ts @@ -0,0 +1,61 @@ +// The digital rain's arithmetic, apart from the canvas: the glyph cell, how a +// column starts and steps, which glyph a column draws, and the translucent +// fade colour that leaves each glyph's trail. Plain functions, with the random +// source passed in, so they can be tested. + +export const GLYPHS = + "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦"; + +/** The glyph cell size, in px. */ +export const CELL = 16; + +/** How far above the top a column may start, in cells. */ +const MAX_HEAD_START = 40; + +/** The chance, once a column has fallen past the bottom, that it restarts at + * the top on a given step. */ +const RESTART_ABOVE = 0.975; + +/** The fade trail's alpha. */ +const FADE_ALPHA = 0.09; + +/** How many columns fill a canvas this wide. */ +export function columnsFor(width: number): number { + return Math.max(1, Math.ceil(width / CELL)); +} + +/** Each column's starting row: up to `MAX_HEAD_START` cells above the top, so + * the columns do not all start falling together. */ +export function startDrops(cols: number, rand: () => number): number[] { + return Array.from({ length: cols }, () => + Math.floor(rand() * -MAX_HEAD_START), + ); +} + +/** One step: every column moves down a row, and one that has fallen past the + * bottom of a canvas `height` tall goes back to the top on a small chance. */ +export function stepDrops( + drops: number[], + height: number, + rand: () => number, +): number[] { + return drops.map((row) => { + const restarted = row * CELL > height && rand() > RESTART_ABOVE ? 0 : row; + return restarted + 1; + }); +} + +/** The glyph a column draws this step. */ +export function glyphFor(rand: () => number): string { + return GLYPHS[Math.floor(rand() * GLYPHS.length)] ?? "0"; +} + +/** The fade colour for a computed background colour: the same channels at a low + * alpha. They are read out numerically, so the trail fades whatever spacing + * `rgb()` or `rgba()` comes back in; a colour with no channels gets the + * default background's, so the trail never fills solid. */ +export function fadeColorOf(background: string): string { + const m = background.match(/(\d+)[^\d]+(\d+)[^\d]+(\d+)/); + const [r, g, b] = m ? [m[1], m[2], m[3]] : [30, 30, 46]; + return `rgba(${r}, ${g}, ${b}, ${FADE_ALPHA})`; +} diff --git a/frontend/packages/swarm-ui/src/shell/swarm-name.ts b/frontend/packages/swarm-ui/src/shell/swarm-name.ts new file mode 100644 index 00000000..0d914cd9 --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/swarm-name.ts @@ -0,0 +1,28 @@ +// Loading the swarm's display name from `GET /api/swarm`, once. The header and +// the start page both ask for it and it does not change within a page visit, +// so the loader remembers the first request and every caller shares it. The +// label is cosmetic, so a failed request, an error response or an unconfigured +// name all answer `null` instead of throwing: callers fall back to their own +// default, as an operator who never set a name sees. + +type Fetch = (url: string) => Promise<{ + ok: boolean; + json: () => Promise; +}>; + +export function createSwarmNameLoader( + fetchImpl: Fetch, +): () => Promise { + let pending: Promise | null = null; + return () => { + if (pending === null) { + pending = (async () => { + const r = await fetchImpl("/api/swarm"); + if (!r.ok) return null; + const data = (await r.json()) as { name?: string | null }; + return data.name || null; + })().catch(() => null); + } + return pending; + }; +} diff --git a/frontend/packages/swarm-ui/src/shell/useSwarmName.ts b/frontend/packages/swarm-ui/src/shell/useSwarmName.ts index c2f3331a..19c4732a 100644 --- a/frontend/packages/swarm-ui/src/shell/useSwarmName.ts +++ b/frontend/packages/swarm-ui/src/shell/useSwarmName.ts @@ -1,31 +1,16 @@ -// useSwarmName — the swarm's configured display name from `GET /api/swarm`, -// or `null` until it arrives, when none is configured, or when the fetch -// fails. The label is cosmetic, so a failure is swallowed rather than shown: -// callers fall back to their own default, as an operator who never set a -// name sees. One request per page load, shared by every caller, because the -// header and the start page both ask for it and the name does not change -// within a visit. +// useSwarmName — the swarm's configured display name, or `null` until it +// arrives, when none is configured, or when the request fails. One request per +// page load, shared by every caller; see `swarm-name.ts`. import { useEffect, useState } from "preact/hooks"; +import { createSwarmNameLoader } from "./swarm-name.js"; -let pending: Promise | null = null; - -function fetchSwarmName(): Promise { - if (pending === null) { - pending = (async () => { - const r = await fetch("/api/swarm"); - if (!r.ok) return null; - const data = (await r.json()) as { name: string | null }; - return data.name || null; - })().catch(() => null); - } - return pending; -} +const loadSwarmName = createSwarmNameLoader((url) => fetch(url)); export function useSwarmName(): string | null { const [name, setName] = useState(null); useEffect(() => { let cancelled = false; - void fetchSwarmName().then((n) => { + void loadSwarmName().then((n) => { if (!cancelled && n !== null) setName(n); }); return () => { diff --git a/frontend/packages/swarm-ui/test/rain.test.mjs b/frontend/packages/swarm-ui/test/rain.test.mjs new file mode 100644 index 00000000..2e774f66 --- /dev/null +++ b/frontend/packages/swarm-ui/test/rain.test.mjs @@ -0,0 +1,69 @@ +// The digital rain's arithmetic: columns, how they start and step, the glyph +// drawn, and the fade colour. The random source is passed in, so each case is +// exact. Run by `npm test`. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + CELL, + GLYPHS, + columnsFor, + fadeColorOf, + glyphFor, + startDrops, + stepDrops, +} from "../src/shell/rain.ts"; + +const always = (n) => () => n; + +test("a canvas gets one column per cell, rounded up, and at least one", () => { + assert.equal(columnsFor(CELL * 10), 10); + assert.equal(columnsFor(CELL * 10 + 1), 11); + assert.equal(columnsFor(0), 1); +}); + +test("columns start above the top, spread over 40 cells", () => { + assert.deepEqual( + startDrops(3, always(0)).map((row) => row === 0), + [true, true, true], + ); + assert.deepEqual(startDrops(2, always(0.5)), [-20, -20]); + for (const row of startDrops(200, Math.random)) { + assert.ok(row <= 0 && row >= -40, String(row)); + } +}); + +test("a step moves every column down one row", () => { + assert.deepEqual(stepDrops([-3, 0, 5], 10_000, always(0)), [-2, 1, 6]); +}); + +test("a column past the bottom restarts only on a high enough roll", () => { + const past = [Math.ceil(100 / CELL) + 1]; + assert.deepEqual(stepDrops(past, 100, always(0.99)), [1]); + assert.deepEqual(stepDrops(past, 100, always(0.5)), [past[0] + 1]); +}); + +test("a column still on the canvas never restarts", () => { + assert.deepEqual(stepDrops([2], 100, always(0.99)), [3]); +}); + +test("a step does not change the array it was given", () => { + const drops = [1, 2]; + stepDrops(drops, 100, always(0.99)); + assert.deepEqual(drops, [1, 2]); +}); + +test("the glyph comes from the set, first and last at the ends of the roll", () => { + assert.equal(glyphFor(always(0)), GLYPHS[0]); + assert.equal(glyphFor(always(0.999999)), GLYPHS[GLYPHS.length - 1]); +}); + +test("the fade colour keeps the background's channels at a low alpha", () => { + assert.equal(fadeColorOf("rgb(30, 30, 46)"), "rgba(30, 30, 46, 0.09)"); + assert.equal(fadeColorOf("rgb(239,241,245)"), "rgba(239, 241, 245, 0.09)"); + assert.equal(fadeColorOf("rgba(1, 2, 3, 0.5)"), "rgba(1, 2, 3, 0.09)"); +}); + +test("a colour with no channels fades from the default background", () => { + assert.equal(fadeColorOf("transparent"), "rgba(30, 30, 46, 0.09)"); + assert.equal(fadeColorOf(""), "rgba(30, 30, 46, 0.09)"); +}); diff --git a/frontend/packages/swarm-ui/test/swarm-name.test.mjs b/frontend/packages/swarm-ui/test/swarm-name.test.mjs new file mode 100644 index 00000000..e3538182 --- /dev/null +++ b/frontend/packages/swarm-ui/test/swarm-name.test.mjs @@ -0,0 +1,60 @@ +// The swarm name loader: one request however many callers, and a name that is +// absent, empty, or failed to load all answer null. Run by `npm test`. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createSwarmNameLoader } from "../src/shell/swarm-name.ts"; + +// A fetch that answers `body` as an ok response, counting its calls. +function fetching(body, ok = true) { + const f = async (url) => { + f.calls.push(url); + return { ok, json: async () => body }; + }; + f.calls = []; + return f; +} + +test("the loader answers the configured name", async () => { + const f = fetching({ name: "constellation" }); + assert.equal(await createSwarmNameLoader(f)(), "constellation"); + assert.deepEqual(f.calls, ["/api/swarm"]); +}); + +test("every caller shares one request", async () => { + const f = fetching({ name: "constellation" }); + const load = createSwarmNameLoader(f); + const names = await Promise.all([load(), load(), load()]); + assert.deepEqual(names, ["constellation", "constellation", "constellation"]); + assert.equal(f.calls.length, 1); +}); + +test("a null, empty or missing name answers null", async () => { + for (const body of [{ name: null }, { name: "" }, {}]) { + assert.equal(await createSwarmNameLoader(fetching(body))(), null); + } +}); + +test("an error response answers null", async () => { + assert.equal( + await createSwarmNameLoader(fetching({ name: "x" }, false))(), + null, + ); +}); + +test("a failed request answers null rather than throwing", async () => { + const load = createSwarmNameLoader(async () => { + throw new Error("offline"); + }); + assert.equal(await load(), null); +}); + +test("a failed request is remembered, not retried by later callers", async () => { + let calls = 0; + const load = createSwarmNameLoader(async () => { + calls += 1; + throw new Error("offline"); + }); + await load(); + await load(); + assert.equal(calls, 1); +});