Files
MBN_STOCK_WEBVIEW/tests/LegacyParityWeb/thin-client.test.cjs

638 lines
26 KiB
JavaScript

"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const webRoot = path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web");
const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8");
const playout = fs.readFileSync(path.join(webRoot, "playout-ui.js"), "utf8");
const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
test("legacy shell exposes the original stock, cut, playlist and playout controls", () => {
for (const id of [
"stock-search",
"stock-search-button",
"stock-results",
"market-tabs",
"category-tree",
"catalog-expand-all",
"moving-average-5",
"moving-average-20",
"cut-list",
"playlist-drop-zone",
"playlist-rows",
"playlist-enable-all",
"playlist-move-up",
"playlist-move-down",
"playlist-delete-selected",
"playlist-delete-all",
"db-save-button",
"db-load-button",
"named-playlist-modal",
"named-playlist-definitions",
"named-playlist-new-title",
"named-playlist-create-button",
"named-playlist-delete-button",
"named-playlist-confirm-button",
"legacy-dialog"
]) {
assert.match(markup, new RegExp("id=\\\"" + id + "\\\""));
}
assert.match(markup, /TAKE IN/);
assert.match(markup, /TAKE OUT/);
assert.match(markup, /NEXT/);
assert.match(markup, /class="connection-state"[^>]*>미연결<\/div>/);
assert.match(markup, /class="active"[^>]*data-tab-id="overseas">해외<\/button>/);
assert.match(markup, /data-tab-id="tradingHalt">정지<\/button>/);
});
test("web layer sends intent and does not own scene, DB identity or playlist state", () => {
assert.doesNotMatch(app, /(?:sceneCode|cutCode|stockCode|dataCode|builderKey)/);
assert.doesNotMatch(app, /(?:localStorage|sessionStorage|playlist\.push|fetch\s*\()/);
assert.doesNotMatch(app, /\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\b/);
});
test("legacy stock double click is represented by pointer-down facts", () => {
assert.match(app, /cut-pointer-down/);
assert.match(app, /timestampMilliseconds/);
assert.match(app, /event\.ctrlKey/);
assert.match(app, /event\.shiftKey/);
const cutFactory = app.slice(app.indexOf("function createCutElement"), app.indexOf("function renderCuts"));
assert.doesNotMatch(cutFactory, /dblclick/);
});
test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => {
assert.match(app, /dataset\.physicalIndex/);
assert.match(app, /function createCutElement/);
assert.doesNotMatch(app, /cutList\.replaceChildren/);
});
test("state rendering preserves stock and playlist focus like native controls", () => {
assert.match(app, /dataset\.resultIndex/);
assert.match(app, /dataset\.rowId/);
assert.doesNotMatch(app, /stockResults\.replaceChildren/);
assert.doesNotMatch(app, /playlistRows\.replaceChildren/);
});
test("playlist controls send intent for selection, enabled state, movement and deletion", () => {
for (const intent of [
"select-playlist-row",
"set-playlist-enabled",
"set-all-playlist-enabled",
"reorder-playlist-rows",
"move-selected-playlist-rows",
"delete-selected-playlist-rows",
"delete-all-playlist-rows"
]) {
assert.match(app, new RegExp("send\\(\\\"" + intent + "\\\""));
}
assert.match(app, /control: event\.ctrlKey/);
assert.match(app, /shift: event\.shiftKey/);
assert.match(app, /event\.key === "Delete"/);
assert.match(app, /toggle-active-playlist-enabled/);
assert.match(app, /select-playlist-boundary/);
assert.match(app, /event\.key === "Home"/);
assert.match(app, /event\.key === "End"/);
assert.match(app, /row\.isSelected/);
assert.match(app, /enabled\.disabled = state\.isBusy === true/);
assert.match(app, /playlistEnableAll\.indeterminate/);
});
test("playlist reorder reconciles keyed DOM nodes without discarding focus", () => {
assert.match(app, /const existingRows = new Map\(\)/);
assert.match(app, /existingRows\.get\(row\.rowId\)/);
assert.match(app, /playlistRows\.insertBefore\(element, elementAtPosition\)/);
assert.match(app, /const retainedRowIds = new Set/);
assert.doesNotMatch(app, /playlistRows\.replaceChild/);
});
test("playlist native drag preserves a multi-selection and rejects checkbox drag", () => {
class MiniClassList {
constructor() { this.names = new Set(); }
add(...names) { names.forEach((name) => this.names.add(name)); }
remove(...names) { names.forEach((name) => this.names.delete(name)); }
contains(name) { return this.names.has(name); }
toggle(name, force) {
if (force === undefined ? !this.names.has(name) : force) this.names.add(name);
else this.names.delete(name);
}
}
class MiniElement extends EventTarget {
constructor(tagName) {
super();
this.tagName = tagName.toUpperCase();
this.dataset = {};
this.children = [];
this.parentElement = null;
this.attributes = new Map();
this.classList = new MiniClassList();
this.listeners = new Map();
}
set className(value) {
this.classList = new MiniClassList();
String(value).split(/\s+/).filter(Boolean)
.forEach((name) => this.classList.add(name));
}
get className() { return [...this.classList.names].join(" "); }
appendChild(child) {
child.parentElement = this;
this.children.push(child);
return child;
}
contains(candidate) {
return candidate === this || this.children.some((child) => child.contains(candidate));
}
closest(selector) {
const tags = selector.split(",").map((value) => value.trim().toUpperCase());
return tags.includes(this.tagName)
? this
: this.parentElement?.closest(selector) || null;
}
focus() {}
addEventListener(type, listener, options) {
if (!this.listeners.has(type)) this.listeners.set(type, []);
this.listeners.get(type).push(listener);
super.addEventListener(type, listener, options);
}
invoke(type, event) {
for (const listener of this.listeners.get(type) || []) {
listener.call(this, event);
}
}
getBoundingClientRect() { return { top: 0, height: 20 }; }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
querySelectorAll(selector) {
const descendants = [];
const visit = (element) => {
element.children.forEach((child) => {
descendants.push(child);
visit(child);
});
};
visit(this);
if (selector === '.playlist-data-row[aria-selected="true"]') {
return descendants.filter((element) =>
element.classList.contains("playlist-data-row") &&
element.getAttribute("aria-selected") === "true");
}
if (selector === ".dragging, .drag-before, .drag-after") {
return descendants.filter((element) =>
["dragging", "drag-before", "drag-after"]
.some((name) => element.classList.contains(name)));
}
return [];
}
}
class MiniDataTransfer {
constructor() { this.values = new Map(); }
setData(type, value) { this.values.set(type, value); }
getData(type) { return this.values.get(type) || ""; }
}
const sourceStart = app.indexOf("function clearPlaylistDragVisuals");
const sourceEnd = app.indexOf("function renderPlaylist", sourceStart);
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
const createHarness = new Function("document", "playlistRows", "send", "playlistDragMime", `
let draggedPlaylistRowId = null;
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragBlockedRowId = null;
let playlistMutationLocked = false;
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
}
${app.slice(sourceStart, sourceEnd)}
return {
createPlaylistElement,
onPlaylistDragStart,
state: () => ({ draggedPlaylistRowId, deferredPlaylistPlainSelectionRowId })
};
`);
const document = { createElement: (tagName) => new MiniElement(tagName) };
const playlistRows = new MiniElement("div");
const sent = [];
const harness = createHarness(
document, playlistRows, (type, payload) => sent.push({ type, payload }),
"text/x-mbn-playlist-row");
const source = harness.createPlaylistElement({ rowId: "row-a" });
source.setAttribute("aria-selected", "true");
playlistRows.appendChild(source);
const selectedPeer = harness.createPlaylistElement({ rowId: "row-b" });
selectedPeer.setAttribute("aria-selected", "true");
playlistRows.appendChild(selectedPeer);
const target = harness.createPlaylistElement({ rowId: "row-c" });
target.setAttribute("aria-selected", "false");
playlistRows.appendChild(target);
const checkbox = source.children[0].children[0];
const blockedTransfer = new MiniDataTransfer();
let checkboxDragPrevented = false;
source.invoke("pointerdown", {
button: 0,
currentTarget: source,
target: checkbox,
ctrlKey: false,
shiftKey: false
});
harness.onPlaylistDragStart({
currentTarget: source,
// Chromium reports the draggable row as dragstart.target even though
// pointerdown originated on the checkbox.
target: source,
dataTransfer: blockedTransfer,
preventDefault: () => { checkboxDragPrevented = true; }
});
assert.equal(checkboxDragPrevented, true);
assert.equal(blockedTransfer.getData("text/x-mbn-playlist-row"), "");
const pointerDown = new Event("pointerdown", { cancelable: true });
Object.assign(pointerDown, { button: 0, ctrlKey: false, shiftKey: false });
source.dispatchEvent(pointerDown);
assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, "row-a");
assert.deepEqual(sent, []);
const transfer = new MiniDataTransfer();
const dragStart = new Event("dragstart", { cancelable: true });
Object.assign(dragStart, { dataTransfer: transfer });
source.dispatchEvent(dragStart);
assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, null);
assert.equal(transfer.getData("text/x-mbn-playlist-row"), "row-a");
assert.deepEqual(sent, []);
const dragOver = new Event("dragover", { cancelable: true });
Object.assign(dragOver, { dataTransfer: transfer, clientY: 1 });
target.dispatchEvent(dragOver);
assert.equal(dragOver.defaultPrevented, true);
const drop = new Event("drop", { cancelable: true });
Object.assign(drop, { dataTransfer: transfer, clientY: 1 });
target.dispatchEvent(drop);
assert.deepEqual(sent, [{
type: "reorder-playlist-rows",
payload: { sourceRowId: "row-a", targetRowId: "row-c", position: "before" }
}]);
});
test("playlist drop zone accepts only the exact selected-cut drag token", () => {
class MiniDataTransfer {
constructor() { this.values = new Map(); }
get types() { return [...this.values.keys()]; }
setData(type, value) { this.values.set(type, value); }
getData(type) { return this.values.get(type) || ""; }
}
const sourceStart = app.indexOf("function hasDragType");
const sourceEnd = app.indexOf(
'playlistMoveUp.addEventListener("click"', sourceStart);
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
const playlistDropZone = new EventTarget();
const sent = [];
new Function(
"playlistDropZone",
"send",
"selectedCutsDragToken",
app.slice(sourceStart, sourceEnd))(
playlistDropZone,
(type, payload) => sent.push({ type, payload }),
"legacy-selected-cuts");
const playlistTransfer = new MiniDataTransfer();
playlistTransfer.setData("text/x-mbn-playlist-row", "row-a");
const playlistDrop = new Event("drop", { cancelable: true, bubbles: true });
Object.assign(playlistDrop, { dataTransfer: playlistTransfer });
playlistDropZone.dispatchEvent(playlistDrop);
assert.equal(playlistDrop.defaultPrevented, false);
assert.deepEqual(sent, []);
const externalText = new MiniDataTransfer();
externalText.setData("text/plain", "external-text");
const externalDrop = new Event("drop", { cancelable: true, bubbles: true });
Object.assign(externalDrop, { dataTransfer: externalText });
playlistDropZone.dispatchEvent(externalDrop);
assert.equal(externalDrop.defaultPrevented, false);
assert.deepEqual(sent, []);
const cutTransfer = new MiniDataTransfer();
cutTransfer.setData("text/plain", "legacy-selected-cuts");
const cutOver = new Event("dragover", { cancelable: true });
Object.assign(cutOver, { dataTransfer: cutTransfer });
playlistDropZone.dispatchEvent(cutOver);
assert.equal(cutOver.defaultPrevented, true);
const cutDrop = new Event("drop", { cancelable: true, bubbles: true });
Object.assign(cutDrop, { dataTransfer: cutTransfer });
playlistDropZone.dispatchEvent(cutDrop);
assert.equal(cutDrop.defaultPrevented, true);
assert.deepEqual(sent, [{ type: "drop-selected-cuts", payload: {} }]);
});
test("operator catalog drag rejects an interactive child pointer origin", () => {
class MiniDataTransfer {
constructor() { this.values = new Map(); }
setData(type, value) { this.values.set(type, value); }
getData(type) { return this.values.get(type) || ""; }
}
const sourceStart = app.indexOf("function clearOperatorCatalogDragVisuals");
const sourceEnd = app.indexOf("function renderOperatorCatalog", sourceStart);
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
const createHarness = new Function(
"operatorCatalogDraftRows",
"send",
"operatorCatalogDragMime",
`
let draggedOperatorCatalogRowId = null;
let operatorCatalogDragBlockedRowId = null;
let operatorCatalogMutationLocked = false;
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
}
${app.slice(sourceStart, sourceEnd)}
return {
onOperatorCatalogPointerDown,
onOperatorCatalogDragStart,
state: () => ({ draggedOperatorCatalogRowId, operatorCatalogDragBlockedRowId })
};
`);
const harness = createHarness(
{ querySelectorAll: () => [] },
() => {},
"text/x-mbn-operator-catalog-row");
const row = {
dataset: { operatorCatalogRowId: "catalog-draft-row-a" },
classList: { add() {}, remove() {} },
closest: () => null
};
const input = { closest: () => input };
harness.onOperatorCatalogPointerDown({
button: 0,
currentTarget: row,
target: input
});
const blockedTransfer = new MiniDataTransfer();
let prevented = false;
harness.onOperatorCatalogDragStart({
currentTarget: row,
target: row,
dataTransfer: blockedTransfer,
preventDefault: () => { prevented = true; }
});
assert.equal(prevented, true);
assert.equal(
blockedTransfer.getData("text/x-mbn-operator-catalog-row"),
"");
assert.deepEqual(harness.state(), {
draggedOperatorCatalogRowId: null,
operatorCatalogDragBlockedRowId: null
});
harness.onOperatorCatalogPointerDown({
button: 0,
currentTarget: row,
target: row
});
const allowedTransfer = new MiniDataTransfer();
harness.onOperatorCatalogDragStart({
currentTarget: row,
target: row,
dataTransfer: allowedTransfer,
preventDefault: () => { throw new Error("non-interactive drag was rejected"); }
});
assert.equal(
allowedTransfer.getData("text/x-mbn-operator-catalog-row"),
"catalog-draft-row-a");
});
test("market tabs send closed identity intents and reconcile C# order", () => {
assert.match(app, /send\("select-tab"/);
assert.match(app, /send\("hover-swap-tabs"/);
assert.match(app, /expectedSourceIndex: sourceIndex/);
assert.match(app, /expectedTargetIndex: targetIndex/);
assert.doesNotMatch(app, /send\("swap-tabs"/);
assert.match(app, /dataset\.tabId/);
assert.match(app, /const existingTabs = new Map\(\)/);
assert.match(app, /marketTabs\.insertBefore/);
assert.doesNotMatch(app, /selectedTab\s*=/);
});
test("fixed catalog renders C# view and sends opaque leaf or section intent", () => {
assert.match(app, /state\.fixedCatalog/);
assert.match(app, /dataset\.actionId/);
assert.match(app, /dataset\.sectionIndex/);
assert.match(app, /send\("activate-fixed-action"/);
assert.match(app, /send\("activate-fixed-section"/);
assert.match(app, /send\("activate-industry-section"/);
assert.match(app, /send\("activate-comparison-section"/);
assert.match(app, /send\("set-moving-averages"/);
assert.doesNotMatch(app, /builderKey|sceneCode|cutCode|dataCode/);
});
test("fixed-262 ignores a single click and emits exactly one intent on double click", () => {
const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"');
const clickStart = app.indexOf(
'categoryTree.addEventListener("click"', doubleClickStart + 1);
const changeStart = app.indexOf(
'categoryTree.addEventListener("change"', clickStart + 1);
assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart);
class FixedActionElement extends EventTarget {
constructor() {
super();
this.dataset = { actionId: "fixed-262" };
this.disabled = false;
}
closest(selector) {
return selector === "button[data-action-id]" ? this : null;
}
}
const categoryTree = new FixedActionElement();
const sent = [];
const send = (type, payload) => sent.push({ type, payload });
new Function("categoryTree", "send", app.slice(doubleClickStart, clickStart))(
categoryTree, send);
new Function("categoryTree", "send", app.slice(clickStart, changeStart))(
categoryTree, send);
categoryTree.dispatchEvent(new Event("click", { bubbles: true, cancelable: true }));
assert.deepEqual(sent, []);
categoryTree.dispatchEvent(new Event("dblclick", { bubbles: true, cancelable: true }));
assert.deepEqual(sent, [{
type: "activate-fixed-action",
payload: { actionId: "fixed-262" }
}]);
});
test("industry workspace sends index and opaque action intents only", () => {
assert.match(app, /state\.industry/);
assert.match(app, /dataset\.industryIndex/);
assert.match(app, /dataset\.industryActionId/);
assert.match(app, /send\("select-industry"/);
assert.match(app, /send\("double-click-industry"/);
assert.match(app, /send\("swap-industries"/);
assert.match(app, /send\("activate-industry-action"/);
});
test("comparison workspace sends opaque target, pair and action intents", () => {
for (const intent of [
"search-comparison-domestic", "search-comparison-world",
"select-comparison-domestic", "select-comparison-world",
"select-comparison-fixed",
"choose-comparison-domestic", "choose-comparison-world",
"choose-comparison-fixed", "swap-comparison-targets",
"clear-comparison-targets", "add-comparison-pair",
"select-comparison-pair", "move-comparison-pair",
"delete-selected-comparison-pair", "delete-all-comparison-pairs",
"activate-comparison-action"
]) assert.match(app, new RegExp("\\\"" + intent + "\\\""));
assert.match(app, /dataset\.comparisonSelectionId/);
assert.match(app, /dataset\.comparisonPairId/);
assert.match(app, /dataset\.comparisonActionId/);
assert.doesNotMatch(app, /send\([^\n]*stockCode/);
});
test("theme workspace sends query, opaque selection, sort and action intents", () => {
assert.match(app, /state\.theme/);
assert.match(app, /dataset\.themeResultId/);
assert.match(app, /dataset\.themeActionId/);
assert.match(app, /send\("search-themes"/);
assert.match(app, /send\("select-theme"/);
assert.match(app, /send\("set-theme-sort"/);
assert.match(app, /send\("activate-theme-action"/);
});
test("expert and trading halt workspaces send opaque selection and action intents", () => {
for (const intent of [
"search-experts", "select-expert", "activate-expert-action",
"search-trading-halts", "select-trading-halt", "toggle-trading-halt-sort",
"activate-trading-halt-action"
]) assert.match(app, new RegExp("send\\(\\\"" + intent + "\\\""));
assert.match(app, /dataset\.expertSelectionId/);
assert.match(app, /dataset\.haltSelectionId/);
});
test("theme expert and trading halt actions preserve original single click wiring", () => {
const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"');
const clickStart = app.indexOf('categoryTree.addEventListener("click"', doubleClickStart + 1);
const changeStart = app.indexOf('categoryTree.addEventListener("change"', clickStart + 1);
assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart);
const doubleClickDelegate = app.slice(doubleClickStart, clickStart);
const clickDelegate = app.slice(clickStart, changeStart);
for (const intent of [
"activate-theme-action",
"activate-expert-action",
"activate-trading-halt-action"
]) {
assert.match(clickDelegate, new RegExp("send\\(\\\"" + intent + "\\\""));
assert.doesNotMatch(doubleClickDelegate, new RegExp("send\\(\\\"" + intent + "\\\""));
assert.equal(app.split('send("' + intent + '"').length - 1, 1);
}
assert.match(doubleClickDelegate, /send\("double-click-industry"/);
for (const intent of [
"choose-comparison-domestic", "choose-comparison-world", "choose-comparison-fixed"
]) {
assert.match(doubleClickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s"));
assert.doesNotMatch(clickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s"));
}
for (const intent of [
"select-comparison-domestic", "select-comparison-world", "select-comparison-fixed"
]) {
assert.match(clickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s"));
assert.doesNotMatch(doubleClickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s"));
}
assert.match(clickDelegate, /send\("select-theme"/);
assert.match(clickDelegate, /send\("select-expert"/);
assert.match(clickDelegate, /send\("select-trading-halt"/);
});
test("named DB playlists and playout use separate closed native intents", () => {
for (const intent of [
"refresh-named-playlists", "select-named-playlist",
"load-selected-named-playlist", "create-named-playlist",
"save-current-named-playlist", "delete-selected-named-playlist"
]) assert.match(app, new RegExp("\\\"" + intent + "\\\""));
assert.match(app, /state\.namedPlaylist/);
assert.match(app, /dataset\.namedPlaylistDefinitionId/);
assert.match(app, /definitionId: definition\.dataset\.namedPlaylistDefinitionId/);
assert.match(app, /dbLoadButton\.disabled = busy \|\| !named/);
assert.match(app, /named\.isWriteQuarantined/);
assert.doesNotMatch(app, /programCode|listText/i);
for (const label of ["PREPARE", "TAKE IN", "NEXT", "TAKE OUT"]) {
assert.match(markup, new RegExp("<button[^>]*disabled[^>]*>[^<]*" + label));
}
for (const intent of [
"prepare-playout", "take-in", "next-playout", "take-out",
"choose-background", "toggle-background", "set-fade-duration"
]) assert.match(playout, new RegExp("\\\"" + intent + "\\\""));
assert.match(playout, /playout\.phase !== "prepared"/);
assert.match(playout, /playout\.isPlayCompletionPending === true/);
assert.match(playout, /playout\.outcomeUnknown !== true/);
assert.doesNotMatch(playout, /fetch\s*\(|localStorage|sessionStorage|sceneCode|filePath/);
});
test("GraphE preserves name header sorting and directional find in native authority", () => {
for (const intent of [
"find-manual-financial", "toggle-manual-financial-name-sort"
]) assert.match(app, new RegExp("\\\"" + intent + "\\\""));
assert.match(app, /id = "manual-financial-find-query"/);
assert.match(app, /dataset\.manualFindDirection/);
assert.match(app, /dataset\.manualNameSort/);
assert.match(app, /dataset\.manualGeneration/);
assert.match(app, /generation: generation/);
assert.match(app, /manual-financial-find-query[\s\S]*down\.click\(\)/);
assert.doesNotMatch(app, /(?:INPUT_PIE|INPUT_GROW|INPUT_SELL|INPUT_PROFIT)[\s\S]*manual-financial-find-query/);
});
test("FSell and VI dialogs send opaque native intents and never own file or stock authority", () => {
for (const id of ["manual-list-modal", "manual-list-workspace", "manual-list-close"]) {
assert.match(markup, new RegExp("id=\\\"" + id + "\\\""));
}
for (const screen of ["individual", "foreign", "institution", "vi"]) {
assert.match(markup, new RegExp("data-manual-list-screen=\\\"" + screen + "\\\""));
}
for (const intent of [
"open-manual-list", "close-manual-list", "refresh-manual-list",
"set-manual-net-sell-cell", "search-manual-vi", "select-manual-vi-result",
"add-manual-vi-result",
"move-manual-vi-item", "delete-manual-vi-item",
"delete-all-manual-vi-items", "confirm-manual-list",
"import-manual-lists"
]) assert.match(app, new RegExp("\\\"" + intent + "\\\""));
for (const obsoleteIntent of ["save-manual-net-sell", "add-manual-net-sell-cut", "save-manual-vi"]) {
assert.doesNotMatch(app, new RegExp("\\\"" + obsoleteIntent + "\\\""));
}
const start = app.indexOf("function renderManualLists");
const end = app.indexOf("function openNamedPlaylist", start);
const manualLists = app.slice(start, end);
assert.match(manualLists, /rowId: row\.rowId/);
assert.match(manualLists, /resultId: row\.resultId/);
assert.match(manualLists, /itemId: row\.itemId/);
assert.match(manualLists, /addEventListener\("click"[\s\S]*setTimeout[\s\S]*send\("select-manual-vi-result"/);
assert.match(manualLists, /addEventListener\("dblclick"[\s\S]*send\("add-manual-vi-result"/);
assert.doesNotMatch(manualLists, /(?:rowVersion|stockCode|item\.code|filePath|directory|localStorage|fetch\s*\()/i);
});
test("separator rows preserve the original drag gesture", () => {
assert.match(app, /element\.draggable = true/);
assert.doesNotMatch(app, /draggable = !row\.isSeparator/);
});
test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => {
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
assert.match(app, /state\.isBusy === true/);
assert.match(app, /classList\.toggle\("busy"/);
assert.match(styles, /body\.busy \.operator-shell \{ pointer-events: none; \}/);
});