212 lines
7.9 KiB
JavaScript
212 lines
7.9 KiB
JavaScript
"use strict";
|
|||
|
|
|
||
|
|
const test = require("node:test");
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const fs = require("node:fs");
|
||
|
|
const path = require("node:path");
|
||
|
|
const vm = require("node:vm");
|
||
|
|
|
||
|
|
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
|
||
|
|
|
||
|
|
test("visible button opens the same routed detail and close restores list state", async () => {
|
||
|
|
const harness = createDrawerHarness();
|
||
|
|
|
||
|
|
harness.button.listeners.click();
|
||
|
|
await harness.flush();
|
||
|
|
|
||
|
|
assert.equal(harness.requests.length, 1);
|
||
|
|
assert.equal(harness.requests[0].url, "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301");
|
||
|
|
assert.equal(harness.requests[0].options.headers["X-CMBuyer-View"], "drawer");
|
||
|
|
assert.equal(harness.drawer.open, true);
|
||
|
|
assert.equal(harness.closeButton.focused, true);
|
||
|
|
assert.equal(harness.history.pushes.length, 1);
|
||
|
|
assert.equal(harness.history.pushes[0].url, harness.requests[0].url);
|
||
|
|
assert.equal(harness.history.pushes[0].state.focusTarget, "button");
|
||
|
|
|
||
|
|
harness.closeButton.listeners.click();
|
||
|
|
assert.equal(harness.history.backCalls, 1);
|
||
|
|
harness.popstate({state: {cmbuyerList: true}});
|
||
|
|
assert.equal(harness.drawer.open, false);
|
||
|
|
assert.equal(harness.button.focused, true);
|
||
|
|
assert.equal(harness.row.focused, false);
|
||
|
|
assert.equal(harness.scrolls.length, 1);
|
||
|
|
assert.equal(harness.scrolls[0].top, 275);
|
||
|
|
assert.equal(harness.scrolls[0].behavior, "auto");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("button focus target survives back, forward, and back again", async () => {
|
||
|
|
const harness = createDrawerHarness();
|
||
|
|
harness.button.listeners.click();
|
||
|
|
await harness.flush();
|
||
|
|
const drawerState = harness.history.pushes[0].state;
|
||
|
|
|
||
|
|
harness.popstate({state: {cmbuyerList: true}});
|
||
|
|
assert.equal(harness.button.focusCalls, 1);
|
||
|
|
assert.equal(harness.row.focusCalls, 0);
|
||
|
|
|
||
|
|
harness.popstate({state: drawerState});
|
||
|
|
await harness.flush();
|
||
|
|
assert.equal(harness.drawer.open, true);
|
||
|
|
assert.equal(harness.history.pushes.length, 1);
|
||
|
|
|
||
|
|
harness.popstate({state: {cmbuyerList: true}});
|
||
|
|
assert.equal(harness.drawer.open, false);
|
||
|
|
assert.equal(harness.button.focusCalls, 2);
|
||
|
|
assert.equal(harness.row.focusCalls, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("failed forward retry reuses history and closes back to button in one step", async () => {
|
||
|
|
const harness = createDrawerHarness();
|
||
|
|
harness.button.listeners.click();
|
||
|
|
await harness.flush();
|
||
|
|
const drawerState = harness.history.pushes[0].state;
|
||
|
|
harness.popstate({state: {cmbuyerList: true}});
|
||
|
|
|
||
|
|
harness.failNextRequest();
|
||
|
|
harness.popstate({state: drawerState});
|
||
|
|
await harness.flush();
|
||
|
|
const retry = harness.body.children[1].children[0];
|
||
|
|
retry.listeners.click();
|
||
|
|
await harness.flush();
|
||
|
|
|
||
|
|
assert.equal(harness.history.pushes.length, 1);
|
||
|
|
assert.equal(harness.drawer.open, true);
|
||
|
|
harness.closeButton.listeners.click();
|
||
|
|
assert.equal(harness.history.backCalls, 1);
|
||
|
|
harness.popstate({state: {cmbuyerList: true}});
|
||
|
|
assert.equal(harness.drawer.open, false);
|
||
|
|
assert.equal(harness.button.focusCalls, 2);
|
||
|
|
assert.equal(harness.row.focusCalls, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("double click and Enter open rows but nested controls never do", async () => {
|
||
|
|
const harness = createDrawerHarness();
|
||
|
|
const ignored = {closest: () => ({})};
|
||
|
|
const rowTarget = {closest: () => null};
|
||
|
|
|
||
|
|
harness.row.listeners.dblclick({target: ignored});
|
||
|
|
harness.row.listeners.dblclick({target: rowTarget});
|
||
|
|
await harness.flush();
|
||
|
|
assert.equal(harness.requests.length, 1);
|
||
|
|
|
||
|
|
harness.popstate({state: {cmbuyerList: true}});
|
||
|
|
let prevented = false;
|
||
|
|
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault: () => { prevented = true; }});
|
||
|
|
await harness.flush();
|
||
|
|
assert.equal(prevented, true);
|
||
|
|
assert.equal(harness.requests.length, 2);
|
||
|
|
|
||
|
|
harness.row.listeners.keydown({key: "Enter", target: ignored, preventDefault: () => assert.fail("nested control Enter was intercepted")});
|
||
|
|
assert.equal(harness.requests.length, 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("browser back and forward close and reopen without duplicating history", async () => {
|
||
|
|
const harness = createDrawerHarness();
|
||
|
|
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault() {}});
|
||
|
|
await harness.flush();
|
||
|
|
assert.equal(harness.history.pushes.length, 1);
|
||
|
|
|
||
|
|
harness.popstate({state: {cmbuyerList: true}});
|
||
|
|
assert.equal(harness.drawer.open, false);
|
||
|
|
harness.popstate({state: {cmbuyerDrawer: true, detailURL: harness.row.dataset.detailUrl}});
|
||
|
|
await harness.flush();
|
||
|
|
|
||
|
|
assert.equal(harness.drawer.open, true);
|
||
|
|
assert.equal(harness.requests.length, 2);
|
||
|
|
assert.equal(harness.history.pushes.length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Escape follows browser history and does not mutate list URL", async () => {
|
||
|
|
const harness = createDrawerHarness();
|
||
|
|
harness.button.listeners.click();
|
||
|
|
await harness.flush();
|
||
|
|
let prevented = false;
|
||
|
|
|
||
|
|
harness.drawer.listeners.cancel({preventDefault: () => { prevented = true; }});
|
||
|
|
|
||
|
|
assert.equal(prevented, true);
|
||
|
|
assert.equal(harness.history.backCalls, 1);
|
||
|
|
assert.equal(harness.history.replaces[0].url, "/tasks?status=DRAFT");
|
||
|
|
});
|
||
|
|
|
||
|
|
function createDrawerHarness() {
|
||
|
|
class FakeElement {
|
||
|
|
constructor() {
|
||
|
|
this.listeners = {};
|
||
|
|
this.dataset = {};
|
||
|
|
this.open = false;
|
||
|
|
this.focused = false;
|
||
|
|
this.focusCalls = 0;
|
||
|
|
this.children = [];
|
||
|
|
this._innerHTML = "";
|
||
|
|
}
|
||
|
|
addEventListener(type, listener) { this.listeners[type] = listener; }
|
||
|
|
focus() { this.focused = true; this.focusCalls++; }
|
||
|
|
showModal() { this.open = true; }
|
||
|
|
close() { this.open = false; }
|
||
|
|
replaceChildren(...children) { this.children = children; this._innerHTML = ""; }
|
||
|
|
append(...children) { this.children.push(...children); }
|
||
|
|
setAttribute() {}
|
||
|
|
closest() { return null; }
|
||
|
|
set innerHTML(value) { this._innerHTML = value; }
|
||
|
|
get innerHTML() { return this._innerHTML; }
|
||
|
|
}
|
||
|
|
|
||
|
|
const body = new FakeElement();
|
||
|
|
const closeButton = new FakeElement();
|
||
|
|
const button = new FakeElement();
|
||
|
|
const row = new FakeElement();
|
||
|
|
row.dataset.detailUrl = "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301";
|
||
|
|
row.querySelector = (selector) => selector === "[data-open-detail]" ? button : null;
|
||
|
|
const drawer = new FakeElement();
|
||
|
|
drawer.querySelector = (selector) => ({"[data-detail-body]": body, "[data-close-detail]": closeButton})[selector] || null;
|
||
|
|
|
||
|
|
const requests = [];
|
||
|
|
const popstateListeners = [];
|
||
|
|
const scrolls = [];
|
||
|
|
let failNext = false;
|
||
|
|
const history = {
|
||
|
|
state: null,
|
||
|
|
pushes: [],
|
||
|
|
replaces: [],
|
||
|
|
backCalls: 0,
|
||
|
|
pushState(state, _title, url) { this.state = state; this.pushes.push({state, url}); },
|
||
|
|
replaceState(state, _title, url) { this.state = state; this.replaces.push({state, url}); },
|
||
|
|
back() { this.backCalls++; },
|
||
|
|
};
|
||
|
|
const document = {
|
||
|
|
querySelector: (selector) => selector === "[data-start-purchases]" ? null : selector === "[data-detail-drawer]" ? drawer : null,
|
||
|
|
querySelectorAll: (selector) => selector === "[data-task-row]" ? [row] : [],
|
||
|
|
createElement: () => new FakeElement(),
|
||
|
|
contains: (element) => element === row || element === button,
|
||
|
|
};
|
||
|
|
const window = {
|
||
|
|
location: {pathname: "/tasks", search: "?status=DRAFT"},
|
||
|
|
history,
|
||
|
|
scrollY: 275,
|
||
|
|
scrollTo: (value) => scrolls.push(value),
|
||
|
|
addEventListener(type, listener) { if (type === "popstate") popstateListeners.push(listener); },
|
||
|
|
};
|
||
|
|
const context = {
|
||
|
|
AbortController,
|
||
|
|
document,
|
||
|
|
window,
|
||
|
|
fetch: async (url, options) => {
|
||
|
|
requests.push({url, options});
|
||
|
|
if (failNext) {
|
||
|
|
failNext = false;
|
||
|
|
return {ok: false, headers: {get: () => "text/html"}, text: async () => ""};
|
||
|
|
}
|
||
|
|
return {ok: true, headers: {get: () => "text/html; charset=utf-8"}, text: async () => '<article data-task-detail-content>详情</article>'};
|
||
|
|
},
|
||
|
|
};
|
||
|
|
vm.runInNewContext(source, context, {filename: "tasks.js"});
|
||
|
|
|
||
|
|
return {
|
||
|
|
body, button, closeButton, drawer, history, requests, row, scrolls,
|
||
|
|
failNextRequest: () => { failNext = true; },
|
||
|
|
popstate: (event) => { history.state = event.state; popstateListeners.forEach((listener) => listener(event)); },
|
||
|
|
flush: () => new Promise((resolve) => setImmediate(resolve)),
|
||
|
|
};
|
||
|
|
}
|