v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
166
src/test/utils/feed/journalLayout.test.ts
Normal file
166
src/test/utils/feed/journalLayout.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
composeJournal,
|
||||
hasQuote,
|
||||
hasThumbnail,
|
||||
type JournalShape,
|
||||
} from "../../../utils/feed/journalLayout.ts";
|
||||
import type { Dump } from "../../../model.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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user