Files
marcandClaude Opus 5 3b7958b713 A chapter with an order of its own
A chapter orders its own notes now, chosen in the same dialog as its name,
mark and colour -- one decision about one thing. The notebook's setting
governs the notebook's own list; a chapter is a place someone made and
filled, so a reference chapter can stay alphabetical while the notebook
around it stays as it was arranged. It starts hand-made, and a chapter put
in one of the other orders loses its lines and its handles exactly as the
notebook does: neither means anything against an order the app is choosing.

A line still would not go under another line, and the comparator was only
half of it. The write was then thrown away by the "did anything change?"
test, which sorted the separators before comparing them -- to ignore the
other chapters' lines riding along in the same field -- and a sorted
comparison cannot see a sequence. Two lines in one gap carry the same
position, so swapping them changes nothing else. It compares this list's own
lines, in order.

An open chapter swelled while rearranging: the strips at the two ends of its
list stood open for the whole of the mode, adding half a finger of empty
space inside every chapter. They are thin now, and only there while
something is actually being carried -- which is also why the suite presses
first and aims afterwards.

Server: sort_mode on chapters, validated against the same five modes as a
notebook's, and set outside the COALESCE so null means the hand-made order
rather than "leave it alone".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:45:05 +02:00

784 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Sorting, importing, and the two dialogs that replaced a guess and a prompt.
*
* The load-bearing claim here is the one about sorting: an arrangement the user
* made by hand has to survive being looked away from. Searching imposes its own
* order, and the danger is that going back to "own order" shows something
* nobody arranged — because the reorder was computed against a filtered list.
* That is checked by arranging, searching, and coming back.
*/
import {
api,
check,
createNote,
createNotebook,
launch,
openAddMenu,
report,
search,
setTitle,
typeInEditor,
withFreshUser,
} from "./lib.mjs";
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "organise");
const p = session.page;
const titles = () =>
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) =>
els.map((e) => e.textContent.trim())
);
const sortBy = async (mode) => {
await p.locator("[data-sort-button]").click();
await p.waitForTimeout(300);
await p.locator(`[data-sort-option="${mode}"]`).click();
await p.waitForTimeout(900);
/* Rows are only draggable while rearranging is switched on — the handles
are asked for rather than standing in every row. The suite drags a lot,
so it turns the mode on with the order it belongs to. */
if (mode === "manual") await startMoving();
};
const startMoving = async () => {
const toggle = p.locator("[data-reorder-toggle]");
if ((await toggle.count()) && (await toggle.getAttribute("data-reorder-toggle")) === "off") {
await toggle.click();
await p.waitForTimeout(500);
}
};
/* ── three notes, made in a known order ── */
await createNotebook(p, "Ordnung");
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(700);
for (const name of ["Alpha", "Beta", "Gamma"]) {
await createNote(p);
await setTitle(p, name);
await typeInEditor(p, `Inhalt von ${name}. `);
await p.waitForTimeout(700);
}
await p.waitForTimeout(1200);
check((await titles()).length === 3, "three notes to arrange", (await titles()).join(", "));
/* ── what a notebook is in before anyone chooses ──
* Its own order. That is where a notebook's arrangement lives: lines show,
* rows can be put where they belong, nothing slides about by itself. It used
* to open in "last changed", so both of those looked broken until the sort
* menu was found — a notebook nobody had arranged still reads newest-first,
* because untouched rows all share position 0. */
check(
(await p.locator("[data-sort-button]").getAttribute("data-sort-button")) === "manual",
"a notebook is in its own order to begin with",
await p.locator("[data-sort-button]").getAttribute("data-sort-button")
);
check(
(await p.locator("[data-add-separator]").count()) === 1,
"so a line can be drawn in it straight away"
);
check((await titles())[0] === "Gamma", "and it reads newest first until something is moved", (await titles()).join(", "));
/* ── the built-in orders ── */
await sortBy("updated");
check((await titles())[0] === "Gamma", "newest first by default", (await titles()).join(", "));
await sortBy("oldest");
check((await titles())[0] === "Alpha", "oldest first reverses it", (await titles()).join(", "));
await sortBy("title");
check((await titles()).join(",") === "Alpha,Beta,Gamma", "A–Z sorts by title", (await titles()).join(", "));
/* ── it is remembered, not just applied ── */
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1500);
check(
(await titles()).join(",") === "Alpha,Beta,Gamma",
"and it survives a reload, because it belongs to the notebook",
(await titles()).join(", ")
);
const notebooks = await api(p, "GET", "/notebooks");
check(notebooks.body[0].sort_mode === "title", "the notebook itself carries the choice", String(notebooks.body[0].sort_mode));
/* ── the user's own order ── */
await sortBy("manual");
const beforeDrag = await titles();
check(beforeDrag.length === 3, "manual order starts from the notes it has", beforeDrag.join(", "));
// Drag the last note onto the first. The moves are stepped: a single jump can
// land before the drag has started, and the drop indicator shifts the rows as
// it appears, so the pointer has to arrive gradually.
const drag = async (fromLoc, toLoc) => {
const from = await fromLoc.boundingBox();
const to = await toLoc.boundingBox();
await p.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await p.mouse.down();
await p.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 12, { steps: 4 });
await p.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 });
await p.waitForTimeout(300);
await p.mouse.move(to.x + to.width / 2, to.y + to.height / 2 + 2, { steps: 4 });
await p.mouse.up();
await p.waitForTimeout(2000);
};
const rows = p.locator("[data-note-row]");
/* A drop lands *under* the row it was released on, and only there — both
directions meant every gap could be aimed at from two sides. */
await drag(rows.nth(2), rows.nth(0));
const under = await titles();
check(
under[1] === beforeDrag[2] && under[0] === beforeDrag[0],
"dropping a note on a row puts it under that row",
`${beforeDrag.join(",")} → ${under.join(",")}`
);
// Which leaves the very top out of reach, so it has a strip of its own.
await drag(rows.nth(2), p.locator("[data-drop-start]"));
const arranged = await titles();
check(
arranged[0] === under[2],
"and the strip above the first row puts it at the very top",
`${under.join(",")} → ${arranged.join(",")}`
);
/* The search box is simply there, with nothing to open first. A magnifying
* glass costs a click and having to remember that searching is possible; the
* box is one line and says for itself what it does. */
check(
(await p.locator('input[aria-label="Search in this notebook"]').count()) === 1,
"the search box is there without opening anything"
);
check(
(await p.locator("[data-search-toggle]").count()) === 0,
"and the button that used to open it is gone"
);
/* The point of the whole feature. Measured against the arrangement as it
stands now: the rows have been moved about since it was first made. */
const arrangedNow = await titles();
await search(p, "Beta");
const searched = await titles();
check(searched.length < 3, "searching narrows the list", searched.join(", "));
await search(p, "");
check(
(await titles()).join(",") === arrangedNow.join(","),
"and clearing the search shows exactly the arrangement again",
`${arrangedNow.join(",")} vs ${(await titles()).join(",")}`
);
/* Reordering must not look like editing: a dragged note keeps its place in
"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(
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);
/* ── the drop indicator ── */
// It used to be a border on the row, set beside a `border` shorthand — which
// left a permanent 2px black line behind once it was cleared.
const strayLines = await p.locator("[data-drop-line]").count();
check(strayLines === 0, "no drop indicator is showing while nothing is being dragged", String(strayLines));
const borders = await p
.locator("[data-note-row]")
.evaluateAll((rows) => rows.map((r) => getComputedStyle(r).borderTopWidth));
check(
borders.every((b) => b === "1px"),
"and no row has grown a stray top border",
borders.join(", ")
);
/* ── 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");
/* Everything about a line is set in one dialog reached by right-click, like
every other row here is acted on. A left-click on the label used to start an
edit in place, so the row could not be touched without falling into one. */
check(
(await p.getByLabel("Separator label").count()) === 0,
"clicking a separator does not drop straight into renaming"
);
await p.locator("[data-separator]").click({ button: "right" });
await p.waitForTimeout(600);
check(
!/Colour|Line/.test(await p.locator("[data-context-menu], body").first().innerText()) ||
(await p.getByText("Edit", { exact: true }).count()) === 1,
"its menu offers one Edit rather than one entry per property"
);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
/* The dialog draws the line itself, so "dotted" is something you see rather
than a word you have to imagine. */
check((await p.locator("[data-separator-preview]").count()) === 1, "the dialog shows the line as it will be");
check(
(await p.locator("[data-line-option]").count()) === 5,
"with every shape drawn beside its name",
String(await p.locator("[data-line-option]").count())
);
const samples = await p
.locator("[data-line-option] [data-separator-rule]")
.evaluateAll((els) => els.map((el) => getComputedStyle(el).borderTopStyle || "solid"));
check(
new Set(samples).size > 1,
"and the samples really differ from one another",
samples.join(", ")
);
await p.getByLabel("Separator name").fill("Wichtig");
await p.locator('[data-line-option="dashed"]').click();
await p.locator('[data-colour-option="#1f9c96"]').click();
await p.locator("[data-separator-save]").click();
await p.waitForTimeout(2000);
check(
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
"and it carries the name it was given",
await p.locator("[data-separator]").innerText()
);
// It belongs to the notebook, so it has to survive a reload like the order does.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1800);
check(
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
"a separator survives a reload, because it belongs to the notebook"
);
const withSeparator = await titles();
check(
withSeparator.join(",") === beforeSeparator.join(","),
"and adding one did not disturb the arrangement",
`${beforeSeparator.join(",")} vs ${withSeparator.join(",")}`
);
// Separators only mean something in a hand-made order; in "newest first" the
// notes would slide past the line on every edit.
await sortBy("updated");
check((await p.locator("[data-separator]").count()) === 0, "it is hidden in the other orders");
/* And the button that draws one goes with it. Standing in an order that
cannot show a line, pressing it produced nothing at all — which reads
exactly like a button that is broken. */
check(
(await p.locator("[data-add-separator]").count()) === 0,
"and so is the button that draws one, which could only do nothing there"
);
await sortBy("manual");
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
/* ── one line under another ──
* Two lines in the same gap carry the same position, so swapping them changes
* nothing but their sequence. The write was computed correctly and then thrown
* away by a "did anything change?" test that sorted before comparing — and a
* sorted comparison cannot see a sequence. The line sprang back every time. */
await p.locator("[data-add-separator]").click();
await p.waitForTimeout(1800);
const lines = () =>
p.locator("[data-separator]").evaluateAll((els) => els.map((e) => e.innerText.trim()));
check((await p.locator("[data-separator]").count()) === 2, "a second line can be drawn");
// First bring them together, so what follows is the case that was broken:
// two lines in one gap, where only their sequence tells them apart.
await drag(p.locator("[data-separator]").first(), p.locator("[data-separator]").nth(1));
const lineOrder = await lines();
await drag(p.locator("[data-separator]").first(), p.locator("[data-separator]").nth(1));
const swapped = await lines();
check(
swapped[0] === lineOrder[1] && swapped[1] === lineOrder[0],
"a line dropped on another in the same gap goes under it",
`${lineOrder.join(" | ")} → ${swapped.join(" | ")}`
);
// And it was written, not only drawn.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1800);
check(
(await lines()).join(" | ") === swapped.join(" | "),
"and stays under it after a reload",
(await lines()).join(" | ")
);
// Back to one line, so what follows reads the notebook it expects.
await p.locator("[data-separator]").first().click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Remove separator").click();
await p.waitForTimeout(600);
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 1, "and one of them can be taken away again");
/* ── a new note goes on top, and takes nothing with it ──
*
* It used to be given no position at all and left to the tie-break, which put
* it near the top and moved everything else: a line's position counts the rows
* above it, so a row appearing without a renumber left every line one row too
* high, and the arrangement came apart a little more with each new note. */
const layout = () =>
p
.locator("[data-note-row], [data-separator]")
.evaluateAll((els) =>
els.map((el) =>
el.hasAttribute("data-separator") ? `── ${el.innerText.trim()}` : el.innerText.split("\n")[0].trim()
)
);
const beforeNew = await layout();
await createNote(p);
await p.waitForTimeout(2500);
const afterNew = await layout();
check(
afterNew[0] === "Untitled Note",
"a new note is made at the very top, above the lines as well",
afterNew.join(" | ")
);
check(
afterNew.slice(1).join(" | ") === beforeNew.join(" | "),
"and every other row stayed exactly where it was",
`${beforeNew.join(" | ")} → ${afterNew.join(" | ")}`
);
// And it survives the trip to the server: the positions were written, not
// only drawn.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1800);
check(
(await layout()).join(" | ") === afterNew.join(" | "),
"and the arrangement comes back the same after a reload",
(await layout()).join(" | ")
);
/* ── and taking one away is the same promise, in reverse ──
* A line's position counts the rows above it, so deleting a note above one
* left the line counting rows that were no longer there: everything under it
* appeared to jump up past the line, which is what made deleting feel as
* though it scrambled the notebook. */
const beforeDelete = await layout();
await p.locator("[data-note-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-context-menu]").getByText("Move to Trash", { exact: true }).click();
await p.waitForTimeout(700);
await p.locator("[data-confirm-dialog]").getByRole("button", { name: "Move to Trash", exact: true }).click();
await p.waitForTimeout(2500);
const arrangedRows = await layout();
check(
arrangedRows.join(" | ") === beforeDelete.slice(1).join(" | "),
"deleting a note takes out only that note — the lines keep the rows they were drawn above",
`${beforeDelete.join(" | ")} → ${arrangedRows.join(" | ")}`
);
/* ── the choices reach the server, and the screen ──
* A notebook divided into three parts is easier to read at a glance when the
* lines differ; grey-on-grey they all look like the same line repeated. */
const stored = (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Ordnung");
const seps = JSON.parse(stored?.separators || "[]");
check(
seps[0]?.color === "#1f9c96" && seps[0]?.style === "dashed",
"a separator keeps its own colour and line style, on the server",
JSON.stringify(seps)
);
// Drawn, not just stored: the rule is a dashed border rather than a filled bar.
const drawn = await p.locator("[data-separator-rule]").first().evaluate((el) => {
const cs = getComputedStyle(el);
return { style: cs.borderTopStyle, colour: cs.borderTopColor };
});
check(
drawn.style === "dashed" && /31,\s*156,\s*150/.test(drawn.colour),
"and it is drawn that way, not only recorded",
JSON.stringify(drawn)
);
/* Removing is a right-click behind a confirmation. The × used to sit a few
pixels from the label you click to rename, so tidying the list and
destroying part of it were neighbouring gestures. */
check(
(await p.locator('[data-separator] button[aria-label="Remove separator"]').count()) === 0,
"there is no delete button sitting next to the rename target"
);
await p.locator("[data-separator]").click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Remove separator").click();
await p.waitForTimeout(600);
check(
/notes stay/i.test(await p.locator("body").innerText()),
"right-clicking it asks first, and says the notes are not affected"
);
await p.getByRole("button", { name: "Remove" }).click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 0, "and then it is gone");
check(
(await p.locator("[data-confirm-dialog]").count()) === 0,
"and the question is gone with it, not left standing over the app"
);
check(
(await titles()).join(",") === arrangedRows.filter((row) => !row.startsWith("──")).join(","),
"with every note still there",
(await titles()).join(", ")
);
/* ── exporting a note and importing it back ── */
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
await p.waitForTimeout(1200);
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.press("Enter");
// Through the toolbar rather than by typing "- [x] ": inside a bullet item
// that sequence stays literal text, which is a quirk of the editor's input
// rules and not what this suite is about.
await p.getByTitle("Task list").click();
await p.waitForTimeout(400);
await p.keyboard.type("erledigt");
await p.waitForTimeout(600);
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
await p.waitForTimeout(2000);
const exported = await p.evaluate(async () => {
const captured = [];
const realCreate = URL.createObjectURL.bind(URL);
URL.createObjectURL = (blob) => {
captured.push(blob);
return realCreate(blob);
};
window.__captured = captured;
HTMLAnchorElement.prototype.click = function () {};
return true;
});
check(exported, "downloads can be captured");
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(600);
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
await p.waitForTimeout(2500);
const markdown = await p.evaluate(async () => {
const blob = window.__captured?.[0];
return blob ? await blob.text() : "";
});
check(markdown.includes("- [x] erledigt"), "the export carries the ticked box", JSON.stringify(markdown.slice(-60)));
check(markdown.startsWith("# Beta"), "and the title as a heading");
await p.locator("[data-export-dialog] button").filter({ hasText: /Close|Done/ }).first().click().catch(() => {});
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* Import it back as a second note. A real file chooser cannot be driven, so
the file is handed to the input directly — the code under test is what
happens after a file is chosen. */
const before = (await api(p, "GET", "/notes")).body.length;
await p.evaluate((md) => {
const input = document.createElement("input");
input.type = "file";
document.body.appendChild(input);
const dt = new DataTransfer();
dt.items.add(new File([md], "Beta.md", { type: "text/markdown" }));
input.files = dt.files;
window.__importInput = input;
}, markdown);
const chooser = p.waitForEvent("filechooser");
await openAddMenu(p);
await p.locator('[data-add-option="Import Markdown"]').click();
const fc = await chooser;
await fc.setFiles({ name: "Beta.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) });
await p.waitForTimeout(4000);
const importedText = await p.locator("body").innerText();
check(/imported/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " "));
await p.getByRole("button", { name: /Done/ }).click();
await p.waitForTimeout(2500);
const after = (await api(p, "GET", "/notes")).body.length;
check(after === before + 1, "one note more than before", `${before} → ${after}`);
const importedNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Beta" && n.id);
check(!!importedNote, "the imported note kept its title from the heading");
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).last().click();
await p.waitForTimeout(1800);
const boxes = await p.locator('.ProseMirror input[type="checkbox"]').count();
check(boxes > 0, "the task box came back as a real checkbox, not as text", String(boxes));
const checked = await p.locator('.ProseMirror input[type="checkbox"]:checked').count();
check(checked > 0, "and it came back ticked", String(checked));
/* ── the link dialog, which used to be a browser prompt ── */
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.type("Zielwort");
await p.keyboard.down("Shift");
for (let i = 0; i < 8; i++) await p.keyboard.press("ArrowLeft");
await p.keyboard.up("Shift");
await p.getByTitle("Web link").click();
await p.waitForTimeout(600);
const field = p.getByLabel("Link address");
check(await field.isVisible(), "the link dialog is a real dialog in the page");
await field.fill("javascript:alert(1)");
await p.getByRole("button", { name: /^Insert$/ }).click();
await p.waitForTimeout(400);
check(
await field.isVisible(),
"a javascript: address is refused rather than inserted"
);
await field.fill("example.com");
await p.getByRole("button", { name: /^Insert$/ }).click();
await p.waitForTimeout(1800);
const href = await p.locator(".ProseMirror a[href]").first().getAttribute("href");
check(href === "https://example.com", "a bare domain is taken as https", String(href));
/* ── which notebook, when there is no notebook ── */
await createNotebook(p, "Zweites Buch");
await p.waitForTimeout(800);
await p.locator("button").filter({ hasText: "All Notes" }).first().click();
await p.waitForTimeout(900);
// "All Notes" is a question asked of every notebook, not a place — there is
// nothing for a hand-made arrangement to belong to.
await p.locator("[data-sort-button]").click();
await p.waitForTimeout(400);
check(
(await p.locator('[data-sort-option="manual"]').count()) === 0,
"no hand-made order is offered outside a notebook"
);
check(
(await p.locator('[data-sort-option="updated"]').count()) === 1,
"but the derived orders still are"
);
await p.keyboard.press("Escape");
await p.locator("h2").first().click();
await p.waitForTimeout(500);
await openAddMenu(p);
await p.locator('[data-add-option="New note"]').click();
await p.waitForTimeout(900);
const books0 = (await api(p, "GET", "/notebooks")).body;
const picker = p.locator("[data-notebook-option]");
check((await picker.count()) === 2, "creating a note from All Notes asks where it should go", String(await picker.count()));
// By name, not by position: two notebooks created without dragging share a
// sort order, so "the last one" is not a stable way to mean a particular book.
const target = books0.find((b) => b.name === "Zweites Buch");
await p.locator(`[data-notebook-option="${target.id}"]`).click();
await p.getByRole("button", { name: /Continue/ }).click();
await p.waitForTimeout(2500);
// By title, not by position: the list is ordered by a timestamp with one
// second of resolution, and several notes here share that second.
const fresh = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Untitled Note");
check(
fresh?.notebook_id === target.id,
"and it lands in the notebook that was chosen, not the first one",
`${fresh?.notebook_id} vs ${target.id}`
);
/* ── choosing a cover ──
* Covers are patterns as well as colours now, and there are enough of them
* that the grid has to scroll: without that the dialog runs past the bottom of
* a laptop screen and takes the Create button with it. */
await p.setViewportSize({ width: 1440, height: 800 });
await p.locator("button").filter({ hasText: "Dashboard" }).first().click();
await p.waitForTimeout(600);
await p.getByRole("button", { name: "Add Notebook" }).click();
await p.waitForTimeout(700);
/* Colour and pattern are two choices, not one long list of combinations. */
check((await p.locator("[data-cover-option]").count()) >= 15, "a colour to choose", `${await p.locator("[data-cover-option]").count()}`);
check((await p.locator("[data-pattern-option]").count()) > 5, "and a pattern to lay over it", `${await p.locator("[data-pattern-option]").count()}`);
check(
await p.getByRole("button", { name: "Create", exact: true }).isVisible(),
"and the dialog still fits a laptop screen, Create and all"
);
// The two together make the cover, and both reach the server.
await p.getByPlaceholder("Notebook name...").fill("Kariert");
await p.locator('[data-cover-option="6"]').click();
await p.locator('[data-pattern-option="2"]').click();
await p.waitForTimeout(300);
await p.getByRole("button", { name: "Create", exact: true }).click();
await p.waitForTimeout(2000);
const books = (await api(p, "GET", "/notebooks")).body;
const made = books.find((b) => b.name === "Kariert");
check(
made?.cover_index === 6 && made?.cover_pattern === 2,
"the colour and the pattern are both stored",
`cover ${made?.cover_index}, pattern ${made?.cover_pattern}`
);
/* ── moving a row with a finger ──
* Last in the suite, because moving rows about would pull the ground from
* under every check above that names an order.
*
* The list used the HTML5 drag API, which mobile browsers never fire from
* touch: on a phone the arrangement could not be changed at all, and nothing
* on screen said why. A finger drags from the grip — pressing the row itself
* has to go on scrolling, and holding it has to go on opening the menu.
*
* The pointer events below are synthesised, because Playwright drives a mouse.
* They still exercise the branch that was missing: pointerType "touch".
*/
const touchDrag = async (fromIndex, toIndex) =>
p.evaluate(
([from, toIdx]) => {
const grip = document.querySelectorAll("[data-reorder-grip]")[from];
const to = document.querySelectorAll("[data-note-row]")[toIdx];
if (!grip || !to) return "missing";
const box = to.getBoundingClientRect();
const fire = (target, type, x, y) =>
target.dispatchEvent(
new PointerEvent(type, {
pointerType: "touch",
pointerId: 1,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
})
);
const start = grip.getBoundingClientRect();
fire(grip, "pointerdown", start.x + start.width / 2, start.y + start.height / 2);
fire(window, "pointermove", box.x + box.width / 2, box.y + box.height / 2 - 20);
fire(window, "pointermove", box.x + box.width / 2, box.y + box.height / 2);
fire(window, "pointerup", box.x + box.width / 2, box.y + box.height / 2);
return "done";
},
[fromIndex, toIndex]
);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1200);
await sortBy("manual");
const beforeTouch = await titles();
// The last row onto the first, so the order cannot come out the same by luck.
const rowCount = (await titles()).length;
check(
(await p.locator("[data-reorder-grip]").count()) >= rowCount,
"with rearranging switched on, every row has a grip to take hold of"
);
await touchDrag(rowCount - 1, 0);
await p.waitForTimeout(2000);
check(
(await titles()).join(",") !== beforeTouch.join(","),
"a row can be moved with a finger, from its grip",
`${beforeTouch.join(",")} → ${(await titles()).join(",")}`
);
// And pressing the row itself must still scroll rather than drag.
const beforeBody = await titles();
await p.evaluate(() => {
const row = document.querySelector("[data-note-row]");
const b = row.getBoundingClientRect();
const fire = (t, type, y) =>
t.dispatchEvent(
new PointerEvent(type, { pointerType: "touch", pointerId: 2, clientX: b.x + 40, clientY: y, bubbles: true, cancelable: true })
);
fire(row, "pointerdown", b.y + b.height / 2);
fire(window, "pointermove", b.y + 260);
fire(window, "pointerup", b.y + 260);
});
await p.waitForTimeout(1200);
check(
(await titles()).join(",") === beforeBody.join(","),
"and pressing the row itself does not drag it — that gesture still scrolls",
`${beforeBody.join(",")} → ${(await titles()).join(",")}`
);
/* ── "Move to..." on a phone ──
* The submenu was pinned to `left: 100%` and opened only on hover. On a narrow
* screen the parent menu already sits against the right edge, so the list of
* notebooks appeared off the side of the display — and with no hover there was
* no way to open it in the first place. */
await createNotebook(p, "Anderes Buch");
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(900);
await p.setViewportSize({ width: 420, height: 780 });
await p.waitForTimeout(900);
// Below the breakpoint the sidebar stays open as a full-screen overlay whose
// backdrop swallows every click. Dismiss it by tapping beside the panel.
await p.mouse.click(408, 420);
await p.waitForTimeout(700);
await p.locator("[data-note-row]").first().click({ button: "right" });
await p.waitForTimeout(600);
const menu = p.locator("[data-context-menu]");
check((await menu.count()) === 1, "the menu opens on a phone-sized screen");
const inside = async (loc) => {
const b = await loc.boundingBox();
return !!b && b.x >= -1 && b.x + b.width <= 421;
};
check(await inside(menu), "and stays on the screen", JSON.stringify(await menu.boundingBox()));
await p.locator("[data-submenu-toggle]").first().click();
await p.waitForTimeout(500);
const sub = p.locator("[data-submenu]");
check((await sub.count()) === 1, "tapping 'Move to...' opens it — there is no hover on a phone");
check(
await inside(sub),
"and the notebooks it offers are on the screen, not past its edge",
JSON.stringify(await sub.boundingBox())
);
/* It holds two different kinds of place, so it says which is which. The
headings are deliberately not shaped like entries: one built like a menu row
is a button that does nothing when pressed. */
const headings = await sub.locator("[data-menu-heading]").allInnerTexts();
check(
headings.some((h) => /other notebooks/i.test(h)),
"the list is divided under headings rather than run together",
headings.join(" / ")
);
check(
(await sub.locator("[data-menu-heading]").evaluateAll((els) => els.every((el) => el.tagName !== "BUTTON"))),
"and a heading is not a button"
);
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
await p.setViewportSize({ width: 1400, height: 950 });
await p.waitForTimeout(900);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}