Files
cmbuyer/admin/internal/transport/webui/static/drawer.test.js
T

212 lines
7.9 KiB
JavaScript
Raw Normal View History

"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)),
};
}