feat: expose graphic screens in sidebar

This commit is contained in:
2026-07-21 09:45:12 +09:00
parent 44a7d90413
commit 15ad9934a9
11 changed files with 1044 additions and 117 deletions

View File

@@ -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') {

View File

@@ -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 {

View File

@@ -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 ||

View File

@@ -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; }));
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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"; }
});
}());

View File

@@ -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*=/);
});

View File

@@ -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>');

View File

@@ -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()
{

View File

@@ -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);