import fc from "fast-check"; import { describe, expect, it } from "vitest"; import { compositeLayers, compositeSourceOver } from "../../src/colour"; import type { BlendMode } from "../../src/colour"; describe("source-over compositing", () => { it("matches the canonical translucent-red-over-white result", () => { const result = compositeSourceOver("rgba(255, 0, 0, .5)", "white"); expect(result.colour.coords).toEqual([1, 0.5, 0.5]); expect(result.colour.alpha).toBe(1); expect(result.hex).toBe("#ff8080"); }); it("composites an arbitrary RGBA reference with the source-over equation", () => { const result = compositeSourceOver( "rgba(0, 129, 255, .42)", "rgb(255, 255, 255)", ); expect(result.colour.coords[0]).toBeCloseTo(0.58, 12); expect(result.colour.coords[1]).toBeCloseTo(0.7924705882352941, 12); expect(result.colour.coords[2]).toBe(1); }); it("supports N layers and an explicit stack order", () => { const layers = [ { colour: "white" }, { colour: "rgba(255,0,0,.5)" }, { colour: "rgba(0,0,255,.5)" }, ]; expect(compositeLayers(layers).hex).toBe("#8040bf"); expect( compositeLayers([...layers].reverse(), { order: "top-to-bottom" }).hex, ).toBe("#8040bf"); }); it("offers visibly different sRGB and linear-light workflows", () => { const srgb = compositeSourceOver("rgba(255,0,0,.5)", "#00ff00", { space: "srgb", }); const linear = compositeSourceOver("rgba(255,0,0,.5)", "#00ff00", { space: "linear-srgb", }); expect(srgb.hex).toBe("#808000"); expect(linear.hex).toBe("#bcbc00"); }); it("implements opacity and common blend modes", () => { expect( compositeSourceOver("#ff0000", "#808080", { blendMode: "multiply" }).hex, ).toBe("#800000"); expect( compositeSourceOver("#ff0000", "#808080", { blendMode: "screen" }).hex, ).toBe("#ff8080"); expect(compositeSourceOver("#ff0000", "#ffffff", { opacity: 0 }).hex).toBe( "#ffffff", ); }); it("keeps every exposed blend mode renderable", () => { const modes: BlendMode[] = [ "normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity", ]; for (const blendMode of modes) { const result = compositeSourceOver("rgba(230,30,80,.7)", "#2878c8", { blendMode, }); expect(result.colour.coords.every(Number.isFinite), blendMode).toBe(true); expect(result.hex, blendMode).toMatch(/^#[0-9a-f]{6}$/); } }); it("keeps arbitrary composites bounded and alpha-correct", () => { fc.assert( fc.property( fc.tuple( fc.double({ min: 0, max: 1, noNaN: true }), fc.double({ min: 0, max: 1, noNaN: true }), fc.double({ min: 0, max: 1, noNaN: true }), fc.double({ min: 0, max: 1, noNaN: true }), ), fc.tuple( fc.double({ min: 0, max: 1, noNaN: true }), fc.double({ min: 0, max: 1, noNaN: true }), fc.double({ min: 0, max: 1, noNaN: true }), fc.double({ min: 0, max: 1, noNaN: true }), ), (foreground, background) => { const result = compositeSourceOver( { space: "srgb", coords: foreground.slice(0, 3) as [number, number, number], alpha: foreground[3], }, { space: "srgb", coords: background.slice(0, 3) as [number, number, number], alpha: background[3], }, ).colour; expect( result.coords.every((channel) => channel >= 0 && channel <= 1), ).toBe(true); expect(result.alpha).toBeGreaterThanOrEqual(0); expect(result.alpha).toBeLessThanOrEqual(1); expect(result.alpha).toBeCloseTo( foreground[3] + background[3] * (1 - foreground[3]), 12, ); }, ), { numRuns: 100 }, ); }); });