feat: expose graphic screens in sidebar
This commit is contained in:
@@ -2304,7 +2304,9 @@ function Read-AndAssertHarnessEvidence(
|
||||
$HarnessProfile -ceq 'DryRunPlayout') {
|
||||
$allowedOutbound = @(
|
||||
'ready', 'search-stocks', 'select-stock', 'cut-pointer-down',
|
||||
'cut-key-down', 'drop-selected-cuts', 'activate-playlist-row',
|
||||
'cut-key-down', 'drop-selected-cuts', 'select-tab',
|
||||
'hover-swap-tabs',
|
||||
'activate-playlist-row',
|
||||
'select-playlist-row', 'reorder-playlist-rows',
|
||||
'select-playlist-boundary', 'refresh-named-playlists')
|
||||
if ($HarnessProfile -ceq 'DryRunPlayout') {
|
||||
|
||||
@@ -36,6 +36,8 @@ const readOnlyAllowedOutboundMessageTypes = Object.freeze([
|
||||
"cut-pointer-down",
|
||||
"cut-key-down",
|
||||
"drop-selected-cuts",
|
||||
"select-tab",
|
||||
"hover-swap-tabs",
|
||||
"activate-playlist-row",
|
||||
"select-playlist-row",
|
||||
"reorder-playlist-rows",
|
||||
@@ -44,7 +46,6 @@ const readOnlyAllowedOutboundMessageTypes = Object.freeze([
|
||||
]);
|
||||
const fullUiDbAllowedOutboundMessageTypes = Object.freeze([
|
||||
...readOnlyAllowedOutboundMessageTypes,
|
||||
"select-tab",
|
||||
"activate-fixed-action",
|
||||
"activate-fixed-section",
|
||||
"select-industry",
|
||||
@@ -466,10 +467,13 @@ let cancellationInputBuffer = "";
|
||||
let pointerIsPressed = false;
|
||||
let pointerReleasePoint = null;
|
||||
let pointerReleaseModifiers = 0;
|
||||
let dragInterceptionEnabled = false;
|
||||
let activeInterceptedDragData = null;
|
||||
let probeInstalled = false;
|
||||
let cleanupMode = false;
|
||||
let cleanupDeadlineEpochMilliseconds = 0;
|
||||
let expectedJavaScriptDialog = null;
|
||||
let pendingDragIntercept = null;
|
||||
const unexpectedJavaScriptDialogs = [];
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
@@ -488,6 +492,13 @@ process.stdin.on("data", chunk => {
|
||||
`Wrapper cancellation interrupted ${pending.method}; the command is not retried.`));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
if (pendingDragIntercept) {
|
||||
clearTimeout(pendingDragIntercept.timer);
|
||||
pendingDragIntercept.reject(new HarnessFailure(
|
||||
"OUTCOME_UNKNOWN",
|
||||
"Wrapper cancellation interrupted the intercepted drag; it is not retried."));
|
||||
pendingDragIntercept = null;
|
||||
}
|
||||
});
|
||||
if (typeof process.stdin.unref === "function") process.stdin.unref();
|
||||
|
||||
@@ -568,6 +579,15 @@ async function connect(endpoint) {
|
||||
|
||||
socket.addEventListener("message", event => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.method === "Input.dragIntercepted") {
|
||||
const pending = pendingDragIntercept;
|
||||
if (pending) {
|
||||
pendingDragIntercept = null;
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(message.params?.data || null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method === "Page.javascriptDialogOpening") {
|
||||
const dialog = message.params || {};
|
||||
const expected = expectedJavaScriptDialog;
|
||||
@@ -608,13 +628,49 @@ async function connect(endpoint) {
|
||||
`CDP socket closed while waiting for ${pending.method}.`));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
if (pendingDragIntercept) {
|
||||
clearTimeout(pendingDragIntercept.timer);
|
||||
pendingDragIntercept.reject(new HarnessFailure(
|
||||
"OUTCOME_UNKNOWN",
|
||||
"CDP socket closed while waiting for the intercepted drag."));
|
||||
pendingDragIntercept = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function waitForDragIntercept(label) {
|
||||
if (pendingDragIntercept) {
|
||||
failKnown("A second intercepted drag was requested before the first completed.");
|
||||
}
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingDragIntercept = null;
|
||||
reject(new HarnessFailure(
|
||||
"KNOWN_FAILURE",
|
||||
`${label} did not expose Chromium's intercepted drag data.`));
|
||||
}, 5_000);
|
||||
pendingDragIntercept = { resolve, reject, timer };
|
||||
});
|
||||
// A pointer or transport failure can happen before the caller reaches its
|
||||
// await. Attach a handler immediately; the caller still observes the same
|
||||
// rejection when it awaits this promise during structured cleanup.
|
||||
void promise.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function cancelPendingDragIntercept(message) {
|
||||
const pending = pendingDragIntercept;
|
||||
if (!pending) return;
|
||||
pendingDragIntercept = null;
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new HarnessFailure("KNOWN_FAILURE", message));
|
||||
}
|
||||
|
||||
function rpc(method, params = {}, timeoutMilliseconds = 10_000) {
|
||||
assertWithinHardDeadline(`before ${method}`);
|
||||
if (cleanupMode && method !== "Input.dispatchMouseEvent" &&
|
||||
method !== "Runtime.evaluate") {
|
||||
method !== "Input.dispatchDragEvent" &&
|
||||
method !== "Input.setInterceptDrags" && method !== "Runtime.evaluate") {
|
||||
failKnown(`Cleanup refused unexpected CDP method ${method}.`);
|
||||
}
|
||||
if (forbiddenCdpMethods.has(method)) {
|
||||
@@ -701,6 +757,7 @@ function probeSnapshotExpression() {
|
||||
capturedAt: new Date().toISOString(),
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
stateCount: probe?.stateCount ?? null,
|
||||
busyStateCount: probe?.busyStateCount ?? null,
|
||||
safety: {
|
||||
postMessageWrapped: probe?.postMessageWrapper != null &&
|
||||
window.chrome?.webview?.postMessage === probe.postMessageWrapper,
|
||||
@@ -1061,9 +1118,12 @@ async function readWorkspaceLayout() {
|
||||
const surface = document.querySelector(".workspace-surface");
|
||||
const toggle = document.getElementById("workspace-nav-toggle");
|
||||
const stockTab = document.getElementById("workspace-stock-tab");
|
||||
const catalogTab = document.getElementById("workspace-catalog-tab");
|
||||
const settingsTab = document.getElementById("workspace-settings-tab");
|
||||
const marketTabs = Array.from(
|
||||
document.querySelectorAll("#market-tabs > button[data-tab-id]"));
|
||||
const stock = document.getElementById("stock-workspace");
|
||||
const catalog = document.getElementById("catalog-workspace");
|
||||
const settings = document.getElementById("settings-workspace");
|
||||
const schedule = document.querySelector(".playlist-panel");
|
||||
const scheduleBounds = schedule?.getBoundingClientRect() || null;
|
||||
const scheduleHit = scheduleBounds && scheduleBounds.width > 0 && scheduleBounds.height > 0
|
||||
@@ -1086,6 +1146,16 @@ async function readWorkspaceLayout() {
|
||||
navigationExpanded: toggle?.getAttribute("aria-expanded") === "true",
|
||||
navigationCollapsedClass: region?.classList.contains("nav-collapsed") === true,
|
||||
title: (document.getElementById("workspace-title")?.textContent || "").trim(),
|
||||
activeMarketTab: region?.dataset?.activeMarketTab || null,
|
||||
currentMenuItemCount: document.querySelectorAll(
|
||||
"#workspace-navigation .workspace-nav-item[aria-current='page']").length,
|
||||
marketTabs: marketTabs.map(button => ({
|
||||
tabId: button.dataset.tabId,
|
||||
label: (button.querySelector(".workspace-nav-label")?.textContent || "").trim(),
|
||||
current: button.getAttribute("aria-current") || null,
|
||||
focused: document.activeElement === button,
|
||||
hasIcon: Boolean(button.querySelector("svg"))
|
||||
})),
|
||||
stock: {
|
||||
hidden: stock?.hidden === true,
|
||||
inert: stock?.hasAttribute("inert") === true,
|
||||
@@ -1098,9 +1168,16 @@ async function readWorkspaceLayout() {
|
||||
hidden: catalog?.hidden === true,
|
||||
inert: catalog?.hasAttribute("inert") === true,
|
||||
ariaHidden: catalog?.getAttribute("aria-hidden") || null,
|
||||
tabCurrent: catalogTab?.getAttribute("aria-current") || null,
|
||||
width: catalog?.getBoundingClientRect().width ?? null,
|
||||
height: catalog?.getBoundingClientRect().height ?? null
|
||||
},
|
||||
settings: {
|
||||
hidden: settings?.hidden === true,
|
||||
inert: settings?.hasAttribute("inert") === true,
|
||||
ariaHidden: settings?.getAttribute("aria-hidden") || null,
|
||||
tabCurrent: settingsTab?.getAttribute("aria-current") || null,
|
||||
width: settings?.getBoundingClientRect().width ?? null,
|
||||
height: settings?.getBoundingClientRect().height ?? null
|
||||
}
|
||||
};
|
||||
})()`);
|
||||
@@ -1109,13 +1186,31 @@ async function readWorkspaceLayout() {
|
||||
function workspaceLayoutMatches(layout, workspace, expanded = null) {
|
||||
if (!layout || layout.activeWorkspace !== workspace) return false;
|
||||
if (expanded !== null && layout.navigationExpanded !== expanded) return false;
|
||||
const selected = workspace === "stock" ? layout.stock : layout.catalog;
|
||||
const other = workspace === "stock" ? layout.catalog : layout.stock;
|
||||
return selected.hidden === false && selected.inert === false &&
|
||||
selected.ariaHidden === "false" && selected.tabCurrent === "page" &&
|
||||
selected.width > 0 && selected.height > 0 &&
|
||||
other.hidden === true && other.inert === true &&
|
||||
other.ariaHidden === "true" && other.tabCurrent === "false";
|
||||
if (layout.scheduleHitOwned !== true || layout.currentMenuItemCount !== 1 ||
|
||||
layout.marketTabs?.length !== 10 ||
|
||||
layout.marketTabs.some(tab => tab.hasIcon !== true)) return false;
|
||||
const visible = view => view?.hidden === false && view.inert === false &&
|
||||
view.ariaHidden === "false" && view.width > 0 && view.height > 0;
|
||||
const hidden = view => view?.hidden === true && view.inert === true &&
|
||||
view.ariaHidden === "true";
|
||||
const currentMarkets = layout.marketTabs.filter(tab => tab.current === "page");
|
||||
|
||||
if (workspace === "stock") {
|
||||
return visible(layout.stock) && hidden(layout.catalog) && hidden(layout.settings) &&
|
||||
layout.stock.tabCurrent === "page" && layout.settings.tabCurrent === "false" &&
|
||||
currentMarkets.length === 0;
|
||||
}
|
||||
if (workspace === "settings") {
|
||||
return hidden(layout.stock) && hidden(layout.catalog) && visible(layout.settings) &&
|
||||
layout.stock.tabCurrent === "false" && layout.settings.tabCurrent === "page" &&
|
||||
currentMarkets.length === 0;
|
||||
}
|
||||
if (workspace === "catalog") {
|
||||
return hidden(layout.stock) && visible(layout.catalog) && hidden(layout.settings) &&
|
||||
layout.stock.tabCurrent === "false" && layout.settings.tabCurrent === "false" &&
|
||||
currentMarkets.length === 1 && currentMarkets[0].tabId === layout.activeMarketTab;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForWorkspaceLayout(label, predicate) {
|
||||
@@ -1133,7 +1228,8 @@ function assertWorkspaceAndScheduleGeometry(layout, label) {
|
||||
if (!layout?.shell || !layout.region || !layout.navigation || !layout.surface ||
|
||||
!layout.schedule || layout.schedule.width <= 0 || layout.schedule.height <= 0 ||
|
||||
layout.scheduleHitOwned !== true) {
|
||||
failKnown(`${label} does not expose the complete workspace and fixed schedule geometry.`);
|
||||
failKnown(`${label} does not expose the complete workspace and fixed schedule geometry: ` +
|
||||
JSON.stringify(layout));
|
||||
}
|
||||
const widthRatio = layout.region.width / layout.schedule.width;
|
||||
const workspaceShare = layout.region.width / layout.shell.width;
|
||||
@@ -1157,16 +1253,21 @@ function assertFixedSchedule(baseline, current, label) {
|
||||
}
|
||||
|
||||
async function ensureWorkspace(workspace, label) {
|
||||
const tabId = workspace === "stock"
|
||||
? "workspace-stock-tab"
|
||||
: workspace === "catalog"
|
||||
? "workspace-catalog-tab"
|
||||
: null;
|
||||
if (!tabId) failKnown(`${label} requested an unknown workspace.`);
|
||||
|
||||
let layout = await readWorkspaceLayout();
|
||||
if (!workspaceLayoutMatches(layout, workspace)) {
|
||||
await pointerClick(`document.getElementById("${tabId}")`, label);
|
||||
let expression = null;
|
||||
if (workspace === "stock") {
|
||||
expression = 'document.getElementById("workspace-stock-tab")';
|
||||
} else if (workspace === "settings") {
|
||||
expression = 'document.getElementById("workspace-settings-tab")';
|
||||
} else if (workspace === "catalog") {
|
||||
const activeTabId = layout.activeMarketTab || layout.marketTabs?.[0]?.tabId;
|
||||
if (activeTabId) {
|
||||
expression = `document.querySelector('#market-tabs > button[data-tab-id="${activeTabId}"]')`;
|
||||
}
|
||||
}
|
||||
if (!expression) failKnown(`${label} requested an unknown workspace.`);
|
||||
await pointerClick(expression, label);
|
||||
layout = await waitForWorkspaceLayout(label, candidate =>
|
||||
workspaceLayoutMatches(candidate, workspace));
|
||||
}
|
||||
@@ -1179,6 +1280,10 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
if (baseline.navigationExpanded !== true || baseline.navigationCollapsedClass === true) {
|
||||
failKnown("The workspace menu was not initially expanded.");
|
||||
}
|
||||
const firstMarket = baseline.marketTabs[0];
|
||||
if (!firstMarket?.tabId || !firstMarket.label) {
|
||||
failKnown("The first graphic menu item is missing its native identity or label.");
|
||||
}
|
||||
const baselineRatio = assertWorkspaceAndScheduleGeometry(
|
||||
baseline,
|
||||
"expanded stock workspace preflight");
|
||||
@@ -1195,13 +1300,21 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
}
|
||||
assertFixedSchedule(baseline, collapsed, "collapsed workspace menu");
|
||||
|
||||
const beforeGraphicSelection = await readSnapshot();
|
||||
assertSafety(beforeGraphicSelection, "first graphic native selection preflight", false);
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-catalog-tab")',
|
||||
"select catalog workspace from collapsed menu");
|
||||
const catalog = await waitForWorkspaceLayout("catalog workspace", layout =>
|
||||
`document.querySelector('#market-tabs > button[data-tab-id="${firstMarket.tabId}"]')`,
|
||||
"select first graphic screen from collapsed menu");
|
||||
const catalog = await waitForWorkspaceLayout("first graphic workspace", layout =>
|
||||
workspaceLayoutMatches(layout, "catalog", false) &&
|
||||
layout.title === "기타 그래픽");
|
||||
assertFixedSchedule(baseline, catalog, "catalog workspace transition");
|
||||
layout.title === firstMarket.label && layout.activeMarketTab === firstMarket.tabId);
|
||||
assertFixedSchedule(baseline, catalog, "first graphic workspace transition");
|
||||
let nativeState = await waitFor("first graphic native selection", snapshot =>
|
||||
snapshot.stateCount >= beforeGraphicSelection.stateCount + 2 &&
|
||||
snapshot.busyStateCount > beforeGraphicSelection.busyStateCount &&
|
||||
snapshot.state.isBusy === false &&
|
||||
snapshot.state.tabs?.some(tab => tab.id === firstMarket.tabId && tab.isActive) === true,
|
||||
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
|
||||
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-nav-toggle")',
|
||||
@@ -1215,6 +1328,41 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
}
|
||||
assertFixedSchedule(baseline, expandedCatalog, "expanded catalog workspace");
|
||||
|
||||
const secondMarket = baseline.marketTabs[1];
|
||||
if (!secondMarket?.tabId || secondMarket.tabId === firstMarket.tabId) {
|
||||
failKnown("The second graphic menu item is unavailable for vertical drag verification.");
|
||||
}
|
||||
const swappedMarketTabs = await dragMarketTab(
|
||||
firstMarket.tabId,
|
||||
secondMarket.tabId,
|
||||
"vertically reorder graphic menu items");
|
||||
assertFixedSchedule(baseline, swappedMarketTabs.layout,
|
||||
"vertical graphic menu reorder");
|
||||
const restoredMarketTabs = await dragMarketTab(
|
||||
firstMarket.tabId,
|
||||
secondMarket.tabId,
|
||||
"restore graphic menu item order");
|
||||
assertFixedSchedule(baseline, restoredMarketTabs.layout,
|
||||
"restored graphic menu order");
|
||||
if (!restoredMarketTabs.layout.marketTabs.every(
|
||||
(tab, index) => tab.tabId === baseline.marketTabs[index].tabId)) {
|
||||
failKnown("The vertical graphic menu drag did not restore the original native order.");
|
||||
}
|
||||
const beforeSettingsMessageCount =
|
||||
restoredMarketTabs.completed.safety.outboundMessages.length;
|
||||
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-settings-tab")',
|
||||
"open settings workspace");
|
||||
const settings = await waitForWorkspaceLayout("settings workspace", layout =>
|
||||
workspaceLayoutMatches(layout, "settings", true) && layout.title === "설정");
|
||||
assertFixedSchedule(baseline, settings, "settings workspace transition");
|
||||
nativeState = await readSnapshot();
|
||||
assertSafety(nativeState, "settings workspace local transition", false);
|
||||
if (nativeState.safety.outboundMessages.length !== beforeSettingsMessageCount) {
|
||||
failKnown("The local Settings menu emitted a native WebView message.");
|
||||
}
|
||||
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-stock-tab")',
|
||||
"return to stock workspace");
|
||||
@@ -1225,20 +1373,31 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) {
|
||||
failKnown("The pointer-selected stock menu item did not retain keyboard focus.");
|
||||
}
|
||||
await pressKey("ArrowDown", "focus catalog workspace menu item");
|
||||
if (await evaluate('document.activeElement?.id === "workspace-catalog-tab"') !== true) {
|
||||
failKnown("ArrowDown did not move focus to the catalog workspace menu item.");
|
||||
await pressKey("ArrowDown", "focus first graphic menu item");
|
||||
if (await evaluate(
|
||||
`document.activeElement?.dataset?.tabId === ${JSON.stringify(firstMarket.tabId)}`) !== true) {
|
||||
failKnown("ArrowDown did not move focus to the first graphic menu item.");
|
||||
}
|
||||
const focusOnlyStock = await readWorkspaceLayout();
|
||||
if (!workspaceLayoutMatches(focusOnlyStock, "stock", true)) {
|
||||
failKnown("ArrowDown changed the workspace before explicit keyboard activation.");
|
||||
}
|
||||
assertFixedSchedule(baseline, focusOnlyStock, "keyboard catalog focus");
|
||||
assertFixedSchedule(baseline, focusOnlyStock, "keyboard graphic focus");
|
||||
|
||||
await pressKey("Enter", "activate catalog workspace from keyboard");
|
||||
const keyboardCatalog = await waitForWorkspaceLayout("keyboard catalog workspace", layout =>
|
||||
workspaceLayoutMatches(layout, "catalog", true) && layout.title === "기타 그래픽");
|
||||
assertFixedSchedule(baseline, keyboardCatalog, "keyboard catalog activation");
|
||||
const beforeKeyboardGraphicSelection = await readSnapshot();
|
||||
assertSafety(beforeKeyboardGraphicSelection,
|
||||
"keyboard graphic native selection preflight", false);
|
||||
await pressKey("Enter", "activate first graphic screen from keyboard");
|
||||
const keyboardCatalog = await waitForWorkspaceLayout("keyboard graphic workspace", layout =>
|
||||
workspaceLayoutMatches(layout, "catalog", true) &&
|
||||
layout.title === firstMarket.label && layout.activeMarketTab === firstMarket.tabId);
|
||||
assertFixedSchedule(baseline, keyboardCatalog, "keyboard graphic activation");
|
||||
await waitFor("keyboard graphic native selection", snapshot =>
|
||||
snapshot.stateCount >= beforeKeyboardGraphicSelection.stateCount + 2 &&
|
||||
snapshot.busyStateCount > beforeKeyboardGraphicSelection.busyStateCount &&
|
||||
snapshot.state.isBusy === false &&
|
||||
snapshot.state.tabs?.some(tab => tab.id === firstMarket.tabId && tab.isActive) === true,
|
||||
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
|
||||
|
||||
await pressKey("ArrowUp", "focus stock workspace menu item");
|
||||
if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) {
|
||||
@@ -1249,12 +1408,41 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷");
|
||||
assertFixedSchedule(baseline, keyboardStock, "keyboard stock activation");
|
||||
|
||||
await pressKey("ArrowUp", "wrap focus to settings menu item");
|
||||
if (await evaluate('document.activeElement?.id === "workspace-settings-tab"') !== true) {
|
||||
failKnown("ArrowUp did not wrap focus from Stock to Settings.");
|
||||
}
|
||||
const focusOnlyStockBeforeSettings = await readWorkspaceLayout();
|
||||
if (!workspaceLayoutMatches(focusOnlyStockBeforeSettings, "stock", true)) {
|
||||
failKnown("Focusing Settings changed the workspace before keyboard activation.");
|
||||
}
|
||||
await pressKey("Enter", "activate settings workspace from keyboard");
|
||||
const keyboardSettings = await waitForWorkspaceLayout("keyboard settings workspace", layout =>
|
||||
workspaceLayoutMatches(layout, "settings", true) && layout.title === "설정");
|
||||
assertFixedSchedule(baseline, keyboardSettings, "keyboard settings activation");
|
||||
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-stock-tab")',
|
||||
"restore stock workspace after keyboard navigation");
|
||||
const finalStock = await waitForWorkspaceLayout("final stock workspace", layout =>
|
||||
workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷");
|
||||
assertFixedSchedule(baseline, finalStock, "final stock workspace");
|
||||
|
||||
const after = await readSnapshot();
|
||||
assertSafety(after, "workspace navigation/layout input", false);
|
||||
if (after.safety.outboundMessages.length !== preflight.safety.outboundMessages.length ||
|
||||
const navigationMessages = after.safety.outboundMessages.slice(
|
||||
preflight.safety.outboundMessages.length);
|
||||
const selectTabMessages = navigationMessages.filter(
|
||||
message => message.type === "select-tab");
|
||||
const hoverSwapMessages = navigationMessages.filter(
|
||||
message => message.type === "hover-swap-tabs");
|
||||
if (navigationMessages.length !== 4 || selectTabMessages.length !== 2 ||
|
||||
hoverSwapMessages.length !== 2 ||
|
||||
navigationMessages.some(message =>
|
||||
message.type !== "select-tab" && message.type !== "hover-swap-tabs") ||
|
||||
after.safety.blockedOutboundMessages.length !==
|
||||
preflight.safety.blockedOutboundMessages.length) {
|
||||
failKnown("Workspace-only pointer input crossed the native WebView message boundary.");
|
||||
failKnown("Sidebar navigation emitted duplicate or unexpected native messages.");
|
||||
}
|
||||
|
||||
await checkpoint("workspace-navigation-layout", {
|
||||
@@ -1263,8 +1451,17 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
expandedNavigationWidth: baseline.navigation.width,
|
||||
collapsedNavigationWidth: collapsed.navigation.width,
|
||||
schedule: baseline.schedule,
|
||||
pointerTransitions: ["collapse", "catalog", "expand", "stock"],
|
||||
keyboardTransitions: ["ArrowDown", "Enter catalog", "ArrowUp", "Enter stock"]
|
||||
graphicMenuCount: baseline.marketTabs.length,
|
||||
pointerTransitions: ["collapse", firstMarket.tabId, "expand", "settings", "stock"],
|
||||
verticalMenuDrag: [
|
||||
`${firstMarket.tabId} -> ${secondMarket.tabId}`,
|
||||
`${firstMarket.tabId} -> ${secondMarket.tabId} (restore)`
|
||||
],
|
||||
keyboardTransitions: [
|
||||
"ArrowDown", `Enter ${firstMarket.tabId}`, "ArrowUp", "Enter stock",
|
||||
"ArrowUp wrap", "Enter settings"
|
||||
],
|
||||
nativeNavigationMessages: navigationMessages.map(message => message.type)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1375,6 +1572,42 @@ async function releaseMouse(point, modifiers = 0) {
|
||||
pointerReleaseModifiers = 0;
|
||||
}
|
||||
|
||||
async function cleanupDragInputSession(cancelActiveDrag = true) {
|
||||
const failures = [];
|
||||
const point = pointerReleasePoint || { x: 1, y: 1 };
|
||||
if (cancelActiveDrag && activeInterceptedDragData) {
|
||||
try {
|
||||
await rpc("Input.dispatchDragEvent", {
|
||||
type: "dragCancel",
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
data: activeInterceptedDragData
|
||||
});
|
||||
activeInterceptedDragData = null;
|
||||
} catch (error) {
|
||||
failures.push(`drag cancel: ${error.message}`);
|
||||
}
|
||||
}
|
||||
if (pointerIsPressed) {
|
||||
try {
|
||||
await releaseMouse(point, pointerReleaseModifiers);
|
||||
} catch (error) {
|
||||
failures.push(`pointer release: ${error.message}`);
|
||||
}
|
||||
}
|
||||
if (dragInterceptionEnabled) {
|
||||
try {
|
||||
await rpc("Input.setInterceptDrags", { enabled: false });
|
||||
dragInterceptionEnabled = false;
|
||||
} catch (error) {
|
||||
failures.push(`drag interception disable: ${error.message}`);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
failUnknown(`Input cleanup did not complete (${failures.join("; ")}).`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPointerReady(
|
||||
label,
|
||||
allowLegacyDialog = false,
|
||||
@@ -1411,6 +1644,113 @@ async function pointerClick(expression, label, modifiers = 0, options = {}) {
|
||||
return point;
|
||||
}
|
||||
|
||||
async function dragMarketTab(sourceTabId, targetTabId, label) {
|
||||
await waitForPointerReady(label);
|
||||
const beforeLayout = await readWorkspaceLayout();
|
||||
const beforeOrder = beforeLayout.marketTabs.map(tab => tab.tabId);
|
||||
const sourceIndex = beforeOrder.indexOf(sourceTabId);
|
||||
const targetIndex = beforeOrder.indexOf(targetTabId);
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
|
||||
failKnown(`${label} does not have two distinct visible market menu items.`);
|
||||
}
|
||||
const expectedOrder = beforeOrder.slice();
|
||||
expectedOrder[sourceIndex] = targetTabId;
|
||||
expectedOrder[targetIndex] = sourceTabId;
|
||||
const source = await elementGeometry(
|
||||
`document.querySelector('#market-tabs > button[data-tab-id="${sourceTabId}"]')`,
|
||||
`${label} source`);
|
||||
const target = await elementGeometry(
|
||||
`document.querySelector('#market-tabs > button[data-tab-id="${targetTabId}"]')`,
|
||||
`${label} target`);
|
||||
const before = await readSnapshot();
|
||||
assertSafety(before, `${label} preflight`, false);
|
||||
evidence.inputs.push({
|
||||
type: "market-tab-drag",
|
||||
label,
|
||||
sourceTabId,
|
||||
targetTabId,
|
||||
source,
|
||||
target,
|
||||
beforeOrder,
|
||||
expectedOrder
|
||||
});
|
||||
|
||||
let dragData = null;
|
||||
let dragCompleted = false;
|
||||
let operationError = null;
|
||||
let intercepted = null;
|
||||
// Record the attempt before awaiting the acknowledgement. If Chromium
|
||||
// applies the command but its response is lost, bounded outer cleanup must
|
||||
// still send and confirm the idempotent disable command once.
|
||||
dragInterceptionEnabled = true;
|
||||
try {
|
||||
await rpc("Input.setInterceptDrags", { enabled: true });
|
||||
intercepted = waitForDragIntercept(label);
|
||||
await pressMouse(source, 0);
|
||||
pointerReleasePoint = source;
|
||||
const direction = target.y >= source.y ? 1 : -1;
|
||||
await moveMouse({ x: source.x, y: source.y + direction * 10 }, 0, 1);
|
||||
dragData = await intercepted;
|
||||
activeInterceptedDragData = dragData;
|
||||
const marketItem = dragData?.items?.find(item => item.mimeType === "text/x-mbn-tab");
|
||||
if (!marketItem || marketItem.data !== sourceTabId) {
|
||||
failKnown(`${label} did not preserve the closed market identity in drag data.`);
|
||||
}
|
||||
await rpc("Input.dispatchDragEvent", {
|
||||
type: "dragEnter",
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
data: dragData
|
||||
});
|
||||
await rpc("Input.dispatchDragEvent", {
|
||||
type: "dragOver",
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
data: dragData
|
||||
});
|
||||
await sleep(100);
|
||||
await rpc("Input.dispatchDragEvent", {
|
||||
type: "drop",
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
data: dragData
|
||||
});
|
||||
activeInterceptedDragData = null;
|
||||
dragCompleted = true;
|
||||
await releaseMouse(target, 0);
|
||||
} catch (error) {
|
||||
operationError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (pendingDragIntercept) {
|
||||
cancelPendingDragIntercept(
|
||||
`${label} ended before Chromium supplied intercepted drag data.`);
|
||||
}
|
||||
if (intercepted) await intercepted.catch(() => null);
|
||||
try {
|
||||
await cleanupDragInputSession(!dragCompleted);
|
||||
} catch (cleanupError) {
|
||||
if (!operationError) throw cleanupError;
|
||||
// Keep the original operation failure. The outer cleanup switches to a
|
||||
// bounded cleanup mode and makes one final release/disable attempt.
|
||||
}
|
||||
}
|
||||
|
||||
const completed = await waitFor(`${label} native swap`, snapshot => {
|
||||
const order = snapshot.state.tabs?.map(tab => tab.id) || [];
|
||||
return snapshot.safety.outboundMessages.length ===
|
||||
before.safety.outboundMessages.length + 1 &&
|
||||
snapshot.safety.outboundMessages.at(-1)?.type === "hover-swap-tabs" &&
|
||||
order.length === expectedOrder.length &&
|
||||
order.every((tabId, index) => tabId === expectedOrder[index]);
|
||||
}, { timeoutMilliseconds: 60_000, allowUiBusy: true });
|
||||
const completedLayout = await waitForWorkspaceLayout(`${label} menu order`, layout =>
|
||||
layout.marketTabs.length === expectedOrder.length &&
|
||||
layout.marketTabs.every((tab, index) => tab.tabId === expectedOrder[index]));
|
||||
assertWorkspaceAndScheduleGeometry(completedLayout, `${label} menu order`);
|
||||
return { completed, layout: completedLayout, beforeOrder, expectedOrder };
|
||||
}
|
||||
|
||||
const keyDefinitions = Object.freeze({
|
||||
ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 },
|
||||
ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 },
|
||||
@@ -2066,6 +2406,7 @@ async function installProbe() {
|
||||
const probe = {
|
||||
token: ${JSON.stringify(token)},
|
||||
stateCount: 0,
|
||||
busyStateCount: 0,
|
||||
states: [],
|
||||
listener: null,
|
||||
pointerEventTypes: [
|
||||
@@ -2090,6 +2431,7 @@ async function installProbe() {
|
||||
if (event.data?.type !== "state" || !event.data.payload) return;
|
||||
const payload = event.data.payload;
|
||||
probe.stateCount += 1;
|
||||
if (payload.isBusy === true) probe.busyStateCount += 1;
|
||||
probe.states.push(structuredClone(payload));
|
||||
if (probe.states.length > 750) probe.states.shift();
|
||||
const playout = payload.playout;
|
||||
@@ -3162,24 +3504,34 @@ async function executeManualFinancialCrud() {
|
||||
}
|
||||
|
||||
async function selectMainTab(tabId, label) {
|
||||
await ensureWorkspace("catalog", `${label} catalog workspace`);
|
||||
const isLoaded = snapshot =>
|
||||
snapshot.state.isBusy === false &&
|
||||
snapshot.state.tabs?.some(tab => tab.id === tabId && tab.isActive) === true &&
|
||||
(tabId !== "theme" || snapshot.state.theme != null) &&
|
||||
(tabId !== "expert" || snapshot.state.expert != null);
|
||||
const layout = await readWorkspaceLayout();
|
||||
const before = await readSnapshot();
|
||||
assertSafety(before, `${label} preflight`);
|
||||
const alreadyActive = before.state.tabs?.some(
|
||||
tab => tab.id === tabId && tab.isActive) === true;
|
||||
if (!alreadyActive) {
|
||||
const selectionRequired = !alreadyActive || layout.activeWorkspace !== "catalog" ||
|
||||
layout.activeMarketTab !== tabId;
|
||||
if (selectionRequired) {
|
||||
await pointerClick(
|
||||
`document.querySelector('button[data-tab-id="${tabId}"]')`,
|
||||
`document.querySelector('#market-tabs > button[data-tab-id="${tabId}"]')`,
|
||||
label);
|
||||
}
|
||||
return await waitFor(`${label} load`, snapshot =>
|
||||
const loaded = await waitFor(`${label} load`, snapshot =>
|
||||
(!selectionRequired ||
|
||||
(snapshot.stateCount >= before.stateCount + 2 &&
|
||||
snapshot.busyStateCount > before.busyStateCount)) &&
|
||||
isLoaded(snapshot),
|
||||
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
|
||||
const loadedLayout = await waitForWorkspaceLayout(`${label} workspace`, candidate =>
|
||||
workspaceLayoutMatches(candidate, "catalog") &&
|
||||
candidate.activeMarketTab === tabId);
|
||||
assertWorkspaceAndScheduleGeometry(loadedLayout, `${label} workspace`);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function openThemeCatalogList(label) {
|
||||
@@ -4394,13 +4746,19 @@ try {
|
||||
cleanupMode = true;
|
||||
cleanupDeadlineEpochMilliseconds = Date.now() + 5_000;
|
||||
const cleanupFailures = [];
|
||||
if (pointerIsPressed && socket?.readyState === WebSocket.OPEN) {
|
||||
if ((pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) &&
|
||||
socket?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
await releaseMouse(pointerReleasePoint || { x: 1, y: 1 }, pointerReleaseModifiers);
|
||||
} catch (releaseError) {
|
||||
evidence.warnings.push(`Pointer release cleanup failed: ${releaseError.message}`);
|
||||
cleanupFailures.push(`pointer release: ${releaseError.message}`);
|
||||
await cleanupDragInputSession(true);
|
||||
} catch (inputCleanupError) {
|
||||
evidence.warnings.push(`Input cleanup failed: ${inputCleanupError.message}`);
|
||||
cleanupFailures.push(`input cleanup: ${inputCleanupError.message}`);
|
||||
}
|
||||
} else if ((pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) &&
|
||||
socket?.readyState !== WebSocket.OPEN) {
|
||||
evidence.warnings.push(
|
||||
"Input cleanup failed: the CDP socket closed before drag/pointer restoration.");
|
||||
cleanupFailures.push("input cleanup: CDP socket closed");
|
||||
}
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
|
||||
@@ -215,9 +215,11 @@ async function readShell() {
|
||||
const workspaceRegion = document.getElementById("workspace-region");
|
||||
const navigationToggle = document.getElementById("workspace-nav-toggle");
|
||||
const stockTab = document.getElementById("workspace-stock-tab");
|
||||
const catalogTab = document.getElementById("workspace-catalog-tab");
|
||||
const marketTabs = Array.from(document.querySelectorAll("#market-tabs > button[data-tab-id]"));
|
||||
const settingsTab = document.getElementById("workspace-settings-tab");
|
||||
const stockWorkspace = document.getElementById("stock-workspace");
|
||||
const catalogWorkspace = document.getElementById("catalog-workspace");
|
||||
const settingsWorkspace = document.getElementById("settings-workspace");
|
||||
const schedule = document.querySelector(".playlist-panel");
|
||||
const workspaceBounds = rectangle(workspaceRegion);
|
||||
const scheduleBounds = rectangle(schedule);
|
||||
@@ -250,13 +252,25 @@ async function readShell() {
|
||||
navigationExpanded: navigationToggle?.getAttribute("aria-expanded") === "true" &&
|
||||
workspaceRegion?.classList.contains("nav-collapsed") !== true,
|
||||
stockTabCurrent: stockTab?.getAttribute("aria-current") || null,
|
||||
catalogTabCurrent: catalogTab?.getAttribute("aria-current") || null,
|
||||
marketTabCount: marketTabs.length,
|
||||
marketTabsCurrent: marketTabs.map(button => ({
|
||||
tabId: button.dataset.tabId,
|
||||
current: button.getAttribute("aria-current") || null,
|
||||
hasIcon: Boolean(button.querySelector("svg")),
|
||||
hasLabel: Boolean(button.querySelector(".workspace-nav-label"))
|
||||
})),
|
||||
settingsTabCurrent: settingsTab?.getAttribute("aria-current") || null,
|
||||
currentMenuItemCount: document.querySelectorAll(
|
||||
"#workspace-navigation .workspace-nav-item[aria-current='page']").length,
|
||||
stockWorkspaceVisible: isVisible(stockWorkspace, rectangle(stockWorkspace)) &&
|
||||
stockWorkspace?.getAttribute("aria-hidden") === "false" &&
|
||||
stockWorkspace?.hasAttribute("inert") !== true,
|
||||
catalogWorkspaceHidden: catalogWorkspace?.hidden === true &&
|
||||
catalogWorkspace?.getAttribute("aria-hidden") === "true" &&
|
||||
catalogWorkspace?.hasAttribute("inert") === true,
|
||||
settingsWorkspaceHidden: settingsWorkspace?.hidden === true &&
|
||||
settingsWorkspace?.getAttribute("aria-hidden") === "true" &&
|
||||
settingsWorkspace?.hasAttribute("inert") === true,
|
||||
workspace: workspaceBounds,
|
||||
schedule: scheduleBounds,
|
||||
workspaceToScheduleWidthRatio: workspaceBounds && scheduleBounds &&
|
||||
@@ -286,8 +300,13 @@ function assertShell(shell) {
|
||||
const layout = shell.layout;
|
||||
if (!layout || layout.activeWorkspace !== "stock" ||
|
||||
layout.navigationExpanded !== true ||
|
||||
layout.stockTabCurrent !== "page" || layout.catalogTabCurrent !== "false" ||
|
||||
layout.stockWorkspaceVisible !== true || layout.catalogWorkspaceHidden !== true) {
|
||||
layout.stockTabCurrent !== "page" || layout.settingsTabCurrent !== "false" ||
|
||||
layout.marketTabCount !== 10 ||
|
||||
layout.marketTabsCurrent.some(tab => tab.current !== "false" ||
|
||||
tab.hasIcon !== true || tab.hasLabel !== true) ||
|
||||
layout.currentMenuItemCount !== 1 ||
|
||||
layout.stockWorkspaceVisible !== true || layout.catalogWorkspaceHidden !== true ||
|
||||
layout.settingsWorkspaceHidden !== true) {
|
||||
fail("KNOWN_FAILURE", "The package did not render the expanded navigation with stock as the sole initial workspace.");
|
||||
}
|
||||
if (layout.scheduleVisible !== true || layout.scheduleHitTestVisible !== true ||
|
||||
|
||||
@@ -1374,12 +1374,23 @@
|
||||
if (!button) return;
|
||||
const buttonAtPosition = marketTabs.children.item(position);
|
||||
if (buttonAtPosition !== button) marketTabs.insertBefore(button, buttonAtPosition);
|
||||
button.textContent = tab.label;
|
||||
const label = button.querySelector(".workspace-nav-label");
|
||||
if (label) label.textContent = tab.label;
|
||||
else button.textContent = tab.label;
|
||||
button.setAttribute("aria-label", tab.label);
|
||||
button.title = tab.label;
|
||||
button.classList.toggle("active", tab.isActive === true);
|
||||
button.setAttribute("aria-current", tab.isActive === true ? "page" : "false");
|
||||
button.disabled = state.isBusy === true || state.fixedSectionBatch != null;
|
||||
button.draggable = state.isBusy !== true && state.fixedSectionBatch == null;
|
||||
});
|
||||
const activeTab = state.tabs.find(function (tab) { return tab.isActive === true; });
|
||||
// Keep the clicked menu highlighted while native tab loading is in flight.
|
||||
// The busy snapshot still carries the previous active tab; only reconcile
|
||||
// once the authoritative operation has completed.
|
||||
if (activeTab && state.isBusy !== true &&
|
||||
window.LegacyWorkspaceNavigation?.syncMarket) {
|
||||
window.LegacyWorkspaceNavigation.syncMarket(activeTab.id);
|
||||
}
|
||||
acknowledgePendingTabHoverSwap(
|
||||
state.tabs.map(function (tab) { return tab.id; }));
|
||||
}
|
||||
|
||||
@@ -9,17 +9,20 @@
|
||||
<body>
|
||||
<main class="operator-shell" aria-label="V-Stock 증권정보송출시스템">
|
||||
<section id="workspace-region" class="workspace-region" aria-label="작업 영역">
|
||||
<aside id="workspace-navigation" class="workspace-navigation" aria-label="작업 메뉴">
|
||||
<aside id="workspace-navigation" class="workspace-navigation" role="navigation"
|
||||
aria-label="작업 메뉴">
|
||||
<div class="workspace-nav-header">
|
||||
<button id="workspace-nav-toggle" class="workspace-nav-toggle" type="button"
|
||||
aria-expanded="true" aria-controls="workspace-nav-items" title="메뉴 접기">
|
||||
aria-expanded="true"
|
||||
aria-controls="workspace-nav-items workspace-nav-footer" title="메뉴 접기">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-toggle-label">메뉴 접기</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav id="workspace-nav-items" class="workspace-nav-items" aria-label="작업 화면">
|
||||
<div id="workspace-nav-items" class="workspace-nav-items" role="group"
|
||||
aria-label="작업 화면">
|
||||
<button id="workspace-stock-tab" class="workspace-nav-item workspace-current"
|
||||
type="button" aria-label="종목·컷" title="종목·컷"
|
||||
aria-current="page" aria-controls="stock-workspace">
|
||||
@@ -27,20 +30,104 @@
|
||||
<circle cx="10" cy="10" r="5"></circle>
|
||||
<path d="m14 14 5 5M4 20h16M6 17l3-3 3 2 5-6"></path>
|
||||
</svg>
|
||||
<span>종목·컷</span>
|
||||
<span class="workspace-nav-label">종목·컷</span>
|
||||
</button>
|
||||
<button id="workspace-catalog-tab" class="workspace-nav-item" type="button"
|
||||
aria-label="기타 그래픽" title="기타 그래픽"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<div class="workspace-nav-section-label"><span>기타 그래픽</span></div>
|
||||
<div id="market-tabs" class="market-tabs workspace-market-nav" role="group"
|
||||
aria-label="기타 그래픽">
|
||||
<button class="workspace-nav-item workspace-market-item active" type="button"
|
||||
draggable="true" data-tab-id="overseas" aria-label="해외" title="해외"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<circle cx="12" cy="12" r="8"></circle><path d="M4 12h16M12 4c2.5 2.2 3.8 4.9 3.8 8s-1.3 5.8-3.8 8c-2.5-2.2-3.8-4.9-3.8-8S9.5 6.2 12 4Z"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">해외</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="exchange" aria-label="환율" title="환율"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M5 8h13l-3-3M19 16H6l3 3"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">환율</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="index" aria-label="지수" title="지수"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M4 19h16M5 16l4-5 3 2 6-8"></path><path d="m14 5 4 0 0 4"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">지수</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="kospiIndustry" aria-label="코스피" title="코스피"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M5 19V9h3v10M11 19V5h3v14M17 19v-7h3v7M4 19h17"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">코스피</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="kosdaqIndustry" aria-label="코스닥" title="코스닥"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M6 5v14M4 9h4M12 4v16M10 14h4M18 6v13M16 10h4"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">코스닥</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="comparison" aria-label="비교" title="비교"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M5 7h14M5 17h14M8 4 5 7l3 3M16 14l3 3-3 3"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">비교</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="theme" aria-label="테마" title="테마"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="m12 4 2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7L12 4Z"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">테마</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="overseasStocks" aria-label="해외종목" title="해외종목"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<circle cx="8" cy="9" r="4"></circle><path d="M3 19c.7-3 2.3-5 5-5s4.3 2 5 5M15 7h6M18 4v6M15 14h6M18 11v6"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">해외종목</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="expert" aria-label="전문가" title="전문가"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<circle cx="12" cy="8" r="4"></circle><path d="M5 20c.8-4 3.1-6 7-6s6.2 2 7 6M17 5l2-2M7 5 5-2"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">전문가</span>
|
||||
</button>
|
||||
<button class="workspace-nav-item workspace-market-item" type="button"
|
||||
draggable="true" data-tab-id="tradingHalt" aria-label="정지" title="정지"
|
||||
aria-current="false" aria-controls="catalog-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<circle cx="12" cy="12" r="8"></circle><path d="M9.5 8v8M14.5 8v8"></path>
|
||||
</svg>
|
||||
<span class="workspace-nav-label">정지</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="workspace-nav-footer" class="workspace-nav-footer">
|
||||
<button id="workspace-settings-tab" class="workspace-nav-item" type="button"
|
||||
aria-label="설정" title="설정" aria-current="false"
|
||||
aria-controls="settings-workspace">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<rect x="4" y="4" width="6" height="6" rx="1"></rect>
|
||||
<rect x="14" y="4" width="6" height="6" rx="1"></rect>
|
||||
<rect x="4" y="14" width="6" height="6" rx="1"></rect>
|
||||
<rect x="14" y="14" width="6" height="6" rx="1"></rect>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.4 1A8 8 0 0 0 15 6.2L14.7 4h-4l-.3 2.2a8 8 0 0 0-1.5.9l-2.4-1-2 3.4 2 1.5a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.4-1a8 8 0 0 0 1.5.9l.3 2.2h4l.3-2.2a8 8 0 0 0 1.5-.9l2.4 1 2-3.4-2-1.5a7 7 0 0 0 .1-1Z"></path>
|
||||
</svg>
|
||||
<span>기타 그래픽</span>
|
||||
<span class="workspace-nav-label">설정</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace-surface" aria-labelledby="workspace-title">
|
||||
@@ -91,22 +178,49 @@
|
||||
|
||||
<section id="catalog-workspace" class="catalog-panel workspace-view"
|
||||
aria-label="분류별 그래픽" hidden inert>
|
||||
<nav id="market-tabs" class="market-tabs" aria-label="시장 분류">
|
||||
<button class="active" type="button" draggable="true" data-tab-id="overseas">해외</button>
|
||||
<button type="button" draggable="true" data-tab-id="exchange">환율</button>
|
||||
<button type="button" draggable="true" data-tab-id="index">지수</button>
|
||||
<button type="button" draggable="true" data-tab-id="kospiIndustry">코스피</button>
|
||||
<button type="button" draggable="true" data-tab-id="kosdaqIndustry">코스닥</button>
|
||||
<button type="button" draggable="true" data-tab-id="comparison">비교</button>
|
||||
<button type="button" draggable="true" data-tab-id="theme">테마</button>
|
||||
<button type="button" draggable="true" data-tab-id="overseasStocks">해외종목</button>
|
||||
<button type="button" draggable="true" data-tab-id="expert">전문가</button>
|
||||
<button type="button" draggable="true" data-tab-id="tradingHalt">정지</button>
|
||||
</nav>
|
||||
<label class="expand-line"><input id="catalog-expand-all" type="checkbox" checked> Expand</label>
|
||||
<div class="category-title">Category</div>
|
||||
<div id="category-tree" class="category-tree" aria-label="원본 카테고리"></div>
|
||||
</section>
|
||||
|
||||
<section id="settings-workspace" class="settings-panel workspace-view"
|
||||
aria-label="설정" hidden inert>
|
||||
<div class="settings-intro">
|
||||
<div class="settings-intro-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.4 1A8 8 0 0 0 15 6.2L14.7 4h-4l-.3 2.2a8 8 0 0 0-1.5.9l-2.4-1-2 3.4 2 1.5a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.4-1a8 8 0 0 0 1.5.9l.3 2.2h4l.3-2.2a8 8 0 0 0 1.5-.9l2.4 1 2-3.4-2-1.5a7 7 0 0 0 .1-1Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>운영 설정</h2>
|
||||
<p>화면 구성과 운영 단축키를 확인합니다. 송출 대상과 데이터베이스 연결은 검증된 로컬 설정을 그대로 사용합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<article class="settings-card">
|
||||
<h3>화면 구성</h3>
|
||||
<dl>
|
||||
<div><dt>왼쪽 메뉴</dt><dd>상단 메뉴 버튼으로 접기·펼치기</dd></div>
|
||||
<div><dt>작업 화면</dt><dd>선택한 종목 또는 그래픽 작업 표시</dd></div>
|
||||
<div><dt>송출 스케줄</dt><dd>오른쪽에 항상 고정 표시</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<article class="settings-card">
|
||||
<h3>운영 단축키</h3>
|
||||
<dl class="settings-shortcuts">
|
||||
<div><dt><kbd>F2</kbd></dt><dd>배경 파일</dd></div>
|
||||
<div><dt><kbd>F3</kbd></dt><dd>배경 없음</dd></div>
|
||||
<div><dt><kbd>F8</kbd></dt><dd>TAKE IN</dd></div>
|
||||
<div><dt><kbd>Esc</kbd></dt><dd>TAKE OUT</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<article class="settings-card settings-card-wide">
|
||||
<h3>보호되는 로컬 설정</h3>
|
||||
<p>DB 자격 증명, Tornado2 송출 대상, 자산 경로와 Live/DryRun 권한은 이 화면에서 변경하지 않습니다. 현재 PC에 검증된 운영 프로필이 계속 적용됩니다.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -42,7 +42,7 @@ button:disabled { color: #555; opacity: 1; }
|
||||
.workspace-region.nav-collapsed { --workspace-nav-width: var(--workspace-nav-collapsed); }
|
||||
.workspace-navigation {
|
||||
display: grid;
|
||||
grid-template-rows: 66px minmax(0, 1fr);
|
||||
grid-template-rows: 66px minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -87,8 +87,22 @@ button:disabled { color: #555; opacity: 1; }
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: auto;
|
||||
padding: 4px 8px 12px;
|
||||
padding: 4px 8px 8px;
|
||||
}
|
||||
.workspace-nav-section-label {
|
||||
display: flex;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
align-items: center;
|
||||
padding: 4px 12px 2px;
|
||||
overflow: hidden;
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workspace-nav-footer { padding: 8px; border-top: 1px solid var(--workspace-border); }
|
||||
.workspace-nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -124,8 +138,17 @@ button:disabled { color: #555; opacity: 1; }
|
||||
content: "";
|
||||
}
|
||||
.nav-collapsed .workspace-nav-toggle { justify-content: center; padding: 0; }
|
||||
.nav-collapsed .workspace-nav-toggle-label, .nav-collapsed .workspace-nav-item span { display: none; }
|
||||
.nav-collapsed .workspace-nav-toggle-label,
|
||||
.nav-collapsed .workspace-nav-item span,
|
||||
.nav-collapsed .workspace-nav-section-label span { display: none; }
|
||||
.nav-collapsed .workspace-nav-item { justify-content: center; padding: 0; }
|
||||
.nav-collapsed .workspace-nav-section-label {
|
||||
height: 12px;
|
||||
flex-basis: 12px;
|
||||
margin: 0 7px;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
}
|
||||
|
||||
.workspace-surface {
|
||||
display: grid;
|
||||
@@ -1224,6 +1247,166 @@ summary[data-tree-root]:focus-visible {
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* Sidebar catalog navigation and its read-only settings landing. */
|
||||
.workspace-nav-section-label {
|
||||
color: var(--ui-text-subtle);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.workspace-nav-footer {
|
||||
border-top-color: var(--ui-border);
|
||||
background: rgba(255, 255, 255, .42);
|
||||
}
|
||||
.workspace-navigation .market-tabs {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.workspace-navigation .market-tabs button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 44px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 13px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #445066;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workspace-navigation .market-tabs button.active:not(.workspace-current) {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #445066;
|
||||
font-weight: 600;
|
||||
}
|
||||
.workspace-navigation .market-tabs button:not(:disabled):hover {
|
||||
background: #edf1f6;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
.workspace-navigation .market-tabs button:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: -2px;
|
||||
background: var(--ui-accent-soft);
|
||||
}
|
||||
.workspace-navigation .market-tabs button.workspace-current,
|
||||
.workspace-navigation .market-tabs button.active.workspace-current {
|
||||
background: var(--ui-accent-soft);
|
||||
box-shadow: none;
|
||||
color: #174ea6;
|
||||
font-weight: 700;
|
||||
}
|
||||
.workspace-navigation .market-tabs button.workspace-current::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 4px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: var(--ui-accent);
|
||||
content: "";
|
||||
}
|
||||
.workspace-navigation .market-tabs button[draggable="true"] { cursor: grab; }
|
||||
.workspace-navigation .market-tabs button.dragging { opacity: .58; cursor: grabbing; }
|
||||
.nav-collapsed .workspace-navigation .market-tabs button {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
background: var(--ui-canvas);
|
||||
}
|
||||
.settings-intro {
|
||||
display: flex;
|
||||
max-width: 900px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid #c9d8ee;
|
||||
border-radius: var(--ui-radius-lg);
|
||||
background: linear-gradient(135deg, #f8fbff, #eef4ff);
|
||||
box-shadow: var(--ui-shadow-sm);
|
||||
}
|
||||
.settings-intro-icon {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 48px;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
background: var(--ui-accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px rgba(37, 99, 235, .2);
|
||||
}
|
||||
.settings-intro-icon svg {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.settings-intro h2 { margin: 0 0 5px; font-size: 20px; }
|
||||
.settings-intro p { margin: 0; color: var(--ui-text-muted); line-height: 1.55; }
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
max-width: 900px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.settings-card {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius);
|
||||
background: var(--ui-surface);
|
||||
box-shadow: var(--ui-shadow-card);
|
||||
}
|
||||
.settings-card-wide { grid-column: 1 / -1; }
|
||||
.settings-card h3 { margin: 0 0 13px; font-size: 15px; }
|
||||
.settings-card p { margin: 0; color: var(--ui-text-muted); line-height: 1.6; }
|
||||
.settings-card dl { margin: 0; }
|
||||
.settings-card dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, .42fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid #edf0f4;
|
||||
}
|
||||
.settings-card dl > div:first-child { padding-top: 0; border-top: 0; }
|
||||
.settings-card dl > div:last-child { padding-bottom: 0; }
|
||||
.settings-card dt { color: #344054; font-weight: 700; }
|
||||
.settings-card dd { margin: 0; color: var(--ui-text-muted); }
|
||||
.settings-shortcuts kbd {
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid #b8c4d4;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--ui-surface-subtle);
|
||||
color: #344054;
|
||||
font: 700 11px "Segoe UI", sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
* { scrollbar-color: auto; }
|
||||
button:focus-visible,
|
||||
|
||||
@@ -7,22 +7,63 @@
|
||||
const toggle = document.getElementById("workspace-nav-toggle");
|
||||
const toggleLabel = toggle && toggle.querySelector(".workspace-nav-toggle-label");
|
||||
const stockTab = document.getElementById("workspace-stock-tab");
|
||||
const catalogTab = document.getElementById("workspace-catalog-tab");
|
||||
const settingsTab = document.getElementById("workspace-settings-tab");
|
||||
const stockWorkspace = document.getElementById("stock-workspace");
|
||||
const catalogWorkspace = document.getElementById("catalog-workspace");
|
||||
const settingsWorkspace = document.getElementById("settings-workspace");
|
||||
const marketTabs = document.getElementById("market-tabs");
|
||||
const title = document.getElementById("workspace-title");
|
||||
|
||||
if (!region || !navigation || !navigationItems || !toggle || !toggleLabel ||
|
||||
!stockTab || !catalogTab || !stockWorkspace || !catalogWorkspace ||
|
||||
!marketTabs || !title) {
|
||||
!stockTab || !settingsTab || !stockWorkspace || !catalogWorkspace ||
|
||||
!settingsWorkspace || !marketTabs || !title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const views = Object.freeze({
|
||||
stock: Object.freeze({ tab: stockTab, view: stockWorkspace, title: "종목·컷" }),
|
||||
catalog: Object.freeze({ tab: catalogTab, view: catalogWorkspace, title: "기타 그래픽" })
|
||||
stock: Object.freeze({ view: stockWorkspace, title: "종목·컷" }),
|
||||
catalog: Object.freeze({ view: catalogWorkspace, title: "기타 그래픽" }),
|
||||
settings: Object.freeze({ view: settingsWorkspace, title: "설정" })
|
||||
});
|
||||
let activeWorkspace = "stock";
|
||||
let activeMarketTabId = marketTabs.querySelector("button.active[data-tab-id]")
|
||||
?.dataset.tabId || marketTabs.querySelector("button[data-tab-id]")?.dataset.tabId || null;
|
||||
let pendingKeyboardMarketTabId = null;
|
||||
|
||||
function marketButtons() {
|
||||
return Array.from(marketTabs.querySelectorAll("button[data-tab-id]"));
|
||||
}
|
||||
|
||||
function marketButton(tabId) {
|
||||
return marketButtons().find(function (button) {
|
||||
return button.dataset.tabId === tabId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function marketLabel(button) {
|
||||
const label = button && button.querySelector(".workspace-nav-label");
|
||||
return (label && label.textContent.trim()) || button?.textContent.trim() || views.catalog.title;
|
||||
}
|
||||
|
||||
function selectedMenuButton(name) {
|
||||
if (name === "stock") return stockTab;
|
||||
if (name === "settings") return settingsTab;
|
||||
if (name === "catalog") {
|
||||
return marketButton(activeMarketTabId) || marketButtons()[0] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function refreshCurrentMenu() {
|
||||
const selected = selectedMenuButton(activeWorkspace);
|
||||
Array.from(navigation.querySelectorAll(".workspace-nav-item")).forEach(function (button) {
|
||||
const isCurrent = button === selected;
|
||||
button.classList.toggle("workspace-current", isCurrent);
|
||||
button.setAttribute("aria-current", isCurrent ? "page" : "false");
|
||||
});
|
||||
region.dataset.activeWorkspace = activeWorkspace;
|
||||
if (activeMarketTabId) region.dataset.activeMarketTab = activeMarketTabId;
|
||||
}
|
||||
|
||||
function setNavigationExpanded(expanded) {
|
||||
const isExpanded = expanded === true;
|
||||
@@ -37,31 +78,62 @@
|
||||
const selected = views[name];
|
||||
if (!selected) return false;
|
||||
|
||||
const selectedButton = selectedMenuButton(name);
|
||||
const currentFocus = document.activeElement;
|
||||
const focusWillBeHidden = Object.keys(views).some(function (key) {
|
||||
return key !== name && views[key].view.contains(currentFocus);
|
||||
});
|
||||
if (focusWillBeHidden) selected.tab.focus();
|
||||
if (focusWillBeHidden && selectedButton) selectedButton.focus();
|
||||
|
||||
Object.keys(views).forEach(function (key) {
|
||||
const entry = views[key];
|
||||
const isCurrent = key === name;
|
||||
entry.tab.classList.toggle("workspace-current", isCurrent);
|
||||
entry.tab.setAttribute("aria-current", isCurrent ? "page" : "false");
|
||||
entry.view.hidden = !isCurrent;
|
||||
entry.view.toggleAttribute("inert", !isCurrent);
|
||||
entry.view.setAttribute("aria-hidden", isCurrent ? "false" : "true");
|
||||
});
|
||||
|
||||
region.dataset.activeWorkspace = name;
|
||||
title.textContent = selected.title;
|
||||
activeWorkspace = name;
|
||||
refreshCurrentMenu();
|
||||
title.textContent = name === "catalog"
|
||||
? marketLabel(selectedMenuButton(name))
|
||||
: selected.title;
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncMarket(tabId) {
|
||||
const button = marketButton(tabId);
|
||||
if (!button) return false;
|
||||
activeMarketTabId = tabId;
|
||||
region.dataset.activeMarketTab = tabId;
|
||||
if (activeWorkspace === "catalog") {
|
||||
refreshCurrentMenu();
|
||||
title.textContent = marketLabel(button);
|
||||
}
|
||||
if (pendingKeyboardMarketTabId && !button.disabled) {
|
||||
const pendingButton = marketButton(pendingKeyboardMarketTabId);
|
||||
const focusWasLost = !document.activeElement ||
|
||||
document.activeElement === document.body ||
|
||||
document.activeElement === document.documentElement;
|
||||
if (activeWorkspace === "catalog" && pendingButton &&
|
||||
!pendingButton.disabled && focusWasLost) {
|
||||
pendingButton.focus();
|
||||
}
|
||||
pendingKeyboardMarketTabId = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function select(name) {
|
||||
if (views[name]) return setWorkspace(name);
|
||||
if (!syncMarket(name)) return false;
|
||||
return setWorkspace("catalog");
|
||||
}
|
||||
|
||||
function focusAdjacentItem(direction) {
|
||||
const items = [stockTab, catalogTab].filter(function (button) {
|
||||
return button.disabled !== true;
|
||||
});
|
||||
const items = Array.from(navigation.querySelectorAll(".workspace-nav-item"))
|
||||
.filter(function (button) { return button.disabled !== true; });
|
||||
if (items.length === 0) return;
|
||||
const currentIndex = items.indexOf(document.activeElement);
|
||||
const nextIndex = currentIndex < 0
|
||||
? 0
|
||||
@@ -73,14 +145,17 @@
|
||||
setNavigationExpanded(toggle.getAttribute("aria-expanded") !== "true");
|
||||
});
|
||||
stockTab.addEventListener("click", function () { setWorkspace("stock"); });
|
||||
catalogTab.addEventListener("click", function () { setWorkspace("catalog"); });
|
||||
settingsTab.addEventListener("click", function () { setWorkspace("settings"); });
|
||||
|
||||
navigationItems.addEventListener("keydown", function (event) {
|
||||
navigation.addEventListener("keydown", function (event) {
|
||||
const menuButton = event.target.closest && event.target.closest(".workspace-nav-item");
|
||||
if ((event.key === "Enter" || event.key === " ") && menuButton) {
|
||||
if (!menuButton) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setWorkspace(menuButton === stockTab ? "stock" : "catalog");
|
||||
const keyboardMarketTabId = menuButton.dataset.tabId || null;
|
||||
menuButton.click();
|
||||
if (keyboardMarketTabId) pendingKeyboardMarketTabId = keyboardMarketTabId;
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||
@@ -89,19 +164,25 @@
|
||||
focusAdjacentItem(event.key === "ArrowUp" ? -1 : 1);
|
||||
});
|
||||
|
||||
// A market tab can also be activated by automation or accessibility tools.
|
||||
// Reveal its owning workspace before app.js dispatches the native select-tab intent.
|
||||
// Reveal the selected catalog screen before app.js dispatches its native
|
||||
// select-tab intent. The native state response remains authoritative and
|
||||
// is reconciled through syncMarket after every render.
|
||||
marketTabs.addEventListener("click", function (event) {
|
||||
if (event.target.closest("button[data-tab-id]")) setWorkspace("catalog");
|
||||
const button = event.target.closest("button[data-tab-id]");
|
||||
if (!button || !marketTabs.contains(button)) return;
|
||||
syncMarket(button.dataset.tabId);
|
||||
setWorkspace("catalog");
|
||||
}, true);
|
||||
|
||||
setNavigationExpanded(true);
|
||||
setWorkspace("stock");
|
||||
|
||||
window.LegacyWorkspaceNavigation = Object.freeze({
|
||||
select: setWorkspace,
|
||||
select: select,
|
||||
syncMarket: syncMarket,
|
||||
setExpanded: setNavigationExpanded,
|
||||
active: function () { return region.dataset.activeWorkspace; },
|
||||
active: function () { return activeWorkspace; },
|
||||
activeMarket: function () { return activeMarketTabId; },
|
||||
expanded: function () { return toggle.getAttribute("aria-expanded") === "true"; }
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -47,8 +47,10 @@ test("legacy shell exposes the original stock, cut, playlist and playout control
|
||||
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>/);
|
||||
assert.match(markup,
|
||||
/class="[^"]*\bactive\b[^"]*"[^>]*data-tab-id="overseas"[\s\S]*?<span class="workspace-nav-label">해외<\/span>[\s\S]*?<\/button>/);
|
||||
assert.match(markup,
|
||||
/data-tab-id="tradingHalt"[\s\S]*?<span class="workspace-nav-label">정지<\/span>[\s\S]*?<\/button>/);
|
||||
});
|
||||
|
||||
test("web layer sends intent and does not own scene, DB identity or playlist state", () => {
|
||||
@@ -840,6 +842,8 @@ test("market tabs send closed identity intents and reconcile C# order", () => {
|
||||
assert.match(app, /dataset\.tabId/);
|
||||
assert.match(app, /const existingTabs = new Map\(\)/);
|
||||
assert.match(app, /marketTabs\.insertBefore/);
|
||||
assert.match(app, /button\.querySelector\("\.workspace-nav-label"\)/);
|
||||
assert.match(app, /LegacyWorkspaceNavigation\?\.syncMarket/);
|
||||
assert.doesNotMatch(app, /selectedTab\s*=/);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,34 +11,72 @@ const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
|
||||
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
|
||||
const navigation = fs.readFileSync(path.join(webRoot, "workspace-navigation.js"), "utf8");
|
||||
|
||||
test("operator shell keeps one switchable workspace beside an always-present schedule", () => {
|
||||
const expectedMarketTabs = [
|
||||
"overseas",
|
||||
"exchange",
|
||||
"index",
|
||||
"kospiIndustry",
|
||||
"kosdaqIndustry",
|
||||
"comparison",
|
||||
"theme",
|
||||
"overseasStocks",
|
||||
"expert",
|
||||
"tradingHalt"
|
||||
];
|
||||
|
||||
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",
|
||||
"workspace-catalog-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 && stockStart > workspaceStart);
|
||||
assert.ok(workspaceStart >= 0 && navigationStart > workspaceStart);
|
||||
assert.ok(marketStart > navigationStart && settingsTabStart > marketStart);
|
||||
assert.ok(stockStart > settingsTabStart);
|
||||
assert.ok(catalogStart > stockStart);
|
||||
assert.ok(scheduleStart > catalogStart);
|
||||
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 uses a two-to-one work and schedule split with compact schedule columns", () => {
|
||||
assert.match(styles,
|
||||
/grid-template-columns:\s*minmax\(900px, 2fr\) minmax\(600px, 1fr\)/);
|
||||
@@ -46,6 +84,11 @@ test("layout uses a two-to-one work and schedule split with compact schedule col
|
||||
/\.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/);
|
||||
@@ -76,19 +119,48 @@ test("modern operator theme keeps clear semantic states and accessible focus", (
|
||||
assert.match(markup, /class="playlist-heading-icon"[\s\S]*?<svg[^>]*viewBox="0 0 24 24"/);
|
||||
});
|
||||
|
||||
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\) selected\.tab\.focus\(\)/);
|
||||
assert.match(navigation, /setAttribute\("aria-current", isCurrent \? "page" : "false"\)/);
|
||||
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, /setWorkspace\(menuButton === stockTab \? "stock" : "catalog"\)/);
|
||||
assert.match(navigation, /menuButton\.click\(\)/);
|
||||
assert.match(navigation, /pendingKeyboardMarketTabId = keyboardMarketTabId/);
|
||||
assert.match(navigation, /pendingButton\.focus\(\)/);
|
||||
assert.match(navigation, /document\.activeElement === document\.body/);
|
||||
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, /activeTab && state\.isBusy !== true/);
|
||||
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>');
|
||||
|
||||
@@ -53,8 +53,12 @@ public sealed class LegacyWebContractTests
|
||||
Assert.Contains("TAKE OUT", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("NEXT", Markup, StringComparison.Ordinal);
|
||||
Assert.Matches("class=\"connection-state\"[^>]*>미연결</div>", Markup);
|
||||
Assert.Matches("class=\"active\"[^>]*data-tab-id=\"overseas\">해외</button>", Markup);
|
||||
Assert.Matches("data-tab-id=\"tradingHalt\">정지</button>", Markup);
|
||||
Assert.Matches(
|
||||
@"class=""[^""]*\bactive\b[^""]*""[^>]*data-tab-id=""overseas""[\s\S]*?<span class=""workspace-nav-label"">해외<\/span>[\s\S]*?<\/button>",
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
@"data-tab-id=""tradingHalt""[\s\S]*?<span class=""workspace-nav-label"">정지<\/span>[\s\S]*?<\/button>",
|
||||
Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -265,6 +269,17 @@ public sealed class LegacyWebContractTests
|
||||
Assert.DoesNotContain("send(\"swap-tabs\"", App, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarketTabRenderingPreservesSidebarLabelsAndReconcilesNativeSelection()
|
||||
{
|
||||
Assert.Contains("button.querySelector(\".workspace-nav-label\")", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("if (label) label.textContent = tab.label", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("window.LegacyWorkspaceNavigation?.syncMarket", App,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedCatalogUsesOpaqueCSharpActionAndSectionIntents()
|
||||
{
|
||||
|
||||
@@ -17,25 +17,56 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Path.Combine(WebRoot, "workspace-navigation.js"));
|
||||
|
||||
[Fact]
|
||||
public void ShellUsesSwitchableWorkspaceBesidePersistentSchedule()
|
||||
public void ShellUsesSidebarScreensBesidePersistentSchedule()
|
||||
{
|
||||
var workspaceStart = Markup.IndexOf("id=\"workspace-region\"", StringComparison.Ordinal);
|
||||
var navigationStart = Markup.IndexOf("id=\"workspace-navigation\"", StringComparison.Ordinal);
|
||||
var marketStart = Markup.IndexOf("id=\"market-tabs\"", StringComparison.Ordinal);
|
||||
var settingsTabStart = Markup.IndexOf("id=\"workspace-settings-tab\"", StringComparison.Ordinal);
|
||||
var stockStart = Markup.IndexOf("id=\"stock-workspace\"", StringComparison.Ordinal);
|
||||
var catalogStart = Markup.IndexOf("id=\"catalog-workspace\"", StringComparison.Ordinal);
|
||||
var settingsStart = Markup.IndexOf("id=\"settings-workspace\"", StringComparison.Ordinal);
|
||||
var scheduleStart = Markup.IndexOf("class=\"playlist-panel\"", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(workspaceStart >= 0);
|
||||
Assert.True(stockStart > workspaceStart);
|
||||
Assert.True(navigationStart > workspaceStart);
|
||||
Assert.True(marketStart > navigationStart);
|
||||
Assert.True(settingsTabStart > marketStart);
|
||||
Assert.True(stockStart > settingsTabStart);
|
||||
Assert.True(catalogStart > stockStart);
|
||||
Assert.True(scheduleStart > catalogStart);
|
||||
Assert.True(settingsStart > catalogStart);
|
||||
Assert.True(scheduleStart > settingsStart);
|
||||
Assert.Matches(
|
||||
@"<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*<section class=""playlist-panel""",
|
||||
Markup);
|
||||
Assert.Matches("id=\"catalog-workspace\"[^>]*hidden inert", Markup);
|
||||
Assert.Matches("id=\"settings-workspace\"[^>]*hidden inert", Markup);
|
||||
Assert.Contains("<small>항상 표시</small><h2>송출 스케줄</h2>", Markup,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SidebarOwnsEachNativeMarketButtonAndKeepsSettingsOutsideTheirOrder()
|
||||
{
|
||||
var marketStart = Markup.IndexOf("<div id=\"market-tabs\"", StringComparison.Ordinal);
|
||||
var marketEnd = Markup.IndexOf("</div>", marketStart, StringComparison.Ordinal);
|
||||
Assert.True(marketStart >= 0 && marketEnd > marketStart);
|
||||
var marketMarkup = Markup[marketStart..marketEnd];
|
||||
var expected = new[]
|
||||
{
|
||||
"overseas", "exchange", "index", "kospiIndustry", "kosdaqIndustry",
|
||||
"comparison", "theme", "overseasStocks", "expert", "tradingHalt"
|
||||
};
|
||||
var actual = Regex.Matches(marketMarkup, "data-tab-id=\"([^\"]+)\"")
|
||||
.Select(match => match.Groups[1].Value)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
Assert.Equal(expected.Length, Regex.Matches(marketMarkup, "<button\\b").Count);
|
||||
Assert.DoesNotContain("workspace-settings-tab", marketMarkup, StringComparison.Ordinal);
|
||||
Assert.Contains("workspace-nav-label", marketMarkup, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkAndScheduleUseResponsiveTwoToOneColumns()
|
||||
{
|
||||
@@ -43,6 +74,13 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
"grid-template-columns: minmax(900px, 2fr) minmax(600px, 1fr);",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"grid-template-rows: 66px minmax(0, 1fr) auto;",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(".workspace-nav-footer", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".workspace-navigation .market-tabs button", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
new Regex(
|
||||
@"\.playlist-toolbar\s*\{[^}]*flex-wrap:\s*wrap",
|
||||
@@ -55,6 +93,28 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Styles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SidebarIsOneLabelledAndFullyControlledNavigationRegion()
|
||||
{
|
||||
Assert.Matches(
|
||||
"id=\"workspace-navigation\"[^>]*role=\"navigation\"[^>]*aria-label=",
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
"id=\"workspace-nav-toggle\"[^>]*aria-expanded=\"true\"[\\s\\S]*?" +
|
||||
"aria-controls=\"workspace-nav-items workspace-nav-footer\"",
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
"id=\"workspace-nav-items\"[^>]*role=\"group\"[^>]*aria-label=",
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
"id=\"market-tabs\"[^>]*role=\"group\"[^>]*aria-label=",
|
||||
Markup);
|
||||
Assert.Contains(
|
||||
"id=\"workspace-nav-footer\" class=\"workspace-nav-footer\"",
|
||||
Markup,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModernOperatorThemePreservesAccessibleAndSemanticStates()
|
||||
{
|
||||
@@ -119,6 +179,14 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
"entry.view.toggleAttribute(\"inert\", !isCurrent)",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("settingsTab.addEventListener(\"click\"", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("syncMarket(button.dataset.tabId)", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("menuButton.click()", Navigation, StringComparison.Ordinal);
|
||||
Assert.Contains("pendingKeyboardMarketTabId", Navigation, StringComparison.Ordinal);
|
||||
Assert.Contains("pendingButton.focus()", Navigation, StringComparison.Ordinal);
|
||||
Assert.Contains("LegacyWorkspaceNavigation", Navigation, StringComparison.Ordinal);
|
||||
Assert.Contains("nav-collapsed", Navigation, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("postMessage", Navigation, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("localStorage", Navigation, StringComparison.Ordinal);
|
||||
|
||||
Reference in New Issue
Block a user