Loading packages/client/src/lib/__tests__/canvasRenderer.test.ts 0 → 100644 +549 −0 Original line number Diff line number Diff line /** * Tests for CanvasRenderer — runs in browser (Playwright Chromium + Firefox). * * No mocks or stubs needed: real document.createElement('canvas'), * CanvasRenderingContext2D, requestAnimationFrame, and getImageData are * all available in the browser environment. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { CanvasRenderer, type CanvasPixel } from "../canvasRenderer"; /* ─── Helpers ─── */ function makeCanvas(w = 100, h = 100): HTMLCanvasElement { const c = document.createElement("canvas"); c.width = w; c.height = h; return c; } function ctxOf(r: CanvasRenderer): CanvasRenderingContext2D { const ctx = (r as any).ctx as CanvasRenderingContext2D | undefined; if (!ctx) throw new Error("No canvas context set"); return ctx; } function pixelColor(ctx: CanvasRenderingContext2D, x: number, y: number) { const [r, g, b] = ctx.getImageData(x, y, 1, 1).data; return { r, g, b }; } function hexColor(ctx: CanvasRenderingContext2D, x: number, y: number) { const { r, g, b } = pixelColor(ctx, x, y); return "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join(""); } function pixelColorAlpha(ctx: CanvasRenderingContext2D, x: number, y: number) { const [r, g, b, a] = ctx.getImageData(x, y, 1, 1).data; return { r, g, b, a }; } function blankCtxOf(r: CanvasRenderer): CanvasRenderingContext2D { const ctx = (r as any).blank_ctx as CanvasRenderingContext2D | undefined; if (!ctx) throw new Error("No blank canvas context set"); return ctx; } /** * Attach a canvas to the renderer as "main" and call setSize (which triggers * drawFull synchronously since ctx is available). */ function setup(r: CanvasRenderer, w = 100, h = 100): HTMLCanvasElement { const c = makeCanvas(); r.useCanvas(c, "main"); r.setSize(w, h); return c; } /* ─── Tests ─── */ describe("CanvasRenderer", () => { let renderer: CanvasRenderer; beforeEach(() => { renderer = new CanvasRenderer(); }); afterEach(() => { renderer.removeAllListeners(); }); /* ─────── Constructor ─────── */ describe("constructor", () => { it("creates an instance", () => { expect(renderer).toBeInstanceOf(CanvasRenderer); }); it("initialises empty pixel maps", () => { const r = renderer as any; expect(r.pixels.size).toBe(0); expect(r.allPixels.size).toBe(0); }); it("initialises dimensions to zero", () => { const r = renderer as any; expect(r.dimentions.width).toBe(0); expect(r.dimentions.height).toBe(0); }); it("detects it is not inside a WorkerGlobalScope", () => { const r = renderer as any; expect(r.isWorker).toBe(false); }); }); /* ─────── useCanvas ─────── */ describe("useCanvas", () => { it("stores a reference and context for the main canvas", () => { const c = makeCanvas(); renderer.useCanvas(c, "main"); const r = renderer as any; expect(r.canvas).toBe(c); expect(r.ctx).toBeTruthy(); }); it("stores a reference and context for the blank canvas", () => { const c = makeCanvas(); renderer.useCanvas(c, "blank"); const r = renderer as any; expect(r.blank).toBe(c); expect(r.blank_ctx).toBeTruthy(); }); it("sets canvas width/height from internal dimensions", () => { renderer.setSize(50, 60); const c = makeCanvas(); renderer.useCanvas(c, "main"); expect(c.width).toBe(50); expect(c.height).toBe(60); }); it("throws when getContext returns null", () => { const c = makeCanvas(); vi.spyOn(c, "getContext").mockReturnValue(null); expect(() => renderer.useCanvas(c, "main")).toThrow( "Unable to get canvas context", ); }); }); /* ─────── removeCanvas ─────── */ describe("removeCanvas", () => { it("removes the blank canvas reference", () => { const c = makeCanvas(); renderer.useCanvas(c, "blank"); renderer.removeCanvas("blank"); const r = renderer as any; expect(r.blank).toBeUndefined(); expect(r.blank_ctx).toBeUndefined(); }); it("throws when trying to remove the main canvas", () => { expect(() => renderer.removeCanvas("main")).toThrow( "Cannot remove main canvas", ); }); }); /* ─────── usePixel / usePixels ─────── */ describe("usePixel", () => { it("stores a pixel in both pending and allPixels maps", () => { renderer.usePixel({ x: 5, y: 10, hex: "ff0000" }); const r = renderer as any; expect(r.pixels.get("5,10")).toBe("ff0000"); expect(r.allPixels.get("5,10")).toBe("ff0000"); }); it("overwrites an existing pixel at the same coordinate", () => { renderer.usePixel({ x: 5, y: 10, hex: "ff0000" }); renderer.usePixel({ x: 5, y: 10, hex: "00ff00" }); const r = renderer as any; expect(r.pixels.get("5,10")).toBe("00ff00"); }); it("accepts 'null' as a valid hex value", () => { renderer.usePixel({ x: 0, y: 0, hex: "null" }); const r = renderer as any; expect(r.pixels.get("0,0")).toBe("null"); }); }); describe("usePixels", () => { it("stores multiple pixels in both maps", () => { const pixels: CanvasPixel[] = [ { x: 0, y: 0, hex: "ff0000" }, { x: 1, y: 1, hex: "00ff00" }, ]; renderer.usePixels(pixels); const r = renderer as any; expect(r.pixels.size).toBe(2); expect(r.allPixels.size).toBe(2); }); it("is a no-op for an empty array", () => { renderer.usePixels([]); const r = renderer as any; expect(r.pixels.size).toBe(0); }); it("ignores the replace parameter (same as usePixel)", () => { renderer.usePixels([{ x: 0, y: 0, hex: "ff0000" }], true); const r = renderer as any; expect(r.pixels.get("0,0")).toBe("ff0000"); }); }); /* ─────── setSize ─────── */ describe("setSize", () => { it("updates internal dimensions", () => { renderer.setSize(200, 150); const r = renderer as any; expect(r.dimentions.width).toBe(200); expect(r.dimentions.height).toBe(150); }); it("resizes the attached main canvas", () => { const c = makeCanvas(); renderer.useCanvas(c, "main"); renderer.setSize(200, 150); expect(c.width).toBe(200); expect(c.height).toBe(150); }); it("resizes the attached blank canvas", () => { const c = makeCanvas(); renderer.useCanvas(c, "blank"); renderer.setSize(200, 150); expect(c.width).toBe(200); expect(c.height).toBe(150); }); it("emits ready event", () => { const fn = vi.fn(); renderer.on("ready", fn); renderer.setSize(100, 100); expect(fn).toHaveBeenCalledTimes(1); }); }); /* ─────── draw ─────── */ describe("draw", () => { it("draws pending pixels onto the canvas", () => { const canvas = setup(renderer); const ctx = ctxOf(renderer); renderer.usePixel({ x: 5, y: 5, hex: "ff0000" }); renderer.usePixel({ x: 10, y: 10, hex: "00ff00" }); renderer.draw(); expect(hexColor(ctx, 5, 5)).toBe("#ff0000"); expect(hexColor(ctx, 10, 10)).toBe("#00ff00"); }); it("clears the pending queue after drawing", () => { setup(renderer); renderer.usePixel({ x: 0, y: 0, hex: "ff0000" }); renderer.draw(); const r = renderer as any; expect(r.pixels.size).toBe(0); }); it("draws 'null' hex as white (#ffffff)", () => { const canvas = setup(renderer); const ctx = ctxOf(renderer); renderer.usePixel({ x: 15, y: 15, hex: "null" }); renderer.draw(); const { r, g, b } = pixelColor(ctx, 15, 15); expect(r).toBe(255); expect(g).toBe(255); expect(b).toBe(255); }); it("is a no-op when there are no pending pixels", () => { setup(renderer); expect(() => renderer.draw()).not.toThrow(); }); it("does not crash when ctx is undefined and pixels queue is empty", () => { // No canvas attached — ctx is undefined expect(() => renderer.draw()).not.toThrow(); }); it("tracks draw timing statistics across multiple calls", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); setup(renderer); // Draw enough pixels so performance.now() registers a real time diff // (the pixel loop is never a no-op when there are pixels to draw). for (let i = 0; i < 50; i++) { for (let j = 0; j < 10; j++) { renderer.usePixel({ x: i, y: j, hex: "ff0000" }); } } // First draw: pushes a diff value to drawTimes renderer.draw(); warnSpy.mockClear(); // Second draw: drawTimes.length > 0 → exercises the reduce callback renderer.draw(); expect(() => renderer.draw()).not.toThrow(); warnSpy.mockRestore(); }); }); /* ─────── drawFull (triggered by setSize) ─────── */ describe("drawFull (triggered by setSize)", () => { it("draws all accumulated pixels", () => { renderer.usePixel({ x: 10, y: 10, hex: "ff0000" }); const canvas = setup(renderer); // calls setSize → tryDrawFull → drawFull const ctx = ctxOf(renderer); expect(hexColor(ctx, 10, 10)).toBe("#ff0000"); }); it("fills the entire canvas with white first", () => { const canvas = setup(renderer); const ctx = ctxOf(renderer); // After setup → drawFull, whole canvas should be white const { r, g, b } = pixelColor(ctx, 50, 50); expect(r).toBe(255); expect(g).toBe(255); expect(b).toBe(255); }); it("draws 'null' hex pixels as white via drawFull", () => { renderer.usePixel({ x: 3, y: 3, hex: "null" }); setup(renderer); // calls setSize → tryDrawFull → drawFull const ctx = ctxOf(renderer); const { r, g, b } = pixelColor(ctx, 3, 3); expect(r).toBe(255); expect(g).toBe(255); expect(b).toBe(255); }); }); /* ─────── startRender / stopRender ─────── */ describe("startRender / stopRender", () => { it("calls drawFull and sets _stopRender = false", () => { const canvas = setup(renderer); renderer.usePixel({ x: 20, y: 20, hex: "ff0000" }); renderer.startRender(); // drawFull runs synchronously inside startRender (ctx exists) const ctx = ctxOf(renderer); expect(hexColor(ctx, 20, 20)).toBe("#ff0000"); expect((renderer as any)._stopRender).toBe(false); }); it("stopRender sets _stopRender = true", () => { renderer.startRender(); renderer.stopRender(); expect((renderer as any)._stopRender).toBe(true); }); it("does not throw when called before startRender", () => { expect(() => renderer.stopRender()).not.toThrow(); }); it("prevents tryDrawFull from drawing", () => { setup(renderer); renderer.startRender(); renderer.stopRender(); const ctx = ctxOf(renderer); // After stop, setSize → tryDrawFull checks _stopRender and returns // early. Setting canvas.width/height inside setSize clears the canvas // to transparent black, but drawFull never re-runs. renderer.usePixel({ x: 0, y: 0, hex: "ff0000" }); renderer.setSize(100, 100); // Canvas was cleared by resize, but drawFull was blocked // Default cleared state is transparent black (0, 0, 0) const { r, g, b } = pixelColor(ctx, 0, 0); expect(r).toBe(0); expect(g).toBe(0); expect(b).toBe(0); // The pixel IS stored in the maps though expect((renderer as any).allPixels.get("0,0")).toBe("ff0000"); }); }); /* ─────── Edge cases ─────── */ describe("edge cases", () => { it("removing an un-attached blank canvas does not throw", () => { expect(() => renderer.removeCanvas("blank")).not.toThrow(); }); it("setSize with zero dimensions still emits ready", () => { const fn = vi.fn(); renderer.on("ready", fn); renderer.setSize(0, 0); expect(fn).toHaveBeenCalledTimes(1); }); it("multiple setSize calls resize the canvas each time", () => { const c = makeCanvas(); renderer.useCanvas(c, "main"); renderer.setSize(50, 50); expect(c.width).toBe(50); expect(c.height).toBe(50); renderer.setSize(200, 100); expect(c.width).toBe(200); expect(c.height).toBe(100); }); it("allPixels persists across draw calls", () => { setup(renderer); renderer.usePixel({ x: 0, y: 0, hex: "ff0000" }); renderer.draw(); renderer.usePixel({ x: 1, y: 1, hex: "00ff00" }); renderer.draw(); const r = renderer as any; expect(r.allPixels.size).toBe(2); expect(r.allPixels.get("0,0")).toBe("ff0000"); expect(r.allPixels.get("1,1")).toBe("00ff00"); }); it("setSize is idempotent with same dimensions", () => { const fn = vi.fn(); renderer.on("ready", fn); setup(renderer, 100, 100); expect(fn).toHaveBeenCalledTimes(1); renderer.setSize(100, 100); expect(fn).toHaveBeenCalledTimes(2); }); }); /* ─────── Blank canvas / drawBlank ─────── */ describe("blank canvas (drawBlank via startRender)", () => { it("draws semi-transparent green where hex is 'null'", () => { const main = makeCanvas(); const blank = makeCanvas(); renderer.useCanvas(main, "main"); renderer.useCanvas(blank, "blank"); renderer.setSize(100, 100); renderer.usePixel({ x: 5, y: 5, hex: "null" }); renderer.usePixel({ x: 10, y: 10, hex: "ff0000" }); // should NOT appear on blank // startRender calls tryDrawBlank → drawBlank (blank_ctx is set) renderer.startRender(); renderer.stopRender(); const bCtx = blankCtxOf(renderer); // Null pixel at (5,5) should be rgba(0,140,0,0.5) on the blank canvas // Note: getImageData may be off by 1 due to premultiplied-alpha precision const nil = pixelColorAlpha(bCtx, 5, 5); expect(nil.r).toBe(0); expect(nil.g).toBeGreaterThanOrEqual(130); expect(nil.g).toBeLessThanOrEqual(140); expect(nil.b).toBe(0); // Alpha = Math.round(0.5 * 255) = 127 or 128 expect(nil.a).toBeGreaterThanOrEqual(120); expect(nil.a).toBeLessThanOrEqual(130); // Non-null pixel at (10,10) should NOT appear on blank const solid = pixelColorAlpha(bCtx, 10, 10); expect(solid.a).toBe(0); // transparent / untouched }); it("clears the blank canvas before each drawBlank call", () => { const main = makeCanvas(); const blank = makeCanvas(100, 100); renderer.useCanvas(main, "main"); renderer.useCanvas(blank, "blank"); renderer.setSize(100, 100); // Add a null pixel, render once renderer.usePixel({ x: 5, y: 5, hex: "null" }); renderer.startRender(); renderer.stopRender(); const bCtx = blankCtxOf(renderer); expect(pixelColorAlpha(bCtx, 5, 5).a).toBeGreaterThan(0); // Remove that pixel from allPixels and re-render renderer.startRender(); renderer.stopRender(); // Remove the pixel and restart (renderer as any).allPixels.delete("5,5"); renderer.startRender(); renderer.stopRender(); // Pixel should now be transparent (cleared by drawBlank's clearRect) expect(pixelColorAlpha(bCtx, 5, 5).a).toBe(0); }); it("is a no-op when _stopRender is set", () => { renderer.startRender(); renderer.stopRender(); // second startRender returns immediately from tryDrawFull/tryDrawBlank renderer.startRender(); // _stopRender is still false now... // but stopRender is called to clean up renderer.stopRender(); // No crash = pass }); }); /* ─────── Events ─────── */ describe("events", () => { it("emits ready only from setSize", () => { const fn = vi.fn(); renderer.on("ready", fn); setup(renderer); expect(fn).toHaveBeenCalledTimes(1); renderer.usePixel({ x: 0, y: 0, hex: "ff0000" }); renderer.draw(); expect(fn).toHaveBeenCalledTimes(1); }); it("supports multiple ready listeners", () => { const fn1 = vi.fn(); const fn2 = vi.fn(); renderer.on("ready", fn1); renderer.on("ready", fn2); renderer.setSize(100, 100); expect(fn1).toHaveBeenCalledTimes(1); expect(fn2).toHaveBeenCalledTimes(1); }); it("removeListener works", () => { const fn = vi.fn(); renderer.on("ready", fn); renderer.removeListener("ready", fn); renderer.setSize(100, 100); expect(fn).not.toHaveBeenCalled(); }); it("removeAllListeners clears all listeners", () => { const fn = vi.fn(); renderer.on("ready", fn); renderer.removeAllListeners(); renderer.setSize(100, 100); expect(fn).not.toHaveBeenCalled(); }); }); }); No newline at end of file Loading
packages/client/src/lib/__tests__/canvasRenderer.test.ts 0 → 100644 +549 −0 Original line number Diff line number Diff line /** * Tests for CanvasRenderer — runs in browser (Playwright Chromium + Firefox). * * No mocks or stubs needed: real document.createElement('canvas'), * CanvasRenderingContext2D, requestAnimationFrame, and getImageData are * all available in the browser environment. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { CanvasRenderer, type CanvasPixel } from "../canvasRenderer"; /* ─── Helpers ─── */ function makeCanvas(w = 100, h = 100): HTMLCanvasElement { const c = document.createElement("canvas"); c.width = w; c.height = h; return c; } function ctxOf(r: CanvasRenderer): CanvasRenderingContext2D { const ctx = (r as any).ctx as CanvasRenderingContext2D | undefined; if (!ctx) throw new Error("No canvas context set"); return ctx; } function pixelColor(ctx: CanvasRenderingContext2D, x: number, y: number) { const [r, g, b] = ctx.getImageData(x, y, 1, 1).data; return { r, g, b }; } function hexColor(ctx: CanvasRenderingContext2D, x: number, y: number) { const { r, g, b } = pixelColor(ctx, x, y); return "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join(""); } function pixelColorAlpha(ctx: CanvasRenderingContext2D, x: number, y: number) { const [r, g, b, a] = ctx.getImageData(x, y, 1, 1).data; return { r, g, b, a }; } function blankCtxOf(r: CanvasRenderer): CanvasRenderingContext2D { const ctx = (r as any).blank_ctx as CanvasRenderingContext2D | undefined; if (!ctx) throw new Error("No blank canvas context set"); return ctx; } /** * Attach a canvas to the renderer as "main" and call setSize (which triggers * drawFull synchronously since ctx is available). */ function setup(r: CanvasRenderer, w = 100, h = 100): HTMLCanvasElement { const c = makeCanvas(); r.useCanvas(c, "main"); r.setSize(w, h); return c; } /* ─── Tests ─── */ describe("CanvasRenderer", () => { let renderer: CanvasRenderer; beforeEach(() => { renderer = new CanvasRenderer(); }); afterEach(() => { renderer.removeAllListeners(); }); /* ─────── Constructor ─────── */ describe("constructor", () => { it("creates an instance", () => { expect(renderer).toBeInstanceOf(CanvasRenderer); }); it("initialises empty pixel maps", () => { const r = renderer as any; expect(r.pixels.size).toBe(0); expect(r.allPixels.size).toBe(0); }); it("initialises dimensions to zero", () => { const r = renderer as any; expect(r.dimentions.width).toBe(0); expect(r.dimentions.height).toBe(0); }); it("detects it is not inside a WorkerGlobalScope", () => { const r = renderer as any; expect(r.isWorker).toBe(false); }); }); /* ─────── useCanvas ─────── */ describe("useCanvas", () => { it("stores a reference and context for the main canvas", () => { const c = makeCanvas(); renderer.useCanvas(c, "main"); const r = renderer as any; expect(r.canvas).toBe(c); expect(r.ctx).toBeTruthy(); }); it("stores a reference and context for the blank canvas", () => { const c = makeCanvas(); renderer.useCanvas(c, "blank"); const r = renderer as any; expect(r.blank).toBe(c); expect(r.blank_ctx).toBeTruthy(); }); it("sets canvas width/height from internal dimensions", () => { renderer.setSize(50, 60); const c = makeCanvas(); renderer.useCanvas(c, "main"); expect(c.width).toBe(50); expect(c.height).toBe(60); }); it("throws when getContext returns null", () => { const c = makeCanvas(); vi.spyOn(c, "getContext").mockReturnValue(null); expect(() => renderer.useCanvas(c, "main")).toThrow( "Unable to get canvas context", ); }); }); /* ─────── removeCanvas ─────── */ describe("removeCanvas", () => { it("removes the blank canvas reference", () => { const c = makeCanvas(); renderer.useCanvas(c, "blank"); renderer.removeCanvas("blank"); const r = renderer as any; expect(r.blank).toBeUndefined(); expect(r.blank_ctx).toBeUndefined(); }); it("throws when trying to remove the main canvas", () => { expect(() => renderer.removeCanvas("main")).toThrow( "Cannot remove main canvas", ); }); }); /* ─────── usePixel / usePixels ─────── */ describe("usePixel", () => { it("stores a pixel in both pending and allPixels maps", () => { renderer.usePixel({ x: 5, y: 10, hex: "ff0000" }); const r = renderer as any; expect(r.pixels.get("5,10")).toBe("ff0000"); expect(r.allPixels.get("5,10")).toBe("ff0000"); }); it("overwrites an existing pixel at the same coordinate", () => { renderer.usePixel({ x: 5, y: 10, hex: "ff0000" }); renderer.usePixel({ x: 5, y: 10, hex: "00ff00" }); const r = renderer as any; expect(r.pixels.get("5,10")).toBe("00ff00"); }); it("accepts 'null' as a valid hex value", () => { renderer.usePixel({ x: 0, y: 0, hex: "null" }); const r = renderer as any; expect(r.pixels.get("0,0")).toBe("null"); }); }); describe("usePixels", () => { it("stores multiple pixels in both maps", () => { const pixels: CanvasPixel[] = [ { x: 0, y: 0, hex: "ff0000" }, { x: 1, y: 1, hex: "00ff00" }, ]; renderer.usePixels(pixels); const r = renderer as any; expect(r.pixels.size).toBe(2); expect(r.allPixels.size).toBe(2); }); it("is a no-op for an empty array", () => { renderer.usePixels([]); const r = renderer as any; expect(r.pixels.size).toBe(0); }); it("ignores the replace parameter (same as usePixel)", () => { renderer.usePixels([{ x: 0, y: 0, hex: "ff0000" }], true); const r = renderer as any; expect(r.pixels.get("0,0")).toBe("ff0000"); }); }); /* ─────── setSize ─────── */ describe("setSize", () => { it("updates internal dimensions", () => { renderer.setSize(200, 150); const r = renderer as any; expect(r.dimentions.width).toBe(200); expect(r.dimentions.height).toBe(150); }); it("resizes the attached main canvas", () => { const c = makeCanvas(); renderer.useCanvas(c, "main"); renderer.setSize(200, 150); expect(c.width).toBe(200); expect(c.height).toBe(150); }); it("resizes the attached blank canvas", () => { const c = makeCanvas(); renderer.useCanvas(c, "blank"); renderer.setSize(200, 150); expect(c.width).toBe(200); expect(c.height).toBe(150); }); it("emits ready event", () => { const fn = vi.fn(); renderer.on("ready", fn); renderer.setSize(100, 100); expect(fn).toHaveBeenCalledTimes(1); }); }); /* ─────── draw ─────── */ describe("draw", () => { it("draws pending pixels onto the canvas", () => { const canvas = setup(renderer); const ctx = ctxOf(renderer); renderer.usePixel({ x: 5, y: 5, hex: "ff0000" }); renderer.usePixel({ x: 10, y: 10, hex: "00ff00" }); renderer.draw(); expect(hexColor(ctx, 5, 5)).toBe("#ff0000"); expect(hexColor(ctx, 10, 10)).toBe("#00ff00"); }); it("clears the pending queue after drawing", () => { setup(renderer); renderer.usePixel({ x: 0, y: 0, hex: "ff0000" }); renderer.draw(); const r = renderer as any; expect(r.pixels.size).toBe(0); }); it("draws 'null' hex as white (#ffffff)", () => { const canvas = setup(renderer); const ctx = ctxOf(renderer); renderer.usePixel({ x: 15, y: 15, hex: "null" }); renderer.draw(); const { r, g, b } = pixelColor(ctx, 15, 15); expect(r).toBe(255); expect(g).toBe(255); expect(b).toBe(255); }); it("is a no-op when there are no pending pixels", () => { setup(renderer); expect(() => renderer.draw()).not.toThrow(); }); it("does not crash when ctx is undefined and pixels queue is empty", () => { // No canvas attached — ctx is undefined expect(() => renderer.draw()).not.toThrow(); }); it("tracks draw timing statistics across multiple calls", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); setup(renderer); // Draw enough pixels so performance.now() registers a real time diff // (the pixel loop is never a no-op when there are pixels to draw). for (let i = 0; i < 50; i++) { for (let j = 0; j < 10; j++) { renderer.usePixel({ x: i, y: j, hex: "ff0000" }); } } // First draw: pushes a diff value to drawTimes renderer.draw(); warnSpy.mockClear(); // Second draw: drawTimes.length > 0 → exercises the reduce callback renderer.draw(); expect(() => renderer.draw()).not.toThrow(); warnSpy.mockRestore(); }); }); /* ─────── drawFull (triggered by setSize) ─────── */ describe("drawFull (triggered by setSize)", () => { it("draws all accumulated pixels", () => { renderer.usePixel({ x: 10, y: 10, hex: "ff0000" }); const canvas = setup(renderer); // calls setSize → tryDrawFull → drawFull const ctx = ctxOf(renderer); expect(hexColor(ctx, 10, 10)).toBe("#ff0000"); }); it("fills the entire canvas with white first", () => { const canvas = setup(renderer); const ctx = ctxOf(renderer); // After setup → drawFull, whole canvas should be white const { r, g, b } = pixelColor(ctx, 50, 50); expect(r).toBe(255); expect(g).toBe(255); expect(b).toBe(255); }); it("draws 'null' hex pixels as white via drawFull", () => { renderer.usePixel({ x: 3, y: 3, hex: "null" }); setup(renderer); // calls setSize → tryDrawFull → drawFull const ctx = ctxOf(renderer); const { r, g, b } = pixelColor(ctx, 3, 3); expect(r).toBe(255); expect(g).toBe(255); expect(b).toBe(255); }); }); /* ─────── startRender / stopRender ─────── */ describe("startRender / stopRender", () => { it("calls drawFull and sets _stopRender = false", () => { const canvas = setup(renderer); renderer.usePixel({ x: 20, y: 20, hex: "ff0000" }); renderer.startRender(); // drawFull runs synchronously inside startRender (ctx exists) const ctx = ctxOf(renderer); expect(hexColor(ctx, 20, 20)).toBe("#ff0000"); expect((renderer as any)._stopRender).toBe(false); }); it("stopRender sets _stopRender = true", () => { renderer.startRender(); renderer.stopRender(); expect((renderer as any)._stopRender).toBe(true); }); it("does not throw when called before startRender", () => { expect(() => renderer.stopRender()).not.toThrow(); }); it("prevents tryDrawFull from drawing", () => { setup(renderer); renderer.startRender(); renderer.stopRender(); const ctx = ctxOf(renderer); // After stop, setSize → tryDrawFull checks _stopRender and returns // early. Setting canvas.width/height inside setSize clears the canvas // to transparent black, but drawFull never re-runs. renderer.usePixel({ x: 0, y: 0, hex: "ff0000" }); renderer.setSize(100, 100); // Canvas was cleared by resize, but drawFull was blocked // Default cleared state is transparent black (0, 0, 0) const { r, g, b } = pixelColor(ctx, 0, 0); expect(r).toBe(0); expect(g).toBe(0); expect(b).toBe(0); // The pixel IS stored in the maps though expect((renderer as any).allPixels.get("0,0")).toBe("ff0000"); }); }); /* ─────── Edge cases ─────── */ describe("edge cases", () => { it("removing an un-attached blank canvas does not throw", () => { expect(() => renderer.removeCanvas("blank")).not.toThrow(); }); it("setSize with zero dimensions still emits ready", () => { const fn = vi.fn(); renderer.on("ready", fn); renderer.setSize(0, 0); expect(fn).toHaveBeenCalledTimes(1); }); it("multiple setSize calls resize the canvas each time", () => { const c = makeCanvas(); renderer.useCanvas(c, "main"); renderer.setSize(50, 50); expect(c.width).toBe(50); expect(c.height).toBe(50); renderer.setSize(200, 100); expect(c.width).toBe(200); expect(c.height).toBe(100); }); it("allPixels persists across draw calls", () => { setup(renderer); renderer.usePixel({ x: 0, y: 0, hex: "ff0000" }); renderer.draw(); renderer.usePixel({ x: 1, y: 1, hex: "00ff00" }); renderer.draw(); const r = renderer as any; expect(r.allPixels.size).toBe(2); expect(r.allPixels.get("0,0")).toBe("ff0000"); expect(r.allPixels.get("1,1")).toBe("00ff00"); }); it("setSize is idempotent with same dimensions", () => { const fn = vi.fn(); renderer.on("ready", fn); setup(renderer, 100, 100); expect(fn).toHaveBeenCalledTimes(1); renderer.setSize(100, 100); expect(fn).toHaveBeenCalledTimes(2); }); }); /* ─────── Blank canvas / drawBlank ─────── */ describe("blank canvas (drawBlank via startRender)", () => { it("draws semi-transparent green where hex is 'null'", () => { const main = makeCanvas(); const blank = makeCanvas(); renderer.useCanvas(main, "main"); renderer.useCanvas(blank, "blank"); renderer.setSize(100, 100); renderer.usePixel({ x: 5, y: 5, hex: "null" }); renderer.usePixel({ x: 10, y: 10, hex: "ff0000" }); // should NOT appear on blank // startRender calls tryDrawBlank → drawBlank (blank_ctx is set) renderer.startRender(); renderer.stopRender(); const bCtx = blankCtxOf(renderer); // Null pixel at (5,5) should be rgba(0,140,0,0.5) on the blank canvas // Note: getImageData may be off by 1 due to premultiplied-alpha precision const nil = pixelColorAlpha(bCtx, 5, 5); expect(nil.r).toBe(0); expect(nil.g).toBeGreaterThanOrEqual(130); expect(nil.g).toBeLessThanOrEqual(140); expect(nil.b).toBe(0); // Alpha = Math.round(0.5 * 255) = 127 or 128 expect(nil.a).toBeGreaterThanOrEqual(120); expect(nil.a).toBeLessThanOrEqual(130); // Non-null pixel at (10,10) should NOT appear on blank const solid = pixelColorAlpha(bCtx, 10, 10); expect(solid.a).toBe(0); // transparent / untouched }); it("clears the blank canvas before each drawBlank call", () => { const main = makeCanvas(); const blank = makeCanvas(100, 100); renderer.useCanvas(main, "main"); renderer.useCanvas(blank, "blank"); renderer.setSize(100, 100); // Add a null pixel, render once renderer.usePixel({ x: 5, y: 5, hex: "null" }); renderer.startRender(); renderer.stopRender(); const bCtx = blankCtxOf(renderer); expect(pixelColorAlpha(bCtx, 5, 5).a).toBeGreaterThan(0); // Remove that pixel from allPixels and re-render renderer.startRender(); renderer.stopRender(); // Remove the pixel and restart (renderer as any).allPixels.delete("5,5"); renderer.startRender(); renderer.stopRender(); // Pixel should now be transparent (cleared by drawBlank's clearRect) expect(pixelColorAlpha(bCtx, 5, 5).a).toBe(0); }); it("is a no-op when _stopRender is set", () => { renderer.startRender(); renderer.stopRender(); // second startRender returns immediately from tryDrawFull/tryDrawBlank renderer.startRender(); // _stopRender is still false now... // but stopRender is called to clean up renderer.stopRender(); // No crash = pass }); }); /* ─────── Events ─────── */ describe("events", () => { it("emits ready only from setSize", () => { const fn = vi.fn(); renderer.on("ready", fn); setup(renderer); expect(fn).toHaveBeenCalledTimes(1); renderer.usePixel({ x: 0, y: 0, hex: "ff0000" }); renderer.draw(); expect(fn).toHaveBeenCalledTimes(1); }); it("supports multiple ready listeners", () => { const fn1 = vi.fn(); const fn2 = vi.fn(); renderer.on("ready", fn1); renderer.on("ready", fn2); renderer.setSize(100, 100); expect(fn1).toHaveBeenCalledTimes(1); expect(fn2).toHaveBeenCalledTimes(1); }); it("removeListener works", () => { const fn = vi.fn(); renderer.on("ready", fn); renderer.removeListener("ready", fn); renderer.setSize(100, 100); expect(fn).not.toHaveBeenCalled(); }); it("removeAllListeners clears all listeners", () => { const fn = vi.fn(); renderer.on("ready", fn); renderer.removeAllListeners(); renderer.setSize(100, 100); expect(fn).not.toHaveBeenCalled(); }); }); }); No newline at end of file