// Undo, and whether a dealer can actually get to it. // // This tool prefers undo to confirmation — NN/g's own preference, and `runUndoable` // says so in as many words: "it now just happens, and the toast offers the way back for // the few seconds anyone would notice a misclick." That makes the toast's Undo the // safety net under the biggest things the tool does. Three ways it wasn't one: // // * Every whole-step undo clears the per-action history on its way past, so the bar // has no pill left and this button is the only way back. Measured on a desktop it // is the 67th of the page's 68 tab stops, and it is gone in eight seconds. `U` — // the key the sidebar legend advertises as taking back the last action — found // nothing at all and did nothing. // * The eight seconds ran whatever the dealer was doing. A cursor resting on the // toast did not stop it; nor did tabbing onto the button itself. Measured: focus // the Undo, wait 8.6 seconds, and the toast is gone and focus has been handed to // — the offer withdrawn and the dealer's place in the page with it. // * Each toast carried its own role="status" inside #toastHost, which is already a // live region in the markup. A region nested in a region, announced twice. // // The timing rules here are the usual ones: three to four seconds for a plain // confirmation, six to ten for anything carrying a button, pause on hover and on // keyboard focus, and always a manual way out — which is what WCAG 2.2.1 asks of // content that disappears on a clock. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const fs = require("fs"); const path = require("path"); const { Toast } = require("./load"); const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem"); const MAIN = fs.readFileSync(path.join(SRC, "main.js"), "utf8"); const FEEDBACK = fs.readFileSync(path.join(SRC, "feedback.js"), "utf8"); const SHORTCUTS = fs.readFileSync(path.join(SRC, "shortcuts.js"), "utf8"); const PAGE = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8"); // --------------------------------------------------------------------------- // Enough of a document for a toast: a host it can be put into, elements that can hold // listeners and focus, and a `:hover` that a test can turn on. let active = null; const node = (tag, className) => { const self = { tagName: String(tag).toUpperCase(), className: className || "", textContent: "", dataset: {}, children: [], parent: null, isConnected: true, hovered: false, listeners: {}, attrs: {}, classList: { add() {} }, appendChild(child) { child.parent = self; self.children.push(child); return child; }, addEventListener(type, fn) { (self.listeners[type] = self.listeners[type] || []).push(fn); }, setAttribute(name, value) { self.attrs[name] = String(value); }, getAttribute(name) { return name in self.attrs ? self.attrs[name] : null; }, contains(other) { for (let n = other; n; n = n.parent) if (n === self) return true; return false; }, matches(selector) { return selector === ":hover" ? self.hovered : false; }, focus() { active = self; }, getBoundingClientRect: () => ({ height: 44 }), // Fire one of the listeners the toast hung on itself. fire(type, event) { (self.listeners[type] || []).forEach(fn => fn(event || {})); }, find(className) { if (self.className === className) return self; for (const child of self.children) { const hit = child.find(className); if (hit) return hit; } return null; }, }; Object.defineProperty(self, "firstElementChild", { get: () => self.children[0] || null }); Object.defineProperty(self, "innerHTML", { get: () => "", set(value) { if (value !== "") return; self.children.forEach(child => { child.parent = null; child.isConnected = false; }); self.children.length = 0; }, }); return self; }; let host; const openDocument = () => { host = node("div", "toastHost"); host.id = "toastHost"; active = null; global.document = { getElementById: id => (id === "toastHost" ? host : null), createElement: tag => node(tag), documentElement: { style: { setProperty() {} } }, }; Object.defineProperty(global.document, "activeElement", { get: () => active }); global.requestAnimationFrame = fn => fn(); }; const toastOnScreen = () => host.firstElementChild; const undoButton = () => toastOnScreen() && toastOnScreen().find("toastAction"); // The toast's own timers are the thing under test, so they are driven rather than // waited on. `resume` defers to the next tick on purpose — see feedback.js — so every // release is followed by a tick of 1 to let that land. const settle = t => t.mock.timers.tick(1); const show = (options = {}) => Toast.show(options.message || "Button moved on.", options); test.beforeEach(t => { t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); openDocument(); }); test.afterEach(() => { Toast.dismiss(); delete global.document; delete global.requestAnimationFrame; }); // --------------------------------------------------------------------------- describe("a toast nobody touches", () => { test("stays for the time it was given, and then goes", t => { show({ duration: 5000 }); t.mock.timers.tick(4900); assert.ok(toastOnScreen(), "it left early"); t.mock.timers.tick(200); assert.equal(toastOnScreen(), null, "it outstayed its duration"); }); test("and the default is the one a message with a button in it gets", () => { // Three to four seconds for a plain confirmation, six to ten for anything // carrying a button. Every plain one here names its own shorter duration; this // default is what the undoable steps and the wake lock fall back to. assert.ok(Toast.DEFAULT_DURATION_MS >= 6000 && Toast.DEFAULT_DURATION_MS <= 10000, `${Toast.DEFAULT_DURATION_MS}ms is outside the range for a toast with an action in it`); }); }); describe("a toast the dealer has reached for", () => { test("a finger on it stops the clock", t => { show({ duration: 8000 }); t.mock.timers.tick(1000); const toast = toastOnScreen(); toast.hovered = true; toast.fire("pointerenter"); t.mock.timers.tick(30000); assert.ok(toastOnScreen(), "eight seconds ran out under a finger that was holding it"); }); test("and so does tabbing onto the button", t => { show({ duration: 8000, actionLabel: "Undo", onAction: () => {} }); const toast = toastOnScreen(); undoButton().focus(); toast.fire("focusin", { relatedTarget: null }); t.mock.timers.tick(30000); assert.ok(toastOnScreen(), "the offer was withdrawn while the keyboard was standing on it"); assert.equal(active, undoButton(), "and focus went with it"); }); test("letting go starts it again", t => { show({ duration: 8000 }); const toast = toastOnScreen(); toast.hovered = true; toast.fire("pointerenter"); t.mock.timers.tick(30000); toast.hovered = false; toast.fire("pointerleave"); settle(t); t.mock.timers.tick(7000); assert.ok(toastOnScreen(), "it left before the time it still had"); t.mock.timers.tick(1200); assert.equal(toastOnScreen(), null, "letting go did not restart the clock at all"); }); test("and letting go late still leaves time to act", t => { // The half of this that pausing alone doesn't fix: touch it at 7.9 seconds of // 8, let go, and what is left of the clock to *notice* the message is 100ms — // gone while the thumb is still travelling to Undo. show({ duration: 8000 }); const toast = toastOnScreen(); t.mock.timers.tick(7900); toast.hovered = true; toast.fire("pointerenter"); t.mock.timers.tick(3000); toast.hovered = false; toast.fire("pointerleave"); settle(t); t.mock.timers.tick(Toast.RESUME_MS - 500); assert.ok(toastOnScreen(), "it went in the 100ms that was left rather than the time to act"); t.mock.timers.tick(700); assert.equal(toastOnScreen(), null, "and then it never went at all"); }); test("a cursor leaving while the keyboard is still inside does not restart it", t => { // Two ways to hold it open, one timer. The pointer leaving is not an answer to // "is anybody still using this?" show({ duration: 8000, actionLabel: "Undo", onAction: () => {} }); const toast = toastOnScreen(); toast.hovered = true; toast.fire("pointerenter"); undoButton().focus(); toast.fire("focusin", { relatedTarget: null }); toast.hovered = false; toast.fire("pointerleave"); settle(t); t.mock.timers.tick(30000); assert.ok(toastOnScreen(), "the clock restarted under a focused button"); }); }); describe("what happens to focus when it goes", () => { test("the dealer is put back where they had got to", t => { const elsewhere = node("button"); show({ actionLabel: "Undo", onAction: () => {} }); const toast = toastOnScreen(); elsewhere.focus(); undoButton().focus(); toast.fire("focusin", { relatedTarget: elsewhere }); Toast.dismiss(); settle(t); assert.equal(active, elsewhere, "focus was left on a button that no longer exists, which hands it to "); }); test("and not somewhere they left eight seconds ago", t => { // Captured when focus crosses into the toast, not when the toast appeared: a // dealer who carries on working and then tabs back should land where they got // to, not where they were when the message arrived. const whenItArrived = node("button"); const whereTheyGotTo = node("button"); whenItArrived.focus(); show({ actionLabel: "Undo", onAction: () => {} }); const toast = toastOnScreen(); whereTheyGotTo.focus(); undoButton().focus(); toast.fire("focusin", { relatedTarget: whereTheyGotTo }); Toast.dismiss(); settle(t); assert.equal(active, whereTheyGotTo); }); test("focus is left alone when it was never in the toast", () => { const elsewhere = node("button"); show({ actionLabel: "Undo", onAction: () => {} }); elsewhere.focus(); Toast.dismiss(); assert.equal(active, elsewhere, "dismissing a toast nobody was in moved the dealer"); }); test("and a control that has gone since is not reached for", () => { const gone = node("button"); show({ actionLabel: "Undo", onAction: () => {} }); const toast = toastOnScreen(); gone.focus(); undoButton().focus(); toast.fire("focusin", { relatedTarget: gone }); // A re-render between the toast going up and coming down. This happens on every // undo: restoreState rebuilds the table. gone.isConnected = false; assert.doesNotThrow(() => Toast.dismiss()); }); }); describe("the key that reaches it", () => { test("a toast says which shortcut presses its button", () => { show({ actionLabel: "Undo", shortcut: "undo", onAction: () => {} }); assert.equal(undoButton().dataset.shortcut, "undo"); }); test("and one that named none is not reachable by a stray keypress", () => { // "Turn off" on the wake lock and "Reload" on the other-tab warning both live // in this same strip. Neither is a thing U should be able to do. show({ actionLabel: "Turn off", onAction: () => {} }); assert.equal(undoButton().dataset.shortcut, undefined); }); test("both of the undoable paths opt in", () => { // runUndoable, for whole steps, and seatAction, for a gesture on a seat. const offers = MAIN.split('actionLabel: "Undo"').length - 1; const named = MAIN.split('shortcut: "undo"').length - 1; assert.equal(offers, 2, "the number of Undo toasts has changed"); assert.equal(named, offers, "an Undo the keyboard cannot reach"); }); test("the key presses the button rather than running its own copy of it", () => { // The house rule for every other shortcut in this file: press what is on the // screen, so there is no second code path to drift out of step with it. assert.match(MAIN, /#toastHost \.toastAction\[data-shortcut=/); assert.match(MAIN, /undo: \(\) => pressToastAction\("undo"\) \|\| pressInBar\("\.actionUndo"\)/, "the toast is the more recent of the two and has to be asked first"); }); test("and the legend covers both undos, because a dealer has no reason to know there are two", () => { const line = SHORTCUTS.match(/keys: \["U"\][^}]*what: "([^"]+)"/); assert.ok(line, "the U shortcut has no legend line"); assert.doesNotMatch(line[1], /last action$/, "the legend still promises only the bar's pill"); }); }); describe("one live region, and it is in the markup", () => { test("the host is the region", () => { assert.match(PAGE, /id="toastHost"[^>]*aria-live="polite"/); }); test("and a toast does not bring a second one with it", () => { // A region that arrives already carrying its text has not been watched by // anything yet, which is the documented reason such a message is never spoken. // The host is in the markup precisely so that it has been. assert.ok(!/toast\.setAttribute\("role"/.test(FEEDBACK), "each toast is its own live region again, nested inside the one that works"); show({}); assert.equal(toastOnScreen().getAttribute("role"), null); }); });