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:
khannurien
2026-09-27 19:44:14 +00:00
parent 07d0344be7
commit 2b6e68391b
242 changed files with 4390 additions and 4229 deletions

View 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));
});
});