diff --git a/tests/browser/example-board-ui.test.js b/tests/browser/example-board-ui.test.js index a279b544..068111f8 100644 --- a/tests/browser/example-board-ui.test.js +++ b/tests/browser/example-board-ui.test.js @@ -17,7 +17,7 @@ before(async () => { handle: (_request, response, url) => { if (url.pathname === "/drawer") { response.setHeader("content-type", "text/html"); - response.end(drawerMarkup()); + response.end(drawerMarkup(url.searchParams.has("sidebar"))); return true; } if (url.pathname !== "/") return false; @@ -38,8 +38,23 @@ function exampleMarkup() { return html.slice(start, html.indexOf("", start) + 10); } -function drawerMarkup() { - return `
Add a case
${exampleMarkup()}
`; +// The real ending wait, so the board is stacked against the page's own +// overlay rules rather than a stand-in. +function endingMarkup() { + const html = read("web/interview.html"); + const start = html.lastIndexOf( + "", start) + 10); +} + +function drawerMarkup(sidebar = false) { + const panel = `
Add a case
${exampleMarkup()}
`; + const body = sidebar + ? `
${panel}${endingMarkup()}
` + : panel; + return `${body}`; } test("selecting an example pen color switches from erasing to drawing", async (t) => { @@ -799,7 +814,7 @@ test("offline drawings skip JPEG encoding and locked controls repaint", async (t await page.waitForFunction( () => window.uploads === 1 && - document.querySelector("#example-board > [role=status]").textContent === + document.querySelector(".example-pane > [role=status]").textContent === "Drawing ready for Jim to read when you refer to it.", ); assert.equal(await page.evaluate(() => window.encodings), 1); @@ -820,3 +835,557 @@ test("offline drawings skip JPEG encoding and locked controls repaint", async (t assert.equal(await page.locator("[data-action=clear]").isDisabled(), false); await page.evaluate(() => window.example.finish()); }); + +// The interview's own grid with a stand-in sidebar, so an expanded board can be +// checked against both the editor panel it covers and the sidebar it leaves. +async function openExpandableBoard(viewport) { + const page = await browser.newPage({ viewport }); + await page.goto(`${base}/drawer?sidebar`); + await page.waitForFunction( + () => + getComputedStyle(document.querySelector(".drawer-resizer")).height === + "8px", + ); + await page.evaluate(async (base) => { + const { mountExampleBoard } = await import(`${base}/example-board.js`); + const { mountInterviewDrawer } = await import( + `${base}/interview-drawer.js` + ); + window.ops = []; + window.uploads = []; + window.turns = 0; + document + .querySelector("#yield-turn") + .addEventListener("click", () => window.turns++); + mountExampleBoard(document.querySelector("#example-board"), { + locked: () => false, + record: (ops) => window.ops.push(...ops), + // Kept as the bytes Jim's read_board is handed, to decode later. + publish: async (blob, strokes) => { + const image = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + window.uploads.push({ strokes, image }); + return true; + }, + }); + mountInterviewDrawer(document.querySelector(".editor-panel")); + }, base); + await page.locator("#example-board summary").click(); + await page.waitForFunction(() => + document.querySelector(".example-surface").style.height.endsWith("px"), + ); + return page; +} + +function boardLayout(page) { + return page.evaluate(() => { + const box = (selector) => { + const { x, y, width, height } = document + .querySelector(selector) + .getBoundingClientRect(); + return { x, y, width, height }; + }; + const surface = document.querySelector(".example-surface"); + return { + panel: box(".editor-panel"), + pane: box(".example-pane"), + surface: box(".example-surface"), + canvas: box(".example-surface canvas"), + status: box(".example-pane > [role=status]"), + hidden: + surface.scrollHeight - + surface.clientHeight + + surface.scrollWidth - + surface.clientWidth, + }; + }); +} + +const isExpanded = (page) => + page + .locator("#example-board") + .evaluate((node) => node.hasAttribute("data-expanded")); + +async function drawAcross(page, canvas, fromX, toX, y) { + await page.mouse.move(canvas.x + canvas.width * fromX, canvas.y + y); + await page.mouse.down(); + await page.mouse.move(canvas.x + canvas.width * toX, canvas.y + y, { + steps: 4, + }); + await page.mouse.up(); +} + +// The darkest red channel around each board point, on the canvas or in an +// image sent to Jim; both keep the board's 1600x1000 pixels. +function inkAt(page, points, upload = null) { + return page.evaluate( + async ({ points, upload }) => { + let context = document + .querySelector(".example-surface canvas") + .getContext("2d"); + if (upload !== null) { + const image = new Image(); + image.src = window.uploads.at(upload).image; + await image.decode(); + context = document.createElement("canvas").getContext("2d"); + context.canvas.width = image.width; + context.canvas.height = image.height; + context.drawImage(image, 0, 0); + } + return points.map(([x, y]) => { + const { data } = context.getImageData(x, y - 3, 1, 7); + return Math.min(...data.filter((_, index) => index % 4 === 0)); + }); + }, + { points, upload }, + ); +} + +// The three sizes #579 measured. +for (const viewport of [ + { width: 1280, height: 800 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, +]) + test(`at ${viewport.width}x${viewport.height} the expanded example board shows the whole canvas over the editor panel only`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard(viewport); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const lifted = await boardLayout(page); + assert.equal(lifted.hidden, 0); + assert.ok( + Math.abs(lifted.canvas.width / lifted.canvas.height - 1.6) < 0.01, + ); + assert.ok( + Math.abs(lifted.canvas.width - lifted.surface.width) <= 1 || + Math.abs(lifted.canvas.height - lifted.surface.height) <= 1, + "the canvas fills the width or the height it is given", + ); + const left = lifted.canvas.x - lifted.surface.x; + const right = + lifted.surface.x + + lifted.surface.width - + lifted.canvas.x - + lifted.canvas.width; + assert.ok(Math.abs(left - right) <= 1, "the canvas is centred"); + // The pane is the panel inside its 1px border, and the drawing takes the + // height its toolbar and status line leave. + for (const side of ["x", "y", "width", "height"]) + assert.ok(Math.abs(lifted.pane[side] - lifted.panel[side]) <= 2, side); + assert.ok( + lifted.pane.y + + lifted.pane.height - + lifted.status.y - + lifted.status.height <= + 16, + ); + await page.getByRole("button", { name: "Your turn is done" }).click(); + assert.equal(await page.evaluate(() => window.turns), 1); + }); + +test("strokes drawn before and while expanded stay on the canvas, in Jim's image and undoable", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + // Board y=100 in the drawer, then board y=500 once expanded. + const docked = await boardLayout(page); + await drawAcross(page, docked.canvas, 0.2, 0.6, docked.canvas.height * 0.1); + await page.waitForFunction(() => window.uploads.length === 1); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const { canvas } = await boardLayout(page); + assert.ok((await inkAt(page, [[640, 100]]))[0] < 128); + await drawAcross(page, canvas, 0.2, 0.6, canvas.height / 2); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + + const [before, during, blank] = await inkAt(page, [ + [640, 100], + [640, 500], + [640, 800], + ]); + assert.ok(before < 128 && during < 128 && blank > 200); + // What Jim reads is the sent image, not the screen. + await page.waitForFunction(() => window.uploads.at(-1).strokes === 2); + const sent = await inkAt( + page, + [ + [640, 100], + [640, 500], + [640, 800], + ], + -1, + ); + assert.ok(sent[0] < 128, "Jim's image keeps the stroke from the drawer"); + assert.ok(sent[1] < 128, "Jim's image has the stroke drawn while expanded"); + assert.ok(sent[2] > 200, "Jim's image is not dark everywhere"); + + await page.getByRole("button", { name: "Undo", exact: true }).click(); + const [kept, undone] = await inkAt(page, [ + [640, 100], + [640, 500], + ]); + assert.ok(kept < 128 && undone > 200); +}); + +test("the button or Escape shrinks the board, but not from a sidebar field or mid-stroke", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const docked = await boardLayout(page); + const expand = page.getByRole("button", { name: "Expand drawing" }); + const shrink = page.getByRole("button", { name: "Shrink drawing" }); + const turn = page.getByRole("button", { name: "Your turn is done" }); + const icon = await expand.innerHTML(); + await expand.click(); + // The label names the action, so the button is not also a pressed toggle. + assert.equal(await shrink.getAttribute("aria-pressed"), null); + assert.equal(await shrink.getAttribute("title"), "Shrink drawing"); + assert.notEqual(await shrink.innerHTML(), icon); + await shrink.click(); + assert.equal(await isExpanded(page), false); + await expand.click(); + const { canvas } = await boardLayout(page); + + // A field outside the board keeps Escape for itself, as a select does. + await page.locator("#sidebar-field").focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), true); + + // Mid-stroke, neither Escape nor the focused button changes the canvas size. + await shrink.focus(); + await page.mouse.move(canvas.x + 100, canvas.y + 200); + await page.mouse.down(); + await page.mouse.move(canvas.x + 300, canvas.y + 300, { steps: 3 }); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), true); + await page.keyboard.press("Enter"); + assert.equal(await isExpanded(page), true); + await page.mouse.up(); + + // A sidebar button has no use for Escape, so from there it shrinks the board. + await turn.click(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false); + assert.equal(await expand.getAttribute("title"), "Expand drawing"); + assert.equal(await expand.innerHTML(), icon); + // Focus stays in the sidebar the key came from. + assert.equal( + await page.evaluate(() => document.activeElement.id), + "yield-turn", + ); + const back = await boardLayout(page); + assert.ok(Math.abs(back.canvas.width - docked.canvas.width) <= 1); +}); + +// Escape never moves focus: from the board's own button, a tool, or the +// sidebar, the candidate stays where they were. +test("shrinking by Escape leaves focus where it was, in the board or the sidebar", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const toggle = page.locator("#example-board [data-action=expand]"); + const focused = (locator) => + locator.evaluate((node) => node === document.activeElement); + for (const from of [ + toggle, + page.getByRole("button", { name: "Pen", exact: true }), + page.getByRole("button", { name: "Your turn is done" }), + ]) { + await toggle.click(); + assert.equal(await isExpanded(page), true); + await from.focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false); + assert.equal( + await focused(from), + true, + (await from.getAttribute("aria-label")) ?? (await from.textContent()), + ); + } + // The button is the same element either way, so it keeps focus through the + // label change. + assert.equal(await toggle.getAttribute("aria-label"), "Expand drawing"); +}); + +test("Escape from an input method, a modifier chord or a held key leaves the board expanded", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.getByRole("button", { name: "Text", exact: true }).click(); + const { canvas } = await boardLayout(page); + await page.mouse.click(canvas.x + 120, canvas.y + 120); + const label = page.getByRole("textbox", { name: "Label text" }); + await label.fill("ab"); + // An IME composing in the label ends with an Escape the draft lets through, + // flagged by either field depending on the browser. + for (const flag of [{ isComposing: true }, { keyCode: 229 }]) { + await label.evaluate( + (node, flag) => + node.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + ...flag, + }), + ), + flag, + ); + assert.equal(await isExpanded(page), true, JSON.stringify(flag)); + } + await page.getByRole("button", { name: "Your turn is done" }).focus(); + for (const chord of [ + "Alt+Escape", + "Shift+Escape", + "Control+Escape", + "Meta+Escape", + ]) { + await page.keyboard.press(chord); + assert.equal(await isExpanded(page), true, chord); + } + // A held key's repeats are not a new press. + await page.evaluate(() => + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + repeat: true, + bubbles: true, + }), + ), + ); + assert.equal(await isExpanded(page), true); +}); + +test("the covered editor panel and Jim's stage are out of reach while expanded and all come back on shrink", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const active = () => + page.evaluate( + () => document.activeElement.id || document.activeElement.tagName, + ); + // Every control the pane covers, not only the ones Shift+Tab passes, and + // whether the stage's button can take focus. + const reachable = () => + page.evaluate(() => { + const hide = document.querySelector("#jim-stage .hide-avatar"); + hide.focus(); + const stage = document.activeElement === hide; + hide.blur(); + return { + panel: [ + ...document + .querySelector(".editor-panel") + .querySelectorAll( + "button, input, select, textarea, summary, [tabindex]", + ), + ] + .filter((node) => !node.closest(".example-pane, [inert]")) + .map((node) => node.id || node.textContent.trim() || node.tagName), + stage, + }; + }); + const docked = await reachable(); + assert.ok(docked.panel.length > 3); + assert.equal(docked.stage, true); + // Twice, shrinking by the button and then by Escape from the sidebar. + for (const shrinkBy of ["button", "Escape"]) { + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.deepEqual(await reachable(), { panel: [], stage: false }, shrinkBy); + await page.getByRole("button", { name: "Shrink drawing" }).focus(); + await page.keyboard.press("Shift+Tab"); + assert.equal(await active(), "yield-turn", shrinkBy); + if (shrinkBy === "button") + await page.getByRole("button", { name: "Shrink drawing" }).click(); + else await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false, shrinkBy); + assert.deepEqual(await reachable(), docked, shrinkBy); + } + await page.getByRole("button", { name: "Expand drawing" }).focus(); + await page.keyboard.press("Shift+Tab"); + assert.equal(await active(), "SUMMARY"); + await page.locator("#code").focus(); + assert.equal(await active(), "code"); +}); + +// An ink stroke and each text-box gesture, against each key that resizes the +// board, docked and expanded; each key is asserted alone so a wrongly obeyed +// one cannot be undone by the next. +test("Expand, Shrink and Escape are ignored during a stroke or a text-box gesture, and work once it ends", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const toggle = page.locator("#example-board [data-action=expand]"); + const draft = page.locator(".board-text-draft"); + const centre = (box) => ({ + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }); + async function holdsThrough(from, to, keys, what) { + const expanded = await isExpanded(page); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 3 }); + for (const key of keys) { + // A new text box leaves focus in its label, so the button is focused + // again first: otherwise Enter would never reach it and prove nothing. + if (key !== "Escape") { + await toggle.focus(); + assert.equal( + await page.evaluate(() => document.activeElement.dataset.action), + "expand", + ); + } + await page.keyboard.press(key); + assert.equal(await isExpanded(page), expanded, `${what}, ${key}`); + } + await page.mouse.up(); + } + for (const expanded of [false, true]) { + const state = expanded ? "expanded" : "docked"; + if (expanded) await toggle.click(); + // Escape last: on a text box the draft takes it and is cancelled, and that + // same press must not resize the board as well. + const keys = expanded ? ["Enter", " ", "Escape"] : ["Enter", " "]; + const { canvas } = await boardLayout(page); + const at = (x, y) => ({ x: canvas.x + x, y: canvas.y + y }); + const openDraft = async () => { + if ((await draft.count()) === 0) + await page.mouse.click(canvas.x + 80, canvas.y + 40); + }; + await page.getByRole("button", { name: "Pen", exact: true }).click(); + await holdsThrough(at(60, 40), at(200, 90), keys, `${state} stroke`); + await page.getByRole("button", { name: "Text", exact: true }).click(); + await holdsThrough(at(260, 30), at(420, 100), keys, `${state} new box`); + await openDraft(); + const edge = centre(await draft.locator("[data-edge=n]").boundingBox()); + await holdsThrough( + edge, + { x: edge.x + 30, y: edge.y + 10 }, + keys, + `${state} moved box`, + ); + await openDraft(); + const corner = centre( + await draft.locator('[data-handle="se"]').boundingBox(), + ); + await holdsThrough( + corner, + { x: corner.x + 30, y: corner.y + 10 }, + keys, + `${state} scaled box`, + ); + // Once the gesture is over, the button works again. + await toggle.focus(); + await page.keyboard.press("Enter"); + assert.equal(await isExpanded(page), !expanded, `${state}, after`); + if (!expanded) await toggle.click(); + if ((await draft.count()) > 0) + await page.getByRole("textbox", { name: "Label text" }).press("Escape"); + } +}); + +test("the ending wait covers an expanded board and keeps its own Escape", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page + .locator("#ending-overlay") + .evaluate((node) => (node.hidden = false)); + const { pane } = await boardLayout(page); + const covered = await page.evaluate( + ([x, y]) => + Boolean(document.elementFromPoint(x, y).closest("#ending-overlay")), + [pane.x + pane.width / 2, pane.y + pane.height / 2], + ); + assert.equal(covered, true); + // Escape inside that dialog is not the hidden board's to take. + await page + .locator("#ending-overlay") + .evaluate((node) => + node.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ), + ); + assert.equal(await isExpanded(page), true); +}); + +test("an expanded board hides Jim's full stage outside the ending wait, so Tab skips it", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const hidden = () => + page.evaluate( + () => getComputedStyle(document.querySelector("#jim-stage")).visibility, + ); + // Tab on from Shrink until focus leaves the board. + const landsIn = async () => { + await page.getByRole("button", { name: "Shrink drawing" }).focus(); + for (let presses = 0; presses < 20; presses++) { + await page.keyboard.press("Tab"); + const where = await page.evaluate(() => { + const node = document.activeElement; + if (node.closest(".example-pane")) return null; + return node.closest("#jim-stage") ? "stage" : "elsewhere"; + }); + if (where) return where; + } + return "the board"; + }; + assert.equal(await hidden(), "visible"); + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.equal(await hidden(), "hidden"); + assert.equal(await landsIn(), "elsewhere"); + // The wait lifts the stage over everything so Jim's closing lines are seen. + await page + .locator("#ending-overlay") + .evaluate((node) => (node.hidden = false)); + assert.equal(await hidden(), "visible"); + await page + .locator("#ending-overlay") + .evaluate((node) => (node.hidden = true)); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + assert.equal(await hidden(), "visible"); +}); + +test("closing Example drawing shrinks an expanded board and frees the editor", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.locator("#example-board").evaluate((node) => (node.open = false)); + // The details element fires toggle on a later task. + await page.waitForFunction( + () => + !document.querySelector("#example-board").hasAttribute("data-expanded"), + ); + assert.equal( + await page.evaluate( + () => document.querySelectorAll(".editor-panel [inert]").length, + ), + 0, + ); +}); + +test("a click where Hide avatar sits reaches the expanded board, not the stage", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const hide = await page + .getByRole("button", { name: "Hide avatar" }) + .boundingBox(); + const topAt = () => + page.evaluate( + ({ x, y }) => + document.elementFromPoint(x, y).closest(".example-pane, .jim-stage") + ?.className, + { x: hide.x + hide.width / 2, y: hide.y + hide.height / 2 }, + ); + assert.equal(await topAt(), "jim-stage"); + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.equal(await topAt(), "example-pane"); +}); diff --git a/tests/browser/interview-layout.test.js b/tests/browser/interview-layout.test.js index 99527081..3a8fca8a 100644 --- a/tests/browser/interview-layout.test.js +++ b/tests/browser/interview-layout.test.js @@ -280,6 +280,254 @@ test("the wait keeps Jim heard and the recording withdrawable", async (t) => { } }); +// #579's own case: a 13.6-inch MacBook Air window with the divider at its +// maximum, where the editor panel overflows by a few pixels. The expanded +// board must not scroll with it and show what it covers. +test("the expanded Example drawing stays at the panel's top when the panel could scroll", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1250, height: 630 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.locator(".drawer-resizer").focus(); + await page.keyboard.press("End"); + const offset = () => + page.evaluate(() => { + const panel = document.querySelector(".editor-panel"); + const pane = document.querySelector(".example-pane"); + return Math.round( + pane.getBoundingClientRect().top - panel.getBoundingClientRect().top, + ); + }); + // Scrolled before expanding, and wheeled over the board after. + const scrolled = await page + .locator(".editor-panel") + .evaluate( + (panel) => (panel.scrollTop = panel.scrollHeight) && panel.scrollTop, + ); + assert.ok(scrolled > 0, "the panel can scroll at this size"); + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.ok( + Math.abs(await offset()) <= 1, + "the pane starts at the panel's top", + ); + const pane = await page.locator(".example-pane").boundingBox(); + await page.mouse.move(pane.x + pane.width / 2, pane.y + pane.height / 2); + await page.mouse.wheel(0, 300); + // The wheel scrolls on a later frame. + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + assert.ok(Math.abs(await offset()) <= 1, "and stays there under the wheel"); + } finally { + await page.close(); + } +}); + +// The drawer sizes the docked drawing from its own layout, which an expanded +// board is not in: a resize while expanded must not leave a wrong height +// behind for the docked board to come back to. +test("shrinking after a resize while expanded sizes the docked drawing for the new window", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + // The drawer repaints from observers, a frame or two after a change. + const settled = () => + page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + const surface = () => + page + .locator(".example-surface") + .evaluate((node) => node.getBoundingClientRect().height); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.setViewportSize({ width: 1280, height: 600 }); + await settled(); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + await settled(); + const back = await surface(); + // What the drawer gives the drawing at this size when it paints afresh. + await page + .locator("#results-body") + .evaluate((node) => (node.hidden = !node.hidden)); + await settled(); + await page + .locator("#results-body") + .evaluate((node) => (node.hidden = !node.hidden)); + await settled(); + assert.ok(Math.abs(back - (await surface())) <= 1, `${back}px`); + } finally { + await page.close(); + } +}); + +// Each of the page's own overlays over an expanded board, on the real page: +// the pane sits under them, so whichever one opens is what the candidate sees. +test("an expanded Example drawing stays under the setup check, the ending wait and the report", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + for (const overlay of [ + "#audio-check", + "#ending-overlay", + "#report-modal", + ]) { + const covered = await page.evaluate((overlay) => { + const node = document.querySelector(overlay); + node.hidden = false; + const pane = document + .querySelector(".example-pane") + .getBoundingClientRect(); + const hit = document.elementFromPoint( + pane.x + pane.width / 2, + pane.y + pane.height / 2, + ); + node.hidden = true; + return node.contains(hit); + }, overlay); + assert.ok(covered, overlay); + } + } finally { + await page.close(); + } +}); + +// On the real page, wide and narrow. The full stage is hidden, so no covered +// control is left in the Tab order, until audio is blocked and Resume audio has +// to be reachable, and while it holds the focus Resume audio gives it; the +// compact card keeps its captions on top of the board. +test("an expanded Example drawing hides Jim's full stage, but not the compact card or Resume audio", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + for (const [width, height, compact] of [ + [1440, 900, false], + [900, 800, true], + ]) { + const size = `${width}x${height}`; + const page = await browser.newPage({ viewport: { width, height } }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => + document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page + .locator("#example-board") + .evaluate((node) => (node.open = true)); + assert.equal( + await page + .locator("#jim-stage") + .evaluate((node) => node.classList.contains("compact")), + compact, + size, + ); + const visibility = () => + page.evaluate( + () => + getComputedStyle(document.querySelector("#jim-stage")).visibility, + ); + // Whether the element at the centre of `selector` is inside it. + const onTop = (selector) => + page.evaluate((selector) => { + const node = document.querySelector(selector); + const box = node.getBoundingClientRect(); + return node.contains( + document.elementFromPoint( + box.x + box.width / 2, + box.y + box.height / 2, + ), + ); + }, selector); + assert.equal(await visibility(), "visible", size); + await page.getByRole("button", { name: "Expand drawing" }).click(); + if (compact) { + assert.equal(await visibility(), "visible", size); + assert.ok(await onTop("#captions-bar"), size); + } else { + assert.equal(await visibility(), "hidden", size); + } + await page.evaluate( + () => (document.querySelector("#audio-playback-notice").hidden = false), + ); + assert.ok(await onTop("#resume-audio"), size); + // What audio-playback.js does once Resume audio plays, done here by + // script: focus moves to the stage, then the notice goes. The stage + // keeps focus and stays shown, so its "Audio restored." is not hidden. + await page.locator("#resume-audio").focus(); + await page.evaluate(() => { + document.querySelector("#jim-stage").focus(); + document.querySelector("#audio-playback-notice").hidden = true; + }); + assert.equal( + await page.evaluate(() => document.activeElement.id), + "jim-stage", + size, + ); + assert.equal(await visibility(), "visible", size); + assert.ok( + await onTop(compact ? "#captions-bar" : "#jim-stage .hide-avatar"), + size, + ); + assert.ok( + await page.evaluate( + () => + getComputedStyle(document.querySelector("#audio-playback-status")) + .visibility === "visible", + ), + size, + ); + // Once focus moves on, the full stage goes again. + await page.locator("#yield-turn").focus(); + assert.equal(await visibility(), compact ? "visible" : "hidden", size); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + assert.equal(await visibility(), "visible", size); + } finally { + await page.close(); + } + } +}); + // On a phone the compact stage runs along the bottom edge above the wait, so a // long closing line must not grow it over the wait's own buttons. test("on a phone Jim's closing lines leave the wait's buttons reachable", async (t) => { diff --git a/tests/browser/run-shortcut.test.js b/tests/browser/run-shortcut.test.js index 76bc1fa2..5e9e6f68 100644 --- a/tests/browser/run-shortcut.test.js +++ b/tests/browser/run-shortcut.test.js @@ -410,6 +410,32 @@ test("the shortcut does nothing while the media preflight is still up", async (t } }); +test("the shortcut runs nothing while Example drawing is expanded, and runs again once it shrinks", async (t) => { + const ctx = await interviewPage(t, { params: { exampleDrawing: "1" } }); + if (!ctx) return; + const { page, errors } = ctx; + try { + await runButtonReady(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + // Taken, so it presses nothing else, and runs nothing while the board + // covers the results. + assert.equal(await runs(page), 0); + assert.equal(await enterDefaultPrevented(page), true); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + assert.equal(await runs(page), 1); + assert.deepEqual( + errors, + [], + `the shortcut threw in the browser: ${errors[0]}`, + ); + } finally { + await page.close(); + } +}); + test("the shortcut does nothing once the interview has ended", async (t) => { const ctx = await interviewPage(t, { clock: true }); if (!ctx) return; diff --git a/web/board-text.js b/web/board-text.js index 6084c4e9..6a9d91d8 100644 --- a/web/board-text.js +++ b/web/board-text.js @@ -455,5 +455,5 @@ export function mountBoardText( canvas.addEventListener("keydown", onDraftKeydown); const observer = new ResizeObserver(refresh); observer.observe(canvas); - return { refresh, cancel: close }; + return { refresh, cancel: close, busy: () => gesture !== null }; } diff --git a/web/example-board.js b/web/example-board.js index 94bb122f..530cb40a 100644 --- a/web/example-board.js +++ b/web/example-board.js @@ -16,6 +16,8 @@ const TOOL_ICONS = { eraser: '', undo: '', clear: '', + expand: '', + shrink: '', }; function setToolIcon(button, name) { @@ -44,7 +46,13 @@ export function mountExampleBoard( const model = createBoard(); const pointers = createPointerGate(); const tools = root.querySelector("[data-tools]"); - const status = root.querySelector(":scope > [role=status]"); + // The interview wraps the board in `.example-pane` so it can lift out of + // the drawer; the task page keeps it directly under the details element. + const status = root.querySelector( + ":scope > [role=status], :scope > .example-pane > [role=status]", + ); + const expand = root.querySelector("[data-action=expand]"); + const pane = root.querySelector(":scope > .example-pane"); let tool = "pen"; let color = PEN_COLORS[0].color; let drag = null; @@ -269,6 +277,60 @@ export function mountExampleBoard( }); canvas.addEventListener("pointercancel", cancel); canvas.addEventListener("lostpointercapture", cancel); + // Expanding moves no element: the pane is restyled in place, so the canvas, + // its strokes and anything waiting to reach Jim stay exactly as they were. + // Only the drawn size changes, and `boardPoint` already scales by it. + function setExpanded(on) { + root.toggleAttribute("data-expanded", on); + setToolIcon(expand, on ? "shrink" : "expand"); + expand.setAttribute("aria-label", on ? "Shrink drawing" : "Expand drawing"); + expand.title = expand.getAttribute("aria-label"); + const panel = root.closest(".editor-panel"); + // The panel can scroll a little on a short screen, and the pane would + // scroll with it; the stylesheet stops it scrolling while expanded. + if (on && panel) panel.scrollTop = 0; + // What the pane covers leaves the tab order with it, or Shift+Tab walks + // into a code editor nobody can see. The sidebar is outside the panel. + for (let node = pane; panel && node !== panel; node = node.parentElement) + for (const sibling of node.parentElement.children) + if (sibling !== node) sibling.inert = on; + } + if (expand) { + setExpanded(false); + // Nothing on the page closes it while expanded, but whatever does must + // not leave the editor inert under a board that is gone. + root.addEventListener("toggle", () => { + if (!root.open) setExpanded(false); + }); + expand.addEventListener("click", () => { + if (!drag && !textDraft.busy()) + setExpanded(!root.hasAttribute("data-expanded")); + }); + // Not a modal dialog: the sidebar's turn and mute controls stay usable + // while the board is large, so Escape is handled here rather than by + // `showModal`. A text draft claims Escape first and stops it. An ink + // stroke takes no focus, so after a sidebar button the key still arrives + // there. A field or a dialog keeps Escape for itself, and so does an + // input method, a modifier chord or a held key. + document.addEventListener("keydown", (event) => { + if ( + event.key !== "Escape" || + event.isComposing || + event.keyCode === 229 || + event.repeat || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + drag || + textDraft.busy() || + !root.hasAttribute("data-expanded") || + event.target.closest?.("input, textarea, select, [role=dialog]") + ) + return; + setExpanded(false); + }); + } root.addEventListener("toggle", () => { if (!root.open) cancelTurn?.(); if (!root.open && drag) { diff --git a/web/interview-drawer.js b/web/interview-drawer.js index 6031e85e..774202de 100644 --- a/web/interview-drawer.js +++ b/web/interview-drawer.js @@ -107,6 +107,12 @@ export function mountInterviewDrawer(panel) { }); for (const details of drawer.querySelectorAll("details")) details.addEventListener("toggle", paint); + // A shrinking board comes back to a drawer that may have been resized while + // it was out, so its docked height is measured again. + new MutationObserver(paint).observe(example, { + attributes: true, + attributeFilter: ["data-expanded"], + }); new MutationObserver(paint).observe(results, { attributes: true, attributeFilter: ["hidden"], diff --git a/web/interview.html b/web/interview.html index e434c31b..2ee046ef 100644 --- a/web/interview.html +++ b/web/interview.html @@ -397,38 +397,41 @@

Loading interview...