feat: add configurable operator appearance and layout

This commit is contained in:
2026-07-23 11:14:10 +09:00
parent cf117de144
commit f43483533a
34 changed files with 5942 additions and 740 deletions

View File

@@ -0,0 +1,108 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const root = path.resolve(__dirname, "..", "..");
const harness = fs.readFileSync(path.join(
root, "scripts", "Test-LegacyPackageAppearanceSmoke.mjs"), "utf8");
const wrapper = fs.readFileSync(path.join(
root, "scripts", "Invoke-LegacyPackageAppearanceSmoke.ps1"), "utf8");
test("appearance smoke uses two exact launches and six physical select changes", () => {
assert.match(wrapper, /-BuildFlavor DebugAppX/u);
assert.match(wrapper, /-DefinitionsOnly/u);
assert.match(wrapper, /Start-ExactAppearanceApplication \$registration 1/u);
assert.match(wrapper, /Start-ExactAppearanceApplication \$registration 2/u);
assert.equal((wrapper.match(/Close-ExactAppearanceApplication \$/gu) || []).length, 2);
assert.match(wrapper, /physicalSelectCount = 6/u);
assert.match(harness, /nextExchangeSequence !== 5/u);
assert.match(harness, /evidence\.exchanges\.length !== 4/u);
assert.match(harness, /for \(const appearanceKey of \["colorTheme", "viewMode", "startWorkspace"\]\)/u);
assert.match(harness, /trustedChange: true/u);
assert.match(harness, /physicalKeyCounts:/u);
assert.match(harness, /home: ack\.homeKeyCalls/u);
assert.match(harness, /arrowDown: ack\.arrowDownKeyCalls/u);
assert.match(harness, /enter: ack\.enterKeyCalls/u);
assert.match(harness, /A native HTML <select> popup consumes Home\/ArrowDown\/Enter/u);
assert.doesNotMatch(harness, /trustedKeyDowns\.length >= expectedKeyCalls/u);
assert.doesNotMatch(wrapper, /trustedKeyDownCount -lt/u);
assert.match(wrapper, /selection\.physicalKeyCounts\.home -ne 1/u);
assert.match(wrapper, /selection\.physicalKeyCounts\.arrowDown -ne \$expectedDownCount/u);
assert.match(wrapper, /selection\.physicalKeyCounts\.enter -ne 1/u);
assert.match(wrapper, /exchange\.physicalInput\.change\.isTrusted -ne \$true/u);
assert.match(wrapper, /exchange\.physicalInput\.change\.value -cne/u);
assert.match(wrapper, /exchange\.physicalKeyCounts\.arrowDown -ne/u);
assert.match(harness, /event\.isTrusted === true/u);
assert.match(wrapper, /SendApplicationClick\(/u);
assert.match(wrapper, /SendClosedSelectKeys\(/u);
assert.match(wrapper, /\[FieldOffset\(0\)\] public MouseInput Mouse/u);
assert.match(wrapper, /\[FieldOffset\(0\)\] public KeyboardInput Keyboard/u);
assert.match(wrapper, /\[FieldOffset\(0\)\] public HardwareInput Hardware/u);
assert.match(wrapper, /IntPtr\.Size == 8 \? 40 : 28/u);
assert.match(wrapper, /inputSize != ExpectedInputStructureBytes\(\)/u);
assert.match(wrapper, /Assert-NoTornadoConnection/u);
assert.match(wrapper, /inputRetryCount = 0/u);
});
test("appearance actions cannot use synthetic CDP input or bridge mutation", () => {
for (const method of [
"Input.dispatchMouseEvent",
"Input.dispatchKeyEvent",
"Input.insertText"
]) {
assert.ok(harness.includes(`"${method}"`));
assert.ok(!harness.includes(`rpc("${method}"`));
}
assert.doesNotMatch(harness,
/postMessage\s*\(\s*\{\s*type:\s*["']set-operator-appearance/iu);
assert.doesNotMatch(harness,
/(?:\.value\s*=(?!=)|dispatchEvent\s*\(|\.click\s*\(|localStorage\.setItem)/u);
assert.match(harness,
/window\.chrome\.webview\.postMessage\(\{ type: "ready", payload: \{\} \}\)/u);
assert.match(harness, /outbound\.length !== 1/u);
assert.match(harness, /outbound\[0\]\.type !== "set-operator-appearance"/u);
});
test("relaunch verifies persisted appearance and the saved start-workspace behavior", () => {
assert.match(harness, /configuration\.phase === "verify-restore"/u);
assert.match(harness, /persisted-appearance-after-relaunch/u);
assert.match(harness, /persisted start workspace behavior/u);
assert.match(harness,
/configuration\.expected\.startWorkspace === "lastWorkspace"\s*\? "settings"\s*:\s*"stock"/u);
assert.match(harness, /snapshot\.dom\.activeWorkspace === expectedStartupWorkspace/u);
assert.match(harness, /startWorkspaceBehaviorVerifiedBeforeInput = true/u);
assert.match(wrapper, /startWorkspaceBehaviorVerifiedBeforeInput -ne \$true/u);
assert.match(wrapper, /persistenceVerifiedBeforeInput/u);
assert.match(wrapper, /logicalAppearanceRestored = \$true/u);
});
test("original operator settings are recoverable and restored byte-for-byte", () => {
assert.match(wrapper, /Initialize-ExactBaselineRecovery/u);
assert.match(wrapper, /operatorSettingsOriginallyAbsent/u);
assert.match(wrapper, /Restore-ExactBaselineSettings/u);
assert.match(wrapper, /BaselineRestoreAttempted/u);
assert.match(wrapper, /restoration is one-shot and was already attempted/u);
assert.match(wrapper, /Get-LegacyApplicationProcesses\)\.Count -ne 0/u);
assert.match(wrapper, /\[IO\.File\]::Replace\(/u);
assert.match(wrapper, /\[IO\.File\]::Delete\(\$OperatorSettingsPath\)/u);
assert.match(wrapper, /exactOriginalBytesRestored = \$true/u);
assert.match(wrapper, /recoveryCopyRemoved/u);
assert.match(wrapper, /The application was intentionally left open/u);
assert.match(wrapper, /no input retry, close retry,/u);
assert.match(wrapper, /one-shot settings restoration had an unclear or failed result/u);
assert.match(wrapper, /retried\. Preserve the recovery evidence/u);
assert.doesNotMatch(wrapper, /(?:Stop-Process|taskkill)/iu);
assert.doesNotMatch(wrapper, /C:\\Users\\[^\\]+/iu);
});
test("appearance evidence is bounded, sealed, and never overwritten", () => {
assert.match(harness, /fs\.writeFileSync\(requestPath, requestBytes, \{ flag: "wx" \}\)/u);
assert.match(harness, /fs\.writeFileSync\(configuration\.screenshotPath, bytes, \{ flag: "wx" \}\)/u);
assert.match(harness, /fs\.writeFileSync\(configuration\.outputPath, bytes, \{ flag: "wx" \}\)/u);
assert.match(wrapper, /\$item\.Length -gt 512KB/u);
assert.match(wrapper, /phaseEvidenceSha256/u);
assert.match(wrapper, /screenshotSha256/u);
assert.match(wrapper, /\[IO\.FileMode\]::CreateNew/u);
assert.match(wrapper, /\$stream\.Flush\(\$true\)/u);
});

View File

@@ -63,14 +63,25 @@ test("three app-owned folder pickers are clicked once and Escape-cancelled", ()
test("comparison import preflight and idempotence are exact", () => {
for (const hash of [
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2",
"13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087"
"13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087",
"2575C2420800670B0022EBEE819BA3D9640DB14E9552426DF46075D079F1F697"
]) {
assert.ok(harness.includes(hash));
assert.ok(wrapper.includes(hash));
}
assert.match(harness, /receipt\?\.addedPairCount === 8/u);
assert.match(harness, /baselineMode === "fresh" \? 8 : 0/u);
assert.match(harness, /baselineMode === "fresh" \? 0 : 8/u);
assert.match(harness,
/waitFor\(`comparison \$\{baselineMode\} baseline`[\s\S]*?\{ allowBusy: true \}\);/u);
assert.match(harness, /receipt\?\.addedPairCount === expectedFirstAdded/u);
assert.match(harness, /receipt\?\.skippedDuplicateCount === expectedFirstSkipped/u);
assert.match(harness, /receipt\?\.addedPairCount === 0/u);
assert.match(harness, /receipt\?\.skippedDuplicateCount === 8/u);
assert.match(harness,
/initialDestinationMode = isFresh \? "fresh" : "alreadyImported"/u);
assert.match(wrapper, /\$script:DestinationBaselineMode = if \(\$isFresh\)/u);
assert.match(wrapper,
/Test-JsonEquivalent \$files\.destination \$script:InitialDestinationState/u);
assert.equal((harness.match(
/allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000/g) || []).length, 2);
assert.match(harness, /secondImportIdempotent = true/u);

View File

@@ -88,22 +88,101 @@ test("dry-run mode reads as information and clipped status keeps its full toolti
assert.match(playout, /status\.title = status\.textContent/);
});
test("workspace header uses a modern semantic lockup without changing native status text", () => {
test("appearance themes expose explicit light, dark, and Windows-following palettes", () => {
assert.match(styles,
/:root\[data-color-theme="light"\],[\s\S]*?body\[data-color-theme="light"\]\s*\{\s*color-scheme:\s*light/);
assert.match(styles,
/:root\[data-color-theme="dark"\],[\s\S]*?body\[data-color-theme="dark"\]\s*\{[\s\S]*?color-scheme:\s*dark/);
assert.match(styles,
/@media \(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?:root\[data-color-theme="system"\],[\s\S]*?body\[data-color-theme="system"\]/);
assert.match(styles, /--ui-brand:\s*#dc5826/i);
assert.match(styles, /\.brand-lockup strong\s*\{[^}]*color:\s*var\(--ui-brand\)/s);
assert.match(styles,
/\.playlist-state-legend \.legend-selected\s*\{\s*color:\s*var\(--ui-accent\)/);
assert.match(styles,
/\.playlist-state-legend \.legend-next\s*\{\s*color:\s*var\(--ui-warning\)/);
assert.match(styles,
/\.playlist-state-legend \.legend-on-air\s*\{\s*color:\s*var\(--ui-danger\)/);
});
test("dark palette keeps text, actions, and semantic states readable", () => {
const match = styles.match(
/:root\[data-color-theme="dark"\],[\s\S]*?body\[data-color-theme="dark"\]\s*\{([\s\S]*?)\n\}/);
assert.ok(match, "explicit dark palette must exist");
const token = name => {
const value = match[1].match(new RegExp(`--${name}:\\s*(#[0-9a-f]{6})`, "i"));
assert.ok(value, `${name} dark token must exist`);
return value[1];
};
assert.ok(contrast(token("ui-text"), token("ui-surface")) >= 7);
assert.ok(contrast(token("ui-text-muted"), token("ui-surface")) >= 4.5);
assert.ok(contrast(token("ui-accent"), token("ui-canvas")) >= 4.5);
assert.ok(contrast(token("ui-on-accent"), token("ui-accent")) >= 4.5);
assert.ok(contrast(token("ui-on-accent"), token("ui-success")) >= 4.5);
assert.ok(contrast(token("ui-danger"), token("ui-danger-soft")) >= 4.5);
assert.ok(contrast(token("ui-warning"), token("ui-warning-soft")) >= 4.5);
assert.match(styles,
/\.playlist-data-row\.on-air\s*\{[^}]*background:\s*var\(--ui-on-air\)[^}]*var\(--ui-on-air-edge\)/s);
});
test("light palette keeps hints and small semantic labels readable", () => {
const match = styles.match(/:root\s*\{([\s\S]*?)\n\}/);
assert.ok(match, "default light palette must exist");
const token = name => {
const value = match[1].match(new RegExp(`--${name}:\\s*(#[0-9a-f]{6})`, "i"));
assert.ok(value, `${name} light token must exist`);
return value[1];
};
assert.ok(contrast(token("ui-text-subtle"), token("ui-surface")) >= 4.5);
assert.ok(contrast(token("ui-text-subtle"), token("ui-surface-subtle")) >= 4.5);
assert.ok(contrast(token("ui-success"), token("ui-success-soft")) >= 4.5);
assert.ok(contrast(token("ui-warning"), token("ui-warning-soft")) >= 4.5);
});
test("view modes change catalog paint without changing operator row geometry", () => {
const start = styles.indexOf("/* View-mode presentation is paint-only:");
const end = styles.indexOf("/* End view-mode presentation. */", start);
assert.ok(start >= 0 && end > start, "view-mode CSS must have an auditable boundary");
const viewModeRules = styles.slice(start, end);
for (const mode of ["automatic", "compact", "cards"]) {
assert.ok(viewModeRules.includes(`data-view-mode="${mode}"`),
`${mode} mode must have explicit presentation rules`);
}
assert.match(viewModeRules, /\.catalog-section/);
assert.match(viewModeRules, /\.operator-catalog-results button/);
assert.doesNotMatch(viewModeRules, /\.playlist-(?:row|data-row|grid)/);
assert.doesNotMatch(viewModeRules,
/(?:^|[;{]\s*)(?:height|min-height|padding|margin|grid-template(?:-columns|-rows)?|display)\s*:/m);
});
test("appearance controls and accessibility preferences retain clear feedback", () => {
assert.match(styles, /\.settings-choice-list\s*\{/);
assert.match(styles, /\.settings-choice-row\s*\{/);
assert.match(styles, /\.settings-choice-row select\s*\{/);
assert.match(styles,
/@media \(forced-colors:\s*active\)[\s\S]*?\.settings-choice-row\s*\{\s*border-bottom-color:\s*CanvasText/);
assert.match(styles,
/@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.settings-switch::after\s*\{\s*transition:\s*none !important/);
});
test("workspace header keeps one concise title and the live native connection state", () => {
assert.match(markup,
/class="brand"[^>]*role="img"[^>]*aria-label="매일경제TV VRi"[\s\S]*?class="brand-symbol"[\s\S]*?<svg/);
assert.match(markup,
/class="workspace-heading"[\s\S]*?class="workspace-heading-icon"[\s\S]*?<div class="workspace-heading-copy">[\s\S]*?<small>현재 작업<\/small>[\s\S]*?id="workspace-title"/);
/class="workspace-heading"[\s\S]*?class="workspace-heading-icon"[\s\S]*?<div class="workspace-heading-copy">[\s\S]*?<h1 id="workspace-title">[^<]+<\/h1>/);
assert.doesNotMatch(markup, /<small>현재 작업<\/small>/);
assert.match(markup,
/class="playout-connection-summary"[\s\S]*?<small>송출 연결<\/small>[\s\S]*?<strong>Tornado2<\/strong>[\s\S]*?id="playout-connection-state"/);
/class="playout-connection-summary">[\s\S]*?id="playout-connection-state"/);
assert.doesNotMatch(markup, /class="playout-connection-copy"/);
assert.doesNotMatch(markup, /<strong>Tornado2<\/strong>/);
assert.match(markup,
/id="playout-connection-state"[^>]*role="status"[^>]*aria-label="Tornado2 연결 상태"[^>]*aria-live="polite"[^>]*aria-atomic="true">미연결<\/div>/);
assert.match(styles,
/\.workspace-header\s*\{[^}]*radial-gradient[\s\S]*?linear-gradient/);
assert.match(styles, /\.workspace-header::before\s*\{/);
assert.match(styles, /\.playlist-heading::before\s*\{/);
/\.workspace-header::before,\s*\.playlist-heading::before\s*\{\s*display:\s*none;/);
assert.match(styles, /\.connection-state::before\s*\{/);
assert.match(styles,
/\.playout-connection-copy small\s*\{[^}]*color:\s*var\(--ui-text-muted\)/);
assert.match(styles, /\.connection-state\.connected::before\s*\{/);
assert.match(styles,
/\.connection-state\.dry-run:not\(\.outcome-unknown\)::before\s*\{/);

View File

@@ -98,6 +98,7 @@ test("operator shell exposes stock and each graphic screen beside an always-pres
"stock-workspace",
"catalog-workspace",
"settings-workspace",
"workspace-splitter",
"playlist-drop-zone"
]) {
assert.match(markup, new RegExp(`id="${id}"`));
@@ -110,17 +111,26 @@ test("operator shell exposes stock and each graphic screen beside an always-pres
const stockStart = markup.indexOf('id="stock-workspace"');
const catalogStart = markup.indexOf('id="catalog-workspace"');
const settingsStart = markup.indexOf('id="settings-workspace"');
const splitterStart = markup.indexOf('id="workspace-splitter"');
const scheduleStart = markup.indexOf('class="playlist-panel"');
assert.ok(workspaceStart >= 0 && navigationStart > workspaceStart);
assert.ok(marketStart > navigationStart && settingsTabStart > marketStart);
assert.ok(stockStart > settingsTabStart);
assert.ok(catalogStart > stockStart);
assert.ok(settingsStart > catalogStart && scheduleStart > settingsStart);
assert.ok(settingsStart > catalogStart && splitterStart > settingsStart);
assert.ok(scheduleStart > splitterStart);
assert.match(markup,
/<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*<section class="playlist-panel"/);
/<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*<div id="workspace-splitter"[^>]*><\/div>\s*<section class="playlist-panel"/);
assert.match(markup,
/id="workspace-splitter"[^>]*class="workspace-splitter"[^>]*role="separator"[^>]*aria-label="작업 영역과 송출 스케줄 너비 조절"[^>]*aria-orientation="vertical"[^>]*aria-controls="workspace-region"[^>]*aria-valuemin="34"[^>]*aria-valuemax="48"[^>]*aria-valuenow="39"[^>]*tabindex="0"/);
assert.match(markup, /id="catalog-workspace"[^>]*hidden inert/);
assert.match(markup, /id="settings-workspace"[^>]*hidden inert/);
assert.match(markup, /<small>항상 표시<\/small><h2>송출 스케줄<\/h2>/);
const scheduleHeaderEnd = markup.indexOf("</header>", scheduleStart);
const scheduleHeader = markup.slice(scheduleStart, scheduleHeaderEnd);
assert.match(scheduleHeader, /<h2>송출 스케줄<\/h2>/);
assert.doesNotMatch(scheduleHeader, /<small\b/);
assert.match(scheduleHeader,
/class="playlist-state-legend"[^>]*aria-label="스케줄 상태"[\s\S]*?class="legend-selected"[\s\S]*?class="legend-next"[\s\S]*?class="legend-on-air"/);
});
test("the sidebar owns the ten native market buttons and keeps settings outside their order", () => {
@@ -139,9 +149,16 @@ test("the sidebar owns the ten native market buttons and keeps settings outside
}
});
test("layout gives the always-visible schedule a wider responsive share", () => {
test("layout keeps an adjustable 34-to-48-percent schedule beside the workspace", () => {
assert.match(styles,
/grid-template-columns:\s*minmax\(900px, 1\.75fr\) minmax\(600px, 1fr\)/);
/\.operator-shell\s*\{[^}]*grid-template-columns:\s*minmax\(780px,\s*1fr\)\s+var\(--workspace-splitter-width\)\s+minmax\(600px,\s*var\(--schedule-pane-width,[^)]+\)\)/);
assert.match(styles, /--workspace-splitter-width:\s*8px/);
assert.match(styles,
/\.workspace-splitter\s*\{[^}]*cursor:\s*col-resize[^}]*touch-action:\s*none/);
assert.match(styles, /body\.resizing-workspace\s*\{[^}]*cursor:\s*col-resize[^}]*user-select:\s*none/);
assert.match(styles, /\.workspace-splitter:focus-visible/);
assert.match(styles,
/@media \(forced-colors:\s*active\)[\s\S]*?\.workspace-splitter/);
assert.match(styles,
/\.workspace-region\s*\{[^}]*grid-template-columns:\s*var\(--workspace-nav-width\) minmax\(0, 1fr\)/);
assert.match(styles, /\.workspace-region\.nav-collapsed/);
@@ -156,23 +173,108 @@ test("layout gives the always-visible schedule a wider responsive share", () =>
assert.match(styles, /\.playlist-toolbar\s*\{[^}]*flex-wrap:\s*wrap/);
assert.match(styles, /\.playlist-row\s*\{[^}]*min-width:\s*600px/);
assert.match(packageInputHarness,
/widthRatio < 1\.60 \|\| widthRatio > 1\.90/);
/widthRatio < 1\.05 \|\| widthRatio > 1\.95/);
assert.match(packageInputHarness,
/workspaceShare < 0\.50 \|\| workspaceShare > 0\.67/);
assert.match(packageInputHarness,
/scheduleShare < 0\.33 \|\| scheduleShare > 0\.49/);
assert.match(uiOnlySmokeHarness,
/workspaceToScheduleWidthRatio < 1\.60 \|\|[\s\S]*?workspaceToScheduleWidthRatio > 1\.90/);
assert.match(packageInputHarness, /approximately 1\.75:1 workspace\/schedule split/);
assert.match(uiOnlySmokeHarness, /approximately 1\.75:1 workspace layout/);
/workspaceToScheduleWidthRatio < 1\.05 \|\|[\s\S]*?workspaceToScheduleWidthRatio > 1\.95/);
assert.match(uiOnlySmokeHarness,
/scheduleShare < 0\.33 \|\|[\s\S]*?scheduleShare > 0\.49/);
assert.match(packageInputHarness, /adjustable workspace\/schedule split/);
assert.match(uiOnlySmokeHarness, /adjustable workspace layout/);
assert.match(packageInputHarness,
/let baseline = await ensureWorkspace\("stock"[\s\S]*?expand workspace menu before layout verification/);
assert.match(uiOnlySmokeHarness,
/\[stockWorkspaceOk, settingsWorkspaceOk, catalogWorkspaceOk\]\.filter\(Boolean\)\.length !== 1/);
assert.match(uiOnlySmokeHarness,
/navigationAriaExpanded === "true" && layout\.navigationCollapsedClass === false[\s\S]*?navigationAriaExpanded === "false" && layout\.navigationCollapsedClass === true/);
assert.doesNotMatch(packageInputHarness, /approximately 2:1 workspace\/schedule split/);
assert.doesNotMatch(uiOnlySmokeHarness, /approximately 2:1 workspace layout/);
});
test("modern operator theme keeps clear semantic states and accessible focus", () => {
test("UI-only smoke settles a remembered startup screen before guarding later intents", () => {
assert.match(uiOnlySmokeHarness,
/async function waitForSettledStartup\(\)[\s\S]*?Date\.now\(\) \+ 10_000/);
assert.match(uiOnlySmokeHarness,
/lastSample\.bodyBusy === "false"[\s\S]*?lastSample\.connectionText === "DRY RUN"[\s\S]*?lastSample\.playoutStatus\.startsWith\("IDLE"\)/);
assert.match(uiOnlySmokeHarness,
/signature === stableSignature[\s\S]*?Date\.now\(\) - stableSince >= 400/);
const settle = uiOnlySmokeHarness.indexOf(
"evidence.safety.preGuardStartupSettle = await waitForSettledStartup()");
const guard = uiOnlySmokeHarness.indexOf("await installNoIntentGuard()", settle);
assert.ok(settle >= 0 && guard > settle,
"startup must settle before the zero-intent guard is installed");
assert.match(uiOnlySmokeHarness, /noNativeIntentAfterGuard:\s*true/);
});
test("package input physically resizes the splitter, verifies persistence, and restores width", () => {
const start = packageInputHarness.indexOf("async function exerciseWorkspaceSplitter(baseline)");
const end = packageInputHarness.indexOf("async function ensureWorkspace(", start);
assert.ok(start >= 0 && end > start, "splitter input exercise must be a bounded helper");
const exercise = packageInputHarness.slice(start, end);
for (const contract of [
/dragWorkspaceSplitterToPercent\(/,
/pressKey\(keyboardKey, "resize workspace splitter from keyboard"\)/,
/assertSplitterResizeResult\(/,
/storedScheduleWidth/,
/assertFixedSchedule\(baseline, restoredLayout/,
/outboundMessages\.length/
]) {
assert.match(exercise, contract);
}
assert.doesNotMatch(exercise, /\.click\(|localStorage\.setItem|style\.setProperty/);
const validationStart = packageInputHarness.indexOf(
"function assertSplitterResizeResult(layout, expectedPercent, label)");
const validationEnd = packageInputHarness.indexOf(
"async function dragWorkspaceSplitterToPercent(", validationStart);
const validation = packageInputHarness.slice(validationStart, validationEnd);
assert.ok(validationStart >= 0 && validationEnd > validationStart);
assert.match(validation, /splitterState\?\.valueNow/);
assert.match(validation,
/Math\.abs\(layout\.splitterState\.valueNow -\s*Number\(layout\.scheduleWidthPercent\.toFixed\(1\)\)\) > 0\.001/);
assert.doesNotMatch(validation,
/splitterState\??\.valueNow\s*!==\s*Math\.round\(layout\.scheduleWidthPercent\)/);
assert.match(validation, /storedScheduleWidth/);
assert.match(validation, /scheduleCssWidth/);
assert.match(validation, /geometryPercent/);
const dragStart = packageInputHarness.indexOf(
"async function dragWorkspaceSplitterToPercent(beforeLayout, targetPercent, label)");
const dragEnd = packageInputHarness.indexOf("async function exerciseWorkspaceSplitter(", dragStart);
const drag = packageInputHarness.slice(dragStart, dragEnd);
assert.ok(dragStart >= 0 && dragEnd > dragStart);
for (const gesture of [
/await pressMouse\(start, 0\)/,
/await moveMouse\(target, 0, 1\)/,
/await releaseMouse\(target, 0\)/
]) {
assert.match(drag, gesture);
}
assert.doesNotMatch(drag, /Input\.dispatchMouseEvent|\.click\(/);
assert.match(packageInputHarness,
/const splitterResize = await exerciseWorkspaceSplitter\(baseline\)[\s\S]*?"collapse workspace menu"/);
assert.match(packageInputHarness, /splitterResize,/);
assert.match(packageInputHarness,
/ArrowLeft: \{ key: "ArrowLeft", code: "ArrowLeft", virtualKey: 37 \}/);
assert.match(packageInputHarness,
/ArrowRight: \{ key: "ArrowRight", code: "ArrowRight", virtualKey: 39 \}/);
});
test("unified operator theme keeps clear semantic states and concise feedback", () => {
assert.match(styles,
/Modern operator theme: visual-only overrides; geometry and native contracts stay intact/);
/Unified operator theme: visual-only rules; geometry and native contracts stay intact/);
assert.match(styles, /Compact broadcast-console components/);
assert.equal((styles.match(/(?:^|\n):root\s*\{/g) || []).length, 1,
"design tokens must have one authoritative :root block");
for (const token of [
"--ui-canvas",
"--ui-accent",
"--ui-success",
"--ui-danger"
"--ui-danger",
"--ui-warning",
"--ui-control-border"
]) {
assert.match(styles, new RegExp(`${token}:`));
}
@@ -184,16 +286,30 @@ test("modern operator theme keeps clear semantic states and accessible focus", (
assert.match(styles, /\.playout-actions \.take-in:not\(:disabled\)\s*\{/);
assert.match(styles, /\.playout-actions \.next:not\(:disabled\)\s*\{/);
assert.match(styles, /\.playout-actions \.take-out:not\(:disabled\)\s*\{/);
assert.match(styles,
/Contemporary density refinement: keep operator geometry, clarify visual hierarchy/);
assert.match(styles, /\.stock-result\.selected\s*\{/);
assert.match(styles, /\.playlist-heading-count\s*\{/);
assert.match(styles, /\.playlist-rows:empty::after\s*\{/);
assert.match(styles, /\.playlist-state-legend\s*\{/);
assert.match(styles, /\.playlist-toolbar-group\s*\{/);
assert.match(styles, /\.search-meta\s*\{/);
assert.match(styles, /\.stock-results:empty\[data-empty-label\]::after,/);
assert.match(styles, /content:\s*attr\(data-empty-label\)/);
assert.match(styles, /body\.busy::before\s*\{/);
assert.match(styles, /@keyframes operator-progress/);
assert.match(styles, /\.playout-status\[data-state="outcome-unknown"\]\s*\{/);
assert.match(styles, /@media \(prefers-reduced-motion:\s*reduce\)/);
assert.match(markup, /id="stock-search-button"[\s\S]*?<svg[^>]*viewBox="0 0 24 24"/);
assert.match(markup,
/id="stock-search-meta"[^>]*class="search-meta"[^>]*role="status"[^>]*aria-live="polite"[^>]*aria-atomic="true"[^>]*hidden/);
assert.match(markup, /class="playlist-heading-icon"[\s\S]*?<svg[^>]*viewBox="0 0 24 24"/);
for (const group of ["playlist-toolbar-order", "playlist-toolbar-background",
"playlist-toolbar-db", "playlist-toolbar-danger"]) {
assert.match(markup, new RegExp(`class="[^"]*${group}[^"]*"`));
}
assert.match(markup,
/id="playlist-heading-count"[^>]*role="status"[^>]*aria-live="polite"[^>]*aria-atomic="true"/);
assert.match(app, /stockResults\.dataset\.emptyLabel\s*=\s*"결과 없음"/);
assert.match(app, /cutList\.dataset\.emptyLabel\s*=\s*Number\.isInteger/);
assert.match(app, /const nextHeadingCount = `[^`]*\$\{state\.playlist\.length\}[^`]*\$\{enabledCount\}[^`]*`/);
assert.match(app,
/if \(playlistHeadingCount\.textContent !== nextHeadingCount\)\s*\{\s*playlistHeadingCount\.textContent = nextHeadingCount;/);
@@ -211,7 +327,7 @@ test("the complete sidebar is one labelled and collapsible navigation region", (
assert.match(markup, /id="workspace-nav-footer" class="workspace-nav-footer"/);
});
test("workspace navigation is local presentation state and preserves native contracts", () => {
test("workspace navigation stores only the approved local presentation preferences", () => {
assert.match(navigation, /entry\.view\.hidden = !isCurrent/);
assert.match(navigation, /entry\.view\.toggleAttribute\("inert", !isCurrent\)/);
assert.match(navigation, /if \(focusWillBeHidden && selectedButton\) selectedButton\.focus\(\)/);
@@ -232,7 +348,122 @@ test("workspace navigation is local presentation state and preserves native cont
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*\(/);
assert.deepEqual(
Array.from(navigation.matchAll(/"(mbn-stock-webview\.[^"]+)"/g), match => match[1]),
[
"mbn-stock-webview.last-workspace.v1",
"mbn-stock-webview.schedule-width.v1"
]);
assert.match(navigation, /window\.localStorage && window\.localStorage\.getItem\(key\)/);
assert.match(navigation, /window\.localStorage\.setItem\(key, value\)/);
assert.doesNotMatch(navigation, /postMessage|sessionStorage|send\s*\(/);
});
test("splitter clamps pointer and keyboard resizing and Escape restores the gesture", () => {
for (const declaration of [
/const scheduleMinimumPercent = 34/,
/const scheduleMaximumPercent = 48/,
/const workspaceMinimumPixels = 780/,
/const scheduleMinimumPixels = 600/,
/const splitterPixels = 8/
]) {
assert.match(navigation, declaration);
}
assert.match(navigation,
/const minimum = Math\.max\([\s\S]*?scheduleMinimumPercent,[\s\S]*?scheduleMinimumPixels \/ width \* 100\)/);
assert.match(navigation,
/const maximum = Math\.min\([\s\S]*?scheduleMaximumPercent,[\s\S]*?\(width - workspaceMinimumPixels - splitterPixels\) \/ width \* 100\)/);
assert.match(navigation,
/return Math\.min\(bounds\.maximum, Math\.max\(bounds\.minimum, value\)\)/);
assert.match(navigation, /shell\.style\.setProperty\("--schedule-pane-width", serialized\)/);
for (const attribute of ["aria-valuemin", "aria-valuemax", "aria-valuenow", "aria-valuetext"]) {
assert.match(navigation, new RegExp(`splitter\\.setAttribute\\("${attribute}"`));
}
assert.match(navigation,
/splitter\.setAttribute\("aria-valuemin", bounds\.minimum\.toFixed\(1\)\)/);
assert.match(navigation,
/splitter\.setAttribute\("aria-valuemax", bounds\.maximum\.toFixed\(1\)\)/);
assert.match(navigation,
/splitter\.setAttribute\("aria-valuenow", scheduleWidthPercent\.toFixed\(1\)\)/);
assert.doesNotMatch(navigation,
/aria-valuemin"[\s\S]{0,80}Math\.ceil|aria-valuemax"[\s\S]{0,80}Math\.floor/);
assert.match(navigation,
/event\.button !== 0 \|\| event\.isPrimary === false \|\| splitterGesture/);
assert.match(navigation, /splitter\.setPointerCapture\?\.\(event\.pointerId\)/);
for (const eventName of ["pointerdown", "pointermove", "pointerup", "pointercancel",
"lostpointercapture"]) {
assert.match(navigation,
new RegExp(`splitter\\.addEventListener\\("${eventName}"`));
}
assert.match(navigation,
/event\.key === "Escape" && splitterGesture[\s\S]*?event\.stopImmediatePropagation\(\)[\s\S]*?finishSplitterGesture\(true\)/);
assert.match(navigation, /event\.key === "ArrowLeft"\) next \+= 2/);
assert.match(navigation, /event\.key === "ArrowRight"\) next -= 2/);
assert.match(navigation, /event\.key === "Home"\) next = numericScheduleBounds\(\)\.minimum/);
assert.match(navigation, /event\.key === "End"\) next = numericScheduleBounds\(\)\.maximum/);
assert.match(navigation,
/applyScheduleWidth\(\s*restore === true \? gesture\.startPercent : scheduleWidthPercent,[\s\S]*?restore !== true\)/);
});
test("fractional splitter bounds always publish an ordered ARIA range", () => {
const declarations = [
"scheduleMinimumPercent",
"scheduleMaximumPercent",
"workspaceMinimumPixels",
"scheduleMinimumPixels",
"splitterPixels"
].map(name => {
const match = navigation.match(new RegExp(`const ${name} = [^;]+;`));
assert.ok(match, `missing ${name}`);
return match[0];
}).join("\n");
const functionsStart = navigation.indexOf("function numericScheduleBounds()");
const functionsEnd = navigation.indexOf("function schedulePercentFromPointer", functionsStart);
assert.ok(functionsStart >= 0 && functionsEnd > functionsStart);
const functionsSource = navigation.slice(functionsStart, functionsEnd);
for (const shellWidth of [1388, 1389, 1501, 1919]) {
const attributes = new Map();
const properties = new Map();
const shell = {
getBoundingClientRect() { return { width: shellWidth }; },
style: { setProperty(name, value) { properties.set(name, value); } }
};
const splitter = {
setAttribute(name, value) { attributes.set(name, value); }
};
const api = Function("shell", "splitter", "writeStoredValue", `
"use strict";
${declarations}
let scheduleWidthPercent = null;
${functionsSource}
return { numericScheduleBounds, applyScheduleWidth };
`)(shell, splitter, () => {});
const bounds = api.numericScheduleBounds();
for (const requested of [bounds.minimum - 5,
(bounds.minimum + bounds.maximum) / 2, bounds.maximum + 5]) {
assert.equal(api.applyScheduleWidth(requested, false), true);
const minimum = Number(attributes.get("aria-valuemin"));
const maximum = Number(attributes.get("aria-valuemax"));
const now = Number(attributes.get("aria-valuenow"));
assert.ok(Number.isFinite(minimum) && Number.isFinite(maximum) && Number.isFinite(now));
assert.ok(minimum <= now && now <= maximum,
`ARIA range is unordered at ${shellWidth}px: ${minimum} <= ${now} <= ${maximum}`);
assert.match(attributes.get("aria-valuemin"), /^\d+\.\d$/u);
assert.match(attributes.get("aria-valuemax"), /^\d+\.\d$/u);
assert.match(attributes.get("aria-valuenow"), /^\d+\.\d$/u);
assert.match(properties.get("--schedule-pane-width"), /^\d+\.\d{3}%$/u);
}
}
});
test("explicit menu input wins over any deferred last-workspace restore", () => {
assert.match(navigation,
/stockTab\.addEventListener\("click", function \(\) \{\s*startPreferenceApplied = true;\s*setWorkspace\("stock"\);\s*\}\)/);
assert.match(navigation,
/settingsTab\.addEventListener\("click", function \(\) \{\s*startPreferenceApplied = true;\s*setWorkspace\("settings"\);\s*\}\)/);
assert.match(navigation,
/marketTabs\.addEventListener\("click", function \(event\) \{[\s\S]*?if \(!button \|\| !marketTabs\.contains\(button\)\) return;\s*startPreferenceApplied = true;\s*syncMarket\(button\.dataset\.tabId\);\s*setWorkspace\("catalog"\);/);
});
test("native tab rendering preserves sidebar icons and reconciles the active market", () => {