/* 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` ); /* 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); }