/*
 * A conta da animação e da linha do tempo (rodar: npx tsx --test src/lib/banner-editor/linha-do-tempo.test.ts).
 */
import assert from "node:assert/strict";
import { test } from "node:test";
import { CURVAS, EFEITOS_DE_ENTRADA, animacaoLimpa, animacaoNova, curvar, estadoNoTempo, poligonoDoRecorte, poseNoTempo, transicaoLimpa, type AnimacaoDaPeca } from "./animacao";
import { alternarQuadro, animarPagina, comPoseNoTempo, comQuadrosAutomaticos, dividirPagina, dividirUnidade, estadosDaTransicao, estadosNoTempo, intervaloDoClipe, paginaNoTempo, temposDoProjeto, transicaoNoTempo, unidadesDaCena, volumeNoTempo } from "./cena";
import { documentoLimpo, type DocumentoDoBanner, type Peca } from "./documento";

const c = { caixa: { x: 100, y: 100, w: 200, h: 100 }, largura: 1080, altura: 1920, linhas: 3 };

function pagina(pecas: Peca[], duracao = 5, extra: Partial<DocumentoDoBanner> = {}): DocumentoDoBanner {
  return { versao: 3, largura: 1080, altura: 1920, fundo: { pintura: { tipo: "solida", cor: "#fff", cor2: "#fff", angulo: 0 }, textura: { tipo: "nenhuma", cor: "#fff", opacidade: 0, quantidade: 1, tamanho: 0, centroX: 50, centroY: 50, giro: 0 } }, pecas, animacao: { duracao, fundo: "parado" }, ...extra };
}
function forma(id: string, animacao?: AnimacaoDaPeca, extra: Partial<Peca> = {}): Peca {
  return { id, tipo: "forma", x: 100, y: 100 + Number(id.replace(/\D/g, "") || 0) * 200, w: 200, h: 100, rot: 0, opacidade: 1, forma: "retangulo", pintura: { tipo: "solida", cor: "#f00", cor2: "#f00", angulo: 0 }, raio: 0, borda: null, sombra: null, pontas: 5, profundidade: 0.5, ...(animacao ? { animacao } : {}), ...extra } as Peca;
}

test("sem animação a peça fica parada; com entrada, escondida antes e no lugar depois", () => {
  assert.equal(estadoNoTempo(null, 2, c).alfa, 1);
  const a = animacaoNova("subir", { inicio: 1, duracao: 0.5 });
  assert.equal(estadoNoTempo(a, 0.5, c).alfa, 0);
  const meio = estadoNoTempo(a, 1.25, c);
  assert.ok(meio.alfa > 0 && meio.alfa < 1 && meio.dy > 0, "subindo: vem de baixo");
  assert.deepEqual(estadoNoTempo(a, 2, c), { alfa: 1, dx: 0, dy: 0, escala: 1, giro: 0, desfoque: 0, recorte: null, rastro: [0, 0], partes: null, rx: 0, ry: 0 });
});

test("todo efeito de entrada termina exatamente no lugar", () => {
  for (const e of EFEITOS_DE_ENTRADA) {
    const fim = estadoNoTempo(animacaoNova(e.chave, { inicio: 0, duracao: 1 }), 1.0001, c);
    assert.equal(fim.alfa, 1, e.chave);
    assert.equal(fim.recorte, null, e.chave);
    assert.ok(Math.abs(fim.dx) + Math.abs(fim.dy) + Math.abs(fim.giro) < 1e-6 && Math.abs(fim.escala - 1) < 1e-6, e.chave);
  }
});

test("saída: some no fim; sem efeito de saída, some seco", () => {
  const a = animacaoNova("surgir", { inicio: 0, fim: 3, saida: "subir", duracaoDaSaida: 0.5 });
  assert.equal(estadoNoTempo(a, 2.9, c).alfa, 1);
  const saindo = estadoNoTempo(a, 3.25, c);
  assert.ok(saindo.alfa < 1 && saindo.dy < 0, "sai subindo");
  assert.equal(estadoNoTempo(a, 3.6, c).alfa, 0);
  assert.equal(estadoNoTempo({ ...a, saida: null }, 3.01, c).alfa, 0);
});

test("ênfase começa sem tranco e a máquina descobre linha por linha", () => {
  const a = { ...animacaoNova("surgir", { inicio: 0, duracao: 0.5 }), enfase: "pulsar" as const };
  assert.ok(Math.abs(estadoNoTempo(a, 0.5001, c).escala - 1) < 0.001);
  const m = estadoNoTempo(animacaoNova("maquina", { inicio: 0, duracao: 1.5 }), 0.75, c);
  assert.equal(m.recorte?.tipo, "linhas");
  const pontos = poligonoDoRecorte(m.recorte!, c.caixa);
  assert.ok(pontos.length === 6 || pontos.length === 4);
  assert.ok(pontos.every(([x, y]) => x >= 100 && x <= 300 && y >= 100 && y <= 200));
});

test("a limpeza joga fora o que não serve e guarda o que vale", () => {
  assert.equal(animacaoLimpa({ entrada: "explodir" }), null);
  const limpa = animacaoLimpa({ entrada: "pop", inicio: -3, duracao: 99, fim: 2, saida: "surgir", direcao: "lado" });
  assert.equal(limpa?.inicio, 0);
  assert.equal(limpa?.duracao, 6);
  assert.equal(limpa?.direcao, "cima");
  assert.equal(transicaoLimpa({ tipo: "corte" }), null);
  assert.deepEqual(transicaoLimpa({ tipo: "zoom", duracao: 9 }), { tipo: "zoom", duracao: 2 });
  const doc = documentoLimpo({ versao: 3, pecas: [{ ...forma("p1", animacaoNova("pop")), animacao: { entrada: "pop", inicio: 1 } }], animacao: { duracao: 8, fundo: "afastar" }, transicao: { tipo: "dissolver", duracao: 0.5 } }, 1080, 1920);
  assert.equal(doc.pecas[0].animacao?.entrada, "pop");
  assert.deepEqual(doc.animacao, { duracao: 8, fundo: "afastar" });
  assert.deepEqual(doc.transicao, { tipo: "dissolver", duracao: 0.5 });
});

test("o vídeo da peça só aceita o depósito e corte dentro do arquivo", () => {
  const img = { id: "v1", tipo: "imagem", x: 0, y: 0, w: 100, h: 100, src: "https://abc.supabase.co/storage/v1/object/public/products/x.webp", papel: "foto", ajuste: "cobrir" };
  const bom = documentoLimpo({ versao: 3, pecas: [{ ...img, video: { src: "https://abc.supabase.co/storage/v1/object/public/products/x.mp4", duracao: 10, corte: 50, volume: 3 } }] }, 1080, 1080);
  const v = (bom.pecas[0] as { video?: { corte: number; volume: number } }).video;
  assert.ok(v && v.corte <= 9.9 && v.volume === 1);
  const ruim = documentoLimpo({ versao: 3, pecas: [{ ...img, video: { src: "https://mal.com/x.mp4", duracao: 10 } }] }, 1080, 1080);
  assert.equal((ruim.pecas[0] as { video?: unknown }).video, undefined);
});

test("o projeto em sequência: tempos, cena no instante e transição", () => {
  const p = [pagina([], 5), pagina([], 3, { transicao: { tipo: "dissolver", duracao: 1 } }), pagina([], 2)];
  assert.deepEqual(temposDoProjeto(p), { inicios: [0, 5, 8], duracoes: [5, 3, 2], total: 10 });
  assert.deepEqual(paginaNoTempo(p, 0), { indice: 0, local: 0 });
  assert.deepEqual(paginaNoTempo(p, 5), { indice: 1, local: 0 });
  assert.deepEqual(paginaNoTempo(p, 10), { indice: 2, local: 2 });
  assert.deepEqual(paginaNoTempo(p, 99), { indice: 2, local: 2 });
  assert.deepEqual(transicaoNoTempo(p, 5.5), { de: 0, para: 1, fracao: 0.5, tipo: "dissolver" });
  assert.equal(transicaoNoTempo(p, 6.1), null);
  assert.equal(transicaoNoTempo(p, 8.1), null);
  const fim = estadosDaTransicao("empurrar", 1, 1080);
  assert.equal(fim.depois.dx, 0);
  assert.equal(fim.antes.dx, -1080);
});

test("dividir um clipe no meio: duas partes que juntas cobrem o mesmo tempo", () => {
  const a = animacaoNova("pop", { inicio: 1, fim: 4, saida: "surgir", duracaoDaSaida: 0.5 });
  const pg = pagina([forma("p1"), forma("p2", a)], 5);
  const r = dividirUnidade(pg, ["p2"], 2.5)!;
  assert.ok(r);
  assert.equal(r.pagina.pecas.length, 3);
  const [, primeira, segunda] = r.pagina.pecas;
  assert.equal(primeira.id, "p2");
  assert.deepEqual(intervaloDoClipe(primeira.animacao, 5), { comeco: 1, fim: 2.5 });
  assert.equal(primeira.animacao?.entrada, "pop");
  assert.equal(segunda.animacao?.entrada, null);
  assert.deepEqual(intervaloDoClipe(segunda.animacao, 5), { comeco: 2.5, fim: 4.5 });
  assert.notEqual(segunda.id, "p2");
  assert.equal(dividirUnidade(pg, ["p2"], 0.5), null, "fora do clipe não corta");
  assert.equal(dividirUnidade(pg, ["p1"], 2)?.pagina.pecas.length, 3, "peça sem animação também corta");
});

test("dividir um vídeo: a segunda parte continua do ponto do arquivo", () => {
  const video = { ...forma("v1"), tipo: "imagem", src: "https://abc.supabase.co/x.webp", video: { src: "https://abc.supabase.co/x.mp4", duracao: 20, corte: 2, mudo: false, volume: 1 } } as unknown as Peca;
  const r = dividirUnidade(pagina([video], 10), ["v1"], 4)!;
  const segunda = r.pagina.pecas[1] as Peca & { video: { corte: number } };
  assert.equal(segunda.video.corte, 6);
});

test("dividir um grupo leva o grupo inteiro e cria outro grupo", () => {
  const a = animacaoNova("surgir", { inicio: 0 });
  const pg = pagina([forma("p1", a, { grupo: "g1" }), forma("p2", a, { grupo: "g1" })], 5);
  const r = dividirUnidade(pg, ["p1", "p2"], 2)!;
  const copias = r.pagina.pecas.slice(2);
  assert.equal(copias.length, 2);
  assert.ok(copias[0].grupo && copias[0].grupo === copias[1].grupo && copias[0].grupo !== "g1");
});

test("dividir a cena: cada peça fica na metade certa, tempos recontados", () => {
  const pg = pagina([forma("p1", animacaoNova("pop", { inicio: 0.5, fim: 1.5 })), forma("p2", animacaoNova("pop", { inicio: 3 })), forma("p3"), forma("p4", { ...animacaoNova("subir", { inicio: 1, fim: 4 }), saida: "surgir" })], 6);
  const [a, b] = dividirPagina(pg, 2)!;
  assert.equal(a.animacao?.duracao, 2);
  assert.equal(b.animacao?.duracao, 4);
  assert.deepEqual(a.pecas.map((p) => p.id), ["p1", "p3", "p4"]);
  assert.equal(b.pecas.length, 3);
  const doB = (indice: number) => b.pecas[indice].animacao;
  assert.equal(doB(0)?.inicio, 1, "p2 começa 1 s depois do corte");
  assert.equal(doB(1), undefined, "p3 sem animação nas duas");
  assert.equal(doB(2)?.entrada, null);
  assert.equal(doB(2)?.fim, 2);
  assert.equal(a.pecas[2].animacao?.fim, null, "na primeira, p4 fica até o fim");
  assert.ok(b.pecas.every((p) => !["p2", "p3", "p4"].includes(p.id)), "ids novos");
  assert.equal(dividirPagina(pg, 0.1), null);
});

test("animar a página: cada unidade entra depois da anterior e a página cabe a animação", () => {
  const pg = pagina([forma("p1"), forma("p2"), forma("p3")], 1);
  const animada = animarPagina(pg, "pop");
  const inicios = animada.pecas.map((p) => p.animacao!.inicio);
  assert.ok(inicios[0] < inicios[1] && inicios[1] < inicios[2]);
  assert.ok((animada.animacao?.duracao ?? 0) >= inicios[2] + animada.pecas[2].animacao!.duracao);
  assert.equal(unidadesDaCena(animada).filter((u) => u.animacao).length, 3);
});

test("chicote: vem de longe com rastro para trás e para no lugar sem rastro", () => {
  const a = animacaoNova("chicote", { inicio: 0, duracao: 0.5 });
  assert.equal(a.direcao, "esquerda");
  const comeco = estadoNoTempo(a, 0.05, c);
  assert.ok(comeco.dx > 100, `dx ${comeco.dx}`);
  assert.ok(comeco.rastro[0] > 0 && comeco.rastro[1] === 0, `rastro ${comeco.rastro}`);
  const fim = estadoNoTempo(a, 0.6, c);
  assert.equal(fim.dx, 0);
  assert.deepEqual(fim.rastro, [0, 0]);
});

test("palavra por palavra: cada palavra entra na vez dela; sem as medidas, o texto entra inteiro", () => {
  const palavras = [0, 1, 2].map((i) => ({ x: 100 + i * 70, y: 100, w: 60, h: 40 }));
  const a = animacaoLimpa({ ...animacaoNova("surgir", { inicio: 0, duracao: 1.2 }), porPalavra: true })!;
  assert.equal(a.porPalavra, true);
  const meio = estadoNoTempo(a, 0.3, { ...c, palavras });
  assert.equal(meio.partes?.length, 3);
  assert.ok(meio.partes![0].estado.alfa > meio.partes![1].estado.alfa && meio.partes![2].estado.alfa === 0);
  assert.equal(estadoNoTempo(a, 1.3, { ...c, palavras }).partes, null);
  assert.equal(estadoNoTempo(a, 0.3, c).partes, null);
  /* A Máquina não vai por palavra. */
  assert.equal(animacaoLimpa({ ...animacaoNova("maquina"), porPalavra: true })!.porPalavra, undefined);
});

test("mover: anda até o ponto no tempo marcado e fica lá; conferido ao guardar", () => {
  const a = animacaoLimpa({ ...animacaoNova(null, { inicio: 0 }), mover: { dx: 300, dy: -100, inicio: 1, duracao: 1 } })!;
  assert.deepEqual(estadoNoTempo(a, 0.5, c).dx, 0);
  const meio = estadoNoTempo(a, 1.5, c);
  assert.ok(Math.abs(meio.dx - 150) < 1 && Math.abs(meio.dy + 50) < 1);
  assert.deepEqual([estadoNoTempo(a, 4, c).dx, estadoNoTempo(a, 4, c).dy], [300, -100]);
  assert.equal(animacaoLimpa({ ...animacaoNova(null, { inicio: 0 }), mover: { dx: 0, dy: 0, inicio: 1, duracao: 1 } }), null);
});

test("transições novas: círculo abre do meio, chicote passa com rastro, apagar escurece", () => {
  const circulo = estadosDaTransicao("circulo", 0.5, 1080);
  assert.equal(circulo.depois.recorte?.tipo, "circulo");
  const pontos = poligonoDoRecorte({ tipo: "circulo", cx: 0.5, cy: 0.5, r: 1 }, { x: 0, y: 0, w: 1080, h: 1920 });
  assert.ok(Math.max(...pontos.map(([x, y]) => Math.hypot(x - 540, y - 960))) >= Math.hypot(540, 960) - 1, "o raio 1 cobre os cantos");
  const chicote = estadosDaTransicao("chicote", 0.5, 1080);
  assert.ok(chicote.antes.dx < 0 && chicote.depois.dx > 0 && chicote.antes.rastro[0] > 100);
  const apagar = estadosDaTransicao("preto", 0.5, 1080);
  assert.equal(apagar.corDoClarao, "#000000");
  assert.equal(apagar.clarao, 1);
  assert.equal(transicaoLimpa({ tipo: "circulo", duracao: 0.5 })?.tipo, "circulo");
});

test("quadros-chave: a pose anda entre os quadros pela curva e fica no último", () => {
  for (const c of CURVAS) assert.equal(curvar(c.chave, 1), 1, c.chave);
  assert.equal(curvar("segura", 0.99), 0);
  const quadros = [
    { t: 1, x: 0, y: 0, w: 100, h: 100, rot: 0, opacidade: 1, curva: "suave" as const },
    { t: 3, x: 200, y: 100, w: 200, h: 200, rot: 90, opacidade: 0.5, curva: "linear" as const },
  ];
  assert.deepEqual(poseNoTempo(quadros, 0), quadros[0]);
  const meio = poseNoTempo(quadros, 2);
  assert.deepEqual([meio.x, meio.w, meio.rot, meio.opacidade], [100, 150, 45, 0.75]);
  assert.deepEqual(poseNoTempo(quadros, 9), quadros[1]);
});

test("quadros-chave na cena: a camada vai do lugar guardado até a pose do instante", () => {
  const p = forma("f1");
  let pg = pagina([p]);
  pg = alternarQuadro(pg, ["f1"], 0);
  /* A peça arrastada com a agulha em 2 s grava o segundo quadro sozinha. */
  const movida = { ...pg, pecas: pg.pecas.map((x) => ({ ...x, x: x.x + 300, rot: 30 }) as Peca) };
  pg = comQuadrosAutomaticos(pg, movida, 2);
  const a = pg.pecas[0].animacao!;
  assert.equal(a.quadros?.length, 2);
  assert.deepEqual([a.quadros![1].t, a.quadros![1].x, a.quadros![1].rot], [2, 400, 30]);
  /* A camada foi desenhada na pose guardada (a de 2 s): em 0 s ela volta 300 px e desgira. */
  const u = unidadesDaCena(pg).filter((x) => !x.fundo);
  const [e0] = estadosNoTempo(pg, u, 0).slice(-1);
  assert.ok(Math.abs(e0.dx + 300) < 0.5 && Math.abs(e0.dy) < 0.5 && Math.abs(e0.giro + 30) < 0.01, JSON.stringify(e0));
  /* A agulha parada em 0 s põe a peça na pose de 0 s; marcar de novo no mesmo instante tira o quadro. */
  assert.equal(comPoseNoTempo(pg, 0).pecas[0].x, 100);
  assert.equal(alternarQuadro(pg, ["f1"], 2).pecas[0].animacao?.quadros?.length, 1);
});

test("quadros num grupo: a outra peça anda junto com a referência", () => {
  let pg = pagina([forma("f1", undefined, { grupo: "g" }), forma("f2", undefined, { grupo: "g" })]);
  pg = alternarQuadro(pg, ["f1", "f2"], 0);
  const movida = { ...pg, pecas: pg.pecas.map((x) => ({ ...x, x: x.x + 100 }) as Peca) };
  pg = comQuadrosAutomaticos(pg, movida, 1);
  assert.equal(pg.pecas[1].animacao?.quadrosDe, pg.pecas[0].id);
  const em0 = comPoseNoTempo(pg, 0);
  assert.deepEqual(em0.pecas.map((x) => x.x), [100, 100]);
});

test("máscaras que andam e o fade do áudio", () => {
  const c2 = { ...c, linhas: 1 };
  for (const e of ["circulo", "diagonal", "abrir", "persiana"] as const) {
    const est = estadoNoTempo(animacaoNova(e, { inicio: 0, duracao: 1 }), 0.4, c2);
    assert.ok(est.recorte, e);
    assert.equal(estadoNoTempo(animacaoNova(e, { inicio: 0, duracao: 1 }), 1.2, c2).recorte, null, e);
  }
  const persiana = estadoNoTempo(animacaoNova("persiana", { inicio: 0, duracao: 1 }), 0.4, c2).recorte!;
  assert.equal(persiana.tipo === "poligono" && persiana.pontos.length, 28);
  const a = { id: "a", nome: "x", src: "https://x/y.mp3", tipo: "musica" as const, duracao: 30, inicio: 0, corte: 0, dura: 10, volume: 1, suave: false, fadeDeEntrada: 2, fadeDeSaida: 4 };
  assert.equal(volumeNoTempo(a, 1), 0.5);
  assert.equal(volumeNoTempo(a, 5), 1);
  assert.equal(volumeNoTempo(a, 8), 0.5);
  assert.equal(volumeNoTempo({ ...a, fadeDeEntrada: undefined, fadeDeSaida: undefined, suave: true }, 0.25), 0.5);
});

test("número que sobe: conta do começo até o número do texto, no jeito brasileiro", async () => {
  const { escreverComo, htmlComValor, numeroDoTexto, pecaNoValor, valorNoTempo, valoresParaFotografar } = await import("./contador");
  const molde = numeroDoTexto("+R$ 1.300 por ano")!;
  assert.deepEqual(molde, { valor: 1300, casas: 0, milhar: true });
  assert.equal(escreverComo(1289, molde), "1.289");
  assert.deepEqual(numeroDoTexto("R$ 49,90"), { valor: 49.9, casas: 2, milhar: false });
  const c = { de: 0, inicio: 1, duracao: 2, curva: "linear" as const };
  assert.equal(valorNoTempo(c, molde, 0.5), 0);
  assert.equal(valorNoTempo(c, molde, 2), 650);
  assert.equal(valorNoTempo(c, molde, 9), 1300);
  const valores = valoresParaFotografar(c, molde, 30);
  assert.ok(valores.length <= 36 && valores[0] === 0 && valores[valores.length - 1] === 1300, String(valores.length));
  /* No HTML, só o texto muda (o estilo com número fica). */
  assert.equal(htmlComValor('<span style="font-size:40px">+R$ 1.300</span>', 5, molde), '<span style="font-size:40px">+R$ 5</span>');
  const t = pecaNoValor({ ...(forma("f1") as unknown as Peca), tipo: "texto", texto: "+R$ 1.300", html: null } as never, 77);
  assert.equal((t as { texto: string }).texto, "+R$ 77");
  const a = animacaoLimpa({ ...animacaoNova(null, { inicio: 0 }), contador: { de: 10, inicio: 0.5, duracao: 1, curva: "freia" } });
  assert.deepEqual(a?.contador, { de: 10, inicio: 0.5, duracao: 1, curva: "freia" });
});

test("partículas: o mesmo instante dá o mesmo desenho, e o desenho anda com o tempo", async () => {
  const { particulasNoTempo, svgDasParticulas, ESTILOS_DE_PARTICULAS } = await import("./particulas");
  for (const estilo of ESTILOS_DE_PARTICULAS) {
    const campos = { id: "p1", estilo, cor: "#FF0000", quantidade: 40, tamanho: 10, centroX: 50, centroY: 80, w: 1080, h: 1920 };
    const a = particulasNoTempo(campos, 1);
    assert.deepEqual(a, particulasNoTempo(campos, 1), estilo);
    assert.ok(a.length >= 8, estilo);
    assert.notDeepEqual(a.map((p) => [p.x, p.y, p.r, p.alfa]), particulasNoTempo(campos, 1.5).map((p) => [p.x, p.y, p.r, p.alfa]), estilo);
    assert.ok(svgDasParticulas(a, 1080, 1920).startsWith("<svg"), estilo);
  }
});

test("fundo verde: a mesma conta do filtro some com o verde e deixa a pele", async () => {
  const { aplicarChroma, filtroDoChroma } = await import("./chroma");
  const px = new Uint8ClampedArray([0, 200, 0, 255, 210, 160, 130, 255, 40, 220, 60, 255]);
  aplicarChroma(px, { cor: "verde", forca: 50 });
  assert.equal(px[3], 0, "verde puro some");
  assert.equal(px[7], 255, "pele fica");
  assert.equal(px[11], 0, "verde da tela some");
  assert.ok(filtroDoChroma({ cor: "verde", forca: 50 }, "x").includes('id="crmx"'));
});

test("3D: o canto projetado é o mesmo do CSS (perspectiva) e a transição Virar gira as cenas", async () => {
  const { pontoNoEstado, css3D } = await import("./perspectiva");
  const e = { ...estadoNoTempo(null, 0, c), ry: 90 };
  /* Girado 90° de lado, a borda da direita cai no centro. */
  const [x] = pontoNoEstado(e, [500, 500], 2000, 1000, 500);
  assert.ok(Math.abs(x - 500) < 1, String(x));
  assert.ok(css3D({ rx: 0, ry: 30 }, { w: 100, h: 200 }).includes("rotateY(30.00deg)"));
  const v = estadosDaTransicao("virar", 0.25, 1080);
  assert.ok(v.antes.ry > 0 && v.depois.alfa === 0);
  const virar = estadoNoTempo(animacaoNova("virar", { inicio: 0, duracao: 1 }), 0.1, c);
  assert.ok(virar.ry < -30, String(virar.ry));
});

test("legenda automática: trechos curtos na cena certa, cada palavra no tempo da fala", async () => {
  const { comLegenda } = await import("./narracao");
  const palavras = ["Chegou", "a", "coleção", "nova.", "Peça", "já", "pelo", "link"].map((texto, i) => ({ texto, inicio: 0.5 + i * 0.4, fim: 0.85 + i * 0.4 }));
  const paginas = [pagina([], 2), pagina([], 5)];
  const com = comLegenda(paginas, palavras, 0, 0, "palavras");
  const legendas = com.flatMap((p) => p.pecas.filter((x) => x.nome === "Legenda automática"));
  assert.equal(legendas.length, 2);
  assert.equal((legendas[0] as { texto: string }).texto, "CHEGOU A COLEÇÃO NOVA.");
  assert.equal(com[0].pecas.length, 1, "o primeiro trecho na cena 1");
  assert.deepEqual(legendas[1].animacao?.tempoDasPalavras, [0, 0.4, 0.8, 1.2]);
  assert.equal(legendas[1].animacao?.inicio, 0.1, "o segundo trecho na cena 2, no segundo certo");
  /* Gerar de novo troca a anterior. */
  assert.equal(comLegenda(com, palavras, 0, 0, "caixa").flatMap((p) => p.pecas).length, 2);
});

test("alinhar no som: as frases caem nos trechos de voz, depois das pausas", async () => {
  const { alinharNoSom } = await import("./narracao");
  const taxa = 8000;
  /* Voz de 0,2 a 1,2 s, de 1,6 a 2,6 s e de 3,0 a 3,6 s (as pausas entre elas). */
  const som = new Float32Array(taxa * 4);
  for (const [a, b] of [[0.2, 1.2], [1.6, 2.6], [3.0, 3.6]]) for (let i = Math.round(a * taxa); i < b * taxa; i += 1) som[i] = 0.5 * Math.sin(i / 3);
  const p = alinharNoSom(["Chegou", "novidade!", "Corre", "agora,", "que", "acaba."], som, taxa);
  assert.ok(Math.abs(p[0].inicio - 0.2) < 0.05, `começa na voz: ${p[0].inicio}`);
  assert.ok(Math.abs(p[2].inicio - 1.6) < 0.05, `segunda frase depois da pausa: ${p[2].inicio}`);
  assert.ok(Math.abs(p[4].inicio - 3.0) < 0.05, `terceira depois da outra pausa: ${p[4].inicio}`);
  assert.ok(p.every((x, i) => i === 0 || x.inicio >= p[i - 1].inicio), "em ordem");
});
