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

332 lines
15 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",
"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("market tabs send closed identity intents and reconcile C# order", () => {
assert.match(app, /send\("select-tab"/);
assert.match(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; \}/);
});