import { describe, expect, test } from "vitest"; import { composeJournal, hasQuote, hasThumbnail, type JournalShape, } from "../../../utils/feed/journalLayout.ts"; import type { Dump } from "../../../../shared/dump.ts"; import { makeDump } from "../../fixtures.ts"; const HOUR = 3_600_000; const SPAN: Record = { feature: [2, 2], wide: [2, 1], tall: [1, 2], square: [1, 1], }; /** Replays the layout onto a 4-column grid, failing on overlap. Returns rows. */ function tile(shapes: JournalShape[]): boolean[][] { const grid: boolean[][] = []; const row = (r: number) => (grid[r] ??= [false, false, false, false]); let cursor = 0; for (const shape of shapes) { while (row(Math.floor(cursor / 4))[cursor % 4]) cursor++; const r = Math.floor(cursor / 4), c = cursor % 4; const [w, h] = SPAN[shape]; for (let dr = 0; dr < h; dr++) { for (let dc = 0; dc < w; dc++) { if (c + dc >= 4) throw new Error(`${shape} overflows at ${r},${c}`); if (row(r + dr)[c + dc]) { throw new Error(`overlap at ${r + dr},${c + dc}`); } row(r + dr)[c + dc] = true; } } } return grid; } function feed(n: number, withArt: (i: number) => boolean): Dump[] { return Array.from({ length: n }, (_, i) => makeDump({ id: `d${i}`, // Strictly decreasing heat, so the ranking is the index order. createdAt: new Date(Date.now() - (i + 1) * HOUR), richContent: withArt(i) ? { type: "link", url: "https://x", thumbnailUrl: "https://x/t.png" } : undefined, })); } describe("hasThumbnail / hasQuote", () => { test("image and video files have art; other files don't", () => { expect(hasThumbnail(makeDump({ kind: "file", fileMime: "image/png" }))) .toBe(true); expect(hasThumbnail(makeDump({ kind: "file", fileMime: "video/mp4" }))) .toBe(true); expect(hasThumbnail(makeDump({ kind: "file", fileMime: "audio/mpeg" }))) .toBe(false); }); test("an uploaded thumbnail or a rich preview image counts", () => { expect(hasThumbnail(makeDump({ thumbnailMime: "image/webp" }))).toBe(true); expect( hasThumbnail( makeDump({ richContent: { type: "x", url: "u", thumbnailUrl: "t" } }), ), ).toBe(true); expect(hasThumbnail(makeDump())).toBe(false); }); test("a quote needs non-blank text", () => { expect(hasQuote(makeDump({ comment: " " }))).toBe(false); expect(hasQuote(makeDump({ comment: "yes" }))).toBe(true); }); }); describe("composeJournal", () => { test("nothing in, nothing out", () => { expect(composeJournal([])).toEqual([]); }); test("sorts by heat and leads with a feature when the top dump has art", () => { const dumps = feed(5, () => true).reverse(); const [block] = composeJournal(dumps); expect(block.entries.map((e) => e.dump.id)).toEqual([ "d0", "d1", "d2", "d3", "d4", ]); expect(block.entries[0].shape).toBe("feature"); }); test("a bare note on top gives way to art from just behind it", () => { const dumps = feed(6, (i) => i === 2); const [block] = composeJournal(dumps); expect(block.entries[0].dump.id).toBe("d2"); expect(block.entries[0].shape).toBe("feature"); }); test("art too far down doesn't jump the queue", () => { const dumps = feed(8, (i) => i === 5); const [block] = composeJournal(dumps); expect(block.entries[0].dump.id).toBe("d0"); expect(block.entries[0].shape).toBe("wide"); }); test("only dumps with art take the big footprints", () => { const blocks = composeJournal(feed(40, (i) => i % 3 === 0)); for (const { entries } of blocks) { for (const { dump, shape } of entries) { if (shape === "tall" || shape === "feature") { expect(hasThumbnail(dump)).toBe(true); } } } }); for ( const [name, art] of [ ["all art", () => true], ["no art", () => false], ["mixed", (i: number) => i % 2 === 0], ["sparse", (i: number) => i % 7 === 0], ] as const ) { test(`tiles without gaps or overlap (${name})`, () => { for (const n of [1, 2, 3, 7, 13, 25, 60]) { const blocks = composeJournal(feed(n, art)); expect(blocks.flatMap((b) => b.entries)).toHaveLength(n); for (const block of blocks) { const grid = tile(block.entries.map((e) => e.shape)); // Every row but the last closes flush. grid.slice(0, -1).forEach((r) => expect(r).toEqual([true, true, true, true]) ); } } }); } test("long feeds break into blocks, each opening on a hero", () => { const blocks = composeJournal(feed(60, () => true)); expect(blocks.length).toBeGreaterThan(1); for (const block of blocks) { expect(["feature", "wide"]).toContain(block.entries[0].shape); expect(block.key).toBe(block.entries[0].dump.id); } }); test("no break leaves a fragment at the tail", () => { const blocks = composeJournal(feed(60, () => true)); expect(blocks.at(-1)!.entries.length).toBeGreaterThanOrEqual(6); }); test("appending a page doesn't reshuffle what came before", () => { const first = composeJournal(feed(30, (i) => i % 2 === 0)); const more = composeJournal(feed(60, (i) => i % 2 === 0)); const flat = (bs: typeof first) => bs.flatMap((b) => b.entries.map((e) => `${e.dump.id}:${e.shape}`)); // Shapes near the old tail may grow once more cards follow, but the order // and the opening stretch are fixed. expect(flat(more).slice(0, 20)).toEqual(flat(first).slice(0, 20)); }); });