import { describe, it, expect } from "vitest"; import { layoutMosaic, layoutMosaicStrip, mosaicTargetHeight, type MosaicInput, type MosaicRow, } from "./mosaic"; const VIDEO = 16 / 9; const SQUARE = 1; const POSTER = 2 / 3; function tiles(...ratios: number[]): MosaicInput[] { return ratios.map((ratio, i) => ({ key: `t${i}`, ratio })); } function rowWidth(row: MosaicRow, gap: number): number { return row.tiles.reduce((sum, t) => sum + t.width, 0) + gap * (row.tiles.length - 1); } /** Every tile bar the one that absorbs the rounding remainder keeps its ratio. */ function offRatioTiles(row: MosaicRow, tolerancePx = 1): number { return row.tiles.filter((t) => Math.abs(t.width - t.ratio * t.height) > tolerancePx).length; } describe("layoutMosaic", () => { const opts = { containerWidth: 1000, targetHeight: 160, gap: 8 }; it("fills the container width exactly on every row but the last", () => { const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts); expect(rows.length).toBeGreaterThan(1); for (const row of rows.slice(0, -1)) { expect(rowWidth(row, opts.gap)).toBe(opts.containerWidth); } }); it("never overflows the container, last row included", () => { const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts); for (const row of rows) { expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth); } }); it("gives every tile in a row the same height", () => { const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts); for (const row of rows) { for (const tile of row.tiles) { expect(tile.height).toBe(row.height); } } }); it("honours each tile's aspect ratio — widths vary, nothing is squashed", () => { const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts); for (const row of rows) { // At most the single remainder-absorbing tile may be off, and only by the // few pixels the row was short of the container width. expect(offRatioTiles(row)).toBeLessThanOrEqual(1); } // A 16:9 tile is meaningfully wider than a 2:3 tile at the same height. const all = rows.flatMap((r) => r.tiles); const video = all.find((t) => t.key === "t0")!; const poster = all.find((t) => t.key === "t2")!; expect(video.width).toBeGreaterThan(poster.width * 2); }); it("keeps rows at or below the target height", () => { const rows = layoutMosaic(tiles(...Array(12).fill(VIDEO)), opts); for (const row of rows) { expect(row.height).toBeLessThanOrEqual(opts.targetHeight); } }); it("does not stretch a short last row across the whole container", () => { // Two 16:9 tiles cannot fill 1000px at 160px tall (they want ~569px), so the // last row must stay at the target height rather than blow up to fill. const rows = layoutMosaic(tiles(VIDEO, VIDEO), opts); expect(rows).toHaveLength(1); expect(rows[0].height).toBe(opts.targetHeight); expect(rowWidth(rows[0], opts.gap)).toBeLessThan(opts.containerWidth); }); it("shrinks a last row that would otherwise overflow", () => { // Five 16:9 tiles at 160px tall want ~1454px; the row has to come down. const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO, VIDEO, VIDEO), { ...opts, targetHeight: 400, }); for (const row of rows) { expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth); } }); it("clamps an extreme ratio instead of letting it own a row", () => { const rows = layoutMosaic(tiles(20, SQUARE, SQUARE), { ...opts, maxRatio: 2.5 }); const panorama = rows.flatMap((r) => r.tiles).find((t) => t.key === "t0")!; expect(panorama.width / panorama.height).toBeLessThanOrEqual(2.6); }); it("treats a missing or nonsensical ratio as square rather than collapsing", () => { const rows = layoutMosaic( [ { key: "nan", ratio: Number.NaN }, { key: "zero", ratio: 0 }, { key: "neg", ratio: -2 }, ], opts, ); for (const tile of rows.flatMap((r) => r.tiles)) { expect(tile.width).toBeCloseTo(tile.height, -1); } }); it("renders nothing before the container has been measured", () => { expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, containerWidth: 0 })).toEqual([]); expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, targetHeight: 0 })).toEqual([]); expect(layoutMosaic([], opts)).toEqual([]); }); it("places every tile exactly once, in order", () => { const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER, SQUARE); const placed = layoutMosaic(input, opts).flatMap((r) => r.tiles.map((t) => t.key)); expect(placed).toEqual(input.map((t) => t.key)); }); it("re-packs when the container narrows", () => { const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO); const wide = layoutMosaic(input, { ...opts, containerWidth: 1400 }); const narrow = layoutMosaic(input, { ...opts, containerWidth: 420 }); expect(narrow.length).toBeGreaterThan(wide.length); }); }); describe("mosaicTargetHeight", () => { it("still fits two 16:9 tiles across a phone", () => { const width = 360; const height = mosaicTargetHeight(width); const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO), { containerWidth: width, targetHeight: height, gap: 8, }); expect(rows[0].tiles.length).toBeGreaterThanOrEqual(2); }); it("grows with the container but stays within bounds", () => { const widths = [0, 320, 600, 900, 1400, 3000]; const heights = widths.map(mosaicTargetHeight); for (const h of heights) { expect(h).toBeGreaterThanOrEqual(96); expect(h).toBeLessThanOrEqual(190); } for (let i = 1; i < heights.length; i++) { expect(heights[i]).toBeGreaterThanOrEqual(heights[i - 1]); } }); }); describe("layoutMosaicStrip", () => { it("gives one height and ratio-derived widths", () => { const strip = layoutMosaicStrip(tiles(VIDEO, SQUARE, POSTER), 140); expect(strip.map((t) => t.height)).toEqual([140, 140, 140]); expect(strip[0].width).toBe(Math.round(VIDEO * 140)); expect(strip[1].width).toBe(140); expect(strip[2].width).toBe(Math.round(POSTER * 140)); }); it("returns nothing for a height it cannot draw", () => { expect(layoutMosaicStrip(tiles(VIDEO), 0)).toEqual([]); }); });