283 lines
13 KiB
JavaScript
283 lines
13 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 markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
|
|
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
|
|
const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8");
|
|
const navigation = fs.readFileSync(path.join(webRoot, "workspace-navigation.js"), "utf8");
|
|
const packageInputHarness = fs.readFileSync(path.join(
|
|
repositoryRoot, "scripts", "Test-LegacyPackageInput.mjs"), "utf8");
|
|
const uiOnlySmokeHarness = fs.readFileSync(path.join(
|
|
repositoryRoot, "scripts", "Test-LegacyPackageUiOnlySmoke.mjs"), "utf8");
|
|
|
|
const expectedMarketTabs = [
|
|
"overseas",
|
|
"exchange",
|
|
"index",
|
|
"kospiIndustry",
|
|
"kosdaqIndustry",
|
|
"comparison",
|
|
"theme",
|
|
"overseasStocks",
|
|
"expert",
|
|
"tradingHalt"
|
|
];
|
|
|
|
function pendingMenuFocusHarness() {
|
|
const start = navigation.indexOf("function syncMarket");
|
|
const end = navigation.indexOf("function select", start);
|
|
assert.ok(start >= 0 && end > start);
|
|
const source = navigation.slice(start, end);
|
|
const documentObject = {
|
|
body: {},
|
|
documentElement: {},
|
|
activeElement: null
|
|
};
|
|
const catalogChild = {};
|
|
const unrelatedNavigationItem = {};
|
|
let scheduled = null;
|
|
const pendingButton = {
|
|
disabled: false,
|
|
focused: false,
|
|
focus() {
|
|
this.focused = true;
|
|
documentObject.activeElement = this;
|
|
}
|
|
};
|
|
const api = new Function(
|
|
"document",
|
|
"window",
|
|
"catalogWorkspace",
|
|
"pendingButton",
|
|
`
|
|
let activeWorkspace = "catalog";
|
|
let activeMarketTabId = "old";
|
|
let pendingKeyboardMarketTabId = "theme";
|
|
const region = { dataset: {} };
|
|
const title = { textContent: "" };
|
|
function marketButton(tabId) {
|
|
return tabId === "theme" ? pendingButton : null;
|
|
}
|
|
function refreshCurrentMenu() {}
|
|
function marketLabel() { return "테마"; }
|
|
${source}
|
|
return { syncMarket, hasPendingKeyboardMarketFocus };
|
|
`)(
|
|
documentObject,
|
|
{ requestAnimationFrame(callback) { scheduled = callback; } },
|
|
{ contains(element) { return element === catalogChild; } },
|
|
pendingButton);
|
|
return {
|
|
...api,
|
|
documentObject,
|
|
catalogChild,
|
|
unrelatedNavigationItem,
|
|
pendingButton,
|
|
runFrame() {
|
|
assert.equal(typeof scheduled, "function");
|
|
scheduled();
|
|
}
|
|
};
|
|
}
|
|
|
|
test("operator shell exposes stock and each graphic screen beside an always-present schedule", () => {
|
|
for (const id of [
|
|
"workspace-region",
|
|
"workspace-navigation",
|
|
"workspace-nav-toggle",
|
|
"workspace-stock-tab",
|
|
"market-tabs",
|
|
"workspace-settings-tab",
|
|
"workspace-title",
|
|
"stock-workspace",
|
|
"catalog-workspace",
|
|
"settings-workspace",
|
|
"playlist-drop-zone"
|
|
]) {
|
|
assert.match(markup, new RegExp(`id="${id}"`));
|
|
}
|
|
|
|
const workspaceStart = markup.indexOf('id="workspace-region"');
|
|
const navigationStart = markup.indexOf('id="workspace-navigation"');
|
|
const marketStart = markup.indexOf('id="market-tabs"');
|
|
const settingsTabStart = markup.indexOf('id="workspace-settings-tab"');
|
|
const stockStart = markup.indexOf('id="stock-workspace"');
|
|
const catalogStart = markup.indexOf('id="catalog-workspace"');
|
|
const settingsStart = markup.indexOf('id="settings-workspace"');
|
|
const scheduleStart = markup.indexOf('class="playlist-panel"');
|
|
assert.ok(workspaceStart >= 0 && navigationStart > workspaceStart);
|
|
assert.ok(marketStart > navigationStart && settingsTabStart > marketStart);
|
|
assert.ok(stockStart > settingsTabStart);
|
|
assert.ok(catalogStart > stockStart);
|
|
assert.ok(settingsStart > catalogStart && scheduleStart > settingsStart);
|
|
assert.match(markup,
|
|
/<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*<section class="playlist-panel"/);
|
|
assert.match(markup, /id="catalog-workspace"[^>]*hidden inert/);
|
|
assert.match(markup, /id="settings-workspace"[^>]*hidden inert/);
|
|
assert.match(markup, /<small>항상 표시<\/small><h2>송출 스케줄<\/h2>/);
|
|
});
|
|
|
|
test("the sidebar owns the ten native market buttons and keeps settings outside their order", () => {
|
|
const marketStart = markup.indexOf('<div id="market-tabs"');
|
|
const marketEnd = markup.indexOf("</div>", marketStart);
|
|
assert.ok(marketStart >= 0 && marketEnd > marketStart);
|
|
const marketMarkup = markup.slice(marketStart, marketEnd);
|
|
const actualTabs = Array.from(
|
|
marketMarkup.matchAll(/data-tab-id="([^"]+)"/g), match => match[1]);
|
|
assert.deepEqual(actualTabs, expectedMarketTabs);
|
|
assert.equal((marketMarkup.match(/<button\b/g) || []).length, expectedMarketTabs.length);
|
|
assert.doesNotMatch(marketMarkup, /workspace-settings-tab|workspace-nav-section-label/);
|
|
for (const tabId of expectedMarketTabs) {
|
|
assert.match(marketMarkup,
|
|
new RegExp(`data-tab-id="${tabId}"[\\s\\S]*?class="workspace-nav-label"`));
|
|
}
|
|
});
|
|
|
|
test("layout gives the always-visible schedule a wider responsive share", () => {
|
|
assert.match(styles,
|
|
/grid-template-columns:\s*minmax\(900px, 1\.75fr\) minmax\(600px, 1fr\)/);
|
|
assert.match(styles,
|
|
/\.workspace-region\s*\{[^}]*grid-template-columns:\s*var\(--workspace-nav-width\) minmax\(0, 1fr\)/);
|
|
assert.match(styles, /\.workspace-region\.nav-collapsed/);
|
|
assert.match(styles, /--workspace-nav-collapsed:\s*62px/);
|
|
assert.match(styles,
|
|
/\.workspace-navigation\s*\{[^}]*grid-template-rows:\s*66px minmax\(0, 1fr\) auto/);
|
|
assert.match(styles, /\.workspace-nav-footer\s*\{[^}]*border-top/);
|
|
assert.match(styles,
|
|
/\.workspace-navigation \.market-tabs button\s*\{[^}]*width:\s*100%[^}]*height:\s*46px/);
|
|
assert.match(styles,
|
|
/\.workspace-nav-item:focus-visible\s*\{[\s\S]*?outline:\s*2px solid var\(--workspace-accent\)/);
|
|
assert.match(styles, /\.playlist-toolbar\s*\{[^}]*flex-wrap:\s*wrap/);
|
|
assert.match(styles, /\.playlist-row\s*\{[^}]*min-width:\s*600px/);
|
|
assert.match(packageInputHarness,
|
|
/widthRatio < 1\.60 \|\| widthRatio > 1\.90/);
|
|
assert.match(uiOnlySmokeHarness,
|
|
/workspaceToScheduleWidthRatio < 1\.60 \|\|[\s\S]*?workspaceToScheduleWidthRatio > 1\.90/);
|
|
assert.match(packageInputHarness, /approximately 1\.75:1 workspace\/schedule split/);
|
|
assert.match(uiOnlySmokeHarness, /approximately 1\.75:1 workspace layout/);
|
|
assert.doesNotMatch(packageInputHarness, /approximately 2:1 workspace\/schedule split/);
|
|
assert.doesNotMatch(uiOnlySmokeHarness, /approximately 2:1 workspace layout/);
|
|
});
|
|
|
|
test("modern operator theme keeps clear semantic states and accessible focus", () => {
|
|
assert.match(styles,
|
|
/Modern operator theme: visual-only overrides; geometry and native contracts stay intact/);
|
|
for (const token of [
|
|
"--ui-canvas",
|
|
"--ui-accent",
|
|
"--ui-success",
|
|
"--ui-danger"
|
|
]) {
|
|
assert.match(styles, new RegExp(`${token}:`));
|
|
}
|
|
|
|
assert.match(styles,
|
|
/font-family:\s*"Segoe UI Variable",\s*"Segoe UI",\s*"Noto Sans KR"/);
|
|
assert.match(styles, /button:focus-visible,[\s\S]*?outline:\s*2px solid var\(--ui-accent\)/);
|
|
assert.match(styles, /\.playlist-data-row\.on-air\.selected\s*\{/);
|
|
assert.match(styles, /\.playout-actions \.take-in:not\(:disabled\)\s*\{/);
|
|
assert.match(styles, /\.playout-actions \.next:not\(:disabled\)\s*\{/);
|
|
assert.match(styles, /\.playout-actions \.take-out:not\(:disabled\)\s*\{/);
|
|
assert.match(styles,
|
|
/Contemporary density refinement: keep operator geometry, clarify visual hierarchy/);
|
|
assert.match(styles, /\.stock-result\.selected\s*\{/);
|
|
assert.match(styles, /\.playlist-heading-count\s*\{/);
|
|
assert.match(styles, /\.playlist-rows:empty::after\s*\{/);
|
|
assert.match(styles, /@media \(prefers-reduced-motion:\s*reduce\)/);
|
|
assert.match(markup, /id="stock-search-button"[\s\S]*?<svg[^>]*viewBox="0 0 24 24"/);
|
|
assert.match(markup, /class="playlist-heading-icon"[\s\S]*?<svg[^>]*viewBox="0 0 24 24"/);
|
|
assert.match(markup,
|
|
/id="playlist-heading-count"[^>]*role="status"[^>]*aria-live="polite"[^>]*aria-atomic="true"/);
|
|
assert.match(app, /const nextHeadingCount = `[^`]*\$\{state\.playlist\.length\}[^`]*\$\{enabledCount\}[^`]*`/);
|
|
assert.match(app,
|
|
/if \(playlistHeadingCount\.textContent !== nextHeadingCount\)\s*\{\s*playlistHeadingCount\.textContent = nextHeadingCount;/);
|
|
});
|
|
|
|
test("the complete sidebar is one labelled and collapsible navigation region", () => {
|
|
assert.match(markup,
|
|
/id="workspace-navigation"[^>]*role="navigation"[^>]*aria-label=/);
|
|
assert.match(markup,
|
|
/id="workspace-nav-toggle"[^>]*aria-expanded="true"[\s\S]*?aria-controls="workspace-nav-items workspace-nav-footer"/);
|
|
assert.match(markup,
|
|
/id="workspace-nav-items"[^>]*role="group"[^>]*aria-label=/);
|
|
assert.match(markup,
|
|
/id="market-tabs"[^>]*role="group"[^>]*aria-label=/);
|
|
assert.match(markup, /id="workspace-nav-footer" class="workspace-nav-footer"/);
|
|
});
|
|
|
|
test("workspace navigation is local presentation state and preserves native contracts", () => {
|
|
assert.match(navigation, /entry\.view\.hidden = !isCurrent/);
|
|
assert.match(navigation, /entry\.view\.toggleAttribute\("inert", !isCurrent\)/);
|
|
assert.match(navigation, /if \(focusWillBeHidden && selectedButton\) selectedButton\.focus\(\)/);
|
|
assert.match(navigation, /button\.setAttribute\("aria-current", isCurrent \? "page" : "false"\)/);
|
|
assert.match(navigation, /region\.classList\.toggle\("nav-collapsed", !isExpanded\)/);
|
|
assert.match(navigation, /event\.key !== "ArrowUp" && event\.key !== "ArrowDown"/);
|
|
assert.match(navigation, /navigation\.querySelectorAll\("\.workspace-nav-item"\)/);
|
|
assert.match(navigation, /event\.key === "Enter" \|\| event\.key === " "/);
|
|
assert.match(navigation, /menuButton\.click\(\)/);
|
|
assert.match(navigation, /pendingKeyboardMarketTabId = keyboardMarketTabId/);
|
|
assert.match(navigation, /pendingButton\.focus\(\)/);
|
|
assert.match(navigation, /window\.requestAnimationFrame\(function/);
|
|
assert.match(navigation, /options\.authoritativeNativeRender === true/);
|
|
assert.match(navigation, /hasPendingKeyboardMarketFocus/);
|
|
assert.match(navigation, /document\.addEventListener\("pointerdown"/);
|
|
assert.doesNotMatch(navigation, /catalogWorkspace\.contains\(active\)/);
|
|
assert.match(navigation, /marketTabs\.addEventListener\("click"/);
|
|
assert.match(navigation, /settingsTab\.addEventListener\("click"/);
|
|
assert.match(navigation, /syncMarket\(button\.dataset\.tabId\)/);
|
|
assert.match(navigation, /setWorkspace\("catalog"\)/);
|
|
assert.doesNotMatch(navigation, /postMessage|localStorage|sessionStorage|send\s*\(/);
|
|
});
|
|
|
|
test("native tab rendering preserves sidebar icons and reconciles the active market", () => {
|
|
const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8");
|
|
assert.match(app, /button\.querySelector\("\.workspace-nav-label"\)/);
|
|
assert.match(app, /if \(label\) label\.textContent = tab\.label/);
|
|
assert.match(app, /LegacyWorkspaceNavigation\?\.syncMarket/);
|
|
assert.match(app, /authoritativeNativeRender: true/);
|
|
assert.match(app, /activeTab && state\.isBusy !== true/);
|
|
assert.match(app, /hasPendingKeyboardMarketFocus\?\.\(\) === true/);
|
|
assert.match(app,
|
|
/!modalFocusManager\.getActiveModal\(\) && !keyboardMarketFocusPending/);
|
|
assert.doesNotMatch(app,
|
|
/button\.classList\.toggle\("active"[^\n]*\n\s*button\.setAttribute\("aria-current"/);
|
|
});
|
|
|
|
test("workspace navigation loads before the native-authority application bridge", () => {
|
|
const navigationScript = markup.indexOf('<script src="workspace-navigation.js"></script>');
|
|
const applicationScript = markup.indexOf('<script src="app.js"></script>');
|
|
assert.ok(navigationScript >= 0 && applicationScript > navigationScript);
|
|
});
|
|
|
|
test("keyboard menu focus wins after a new graphics tree renders, but not after a deliberate move", () => {
|
|
const stolen = pendingMenuFocusHarness();
|
|
stolen.syncMarket("theme");
|
|
assert.equal(stolen.hasPendingKeyboardMarketFocus("theme"), true,
|
|
"the optimistic capture-phase selection retains keyboard ownership");
|
|
assert.equal(stolen.pendingButton.focused, false,
|
|
"the optimistic capture-phase selection cannot restore focus early");
|
|
stolen.syncMarket("theme", { authoritativeNativeRender: true });
|
|
stolen.documentObject.activeElement = stolen.documentObject.body;
|
|
stolen.runFrame();
|
|
assert.equal(stolen.pendingButton.focused, true,
|
|
"the final native render restores focus lost while the menu was disabled");
|
|
|
|
for (const deliberateFocus of ["sidebar", "content"]) {
|
|
const deliberate = pendingMenuFocusHarness();
|
|
deliberate.syncMarket("theme");
|
|
deliberate.syncMarket("theme", { authoritativeNativeRender: true });
|
|
deliberate.documentObject.activeElement = deliberateFocus === "sidebar"
|
|
? deliberate.unrelatedNavigationItem
|
|
: deliberate.catalogChild;
|
|
deliberate.runFrame();
|
|
assert.equal(deliberate.pendingButton.focused, false,
|
|
"a subsequent deliberate sidebar focus move remains authoritative");
|
|
}
|
|
});
|