"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 () => '
详情
'}; }, }; 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)), }; }