@pi-archimedes/core 2.5.1 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,702 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ BorderTypeSpinner,
4
+ SPIN_INTERVALS,
5
+ SPIN_VARIANTS,
6
+ STAGE_MASKS,
7
+ normalizeSpinnerStyle,
8
+ shadeForMask,
9
+ } from "./spin.js";
10
+
11
+ // ── Helpers ──────────────────────────────────────────────────────────────────
12
+
13
+ /** A spinner (the `typing` style, the one that ships in batch 1) whose isIdle() consumes `seq` one value per call (idle afterwards). */
14
+ function spinner(
15
+ seq: boolean[],
16
+ probe: (s: string) => number,
17
+ ): BorderTypeSpinner {
18
+ return new BorderTypeSpinner(
19
+ () => (seq.length > 0 ? (seq.shift() as boolean) : true),
20
+ "typing",
21
+ probe,
22
+ );
23
+ }
24
+
25
+ /** A always-busy spinner: enough `false` isIdle values for `n` ticks. */
26
+ function busyAt(
27
+ seq: boolean[],
28
+ probe: (s: string) => number,
29
+ n: number,
30
+ ): BorderTypeSpinner {
31
+ const sp = spinner(seq, probe);
32
+ for (let i = 0; i < n; i++) sp.tick();
33
+ return sp;
34
+ }
35
+
36
+ const probe1 = (s: string): number => (s === "⣿" ? 1 : 1); // non-EAW: braille set
37
+ const probe2 = (s: string): number => (s === "⣿" ? 2 : 1); // EAW: shade set
38
+ const allBusy = (n: number): boolean[] => Array.from({ length: n }, () => false);
39
+
40
+ /** Dot count of an 8-bit mask (EAW tier input). */
41
+ const popcount = (mask: number): number => {
42
+ let n = 0;
43
+ for (let b = 0; b < 8; b++) n += (mask >> b) & 1;
44
+ return n;
45
+ };
46
+
47
+ // ── 1. Frame table: 32 distinct beats, fill cell-by-cell line by line ───────
48
+ // Cells fill cell-by-cell left→right, each cell walking the 8 chart-order
49
+ // stages in 2-step line pairs (⠁⠉ / ⠋⠛ / ⠟⠿ / ⡿⣿): line 1 1–8,
50
+ // line 2 9–16, line 3 17–24, line 4 25–32 — the 2×4 dot block grows
51
+ // across the 4-cell window line by line; hold clamps; the wrap clears.
52
+
53
+ describe("frame table (non-EAW braille set)", () => {
54
+ const beat = (n: number): string => busyAt(allBusy(n), probe1, n).frame();
55
+
56
+ it("exposes the 4-cell window width as a static", () => {
57
+ expect(BorderTypeSpinner.CELLS).toBe(4);
58
+ });
59
+
60
+ it("step 0 (never ticked): the frame is 4 spaces", () => {
61
+ expect(spinner([true], probe1).frame()).toBe(" ");
62
+ });
63
+
64
+ it("the 32 frames are all distinct, 4 cells wide, and tile-free", () => {
65
+ const seq = spinner(allBusy(32).concat(true, true), probe1);
66
+ const frames: string[] = [];
67
+ for (let i = 0; i < 32; i++) {
68
+ seq.tick();
69
+ frames.push(seq.frame());
70
+ }
71
+ expect(frames).toHaveLength(32);
72
+ expect(new Set(frames).size).toBe(32);
73
+ for (const f of frames) {
74
+ expect(f.length).toBe(4);
75
+ expect(f.split("").every((c) => c === " " || "⠁⠉⠋⠛⠟⠿⡿⣿".includes(c))).toBe(true);
76
+ }
77
+ });
78
+
79
+ it("step 1: only cell 1 is `⠁` (dot block starts to grow)", () => {
80
+ expect(beat(1)).toBe("⠁ ");
81
+ });
82
+
83
+ it("step 2: cell 1 advances to `⠉`", () => {
84
+ expect(beat(2)).toBe("⠉ ");
85
+ });
86
+
87
+ it("step 3: cell 2 enters at `⠁`, cell 1 holds `⠉`", () => {
88
+ expect(beat(3)).toBe("⠉⠁ ");
89
+ });
90
+
91
+ it("step 4: cell 2 completes its line pair at `⠉`", () => {
92
+ expect(beat(4)).toBe("⠉⠉ ");
93
+ });
94
+
95
+ it("step 8 (line 1 complete): all 4 cells are `⠉`", () => {
96
+ expect(beat(8)).toBe("⠉⠉⠉⠉");
97
+ });
98
+
99
+ it("step 9 (inter-line 1→2): cell 1 re-enters at `⠋`, the rest hold `⠉`", () => {
100
+ expect(beat(9)).toBe("⠋⠉⠉⠉");
101
+ });
102
+
103
+ it("step 10: cell 1 completes at `⠛`", () => {
104
+ expect(beat(10)).toBe("⠛⠉⠉⠉");
105
+ });
106
+
107
+ it("step 16 (line 2 complete): all 4 cells are `⠛`", () => {
108
+ expect(beat(16)).toBe("⠛⠛⠛⠛");
109
+ });
110
+
111
+ it("step 17 (inter-line 2→3): cell 1 re-enters at `⠟`, the rest hold `⠛`", () => {
112
+ expect(beat(17)).toBe("⠟⠛⠛⠛");
113
+ });
114
+
115
+ it("step 22: cells 2–3 are `⠿` (cell 3 just completed), cells 1 and 4 hold `⠛`", () => {
116
+ expect(beat(22)).toBe("⠿⠿⠿⠛");
117
+ });
118
+
119
+ it("step 24 (line 3 complete): all 4 cells are `⠿`", () => {
120
+ expect(beat(24)).toBe("⠿⠿⠿⠿");
121
+ });
122
+
123
+ it("step 25 (inter-line 3→4): cell 1 re-enters at `⡿`, the rest hold `⠿`", () => {
124
+ expect(beat(25)).toBe("⡿⠿⠿⠿");
125
+ });
126
+
127
+ it("step 32 (fully grown): all 4 cells are `⣿`", () => {
128
+ expect(beat(32)).toBe("⣿⣿⣿⣿");
129
+ });
130
+
131
+ it("hold clamps (steps 33–37): the full block holds — all 4 cells still `⣿`", () => {
132
+ for (const n of [33, 34, 35, 36, 37]) {
133
+ expect(beat(n)).toBe("⣿⣿⣿⣿");
134
+ }
135
+ expect(beat(38)).toBe(" "); // step 38 is the clear beat, not hold
136
+ });
137
+
138
+ it("step 39 wraps to step 1: the step-38 clear beat is 4 spaces, then the block grows again at `⠁`", () => {
139
+ const seq = spinner(allBusy(39), probe1);
140
+ for (let i = 0; i < 37; i++) seq.tick();
141
+ expect(seq.frame()).toBe("⣿⣿⣿⣿"); // hold tail
142
+ seq.tick(); // step 38 — the clear beat
143
+ expect(seq.frame()).toBe(" ");
144
+ seq.tick(); // step 39 % 38 = step 1
145
+ expect(seq.frame()).toBe("⠁ ");
146
+ });
147
+ });
148
+
149
+ describe("hold-0 port styles: the full source loop (no blank clear beat)", () => {
150
+ const renderMask = (masks: number[]): string =>
151
+ masks.map((m) => (m === 0 ? " " : String.fromCharCode(0x2800 + m))).join("");
152
+ const wave = SPIN_VARIANTS["wave-rows"]!;
153
+
154
+ it("step 0 (never ticked) renders frames[0] — the real first frame, not a blank beat", () => {
155
+ const sp = new BorderTypeSpinner(() => false, "wave-rows", probe1);
156
+ expect(sp.frame()).toBe(renderMask(wave.compute(0)));
157
+ });
158
+
159
+ it("step 5 renders frames[5] (real mask, NOT blank)", () => {
160
+ const sp = new BorderTypeSpinner(() => false, "wave-rows", probe1);
161
+ for (let i = 0; i < 5; i++) sp.tick();
162
+ expect(sp.frame()).toBe(renderMask(wave.compute(5)));
163
+ });
164
+
165
+ it("the last step (steps − 1) renders frames[steps − 1] (previously unreachable)", () => {
166
+ const sp = new BorderTypeSpinner(() => false, "wave-rows", probe1);
167
+ for (let i = 0; i < wave.steps - 1; i++) sp.tick();
168
+ expect(sp.frame()).toBe(renderMask(wave.compute(wave.steps - 1)));
169
+ });
170
+
171
+ it("typing (hold > 0) keeps its blank beat: step 0 is 4 spaces, the wrap tick too", () => {
172
+ const sp = new BorderTypeSpinner(() => false, "typing", probe1);
173
+ expect(sp.frame()).toBe(" "); // step 0 — the blank beat
174
+ for (let i = 0; i < 38; i++) sp.tick(); // wraps to 0
175
+ expect(sp.frame()).toBe(" ");
176
+ });
177
+ });
178
+
179
+ // ── 2. Busy/idle tick state machine ─────────────────────────────────────────
180
+ // idle→idle no repaint; idle→busy advances; busy→busy advances;
181
+ // busy→idle resets + exactly one repaint; the machine re-arms after idle.
182
+
183
+ describe("busy/idle tick state machine", () => {
184
+ it("idle→idle is a full no-op: no repaint, frame holds at 4 spaces", () => {
185
+ const sp = spinner([true, true], probe1);
186
+ expect(sp.tick()).toBe(false);
187
+ expect(sp.tick()).toBe(false);
188
+ expect(sp.frame()).toBe(" ");
189
+ });
190
+
191
+ it("idle→busy advances; busy→busy advances; busy→idle resets + repaints once; then no-op", () => {
192
+ // isIdle(): true, true, false, false, false, true, across the ticks
193
+ const sp = spinner([true, true, false, false, false, true], probe1);
194
+
195
+ expect(sp.tick()).toBe(false); // idle #1 — no repaint
196
+ expect(sp.tick()).toBe(false); // idle #2 — no repaint
197
+ expect(sp.tick()).toBe(true); // busy #1 — repaint, step 1
198
+ expect(sp.frame()).toBe("⠁ ");
199
+ expect(sp.tick()).toBe(true); // busy #2 — repaint, step 2
200
+ expect(sp.frame()).toBe("⠉ ");
201
+ expect(sp.tick()).toBe(true); // busy #3 — repaint, step 3
202
+ expect(sp.frame()).toBe("⠉⠁ ");
203
+ expect(sp.tick()).toBe(true); // idle after busy — reset + exactly one repaint
204
+ expect(sp.frame()).toBe(" "); // reset to the empty clear frame
205
+ expect(sp.frame()).toBe(" "); // and it stays reset (no further repaint)
206
+ });
207
+
208
+ it("the machine re-arms after idle: a new busy streak restarts at step 1", () => {
209
+ // isIdle(): false, true, false, false
210
+ const sp = spinner([false, true, false, false], probe1);
211
+ sp.tick(); // busy → step 1
212
+ expect(sp.frame()).toBe("⠁ ");
213
+ sp.tick(); // idle → reset + repaint
214
+ sp.tick(); // busy again → step 1, not step 2
215
+ expect(sp.frame()).toBe("⠁ ");
216
+ sp.tick(); // busy → step 2
217
+ expect(sp.frame()).toBe("⠉ ");
218
+ });
219
+
220
+ it("a busy streak wraps at the 38-step cycle: the step-38 clear beat is 4 spaces, then the block grows again", () => {
221
+ const sp = spinner(allBusy(40), probe1);
222
+ for (let i = 0; i < 37; i++) sp.tick();
223
+ expect(sp.frame()).toBe("⣿⣿⣿⣿"); // hold region
224
+ sp.tick(); // step 38 — the clear beat: border-line broken spaces
225
+ expect(sp.frame()).toBe(" ");
226
+ sp.tick(); // step 1 — cell 1 enters at ⠁
227
+ expect(sp.frame()).toBe("⠁ ");
228
+ });
229
+ });
230
+
231
+ // ── 3. EAW probe (⣿ reports width 2 → shade set, braille never appears) ───
232
+
233
+ describe("EAW terminal fallback (ctor probe)", () => {
234
+ const braille = ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "⡿", "⣿"];
235
+
236
+ it("probe('⣿') === 2: the window uses the shade set (░→█) in the same cell-wise order, no braille in any frame", () => {
237
+ const sp = spinner(allBusy(38), probe2);
238
+ const frames: string[] = [];
239
+ for (let i = 0; i < 38; i++) {
240
+ sp.tick();
241
+ frames.push(sp.frame());
242
+ }
243
+ expect(frames[0]).toBe("░ "); // step 1
244
+ expect(frames[2]).toBe("░░ "); // step 3
245
+ expect(frames[7]).toBe("░░░░"); // step 8 (line 1 complete)
246
+ expect(frames[16]).toBe("▒░░░"); // step 17 (inter-line 2→3)
247
+ expect(frames[31]).toBe("████"); // step 32 (fully grown)
248
+ expect(frames[32]).toBe("████"); // hold clamps
249
+ expect(frames[37]).toBe(" "); // step 38 clear beat
250
+ for (const f of frames) {
251
+ for (const c of braille) expect(f).not.toContain(c);
252
+ }
253
+ });
254
+
255
+ it("probe('⣿') === 1: the braille set is used; the shade chars never appear", () => {
256
+ const sp = spinner(allBusy(38), probe1);
257
+ for (let i = 0; i < 38; i++) {
258
+ sp.tick();
259
+ const f = sp.frame();
260
+ for (const c of ["░", "▒", "▓", "█"]) expect(f).not.toContain(c);
261
+ }
262
+ });
263
+ });
264
+
265
+ // ── 4. Style-selector architecture (batch 1: SPIN_INTERVALS, variants,
266
+ // normalize, shade mapping, mask sets, typing compute round-trip) ────────
267
+
268
+ describe("SPIN_INTERVALS (native per-tick ms from the source library)", () => {
269
+ it("is the exact ten-style map — the 32 ms tick floor is applied at the editor, not here", () => {
270
+ expect(SPIN_INTERVALS).toEqual({
271
+ typing: 80,
272
+ pulse: 60,
273
+ rain: 40,
274
+ cascade: 40,
275
+ columns: 40,
276
+ "wave-rows": 40,
277
+ "diagonal-swipe": 30,
278
+ sparkle: 40,
279
+ pendulum: 12,
280
+ marquee: 55,
281
+ });
282
+ });
283
+ });
284
+
285
+ describe("normalizeSpinnerStyle (library normalizeVariant fallback)", () => {
286
+ it("all ten registered style names (the full set) normalize to themselves", () => {
287
+ for (const s of ["typing", "wave-rows", "columns", "pulse", "marquee", "pendulum", "cascade", "diagonal-swipe", "rain", "sparkle"]) {
288
+ expect(normalizeSpinnerStyle(s)).toBe(s);
289
+ }
290
+ });
291
+
292
+ it("rain / sparkle (batch 4) normalize to themselves", () => {
293
+ expect(normalizeSpinnerStyle("rain")).toBe("rain");
294
+ expect(normalizeSpinnerStyle("sparkle")).toBe("sparkle");
295
+ });
296
+
297
+ it("unknown / malformed strings normalize to typing", () => {
298
+ // The unknown-value fallback is typing (unchanged); the DEFAULT setting is pendulum (DEFAULT_CORE_CONFIG, config.ts) — the two are distinct.
299
+ for (const s of ["nope", "", "Typing"]) {
300
+ expect(normalizeSpinnerStyle(s)).toBe("typing");
301
+ }
302
+ });
303
+ });
304
+
305
+ describe("shadeForMask (EAW density tier by popcount)", () => {
306
+ it("tier 0 (0 dots) → space", () => {
307
+ expect(shadeForMask(0)).toBe(" ");
308
+ });
309
+
310
+ it("tier 1–2 → ░ (e.g. mask 0x81 popcount 2)", () => {
311
+ expect(shadeForMask(0x01)).toBe("░");
312
+ expect(shadeForMask(0x81)).toBe("░");
313
+ });
314
+
315
+ it("tier 3–4 → ▒", () => {
316
+ expect(shadeForMask(0b111)).toBe("▒");
317
+ expect(shadeForMask(0b1111)).toBe("▒");
318
+ });
319
+
320
+ it("tier 5–6 → ▓", () => {
321
+ expect(shadeForMask(0b11111)).toBe("▓");
322
+ expect(shadeForMask(0b110111)).toBe("▓");
323
+ });
324
+
325
+ it("tier 7–8 → █ (e.g. the two extreme masks)", () => {
326
+ expect(shadeForMask(0b1111111)).toBe("█");
327
+ expect(shadeForMask(0xff)).toBe("█");
328
+ });
329
+
330
+ it("the 8 chart-order STAGE_MASKS query the tiers 1/2/3/4/5/6/7/8 (e.g. 0x0B popcount 3 → ▒) through the density mapping (the hypothetical port-mask set the EAW leg port-styles will use)", () => {
331
+ expect(STAGE_MASKS.map(shadeForMask)).toEqual([
332
+ "░", "░", "▒", "▒", "▓", "▓", "█", "█",
333
+ ]);
334
+ });
335
+ });
336
+
337
+ describe("STAGE_MASKS (the 8 chart-order masks)", () => {
338
+ const BRAILLE = ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "⡿", "⣿"];
339
+ const SHADE = ["░", "░", "░", "░", "▒", "▒", "▓", "█"];
340
+
341
+ it("char k = String.fromCharCode(0x2800 + mask) exactly reproduces the old braille stage set (8 non-empty chars, all SE-width-by-mask)", () => {
342
+ expect(STAGE_MASKS).toHaveLength(8);
343
+ for (let k = 0; k < 8; k++) {
344
+ const mask = STAGE_MASKS[k]!;
345
+ expect(String.fromCharCode(0x2800 + mask)).toBe(BRAILLE[k]);
346
+ expect(String.fromCharCode(0x2800 + mask)).not.toBe(" ");
347
+ }
348
+ });
349
+
350
+ it("the STAGE_SHADE lookup index covers all 8: mask → old shade char is the char at the same index (STAGE_MASKS.indexOf(m) covers 0–7)", () => {
351
+ for (let k = 0; k < 8; k++) {
352
+ const mask = STAGE_MASKS[k]!;
353
+ expect(STAGE_MASKS.indexOf(mask)).toBe(k);
354
+ expect(SHADE[STAGE_MASKS.indexOf(mask)]).toBe(SHADE[k]);
355
+ }
356
+ });
357
+
358
+ it("each mask is exactly 8-bit (0 ≤ m ≤ 0xff) and distinct", () => {
359
+ for (const m of STAGE_MASKS) {
360
+ expect(m).toBeGreaterThanOrEqual(0);
361
+ expect(m).toBeLessThanOrEqual(0xff);
362
+ }
363
+ expect(new Set(STAGE_MASKS).size).toBe(8);
364
+ });
365
+ });
366
+
367
+ describe("typing compute round-trip (SPIN_VARIANTS.typing)", () => {
368
+ const typing = SPIN_VARIANTS.typing!;
369
+
370
+ it("is registered with steps 38, hold 6 (the 38-cycle machine)", () => {
371
+ expect(typing.steps).toBe(38);
372
+ expect(typing.hold).toBe(6);
373
+ expect(normalizeSpinnerStyle("typing")).toBe("typing");
374
+ });
375
+
376
+ it("compute returns 4 8-bit masks; the braille path renders byte-identical to the old frames (spot: step 1/8/16/17/24/32)", () => {
377
+ const render = (f: number): string =>
378
+ typing
379
+ .compute(f)
380
+ .map((m) => (m === 0 ? " " : String.fromCharCode(0x2800 + m)))
381
+ .join("");
382
+ expect(render(0)).toBe("⠁ "); // fill step 1
383
+ expect(render(7)).toBe("⠉⠉⠉⠉"); // fill step 8
384
+ expect(render(15)).toBe("⠛⠛⠛⠛"); // fill step 16
385
+ expect(render(16)).toBe("⠟⠛⠛⠛"); // fill step 17
386
+ expect(render(23)).toBe("⠿⠿⠿⠿"); // fill step 24
387
+ expect(render(31)).toBe("⣿⣿⣿⣿"); // fill step 32
388
+ });
389
+
390
+ it("compute(15) (fill step 16) is all cell masks of the 4th chart-order stage (0x1B)", () => {
391
+ expect(typing.compute(15)).toEqual([0x1b, 0x1b, 0x1b, 0x1b]);
392
+ });
393
+
394
+ it("the EAW path (typing → STAGE_SHADE by STAGE_MASKS index) renders byte-identical to the old EAW frames via the live spinner: steps 1/8/16/17 (stage for ⠛ is the 4th → ░, NOT ▒)", () => {
395
+ const sp = busyAt(Array.from({ length: 17 }, () => false), (s: string) => (s === "⣿" ? 2 : 1), 1);
396
+ expect(sp.frame()).toBe("░ ");
397
+ const sp8 = busyAt(Array.from({ length: 8 }, () => false), (s: string) => (s === "⣿" ? 2 : 1), 8);
398
+ expect(sp8.frame()).toBe("░░░░");
399
+ const sp16 = busyAt(Array.from({ length: 16 }, () => false), (s: string) => (s === "⣿" ? 2 : 1), 16);
400
+ expect(sp16.frame()).toBe("░░░░");
401
+ const sp17 = busyAt(Array.from({ length: 17 }, () => false), (s: string) => (s === "⣿" ? 2 : 1), 17);
402
+ expect(sp17.frame()).toBe("▒░░░");
403
+ });
404
+
405
+ it("SPIN_VARIANTS completeness: 10 entries — typing (batch 1) + wave-rows, columns, pulse, marquee (batch 2) + pendulum, cascade, diagonal-swipe (batch 3) + rain, sparkle (batch 4), in display order", () => {
406
+ expect(Object.keys(SPIN_VARIANTS)).toEqual([
407
+ "typing",
408
+ "wave-rows",
409
+ "columns",
410
+ "pulse",
411
+ "marquee",
412
+ "pendulum",
413
+ "cascade",
414
+ "diagonal-swipe",
415
+ "rain",
416
+ "sparkle",
417
+ ]);
418
+ });
419
+
420
+ it("the 9 batch 2–4 entries are registered with steps = the source's totalFrames and hold 0 (frames wrap, no clamp tail — the idx clamp is N/A)", () => {
421
+ const expected = [
422
+ ["wave-rows", 20],
423
+ ["columns", 48],
424
+ ["pulse", 23],
425
+ ["marquee", 48],
426
+ ["pendulum", 120],
427
+ ["cascade", 60],
428
+ ["diagonal-swipe", 60],
429
+ ["rain", 90],
430
+ ["sparkle", 60],
431
+ ] as const;
432
+ for (const [name, steps] of expected) {
433
+ const cfg = SPIN_VARIANTS[name]!;
434
+ expect(cfg.steps).toBe(steps);
435
+ expect(cfg.hold).toBe(0);
436
+ expect(normalizeSpinnerStyle(name)).toBe(name);
437
+ }
438
+ });
439
+
440
+ it("an unregistered gallery name constructs as typing frames (the normalize fallback — e.g. the un-ported `sort`): after 16 busy ticks it renders the same typing frames", () => {
441
+ const sp = busyAt(Array.from({ length: 16 }, () => false), (s: string) => (s === "⣿" ? 2 : 1), 16);
442
+ const unported = new BorderTypeSpinner(
443
+ () => false,
444
+ "sort",
445
+ (s: string) => (s === "⣿" ? 2 : 1),
446
+ );
447
+ for (let i = 0; i < 16; i++) unported.tick();
448
+ expect(unported.frame()).toBe(sp.frame()); // same typing frames
449
+ });
450
+
451
+ // ── Batch 2: the 4 gallery ports (1×4 — 8 dot-columns × 4 rows; bit map: row 0 L 0x01 / R 0x08, row 1 0x02/0x10, row 2 0x04/0x20, row 3 0x40/0x80; cell = floor(pc/2), dot-column = pc%2) ──
452
+
453
+ const brailleRender = (masks: number[]): string =>
454
+ masks.map((m) => (m === 0 ? " " : String.fromCharCode(0x2800 + m))).join("");
455
+
456
+ describe("wave-rows compute (source waveRows, totalFrames 20, interval 40; colPhaseStep 2π/8 = 45° per dot-column, band 0.9)", () => {
457
+ const wave = SPIN_VARIANTS["wave-rows"]!;
458
+
459
+ it("step 0 (basePhase 0): per-column sine centerRows 1.5 / 2.56 / 3.0 / 2.56 / 1.5 / 0.44 / 0 / 0.44 — bands land on rows (1,2) (2,3) (3) (2,3) (1,2) (0,1) (0) (0,1)", () => {
460
+ expect(wave.compute(0)).toEqual([0xa6, 0xe0, 0x1e, 0x19]);
461
+ expect(brailleRender(wave.compute(0))).toBe(
462
+ ["⢦", "⣠", "⠞", "⠙"].join(""),
463
+ );
464
+ });
465
+
466
+ it("mid (step 10, basePhase π — the mirror of 0): bands on (1,2) (0,1) (0) (0,1) (1,2) (2,3) (3) (2,3)", () => {
467
+ expect(wave.compute(10)).toEqual([0x1e, 0x19, 0xa6, 0xe0]);
468
+ });
469
+
470
+ it("step 19 differs from step 0 in at least one cell (a full squelch does not re-create the frame; wrap is being squelced, not extrapolated)", () => {
471
+ const c19 = wave.compute(19);
472
+ const c0 = wave.compute(0);
473
+ expect(c19.some((m, i) => m !== c0[i!])).toBe(true);
474
+ });
475
+
476
+ it("EAW: the live cell masks hit the density tiers (0x19 popcount 3 → ▒, 0x1E popcount 4 → ▒)", () => {
477
+ expect(shadeForMask(0x19)).toBe("▒");
478
+ expect(shadeForMask(0x1e)).toBe("▒");
479
+ });
480
+ });
481
+
482
+ describe("columns compute (source columns, totalFrames 48, hold 0; stepsPerColumn height+1 = 5, totalSteps 8×5 = 40)", () => {
483
+ const col = SPIN_VARIANTS.columns!;
484
+
485
+ it("step 0: progress 0 → step 0, column 0 only unfilled (fill 0) — 4 blank cells", () => {
486
+ expect(col.compute(0)).toEqual([0, 0, 0, 0]);
487
+ });
488
+
489
+ it("step 10: step floor(10/48×40) = 8 → activePc 1, activeFill 3 — pc 0 (cell 0 left dot-column) full (0x47), pc 1 (right) bottom-up to row 3 (0x80+0x20+0x10 = 0xB0) — cell 0 = 0xF7, cells 1–3 still blank", () => {
490
+ expect(col.compute(10)).toEqual([0xf7, 0, 0, 0]);
491
+ });
492
+
493
+ it("step 24: step 20 → activePc 4, activeFill 0 — the first 4 dot-columns (cells 0 and 1, both dot-columns per cell) fully lit, the last 4 blank", () => {
494
+ expect(col.compute(24)).toEqual([0xff, 0xff, 0, 0]);
495
+ });
496
+
497
+ it("EAW: the full bottom-up column mask 0x47 (popcount 4) renders ▒ via shadeForMask", () => {
498
+ expect(shadeForMask(0x47)).toBe("▒");
499
+ });
500
+ });
501
+
502
+ describe("pulse compute (source pulse, totalFrames 23, interval 60; t = frame×40, period 900, center (3.5, 1.5), maxDist √14.5 ≈ 3.808, ring width 0.8)", () => {
503
+ const pulse = SPIN_VARIANTS.pulse!;
504
+
505
+ it("step 0 (scale 1, ringPos 0.5×maxDist ≈ 1.904, band (1.104, 2.704)): dot-column x-offsets ±3.5 → nothing, ±2.5 → rows 1&2, ±1.5 → all 4 rows, ±0.5 → rows 0&3", () => {
506
+ // cell 0 = L (dist −3.5: nothing) | R (−2.5: rows 1, 2 = 0x30); cell 1 = L (−1.5: full 0x47) | R (−0.5: rows 0, 3 = 0x88); cell 2 = L (+0.5: rows 0, 3 = 0x41) | R (+1.5: full 0xB8); cell 3 = L (+2.5: rows 1, 2 = 0x06) | R (+3.5: nothing).
507
+ expect(pulse.compute(0)).toEqual([0x30, 0xcf, 0xf9, 0x06]);
508
+ });
509
+
510
+ it("mid (step 10): non-empty, 4 cells, no crash", () => {
511
+ const m = pulse.compute(10);
512
+ expect(m).toHaveLength(4);
513
+ for (const x of m) {
514
+ expect(x).toBeGreaterThanOrEqual(0);
515
+ expect(x).toBeLessThanOrEqual(0xff);
516
+ }
517
+ expect(m.some((x) => x > 0)).toBe(true);
518
+ });
519
+
520
+ it("the ring come-back: step 0 and step 22 (the last frame) both light — one cycle does not degenerate to narrow", () => {
521
+ expect(pulse.compute(0).some((x) => x > 0)).toBe(true);
522
+ expect(pulse.compute(22).some((x) => x > 0)).toBe(true);
523
+ });
524
+
525
+ it("EAW: the step-0 frame renders density tiers (0x30 popcount 2 → ░, 0xCF popcount 6 → ▓, 0xF9 popcount 6 → ▓, 0x06 popcount 2 → ░)", () => {
526
+ expect(
527
+ pulse
528
+ .compute(0)
529
+ .map((m) => shadeForMask(m))
530
+ .join(""),
531
+ ).toBe("░▓▓░");
532
+ });
533
+ });
534
+
535
+ describe("marquee compute (source marquee, totalFrames 48, interval 55; offset floor(f/48×8), stripe (pc+row+offset)%4, lit < 2)", () => {
536
+ const marquee = SPIN_VARIANTS.marquee!;
537
+
538
+ it("step 0 (offset 0): light strips are on rows 0-1 (L 0x03), 0&3 (R 0x88), 2-3 (L 0x44), 1-2 (R 0x30) — cell 1 ≡ cells 2/3", () => {
539
+ expect(marquee.compute(0)).toEqual([0x8b, 0x74, 0x8b, 0x74]);
540
+ });
541
+
542
+ it("mid (step 10, offset floor(10/48×8) = 1): strip +1 — rows 0&3 (L 0x41), 2-3 (R 0xA0), 1-2 (L 0x06), 0-1 (R 0x18)", () => {
543
+ expect(marquee.compute(10)).toEqual([0xe1, 0x1e, 0xe1, 0x1e]);
544
+ });
545
+
546
+ it("EAW: the live cell masks render ▒ (0x8B and 0x74 are both popcount 4)", () => {
547
+ expect(shadeForMask(0x8b)).toBe("▒");
548
+ expect(shadeForMask(0x74)).toBe("▒");
549
+ });
550
+ });
551
+
552
+ // ── Batch 3: the 3 gallery ports (pendulum, cascade, diagonal-swipe) — same 1×4 (8 dot-columns × 4 rows) adaptation; formulas and constants unchanged from the source ──
553
+
554
+ describe("pendulum compute (source pendulum, totalFrames 120, interval 12; basePhase progress×8π, spread sin(progress×π)×1.1, threshold 0.7)", () => {
555
+ const pend = SPIN_VARIANTS.pendulum!;
556
+
557
+ it("step 0 (progress 0 → basePhase 0, spread 0 → swing 0 for every pc → center = (1−0)×3/2 = 1.5): |r−1.5| < 0.7 lights rows 1 and 2 for all 8 dot-columns → cell = 0x36 × 4", () => {
558
+ expect(pend.compute(0)).toEqual([0x36, 0x36, 0x36, 0x36]);
559
+ expect(brailleRender(pend.compute(0))).toBe("⠶⠶⠶⠶");
560
+ });
561
+
562
+ it("mid (step 60, progress 0.5 → basePhase 4π, spread 1.1): non-empty, 4 cells, no crash", () => {
563
+ const m = pend.compute(60);
564
+ expect(m).toHaveLength(4);
565
+ for (const x of m) {
566
+ expect(x).toBeGreaterThanOrEqual(0);
567
+ expect(x).toBeLessThanOrEqual(0xff);
568
+ }
569
+ expect(m.some((x) => x > 0)).toBe(true);
570
+ });
571
+
572
+ it("EAW: the step-0 masks (0x36, popcount 4) render ▒ at the density tier", () => {
573
+ expect(
574
+ pend
575
+ .compute(0)
576
+ .map((m) => shadeForMask(m))
577
+ .join(""),
578
+ ).toBe("▒▒▒▒");
579
+ });
580
+ });
581
+
582
+ describe("cascade compute (source cascade, totalFrames 60, interval 40; leadingEdge progress×2, lit |nx + ny − edge| < 0.2)", () => {
583
+ const cas = SPIN_VARIANTS.cascade!;
584
+
585
+ it("step 0 (leadingEdge 0, lit where nx + ny < 0.2): pc0 row 0 (delta 0) and pc1 row 0 (delta 0.125 < 0.2) → cell 0 row 0 L+R (0x09), rest blank", () => {
586
+ expect(cas.compute(0)).toEqual([0x09, 0, 0, 0]);
587
+ expect(brailleRender(cas.compute(0))).toBe("⠉ ");
588
+ });
589
+
590
+ it("step 30 (leadingEdge 1, the anti-diagonal nx+ny=1): pc1 r3 / pc2 r3 / pc3 r2+r3 / pc4 r2 / pc5 r1+r2 / pc6 r1 / pc7 r0+r1 → cells 0x80, 0xE0, 0x34, 0x1A", () => {
591
+ expect(cas.compute(30)).toEqual([0x80, 0xe0, 0x34, 0x1a]);
592
+ });
593
+
594
+ it("EAW: the live cell masks hit the density tiers (step 0: 0x09 popcount 2 → ░; step 30: 0x80 popcount 1 → ░, 0xE0 popcount 3 → ▒, 0x34 popcount 3 → ▒, 0x1A popcount 3 → ▒)", () => {
595
+ expect(
596
+ cas
597
+ .compute(0)
598
+ .map((m) => shadeForMask(m))
599
+ .join(""),
600
+ ).toBe("░ ");
601
+ expect(
602
+ cas
603
+ .compute(30)
604
+ .map((m) => shadeForMask(m))
605
+ .join(""),
606
+ ).toBe("░▒▒▒");
607
+ });
608
+ });
609
+
610
+ describe("diagonal-swipe compute (source diagonalSwipe, totalFrames 60, interval 30; maxDiag 7+3 = 10, clearFrames = fillFrames = 30, localTotal 29, sweepFront progress×11; lit clearPhase ? diag ≥ front : diag < front)", () => {
611
+ const sw = SPIN_VARIANTS["diagonal-swipe"]!;
612
+
613
+ it("step 0 (clear phase, sweepFront 0): lit where diag ≥ 0 — every one of the 32 dots → all 4 cells full (0xFF)", () => {
614
+ expect(sw.compute(0)).toEqual([0xff, 0xff, 0xff, 0xff]);
615
+ expect(brailleRender(sw.compute(0))).toBe("⣿⣿⣿⣿");
616
+ });
617
+
618
+ it("step 29 (clear phase, sweepFront 11 = a full sweep): lit where diag ≥ 11 — nothing — the 4 cells blank, ready for the fill to start", () => {
619
+ expect(sw.compute(29)).toEqual([0, 0, 0, 0]);
620
+ });
621
+
622
+ it("step 50 (fill phase, the inverted `diag < sweepFront` branch — front 20/29×11 ≈ 7.586, lit where diag < 7.586): pc 0–4 full, pc 5 rows 0–2, pc 6 rows 0–1, pc 7 row 0 → cells 0xFF, 0xFF, 0x7F, 0x0B; EAW ██▒", () => {
623
+ expect(sw.compute(50)).toEqual([0xff, 0xff, 0x7f, 0x0b]);
624
+ expect(
625
+ sw
626
+ .compute(50)
627
+ .map((m) => shadeForMask(m))
628
+ .join(""),
629
+ ).toBe("███▒");
630
+ });
631
+ });
632
+
633
+ // ── Batch 4: the final 2 gallery ports (rain, sparkle) — same 1×4 (8 dot-columns × 4 rows) adaptation; formulas and constants unchanged from the source (dot column = pc % 2) ──
634
+
635
+ describe("rain compute (source rain, totalFrames 90, interval 40; t = step×40; period 1200 + rand×1000 with cyclePos t/period + rand×0.91 + pc×0.07 per dot-column, miss-chance skip, rollB spawn delay, gravity-fall + mid-wobble; colRandom = seededRandom(123), 8 draws in order pc 0..7, precomputed once at module scope like the source's contextCache)", () => {
636
+ const rain = SPIN_VARIANTS.rain!;
637
+
638
+ it("registers with steps 90 (the source's totalFrames) and hold 0", () => {
639
+ expect(rain.steps).toBe(90);
640
+ expect(rain.hold).toBe(0);
641
+ expect(normalizeSpinnerStyle("rain")).toBe("rain");
642
+ });
643
+
644
+ it("step 0 (t = 0, no fallback: the rand×0.91 + pc×0.07 phase offsets put 3 dot-columns in their windows — live-computed): cells 0x00, 0x08, 0x20, 0x20; braille ⠈⠠⠠, EAW ░░░ with cell 0 blank", () => {
645
+ expect(rain.compute(0)).toEqual([0, 0x08, 0x20, 0x20]);
646
+ expect(brailleRender(rain.compute(0))).toBe(" ⠈⠠⠠");
647
+ expect(
648
+ rain
649
+ .compute(0)
650
+ .map((m) => shadeForMask(m))
651
+ .join(""),
652
+ ).toBe(" ░░░");
653
+ });
654
+
655
+ it("mid-step (step 45, t = 1800): plausible non-zero — 5 active drops, 5 dots of a 1..32 dot count, 3 non-zero cells — live-verified mask", () => {
656
+ const m = rain.compute(45);
657
+ expect(m).toEqual([0x82, 0x24, 0x04, 0x00]);
658
+ const totalDots = m.reduce((s, x) => s + popcount(x), 0);
659
+ expect(totalDots).toBe(5);
660
+ expect(totalDots).toBeGreaterThanOrEqual(1);
661
+ expect(totalDots).toBeLessThanOrEqual(32);
662
+ expect(m).toHaveLength(4);
663
+ expect(m.filter((x) => x > 0).length).toBeGreaterThanOrEqual(2);
664
+ });
665
+
666
+ it("step 20 (t = 800, activeDrops 0 — no dot-column is in its window): the guarantee fallback draws a dot — pos 800/1600 = 0.5 → pc floor(0.5×8) = 4 (cell 2), y clamp(floor(0.5×5), 0, 3) = 2 → 0x04 (EAW ░)", () => {
667
+ expect(rain.compute(20)).toEqual([0, 0, 0x04, 0]);
668
+ expect(shadeForMask(0x04)).toBe("░");
669
+ });
670
+ });
671
+
672
+ describe("sparkle compute (source sparkle, totalFrames 60, interval 40; the verbatim hash — constants 374761393 / 668265263 / 1442695041, × 1274126177, / 4294967295 in 32-bit ops; density 0.095, lifetime 4, phase floor(step/2), 2×2 region competition with edge compensation 0.12 × (col-edges 0/7 + row-edges 0/3), 4-frame lifecycle, one-frame 8-direction jitter)", () => {
673
+ const spkl = SPIN_VARIANTS.sparkle!;
674
+
675
+ it("registers with steps 60 (the source's totalFrames) and hold 0; the age gate (skip age > 3) lets dots through — step 0 has 6 dots, within a 1..32 dot count", () => {
676
+ expect(spkl.steps).toBe(60);
677
+ expect(spkl.hold).toBe(0);
678
+ expect(normalizeSpinnerStyle("sparkle")).toBe("sparkle");
679
+ expect(spkl.compute(0).reduce((s, x) => s + popcount(x), 0)).toBe(6);
680
+ });
681
+
682
+ it("step 0 (phase 0): deterministic — the live-computed full 4-mask, 6 dots, all 4 cells lit", () => {
683
+ expect(spkl.compute(0)).toEqual([0x01, 0x21, 0x0c, 0x10]);
684
+ expect(brailleRender(spkl.compute(0))).toBe("⠁⠡⠌⠐");
685
+ });
686
+
687
+ it("phase advance (steps 0 → 3, phase 0 → 1): masks re-sample — cell 0 flips 0x01 → 0x81 in particular (all 4 cells re-sample); same-phase frames 2 vs 3 (phase 1) are bit-identical under the 4-frame lifecycle", () => {
688
+ expect(spkl.compute(3)).toEqual([0x81, 0x08, 0x81, 0x02]);
689
+ expect(spkl.compute(3)[0]!).not.toBe(spkl.compute(0)[0]!);
690
+ expect(spkl.compute(2)).toEqual(spkl.compute(3));
691
+ });
692
+
693
+ it("EAW: the step-0 masks are all low popcount (1, 2, 2, 1 dots — every tier ░) → ░░░░", () => {
694
+ expect(
695
+ spkl
696
+ .compute(0)
697
+ .map((m) => shadeForMask(m))
698
+ .join(""),
699
+ ).toBe("░░░░");
700
+ });
701
+ });
702
+ });