/* 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); }; /* One press of the button on the row, then choose. It used to be a right-click into a submenu — two steps before the places even appeared, and on a phone a long press followed by a flyout. */ const moveTo = async (noteTitle, target) => { const row = p.locator("[data-note-row]").filter({ hasText: noteTitle }).first(); await row.locator("xpath=preceding-sibling::button[@data-move-note]").first().click(); await p.waitForTimeout(700); await p.locator("[data-move-target]").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 ── */ check( (await p.locator("[data-move-note]").count()) === 2, "every note has a move button, without a right-click", 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(", ") ); 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)); await p.locator("[data-chapter-close]").click(); await p.waitForTimeout(1200); check( (await titles()).join(",") === "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); await p.mouse.click(1380, 900); // outside the panel await p.waitForTimeout(1200); await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1200); 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); check( (await p.locator("[data-reorder-grip]").count()) >= 2, "a chapter has a grip like a note does", String(await p.locator("[data-reorder-grip]").count()) ); /* ── its own colour ── */ await p.locator("[data-chapter-row]").first().click({ button: "right", force: true }); await p.waitForTimeout(600); await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).first().click(); await p.waitForTimeout(500); await p.locator("[data-submenu] button").filter({ hasText: "Green" }).first().click(); await p.waitForTimeout(2000); const coloured = (await api(p, "GET", "/chapters")).body; check( coloured.some((c) => c.color === "#2da44e"), "a chapter can be given its own colour", JSON.stringify(coloured.map((c) => [c.name, c.color])) ); /* ── 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()) === 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 === "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); }