Chapters: folders inside a notebook

A note is in exactly one chapter, or in none — which means the notebook
itself. Chapters sit above the loose notes; tapping one opens its own
list and the menu button becomes the way back out, which on a phone is
the only way, since the list fills the screen.

A chapter is not a wastebasket. Removing one never removes what it held:
the notes come back to the notebook, where they can be seen and filed
again. That is ON DELETE SET NULL, and it is also written out in the
delete route, because it is the promise the whole thing rests on and
should be visible where it happens rather than inferred from a schema.
The dialog says so too, with the number of notes affected, and the suite
removes a chapter holding three notes and asks the server for all three.

Three rules keep the two levels honest. A note filed into a chapter
leaves the notebook's own list, or filing it would appear to do nothing.
A chapter belongs to its notebook, so the server refuses to file a note
into a chapter of a book it is not in, and moving a note to another
notebook clears its chapter. And separators belong to the list they were
drawn in: a separator's order counts the notes before it, which means
nothing across lists.

Two bugs found while building it, both a day old and both affecting the
desktop rather than the phone they were written for:

- A submenu was clipped away. The context menu had been given
  `overflow-y: auto`, which cuts off anything flying out sideways — so
  the notebooks under "Move to..." were invisible and every click on them
  landed on the note list behind. The side was inverted too:
  `right: 100%` places a panel on the left.
- A separator was a one-pixel hairline in the border colour with faint
  grey text on near-black. Present, and on a phone effectively invisible.

And one test that never measured what it claimed. It asked whether a
dragged note had become "the most recently changed" by reading the order
on screen — but three notes made seconds apart share an `updated_at`
second, so that order is a tie broken arbitrarily. It passed on luck for
weeks and fell over on a run a fraction faster. It now compares the
timestamps themselves, before and after the drag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 13:04:18 +02:00
1 parent e037f01dc2
commit 1757ec9af3
15 files changed
+1078 -78

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
/* Chapters — folders inside a notebook.
*
* The claim the feature rests on is the one under test: a note filed into a
* chapter is *in* it and not also loose in the notebook, and removing a chapter
* never removes what it held. Both look fine on screen while being wrong on the
* server, so the server is asked directly.
*/
import {
api,
check,
createNote,
createNotebook,
launch,
report,
setTitle,
withFreshUser,
} from "./lib.mjs";
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "chapters");
const p = session.page;
const titles = () =>
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
const addChapter = async (name) => {
await p.locator("[data-add-chapter]").click();
await p.waitForTimeout(600);
await p.getByLabel("Chapter name").fill(name);
await p.getByRole("button", { name: "Create", exact: true }).click();
await p.waitForTimeout(2000);
};
const moveTo = async (noteTitle, target) => {
await p.locator("[data-note-row]").filter({ hasText: noteTitle }).first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: target }).first().click();
await p.waitForTimeout(2500);
};
await createNotebook(p, "Haus");
await p.locator("button").filter({ hasText: "Haus" }).first().click();
await p.waitForTimeout(700);
for (const name of ["Zählerstand", "Stromrechnung"]) {
await createNote(p);
await setTitle(p, name);
await p.waitForTimeout(600);
}
await p.waitForTimeout(1000);
/* ── making one ── */
check((await p.locator("[data-add-chapter]").count()) === 1, "a notebook can be given a chapter");
await addChapter("Rechnungen");
check((await p.locator("[data-chapter-row]").count()) === 1, "and it appears in the list");
const server = (await api(p, "GET", "/chapters")).body;
check(server.length === 1 && server[0].name === "Rechnungen", "the server has it", JSON.stringify(server));
/* ── filing a note ── */
await moveTo("Stromrechnung", "Rechnungen");
check(
(await titles()).join(",") === "Zählerstand",
"a note filed into a chapter leaves the notebook's own list",
(await titles()).join(", ")
);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
check((await titles()).join(",") === "Stromrechnung", "and is inside the chapter", (await titles()).join(", "));
check((await p.locator("[data-chapter-back]").count()) === 1, "with a way back out");
const filed = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Stromrechnung");
check(!!filed?.chapter_id, "the server filed it too", String(filed?.chapter_id));
/* A note made inside a chapter belongs to it, without being moved. */
await createNote(p);
await setTitle(p, "Wasserrechnung");
await p.waitForTimeout(1500);
const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung");
check(made?.chapter_id === filed?.chapter_id, "a note made inside a chapter is filed there", String(made?.chapter_id));
await p.locator("[data-chapter-back]").click();
await p.waitForTimeout(1200);
check(
(await titles()).join(",") === "Zählerstand",
"and going back shows the notebook again, without the chapter's notes",
(await titles()).join(", ")
);
/* ── a chapter is not a wastebasket ──
* Removing one must never remove what it held: the notes come back to the
* notebook, where they can be seen and filed again. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Remove chapter", { exact: true }).click();
await p.waitForTimeout(700);
check(/stay/i.test(await p.locator("body").innerText()), "removing one says out loud that the notes stay");
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2500);
check((await p.locator("[data-chapter-row]").count()) === 0, "the chapter is gone");
const after = (await titles()).sort().join(",");
check(
after === "Stromrechnung,Wasserrechnung,Zählerstand",
"and every note it held is back in the notebook",
after
);
const freed = (await api(p, "GET", "/notes")).body;
check(
freed.every((n) => !n.chapter_id),
"with nothing on the server still pointing at a chapter that is gone",
JSON.stringify(freed.map((n) => [n.title, n.chapter_id]))
);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}
+29 -7
View File
@@ -134,13 +134,33 @@ try {
);
/* Reordering must not look like editing: a dragged note keeps its place in
"last changed", or the two orders would fight each other. */
await sortBy("updated");
"last changed", or the two orders would fight each other.
Asked of the timestamps rather than of the order on screen. Three notes made
seconds apart routinely share an `updated_at` second — the column has that
resolution — and then "which is newest" is a tie broken arbitrarily. The
check passed on luck for weeks and started failing on a run that was a
fraction faster. */
const stamps = async () =>
Object.fromEntries((await api(p, "GET", "/notes")).body.map((n) => [n.title, n.updated_at]));
await sortBy("manual");
await p.waitForTimeout(600);
const beforeStamps = await stamps();
const order = await titles();
await drag(rows.nth(2), rows.nth(0));
const afterStamps = await stamps();
check(
(await titles())[0] === "Gamma",
"a dragged note did not become the most recently changed one",
(await titles()).join(", ")
Object.keys(beforeStamps).every((t) => beforeStamps[t] === afterStamps[t]),
"dragging a note does not mark it as changed",
`${JSON.stringify(beforeStamps)} → ${JSON.stringify(afterStamps)}`
);
check(
(await titles()).join(",") !== order.join(","),
"though it did move",
`${order.join(",")} → ${(await titles()).join(",")}`
);
await sortBy("manual");
await p.waitForTimeout(800);
@@ -160,6 +180,8 @@ try {
);
/* ── separators ── */
// The arrangement as it stands now: rows have been dragged since `arranged`.
const beforeSeparator = await titles();
await p.locator("[data-add-separator]").click();
await p.waitForTimeout(1800);
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
@@ -194,9 +216,9 @@ try {
const withSeparator = await titles();
check(
withSeparator.join(",") === arranged.join(","),
withSeparator.join(",") === beforeSeparator.join(","),
"and adding one did not disturb the arrangement",
`${arranged.join(",")} vs ${withSeparator.join(",")}`
`${beforeSeparator.join(",")} vs ${withSeparator.join(",")}`
);
// Separators only mean something in a hand-made order; in "newest first" the
+1 -1
View File
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
URL="${INKNOTE_URL:-http://localhost:3000}"
SUITES=("$@")
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline sync)
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters offline sync)
mkdir -p shots
if [ ! -d node_modules ]; then