feat: add task-manager workspace layout

This commit is contained in:
2026-07-21 00:54:46 +09:00
parent 4a54977341
commit f1fe65ebe4
7 changed files with 859 additions and 72 deletions

View File

@@ -1040,6 +1040,234 @@ async function elementGeometry(expression, label, verticalFraction = 0.5) {
return geometry; return geometry;
} }
async function readWorkspaceLayout() {
return await evaluate(`(() => {
function rectangle(element) {
if (!element) return null;
const bounds = element.getBoundingClientRect();
return {
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
width: bounds.width,
height: bounds.height
};
}
const shell = document.querySelector(".operator-shell");
const region = document.getElementById("workspace-region");
const navigation = document.getElementById("workspace-navigation");
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 stock = document.getElementById("stock-workspace");
const catalog = document.getElementById("catalog-workspace");
const schedule = document.querySelector(".playlist-panel");
const scheduleBounds = schedule?.getBoundingClientRect() || null;
const scheduleHit = scheduleBounds && scheduleBounds.width > 0 && scheduleBounds.height > 0
? document.elementFromPoint(
scheduleBounds.left + Math.min(scheduleBounds.width - 2, scheduleBounds.width / 2),
scheduleBounds.top + Math.min(scheduleBounds.height - 2, 90))
: null;
return {
viewport: {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight
},
shell: rectangle(shell),
region: rectangle(region),
navigation: rectangle(navigation),
surface: rectangle(surface),
schedule: rectangle(schedule),
scheduleHitOwned: Boolean(schedule && scheduleHit && schedule.contains(scheduleHit)),
activeWorkspace: region?.dataset?.activeWorkspace || null,
navigationExpanded: toggle?.getAttribute("aria-expanded") === "true",
navigationCollapsedClass: region?.classList.contains("nav-collapsed") === true,
title: (document.getElementById("workspace-title")?.textContent || "").trim(),
stock: {
hidden: stock?.hidden === true,
inert: stock?.hasAttribute("inert") === true,
ariaHidden: stock?.getAttribute("aria-hidden") || null,
tabCurrent: stockTab?.getAttribute("aria-current") || null,
width: stock?.getBoundingClientRect().width ?? null,
height: stock?.getBoundingClientRect().height ?? null
},
catalog: {
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
}
};
})()`);
}
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";
}
async function waitForWorkspaceLayout(label, predicate) {
const deadline = Math.min(Date.now() + 5_000, hardDeadlineEpochMilliseconds);
let lastLayout = null;
while (Date.now() < deadline) {
lastLayout = await readWorkspaceLayout();
if (predicate(lastLayout)) return lastLayout;
await sleep(25);
}
failKnown(`Timed out waiting for ${label}. Last layout: ${JSON.stringify(lastLayout)}`);
}
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.`);
}
const widthRatio = layout.region.width / layout.schedule.width;
const workspaceShare = layout.region.width / layout.shell.width;
if (!Number.isFinite(widthRatio) || widthRatio < 1.85 || widthRatio > 2.15 ||
!Number.isFinite(workspaceShare) || workspaceShare < 0.62 || workspaceShare > 0.70 ||
Math.abs(layout.schedule.right - layout.shell.right) > 1.5 ||
Math.abs(layout.schedule.top - layout.shell.top) > 1.5 ||
Math.abs(layout.schedule.bottom - layout.shell.bottom) > 1.5) {
failKnown(`${label} does not preserve the approximately 2:1 workspace/schedule split.`);
}
return { widthRatio, workspaceShare };
}
function assertFixedSchedule(baseline, current, label) {
for (const edge of ["left", "top", "right", "bottom", "width", "height"]) {
if (Math.abs(baseline.schedule[edge] - current.schedule[edge]) > 1.5) {
failKnown(`${label} moved or resized the always-visible schedule at ${edge}.`);
}
}
assertWorkspaceAndScheduleGeometry(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);
layout = await waitForWorkspaceLayout(label, candidate =>
workspaceLayoutMatches(candidate, workspace));
}
assertWorkspaceAndScheduleGeometry(layout, label);
return layout;
}
async function verifyWorkspaceNavigationAndLayout(preflight) {
const baseline = await ensureWorkspace("stock", "workspace stock preflight");
if (baseline.navigationExpanded !== true || baseline.navigationCollapsedClass === true) {
failKnown("The workspace menu was not initially expanded.");
}
const baselineRatio = assertWorkspaceAndScheduleGeometry(
baseline,
"expanded stock workspace preflight");
await pointerClick(
'document.getElementById("workspace-nav-toggle")',
"collapse workspace menu");
const collapsed = await waitForWorkspaceLayout("collapsed workspace menu", layout =>
workspaceLayoutMatches(layout, "stock", false) &&
layout.navigationCollapsedClass === true);
if (collapsed.navigation.width >= baseline.navigation.width - 100 ||
collapsed.surface.width <= baseline.surface.width + 100) {
failKnown("The pointer collapse did not transfer menu width to the active workspace.");
}
assertFixedSchedule(baseline, collapsed, "collapsed workspace menu");
await pointerClick(
'document.getElementById("workspace-catalog-tab")',
"select catalog workspace from collapsed menu");
const catalog = await waitForWorkspaceLayout("catalog workspace", layout =>
workspaceLayoutMatches(layout, "catalog", false) &&
layout.title === "기타 그래픽");
assertFixedSchedule(baseline, catalog, "catalog workspace transition");
await pointerClick(
'document.getElementById("workspace-nav-toggle")',
"expand workspace menu");
const expandedCatalog = await waitForWorkspaceLayout("expanded catalog workspace", layout =>
workspaceLayoutMatches(layout, "catalog", true) &&
layout.navigationCollapsedClass === false);
if (Math.abs(expandedCatalog.navigation.width - baseline.navigation.width) > 1.5 ||
Math.abs(expandedCatalog.surface.width - baseline.surface.width) > 1.5) {
failKnown("The pointer expand did not restore the original menu/workspace widths.");
}
assertFixedSchedule(baseline, expandedCatalog, "expanded catalog workspace");
await pointerClick(
'document.getElementById("workspace-stock-tab")',
"return to stock workspace");
const restoredStock = await waitForWorkspaceLayout("restored stock workspace", layout =>
workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷");
assertFixedSchedule(baseline, restoredStock, "restored stock workspace");
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.");
}
const focusOnlyStock = await readWorkspaceLayout();
if (!workspaceLayoutMatches(focusOnlyStock, "stock", true)) {
failKnown("ArrowDown changed the workspace before explicit keyboard activation.");
}
assertFixedSchedule(baseline, focusOnlyStock, "keyboard catalog 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");
await pressKey("ArrowUp", "focus stock workspace menu item");
if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) {
failKnown("ArrowUp did not return focus to the stock workspace menu item.");
}
await pressKey("Enter", "activate stock workspace from keyboard");
const keyboardStock = await waitForWorkspaceLayout("keyboard stock workspace", layout =>
workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷");
assertFixedSchedule(baseline, keyboardStock, "keyboard stock activation");
const after = await readSnapshot();
assertSafety(after, "workspace navigation/layout input", false);
if (after.safety.outboundMessages.length !== preflight.safety.outboundMessages.length ||
after.safety.blockedOutboundMessages.length !==
preflight.safety.blockedOutboundMessages.length) {
failKnown("Workspace-only pointer input crossed the native WebView message boundary.");
}
await checkpoint("workspace-navigation-layout", {
widthRatio: Number(baselineRatio.widthRatio.toFixed(4)),
workspaceShare: Number(baselineRatio.workspaceShare.toFixed(4)),
expandedNavigationWidth: baseline.navigation.width,
collapsedNavigationWidth: collapsed.navigation.width,
schedule: baseline.schedule,
pointerTransitions: ["collapse", "catalog", "expand", "stock"],
keyboardTransitions: ["ArrowDown", "Enter catalog", "ArrowUp", "Enter stock"]
});
}
async function playlistDropGeometry(label) { async function playlistDropGeometry(label) {
const geometry = await evaluate(`(() => { const geometry = await evaluate(`(() => {
const zone = document.getElementById("playlist-drop-zone"); const zone = document.getElementById("playlist-drop-zone");
@@ -1230,6 +1458,7 @@ async function pressKey(definitionName, label, modifiers = 0) {
} }
async function setSearchText(text) { async function setSearchText(text) {
await ensureWorkspace("stock", "show stock workspace before stock search");
await pointerClick( await pointerClick(
'document.getElementById("stock-search")', 'document.getElementById("stock-search")',
"focus stock search"); "focus stock search");
@@ -2035,6 +2264,7 @@ async function executeSafeSequence() {
failKnown("A child modal was already open at preflight."); failKnown("A child modal was already open at preflight.");
} }
await checkpoint("preflight"); await checkpoint("preflight");
await verifyWorkspaceNavigationAndLayout(preflight);
await setSearchText(evidence.fixture.query); await setSearchText(evidence.fixture.query);
await pointerClick( await pointerClick(
@@ -2698,6 +2928,7 @@ function manualScreenButtonExpression(screen) {
} }
async function openManualFinancial(screen, label) { async function openManualFinancial(screen, label) {
await ensureWorkspace("stock", `${label} stock workspace`);
await pointerClick(manualScreenButtonExpression(screen), label); await pointerClick(manualScreenButtonExpression(screen), label);
return await waitFor(`${label} open`, snapshot => return await waitFor(`${label} open`, snapshot =>
snapshot.state.isBusy === false && snapshot.state.isBusy === false &&
@@ -2931,6 +3162,7 @@ async function executeManualFinancialCrud() {
} }
async function selectMainTab(tabId, label) { async function selectMainTab(tabId, label) {
await ensureWorkspace("catalog", `${label} catalog workspace`);
const isLoaded = snapshot => const isLoaded = snapshot =>
snapshot.state.isBusy === false && snapshot.state.isBusy === false &&
snapshot.state.tabs?.some(tab => tab.id === tabId && tab.isActive) === true && snapshot.state.tabs?.some(tab => tab.id === tabId && tab.isActive) === true &&

View File

@@ -190,6 +190,47 @@ async function readShell() {
const element = document.getElementById(id); const element = document.getElementById(id);
return element ? { present: true, disabled: element.disabled === true, text: element.textContent.trim() } : { present: false }; return element ? { present: true, disabled: element.disabled === true, text: element.textContent.trim() } : { present: false };
}; };
const rectangle = element => {
if (!element) return null;
const bounds = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
width: bounds.width,
height: bounds.height,
display: style.display,
visibility: style.visibility
};
};
const isVisible = (element, bounds) => Boolean(element && bounds &&
element.hidden !== true && !element.closest("[hidden]") &&
bounds.width > 0 && bounds.height > 0 &&
bounds.right > 0 && bounds.bottom > 0 &&
bounds.left < document.documentElement.clientWidth &&
bounds.top < document.documentElement.clientHeight &&
bounds.display !== "none" && bounds.visibility !== "hidden");
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 stockWorkspace = document.getElementById("stock-workspace");
const catalogWorkspace = document.getElementById("catalog-workspace");
const schedule = document.querySelector(".playlist-panel");
const workspaceBounds = rectangle(workspaceRegion);
const scheduleBounds = rectangle(schedule);
const scheduleCenter = scheduleBounds ? {
x: scheduleBounds.left + scheduleBounds.width / 2,
y: scheduleBounds.top + scheduleBounds.height / 2
} : null;
const scheduleHit = scheduleCenter &&
scheduleCenter.x >= 0 && scheduleCenter.y >= 0 &&
scheduleCenter.x < document.documentElement.clientWidth &&
scheduleCenter.y < document.documentElement.clientHeight
? document.elementFromPoint(scheduleCenter.x, scheduleCenter.y)
: null;
return { return {
url: location.href, url: location.href,
readyState: document.readyState, readyState: document.readyState,
@@ -204,6 +245,28 @@ async function readShell() {
next: button("playout-next"), next: button("playout-next"),
takeOut: button("playout-take-out") takeOut: button("playout-take-out")
}, },
layout: {
activeWorkspace: workspaceRegion?.dataset?.activeWorkspace || null,
navigationExpanded: navigationToggle?.getAttribute("aria-expanded") === "true" &&
workspaceRegion?.classList.contains("nav-collapsed") !== true,
stockTabCurrent: stockTab?.getAttribute("aria-current") || null,
catalogTabCurrent: catalogTab?.getAttribute("aria-current") || null,
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,
workspace: workspaceBounds,
schedule: scheduleBounds,
workspaceToScheduleWidthRatio: workspaceBounds && scheduleBounds &&
scheduleBounds.width > 0
? workspaceBounds.width / scheduleBounds.width
: null,
scheduleVisible: isVisible(schedule, scheduleBounds),
scheduleHitTestVisible: Boolean(scheduleHit &&
(scheduleHit === schedule || schedule?.contains(scheduleHit)))
},
visibleDialogs: Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']")) visibleDialogs: Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']"))
.filter(element => !element.hidden && !element.closest("[hidden]")).map(element => element.id || element.getAttribute("aria-label") || "dialog"), .filter(element => !element.hidden && !element.closest("[hidden]")).map(element => element.id || element.getAttribute("aria-label") || "dialog"),
postMessageGuardInstalled: window.chrome?.webview?.postMessage === probe?.wrapper, postMessageGuardInstalled: window.chrome?.webview?.postMessage === probe?.wrapper,
@@ -220,6 +283,19 @@ function assertShell(shell) {
shell.postMessageGuardInstalled !== true || shell.observedMessagesAfterGuard.length !== 0 || shell.blockedMessages.length !== 0) { shell.postMessageGuardInstalled !== true || shell.observedMessagesAfterGuard.length !== 0 || shell.blockedMessages.length !== 0) {
fail("KNOWN_FAILURE", "The package shell did not remain rendered DryRun/IDLE with zero guarded native intents."); fail("KNOWN_FAILURE", "The package shell did not remain rendered DryRun/IDLE with zero guarded native intents.");
} }
const layout = shell.layout;
if (!layout || layout.activeWorkspace !== "stock" ||
layout.navigationExpanded !== true ||
layout.stockTabCurrent !== "page" || layout.catalogTabCurrent !== "false" ||
layout.stockWorkspaceVisible !== true || layout.catalogWorkspaceHidden !== 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 ||
!Number.isFinite(layout.workspaceToScheduleWidthRatio) ||
layout.workspaceToScheduleWidthRatio < 1.8 ||
layout.workspaceToScheduleWidthRatio > 2.2) {
fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the approximately 2:1 workspace layout.");
}
for (const [name, control] of Object.entries(shell.controls || {})) { for (const [name, control] of Object.entries(shell.controls || {})) {
if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`); if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`);
} }

View File

@@ -8,57 +8,108 @@
</head> </head>
<body> <body>
<main class="operator-shell" aria-label="V-Stock 증권정보송출시스템"> <main class="operator-shell" aria-label="V-Stock 증권정보송출시스템">
<section class="left-panel" aria-label="종목 및 컷 선택"> <section id="workspace-region" class="workspace-region" aria-label="작업 영역">
<header class="brand-line"> <aside id="workspace-navigation" class="workspace-navigation" aria-label="작업 메뉴">
<div class="brand"><span>매일경제TV</span><strong>VRi</strong></div> <div class="workspace-nav-header">
<div class="connection-state" id="playout-connection-state" <button id="workspace-nav-toggle" class="workspace-nav-toggle" type="button"
aria-label="Tornado2 연결 상태">미연결</div> aria-expanded="true" aria-controls="workspace-nav-items" title="메뉴 접기">
</header> <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="작업 화면">
<button id="workspace-stock-tab" class="workspace-nav-item workspace-current"
type="button" aria-label="종목·컷" title="종목·컷"
aria-current="page" aria-controls="stock-workspace">
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
<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>
</button>
<button id="workspace-catalog-tab" class="workspace-nav-item" type="button"
aria-label="기타 그래픽" title="기타 그래픽"
aria-current="false" aria-controls="catalog-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>
</svg>
<span>기타 그래픽</span>
</button>
</nav>
</aside>
<div class="search-line"> <section class="workspace-surface" aria-labelledby="workspace-title">
<label for="stock-search">종목</label> <header class="workspace-header">
<input id="stock-search" maxlength="32767" autocomplete="off" spellcheck="false"> <div class="brand"><span>매일경제TV</span><strong>VRi</strong></div>
<button id="stock-search-button" type="button"><span aria-hidden="true"></span> 검색</button> <div class="workspace-heading">
</div> <small>작업 영역</small>
<h1 id="workspace-title">종목·컷</h1>
</div>
<div class="connection-state" id="playout-connection-state"
aria-label="Tornado2 연결 상태">미연결</div>
</header>
<div id="stock-results" class="stock-results" role="listbox" aria-label="종목명"></div> <div class="workspace-content">
<section id="stock-workspace" class="left-panel workspace-view"
aria-label="종목 및 컷 선택">
<div class="search-line">
<label for="stock-search">종목</label>
<input id="stock-search" maxlength="32767" autocomplete="off" spellcheck="false">
<button id="stock-search-button" type="button"><span aria-hidden="true"></span> 검색</button>
</div>
<div class="manual-actions" aria-label="수동 입력"> <div id="stock-results" class="stock-results" role="listbox" aria-label="종목명"></div>
<button type="button" data-manual-screen="revenue-composition">주요매출 구성</button>
<button type="button" data-manual-screen="growth-metrics">성장성 지표</button>
<button type="button" data-manual-screen="sales">매출액</button>
<button type="button" data-manual-screen="operating-profit">영업이익</button>
</div>
<div class="manual-list-actions" aria-label="수동 송출 목록" hidden>
<button type="button" data-manual-list-screen="individual">개인 순매도</button>
<button type="button" data-manual-list-screen="foreign">외국인 순매도</button>
<button type="button" data-manual-list-screen="institution">기관 순매도</button>
<button type="button" data-manual-list-screen="vi">VI 발동</button>
</div>
<div id="cut-list" class="cut-list" role="listbox" aria-label="종목 컷" aria-multiselectable="true"></div> <div class="manual-actions" aria-label="수동 입력">
<div id="operator-status" class="operator-status" aria-live="polite"></div> <button type="button" data-manual-screen="revenue-composition">주요매출 구성</button>
</section> <button type="button" data-manual-screen="growth-metrics">성장성 지표</button>
<button type="button" data-manual-screen="sales">매출액</button>
<button type="button" data-manual-screen="operating-profit">영업이익</button>
</div>
<div class="manual-list-actions" aria-label="수동 송출 목록" hidden>
<button type="button" data-manual-list-screen="individual">개인 순매도</button>
<button type="button" data-manual-list-screen="foreign">외국인 순매도</button>
<button type="button" data-manual-list-screen="institution">기관 순매도</button>
<button type="button" data-manual-list-screen="vi">VI 발동</button>
</div>
<section class="catalog-panel" aria-label="분류별 그래픽"> <div id="cut-list" class="cut-list" role="listbox" aria-label="종목 컷"
<nav id="market-tabs" class="market-tabs" aria-label="시장 분류"> aria-multiselectable="true"></div>
<button class="active" type="button" draggable="true" data-tab-id="overseas">해외</button> <div id="operator-status" class="operator-status" aria-live="polite"></div>
<button type="button" draggable="true" data-tab-id="exchange">환율</button> </section>
<button type="button" draggable="true" data-tab-id="index">지수</button>
<button type="button" draggable="true" data-tab-id="kospiIndustry">코스피</button> <section id="catalog-workspace" class="catalog-panel workspace-view"
<button type="button" draggable="true" data-tab-id="kosdaqIndustry">코스닥</button> aria-label="분류별 그래픽" hidden inert>
<button type="button" draggable="true" data-tab-id="comparison">비교</button> <nav id="market-tabs" class="market-tabs" aria-label="시장 분류">
<button type="button" draggable="true" data-tab-id="theme">테마</button> <button class="active" type="button" draggable="true" data-tab-id="overseas">해외</button>
<button type="button" draggable="true" data-tab-id="overseasStocks">해외종목</button> <button type="button" draggable="true" data-tab-id="exchange">환율</button>
<button type="button" draggable="true" data-tab-id="expert">전문가</button> <button type="button" draggable="true" data-tab-id="index">지수</button>
<button type="button" draggable="true" data-tab-id="tradingHalt">정지</button> <button type="button" draggable="true" data-tab-id="kospiIndustry">코스피</button>
</nav> <button type="button" draggable="true" data-tab-id="kosdaqIndustry">코스닥</button>
<label class="expand-line"><input id="catalog-expand-all" type="checkbox" checked> Expand</label> <button type="button" draggable="true" data-tab-id="comparison">비교</button>
<div class="category-title">Category</div> <button type="button" draggable="true" data-tab-id="theme">테마</button>
<div id="category-tree" class="category-tree" aria-label="원본 카테고리"></div> <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>
</div>
</section>
</section> </section>
<section class="playlist-panel" aria-label="플레이리스트"> <section class="playlist-panel" aria-label="플레이리스트">
<header class="playlist-heading">
<div class="playlist-heading-icon" aria-hidden="true"></div>
<div><small>항상 표시</small><h2>송출 스케줄</h2></div>
</header>
<div class="playlist-toolbar"> <div class="playlist-toolbar">
<label><input id="playlist-enable-all" type="checkbox" checked disabled> 전체</label> <label><input id="playlist-enable-all" type="checkbox" checked disabled> 전체</label>
<label><input id="moving-average-5" type="checkbox" checked> 5일선</label> <label><input id="moving-average-5" type="checkbox" checked> 5일선</label>
@@ -265,6 +316,7 @@
<script src="legacy-cut-drag.js"></script> <script src="legacy-cut-drag.js"></script>
<script src="legacy-modal-focus.js"></script> <script src="legacy-modal-focus.js"></script>
<script src="legacy-named-playlist-selection.js"></script> <script src="legacy-named-playlist-selection.js"></script>
<script src="workspace-navigation.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
<script src="playout-ui.js"></script> <script src="playout-ui.js"></script>
</body> </body>

View File

@@ -2,6 +2,11 @@
color-scheme: light; color-scheme: light;
font-family: "NanumGothic", "맑은 고딕", sans-serif; font-family: "NanumGothic", "맑은 고딕", sans-serif;
font-size: 13px; font-size: 13px;
--workspace-nav-expanded: 210px;
--workspace-nav-collapsed: 62px;
--workspace-accent: #0f6cbd;
--workspace-border: #d2d2d2;
--workspace-nav-surface: #f2f2f2;
background: #efefef; background: #efefef;
color: #111; color: #111;
} }
@@ -11,38 +16,154 @@
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body.busy { cursor: wait; } body.busy { cursor: wait; }
body.busy .operator-shell { pointer-events: none; } body.busy .operator-shell { pointer-events: none; }
button, input { font: inherit; } button, input, select { font: inherit; }
button { border: 1px solid #a8a8a8; background: linear-gradient(#fff, #e9e9e9); color: #111; } button { border: 1px solid #a8a8a8; background: linear-gradient(#fff, #e9e9e9); color: #111; }
button:not(:disabled) { cursor: pointer; } button:not(:disabled) { cursor: pointer; }
button:disabled { color: #555; opacity: 1; } button:disabled { color: #555; opacity: 1; }
.operator-shell { .operator-shell {
display: grid; display: grid;
grid-template-columns: 430px 500px minmax(760px, 1fr); grid-template-columns: minmax(900px, 2fr) minmax(600px, 1fr);
width: 100%; width: 100%;
min-width: 1690px; min-width: 1500px;
height: 100vh; height: 100vh;
background: #efefef; background: #efefef;
} }
.left-panel, .catalog-panel, .playlist-panel { min-height: 0; border-right: 1px solid #bbb; } .workspace-region {
.left-panel { display: flex; flex-direction: column; padding: 6px 15px 4px 20px; background: #f5f5f5; } --workspace-nav-width: var(--workspace-nav-expanded);
display: grid;
grid-template-columns: var(--workspace-nav-width) minmax(0, 1fr);
min-width: 0;
min-height: 0;
border-right: 1px solid var(--workspace-border);
background: #f7f7f7;
}
.workspace-region.nav-collapsed { --workspace-nav-width: var(--workspace-nav-collapsed); }
.workspace-navigation {
display: grid;
grid-template-rows: 66px minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--workspace-border);
background: var(--workspace-nav-surface);
}
.workspace-nav-header { display: flex; align-items: center; padding: 10px; }
.workspace-nav-toggle {
display: flex;
width: 100%;
min-width: 42px;
height: 42px;
align-items: center;
gap: 13px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: left;
white-space: nowrap;
}
.workspace-nav-toggle:hover { background: #e5e5e5; }
.workspace-nav-toggle:focus-visible {
outline: 2px solid var(--workspace-accent);
outline-offset: -2px;
background: #e5e5e5;
}
.workspace-nav-toggle svg, .workspace-nav-item svg {
flex: 0 0 22px;
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.workspace-nav-toggle-label { overflow: hidden; text-overflow: ellipsis; }
.workspace-nav-items {
display: flex;
min-height: 0;
flex-direction: column;
gap: 4px;
overflow: auto;
padding: 4px 8px 12px;
}
.workspace-nav-item {
position: relative;
display: flex;
width: 100%;
min-width: 44px;
height: 46px;
flex: 0 0 46px;
align-items: center;
gap: 13px;
padding: 0 12px;
overflow: hidden;
border: 0;
border-radius: 6px;
background: transparent;
text-align: left;
white-space: nowrap;
}
.workspace-nav-item:hover { background: #e7e7e7; }
.workspace-nav-item:focus-visible {
outline: 2px solid var(--workspace-accent);
outline-offset: -2px;
background: #e7e7e7;
}
.workspace-nav-item.workspace-current { background: #dedede; font-weight: 700; }
.workspace-nav-item.workspace-current::before {
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 2px;
background: var(--workspace-accent);
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-item { justify-content: center; padding: 0; }
.brand-line { height: 68px; display: flex; align-items: center; justify-content: space-between; } .workspace-surface {
display: grid;
grid-template-rows: 66px minmax(0, 1fr);
min-width: 0;
min-height: 0;
background: #f7f7f7;
}
.workspace-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 24px;
padding: 8px 20px;
border-bottom: 1px solid var(--workspace-border);
background: #fff;
}
.brand { display: flex; align-items: center; gap: 12px; white-space: nowrap; } .brand { display: flex; align-items: center; gap: 12px; white-space: nowrap; }
.brand span { color: #ed671f; font-size: 28px; font-weight: 800; letter-spacing: -2px; } .brand span { color: #ed671f; font-size: 20px; font-weight: 800; letter-spacing: -1px; }
.brand strong { font: italic 900 38px Arial, sans-serif; letter-spacing: -5px; } .brand strong { font: italic 900 29px Arial, sans-serif; letter-spacing: -4px; }
.connection-state { padding: 10px 12px; background: #c40000; color: white; font: 700 18px Consolas, monospace; } .workspace-heading { min-width: 0; }
.workspace-heading small { display: block; color: #666; font-size: 11px; }
.workspace-heading h1 { margin: 1px 0 0; overflow: hidden; font-size: 21px; text-overflow: ellipsis; white-space: nowrap; }
.connection-state { padding: 8px 10px; border-radius: 4px; background: #c40000; color: white; font: 700 14px Consolas, monospace; }
.connection-state.connected { background: #176c32; } .connection-state.connected { background: #176c32; }
.connection-state.outcome-unknown { background: #8a4d00; } .connection-state.outcome-unknown { background: #8a4d00; }
.workspace-content { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
.workspace-view { width: 100%; height: 100%; min-width: 0; min-height: 0; }
.left-panel, .catalog-panel, .playlist-panel { min-height: 0; }
.left-panel { display: flex; flex-direction: column; padding: 16px 20px 12px; background: #f7f7f7; }
.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; } .search-line { display: grid; grid-template-columns: 44px minmax(220px, 460px) 112px; align-items: center; justify-content: start; gap: 7px; min-height: 42px; }
.search-line label { font-weight: 700; } .search-line label { font-weight: 700; }
.search-line input { grid-column: 2; height: 27px; padding: 2px 7px; border: 1px solid #888; font-size: 16px; } .search-line input { grid-column: 2; height: 31px; padding: 2px 8px; border: 1px solid #888; font-size: 16px; }
.search-line button { grid-column: 4; height: 31px; font-size: 14px; } .search-line button { grid-column: 3; height: 31px; font-size: 14px; }
.search-line button span { font-size: 21px; margin-right: 20px; } .search-line button span { font-size: 19px; margin-right: 12px; }
.stock-results { height: 174px; border: 1px solid #999; background: #fff; overflow-y: auto; } .stock-results { height: 190px; border: 1px solid #999; background: #fff; overflow-y: auto; }
.stock-result { display: block; width: 100%; height: 17px; padding: 0 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 17px; } .stock-result { display: block; width: 100%; height: 17px; padding: 0 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 17px; }
.stock-result.selected { background: #696969; color: #fff; } .stock-result.selected { background: #696969; color: #fff; }
@@ -153,15 +274,15 @@ button:disabled { color: #555; opacity: 1; }
.cut-drag-feedback.allowed .cut-drag-feedback-icon::before { content: "+"; } .cut-drag-feedback.allowed .cut-drag-feedback-icon::before { content: "+"; }
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; } .operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; } .catalog-panel { display: flex; flex-direction: column; padding: 14px 16px 12px; background: #f7f7f7; }
.market-tabs { display: flex; height: 29px; border-bottom: 1px solid #999; } .market-tabs { display: flex; min-height: 34px; border-bottom: 1px solid #999; }
.market-tabs button { min-width: 48px; padding: 3px 8px; border-width: 0 1px 0 0; background: #efefef; } .market-tabs button { min-width: 54px; padding: 4px 10px; border-width: 0 1px 0 0; background: #efefef; }
.market-tabs button.active { background: #fff; border-top: 2px solid #e8781d; } .market-tabs button.active { background: #fff; border-top: 2px solid var(--workspace-accent); font-weight: 700; }
.market-tabs button[draggable="true"] { cursor: grab; } .market-tabs button[draggable="true"] { cursor: grab; }
.market-tabs button.dragging { opacity: .58; cursor: grabbing; } .market-tabs button.dragging { opacity: .58; cursor: grabbing; }
.expand-line { height: 31px; padding: 7px 8px 0; } .expand-line { height: 34px; padding: 8px 8px 0; }
.category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; } .category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; }
.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; } .category-tree { flex: 1; overflow: auto; padding: 10px 14px; background: #fff; border: 1px solid #aaa; border-top: 0; }
.tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; } .tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; }
.tree-root.second { margin-top: 15px; } .tree-root.second { margin-top: 15px; }
.catalog-section > summary { margin: 5px 0 2px; color: #168000; font-size: 16px; cursor: default; user-select: none; } .catalog-section > summary { margin: 5px 0 2px; color: #168000; font-size: 16px; cursor: default; user-select: none; }
@@ -317,16 +438,53 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; }
.operator-catalog-editor-actions { display: grid; grid-template-columns: auto auto 1fr auto auto; gap: 7px; padding: 2px 9px 0; } .operator-catalog-editor-actions { display: grid; grid-template-columns: auto auto 1fr auto auto; gap: 7px; padding: 2px 9px 0; }
.operator-catalog-editor-actions button:last-child { min-width: 92px; color: #fff; background: #176b2d; font-weight: 700; } .operator-catalog-editor-actions button:last-child { min-width: 92px; color: #fff; background: #176b2d; font-weight: 700; }
.playlist-panel { display: grid; grid-template-rows: 58px minmax(0, 1fr) 76px 42px; padding: 0 8px; background: #efefef; } .playlist-panel {
.playlist-toolbar { display: flex; align-items: center; gap: 5px; white-space: nowrap; } display: grid;
.playlist-toolbar button { height: 25px; padding: 2px 7px; } grid-template-rows: 66px minmax(84px, auto) minmax(0, 1fr) 76px 42px;
.playlist-toolbar .toolbar-spacer { flex: 1; } min-width: 0;
.background-name { width: 82px; height: 25px; border: 1px solid #888; background: #fff; } padding: 0 8px;
border-left: 1px solid var(--workspace-border);
background: #efefef;
}
.playlist-heading {
display: flex;
align-items: center;
gap: 11px;
margin: 0 -8px;
padding: 8px 14px;
border-bottom: 1px solid var(--workspace-border);
background: #fff;
}
.playlist-heading-icon {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 7px;
background: #e6f1fb;
color: var(--workspace-accent);
font-size: 22px;
}
.playlist-heading small { display: block; color: #666; font-size: 11px; }
.playlist-heading h2 { margin: 1px 0 0; font-size: 19px; }
.playlist-toolbar {
display: flex;
align-content: center;
align-items: center;
gap: 5px;
overflow: auto;
flex-wrap: wrap;
padding: 6px 0;
}
.playlist-toolbar label { white-space: nowrap; }
.playlist-toolbar button { height: 25px; padding: 2px 6px; white-space: nowrap; }
.playlist-toolbar .toolbar-spacer { display: none; }
.background-name { width: 76px; height: 25px; border: 1px solid #888; background: #fff; }
.fade-control { margin-left: 2px; } .fade-control { margin-left: 2px; }
#playout-fade-duration { width: 48px; height: 25px; } #playout-fade-duration { width: 48px; height: 25px; }
.playlist-grid { min-height: 0; border: 1px solid #888; background: #d5d5d5; overflow: auto; } .playlist-grid { min-height: 0; border: 1px solid #888; background: #d5d5d5; overflow: auto; }
.playlist-row { display: grid; grid-template-columns: 30px 40px 100px 260px 200px minmax(255px, 1fr) 52px; min-width: 937px; } .playlist-row { display: grid; grid-template-columns: 30px 40px 100px minmax(115px, 1.1fr) minmax(95px, .8fr) minmax(138px, 1.3fr) 52px; min-width: 600px; }
.playlist-header { position: sticky; top: 0; z-index: 2; height: 29px; background: #efefef; border-bottom: 1px solid #999; } .playlist-header { position: sticky; top: 0; z-index: 2; height: 29px; background: #efefef; border-bottom: 1px solid #999; }
.playlist-header > div { display: flex; align-items: center; justify-content: center; border-right: 1px solid #aaa; } .playlist-header > div { display: flex; align-items: center; justify-content: center; border-right: 1px solid #aaa; }
.playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; } .playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; }
@@ -343,12 +501,16 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; }
.playlist-data-row.drag-before { box-shadow: inset 0 3px #176b2d; } .playlist-data-row.drag-before { box-shadow: inset 0 3px #176b2d; }
.playlist-data-row.drag-after { box-shadow: inset 0 -3px #176b2d; } .playlist-data-row.drag-after { box-shadow: inset 0 -3px #176b2d; }
.playout-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: 12px; } .playout-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: 7px; }
.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; } .playout-actions button { height: 51px; padding: 2px 4px; background: #fff; font-size: 20px; font-weight: 700; white-space: nowrap; }
.playout-actions button:disabled { color: #888; background: #f7f7f7; } .playout-actions button:disabled { color: #888; background: #f7f7f7; }
.playout-actions .prepare { font-size: 21px; } .playout-actions .prepare { font-size: 21px; }
.playout-actions .prepare.active { color: #fff; background: #1a7700; } .playout-actions .prepare.active { color: #fff; background: #1a7700; }
.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; } .playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; overflow: hidden; color: #666; text-overflow: ellipsis; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.workspace-region { scroll-behavior: auto; }
}
.dialog-backdrop { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .25); } .dialog-backdrop { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .25); }
.dialog-backdrop[hidden] { display: none; } .dialog-backdrop[hidden] { display: none; }

View File

@@ -0,0 +1,107 @@
(function () {
"use strict";
const region = document.getElementById("workspace-region");
const navigation = document.getElementById("workspace-navigation");
const navigationItems = document.getElementById("workspace-nav-items");
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 stockWorkspace = document.getElementById("stock-workspace");
const catalogWorkspace = document.getElementById("catalog-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) {
return;
}
const views = Object.freeze({
stock: Object.freeze({ tab: stockTab, view: stockWorkspace, title: "종목·컷" }),
catalog: Object.freeze({ tab: catalogTab, view: catalogWorkspace, title: "기타 그래픽" })
});
function setNavigationExpanded(expanded) {
const isExpanded = expanded === true;
region.classList.toggle("nav-collapsed", !isExpanded);
toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
toggle.setAttribute("aria-label", isExpanded ? "작업 메뉴 접기" : "작업 메뉴 펼치기");
toggle.title = isExpanded ? "메뉴 접기" : "메뉴 펼치기";
toggleLabel.textContent = isExpanded ? "메뉴 접기" : "메뉴 펼치기";
}
function setWorkspace(name) {
const selected = views[name];
if (!selected) return false;
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();
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;
return true;
}
function focusAdjacentItem(direction) {
const items = [stockTab, catalogTab].filter(function (button) {
return button.disabled !== true;
});
const currentIndex = items.indexOf(document.activeElement);
const nextIndex = currentIndex < 0
? 0
: (currentIndex + direction + items.length) % items.length;
items[nextIndex].focus();
}
toggle.addEventListener("click", function () {
setNavigationExpanded(toggle.getAttribute("aria-expanded") !== "true");
});
stockTab.addEventListener("click", function () { setWorkspace("stock"); });
catalogTab.addEventListener("click", function () { setWorkspace("catalog"); });
navigationItems.addEventListener("keydown", function (event) {
const menuButton = event.target.closest && event.target.closest(".workspace-nav-item");
if ((event.key === "Enter" || event.key === " ") && menuButton) {
event.preventDefault();
event.stopPropagation();
setWorkspace(menuButton === stockTab ? "stock" : "catalog");
return;
}
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
event.stopPropagation();
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.
marketTabs.addEventListener("click", function (event) {
if (event.target.closest("button[data-tab-id]")) setWorkspace("catalog");
}, true);
setNavigationExpanded(true);
setWorkspace("stock");
window.LegacyWorkspaceNavigation = Object.freeze({
select: setWorkspace,
setExpanded: setNavigationExpanded,
active: function () { return region.dataset.activeWorkspace; },
expanded: function () { return toggle.getAttribute("aria-expanded") === "true"; }
});
}());

View File

@@ -0,0 +1,72 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const webRoot = path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web");
const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
const navigation = fs.readFileSync(path.join(webRoot, "workspace-navigation.js"), "utf8");
test("operator shell keeps one switchable workspace beside an always-present schedule", () => {
for (const id of [
"workspace-region",
"workspace-navigation",
"workspace-nav-toggle",
"workspace-stock-tab",
"workspace-catalog-tab",
"workspace-title",
"stock-workspace",
"catalog-workspace",
"playlist-drop-zone"
]) {
assert.match(markup, new RegExp(`id="${id}"`));
}
const workspaceStart = markup.indexOf('id="workspace-region"');
const stockStart = markup.indexOf('id="stock-workspace"');
const catalogStart = markup.indexOf('id="catalog-workspace"');
const scheduleStart = markup.indexOf('class="playlist-panel"');
assert.ok(workspaceStart >= 0 && stockStart > workspaceStart);
assert.ok(catalogStart > stockStart);
assert.ok(scheduleStart > catalogStart);
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, /<small>항상 표시<\/small><h2>송출 스케줄<\/h2>/);
});
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\)/);
assert.match(styles,
/\.workspace-region\s*\{[^}]*grid-template-columns:\s*var\(--workspace-nav-width\) minmax\(0, 1fr\)/);
assert.match(styles, /\.workspace-region\.nav-collapsed/);
assert.match(styles, /--workspace-nav-collapsed:\s*62px/);
assert.match(styles,
/\.workspace-nav-item:focus-visible\s*\{[\s\S]*?outline:\s*2px solid var\(--workspace-accent\)/);
assert.match(styles, /\.playlist-toolbar\s*\{[^}]*flex-wrap:\s*wrap/);
assert.match(styles, /\.playlist-row\s*\{[^}]*min-width:\s*600px/);
});
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, /region\.classList\.toggle\("nav-collapsed", !isExpanded\)/);
assert.match(navigation, /event\.key !== "ArrowUp" && event\.key !== "ArrowDown"/);
assert.match(navigation, /event\.key === "Enter" \|\| event\.key === " "/);
assert.match(navigation, /setWorkspace\(menuButton === stockTab \? "stock" : "catalog"\)/);
assert.match(navigation, /marketTabs\.addEventListener\("click"/);
assert.doesNotMatch(navigation, /postMessage|localStorage|sessionStorage|send\s*\(/);
});
test("workspace navigation loads before the native-authority application bridge", () => {
const navigationScript = markup.indexOf('<script src="workspace-navigation.js"></script>');
const applicationScript = markup.indexOf('<script src="app.js"></script>');
assert.ok(navigationScript >= 0 && applicationScript > navigationScript);
});

View File

@@ -0,0 +1,86 @@
using System.Text.RegularExpressions;
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyWorkspaceLayoutContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string WebRoot = Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"Web");
private static readonly string Markup = File.ReadAllText(Path.Combine(WebRoot, "index.html"));
private static readonly string Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css"));
private static readonly string Navigation = File.ReadAllText(
Path.Combine(WebRoot, "workspace-navigation.js"));
[Fact]
public void ShellUsesSwitchableWorkspaceBesidePersistentSchedule()
{
var workspaceStart = Markup.IndexOf("id=\"workspace-region\"", StringComparison.Ordinal);
var stockStart = Markup.IndexOf("id=\"stock-workspace\"", StringComparison.Ordinal);
var catalogStart = Markup.IndexOf("id=\"catalog-workspace\"", StringComparison.Ordinal);
var scheduleStart = Markup.IndexOf("class=\"playlist-panel\"", StringComparison.Ordinal);
Assert.True(workspaceStart >= 0);
Assert.True(stockStart > workspaceStart);
Assert.True(catalogStart > stockStart);
Assert.True(scheduleStart > catalogStart);
Assert.Matches(
@"<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*<section class=""playlist-panel""",
Markup);
Assert.Matches("id=\"catalog-workspace\"[^>]*hidden inert", Markup);
Assert.Contains("<small>항상 표시</small><h2>송출 스케줄</h2>", Markup,
StringComparison.Ordinal);
}
[Fact]
public void WorkAndScheduleUseResponsiveTwoToOneColumns()
{
Assert.Contains(
"grid-template-columns: minmax(900px, 2fr) minmax(600px, 1fr);",
Styles,
StringComparison.Ordinal);
Assert.Matches(
new Regex(
@"\.playlist-toolbar\s*\{[^}]*flex-wrap:\s*wrap",
RegexOptions.CultureInvariant),
Styles);
Assert.Matches(
new Regex(
@"\.playlist-row\s*\{[^}]*min-width:\s*600px",
RegexOptions.CultureInvariant),
Styles);
}
[Fact]
public void NavigationChangesPresentationWithoutNativeOrStoredState()
{
Assert.Contains("entry.view.hidden = !isCurrent", Navigation, StringComparison.Ordinal);
Assert.Contains(
"entry.view.toggleAttribute(\"inert\", !isCurrent)",
Navigation,
StringComparison.Ordinal);
Assert.Contains("nav-collapsed", Navigation, StringComparison.Ordinal);
Assert.DoesNotContain("postMessage", Navigation, StringComparison.Ordinal);
Assert.DoesNotContain("localStorage", Navigation, StringComparison.Ordinal);
Assert.DoesNotContain("sessionStorage", Navigation, StringComparison.Ordinal);
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Repository root could not be located.");
}
}