/** * previewRuntime.test.ts — Unit test S28 * * Testa PreviewRuntime: * - setHtml: aggiorna stato + schedula auto-refresh (debounced) * - refresh("manual"): refresh immediato + emit preview_refresh * - captureError: aggiunge errore al buffer + emit preview_error * - LRU buffer: non supera maxErrors * - subscribe: notifica i listener ad ogni cambiamento * - dispose: cancella timer + rimuove listener * - createPreviewRuntime: factory genera ID unico * - dedup HTML: setHtml con stesso valore non fa refresh * - debounce: chiamate ravvicinate producono un solo refresh * * Mock: emitter via config.emitter (DI) — nessun vi.mock su moduli globali. * Usa vi.useFakeTimers() per controllare il debounce senza race condition. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { PreviewRuntime, createPreviewRuntime, _setTestEmitter, } from "@/lib/previewRuntime"; import type { EmitPayload } from "@/core/events/EventTypes"; // ─── Mock emitter ───────────────────────────────────────────────────────────── function makeMockEmitter() { const calls: EmitPayload[] = []; const emitter = { emit: vi.fn((p: EmitPayload) => { calls.push(p); }) }; return { emitter, calls }; } // ─── Fake timers ────────────────────────────────────────────────────────────── beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); // ─── PreviewRuntime ─────────────────────────────────────────────────────────── describe("PreviewRuntime — init", () => { it("stato iniziale corretto", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); expect(rt.state.refreshKey).toBe(0); expect(rt.state.errors).toHaveLength(0); expect(rt.state.lastRefreshAt).toBe(0); expect(rt.state.html).toBe(""); }); }); // ─── setHtml + auto-refresh debounce ───────────────────────────────────────── describe("PreviewRuntime — setHtml", () => { it("aggiorna html nello stato", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", debounceMs: 200, emitter }); rt.setHtml("

ciao

"); expect(rt.state.html).toBe("

ciao

"); }); it("schedula auto-refresh dopo debounceMs", () => { const { emitter, calls } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", debounceMs: 200, emitter }); rt.setHtml("

test

"); expect(rt.state.refreshKey).toBe(0); // non ancora refreshato vi.advanceTimersByTime(200); expect(rt.state.refreshKey).toBe(1); expect(calls).toHaveLength(1); expect(calls[0]?.kind).toBe("preview_refresh"); expect((calls[0] as { reason: string }).reason).toBe("html_change"); }); it("debounce: più setHtml ravvicinati → un solo refresh", () => { const { emitter, calls } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", debounceMs: 200, emitter }); rt.setHtml("

v1

"); vi.advanceTimersByTime(100); rt.setHtml("

v2

"); vi.advanceTimersByTime(100); rt.setHtml("

v3

"); vi.advanceTimersByTime(200); expect(rt.state.refreshKey).toBe(1); expect(calls).toHaveLength(1); }); it("nessun refresh se HTML è identico", () => { const { emitter, calls } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", debounceMs: 200, emitter }); rt.setHtml("

stesso

"); vi.advanceTimersByTime(200); const keyAfterFirst = rt.state.refreshKey; rt.setHtml("

stesso

"); // identico vi.advanceTimersByTime(200); expect(rt.state.refreshKey).toBe(keyAfterFirst); expect(calls).toHaveLength(1); }); }); // ─── refresh manuale ────────────────────────────────────────────────────────── describe("PreviewRuntime — refresh()", () => { it("refresh manuale incrementa refreshKey + emette preview_refresh", () => { const { emitter, calls } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); rt.refresh("manual"); expect(rt.state.refreshKey).toBe(1); expect(calls).toHaveLength(1); expect(calls[0]?.kind).toBe("preview_refresh"); expect((calls[0] as { reason: string }).reason).toBe("manual"); }); it("refresh manuale cancella il debounce pendente", () => { const { emitter, calls } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", debounceMs: 500, emitter }); rt.setHtml("

x

"); // schedula debounce 500ms rt.refresh("manual"); // refresh immediato + cancella debounce vi.advanceTimersByTime(500); // il debounce non dovrebbe sparare più expect(rt.state.refreshKey).toBe(1); // solo 1 refresh expect(calls).toHaveLength(1); }); it("refresh svuota gli errori", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); rt.captureError("boom", "runtime"); expect(rt.state.errors).toHaveLength(1); rt.refresh("manual"); expect(rt.state.errors).toHaveLength(0); }); }); // ─── captureError ───────────────────────────────────────────────────────────── describe("PreviewRuntime — captureError()", () => { it("aggiunge errore al buffer + emette preview_error", () => { const { emitter, calls } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); rt.captureError("ReferenceError: x", "runtime", 42); expect(rt.state.errors).toHaveLength(1); expect(rt.state.errors[0]?.message).toBe("ReferenceError: x"); expect(rt.state.errors[0]?.source).toBe("runtime"); expect(rt.state.errors[0]?.line).toBe(42); expect(calls[0]?.kind).toBe("preview_error"); expect((calls[0] as { previewId: string }).previewId).toBe("p1"); }); it("LRU: non supera maxErrors", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter, maxErrors: 3 }); rt.captureError("err1", "console"); rt.captureError("err2", "console"); rt.captureError("err3", "console"); rt.captureError("err4", "console"); expect(rt.state.errors).toHaveLength(3); expect(rt.state.errors[0]?.message).toBe("err2"); expect(rt.state.errors[2]?.message).toBe("err4"); }); it("tronca messaggi lunghi a 500 chars", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); const long = "x".repeat(1000); rt.captureError(long, "runtime"); expect(rt.state.errors[0]?.message).toHaveLength(500); }); it("errorCount distingue runtime da console", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); rt.captureError("runtime err", "runtime"); rt.captureError("console err", "console"); rt.captureError("rejection", "unhandled_rejection"); expect(rt.errorCount).toBe(2); // runtime + unhandled_rejection expect(rt.consoleErrorCount).toBe(1); // solo console expect(rt.totalErrorCount).toBe(3); }); }); // ─── subscribe ──────────────────────────────────────────────────────────────── describe("PreviewRuntime — subscribe()", () => { it("notifica il listener dopo refresh", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); const snapshots: number[] = []; rt.subscribe(s => snapshots.push(s.refreshKey)); rt.refresh("manual"); rt.refresh("manual"); expect(snapshots).toEqual([1, 2]); }); it("unsubscribe funziona correttamente", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); const snapshots: number[] = []; const unsub = rt.subscribe(s => snapshots.push(s.refreshKey)); rt.refresh("manual"); unsub(); rt.refresh("manual"); expect(snapshots).toEqual([1]); // solo il primo }); it("notifica il listener dopo captureError", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); const counts: number[] = []; rt.subscribe(s => counts.push(s.errors.length)); rt.captureError("err1", "console"); rt.captureError("err2", "runtime"); expect(counts).toEqual([1, 2]); }); }); // ─── dispose ────────────────────────────────────────────────────────────────── describe("PreviewRuntime — dispose()", () => { it("cancella il debounce pendente", () => { const { emitter, calls } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", debounceMs: 300, emitter }); rt.setHtml("

x

"); rt.dispose(); vi.advanceTimersByTime(300); expect(calls).toHaveLength(0); // nessun refresh dopo dispose }); it("rimuove tutti i listener", () => { const { emitter } = makeMockEmitter(); const rt = new PreviewRuntime({ previewId: "p1", emitter }); const calls: number[] = []; rt.subscribe(s => calls.push(s.refreshKey)); rt.dispose(); rt.refresh("manual"); // dopo dispose, non dovrebbe notificare expect(calls).toHaveLength(0); }); }); // ─── createPreviewRuntime factory ──────────────────────────────────────────── describe("createPreviewRuntime()", () => { it("crea un'istanza con ID unico", () => { const { emitter } = makeMockEmitter(); _setTestEmitter(emitter); const rt1 = createPreviewRuntime(); const rt2 = createPreviewRuntime(); expect(rt1).not.toBe(rt2); // Non esponiamo previewId pubblicamente — verifichiamo che gli emit abbiano ID diversi rt1.captureError("e1", "runtime"); rt2.captureError("e2", "runtime"); const id1 = (emitter.emit.mock.calls[0]?.[0] as { previewId?: string })?.previewId; const id2 = (emitter.emit.mock.calls[1]?.[0] as { previewId?: string })?.previewId; expect(id1).toBeDefined(); expect(id2).toBeDefined(); expect(id1).not.toBe(id2); rt1.dispose(); rt2.dispose(); }); }); // ─── _setTestEmitter ────────────────────────────────────────────────────────── describe("_setTestEmitter()", () => { it("sostituisce l'emitter di default", () => { const { emitter, calls } = makeMockEmitter(); _setTestEmitter(emitter); const rt = createPreviewRuntime(); // usa _defaultEmitter rt.refresh("manual"); expect(calls).toHaveLength(1); expect(calls[0]?.kind).toBe("preview_refresh"); rt.dispose(); }); });