/* 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())); /* The colour is chosen while the chapter is being made. It is what tells one chapter from the next, and having to right-click a fresh one to say which it is meant two visits for a single decision. */ const addChapter = async (name, colour = null) => { await p.locator("[data-add-chapter]").click(); await p.waitForTimeout(600); await p.getByLabel("Chapter name").fill(name); if (colour) await p.locator(`[data-colour-option="${colour}"]`).click(); await p.getByRole("button", { name: "Create", exact: true }).click(); await p.waitForTimeout(2000); }; /* Filing a note is one of the things a row can have done to it, and they all live in the row's own menu. A button for this one on every row put a second icon beside every title, for something done once in a note's life. */ const moveTo = async (noteTitle, target) => { const row = p.locator("[data-note-row]").filter({ hasText: noteTitle }).first(); await row.click({ button: "right", force: true }); await p.waitForTimeout(600); await p.locator("[data-submenu-toggle]").filter({ hasText: "Move to" }).first().click(); await p.waitForTimeout(500); await p.locator("[data-submenu] button").filter({ hasText: target }).first().click(); await p.waitForTimeout(2500); }; const openNotebook = async () => { await p.locator("button").filter({ hasText: "Haus" }).first().click(); await p.waitForTimeout(1200); }; 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)); /* ── in an empty notebook too ── * "Nothing here" was asked of the notes alone, so a notebook whose only * contents were chapters showed the empty-list panel with the chapters hidden * behind it: making a chapter in a fresh notebook appeared to do nothing. */ await createNotebook(p, "Leeres Buch"); await p.locator("button").filter({ hasText: "Leeres Buch" }).first().click(); await p.waitForTimeout(1000); await addChapter("Nur ein Kapitel", "#a371f7"); check( (await p.locator("[data-chapter-row]").count()) === 1, "a chapter shows in a notebook that has no notes at all", String(await p.locator("[data-chapter-row]").count()) ); const madeWithColour = (await api(p, "GET", "/chapters")).body.find((c) => c.name === "Nur ein Kapitel"); check( madeWithColour?.color === "#a371f7", "and the colour picked while making it is stored with it", String(madeWithColour?.color) ); check( (await p.locator("[data-no-loose-notes]").count()) === 1, "and the list says why it is otherwise empty rather than looking broken" ); await openNotebook(); /* ── filing a note ── */ check( (await p.locator("[data-move-note]").count()) === 0, "filing is in the row's menu, not a button standing in every row", String(await p.locator("[data-move-note]").count()) ); 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(", ") ); /* ── and stays filed ── * It did not. The server had it right, but the sync that follows replaced * every local note with one belonging to no chapter — so about five seconds * after being filed, the note walked back out into the notebook by itself. * A reload is the same journey: the mirror is rebuilt from the server. */ await p.reload(); await p.waitForTimeout(4000); await openNotebook(); check( (await titles()).join(",") === "Zählerstand", "and is still in it after the next sync, not back in the notebook", (await titles()).join(", ") ); await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1200); const inChapter = () => p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.textContent.trim())); check((await inChapter()).join(",").includes("Stromrechnung"), "and is inside the chapter", (await inChapter()).join(", ")); check((await p.locator("[data-chapter-close]").count()) === 1, "with a way to close it again"); 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 while a chapter is open 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 while it is open is filed there", String(made?.chapter_id)); /* ── which of the two am I in ── * Beside the list, a chapter does not replace the notebook: both are on * screen at once. A new note used to go into the chapter however far away the * user had since clicked, and nothing said which of the two was meant. */ check( (await p.locator('[data-chapter-current="on"]').count()) === 1, "opening a chapter makes it the place being written into, and shows it" ); // Into the empty space under the notebook's own notes. await p.locator("[data-note-list-body]").click({ position: { x: 140, y: 330 } }); await p.waitForTimeout(700); check( (await p.locator('[data-list-current="on"]').count()) === 1 && (await p.locator('[data-chapter-current="off"]').count()) === 1, "clicking into the notebook puts you back in the notebook, visibly" ); await createNote(p); await setTitle(p, "Gartenzaun"); await p.waitForTimeout(1500); const loose = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Gartenzaun"); check(!loose?.chapter_id, "and a note made then is the notebook's, not the chapter's", String(loose?.chapter_id)); check( (await titles()).sort().join(",") === "Gartenzaun,Zählerstand", "so it appears in the notebook's own list", (await titles()).join(", ") ); await p.locator("[data-chapter-close]").click(); await p.waitForTimeout(1200); check( (await titles()).sort().join(",") === "Gartenzaun,Zählerstand", "and the notebook's own list never held the chapter's notes", (await titles()).join(", ") ); /* ── the two ways a chapter opens ── * A further column beside the list, the way OneNote lays out its pages, or * unfolded under its own row. Chosen on the account, so the answer follows to * the phone — where there is room for neither and the chapter fills the * screen instead. */ await addChapter("Verträge"); await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1200); check((await p.locator("[data-chapter-column]").count()) === 1, "a chapter opens beside the list by default"); check( (await p.locator("[data-chapter-row]").count()) === 2, "and the notebook's own list keeps every chapter row", String(await p.locator("[data-chapter-row]").count()) ); await p.getByTitle("Account").click(); await p.waitForTimeout(800); await p.locator('[data-chapter-view="inline"]').click(); await p.waitForTimeout(1500); /* Escape closes it. Only a click beside the panel did, which on one this wide means aiming at a sliver of page. */ await p.keyboard.press("Escape"); await p.waitForTimeout(1200); check( (await p.locator("[data-chapter-view]").count()) === 0, "the account panel closes with Escape, not only by clicking beside it" ); // No second click: the chapter that was open stays open, and changing the // setting changes only where it is shown. check((await p.locator("[data-chapter-notes]").count()) === 1, "the other setting unfolds it under its row"); check((await p.locator("[data-chapter-column]").count()) === 0, "and there is no column then"); const me = await api(p, "GET", "/auth/me"); check(me.body?.preferences?.chapterView === "inline", "the choice is on the account, not in this browser"); /* ── a chapter is a row like any other ── * Same level as notes and separators, so it can be dragged into place in the * arrangement rather than being stuck above everything. */ await p.locator("[data-sort-button]").click(); await p.waitForTimeout(300); await p.locator('[data-sort-option="manual"]').click(); await p.waitForTimeout(1200); /* Rearranging is asked for, and until it is, no row wears a handle. * Six dots stood beside every title at all times for something done once in a * while, and a press meant to open a note could carry it off instead. */ check( (await p.locator("[data-reorder-grip]").count()) === 0, "no row wears a handle until rearranging is switched on", String(await p.locator("[data-reorder-grip]").count()) ); await p.locator("[data-reorder-toggle]").click(); await p.waitForTimeout(600); check( (await p.locator("[data-reorder-grip]").count()) >= 2, "switched on, a chapter has a grip like a note does", String(await p.locator("[data-reorder-grip]").count()) ); await p.locator("[data-reorder-toggle]").click(); await p.waitForTimeout(600); check((await p.locator("[data-reorder-grip]").count()) === 0, "and they go away again when it is done"); /* ── a separator drawn in the notebook belongs to the notebook ── * Opening a chapter used to take the notebook's own separators off the screen * with it: the list still showed the notebook, but everything around it had * quietly switched to the chapter. A line vanished for no reason anyone could * see, and dragging in that list wrote nothing at all. */ await p.locator("[data-add-separator]").click(); await p.waitForTimeout(2000); check((await p.locator("[data-separator]").count()) === 1, "a separator can be drawn in the notebook"); await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1500); check( (await p.locator("[data-separator]").count()) === 1, "and it is still there once a chapter is opened", String(await p.locator("[data-separator]").count()) ); await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1200); /* ── a chapter can be divided too ── * It is a list like the notebook is. Its lines are its own: each carries the * list it was drawn in, so the notebook's stay outside and these stay here. */ /* Pressing a chapter row toggles it, so "make sure it is open" cannot be a single click — whether it opens or closes depends on what was open before. */ const openChapterBlock = async () => { for (let i = 0; i < 3; i++) { if (await p.locator("[data-chapter-notes]").count()) return true; await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1100); } return (await p.locator("[data-chapter-notes]").count()) > 0; }; check(await openChapterBlock(), "a chapter unfolds under its row"); const insideBefore = await p.locator("[data-chapter-notes] [data-separator]").count(); await p.locator("[data-add-chapter-separator]").click(); await p.waitForTimeout(2200); check( (await p.locator("[data-chapter-notes] [data-separator]").count()) === insideBefore + 1, "a line can be drawn inside a chapter", String(await p.locator("[data-chapter-notes] [data-separator]").count()) ); const drawnIn = JSON.parse( (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Haus")?.separators || "[]" ); check( drawnIn.some((sep) => sep.chapterId) && drawnIn.some((sep) => !sep.chapterId), "and it belongs to the chapter, while the notebook keeps its own", JSON.stringify(drawnIn.map((sep) => [sep.label, sep.chapterId])) ); await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1000); /* ── a dialog does not close because a selection ended outside it ── * Select a word in the field, drag a little past the edge, let go: the click * lands on the backdrop — the nearest ancestor of press and release — and the * dialog used to take that for "clicked beside me" and vanish mid-edit. */ await p.locator("[data-chapter-row]").first().click({ button: "right", force: true }); await p.waitForTimeout(600); await p.getByText("Rename or recolour", { exact: true }).click(); await p.waitForTimeout(700); const field = p.getByLabel("Chapter name"); const box = await field.boundingBox(); await p.mouse.move(box.x + box.width - 12, box.y + box.height / 2); await p.mouse.down(); await p.mouse.move(box.x - 220, box.y + box.height / 2, { steps: 12 }); await p.mouse.up(); await p.waitForTimeout(800); check( (await p.locator("[data-name-dialog]").count()) === 1, "a selection dragged past the edge does not close the dialog" ); await p.keyboard.press("Escape"); await p.waitForTimeout(600); /* ── its own colour, afterwards ── * The same dialog that named it. There used to be a "Colour" submenu beside * this entry as well: two things in the menu, one of them offering half of * what the other did. */ await p.locator("[data-chapter-row]").first().click({ button: "right", force: true }); await p.waitForTimeout(600); check( (await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).count()) === 0, "there is one entry for a chapter's name and colour, not two" ); await p.getByText("Rename or recolour", { exact: true }).click(); await p.waitForTimeout(700); await p.locator('[data-colour-option="#2da44e"]').click(); await p.getByRole("button", { name: "Save", exact: true }).click(); await p.waitForTimeout(2000); const coloured = (await api(p, "GET", "/chapters")).body; check( coloured.some((c) => c.color === "#2da44e"), "a chapter can be recoloured after the fact", JSON.stringify(coloured.map((c) => [c.name, c.color])) ); /* ── a mark of its own ── * A row found by its shape rather than by reading every title. The same glyph * shows in the list and in the editor, so a note is recognised by one sign in * both places. */ await p.locator("[data-chapter-row]").first().click({ button: "right", force: true }); await p.waitForTimeout(600); await p.getByText("Icon", { exact: true }).click(); await p.waitForTimeout(700); await p.getByLabel("Search icons").fill("invoice"); await p.waitForTimeout(400); const offered = await p.locator("[data-icon-option]").count(); check(offered > 0 && offered < 20, "icons are searched by what they are for", `${offered} shown`); await p.locator("[data-icon-option]").first().click(); await p.locator("[data-icon-save]").click(); await p.waitForTimeout(2200); const marked = (await api(p, "GET", "/chapters")).body.find((c) => c.icon); check(!!marked?.icon, "a chapter keeps the mark it was given", JSON.stringify([marked?.name, marked?.icon])); check( (await p.locator("[data-chapter-mark]").first().innerText()).trim() === marked.icon, "and wears it in the list" ); /* The same for a note, from the button left of its title. */ await p.locator("[data-note-row]").first().click(); await p.waitForTimeout(1200); await p.locator("[data-note-icon]").click(); await p.waitForTimeout(700); await p.getByLabel("Search icons").fill("house"); await p.waitForTimeout(400); await p.locator("[data-icon-option]").first().click(); await p.locator('[data-icon-colour="#2da44e"]').click(); await p.locator("[data-icon-save]").click(); await p.waitForTimeout(2500); const iconed = (await api(p, "GET", "/notes")).body.find((n) => n.icon); check(!!iconed?.icon && iconed?.icon_color === "#2da44e", "a note keeps its mark and its colour", JSON.stringify([iconed?.title, iconed?.icon, iconed?.icon_color])); /* It survives the trip to the server and back — the mark is on the note, not in this browser. */ await p.reload(); await p.waitForTimeout(4000); await openNotebook(); check( (await p.locator("[data-note-icon]").getAttribute("data-note-icon")) === iconed.icon || (await p.locator("[data-note-mark]").first().innerText()).trim() === iconed.icon, "and still wears it after a reload" ); /* ── 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); /* The question has to be gone afterwards. * * It was not: removing a chapter did its work and left the prompt standing * over the app, with nothing but a reload to get out of. Every check below * still passed — they read the list straight through the overlay, which is * there in the DOM the whole time. */ check( (await p.locator("[data-confirm-dialog]").count()) === 0, "confirming closes the question rather than leaving it over the app" ); check( (await p.locator("[data-chapter-row]").count()) === 1, "the chapter is gone, and only that one", String(await p.locator("[data-chapter-row]").count()) ); const after = (await titles()).sort().join(","); check( after === "Gartenzaun,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); }