/* 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); /* ── 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 === "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); }