All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
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<JournalShape, [number, number]> = {
|
|
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));
|
|
});
|
|
});
|