Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-14 16:36:27 +02:00 • committed by mara
commit e32b4172e4
6 changed files with 237 additions and 42 deletions

View file

@ -7,10 +7,16 @@
// `prefers-reduced-motion`. The same effect as the hive dashboard's home // `prefers-reduced-motion`. The same effect as the hive dashboard's home
// page, which is plain JS over a fixed canvas. // page, which is plain JS over a fixed canvas.
import { useEffect, useRef } from "preact/hooks"; import { useEffect, useRef } from "preact/hooks";
import {
CELL,
columnsFor,
fadeColorOf,
glyphFor,
startDrops,
stepDrops,
} from "./rain.js";
import "./MatrixRain.css"; import "./MatrixRain.css";
const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
const CELL = 16; // glyph cell size, in px
const STEP_MS = 55; const STEP_MS = 55;
// `getComputedStyle` on a custom property returns its declared value // `getComputedStyle` on a custom property returns its declared value
@ -42,15 +48,7 @@ export function MatrixRain() {
let fadeColor = ""; let fadeColor = "";
function resolveColors() { function resolveColors() {
glyphColor = resolveColor("--green", "rgb(166, 227, 161)"); glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
// The fade trail is the background at low alpha. Its channels are fadeColor = fadeColorOf(resolveColor("--bg", "rgb(30, 30, 46)"));
// 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)";
} }
resolveColors(); resolveColors();
const themeWatch = new MutationObserver(resolveColors); const themeWatch = new MutationObserver(resolveColors);
@ -65,10 +63,8 @@ export function MatrixRain() {
if (!canvas || !ctx) return; if (!canvas || !ctx) return;
canvas.width = canvas.offsetWidth; canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight; canvas.height = canvas.offsetHeight;
cols = Math.max(1, Math.ceil(canvas.width / CELL)); cols = columnsFor(canvas.width);
drops = Array.from({ length: cols }, () => drops = startDrops(cols, Math.random);
Math.floor(Math.random() * -40),
);
ctx.font = `${CELL}px monospace`; // a canvas resize resets the font 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.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = glyphColor; ctx.fillStyle = glyphColor;
for (let i = 0; i < cols; i++) { for (let i = 0; i < cols; i++) {
const ch = GLYPHS[Math.floor(Math.random() * GLYPHS.length)] ?? "0"; ctx.fillText(glyphFor(Math.random), i * CELL, (drops[i] ?? 0) * CELL);
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;
} }
drops = stepDrops(drops, canvas.height, Math.random);
} }
let timer: number | null = null; let timer: number | null = null;

View file

@ -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})`;
}

View file

@ -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<unknown>;
}>;
export function createSwarmNameLoader(
fetchImpl: Fetch,
): () => Promise<string | null> {
let pending: Promise<string | null> | 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;
};
}

View file

@ -1,31 +1,16 @@
// useSwarmName — the swarm's configured display name from `GET /api/swarm`, // useSwarmName — the swarm's configured display name, or `null` until it
// or `null` until it arrives, when none is configured, or when the fetch // arrives, when none is configured, or when the request fails. One request per
// fails. The label is cosmetic, so a failure is swallowed rather than shown: // page load, shared by every caller; see `swarm-name.ts`.
// 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.
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { createSwarmNameLoader } from "./swarm-name.js";
let pending: Promise<string | null> | null = null; const loadSwarmName = createSwarmNameLoader((url) => fetch(url));
function fetchSwarmName(): Promise<string | null> {
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;
}
export function useSwarmName(): string | null { export function useSwarmName(): string | null {
const [name, setName] = useState<string | null>(null); const [name, setName] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void fetchSwarmName().then((n) => { void loadSwarmName().then((n) => {
if (!cancelled && n !== null) setName(n); if (!cancelled && n !== null) setName(n);
}); });
return () => { return () => {

View file

@ -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)");
});

View file

@ -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);
});