Commit 3f151dd1 authored by Grant's avatar Grant Committed by Grant
Browse files

canvasRenderer.test.ts

Assisted-by: deepseek:v4-mini
parent 0b87a5d9
Loading
Loading
Loading
Loading
Loading
+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