The layer showing saved ink lies over the text. The canvas itself is deaf to the pointer, but the box I wrapped it in yesterday was not — so on any note carrying a drawing, every click meant for the text was swallowed before it arrived, and the note simply could not be typed in. Introduced with per-page drawing, where each page's ink got a wrapper to be positioned by. The suite now clicks into the text of a note that has a drawing and types, and asks what actually answers over the writing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
9.3 KiB
JavaScript
240 lines
9.3 KiB
JavaScript
/* Drawings must look the same on every screen.
|
|
*
|
|
* Strokes are stored as vectors in a fixed 1200x1600 page space, so the same
|
|
* note rendered at different viewport widths should differ only by rasterising
|
|
* error. Each rendering is reduced to a coarse ink grid and compared cell by
|
|
* cell, which ignores antialiasing but catches a stroke that moved or vanished.
|
|
*
|
|
* The second thing checked here is the bug that started all of this: the 6-second
|
|
* poller adopting a server response that was issued before the local save, which
|
|
* made strokes disappear again seconds after being drawn. */
|
|
import {
|
|
OUT,
|
|
check,
|
|
createNote,
|
|
createNotebook,
|
|
launch,
|
|
reopenNote,
|
|
report,
|
|
setTitle,
|
|
withFreshUser,
|
|
} from "./lib.mjs";
|
|
|
|
const GRID_W = 48,
|
|
GRID_H = 64;
|
|
|
|
/** Rasterises one page's drawing surface into a GRID_W x GRID_H map of "has ink". */
|
|
const inkGrid = (page, sheet = 1) =>
|
|
page.evaluate(
|
|
([gw, gh, n]) => {
|
|
const c =
|
|
document.querySelector(`canvas[data-draw-page="${n}"]`) ||
|
|
document.querySelector(`[data-drawn-page="${n}"] canvas`);
|
|
if (!c) return null;
|
|
const off = document.createElement("canvas");
|
|
off.width = gw;
|
|
off.height = gh;
|
|
const ctx = off.getContext("2d", { willReadFrequently: true });
|
|
ctx.drawImage(c, 0, 0, gw, gh);
|
|
const d = ctx.getImageData(0, 0, gw, gh).data;
|
|
const out = [];
|
|
for (let i = 0; i < gw * gh; i++) out.push(d[i * 4 + 3] > 40 ? 1 : 0);
|
|
return out;
|
|
},
|
|
[GRID_W, GRID_H, sheet]
|
|
);
|
|
|
|
const diff = (a, b) => a.reduce((n, v, i) => n + (v === b[i] ? 0 : 1), 0);
|
|
const ink = (g) => g.reduce((n, v) => n + v, 0);
|
|
|
|
async function draw(page, sheet = 1, strokes = null) {
|
|
if (sheet === 1) {
|
|
await page.locator("button").filter({ hasText: "Draw" }).first().click();
|
|
await page.waitForTimeout(1200);
|
|
}
|
|
const canvas = page.locator(`canvas[data-draw-page="${sheet}"]`);
|
|
await canvas.scrollIntoViewIfNeeded();
|
|
await page.waitForTimeout(500);
|
|
const box = await canvas.boundingBox();
|
|
// Three strokes in different parts of the page, so a shifted origin shows up.
|
|
strokes ??= [
|
|
[0.2, 0.2, 0.7, 0.35],
|
|
[0.25, 0.55, 0.75, 0.5],
|
|
[0.5, 0.7, 0.55, 0.85],
|
|
];
|
|
for (const [x1, y1, x2, y2] of strokes) {
|
|
await page.mouse.move(box.x + box.width * x1, box.y + box.height * y1);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 14 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
await page.waitForTimeout(2500);
|
|
return box;
|
|
}
|
|
|
|
const browser = await launch();
|
|
let session;
|
|
try {
|
|
session = await withFreshUser(browser, "draw");
|
|
const p = session.page;
|
|
await createNotebook(p, "Zeichen Buch");
|
|
await createNote(p);
|
|
await setTitle(p, "Zeichnung");
|
|
|
|
await draw(p);
|
|
const reference = await inkGrid(p);
|
|
check(reference !== null && ink(reference) > 0, "strokes were drawn", `${ink(reference || [])} ink cells`);
|
|
await p.screenshot({ path: `${OUT}/drawing-1400.png` });
|
|
|
|
/* ── the poller must not undo what was just drawn ── */
|
|
await p.waitForTimeout(14000);
|
|
const afterPolling = await inkGrid(p);
|
|
check(diff(reference, afterPolling) === 0, "strokes survive 14s of background polling", `${diff(reference, afterPolling)} cells changed`);
|
|
|
|
/* ── same note, other screen widths ── */
|
|
const noteId = await p.evaluate(async () => {
|
|
const r = await fetch("/api/notes", {
|
|
headers: { Authorization: "Bearer " + localStorage.getItem("inknote_token") },
|
|
});
|
|
return (await r.json())[0].id;
|
|
});
|
|
|
|
for (const width of [1000, 700, 420]) {
|
|
await p.setViewportSize({ width, height: 900 });
|
|
await p.waitForTimeout(2500);
|
|
if (width < 768) {
|
|
// Shrinking past the breakpoint leaves the sidebar open as a full-screen
|
|
// overlay, and its backdrop swallows every click. Dismiss it by clicking
|
|
// to the right of the panel, then reopen the note from the list.
|
|
await p.mouse.click(width - 12, 450);
|
|
await p.waitForTimeout(800);
|
|
const row = p.locator(`[data-note-row="${noteId}"]`);
|
|
if (await row.count()) {
|
|
await row.click();
|
|
await p.waitForTimeout(2500);
|
|
}
|
|
}
|
|
const g = await inkGrid(p);
|
|
if (!g) {
|
|
check(false, `drawing visible at ${width}px`);
|
|
continue;
|
|
}
|
|
const d = diff(reference, g);
|
|
const pct = ((d / (GRID_W * GRID_H)) * 100).toFixed(1);
|
|
check(d / (GRID_W * GRID_H) < 0.02, `drawing matches at ${width}px`, `${d} cells, ${pct}%`);
|
|
await p.screenshot({ path: `${OUT}/drawing-${width}.png` });
|
|
}
|
|
|
|
/* ── and after a reload ── */
|
|
// A reload lands on the dashboard, which has no note list — reopen by title.
|
|
await p.setViewportSize({ width: 1400, height: 950 });
|
|
await reopenNote(p, "Zeichnung");
|
|
await p.waitForTimeout(2000);
|
|
const afterReload = await inkGrid(p);
|
|
check(afterReload !== null && ink(afterReload) > 0, "the drawing is still there after a reload");
|
|
if (afterReload) {
|
|
const d = diff(reference, afterReload);
|
|
check(d / (GRID_W * GRID_H) < 0.02, "the reloaded drawing matches the original", `${d} cells`);
|
|
}
|
|
|
|
/* ── a drawing runs over as many pages as the note has ──
|
|
* The ink used to be stuck on page one while the writing carried on without
|
|
* it. Every page is exported anyway, so every page can be drawn on. */
|
|
await createNote(p);
|
|
await setTitle(p, "Mehrere Seiten");
|
|
await p.locator("button").filter({ hasText: "Draw" }).first().click();
|
|
await p.waitForTimeout(1200);
|
|
check(
|
|
(await p.locator('canvas[data-draw-page="2"]').count()) === 1,
|
|
"there is a blank page past the end to draw on, before anything is written"
|
|
);
|
|
|
|
await draw(p, 1);
|
|
const first = await inkGrid(p, 1);
|
|
await draw(p, 2, [
|
|
[0.3, 0.25, 0.8, 0.4],
|
|
[0.2, 0.6, 0.6, 0.75],
|
|
]);
|
|
await p.waitForTimeout(2500);
|
|
|
|
const second = await inkGrid(p, 2);
|
|
check(ink(second) > 0, "a stroke lands on page two", `${ink(second)} ink cells`);
|
|
check(diff(first, await inkGrid(p, 1)) === 0, "and page one is untouched by it");
|
|
check(
|
|
(await p.locator("[data-note-position]").innerText()).includes("of 2"),
|
|
"drawing on page two makes the note two pages",
|
|
await p.locator("[data-note-position]").innerText()
|
|
);
|
|
check(
|
|
(await p.locator('canvas[data-draw-page="3"]').count()) === 1,
|
|
"and there is a blank page after it again"
|
|
);
|
|
await p.screenshot({ path: `${OUT}/drawing-pages.png` });
|
|
|
|
/* Stored per page, and it survives the trip to the server and back. */
|
|
const pageDoc = await p.evaluate(async () => {
|
|
const auth = { Authorization: "Bearer " + localStorage.getItem("inknote_token") };
|
|
const notes = await (await fetch("/api/notes", { headers: auth })).json();
|
|
const mine = notes.find((n) => n.title === "Mehrere Seiten");
|
|
return (await (await fetch(`/api/notes/${mine.id}`, { headers: auth })).json()).drawingStrokes;
|
|
});
|
|
check(!!pageDoc?.sheets, "the server stored it as pages, not as one sheet", JSON.stringify(Object.keys(pageDoc || {})));
|
|
check(
|
|
(pageDoc?.sheets?.["1"]?.length ?? 0) > 0 && (pageDoc?.sheets?.["2"]?.length ?? 0) > 0,
|
|
"with strokes on both",
|
|
`page 1: ${pageDoc?.sheets?.["1"]?.length}, page 2: ${pageDoc?.sheets?.["2"]?.length}`
|
|
);
|
|
|
|
await reopenNote(p, "Mehrere Seiten");
|
|
await p.waitForTimeout(2000);
|
|
const reloadedTwo = await inkGrid(p, 2);
|
|
check(reloadedTwo !== null && ink(reloadedTwo) > 0, "page two's ink is still there after a reload");
|
|
check(
|
|
diff(second, reloadedTwo) / (GRID_W * GRID_H) < 0.02,
|
|
"and it is the same ink",
|
|
`${diff(second, reloadedTwo)} cells`
|
|
);
|
|
|
|
/* ── a drawing must not make the note unwritable ──
|
|
* The layers that show saved ink lie over the text. They are transparent and
|
|
* deaf to the pointer — except the wrapper around one of them was not, and a
|
|
* note with a drawing could not be typed in at all: every click meant for the
|
|
* text was swallowed before it arrived. */
|
|
await p.locator("button").filter({ hasText: "Write" }).first().click();
|
|
await p.waitForTimeout(1200);
|
|
check(
|
|
await p.evaluate(() => {
|
|
const r = document.querySelector(".ProseMirror").getBoundingClientRect();
|
|
const el = document.elementFromPoint(r.x + r.width / 2, r.y + 20);
|
|
return !!el?.closest(".ProseMirror");
|
|
}),
|
|
"over the writing it is the writing that answers, not the ink layer"
|
|
);
|
|
await p.locator(".ProseMirror").click();
|
|
await p.keyboard.type("Getippt, obwohl die Notiz eine Zeichnung hat.");
|
|
await p.waitForTimeout(1800);
|
|
check(
|
|
(await p.locator(".ProseMirror").innerText()).includes("obwohl die Notiz eine Zeichnung hat"),
|
|
"and a note with a drawing can still be written in",
|
|
await p.locator(".ProseMirror").innerText()
|
|
);
|
|
|
|
/* Clearing acts on the page you are looking at, not on the whole drawing. */
|
|
await p.locator("button").filter({ hasText: "Draw" }).first().click();
|
|
await p.waitForTimeout(1200);
|
|
await p.locator('canvas[data-draw-page="1"]').scrollIntoViewIfNeeded();
|
|
await p.waitForTimeout(900);
|
|
await p.locator("[data-clear-page]").click();
|
|
await p.waitForTimeout(2500);
|
|
check(ink(await inkGrid(p, 1)) === 0, "the bin empties the page being looked at");
|
|
check(ink(await inkGrid(p, 2)) > 0, "and leaves the other pages alone");
|
|
} catch (e) {
|
|
check(false, "suite ran to the end", e.message);
|
|
} finally {
|
|
await session?.cleanup();
|
|
const code = report();
|
|
await browser.close();
|
|
process.exit(code);
|
|
}
|