import { readFileSync } from "node:fs"; import { join } from "node:path"; import { caixaDosEstados, cicloAt, createFrameBudget, ESTADOS, type Estado, framePiece, legendaDe, papeis, parseBlocos, seguir, stateAt, } from "./blocos"; /** O arquivo que o Blender exporta (`3d/blocos.py`) e a cena baixa. */ const real = () => JSON.parse( readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"), ) as { n: number; passo: number; estados: string[]; dados: number[] }; describe("stateAt (which block state the dive progress shows)", () => { it("parks on marca at the top of the dive", () => { expect(stateAt(0)).toEqual({ from: 0, to: 0, t: 0 }); expect(stateAt(0.1)).toEqual({ from: 0, to: 0, t: 0 }); expect(stateAt(0.16)).toEqual({ from: 0, to: 0, t: 0 }); }); it("is halfway from marca to tela in the middle of the first stretch", () => { const { from, to, t } = stateAt(0.23); expect([from, to]).toEqual([0, 1]); expect(t).toBeCloseTo(0.5, 6); }); it.each([ [0.35, 1], [0.63, 2], [0.93, 3], [1, 3], ])("parks at %p on state %p", (p, state) => { expect(stateAt(p)).toEqual({ from: state, to: state, t: 0 }); }); it("clamps the progress to 0..1", () => { expect(stateAt(-3)).toEqual({ from: 0, to: 0, t: 0 }); expect(stateAt(7)).toEqual({ from: 3, to: 3, t: 0 }); expect(stateAt(Number.NaN)).toEqual({ from: 0, to: 0, t: 0 }); }); it("eases in and out inside a stretch (smoothstep)", () => { expect(stateAt(0.44 + 0.12 * 0.25).t).toBeCloseTo(0.15625, 6); expect(stateAt(0.7 + 0.12 * 0.75).t).toBeCloseTo(0.84375, 6); }); it("never goes back as the progress grows", () => { let previous = -1; for (let i = 0; i <= 1000; i++) { const { from, to, t } = stateAt(i / 1000); const position = from + (to - from) * t; expect(position).toBeGreaterThanOrEqual(previous); previous = position; } expect(previous).toBe(3); }); }); describe("cicloAt (the resting cycle: time parked -> state)", () => { it("rests on marca first, then goes marca, tela, integracoes, dados and back, 3 s on each", () => { expect(cicloAt(0, 3, 1.2)).toEqual({ from: 0, to: 0, t: 0, wait: 3 }); expect(cicloAt(2, 3, 1.2)).toEqual({ from: 0, to: 0, t: 0, wait: 1 }); const indo = cicloAt(3.6, 3, 1.2); expect([indo.from, indo.to, indo.wait]).toEqual([0, 1, 0]); expect(indo.t).toBeCloseTo(0.5, 6); const parados = [4.3, 8.5, 12.7, 16.9].map((clock) => cicloAt(clock, 3, 1.2), ); expect(parados.map(({ from, to, t }) => [from, to, t])).toEqual([ [1, 1, 0], [2, 2, 0], [3, 3, 0], [0, 0, 0], ]); // de dados volta direto à marca const volta = cicloAt(4.2 * 3 + 3.6, 3, 1.2); expect([volta.from, volta.to]).toEqual([3, 0]); }); it("says how long until the next change while resting", () => { expect(cicloAt(4.2 + 1, 3, 1.2).wait).toBeCloseTo(2, 6); expect(cicloAt(5, 4, 1.2).wait).toBe(0); // 4 s no lite, já na troca }); }); describe("seguir (the scroll taking the piece back from the cycle, and the cycle from the scroll)", () => { const parado = (e: number): Estado => ({ from: e, to: e, t: 0 }); /** Passos de `passo` até alcançar o alvo; devolve os estados do caminho. */ const caminho = (atual: Estado, alvo: Estado, passo = 0.1) => { const passos = [atual]; for (let i = 0; i < 100; i++) { const proximo = seguir(passos[passos.length - 1], alvo, passo); passos.push(proximo); if ( proximo.from === alvo.from && proximo.to === alvo.to && proximo.t === alvo.t ) return passos; } throw new Error("não alcançou o alvo"); }; it("stays put when it already shows the target", () => { expect(seguir(parado(2), parado(2), 0.1)).toEqual(parado(2)); }); it("goes straight from a resting state to another one, one step at a time", () => { expect(seguir(parado(1), parado(0), 0.1)).toEqual({ from: 1, to: 0, t: 0.1, }); const passos = caminho(parado(3), parado(0), 0.25); expect(passos.map(({ t }) => t)).toEqual([0, 0.25, 0.5, 0.75, 0]); expect(passos[passos.length - 1]).toEqual(parado(0)); }); it("enters a scroll transition by the end it shares with it, without a jump", () => { const alvo = { from: 0, to: 1, t: 0.35 }; expect(seguir(parado(0), alvo, 0.1)).toEqual({ from: 0, to: 1, t: 0.1 }); expect(seguir(parado(1), alvo, 0.1)).toEqual({ from: 0, to: 1, t: 0.9 }); expect(seguir({ from: 0, to: 1, t: 0.3 }, alvo, 0.1)).toEqual(alvo); }); it("turns a transition running the other way around instead of restarting it", () => { expect( seguir({ from: 1, to: 0, t: 0.25 }, { from: 0, to: 1, t: 0.3 }, 0.25), ).toEqual({ from: 0, to: 1, t: 0.5 }); }); it("finishes a transition that leads elsewhere at its nearest end, then heads for the target", () => { // o ciclo indo de integrações para dados, e a rolagem pede a marca const passos = caminho({ from: 2, to: 3, t: 0.8 }, parado(0)); expect(passos[1]).toEqual({ from: 2, to: 3, t: 0.9 }); expect(passos).toContainEqual(parado(3)); expect(passos[passos.length - 1]).toEqual(parado(0)); // a troca em curso já vai para o alvo (ou sai dele): só anda nela expect(seguir({ from: 3, to: 0, t: 0.5 }, parado(0), 0.1)).toEqual({ from: 3, to: 0, t: 0.6, }); expect(seguir({ from: 0, to: 1, t: 0.5 }, parado(0), 0.1)).toEqual({ from: 0, to: 1, t: 0.4, }); }); it("moves t by at most one step per call", () => { const passos = caminho({ from: 1, to: 2, t: 0.45 }, parado(3), 0.05); for (let i = 1; i < passos.length; i++) { const [a, b] = [passos[i - 1], passos[i]]; if (a.from === b.from && a.to === b.to) expect(Math.abs(b.t - a.t)).toBeLessThanOrEqual(0.05 + 1e-9); // ou parou numa ponta, ou começou uma troca nova a partir dela else if (b.t !== 0) expect([b.from, b.t]).toEqual([a.to, 0.05]); } }); it("keeps heading for the scroll when its request moves on mid-way (no back and forth)", () => { // o ciclo parado nas integrações; a rolagem pede a marca e, logo depois, a troca da marca para a tela let atual = seguir(parado(2), parado(0), 0.1); expect(atual).toEqual({ from: 2, to: 0, t: 0.1 }); const alvo = { from: 0, to: 1, t: 0.9 }; const vistos = [atual]; for (let i = 0; i < 40; i++) { atual = seguir(atual, alvo, 0.1); vistos.push(atual); } expect(atual).toEqual(alvo); // nunca volta para as integrações expect(vistos.slice(1)).not.toContainEqual(parado(2)); }); }); describe("caixaDosEstados (where tela, integracoes and dados sit in the resting cycle)", () => { const host = { width: 1440, height: 900 }; it("drops the left 22% of the piece box, keeps the 17 x 12 proportion and the height center of the piece", () => { const caixa = caixaDosEstados( { x: 700, y: 100, width: 600, height: 600 }, host, ); expect(caixa.x).toBeCloseTo(832, 6); expect(caixa.width).toBeCloseTo(468, 6); expect(caixa.height).toBeCloseTo((468 * 12) / 17, 6); expect(caixa.y + caixa.height / 2).toBeCloseTo(400, 6); }); it("stops 24 px short of the right edge when the piece box runs past it", () => { const caixa = caixaDosEstados( { x: 694, y: -4, width: 795, height: 795 }, host, ); expect(caixa.x + caixa.width).toBeCloseTo(1416, 6); }); }); describe("legendaDe (the caption under the piece)", () => { it.each([ [0, null], [1, "Sites e sistemas"], [2, "Integrações"], [3, "Painéis e dados"], [null, null], [7, null], ])("state %p -> %p", (estado, texto) => { expect(legendaDe(estado)).toBe(texto); }); }); describe("parseBlocos (the block states exported from Blender)", () => { it("reads the real file: 238 blocks, one vector of n * 7 per state", () => { const blocos = parseBlocos(real()); expect(blocos.n).toBe(238); expect(blocos.estados).toHaveLength(ESTADOS.length); for (const estado of blocos.estados) { expect(estado).toBeInstanceOf(Float32Array); expect(estado).toHaveLength(238 * 7); } // o primeiro bloco da marca: x, y, z, escala e tom, na ordem do arquivo const json = real(); expect(Array.from(blocos.estados[0].slice(0, 7))).toEqual( Array.from(new Float32Array(json.dados.slice(0, 7))), ); // o segundo estado começa logo depois dos n blocos do primeiro expect(blocos.estados[1][0]).toBe(Math.fround(json.dados[238 * 7])); }); it.each([ ["not an object", null, /objeto/], [ "states out of order", { ...real(), estados: ["tela", "marca", "integracoes", "dados"] }, /estados/, ], [ "a short data array", { ...real(), dados: real().dados.slice(7) }, /dados/, ], ["a different step", { ...real(), passo: 8 }, /passo/], ["no block count", { ...real(), n: 0 }, /n/], ])("throws a clear error for %s", (_label, json, message) => { expect(() => parseBlocos(json)).toThrow(message); }); }); describe("papeis (which blocks move in each resting state, from the real file)", () => { const blocos = parseBlocos(real()); const { vida, botao, chao } = papeis(blocos); const papel = (estado: number) => Array.from({ length: blocos.n }, (_, i) => vida[i * 4 + estado]); /** Os índices dos blocos visíveis do estado com o papel que `filtro` aceita. */ const com = (estado: number, filtro: (valor: number) => boolean) => papel(estado).flatMap((valor, i) => blocos.estados[estado][i * 7 + 3] > 0 && filtro(valor) ? [i] : [], ); const tom = (estado: number, i: number) => blocos.estados[estado][i * 7 + 6]; it("splits the lime blocks of marca into its four squares, left to right", () => { const marca = blocos.estados[0]; const quadrados = [0, 1, 2, 3].map((q) => com(0, (v) => v === q)); expect(quadrados.map((q) => q.length)).toEqual([40, 32, 18, 18]); expect(com(0, (v) => v > 3)).toEqual([]); // todo bloco lima visível está num quadrado, e só eles expect(com(0, (v) => v >= 0).sort((a, b) => a - b)).toEqual( com(0, () => true).filter((i) => tom(0, i) === 1), ); const xs = quadrados.map( (q) => q.reduce((soma, i) => soma + marca[i * 7], 0) / q.length, ); expect(xs).toEqual([...xs].sort((a, b) => a - b)); }); it("marks the lime button of tela and its center", () => { const tela = blocos.estados[1]; const botoes = com(1, (v) => v === 1); expect(botoes).toHaveLength(12); for (const i of botoes) expect(tom(1, i)).toBe(1); const x = botoes.reduce((soma, i) => soma + tela[i * 7], 0) / 12; expect(botao[0]).toBeCloseTo(x, 5); // o botão fica na metade de baixo e à esquerda da tela expect(botao[0]).toBeLessThan(0); expect(botao[1]).toBeLessThan(0); }); it("lays the porcelain line blocks of integracoes from the core (0) to the nodes (1)", () => { const integracoes = blocos.estados[2]; const linhas = com(2, (v) => v >= 0); expect(linhas.length).toBeGreaterThan(30); for (const i of linhas) expect(tom(2, i)).toBe(0); expect(Math.min(...linhas.map((i) => vida[i * 4 + 2]))).toBe(0); expect(Math.max(...linhas.map((i) => vida[i * 4 + 2]))).toBe(1); // mais longe do centro, mais adiante na linha (o núcleo fica na origem) const raio = (i: number) => Math.hypot(integracoes[i * 7], integracoes[i * 7 + 1]); const perto = linhas.filter((i) => vida[i * 4 + 2] < 0.2); const longe = linhas.filter((i) => vida[i * 4 + 2] > 0.8); expect(Math.max(...perto.map(raio))).toBeLessThan( Math.min(...longe.map(raio)), ); // seis linhas, uma por nó: cada uma começa no núcleo expect(perto.filter((i) => vida[i * 4 + 2] === 0)).toHaveLength(6); }); it("gives each bar of dados its own phase and the floor it grows from", () => { const dados = blocos.estados[3]; const barras = com(3, (v) => v >= 0); const fases = [...new Set(barras.map((i) => vida[i * 4 + 3]))].sort(); expect(fases).toEqual([0, 0.2, 0.4, 0.6, 0.8].map(Math.fround)); // cinco barras de duas colunas, com 3, 5, 2, 6 e 4 camadas expect(barras).toHaveLength(40); for (const i of com(3, () => true)) expect(dados[i * 7 + 2] - dados[i * 7 + 5] / 2).toBeGreaterThanOrEqual( chao - 1e-6, ); // só as barras saem da camada de base const alto = (i: number) => dados[i * 7 + 2] - dados[i * 7 + 5] / 2 > chao + 1e-3; expect(com(3, (v) => v < 0).filter(alto)).toEqual([]); }); }); describe("createFrameBudget (does this device keep up with the scene?)", () => { const feed = (budget: (delta: number) => string, deltas: number[]) => deltas.map((delta) => budget(delta)); it("is pending until 60 frames, then ok for 16 ms frames, and keeps that verdict", () => { const budget = createFrameBudget(); const verdicts = feed(budget, Array(60).fill(16)); expect(verdicts.slice(0, 59).every((v) => v === "pending")).toBe(true); expect(verdicts[59]).toBe("ok"); expect(budget(200)).toBe("ok"); }); it("is slow when 60 frames average more than 28 ms", () => { const budget = createFrameBudget(); const verdicts = feed(budget, Array(60).fill(40)); expect(verdicts[58]).toBe("pending"); expect(verdicts[59]).toBe("slow"); expect(budget(16)).toBe("slow"); }); it("ignores a stall above 250 ms (a tab switch, a GC pause) instead of counting it", () => { const budget = createFrameBudget(); const verdicts = feed(budget, [ ...Array(30).fill(16), 900, ...Array(29).fill(16), ]); expect(verdicts.at(-1)).toBe("pending"); expect(budget(16)).toBe("ok"); }); it("takes a custom limit and frame count", () => { const budget = createFrameBudget(10, 2); expect(budget(12)).toBe("pending"); expect(budget(12)).toBe("slow"); }); }); describe("framePiece (where the piece sits in the host)", () => { const host = { width: 1700, height: 900 }; const placement = { x: 1000, y: 100, width: 520, height: 650 }; it("fits the 17 x 12 box in the whole host, centered, away from marca (w = 0)", () => { const expected = { x: 850, y: 450, scale: 75 }; // min(1700 / 17, 900 / 12) expect(framePiece(host, null, 0)).toEqual(expected); expect(framePiece(host, placement, 0)).toEqual(expected); }); it("fits the poster box (15.25 x 15.25) in the whole host when there is no placement (w = 1)", () => { const { x, y, scale } = framePiece(host, null, 1); expect([x, y]).toEqual([850, 450]); expect(scale).toBeCloseTo(900 / 15.25, 9); }); it("fits the poster box inside the placement, centered on it (w = 1)", () => { const { x, y, scale } = framePiece(host, placement, 1); expect([x, y]).toEqual([1260, 425]); expect(scale).toBeCloseTo(520 / 15.25, 9); }); it("interpolates center and size in between, and clamps w", () => { const half = framePiece(host, placement, 0.5); expect(half.x).toBeCloseTo((850 + 1260) / 2, 9); expect(half.y).toBeCloseTo((450 + 425) / 2, 9); expect(half.scale).toBeCloseTo((75 + 520 / 15.25) / 2, 9); expect(framePiece(host, placement, 2)).toEqual( framePiece(host, placement, 1), ); expect(framePiece(host, placement, -1)).toEqual( framePiece(host, placement, 0), ); }); it("fits the 17 x 12 box in the useful stage area, centered on it, when one is given (w = 0)", () => { const stage = { x: 86, y: 84, width: 1267, height: 546 }; const { x, y, scale } = framePiece(host, placement, 0, stage); expect([x, y]).toEqual([86 + 1267 / 2, 84 + 546 / 2]); expect(scale).toBeCloseTo(546 / 12, 9); // min(1267 / 17, 546 / 12) // a marca continua no retângulo da peça expect(framePiece(host, placement, 1, stage)).toEqual( framePiece(host, placement, 1), ); }); it("works in a portrait host (the width limits the box)", () => { expect(framePiece({ width: 390, height: 844 }, null, 0)).toEqual({ x: 195, y: 422, scale: 390 / 17, }); }); });