feat: add configurable operator appearance and layout
This commit is contained in:
108
tests/LegacyParityWeb/legacy-package-appearance-smoke.test.cjs
Normal file
108
tests/LegacyParityWeb/legacy-package-appearance-smoke.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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*\{/);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -358,7 +358,10 @@ public sealed class LegacyOperatorSettingsTests
|
||||
sceneDirectory,
|
||||
resourceDirectory,
|
||||
backgroundDirectory,
|
||||
NavigationExpanded: false);
|
||||
NavigationExpanded: false,
|
||||
ColorTheme: LegacyOperatorColorTheme.Dark,
|
||||
ViewMode: LegacyOperatorViewMode.Cards,
|
||||
StartWorkspace: LegacyOperatorStartWorkspace.LastWorkspace);
|
||||
|
||||
var firstSave = store.Save(settings);
|
||||
var secondSave = store.Save(settings with { NavigationExpanded = true });
|
||||
@@ -370,6 +373,11 @@ public sealed class LegacyOperatorSettingsTests
|
||||
Assert.Null(secondSave.WarningMessage);
|
||||
Assert.True(loaded.LoadedFromDisk);
|
||||
Assert.True(loaded.Settings.NavigationExpanded);
|
||||
Assert.Equal(LegacyOperatorColorTheme.Dark, loaded.Settings.ColorTheme);
|
||||
Assert.Equal(LegacyOperatorViewMode.Cards, loaded.Settings.ViewMode);
|
||||
Assert.Equal(
|
||||
LegacyOperatorStartWorkspace.LastWorkspace,
|
||||
loaded.Settings.StartWorkspace);
|
||||
Assert.Equal(Path.GetFullPath(sceneDirectory), loaded.Settings.SceneDirectory);
|
||||
Assert.Equal(Path.GetFullPath(resourceDirectory), loaded.Settings.ResourceDirectory);
|
||||
Assert.Equal(
|
||||
@@ -389,9 +397,20 @@ public sealed class LegacyOperatorSettingsTests
|
||||
"sceneDirectory",
|
||||
"resourceDirectory",
|
||||
"backgroundDirectory",
|
||||
"navigationExpanded"
|
||||
"navigationExpanded",
|
||||
"colorTheme",
|
||||
"viewMode",
|
||||
"startWorkspace"
|
||||
],
|
||||
names);
|
||||
Assert.Equal(
|
||||
LegacyOperatorSettings.CurrentSchemaVersion,
|
||||
document.RootElement.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal("dark", document.RootElement.GetProperty("colorTheme").GetString());
|
||||
Assert.Equal("cards", document.RootElement.GetProperty("viewMode").GetString());
|
||||
Assert.Equal(
|
||||
"lastWorkspace",
|
||||
document.RootElement.GetProperty("startWorkspace").GetString());
|
||||
Assert.DoesNotContain(
|
||||
document.RootElement.EnumerateObject(),
|
||||
property => property.Name.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -399,13 +418,38 @@ public sealed class LegacyOperatorSettingsTests
|
||||
property.Name.Contains("live", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_LegacySchemaUsesClosedAppearanceDefaults()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var configurationPath = Path.Combine(directory.Path, "settings.json");
|
||||
File.WriteAllText(
|
||||
configurationPath,
|
||||
"{\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":false}",
|
||||
new UTF8Encoding(false));
|
||||
|
||||
var result = new LegacyOperatorSettingsStore(configurationPath).Load();
|
||||
|
||||
Assert.True(result.LoadedFromDisk);
|
||||
Assert.Equal(LegacyOperatorSettingsStoreFailure.None, result.Failure);
|
||||
Assert.False(result.Settings.NavigationExpanded);
|
||||
Assert.Equal(LegacyOperatorColorTheme.System, result.Settings.ColorTheme);
|
||||
Assert.Equal(LegacyOperatorViewMode.Automatic, result.Settings.ViewMode);
|
||||
Assert.Equal(
|
||||
LegacyOperatorStartWorkspace.StockCut,
|
||||
result.Settings.StartWorkspace);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
|
||||
[InlineData("{\"schemaVersion\":2,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.UnsupportedSchema)]
|
||||
[InlineData("{\"schemaVersion\":3,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.UnsupportedSchema)]
|
||||
[InlineData("{\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true,\"unexpected\":1}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
|
||||
[InlineData("{\"schemaVersion\":1,\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
|
||||
[InlineData("{\"SchemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
|
||||
[InlineData("{\"schemaVersion\":1,\"sceneDirectory\":\"relative\",\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidSettings)]
|
||||
[InlineData("{\"schemaVersion\":2,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true,\"colorTheme\":\"mbn\",\"viewMode\":\"automatic\",\"startWorkspace\":\"stockCut\"}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
|
||||
[InlineData("{\"schemaVersion\":2,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true,\"colorTheme\":\"system\",\"viewMode\":\"grid\",\"startWorkspace\":\"stockCut\"}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
|
||||
[InlineData("{\"schemaVersion\":2,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true,\"colorTheme\":\"system\",\"viewMode\":\"automatic\",\"startWorkspace\":\"other\"}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
|
||||
public void Load_StrictMalformedOrUnsafeJsonFailsClosed(
|
||||
string json,
|
||||
LegacyOperatorSettingsStoreFailure expectedFailure)
|
||||
@@ -463,6 +507,31 @@ public sealed class LegacyOperatorSettingsTests
|
||||
AssertPathFree(result.WarningMessage, settings.SceneDirectory!);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(99, 0, 0)]
|
||||
[InlineData(0, 99, 0)]
|
||||
[InlineData(0, 0, 99)]
|
||||
public void Save_RejectsUndefinedAppearanceValuesWithoutCreatingAConfigurationFile(
|
||||
int colorTheme,
|
||||
int viewMode,
|
||||
int startWorkspace)
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var configurationPath = Path.Combine(directory.Path, "settings.json");
|
||||
var settings = LegacyOperatorSettings.Default with
|
||||
{
|
||||
ColorTheme = (LegacyOperatorColorTheme)colorTheme,
|
||||
ViewMode = (LegacyOperatorViewMode)viewMode,
|
||||
StartWorkspace = (LegacyOperatorStartWorkspace)startWorkspace
|
||||
};
|
||||
|
||||
var result = new LegacyOperatorSettingsStore(configurationPath).Save(settings);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(LegacyOperatorSettingsStoreFailure.InvalidSettings, result.Failure);
|
||||
Assert.False(File.Exists(configurationPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbbreviateForDisplay_IsBoundedAndNeverReturnsInvalidInput()
|
||||
{
|
||||
|
||||
@@ -54,6 +54,47 @@ public sealed class LegacyOperatorSettingsBridgeTests
|
||||
Assert.Equal(expanded, intent.Expanded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(
|
||||
"system",
|
||||
"automatic",
|
||||
"stockCut",
|
||||
LegacyOperatorColorTheme.System,
|
||||
LegacyOperatorViewMode.Automatic,
|
||||
LegacyOperatorStartWorkspace.StockCut)]
|
||||
[InlineData(
|
||||
"light",
|
||||
"compact",
|
||||
"lastWorkspace",
|
||||
LegacyOperatorColorTheme.Light,
|
||||
LegacyOperatorViewMode.Compact,
|
||||
LegacyOperatorStartWorkspace.LastWorkspace)]
|
||||
[InlineData(
|
||||
"dark",
|
||||
"cards",
|
||||
"stockCut",
|
||||
LegacyOperatorColorTheme.Dark,
|
||||
LegacyOperatorViewMode.Cards,
|
||||
LegacyOperatorStartWorkspace.StockCut)]
|
||||
public void ParseAppearance_AcceptsOnlyClosedValues(
|
||||
string colorTheme,
|
||||
string viewMode,
|
||||
string startWorkspace,
|
||||
LegacyOperatorColorTheme expectedColorTheme,
|
||||
LegacyOperatorViewMode expectedViewMode,
|
||||
LegacyOperatorStartWorkspace expectedStartWorkspace)
|
||||
{
|
||||
var json =
|
||||
$"{{\"type\":\"set-operator-appearance\",\"payload\":{{\"viewMode\":\"{viewMode}\",\"startWorkspace\":\"{startWorkspace}\",\"colorTheme\":\"{colorTheme}\"}}}}";
|
||||
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error));
|
||||
Assert.Null(error);
|
||||
var intent = Assert.IsType<LegacySetOperatorAppearanceIntent>(parsed);
|
||||
Assert.Equal(expectedColorTheme, intent.ColorTheme);
|
||||
Assert.Equal(expectedViewMode, intent.ViewMode);
|
||||
Assert.Equal(expectedStartWorkspace, intent.StartWorkspace);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{}}")]
|
||||
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"settings\"}}")]
|
||||
@@ -65,6 +106,12 @@ public sealed class LegacyOperatorSettingsBridgeTests
|
||||
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{}}")]
|
||||
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{\"expanded\":\"true\"}}")]
|
||||
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{\"expanded\":true,\"path\":\"C:\\\\Config\"}}")]
|
||||
[InlineData("{\"type\":\"set-operator-appearance\",\"payload\":{\"colorTheme\":\"system\",\"viewMode\":\"automatic\"}}")]
|
||||
[InlineData("{\"type\":\"set-operator-appearance\",\"payload\":{\"colorTheme\":\"mbn\",\"viewMode\":\"automatic\",\"startWorkspace\":\"stockCut\"}}")]
|
||||
[InlineData("{\"type\":\"set-operator-appearance\",\"payload\":{\"colorTheme\":\"system\",\"viewMode\":\"grid\",\"startWorkspace\":\"stockCut\"}}")]
|
||||
[InlineData("{\"type\":\"set-operator-appearance\",\"payload\":{\"colorTheme\":\"system\",\"viewMode\":\"automatic\",\"startWorkspace\":\"other\"}}")]
|
||||
[InlineData("{\"type\":\"set-operator-appearance\",\"payload\":{\"colorTheme\":\"system\",\"viewMode\":\"automatic\",\"startWorkspace\":\"stockCut\",\"extra\":true}}")]
|
||||
[InlineData("{\"type\":\"set-operator-appearance\",\"payload\":{\"colorTheme\":\"system\",\"colorTheme\":\"dark\",\"viewMode\":\"automatic\",\"startWorkspace\":\"stockCut\"}}")]
|
||||
public void ParseSettingsIntents_RejectsMissingUnknownAndExtraAuthority(string json)
|
||||
{
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
|
||||
@@ -80,6 +127,9 @@ public sealed class LegacyOperatorSettingsBridgeTests
|
||||
new LegacyOperatorFolderSnapshot("D:\\Resource", true, false, "Res 폴더 없음"),
|
||||
new LegacyOperatorFolderSnapshot("앱 기본 배경", false, true, "기본값"),
|
||||
NavigationExpanded: false,
|
||||
ColorTheme: LegacyOperatorColorTheme.Dark,
|
||||
ViewMode: LegacyOperatorViewMode.Cards,
|
||||
StartWorkspace: LegacyOperatorStartWorkspace.LastWorkspace,
|
||||
RestartRequired: true,
|
||||
Message: "재시작 후 적용됩니다.",
|
||||
MessageKind: LegacyOperatorSettingsMessageKind.Warning,
|
||||
@@ -107,6 +157,11 @@ public sealed class LegacyOperatorSettingsBridgeTests
|
||||
Assert.False(projected.GetProperty("resource").GetProperty("isValid").GetBoolean());
|
||||
Assert.False(projected.GetProperty("background").GetProperty("isCustom").GetBoolean());
|
||||
Assert.False(projected.GetProperty("navigationExpanded").GetBoolean());
|
||||
Assert.Equal("dark", projected.GetProperty("colorTheme").GetString());
|
||||
Assert.Equal("cards", projected.GetProperty("viewMode").GetString());
|
||||
Assert.Equal(
|
||||
"lastWorkspace",
|
||||
projected.GetProperty("startWorkspace").GetString());
|
||||
Assert.True(projected.GetProperty("restartRequired").GetBoolean());
|
||||
Assert.Equal("재시작 후 적용됩니다.", projected.GetProperty("message").GetString());
|
||||
Assert.Equal("warning", projected.GetProperty("messageKind").GetString());
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||
|
||||
public sealed class LegacyOperatorFeedbackWebContractTests
|
||||
{
|
||||
private static readonly string App = File.ReadAllText(Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp",
|
||||
"Web",
|
||||
"app.js"));
|
||||
|
||||
[Fact]
|
||||
public void StockSearchFeedbackStaysConciseAndUsesNativeState()
|
||||
{
|
||||
var feedback = Slice(
|
||||
App,
|
||||
"function renderStockSearchMeta(state)",
|
||||
"function pointerPosition(event)");
|
||||
|
||||
Assert.Contains("state.isBusy === true", feedback, StringComparison.Ordinal);
|
||||
Assert.Contains("label = \"검색 중\"", feedback, StringComparison.Ordinal);
|
||||
Assert.Contains("String(results.length) + \"건\"", feedback,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("row.index === state.selectedStockIndex", feedback,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("selected.displayName", feedback, StringComparison.Ordinal);
|
||||
Assert.Contains("stockSearchMeta.hidden = label.length === 0", feedback,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("stockResults.dataset.emptyLabel = \"결과 없음\"", feedback,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("delete stockResults.dataset.emptyLabel", feedback,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyCutFeedbackDistinguishesStockSelectionAndClearsWhenRowsReturn()
|
||||
{
|
||||
var cuts = Slice(App, "function renderCuts(state)", "cutList.addEventListener");
|
||||
|
||||
Assert.Contains("state.cutRows.length === 0", cuts, StringComparison.Ordinal);
|
||||
Assert.Contains("Number.isInteger(state.selectedStockIndex)", cuts,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("? \"컷 없음\"", cuts, StringComparison.Ordinal);
|
||||
Assert.Contains(": \"종목을 선택하세요\"", cuts, StringComparison.Ordinal);
|
||||
Assert.Contains("delete cutList.dataset.emptyLabel", cuts,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalBusyFeedbackDoesNotMislabelEveryOperationAsSearch()
|
||||
{
|
||||
var render = Slice(App, "function render(state)",
|
||||
"Object.keys(operatorFolderControls)");
|
||||
|
||||
Assert.Contains("document.body.classList.toggle(\"busy\", isBusy)", render,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("document.body.setAttribute(\"aria-busy\"", render,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("renderStockResults(state);\n renderStockSearchMeta(state);", render,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("isBusy ? \"처리 중\"", render, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("종목을 검색하고 있습니다.", render, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Slice(string source, string startMarker, string endMarker)
|
||||
{
|
||||
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Missing start marker: {startMarker}");
|
||||
var end = source.IndexOf(endMarker, start, StringComparison.Ordinal);
|
||||
Assert.True(end > start, $"Missing end marker: {endMarker}");
|
||||
return source[start..end];
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -102,10 +102,35 @@ public sealed class LegacyOperatorSettingsNativeContractTests
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("LegacySetOperatorNavigationExpandedIntent", Playout,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("LegacySetOperatorAppearanceIntent", Playout,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("ReconcileRejectedOperatorSettingsIntent(", process,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceIntentIsPersistedRoutedAndProjectedAsOneClosedUpdate()
|
||||
{
|
||||
Assert.Contains(
|
||||
"case LegacySetOperatorAppearanceIntent appearance:",
|
||||
Settings,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("ColorTheme = appearance.ColorTheme", Settings,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("ViewMode = appearance.ViewMode", Settings,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("StartWorkspace = appearance.StartWorkspace", Settings,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("_operatorSettings.ColorTheme", Settings,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("_operatorSettings.ViewMode", Settings,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("_operatorSettings.StartWorkspace", Settings,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("LegacySetOperatorAppearanceIntent", Window,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsStateIsProjectedWithoutPublishingFullNativePaths()
|
||||
{
|
||||
@@ -132,6 +157,28 @@ public sealed class LegacyOperatorSettingsNativeContractTests
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsUsesConciseRoutineStatusCopy()
|
||||
{
|
||||
var cancelledPicker = Slice(
|
||||
Settings,
|
||||
"if (selected is null)",
|
||||
"cancellationToken.ThrowIfCancellationRequested();");
|
||||
|
||||
Assert.DoesNotContain(
|
||||
"SetOperatorSettingsMessage",
|
||||
cancelledPicker,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("\"저장됨\"", Settings, StringComparison.Ordinal);
|
||||
Assert.Contains("\"저장됨 · 재시작 필요\"", Settings, StringComparison.Ordinal);
|
||||
Assert.Contains("\"초기화됨 · 재시작 필요\"", Settings, StringComparison.Ordinal);
|
||||
Assert.Contains("\"앱 기본값\"", Settings, StringComparison.Ordinal);
|
||||
Assert.Contains("\"자동\"", Settings, StringComparison.Ordinal);
|
||||
Assert.Contains("\"사용 가능\"", Settings, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("외부 영상", Settings, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("핵심 자산 확인됨", Settings, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Slice(string value, string start, string end)
|
||||
{
|
||||
var startIndex = RequiredIndex(value, start);
|
||||
|
||||
@@ -13,9 +13,14 @@ public sealed class LegacyOperatorSettingsWebContractTests
|
||||
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 App = File.ReadAllText(Path.Combine(WebRoot, "app.js"));
|
||||
private static readonly string SettingsHost = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp",
|
||||
"MainWindow.Settings.cs"));
|
||||
|
||||
[Fact]
|
||||
public void SettingsScreenOffersThreeNativeFolderPickersAndRestartFeedback()
|
||||
public void SettingsScreenKeepsThreeNativeFolderPickersWithConciseFeedback()
|
||||
{
|
||||
var expectedKinds = new[] { "design", "resource", "background" };
|
||||
var actualKinds = Regex.Matches(Markup, "data-operator-folder=\"([^\"]+)\"")
|
||||
@@ -33,6 +38,9 @@ public sealed class LegacyOperatorSettingsWebContractTests
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains($"id=\"operator-{kind}-folder-reset\"", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
$"id=\"operator-{kind}-folder-source\"[^>]*hidden",
|
||||
Markup);
|
||||
}
|
||||
|
||||
Assert.Matches(
|
||||
@@ -41,10 +49,31 @@ public sealed class LegacyOperatorSettingsWebContractTests
|
||||
Assert.Matches(
|
||||
"id=\"operator-settings-restart\"[^>]*role=\"status\"[^>]*aria-live=\"polite\"",
|
||||
Markup);
|
||||
Assert.Contains("앱을 다시 시작하면 새 폴더가 적용됩니다.", Markup,
|
||||
Assert.Contains("<h3>폴더</h3>", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("<h3>메뉴</h3>", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("<h3>상태</h3>", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("재시작하면 적용됩니다.", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-design-folder-name\">앱 기본값", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-resource-folder-name\">앱 기본값", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-background-folder-name\">자동", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-navigation-expanded\"", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("메뉴 펼치기", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("settings-status-badge.valid", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("settings-status-badge.invalid", Styles, StringComparison.Ordinal);
|
||||
|
||||
Assert.DoesNotContain("settings-intro", Markup, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("이 PC에서 사용할 디자인·설정·배경 폴더", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("경로를 직접 입력하지 않고", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("송출 장면(.t2s)과 장면별 디자인 자산", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("핵심 자산 확인됨", Markup, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -67,6 +96,63 @@ public sealed class LegacyOperatorSettingsWebContractTests
|
||||
Assert.DoesNotContain("displayName", dispatch, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceSettingsExposeThreeConciseClosedChoices()
|
||||
{
|
||||
var appearance = Slice(
|
||||
Markup,
|
||||
"<article class=\"settings-card settings-appearance-card\">",
|
||||
"<article class=\"settings-card\">");
|
||||
|
||||
Assert.Contains("<h3>화면</h3>", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-color-theme\"", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"system\">자동", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"light\">라이트", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"dark\">다크", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-view-mode\"", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"automatic\">자동", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"compact\">컴팩트", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"cards\">카드", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-start-workspace\"", appearance,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"stockCut\">종목·컷", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"lastWorkspace\">마지막 화면", appearance,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<p", appearance, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("<small", appearance, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceSettingsApplyClosedValuesAndDispatchOneCompleteSnapshot()
|
||||
{
|
||||
var render = Slice(App, "function normalizeOperatorAppearanceValue", "function render(state)");
|
||||
var dispatch = Slice(
|
||||
App,
|
||||
"Object.keys(operatorFolderControls).forEach(function (kind)",
|
||||
"document.addEventListener(\"input\"");
|
||||
|
||||
Assert.Contains("definition.values.includes(value)", render, StringComparison.Ordinal);
|
||||
Assert.Contains("element.dataset.colorTheme = appearance.colorTheme", render,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("element.dataset.viewMode = appearance.viewMode", render,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("element.dataset.startWorkspace = appearance.startWorkspace", render,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("operatorAppearanceControls[key].element.disabled = isBusy", render,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("applyStartPreference(appearance.startWorkspace)", render,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("isInitialSettingsSnapshot", render, StringComparison.Ordinal);
|
||||
Assert.Contains("send(\"set-operator-appearance\", {", dispatch,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("colorTheme: appearance.colorTheme", dispatch, StringComparison.Ordinal);
|
||||
Assert.Contains("viewMode: appearance.viewMode", dispatch, StringComparison.Ordinal);
|
||||
Assert.Contains("startWorkspace: appearance.startWorkspace", dispatch,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("syncOperatorAppearanceControls(operatorAppearanceSnapshot)", dispatch,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedSettingsStateKeepsStaticControlsAndAppliesNavigationByRevision()
|
||||
{
|
||||
@@ -96,7 +182,7 @@ public sealed class LegacyOperatorSettingsWebContractTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsShowsSafeDiagnosticsAndNamesProtectedValues()
|
||||
public void SettingsKeepsSafeDiagnosticsWithoutProtectedValueExplanations()
|
||||
{
|
||||
foreach (var id in new[]
|
||||
{
|
||||
@@ -114,10 +200,26 @@ public sealed class LegacyOperatorSettingsWebContractTests
|
||||
Assert.Contains("id=\"operator-navigation-expanded\"", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("databasePollingSeconds", App, StringComparison.Ordinal);
|
||||
Assert.Contains("DB 자격 증명", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("Tornado2 송출 대상", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("Live/DryRun 권한", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("장면 허용 목록", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("LegacyOperatorFolderValidator.TryValidate", SettingsHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("CanReadSelectedDatabaseProfile", SettingsHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("ResolveDatabaseHealthLabel", SettingsHost,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("\"저장됨\"", SettingsHost, StringComparison.Ordinal);
|
||||
Assert.Contains("\"저장됨 · 재시작 필요\"", SettingsHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("\"초기화됨 · 재시작 필요\"", SettingsHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("\"사용 가능\"", SettingsHost, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("외부 영상", SettingsHost, StringComparison.Ordinal);
|
||||
|
||||
Assert.DoesNotContain("보호되는 운영 설정", Markup, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DB 자격 증명", Markup, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Tornado2 송출 대상", Markup, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Live/DryRun 권한", Markup, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("장면 허용 목록", Markup, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Slice(string source, string startMarker, string endMarker)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||
|
||||
public sealed class LegacyPackageAppearanceSmokeContractTests
|
||||
{
|
||||
private static readonly string Root = FindRepositoryRoot();
|
||||
private static readonly string Wrapper = Read(
|
||||
"scripts", "Invoke-LegacyPackageAppearanceSmoke.ps1");
|
||||
private static readonly string Harness = Read(
|
||||
"scripts", "Test-LegacyPackageAppearanceSmoke.mjs");
|
||||
|
||||
[Fact]
|
||||
public void Dedicated_runner_reuses_exact_debug_package_dryrun_and_close_guards()
|
||||
{
|
||||
Assert.Contains("-BuildFlavor DebugAppX", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("-DefinitionsOnly", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("Get-ExactDevelopmentPackage", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("Assert-LocalConfigurationIsDryRun", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Assert-NoTornadoConnection", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("Start-ExactAppearanceApplication $registration 1", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Start-ExactAppearanceApplication $registration 2", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(2, CountOccurrences(Wrapper, "Close-ExactAppearanceApplication $"));
|
||||
Assert.DoesNotContain("Stop-Process", Wrapper,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("taskkill", Wrapper,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotMatch(@"(?i)C:\\Users\\[^\\]+", Wrapper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Three_selects_use_trusted_windows_mouse_and_keyboard_once_per_phase()
|
||||
{
|
||||
Assert.Contains("SendApplicationClick(", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("SendClosedSelectKeys(", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("[FieldOffset(0)] public MouseInput Mouse", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[FieldOffset(0)] public KeyboardInput Keyboard", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[FieldOffset(0)] public HardwareInput Hardware", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("IntPtr.Size == 8 ? 40 : 28", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("inputSize != ExpectedInputStructureBytes()", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("event.isTrusted === true", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains("trustedChange: true", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains("physicalKeyCounts:", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains("home: ack.homeKeyCalls", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains("arrowDown: ack.arrowDownKeyCalls", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("enter: ack.enterKeyCalls", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains("A native HTML <select> popup consumes Home/ArrowDown/Enter", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("trustedKeyDowns.length >= expectedKeyCalls", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("trustedKeyDownCount -lt", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("selection.physicalKeyCounts.home -ne 1", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("selection.physicalKeyCounts.arrowDown -ne $expectedDownCount", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("selection.physicalKeyCounts.enter -ne 1", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("exchange.physicalInput.change.isTrusted -ne $true", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("exchange.physicalInput.change.value -cne", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("exchange.physicalKeyCounts.arrowDown -ne", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("nextExchangeSequence !== 5", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("evidence.exchanges.length !== 4", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"[\"colorTheme\", \"viewMode\", \"startWorkspace\"]",
|
||||
Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("inputRetryCount: 0", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains("inputRetryCount = 0", Wrapper, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Harness_has_no_synthetic_input_or_appearance_bridge_post()
|
||||
{
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"Input.dispatchMouseEvent",
|
||||
"Input.dispatchKeyEvent",
|
||||
"Input.insertText"
|
||||
})
|
||||
{
|
||||
Assert.Contains($"\"{method}\"", Harness, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain($"rpc(\"{method}\"", Harness,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
Assert.DoesNotMatch(
|
||||
@"postMessage\s*\(\s*\{\s*type:\s*['""]set-operator-appearance",
|
||||
Harness);
|
||||
Assert.DoesNotMatch(
|
||||
@"(?:\.value\s*=(?!=)|dispatchEvent\s*\(|\.click\s*\(|localStorage\.setItem)",
|
||||
Harness);
|
||||
Assert.Contains(
|
||||
"window.chrome.webview.postMessage({ type: \"ready\", payload: {} })",
|
||||
Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("outbound.length !== 1", Harness, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Relaunch_proves_persisted_start_behavior_before_any_settings_click()
|
||||
{
|
||||
Assert.Contains("persisted-appearance-after-relaunch", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("persisted start workspace behavior", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"snapshot.dom.activeWorkspace === expectedStartupWorkspace",
|
||||
Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("startWorkspaceBehaviorVerifiedBeforeInput = true", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("startWorkspaceBehaviorVerifiedBeforeInput -ne $true", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("persistenceVerifiedBeforeInput", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Original_settings_have_exact_raw_recovery_for_present_and_absent_baselines()
|
||||
{
|
||||
Assert.Contains("Initialize-ExactBaselineRecovery", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("operatorSettingsOriginallyAbsent", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Restore-ExactBaselineSettings", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("BaselineRestoreAttempted", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("restoration is one-shot and was already attempted", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[IO.File]::Replace(", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("[IO.File]::Delete($OperatorSettingsPath)", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("exactOriginalBytesRestored = $true", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("recoveryCopyRemoved", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("The application was intentionally left open", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("no input retry, close retry,", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("one-shot settings restoration had an unclear or failed result", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("retried. Preserve the recovery evidence", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evidence_is_bounded_create_new_and_hash_sealed()
|
||||
{
|
||||
Assert.Contains("flag: \"wx\"", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains("$item.Length -gt 512KB", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[IO.FileMode]::CreateNew", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("$stream.Flush($true)", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("phaseEvidenceSha256", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("screenshotSha256", Wrapper, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Read(params string[] segments) =>
|
||||
File.ReadAllText(Path.Combine([Root, .. segments]));
|
||||
|
||||
private static int CountOccurrences(string value, string token) =>
|
||||
value.Split(token, StringSplitOptions.None).Length - 1;
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,107 @@ public sealed class LegacyPackageInputSmokeContractTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicalPointerCoordinatesSupportDpiAwareAndVirtualizedWindowsHosts()
|
||||
{
|
||||
var geometry = Slice(
|
||||
Runner,
|
||||
"private static ClientGeometry ReadAndAssertClientGeometry(",
|
||||
"private static void AssertStableClientGeometry(");
|
||||
foreach (var token in new[]
|
||||
{
|
||||
"bool deviceMatches =",
|
||||
"bool logicalMatches =",
|
||||
"if (!deviceMatches && !logicalMatches)",
|
||||
"double cssScaleX = client.Right / viewportCssWidth;",
|
||||
"double cssScaleY = client.Bottom / viewportCssHeight;",
|
||||
"CssScaleX = cssScaleX",
|
||||
"CssScaleY = cssScaleY"
|
||||
})
|
||||
{
|
||||
Assert.Contains(token, geometry, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var conversion = Slice(
|
||||
Runner,
|
||||
"private static Point CssPointToScreen(",
|
||||
"private static void SendMousePath(");
|
||||
Assert.Contains("cssX * geometry.CssScaleX", conversion,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("cssY * geometry.CssScaleY", conversion,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("cssX * devicePixelRatio", conversion,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("cssY * devicePixelRatio", conversion,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var drag = Slice(
|
||||
Runner,
|
||||
"public static void SendRowHeaderDragAfterThenHome(",
|
||||
"public static void SendShiftClickRangeThenDeleteAndRestore(");
|
||||
Assert.Contains("12.0 * baseline.CssScaleX", drag,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("12.0 * devicePixelRatio", drag,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkspaceSplitterUsesThePointerPipelinePersistsAndRestoresItsWidth()
|
||||
{
|
||||
var splitter = Slice(
|
||||
Harness,
|
||||
"function assertSplitterResizeResult(",
|
||||
"async function ensureWorkspace(");
|
||||
AssertInOrder(
|
||||
splitter,
|
||||
"await pressMouse(start, 0);",
|
||||
"await moveMouse(target, 0, 1);",
|
||||
"await releaseMouse(target, 0);",
|
||||
"async function exerciseWorkspaceSplitter(baseline)",
|
||||
"dragWorkspaceSplitterToPercent(",
|
||||
"pressKey(keyboardKey, \"resize workspace splitter from keyboard\")",
|
||||
"const restoredLayout = await dragWorkspaceSplitterToPercent(",
|
||||
"assertFixedSchedule(baseline, restoredLayout",
|
||||
"storedPercent: Number.parseFloat(restoredLayout.storedScheduleWidth)");
|
||||
Assert.Contains("splitterState?.valueNow", splitter,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"Math\.abs\(layout\.splitterState\.valueNow -\s*" +
|
||||
@"Number\(layout\.scheduleWidthPercent\.toFixed\(1\)\)\) > 0\.001",
|
||||
splitter);
|
||||
Assert.DoesNotMatch(
|
||||
@"splitterState\??\.valueNow\s*!==\s*" +
|
||||
@"Math\.round\(layout\.scheduleWidthPercent\)",
|
||||
splitter);
|
||||
Assert.Contains("outboundMessages.length", splitter,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Input.dispatchMouseEvent", splitter,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".click(", splitter, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("localStorage.setItem", splitter,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("style.setProperty", splitter,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"ArrowLeft: { key: \"ArrowLeft\", code: \"ArrowLeft\", virtualKey: 37 }",
|
||||
Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"ArrowRight: { key: \"ArrowRight\", code: \"ArrowRight\", virtualKey: 39 }",
|
||||
Harness,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var sequence = Slice(
|
||||
Harness,
|
||||
"async function verifyWorkspaceNavigationAndLayout(preflight)",
|
||||
"async function playlistDropGeometry(label)");
|
||||
AssertInOrder(
|
||||
sequence,
|
||||
"const splitterResize = await exerciseWorkspaceSplitter(baseline);",
|
||||
"collapse workspace menu",
|
||||
"splitterResize,");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DryRunFinalSealKeepsWindowsDragOrderAndRequiresTheSafeTailAppend()
|
||||
{
|
||||
@@ -846,6 +947,11 @@ public sealed class LegacyPackageInputSmokeContractTests
|
||||
Assert.Contains("[IO.Compression.ZipFile]::OpenRead($msix)", guard,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/app.js'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/index.html'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/playout-ui.js'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/styles.css'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/workspace-navigation.js'", guard,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Get-StreamSha256Hex $entryStream", guard,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("registered Debug AppX is stale", guard,
|
||||
|
||||
@@ -81,7 +81,7 @@ public sealed class LegacyPackageLocalStateSmokeContractTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comparison_import_requires_exact_source_one_row_baseline_and_idempotence()
|
||||
public void Comparison_import_requires_exact_source_known_baseline_and_idempotence()
|
||||
{
|
||||
Assert.Contains(
|
||||
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2",
|
||||
@@ -91,10 +91,27 @@ public sealed class LegacyPackageLocalStateSmokeContractTests
|
||||
"13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087",
|
||||
Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("receipt?.addedPairCount === 8", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"2575C2420800670B0022EBEE819BA3D9640DB14E9552426DF46075D079F1F697",
|
||||
Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("baselineMode === \"fresh\" ? 8 : 0", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("baselineMode === \"fresh\" ? 0 : 8", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("receipt?.addedPairCount === expectedFirstAdded", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("receipt?.skippedDuplicateCount === expectedFirstSkipped", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("receipt?.addedPairCount === 0", Harness, StringComparison.Ordinal);
|
||||
Assert.Contains("receipt?.skippedDuplicateCount === 8", Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"initialDestinationMode = isFresh ? \"fresh\" : \"alreadyImported\"",
|
||||
Harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("$script:DestinationBaselineMode = if ($isFresh)", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(
|
||||
2,
|
||||
CountOccurrences(
|
||||
|
||||
@@ -134,6 +134,46 @@ public sealed class LegacyPlayoutWebContractTests
|
||||
Assert.Contains("마지막 항목", Script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusStateUsesFailSafePriorityAndIsReassignedOnEveryRender()
|
||||
{
|
||||
var resolver = ExtractFunction(Script, "function statusState(playout, busy)");
|
||||
|
||||
foreach (var state in new[]
|
||||
{
|
||||
"outcome-unknown",
|
||||
"busy",
|
||||
"error",
|
||||
"program",
|
||||
"prepared",
|
||||
"idle"
|
||||
})
|
||||
{
|
||||
Assert.Contains($"\"{state}\"", resolver, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
resolver.IndexOf("playout.outcomeUnknown === true", StringComparison.Ordinal) <
|
||||
resolver.IndexOf("if (busy)", StringComparison.Ordinal),
|
||||
"OutcomeUnknown must remain visually dominant while a command is busy.");
|
||||
Assert.True(
|
||||
resolver.IndexOf("if (busy)", StringComparison.Ordinal) <
|
||||
resolver.IndexOf("playout.mode === \"disabled\"", StringComparison.Ordinal),
|
||||
"Busy must win over an unavailable engine after OutcomeUnknown is excluded.");
|
||||
Assert.True(
|
||||
resolver.IndexOf("playout.mode === \"disabled\"", StringComparison.Ordinal) <
|
||||
resolver.IndexOf("playout.phase === \"program\"", StringComparison.Ordinal),
|
||||
"Engine errors must win over ordinary phase styling.");
|
||||
Assert.Contains(
|
||||
"status.dataset.state = statusState(playout, busy);",
|
||||
Script,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"status.dataset.state = \"error\";",
|
||||
Script,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string value)
|
||||
{
|
||||
var count = 0;
|
||||
@@ -156,6 +196,28 @@ public sealed class LegacyPlayoutWebContractTests
|
||||
return source[start..(end + closingTag.Length)];
|
||||
}
|
||||
|
||||
private static string ExtractFunction(string source, string signature)
|
||||
{
|
||||
var start = source.IndexOf(signature, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Function '{signature}' was not found.");
|
||||
var bodyStart = source.IndexOf('{', start);
|
||||
Assert.True(bodyStart >= 0, $"Function '{signature}' has no body.");
|
||||
var depth = 0;
|
||||
for (var index = bodyStart; index < source.Length; index++)
|
||||
{
|
||||
if (source[index] == '{')
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
else if (source[index] == '}' && --depth == 0)
|
||||
{
|
||||
return source[start..(index + 1)];
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidDataException($"Function '{signature}' is incomplete.");
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
@@ -13,6 +13,8 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
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 App = File.ReadAllText(Path.Combine(WebRoot, "app.js"));
|
||||
private static readonly string Playout = File.ReadAllText(
|
||||
Path.Combine(WebRoot, "playout-ui.js"));
|
||||
private static readonly string Navigation = File.ReadAllText(
|
||||
Path.Combine(WebRoot, "workspace-navigation.js"));
|
||||
|
||||
@@ -26,6 +28,7 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
var stockStart = Markup.IndexOf("id=\"stock-workspace\"", StringComparison.Ordinal);
|
||||
var catalogStart = Markup.IndexOf("id=\"catalog-workspace\"", StringComparison.Ordinal);
|
||||
var settingsStart = Markup.IndexOf("id=\"settings-workspace\"", StringComparison.Ordinal);
|
||||
var splitterStart = Markup.IndexOf("id=\"workspace-splitter\"", StringComparison.Ordinal);
|
||||
var scheduleStart = Markup.IndexOf("class=\"playlist-panel\"", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(workspaceStart >= 0);
|
||||
@@ -35,14 +38,27 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Assert.True(stockStart > settingsTabStart);
|
||||
Assert.True(catalogStart > stockStart);
|
||||
Assert.True(settingsStart > catalogStart);
|
||||
Assert.True(scheduleStart > settingsStart);
|
||||
Assert.True(splitterStart > settingsStart);
|
||||
Assert.True(scheduleStart > splitterStart);
|
||||
Assert.Matches(
|
||||
@"<\/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""",
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
@"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""",
|
||||
Markup);
|
||||
Assert.Matches("id=\"catalog-workspace\"[^>]*hidden inert", Markup);
|
||||
Assert.Matches("id=\"settings-workspace\"[^>]*hidden inert", Markup);
|
||||
Assert.Contains("<small>항상 표시</small><h2>송출 스케줄</h2>", Markup,
|
||||
Assert.Contains("<div><h2>송출 스케줄</h2></div>", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("항상 표시", Markup, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,12 +84,27 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkAndScheduleGiveTheAlwaysVisibleScheduleAWiderResponsiveShare()
|
||||
public void WorkAndScheduleExposeAnAccessibleResizableThreeColumnLayout()
|
||||
{
|
||||
Assert.Contains(
|
||||
"grid-template-columns: minmax(900px, 1.75fr) minmax(600px, 1fr);",
|
||||
Styles,
|
||||
Assert.Matches(
|
||||
@"\.operator-shell\s*\{[^}]*grid-template-columns:\s*" +
|
||||
@"minmax\(780px,\s*1fr\)\s+var\(--workspace-splitter-width\)\s+" +
|
||||
@"minmax\(600px,\s*var\(--schedule-pane-width,[^)]+\)\)",
|
||||
Styles);
|
||||
Assert.Matches(@"--workspace-splitter-width:\s*8px", Styles);
|
||||
Assert.Matches(
|
||||
@"\.workspace-splitter\s*\{[^}]*cursor:\s*col-resize" +
|
||||
@"[^}]*touch-action:\s*none",
|
||||
Styles);
|
||||
Assert.Matches(
|
||||
@"body\.resizing-workspace\s*\{[^}]*cursor:\s*col-resize" +
|
||||
@"[^}]*user-select:\s*none",
|
||||
Styles);
|
||||
Assert.Contains(".workspace-splitter:focus-visible", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"@media \(forced-colors:\s*active\)[\s\S]*?\.workspace-splitter",
|
||||
Styles);
|
||||
Assert.Contains(
|
||||
"grid-template-rows: 66px minmax(0, 1fr) auto;",
|
||||
Styles,
|
||||
@@ -116,12 +147,16 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModernOperatorThemePreservesAccessibleAndSemanticStates()
|
||||
public void UnifiedOperatorThemePreservesAccessibleAndSemanticStates()
|
||||
{
|
||||
Assert.Contains(
|
||||
"Modern operator theme: visual-only overrides; geometry and native contracts stay intact",
|
||||
"Unified operator theme: visual-only rules; geometry and native contracts stay intact.",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Compact broadcast-console components.", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Single(
|
||||
Regex.Matches(Styles, @"(?m)^:root\s*\{").Cast<Match>());
|
||||
Assert.Contains("--ui-canvas:", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("--ui-accent:", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("--ui-success:", Styles, StringComparison.Ordinal);
|
||||
@@ -139,10 +174,6 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Assert.Contains(".playout-actions .take-in:not(:disabled)", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playout-actions .next:not(:disabled)", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playout-actions .take-out:not(:disabled)", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Contemporary density refinement: keep operator geometry, clarify visual hierarchy",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(".stock-result.selected", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-heading-count", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-rows:empty::after", Styles, StringComparison.Ordinal);
|
||||
@@ -188,6 +219,84 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleHeaderAndToolbarExposeClearStatesAndExistingShortcuts()
|
||||
{
|
||||
Assert.Matches(
|
||||
@"class=""playlist-state-legend""[^>]*aria-label=""스케줄 상태""[\s\S]*?" +
|
||||
@"class=""legend-selected""[\s\S]*?>선택</span>[\s\S]*?" +
|
||||
@"class=""legend-next""[\s\S]*?>다음</span>[\s\S]*?" +
|
||||
@"class=""legend-on-air""[\s\S]*?>ON AIR</span>",
|
||||
Markup);
|
||||
Assert.Contains(".playlist-state-legend .legend-selected", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-state-legend .legend-next", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-state-legend .legend-on-air", Styles,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var toolbar = Slice(
|
||||
Markup,
|
||||
"<div class=\"playlist-toolbar\">",
|
||||
"<div id=\"playlist-drop-zone\" class=\"playlist-grid\"");
|
||||
foreach (var group in new[] { "표시", "순서", "배경", "DB", "삭제" })
|
||||
{
|
||||
Assert.Matches(
|
||||
$"class=\"playlist-toolbar-group[^\"]*\"[^>]*aria-label=\"{group}\"",
|
||||
toolbar);
|
||||
}
|
||||
|
||||
Assert.Contains("id=\"moving-average-5\"", toolbar, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"moving-average-20\"", toolbar, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"playlist-move-up\"", toolbar, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"playlist-move-down\"", toolbar, StringComparison.Ordinal);
|
||||
Assert.Matches("id=\"playout-background-none\"[^>]*aria-keyshortcuts=\"F3\"", toolbar);
|
||||
Assert.Matches("id=\"playout-background-file\"[^>]*aria-keyshortcuts=\"F2\"", toolbar);
|
||||
Assert.Matches("id=\"playlist-delete-selected\"[^>]*aria-keyshortcuts=\"Delete\"", toolbar);
|
||||
Assert.Contains("id=\"db-load-button\"", toolbar, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"db-save-button\"", toolbar, StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-toolbar-group", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-toolbar-background", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-toolbar-danger", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("aria-keyshortcuts=\"F8\"", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("aria-keyshortcuts=\"Escape\"", Markup, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchBusyAndEmptyFeedbackRemainAccessibleAndNonBlocking()
|
||||
{
|
||||
Assert.Matches(
|
||||
@"id=""stock-search-meta""[^>]*role=""status""[^>]*aria-live=""polite""" +
|
||||
@"[^>]*aria-atomic=""true""[^>]*hidden",
|
||||
Markup);
|
||||
Assert.Contains(".search-meta", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".stock-results:empty[data-empty-label]::after", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(".cut-list:empty[data-empty-label]::after", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("content: attr(data-empty-label);", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("body.busy::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("@keyframes operator-progress", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("body.busy .operator-shell { pointer-events: none; }", Styles,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("function renderStockSearchMeta(state)", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("label = \"검색 중\";", App, StringComparison.Ordinal);
|
||||
Assert.Contains("stockResults.dataset.emptyLabel = \"결과 없음\";", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("? \"컷 없음\"", App, StringComparison.Ordinal);
|
||||
Assert.Contains(": \"종목을 선택하세요\"", App, StringComparison.Ordinal);
|
||||
Assert.Contains("status.textContent = isBusy ? \"처리 중\"", App,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("status.dataset.state = statusState(playout, busy);", Playout,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("return \"outcome-unknown\";", Playout, StringComparison.Ordinal);
|
||||
Assert.Contains(".playout-status[data-state=\"outcome-unknown\"]", Styles,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkspaceHeaderUsesModernSemanticLockupAndStableNativeStatusTarget()
|
||||
{
|
||||
@@ -198,23 +307,21 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Assert.Matches(
|
||||
@"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""",
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
@"class=""playout-connection-summary""[\s\S]*?<small>송출 연결</small>" +
|
||||
@"[\s\S]*?<strong>Tornado2</strong>[\s\S]*?id=""playout-connection-state""",
|
||||
@"<h1 id=""workspace-title"">종목·컷</h1>",
|
||||
Markup);
|
||||
var header = Slice(Markup, "<header class=\"workspace-header\">", "</header>");
|
||||
Assert.DoesNotContain("<small>", header, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("playout-connection-copy", header, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<strong>Tornado2</strong>", header, StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"id=""playout-connection-state""[^>]*role=""status""" +
|
||||
@"[^>]*aria-label=""Tornado2 연결 상태""[^>]*aria-live=""polite""" +
|
||||
@"[^>]*aria-atomic=""true"">미연결</div>",
|
||||
Markup);
|
||||
Assert.Contains(".workspace-header::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-heading::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".connection-state::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"\.playout-connection-copy small\s*\{[^}]*color:\s*var\(--ui-text-muted\)",
|
||||
@"\.workspace-header::before,\s*\.playlist-heading::before\s*\{\s*display:\s*none;\s*\}",
|
||||
Styles);
|
||||
Assert.Contains(".connection-state::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".connection-state.connected::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
".connection-state.dry-run:not(.outcome-unknown)::before",
|
||||
@@ -222,12 +329,12 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"if (connection.textContent !== nextConnectionText)",
|
||||
File.ReadAllText(Path.Combine(WebRoot, "playout-ui.js")),
|
||||
Playout,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigationChangesPresentationWithoutNativeOrStoredState()
|
||||
public void NavigationStoresOnlyApprovedLocalPresentationState()
|
||||
{
|
||||
Assert.Contains("entry.view.hidden = !isCurrent", Navigation, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
@@ -254,8 +361,143 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Assert.Contains("LegacyWorkspaceNavigation", Navigation, StringComparison.Ordinal);
|
||||
Assert.Contains("nav-collapsed", Navigation, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("postMessage", Navigation, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("localStorage", Navigation, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("sessionStorage", Navigation, StringComparison.Ordinal);
|
||||
var storageKeys = Regex.Matches(
|
||||
Navigation,
|
||||
"\"(mbn-stock-webview\\.[^\"]+)\"")
|
||||
.Select(match => match.Groups[1].Value)
|
||||
.ToArray();
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"mbn-stock-webview.last-workspace.v1",
|
||||
"mbn-stock-webview.schedule-width.v1"
|
||||
},
|
||||
storageKeys);
|
||||
Assert.Contains("window.localStorage && window.localStorage.getItem(key)", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("window.localStorage.setItem(key, value)", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitterClampsPointerAndKeyboardResizingAndEscapeRestoresTheGesture()
|
||||
{
|
||||
foreach (var declaration in new[]
|
||||
{
|
||||
"const scheduleMinimumPercent = 34",
|
||||
"const scheduleMaximumPercent = 48",
|
||||
"const workspaceMinimumPixels = 780",
|
||||
"const scheduleMinimumPixels = 600",
|
||||
"const splitterPixels = 8"
|
||||
})
|
||||
{
|
||||
Assert.Contains(declaration, Navigation, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.Matches(
|
||||
@"const minimum = Math\.max\([\s\S]*?scheduleMinimumPercent," +
|
||||
@"[\s\S]*?scheduleMinimumPixels / width \* 100\)",
|
||||
Navigation);
|
||||
Assert.Matches(
|
||||
@"const maximum = Math\.min\([\s\S]*?scheduleMaximumPercent," +
|
||||
@"[\s\S]*?\(width - workspaceMinimumPixels - splitterPixels\) / width \* 100\)",
|
||||
Navigation);
|
||||
Assert.Contains(
|
||||
"return Math.min(bounds.maximum, Math.max(bounds.minimum, value))",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"shell.style.setProperty(\"--schedule-pane-width\", serialized)",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
foreach (var attribute in new[]
|
||||
{
|
||||
"aria-valuemin", "aria-valuemax", "aria-valuenow", "aria-valuetext"
|
||||
})
|
||||
{
|
||||
Assert.Contains($"splitter.setAttribute(\"{attribute}\"", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
Assert.Contains(
|
||||
"splitter.setAttribute(\"aria-valuemin\", bounds.minimum.toFixed(1))",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"splitter.setAttribute(\"aria-valuemax\", bounds.maximum.toFixed(1))",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"splitter.setAttribute(\"aria-valuenow\", scheduleWidthPercent.toFixed(1))",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotMatch(
|
||||
@"aria-valuemin""[\s\S]{0,80}Math\.ceil|" +
|
||||
@"aria-valuemax""[\s\S]{0,80}Math\.floor",
|
||||
Navigation);
|
||||
|
||||
Assert.Contains(
|
||||
"event.button !== 0 || event.isPrimary === false || splitterGesture",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("splitter.setPointerCapture?.(event.pointerId)", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
foreach (var eventName in new[]
|
||||
{
|
||||
"pointerdown", "pointermove", "pointerup", "pointercancel",
|
||||
"lostpointercapture"
|
||||
})
|
||||
{
|
||||
Assert.Contains($"splitter.addEventListener(\"{eventName}\"", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.Matches(
|
||||
@"event\.key === ""Escape"" && splitterGesture[\s\S]*?" +
|
||||
@"event\.stopImmediatePropagation\(\)[\s\S]*?finishSplitterGesture\(true\)",
|
||||
Navigation);
|
||||
Assert.Contains("event.key === \"ArrowLeft\") next += 2", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("event.key === \"ArrowRight\") next -= 2", Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("event.key === \"Home\") next = numericScheduleBounds().minimum",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("event.key === \"End\") next = numericScheduleBounds().maximum",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"applyScheduleWidth\(\s*restore === true \? gesture\.startPercent" +
|
||||
@" : scheduleWidthPercent,[\s\S]*?restore !== true\)",
|
||||
Navigation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitMenuInputWinsOverDeferredLastWorkspaceRestore()
|
||||
{
|
||||
Assert.Matches(
|
||||
@"stockTab\.addEventListener\(""click"", function \(\) \{\s*" +
|
||||
@"startPreferenceApplied = true;\s*setWorkspace\(""stock""\);\s*\}\)",
|
||||
Navigation);
|
||||
Assert.Matches(
|
||||
@"settingsTab\.addEventListener\(""click"", function \(\) \{\s*" +
|
||||
@"startPreferenceApplied = true;\s*setWorkspace\(""settings""\);\s*\}\)",
|
||||
Navigation);
|
||||
Assert.Matches(
|
||||
@"marketTabs\.addEventListener\(""click"", function \(event\) \{" +
|
||||
@"[\s\S]*?if \(!button \|\| !marketTabs\.contains\(button\)\) return;\s*" +
|
||||
@"startPreferenceApplied = true;\s*syncMarket\(button\.dataset\.tabId\);\s*" +
|
||||
@"setWorkspace\(""catalog""\);",
|
||||
Navigation);
|
||||
}
|
||||
|
||||
private static string Slice(string source, string startMarker, string endMarker)
|
||||
{
|
||||
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Missing start marker: {startMarker}");
|
||||
var end = source.IndexOf(endMarker, start, StringComparison.Ordinal);
|
||||
Assert.True(end > start, $"Missing end marker: {endMarker}");
|
||||
return source[start..end];
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
|
||||
100
tests/Scripts/Test-LegacyPackageAppearanceSmoke.Static.ps1
Normal file
100
tests/Scripts/Test-LegacyPackageAppearanceSmoke.Static.ps1
Normal file
@@ -0,0 +1,100 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
|
||||
$wrapper = Join-Path $repositoryRoot 'scripts\Invoke-LegacyPackageAppearanceSmoke.ps1'
|
||||
$harness = Join-Path $repositoryRoot 'scripts\Test-LegacyPackageAppearanceSmoke.mjs'
|
||||
foreach ($path in @($wrapper, $harness)) {
|
||||
if (-not [IO.File]::Exists($path)) { throw "Missing appearance smoke file: $path" }
|
||||
}
|
||||
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[void][Management.Automation.Language.Parser]::ParseFile(
|
||||
$wrapper,
|
||||
[ref]$tokens,
|
||||
[ref]$errors)
|
||||
if (@($errors).Count -ne 0) { throw 'PowerShell rejected the appearance wrapper.' }
|
||||
|
||||
$node = Join-Path $env:USERPROFILE `
|
||||
'.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'
|
||||
if (-not [IO.File]::Exists($node)) {
|
||||
$node = (Get-Command node.exe -CommandType Application -ErrorAction Stop |
|
||||
Select-Object -First 1).Source
|
||||
}
|
||||
& $node --check $harness
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Node rejected the appearance harness.' }
|
||||
|
||||
$auditJson = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass `
|
||||
-File $wrapper -StaticAudit
|
||||
if ($LASTEXITCODE -ne 0) { throw 'The appearance static audit failed.' }
|
||||
$audit = $auditJson | ConvertFrom-Json
|
||||
if ([string]$audit.result -cne 'PASS' -or
|
||||
[string]$audit.profile -cne 'operator-appearance-persistence' -or
|
||||
[string]$audit.packageFlavor -cne 'DebugAppX' -or
|
||||
[int]$audit.launchCount -ne 2 -or
|
||||
[int]$audit.phaseCount -ne 2 -or
|
||||
[int]$audit.physicalSettingsClickCount -ne 2 -or
|
||||
[int]$audit.physicalSelectCount -ne 6 -or
|
||||
[int]$audit.inputRetryCount -ne 0 -or
|
||||
$audit.rawBaselineRecovery -ne $true -or
|
||||
$audit.packageForceTerminationAllowed -ne $false -or
|
||||
$audit.sendInputClickAvailable -ne $true -or
|
||||
$audit.sendInputSelectKeysAvailable -ne $true -or
|
||||
[int]$audit.inputStructureBytes -ne
|
||||
[int]$audit.expectedInputStructureBytes -or
|
||||
[int]$audit.inputStructureBytes -ne $(if ([IntPtr]::Size -eq 8) { 40 } else { 28 }) -or
|
||||
$audit.inputStructureSizeValidated -ne $true) {
|
||||
throw 'The appearance audit does not expose the exact two-launch input contract.'
|
||||
}
|
||||
|
||||
$wrapperText = [IO.File]::ReadAllText($wrapper)
|
||||
$harnessText = [IO.File]::ReadAllText($harness)
|
||||
foreach ($token in @(
|
||||
'-BuildFlavor DebugAppX',
|
||||
'-DefinitionsOnly',
|
||||
'Start-ExactAppearanceApplication $registration 1',
|
||||
'Start-ExactAppearanceApplication $registration 2',
|
||||
'Restore-ExactBaselineSettings',
|
||||
'[FieldOffset(0)] public MouseInput Mouse',
|
||||
'[FieldOffset(0)] public HardwareInput Hardware',
|
||||
'IntPtr.Size == 8 ? 40 : 28',
|
||||
'operatorSettingsOriginallyAbsent',
|
||||
'startWorkspaceBehaviorVerifiedBeforeInput -ne $true',
|
||||
'The application was intentionally left open')) {
|
||||
if (-not $wrapperText.Contains($token)) { throw "Missing wrapper token: $token" }
|
||||
}
|
||||
foreach ($token in @(
|
||||
'persisted-appearance-after-relaunch',
|
||||
'persisted start workspace behavior',
|
||||
'snapshot.dom.activeWorkspace === expectedStartupWorkspace',
|
||||
'event.isTrusted === true',
|
||||
'trustedChange: true',
|
||||
'physicalKeyCounts:',
|
||||
'A native HTML <select> popup consumes Home/ArrowDown/Enter',
|
||||
'no input was retried')) {
|
||||
if (-not $harnessText.Contains($token)) { throw "Missing harness token: $token" }
|
||||
}
|
||||
if ($harnessText -match
|
||||
'postMessage\s*\(\s*\{\s*type:\s*["'']set-operator-appearance' -or
|
||||
$harnessText.Contains('rpc("Input.dispatchMouseEvent"') -or
|
||||
$harnessText.Contains('rpc("Input.dispatchKeyEvent"') -or
|
||||
$harnessText.Contains('trustedKeyDowns.length >= expectedKeyCalls') -or
|
||||
$wrapperText.Contains('trustedKeyDownCount -lt') -or
|
||||
$wrapperText -match '(?im)^\s*(?:Stop-Process|taskkill)\b' -or
|
||||
$wrapperText -match '(?i)C:\\Users\\[^\\]+') {
|
||||
throw 'The appearance smoke contains a synthetic input or unsafe host contract.'
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
result = 'PASS'
|
||||
launches = 2
|
||||
physicalSettingsClicks = 2
|
||||
physicalSelects = 6
|
||||
persistenceRelaunches = 1
|
||||
exactRawRestores = 1
|
||||
inputRetries = 0
|
||||
} | ConvertTo-Json -Compress
|
||||
@@ -40,9 +40,12 @@ if ([string]$audit.result -cne 'PASS' -or
|
||||
[string]$audit.expectedSourceSha256 -cne
|
||||
'1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2' -or
|
||||
[int]$audit.expectedSourceRows -ne 8 -or
|
||||
[string]$audit.expectedInitialDestinationSha256 -cne
|
||||
[string]$audit.expectedFreshDestinationSha256 -cne
|
||||
'13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087' -or
|
||||
[int]$audit.expectedInitialDestinationRows -ne 1 -or
|
||||
[int]$audit.expectedFreshDestinationRows -ne 1 -or
|
||||
[string]$audit.expectedImportedDestinationSha256 -cne
|
||||
'2575C2420800670B0022EBEE819BA3D9640DB14E9552426DF46075D079F1F697' -or
|
||||
[int]$audit.expectedImportedDestinationRows -ne 9 -or
|
||||
$audit.sendInputClickAvailable -ne $true -or
|
||||
$audit.sendInputEscapeAvailable -ne $true) {
|
||||
throw 'The static audit does not expose the closed input/file contract.'
|
||||
@@ -79,7 +82,8 @@ foreach ($token in @(
|
||||
'"choose-operator-folder"',
|
||||
'"folder-picker-click-cancel"',
|
||||
'"observe-local-files"',
|
||||
'receipt?.addedPairCount === 8',
|
||||
'receipt?.addedPairCount === expectedFirstAdded',
|
||||
'receipt?.skippedDuplicateCount === expectedFirstSkipped',
|
||||
'receipt?.addedPairCount === 0',
|
||||
'process.stdin.unref',
|
||||
'allowMissingState: true',
|
||||
@@ -100,6 +104,7 @@ if ($wrapperText -match '(?im)^\s*(?:Remove-Item|Stop-Process|taskkill)\b' -or
|
||||
physicalClicks = 9
|
||||
pickerCancels = 3
|
||||
observations = 4
|
||||
importAdded = 8
|
||||
freshImportAdded = 8
|
||||
alreadyImportedAdded = 0
|
||||
idempotentAdded = 0
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
Reference in New Issue
Block a user