import { describe, expect, it } from "bun:test"; import { UNASSIGNED_COLOR } from "./colors"; import { blendColors, clusterEvents, formatSize } from "./timeline"; // Parse an `oklch(L C H [/ A])` string back into numeric components for // assertions. Mirrors the (private) parser in timeline.ts. function parseOklch(value: string): { l: number; c: number; h: number; a: number; } { const m = /^oklch\(\s*(.+)\s*\)$/i.exec(value.trim()); if (!m) throw new Error(`not an oklch string: ${value}`); const [coords, alphaRaw] = m[1].split("/"); const parts = coords.trim().split(/\s+/); return { l: Number.parseFloat(parts[0]), c: Number.parseFloat(parts[1]), h: Number.parseFloat(parts[2]), a: alphaRaw != null ? Number.parseFloat(alphaRaw) : 1, }; } describe("blendColors", () => { it("returns UNASSIGNED_COLOR for empty input", () => { expect(blendColors([])).toBe(UNASSIGNED_COLOR); }); it("returns the single color unchanged for one input", () => { expect(blendColors([{ color: "oklch(0.6 0.15 120)", weight: 5 }])).toBe( "oklch(0.6 0.15 120)", ); expect(blendColors([{ color: "#ff0000", weight: 1 }])).toBe("#ff0000"); }); it("blends two oklch colors: L and C average, hue between (oklch+oklch)", () => { const result = blendColors([ { color: "oklch(0.4 0.10 100)", weight: 1 }, { color: "oklch(0.6 0.20 140)", weight: 1 }, ]); const { l, c, h } = parseOklch(result); expect(l).toBeCloseTo(0.5, 3); expect(c).toBeCloseTo(0.15, 3); expect(h).toBeGreaterThan(100); expect(h).toBeLessThan(140); }); it("respects weights when blending oklch colors", () => { const result = blendColors([ { color: "oklch(0.4 0.10 100)", weight: 3 }, { color: "oklch(0.8 0.20 100)", weight: 1 }, ]); const { l } = parseOklch(result); // 0.75*0.4 + 0.25*0.8 = 0.5 expect(l).toBeCloseTo(0.5, 3); }); it("returns a valid oklch string when blending hex colors (hex+hex)", () => { const result = blendColors([ { color: "#ff0000", weight: 1 }, { color: "#0000ff", weight: 1 }, ]); const { l, c, h, a } = parseOklch(result); expect(result.startsWith("oklch(")).toBe(true); expect(l).toBeGreaterThan(0); expect(l).toBeLessThan(1); expect(c).toBeGreaterThan(0); expect(h).toBeGreaterThanOrEqual(0); expect(h).toBeLessThan(360); expect(a).toBe(1); }); it("supports shorthand and rgb() inputs", () => { const fromShort = blendColors([ { color: "#f00", weight: 1 }, { color: "#00f", weight: 1 }, ]); const fromRgb = blendColors([ { color: "rgb(255, 0, 0)", weight: 1 }, { color: "rgb(0, 0, 255)", weight: 1 }, ]); expect(fromShort.startsWith("oklch(")).toBe(true); expect(fromRgb.startsWith("oklch(")).toBe(true); }); it("blends mixed oklch + hex inputs into a valid oklch string (mixed)", () => { const result = blendColors([ { color: "oklch(0.55 0.02 260)", weight: 2 }, { color: "#00ff00", weight: 1 }, ]); expect(result.startsWith("oklch(")).toBe(true); const { l, c } = parseOklch(result); expect(Number.isNaN(l)).toBe(false); expect(Number.isNaN(c)).toBe(false); }); it("does NOT fall back to gray for oklch project colors (the bug fix)", () => { const result = blendColors([ { color: "oklch(0.6 0.15 30)", weight: 1 }, { color: "oklch(0.6 0.15 210)", weight: 1 }, ]); // The old hex-regex blender returned a gray rgb(...) here. The fix keeps // real chroma and stays in oklch space. expect(result.startsWith("oklch(")).toBe(true); const { c } = parseOklch(result); expect(c).toBeGreaterThan(0.05); }); it("interpolates hue along the shortest arc (hue wraparound)", () => { // 350 and 10 should average to ~0/360, not ~180. const result = blendColors([ { color: "oklch(0.6 0.15 350)", weight: 1 }, { color: "oklch(0.6 0.15 10)", weight: 1 }, ]); const { h } = parseOklch(result); const nearZero = h < 30 || h > 330; expect(nearZero).toBe(true); }); it("interpolates alpha and only emits `/ a` when below 1 (alpha)", () => { const translucent = blendColors([ { color: "oklch(0.6 0.15 120 / 0.4)", weight: 1 }, { color: "oklch(0.6 0.15 120 / 0.6)", weight: 1 }, ]); expect(translucent).toContain("/"); expect(parseOklch(translucent).a).toBeCloseTo(0.5, 3); const opaque = blendColors([ { color: "oklch(0.6 0.15 120)", weight: 1 }, { color: "oklch(0.6 0.15 120)", weight: 1 }, ]); expect(opaque).not.toContain("/"); }); it("falls back to UNASSIGNED_COLOR when no input parses", () => { expect( blendColors([ { color: "not-a-color", weight: 1 }, { color: "also-bad", weight: 1 }, ]), ).toBe(UNASSIGNED_COLOR); }); }); describe("clusterEvents", () => { const ev = (timestamp: string, projectId: string | null = "p1") => ({ timestamp, projectId, }); it("returns events unchanged with count 1 when under the cap", () => { const events = [ev("2025-01-01T00:00:00Z"), ev("2025-01-02T00:00:00Z")]; const result = clusterEvents(events, 1000); expect(result).toHaveLength(2); expect(result.every((e) => e._clusterCount === 1)).toBe(true); }); it("preserves the original event fields", () => { const events = [{ timestamp: "2025-01-01T00:00:00Z", projectId: "abc" }]; const [first] = clusterEvents(events, 1000); expect(first.projectId).toBe("abc"); expect(first.timestamp).toBe("2025-01-01T00:00:00Z"); }); it("clusters down to <= input size and conserves total count", () => { const events = Array.from({ length: 100 }, (_, i) => ev(new Date(2025, 0, 1, 0, i).toISOString()), ); const result = clusterEvents(events, 10); expect(result.length).toBeLessThan(events.length); const total = result.reduce((sum, e) => sum + e._clusterCount, 0); expect(total).toBe(100); }); it("keeps separate clusters per project within the same time window", () => { const events = [ ...Array.from({ length: 50 }, (_, i) => ev(new Date(2025, 0, 1, 0, i).toISOString(), "a"), ), ...Array.from({ length: 50 }, (_, i) => ev(new Date(2025, 0, 1, 0, i).toISOString(), "b"), ), ]; const result = clusterEvents(events, 5); const projectIds = new Set(result.map((e) => e.projectId)); expect(projectIds.has("a")).toBe(true); expect(projectIds.has("b")).toBe(true); }); }); describe("formatSize", () => { it("formats bytes", () => { expect(formatSize(0)).toBe("0 B"); expect(formatSize(512)).toBe("512 B"); expect(formatSize(1023)).toBe("1023 B"); }); it("formats kilobytes", () => { expect(formatSize(1024)).toBe("1.0 KB"); expect(formatSize(1536)).toBe("1.5 KB"); }); it("formats megabytes", () => { expect(formatSize(1024 * 1024)).toBe("1.0 MB"); expect(formatSize(5 * 1024 * 1024)).toBe("5.0 MB"); }); });