/* 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())); /* Name, colour and mark are chosen while the chapter is being made — they are one decision, and asked one at a time from three menu entries, getting a new chapter to look right took three visits. */ const addChapter = async (name, colour = null, icon = 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(); if (icon) { await p.getByLabel("Search icons").fill(icon); await p.waitForTimeout(400); await p.locator("[data-icon-option]").nth(1).click(); } await p.locator("[data-chapter-save]").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", "invoice"); check( (await p.locator("[data-chapter-dialog]").count()) === 0, "the chapter dialog closes once it has been saved" ); 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" && !!madeWithColour?.icon, "and the colour and mark picked while making it are stored with it", JSON.stringify([madeWithColour?.color, madeWithColour?.icon]) ); 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 row is put into a chapter by dragging it there ── * A chapter is a place, so it takes what is dropped on it rather than making * room beside it. That is also how a chapter gets a line of its own: there is * no second "+ Separator" inside it, because a second way to make the same * thing in a second place is a second thing to keep straight. */ check( (await p.locator("[data-add-chapter-separator]").count()) === 0, "a chapter has no separator button of its own" ); /* Dragged with synthesised pointer events from the grip, the way a finger does it — Playwright drives a mouse, and the branch worth exercising is the one a phone takes. */ /* Held and dropped in two steps, with a wait between: the highlight is React state, and asking for it in the same breath as the move that caused it reads the DOM before it has been drawn. */ /* `where` picks the part of the row held over: a chapter is a place in the body of its header and a neighbour along the bottom edge of it, which is how a row can still be put in the gap under a chapter. */ /* Pressed first, aimed after. Inside a chapter the strips at the two ends of the list only exist while something is being carried — they are targets, not furniture — so the drop point cannot be looked up until the row is in the hand. */ const holdOver = async (dragId, chapterId, where = "body", scope = "") => { const pressed = await p.evaluate( ([from, sc]) => { window.__fire = (el, type, x, y) => el.dispatchEvent( new PointerEvent(type, { pointerType: "touch", pointerId: 1, clientX: x, clientY: y, bubbles: true, cancelable: true, }) ); /* Looked up in one list: the two ends have the same names in every list on screen, so a bare id would find the notebook's when the chapter's was meant. */ const row = document.querySelector(`[data-reorder-id="${from}"][data-reorder-scope="${sc}"]`); const grip = row?.querySelector("[data-reorder-grip]"); if (!grip) return "missing grip"; const s = grip.getBoundingClientRect(); window.__fire(grip, "pointerdown", s.x + s.width / 2, s.y + s.height / 2); return "pressed"; }, [dragId, scope] ); if (pressed !== "pressed") return pressed; await p.waitForTimeout(300); return p.evaluate( ([to, part, sc]) => { const target = document.querySelector(`[data-reorder-id="${to}"][data-reorder-scope="${sc}"]`); if (!target) return "missing target"; const head = target.querySelector("[data-reorder-head]") || target; const box = head.getBoundingClientRect(); window.__drop = { x: box.x + box.width / 2, y: part === "edge" ? box.bottom - 2 : box.y + box.height / 2, }; window.__fire(window, "pointermove", window.__drop.x, window.__drop.y - 6); window.__fire(window, "pointermove", window.__drop.x, window.__drop.y); return "held"; }, [chapterId, where, scope] ); }; const letGo = () => p.evaluate(() => { const d = window.__drop; window.dispatchEvent( new PointerEvent("pointerup", { pointerType: "touch", pointerId: 1, clientX: d.x, clientY: d.y, bubbles: true, cancelable: true, }) ); }); const dragOnto = async (dragId, chapterId) => { const held = await holdOver(dragId, chapterId); if (held !== "held") return held; await p.waitForTimeout(400); const lit = await p.locator("[data-chapter-drop]").count(); await letGo(); await p.waitForTimeout(2200); return lit === 1 ? "done" : "no-highlight"; }; /* Rows are only draggable while rearranging is switched on — the handles are asked for rather than standing in every row. */ if ((await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle")) === "off") { await p.locator("[data-reorder-toggle]").click(); await p.waitForTimeout(700); } check( (await p.locator("[data-reorder-id]").count()) > 0, "there are handles to drag by", JSON.stringify({ toggle: await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle"), grips: await p.locator("[data-reorder-grip]").count(), rows: await p.locator("[data-reorder-id]").count(), }) ); const looseBefore = (await titles()).length; /* Both ends of the drag are read off the rows on screen: what is draggable is what is rendered, and the server's idea of the notebook includes rows this list is not showing. Only the notebook's own rows: an open chapter draws its notes with handles of their own, and those belong to a second list — dragging one of those from here would be measuring one list against the other. */ const notebookRows = () => p .locator("[data-reorder-id]") .evaluateAll((els) => els.filter((e) => (e.dataset.reorderScope ?? "") === "").map((e) => e.dataset.reorderId) ); const rowOrder = await notebookRows(); const shownChapters = await p .locator("[data-chapter-row]") .evaluateAll((els) => els.map((e) => e.getAttribute("data-chapter-row"))); // The two strips at the ends of the list are drop targets, not rows: they // carry a reorder id and nothing else, least of all a grip to drag by. const noteId = rowOrder.find( (id) => id && !id.startsWith("sep-") && id !== "__end__" && id !== "__start__" && !shownChapters.includes(id) ); check( !!noteId && !!shownChapters[0], "there is a note row and a chapter row to drag between", JSON.stringify({ rowOrder, shownChapters }) ); const dragged = await dragOnto(noteId, shownChapters[0]); check(dragged === "done", "the chapter lights up as the row is held over it", dragged); check( (await titles()).length === looseBefore - 1, "a note dragged onto a chapter leaves the notebook's own list", `${looseBefore} → ${(await titles()).length}` ); const filedByDrag = (await api(p, "GET", "/notes")).body.filter((n) => n.chapter_id).length; check(filedByDrag >= 1, "and the server has it filed there", String(filedByDrag)); /* A line is drawn in the notebook and dragged in, which is how a chapter is divided at all. */ const sepRow = (await notebookRows()).find((id) => id && id.startsWith("sep-")); if (sepRow) { await dragOnto(sepRow, shownChapters[0]); await p.waitForTimeout(2500); const inChapterNow = JSON.parse( (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Haus")?.separators || "[]" ); check( inChapterNow.some((sep) => sep.chapterId), "a separator dragged onto a chapter belongs to it", JSON.stringify(inChapterNow.map((sep) => [sep.label, sep.chapterId])) ); } else { check(false, "a separator was there to drag", "none found"); } /* ── a chapter is a neighbour too ── * Taking the whole row as "into" quietly took reordering away: a chapter could * not be moved among its neighbours, and nothing could be put in the gap under * one. The bottom edge of the header is that gap, and asks for a line rather * than lighting the chapter up. */ const looseLeft = (await notebookRows()).find( (id) => id && !id.startsWith("sep-") && id !== "__end__" && id !== "__start__" && !shownChapters.includes(id) ); if (looseLeft) { const held = await holdOver(looseLeft, shownChapters[0], "edge"); await p.waitForTimeout(400); const litAtEdge = await p.locator("[data-chapter-drop]").count(); const lineAtEdge = await p.locator("[data-drop-line]").count(); await letGo(); await p.waitForTimeout(2000); check( held === "held" && litAtEdge === 0 && lineAtEdge === 1, "held over the bottom edge of a chapter, it offers a line instead of taking the row in", JSON.stringify({ held, litAtEdge, lineAtEdge }) ); } else { check(false, "a loose note was left to try the edge with", "none"); } /* ── a chapter is a list of its own, and can be arranged like one ── * Its notes are shown with handles under the same switch as the list beside * them, and dragged among themselves. They could not be dragged at all before: * a chapter was somewhere to put notes and not somewhere to arrange them. */ /* The one that actually holds notes, asked of the server rather than assumed to be the first row: which chapter comes first is an arrangement, and this test is not about that. */ const fullest = (await api(p, "GET", "/notes")).body .filter((n) => n.chapter_id) .reduce((counts, n) => counts.set(n.chapter_id, (counts.get(n.chapter_id) || 0) + 1), new Map()); const busiest = [...fullest.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; const openRow = p.locator(`[data-chapter-row="${busiest}"]`); if ((await p.locator(`[data-chapter-notes="${busiest}"]`).count()) === 0) { await openRow.click(); await p.waitForTimeout(1600); } if ((await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle")) === "off") { await p.locator("[data-reorder-toggle]").click(); await p.waitForTimeout(700); } const inChapterRows = () => p .locator("[data-reorder-id]") .evaluateAll((els) => els .filter((e) => (e.dataset.reorderScope ?? "").startsWith("chapter:")) .map((e) => e.dataset.reorderId) ); const chapterOrder = () => p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.innerText.trim())); const handles = await inChapterRows(); check(handles.length > 0, "the notes inside a chapter wear handles too", JSON.stringify(handles)); // The name of the chapter's list, which both ends of the drag are looked up in. const chapterScope = await p .locator("[data-reorder-id]") .evaluateAll( (els) => els.map((e) => e.dataset.reorderScope ?? "").find((s) => s.startsWith("chapter:")) ?? "" ); const beforeInside = await chapterOrder(); let afterInside = []; if (beforeInside.length >= 2) { const rows = await inChapterRows(); const last = rows.filter((id) => id !== "__start__" && id !== "__end__").pop(); await holdOver(last, "__start__", "body", chapterScope); await p.waitForTimeout(400); await letGo(); await p.waitForTimeout(2500); afterInside = await chapterOrder(); check( afterInside[0] === beforeInside[beforeInside.length - 1] && afterInside.length === beforeInside.length, "and can be dragged among themselves, the strip at the top included", `${beforeInside.join(",")} → ${afterInside.join(",")}` ); // Written, not only drawn: the chapter's order is the notebook's business // like any other, and has to survive the trip. await p.reload({ waitUntil: "networkidle" }); await p.waitForTimeout(3000); await openNotebook(); await openRow.click(); await p.waitForTimeout(1800); check( (await chapterOrder()).join(",") === afterInside.join(","), "and the chapter comes back in the order it was left in", (await chapterOrder()).join(",") ); } else { check(false, "a chapter with two notes to arrange", beforeInside.join(",")); } /* ── a chapter orders its own notes ── * The notebook's setting governs the notebook's own list. A chapter is a place * someone made and filled, so it carries its own — set in the same dialog as * its name, mark and colour, because they are one decision about one thing. */ await openRow.click({ button: "right", force: true }); await p.waitForTimeout(600); await p.getByText("Edit", { exact: true }).click(); await p.waitForTimeout(700); check((await p.locator("[data-chapter-sort]").count()) === 1, "its dialog offers an order of its own"); await p.locator("[data-chapter-sort]").selectOption("title"); await p.locator("[data-chapter-save]").click(); await p.waitForTimeout(2500); const stored = (await api(p, "GET", "/chapters")).body.find((c) => c.id === busiest); check(stored?.sort_mode === "title", "and keeps it, on the server", JSON.stringify(stored?.sort_mode)); const alphabetical = await chapterOrder(); check( alphabetical.join(",") === [...alphabetical].sort((a, b) => a.localeCompare(b, "de")).join(","), "the chapter's notes come out in that order", alphabetical.join(",") ); /* And the things that only mean something in a hand-made order go with it: a line the notes would slide past on every edit, and handles for an order the app is choosing. */ check( (await inChapterRows()).length === 0, "with no handles in it, since that order is not dragged into place", JSON.stringify(await inChapterRows()) ); await openRow.click({ button: "right", force: true }); await p.waitForTimeout(600); await p.getByText("Edit", { exact: true }).click(); await p.waitForTimeout(700); await p.locator("[data-chapter-sort]").selectOption("manual"); await p.locator("[data-chapter-save]").click(); await p.waitForTimeout(2500); check( (await chapterOrder()).join(",") === afterInside.join(","), "and the hand-made one is still there when it comes back", `${afterInside.join(",")} vs ${(await chapterOrder()).join(",")}` ); /* ── 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("Edit", { 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-chapter-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]").count()) === 0 && (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); check((await p.locator("[data-chapter-preview]").count()) === 1, "and the dialog draws the row as it will be"); await p.locator('[data-colour-option="#2da44e"]').click(); await p.locator("[data-chapter-save]").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("Edit", { exact: true }).click(); await p.waitForTimeout(700); /* The whole set, as the chapter dialog offers it — kept to compare against the note dialog further down. The two grids were once two lists, and marks added to one did not exist in the other. */ const allMarks = (page) => page.evaluate(() => [...document.querySelectorAll("[data-icon-option]")].map((el) => el.dataset.iconOption)); const marksInChapterDialog = await allMarks(p); const headings = await p.locator("[data-icon-group]").count(); check(headings >= 3, "the marks are divided under headings", `${headings} groups`); await p.getByLabel("Search icons").fill("invoice"); await p.waitForTimeout(400); // One extra: "no icon" sits in the grid, because taking a mark off is the // same kind of choice as putting one on. const offered = await p.locator("[data-icon-option]").count(); check(offered > 1 && offered < 20, "icons are searched by what they are for", `${offered} shown`); check( (await p.locator("[data-icon-group]").count()) < headings, "and a heading with nothing under it is not shown" ); await p.locator("[data-icon-option]").nth(1).click(); await p.locator("[data-chapter-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); check( JSON.stringify(await allMarks(p)) === JSON.stringify(marksInChapterDialog), "a note is offered exactly the marks a chapter is", `${marksInChapterDialog.length} marks` ); /* A mark nobody thought of: the search box is a text field, which on a phone is where the emoji keyboard lives, so anything typed into it is offered. */ await p.getByLabel("Search icons").fill("🦖"); await p.waitForTimeout(400); check( (await p.locator('[data-icon-option="🦖"]').count()) === 1, "an emoji typed into the search box is offered as a mark" ); await p.getByLabel("Search icons").fill("plainly not a mark"); await p.waitForTimeout(400); check( (await p.locator("[data-icon-option]").count()) === 1, "while words that match nothing offer only the empty mark" ); await p.getByLabel("Search icons").fill("house"); await p.waitForTimeout(400); // Both dialogs show the same grid, and in it "no icon" is the first tile. await p.locator("[data-icon-option]").nth(1).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. * * The one holding notes, named rather than taken as "the first row": a chapter * is a row in the arrangement like any other and can be anywhere in it. */ const holdings = (await api(p, "GET", "/notes")).body.filter((n) => n.chapter_id); const doomed = holdings[0]?.chapter_id; const freedTitles = holdings .filter((n) => n.chapter_id === doomed) .map((n) => n.title) .sort(); await p.locator(`[data-chapter-row="${doomed}"]`).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(); check( freedTitles.every((t) => after.includes(t)), "and every note it held is back in the notebook", `${freedTitles.join(",")} → ${after.join(",")}` ); const freed = (await api(p, "GET", "/notes")).body; check( freed.every((n) => n.chapter_id !== doomed), "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); }