Three things that all came from chapters being thought through too late. A note put into a chapter came back out of it a few seconds later. The server had it right the whole time; every sync rebuilds the local copy from the server's answer, and the function doing that never carried chapter_id — so each sync handed back a note belonging to no chapter and the notebook took it in again. The bin's query left the column out too, which would have unfiled anything restored from it. A separator drawn in a notebook vanished when a chapter was opened. "Which chapter is open" and "which list is on screen" were one value. Opened beside or below, the list still shows the notebook — but everything around it had switched to the chapter, so the notebook's own separators were filtered away and a drag among its rows was measured against the chapter's notes, found nothing, and did nothing. And where a new note lands is now visible and yours to choose: pressing in either place makes it the one being worked in, marked with a line along its top and the word "here", and the + button names it. Rearranging becomes a mode, switched on beside the sort menu — until then no row wears a grip and none can be dragged. Filing a note is back in the row's own menu, where starring and exporting already are; the button that had appeared in every row is gone with its dialog. The account panel closes with Escape. Thirteen suites green: 335 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
579 lines
24 KiB
JavaScript
579 lines
24 KiB
JavaScript
/* 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(", "));
|
||
|
||
/* ── 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]");
|
||
await drag(rows.nth(2), rows.nth(0));
|
||
|
||
const arranged = await titles();
|
||
check(
|
||
arranged[0] === beforeDrag[2],
|
||
"dragging a note to the top puts it there",
|
||
`${beforeDrag.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");
|
||
|
||
/* Renaming is right-click, like removing. A left-click on the label used to
|
||
start an edit, so the row could not be touched without falling into one. */
|
||
const label = p.getByLabel("Separator label");
|
||
check((await label.count()) === 0, "clicking a separator does not drop straight into renaming");
|
||
|
||
await p.locator("[data-separator]").click({ button: "right" });
|
||
await p.waitForTimeout(600);
|
||
await p.getByText("Rename", { exact: true }).click();
|
||
await p.waitForTimeout(500);
|
||
await label.fill("Wichtig");
|
||
await label.press("Enter");
|
||
await p.waitForTimeout(1800);
|
||
check(
|
||
/wichtig/i.test(await p.locator("[data-separator]").innerText()),
|
||
"and it can be named from the same menu",
|
||
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");
|
||
await sortBy("manual");
|
||
await p.waitForTimeout(800);
|
||
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
|
||
|
||
/* 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 titles()).length === 3, "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())
|
||
);
|
||
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);
|
||
}
|