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;
}
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) {
const geometry = await evaluate(`(() => {
const zone = document.getElementById("playlist-drop-zone");
@@ -1230,6 +1458,7 @@ async function pressKey(definitionName, label, modifiers = 0) {
}
async function setSearchText(text) {
await ensureWorkspace("stock", "show stock workspace before stock search");
await pointerClick(
'document.getElementById("stock-search")',
"focus stock search");
@@ -2035,6 +2264,7 @@ async function executeSafeSequence() {
failKnown("A child modal was already open at preflight.");
}
await checkpoint("preflight");
await verifyWorkspaceNavigationAndLayout(preflight);
await setSearchText(evidence.fixture.query);
await pointerClick(
@@ -2698,6 +2928,7 @@ function manualScreenButtonExpression(screen) {
}
async function openManualFinancial(screen, label) {
await ensureWorkspace("stock", `${label} stock workspace`);
await pointerClick(manualScreenButtonExpression(screen), label);
return await waitFor(`${label} open`, snapshot =>
snapshot.state.isBusy === false &&
@@ -2931,6 +3162,7 @@ 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 &&