feat: refine branded operator experience

Add the MBN-branded startup intro, semantic sidebar icons, and refined card-based operator visuals. Strengthen packaged UI and input smoke coverage for the updated experience.
This commit is contained in:
2026-07-24 01:26:13 +09:00
parent f43483533a
commit b050f2f06c
25 changed files with 2316 additions and 164 deletions

View File

@@ -468,6 +468,7 @@ function Restore-ExactBaselineSettings {
if ([string]::IsNullOrWhiteSpace($directory)) {
Throw-SafeFailure 'The operator settings restore directory is invalid.'
}
$replacementBackupPath = $null
if ($script:BaselineSettingsState.exists -eq $true) {
if (-not $script:BaselineRecoveryCreated -or
-not [IO.File]::Exists($script:BaselineRecoveryPath)) {
@@ -505,7 +506,14 @@ function Restore-ExactBaselineSettings {
[IO.FileAttributes]::Device)) -ne 0) {
Throw-SafeFailure 'The operator settings restore target became unsafe.'
}
[IO.File]::Replace($temporaryPath, $OperatorSettingsPath, $null, $true)
$replacementBackupPath = Join-Path $directory (
'.runtime-folders.local.json.' +
[Guid]::NewGuid().ToString('N') + '.restore.backup')
[IO.File]::Replace(
$temporaryPath,
$OperatorSettingsPath,
$replacementBackupPath,
$true)
}
else {
[IO.File]::Move($temporaryPath, $OperatorSettingsPath)
@@ -549,6 +557,16 @@ function Restore-ExactBaselineSettings {
[string]$script:BaselineRecoverySha256) {
Throw-SafeFailure 'The original-absence recovery marker changed before cleanup.'
}
if ($null -ne $replacementBackupPath -and
[IO.File]::Exists($replacementBackupPath)) {
$replacementBackup = Get-Item -LiteralPath $replacementBackupPath -Force
if ($replacementBackup.PSIsContainer -or
($replacementBackup.Attributes -band ([IO.FileAttributes]::ReparsePoint -bor
[IO.FileAttributes]::Device)) -ne 0) {
Throw-SafeFailure 'The operator settings replacement backup became unsafe.'
}
[IO.File]::Delete($replacementBackupPath)
}
if ($script:BaselineRecoveryCreated -and
[IO.File]::Exists($script:BaselineRecoveryPath)) {
[IO.File]::Delete($script:BaselineRecoveryPath)
@@ -1091,6 +1109,7 @@ function Start-ExactAppearanceApplication(
$application `
([datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds)))
Assert-NoTornadoConnection $application.Id
Wait-StartupIntroDismissed $application $Registration.Executable
$script:ApplicationProcess = $application
$script:ApplicationWasLaunched = $true
$script:CurrentApplicationClosed = $false
@@ -1161,7 +1180,9 @@ $timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ')
$Output = Resolve-EvidencePath $Output "legacy-package-appearance-$timestamp.json"
$Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-appearance-$timestamp.png"
if ([string]::IsNullOrWhiteSpace($ExchangeDirectory)) {
$ExchangeDirectory = [IO.Path]::ChangeExtension($Output, $null) + '.exchanges'
$ExchangeDirectory = Join-Path `
([IO.Path]::GetDirectoryName($Output)) `
([IO.Path]::GetFileNameWithoutExtension($Output) + '.exchanges')
}
elseif ([IO.Path]::IsPathRooted($ExchangeDirectory)) {
$ExchangeDirectory = [IO.Path]::GetFullPath($ExchangeDirectory)

View File

@@ -1798,11 +1798,14 @@ function Assert-DebugAppXMatchesLatestPackage([string]$InstallLocation) {
$archive = [IO.Compression.ZipFile]::OpenRead($msix)
try {
$expectedFiles = @(
'AppxManifest.xml',
'Assets/StartupHero.png',
'MBN_STOCK_WEBVIEW.Core.dll',
'MBN_STOCK_WEBVIEW.Infrastructure.dll',
'MBN_STOCK_WEBVIEW.LegacyApplication.dll',
'MBN_STOCK_WEBVIEW.LegacyBridge.dll',
'MBN_STOCK_WEBVIEW.LegacyParityApp.dll',
'Web/Brand/logo.png',
'Web/app.js',
'Web/index.html',
'Web/playout-ui.js',
@@ -1833,12 +1836,19 @@ function Assert-DebugAppXMatchesLatestPackage([string]$InstallLocation) {
$archive.Dispose()
}
$sourceAppJs = Join-Path $projectRoot 'Web\app.js'
$installedAppJs = Join-Path $InstallLocation 'Web\app.js'
if (-not [IO.File]::Exists($sourceAppJs) -or
(Get-FileHash -LiteralPath $sourceAppJs -Algorithm SHA256).Hash -cne
(Get-FileHash -LiteralPath $installedAppJs -Algorithm SHA256).Hash) {
Throw-SafeFailure 'The registered Debug AppX does not contain the current Web application source.'
foreach ($relativeSource in @(
'Web\app.js',
'Web\index.html',
'Web\styles.css',
'Web\workspace-navigation.js')) {
$sourcePath = Join-Path $projectRoot $relativeSource
$installedPath = Join-Path $InstallLocation $relativeSource
if (-not [IO.File]::Exists($sourcePath) -or
-not [IO.File]::Exists($installedPath) -or
(Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash -cne
(Get-FileHash -LiteralPath $installedPath -Algorithm SHA256).Hash) {
Throw-SafeFailure 'The registered Debug AppX does not contain the current Web application source.'
}
}
}
@@ -3061,6 +3071,60 @@ function Assert-ExactApplicationStillRunning(
}
}
function Wait-StartupIntroDismissed(
[Diagnostics.Process]$Application,
[string]$ExpectedPath) {
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
$deadline = [datetime]::UtcNow.AddSeconds(4)
$nameCondition = [Windows.Automation.PropertyCondition]::new(
[Windows.Automation.AutomationElement]::NameProperty,
'매일경제TV V-STOCK 시작 화면')
while ($true) {
Assert-ExactApplicationStillRunning $Application $ExpectedPath
Assert-NoTornadoConnection $Application.Id
$Application.Refresh()
$root = [Windows.Automation.AutomationElement]::FromHandle(
$Application.MainWindowHandle)
if ($null -eq $root) {
Throw-SafeFailure 'The main window is unavailable while waiting for the startup intro.'
}
$intro = $root.FindFirst(
[Windows.Automation.TreeScope]::Descendants,
$nameCondition)
$introIsVisible = $false
if ($null -ne $intro) {
try {
$introIsVisible = -not $intro.Current.IsOffscreen
}
catch [System.Windows.Automation.ElementNotAvailableException] {
$introIsVisible = $false
}
}
if (-not $introIsVisible) {
Assert-ExactApplicationStillRunning $Application $ExpectedPath
Assert-NoTornadoConnection $Application.Id
return
}
if ([datetime]::UtcNow -ge $deadline) {
break
}
$remainingMilliseconds = [int][Math]::Max(
1,
[Math]::Min(50, ($deadline - [datetime]::UtcNow).TotalMilliseconds))
Start-Sleep -Milliseconds $remainingMilliseconds
}
Assert-ExactApplicationStillRunning $Application $ExpectedPath
Assert-NoTornadoConnection $Application.Id
Throw-SafeFailure 'The startup intro remained visible beyond its four-second bound.'
}
function Assert-NoExistingContentDialog([IntPtr]$MainWindowHandle) {
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
@@ -4091,6 +4155,9 @@ try {
$script:ApplicationProcess `
([datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds)))
Assert-NoTornadoConnection $script:ApplicationProcess.Id
Wait-StartupIntroDismissed `
$script:ApplicationProcess `
$registration.Executable
$script:Phase = 'real input harness'
$harnessRun = Invoke-NodeHarnessUnderTcpWatch `

View File

@@ -9,6 +9,18 @@ const legacyCutDoubleClickWindowMilliseconds = 500;
const defaultObservationTimeoutMilliseconds = 45_000;
const minimumObservationTimeoutMilliseconds = 5_000;
const maximumObservationTimeoutMilliseconds = 900_000;
const expectedWorkspaceIconByTabId = Object.freeze({
overseas: "overseas",
exchange: "exchange",
index: "index",
kospiIndustry: "kospi",
kosdaqIndustry: "kosdaq",
comparison: "comparison",
theme: "theme",
overseasStocks: "overseas-stocks",
expert: "expert",
tradingHalt: "trading-halt"
});
const acceptedArgumentNames = new Set([
"profile",
"scope",
@@ -420,6 +432,7 @@ const configuration = parseArguments(process.argv.slice(2));
const fullUiDbProfile = configuration.profile === "full-ui-db";
const dryRunPlayoutProfile = configuration.profile === "dry-run-playout";
const programPlaylistDbProfile = configuration.profile === "program-playlist-db";
const readOnlyProfile = configuration.profile === "read-only";
const databaseWriteProfile = fullUiDbProfile || programPlaylistDbProfile;
const activeDryRunProfile = dryRunPlayoutProfile || programPlaylistDbProfile;
const forbiddenControlIds = fullUiDbProfile
@@ -1265,6 +1278,62 @@ async function readWorkspaceLayout() {
height: bounds.height
};
}
function icon(container) {
const svg = container?.matches?.("svg.workspace-nav-icon[data-icon]")
? container
: container?.querySelector?.("svg.workspace-nav-icon[data-icon]");
const bounds = rectangle(svg);
let contentBounds = null;
try {
const box = svg?.getBBox?.();
contentBounds = box ? {
x: box.x,
y: box.y,
width: box.width,
height: box.height
} : null;
} catch (_) {
contentBounds = null;
}
const style = svg ? getComputedStyle(svg) : null;
const painted = Boolean(contentBounds &&
contentBounds.width > 0 && contentBounds.height > 0);
return {
key: svg?.dataset?.icon || null,
reference: svg?.querySelector("use")?.getAttribute("href") || null,
bounds,
painted,
rendered: Boolean(bounds && bounds.width > 0 && bounds.height > 0 &&
painted && style && style.display !== "none" &&
style.visibility !== "hidden" &&
Number.parseFloat(style.opacity) > 0 &&
(style.stroke !== "none" || style.fill !== "none"))
};
}
function navigationItem(key, button) {
const bounds = rectangle(button);
const iconState = icon(button);
const label = button?.querySelector(".workspace-nav-label");
const labelStyle = label ? getComputedStyle(label) : null;
return {
key,
bounds,
icon: iconState,
labelPresent: Boolean(label),
labelVisible: Boolean(labelStyle && labelStyle.display !== "none" &&
labelStyle.visibility !== "hidden"),
iconCenterOffset: bounds && iconState.bounds
? Math.abs(
iconState.bounds.left + iconState.bounds.width / 2 -
(bounds.left + bounds.width / 2))
: null,
iconCenterOffsetY: bounds && iconState.bounds
? Math.abs(
iconState.bounds.top + iconState.bounds.height / 2 -
(bounds.top + bounds.height / 2))
: null
};
}
const shell = document.querySelector(".operator-shell");
const region = document.getElementById("workspace-region");
@@ -1280,6 +1349,7 @@ async function readWorkspaceLayout() {
const settings = document.getElementById("settings-workspace");
const splitter = document.getElementById("workspace-splitter");
const schedule = document.querySelector(".playlist-panel");
const headingIcon = document.querySelector(".workspace-heading-icon");
const publishedScheduleWidth = window.LegacyWorkspaceNavigation?.scheduleWidth?.();
let storedScheduleWidth = null;
try {
@@ -1330,12 +1400,18 @@ async function readWorkspaceLayout() {
activeMarketTab: region?.dataset?.activeMarketTab || null,
currentMenuItemCount: document.querySelectorAll(
"#workspace-navigation .workspace-nav-item[aria-current='page']").length,
navigationItems: [
navigationItem("stock", stockTab),
...marketTabs.map(button => navigationItem(button.dataset.tabId, button)),
navigationItem("settings", settingsTab)
],
headingIcon: icon(headingIcon),
marketTabs: marketTabs.map(button => ({
tabId: button.dataset.tabId,
label: (button.querySelector(".workspace-nav-label")?.textContent || "").trim(),
current: button.getAttribute("aria-current") || null,
focused: document.activeElement === button,
hasIcon: Boolean(button.querySelector("svg"))
icon: icon(button)
})),
stock: {
hidden: stock?.hidden === true,
@@ -1369,7 +1445,36 @@ function workspaceLayoutMatches(layout, workspace, expanded = null) {
if (expanded !== null && layout.navigationExpanded !== expanded) return false;
if (layout.scheduleHitOwned !== true || layout.currentMenuItemCount !== 1 ||
layout.marketTabs?.length !== 10 ||
layout.marketTabs.some(tab => tab.hasIcon !== true)) return false;
layout.navigationItems?.length !== 12) return false;
const expectedIconForKey = key => key === "stock"
? "stock-cut"
: key === "settings"
? "settings"
: expectedWorkspaceIconByTabId[key];
if (layout.navigationItems.some(item => {
const expectedIcon = expectedIconForKey(item.key);
return !expectedIcon || item.icon?.key !== expectedIcon ||
item.icon.reference !== `#workspace-icon-${expectedIcon}` ||
item.icon.rendered !== true || item.labelPresent !== true ||
!Number.isFinite(item.iconCenterOffsetY) ||
item.iconCenterOffsetY > 1.5;
})) return false;
if (layout.navigationExpanded === true &&
layout.navigationItems.some(item => item.labelVisible !== true)) return false;
if (layout.navigationExpanded === false &&
layout.navigationItems.some(item =>
item.labelVisible !== false ||
!Number.isFinite(item.iconCenterOffset) ||
item.iconCenterOffset > 1.5)) return false;
const expectedHeadingIcon = workspace === "stock"
? "stock-cut"
: workspace === "settings"
? "settings"
: expectedWorkspaceIconByTabId[layout.activeMarketTab];
if (!expectedHeadingIcon ||
layout.headingIcon?.key !== expectedHeadingIcon ||
layout.headingIcon.reference !== `#workspace-icon-${expectedHeadingIcon}` ||
layout.headingIcon.rendered !== true) return false;
const visible = view => view?.hidden === false && view.inert === false &&
view.ariaHidden === "false" && view.width > 0 && view.height > 0;
const hidden = view => view?.hidden === true && view.inert === true &&
@@ -1690,7 +1795,10 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
assertFixedSchedule(baseline, restoredMarketTabs.layout,
"restored graphic menu order");
if (!restoredMarketTabs.layout.marketTabs.every(
(tab, index) => tab.tabId === baseline.marketTabs[index].tabId)) {
(tab, index) =>
tab.tabId === baseline.marketTabs[index].tabId &&
tab.icon.key === baseline.marketTabs[index].icon.key &&
tab.icon.reference === baseline.marketTabs[index].icon.reference)) {
failKnown("The vertical graphic menu drag did not restore the original native order.");
}
const beforeSettingsMessageCount =
@@ -1796,6 +1904,11 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
scheduleShare: Number(baselineRatio.scheduleShare.toFixed(4)),
expandedNavigationWidth: baseline.navigation.width,
collapsedNavigationWidth: collapsed.navigation.width,
collapsedIconCenterOffsets: collapsed.navigationItems.map(item => ({
key: item.key,
x: item.iconCenterOffset,
y: item.iconCenterOffsetY
})),
schedule: baseline.schedule,
splitterResize,
graphicMenuCount: baseline.marketTabs.length,
@@ -1812,6 +1925,104 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
});
}
async function verifyVisualCardPresentation() {
const loaded = await selectMainTab(
"overseas",
"open fixed graphics for visual-card evidence");
assertSafety(loaded, "visual-card evidence preflight", false);
const outboundBaseline = loaded.safety.outboundMessageCount;
const metrics = await evaluate(`(() => {
const surface = document.querySelector(".workspace-surface");
const button = document.querySelector(
'button[data-action-id="fixed-001"].visual-card');
const list = button?.closest('[data-visual-card-list="true"]');
const title = button?.querySelector(".visual-card-title");
const preview = button?.querySelector(".visual-card-preview");
const state = button?.querySelector(".visual-card-state");
const kicker = button?.querySelector(".visual-card-kicker");
const wordmark = document.querySelector("img.brand-wordmark");
const product = document.querySelector(".brand-product");
if (!surface || !button || !list || !title || !wordmark || !product) return null;
const listStyle = getComputedStyle(list);
const buttonStyle = getComputedStyle(button);
const wordmarkBounds = wordmark.getBoundingClientRect();
const tracks = listStyle.gridTemplateColumns.trim()
.split(/\\s+/u).filter(Boolean);
return {
viewMode: document.body.dataset.viewMode || null,
context: list.dataset.visualCardContext || null,
surfaceWidth: surface.getBoundingClientRect().width,
listDisplay: listStyle.display,
columnCount: tracks.length,
cardCount: list.querySelectorAll("button.visual-card").length,
cardWidth: button.getBoundingClientRect().width,
cardHeight: button.getBoundingClientRect().height,
cardRadius: Number.parseFloat(buttonStyle.borderRadius),
title: title.textContent.trim(),
ariaLabel: button.getAttribute("aria-label"),
hasPreview: Boolean(preview),
hasState: Boolean(state),
hasKicker: Boolean(kicker),
wordmarkSource: wordmark.getAttribute("src"),
wordmarkAlt: wordmark.getAttribute("alt"),
wordmarkComplete: wordmark.complete,
wordmarkNaturalWidth: wordmark.naturalWidth,
wordmarkNaturalHeight: wordmark.naturalHeight,
wordmarkWidth: wordmarkBounds.width,
wordmarkHeight: wordmarkBounds.height,
brandProduct: product.textContent.trim()
};
})()`);
if (!metrics) failKnown("The fixed graphic visual-card structure is missing.");
if (metrics.wordmarkSource !== "Brand/logo.png" ||
metrics.wordmarkAlt !== "매일경제TV" ||
metrics.wordmarkComplete !== true ||
metrics.wordmarkNaturalWidth !== 176 ||
metrics.wordmarkNaturalHeight !== 43 ||
metrics.wordmarkWidth < 140 || metrics.wordmarkHeight < 33 ||
metrics.brandProduct !== "V-STOCK") {
failKnown("The packaged customer wordmark did not render as the verified V-STOCK lockup.");
}
if (metrics.viewMode === "cards") {
const expectedColumns = metrics.surfaceWidth >= 824
? 3 : metrics.surfaceWidth >= 524 ? 2 : 1;
if (metrics.context !== "fixed-actions" || metrics.listDisplay !== "grid" ||
metrics.columnCount !== expectedColumns || metrics.cardCount < 3 ||
metrics.cardWidth < 160 || metrics.cardHeight < 61.5 ||
metrics.cardRadius < 9 || metrics.title !== "다우" ||
metrics.ariaLabel !== metrics.title || metrics.hasPreview ||
metrics.hasState || metrics.hasKicker) {
failKnown("The rendered visual-card geometry is outside its responsive contract.");
}
await pointerClick(
'document.querySelector(\'button[data-action-id="fixed-001"] .visual-card-title\')',
"select fixed graphic from its visual-card title");
await sleep(100);
const selected = await evaluate(`(() => {
const button = document.querySelector('button[data-action-id="fixed-001"]');
return button ? {
selected: button.classList.contains("tree-node-selected"),
ariaSelected: button.getAttribute("aria-selected"),
focused: document.activeElement === button
} : null;
})()`);
const afterSelection = await readSnapshot();
assertSafety(afterSelection, "visual-card nested-title selection", false);
if (!selected?.selected || selected.ariaSelected !== "true" ||
selected.focused !== true ||
afterSelection.safety.outboundMessageCount !== outboundBaseline) {
failKnown("A visual-card title click did not remain a selection-only input.");
}
}
return await checkpoint("visual-card-presentation", {
...metrics,
nestedTitleSelectionVerified: metrics.viewMode === "cards"
});
}
async function playlistDropGeometry(label) {
const geometry = await evaluate(`(() => {
const zone = document.getElementById("playlist-drop-zone");
@@ -3045,6 +3256,28 @@ async function requestWindowsInput(
failKnown(`${label} DOM geometry or native state changed before publication.`);
}
current = geometryGuard;
// Move away from playlist controls before the native ownership guard runs.
// This dismisses any transient app tooltip without changing selection or
// publishing an operator intent, so source/target hit testing stays exact.
const neutralPoint = {
x: Math.max(8, current.dom.viewport.width / 2),
y: 8
};
await moveMouse(neutralPoint);
await sleep(500);
const neutralGuard = await readSnapshot();
assertSafety(neutralGuard, `${label} neutral-pointer guard`, false);
const neutralSignature = JSON.stringify({
revision: neutralGuard.state.revision,
order: neutralGuard.state.playlist.map(row => row.rowId),
selected: selectedPlaylistIds(neutralGuard),
active: activePlaylistId(neutralGuard),
focused: neutralGuard.dom.activeElement?.playlistRowId ?? null
});
if (neutralSignature !== guardSignature) {
failKnown(`${label} state changed while dismissing transient pointer UI.`);
}
current = neutralGuard;
const token = randomBytes(16).toString("hex").toUpperCase();
const beforeOrder = current.state.playlist.map(row => row.rowId);
const reorderedOrder = beforeOrder.filter(rowId => rowId !== sourceRowId);
@@ -3961,6 +4194,9 @@ async function executeSafeSequence() {
if (!current.dom.namedModalHidden || !current.dom.namedConfirmationHidden) {
failKnown("A named-playlist modal remained open at final state.");
}
if (readOnlyProfile) {
current = await verifyVisualCardPresentation();
}
evidence.finalSnapshot = current;
}

View File

@@ -4,6 +4,18 @@ import { createHash } from "node:crypto";
const exactUrl = "https://legacy-parity.mbn.local/index.html";
const allowedArguments = new Set(["port", "output", "screenshot", "timeout-ms"]);
const expectedWorkspaceIconByTabId = Object.freeze({
overseas: "overseas",
exchange: "exchange",
index: "index",
kospiIndustry: "kospi",
kosdaqIndustry: "kosdaq",
comparison: "comparison",
theme: "theme",
overseasStocks: "overseas-stocks",
expert: "expert",
tradingHalt: "trading-halt"
});
const forbiddenCdpMethods = new Set([
"Browser.close", "Page.close", "Target.closeTarget", "Runtime.terminateExecution",
"Input.dispatchMouseEvent", "Input.dispatchKeyEvent", "Input.insertText"
@@ -273,6 +285,39 @@ async function readShell() {
bounds.left < document.documentElement.clientWidth &&
bounds.top < document.documentElement.clientHeight &&
bounds.display !== "none" && bounds.visibility !== "hidden");
const icon = container => {
const svg = container?.matches?.("svg.workspace-nav-icon[data-icon]")
? container
: container?.querySelector?.("svg.workspace-nav-icon[data-icon]");
const bounds = rectangle(svg);
let contentBounds = null;
try {
const box = svg?.getBBox?.();
contentBounds = box ? {
x: box.x,
y: box.y,
width: box.width,
height: box.height
} : null;
} catch (_) {
contentBounds = null;
}
const style = svg ? getComputedStyle(svg) : null;
const visible = isVisible(svg, bounds);
const painted = Boolean(contentBounds &&
contentBounds.width > 0 && contentBounds.height > 0);
return {
key: svg?.dataset?.icon || null,
reference: svg?.querySelector("use")?.getAttribute("href") || null,
visible,
painted,
rendered: Boolean(visible && painted && style &&
Number.parseFloat(style.opacity) > 0 &&
(style.stroke !== "none" || style.fill !== "none")),
bounds,
contentBounds
};
};
const operatorShell = document.querySelector(".operator-shell");
const workspaceRegion = document.getElementById("workspace-region");
const navigationToggle = document.getElementById("workspace-nav-toggle");
@@ -282,8 +327,14 @@ async function readShell() {
const stockWorkspace = document.getElementById("stock-workspace");
const catalogWorkspace = document.getElementById("catalog-workspace");
const settingsWorkspace = document.getElementById("settings-workspace");
const headingIcon = document.querySelector(".workspace-heading-icon");
const splitter = document.getElementById("workspace-splitter");
const schedule = document.querySelector(".playlist-panel");
const brandWordmark = document.querySelector("img.brand-wordmark");
const brandProduct = document.querySelector(".brand-product");
const brandDivider = document.querySelector(".brand-divider");
const brandWordmarkBounds = rectangle(brandWordmark);
const brandDividerBounds = rectangle(brandDivider);
const shellBounds = rectangle(operatorShell);
const workspaceBounds = rectangle(workspaceRegion);
const splitterBounds = rectangle(splitter);
@@ -305,6 +356,17 @@ async function readShell() {
connectionText: text("playout-connection-state"),
playoutStatus: text("playout-status"),
title: document.title,
brand: {
source: brandWordmark?.getAttribute("src") || null,
alt: brandWordmark?.getAttribute("alt") || null,
complete: brandWordmark?.complete === true,
naturalWidth: brandWordmark?.naturalWidth || 0,
naturalHeight: brandWordmark?.naturalHeight || 0,
rendered: brandWordmarkBounds,
visible: isVisible(brandWordmark, brandWordmarkBounds),
dividerVisible: isVisible(brandDivider, brandDividerBounds),
product: brandProduct?.textContent?.trim() || null
},
controls: {
search: button("stock-search-button"),
prepare: button("playout-prepare"),
@@ -314,20 +376,24 @@ async function readShell() {
},
layout: {
activeWorkspace: workspaceRegion?.dataset?.activeWorkspace || null,
activeMarketTab: workspaceRegion?.dataset?.activeMarketTab || null,
navigationAriaExpanded: navigationToggle?.getAttribute("aria-expanded") || null,
navigationCollapsedClass: workspaceRegion?.classList.contains("nav-collapsed") === true,
navigationExpanded: navigationToggle?.getAttribute("aria-expanded") === "true" &&
workspaceRegion?.classList.contains("nav-collapsed") !== true,
stockTabCurrent: stockTab?.getAttribute("aria-current") || null,
stockTabIcon: icon(stockTab),
marketTabCount: marketTabs.length,
marketTabsCurrent: marketTabs.map(button => ({
tabId: button.dataset.tabId,
label: (button.querySelector(".workspace-nav-label")?.textContent || "").trim(),
current: button.getAttribute("aria-current") || null,
hasIcon: Boolean(button.querySelector("svg")),
icon: icon(button),
hasLabel: Boolean(button.querySelector(".workspace-nav-label"))
})),
settingsTabCurrent: settingsTab?.getAttribute("aria-current") || null,
settingsTabIcon: icon(settingsTab),
headingIcon: icon(headingIcon),
currentMenuItemCount: document.querySelectorAll(
"#workspace-navigation .workspace-nav-item[aria-current='page']").length,
stockWorkspaceVisible: isVisible(stockWorkspace, rectangle(stockWorkspace)) &&
@@ -389,6 +455,18 @@ function assertShell(shell) {
shell.postMessageGuardInstalled !== true || shell.observedMessagesAfterGuard.length !== 0 || shell.blockedMessages.length !== 0) {
fail("KNOWN_FAILURE", "The package shell did not remain rendered DryRun/IDLE with zero guarded native intents.");
}
if (shell.brand?.source !== "Brand/logo.png" ||
shell.brand.alt !== "매일경제TV" ||
shell.brand.complete !== true ||
shell.brand.naturalWidth !== 176 ||
shell.brand.naturalHeight !== 43 ||
shell.brand.visible !== true ||
shell.brand.dividerVisible !== true ||
shell.brand.rendered?.width < 140 ||
shell.brand.rendered?.height < 33 ||
shell.brand.product !== "V-STOCK") {
fail("KNOWN_FAILURE", "The package did not render the verified customer wordmark and V-STOCK lockup.");
}
const layout = shell.layout;
const currentMarkets = layout?.marketTabsCurrent?.filter(tab => tab.current === "page") || [];
const navigationExpansionConsistent = layout &&
@@ -396,7 +474,28 @@ function assertShell(shell) {
(layout.navigationAriaExpanded === "false" && layout.navigationCollapsedClass === true));
const commonNavigationOk = layout && navigationExpansionConsistent &&
layout.marketTabCount === 10 && layout.currentMenuItemCount === 1 &&
layout.marketTabsCurrent.every(tab => tab.hasIcon === true && tab.hasLabel === true);
layout.stockTabIcon?.key === "stock-cut" &&
layout.stockTabIcon.reference === "#workspace-icon-stock-cut" &&
layout.stockTabIcon.rendered === true &&
layout.settingsTabIcon?.key === "settings" &&
layout.settingsTabIcon.reference === "#workspace-icon-settings" &&
layout.settingsTabIcon.rendered === true &&
layout.marketTabsCurrent.every(tab => {
const expectedIcon = expectedWorkspaceIconByTabId[tab.tabId];
return Boolean(expectedIcon) && tab.hasLabel === true &&
tab.icon?.key === expectedIcon &&
tab.icon.reference === `#workspace-icon-${expectedIcon}` &&
tab.icon.rendered === true;
});
const expectedHeadingIcon = layout?.activeWorkspace === "stock"
? "stock-cut"
: layout?.activeWorkspace === "settings"
? "settings"
: expectedWorkspaceIconByTabId[layout?.activeMarketTab];
const headingIconOk = Boolean(expectedHeadingIcon) &&
layout?.headingIcon?.key === expectedHeadingIcon &&
layout.headingIcon.reference === `#workspace-icon-${expectedHeadingIcon}` &&
layout.headingIcon.rendered === true;
const stockWorkspaceOk = layout?.activeWorkspace === "stock" &&
layout.stockTabCurrent === "page" && layout.settingsTabCurrent === "false" &&
currentMarkets.length === 0 && layout.stockWorkspaceVisible === true &&
@@ -412,7 +511,7 @@ function assertShell(shell) {
currentMarkets.length === 1 && layout.stockWorkspaceHidden === true &&
layout.catalogWorkspaceVisible === true && layout.settingsWorkspaceHidden === true &&
layout.workspaceTitle === currentMarkets[0]?.label;
if (!commonNavigationOk ||
if (!commonNavigationOk || !headingIconOk ||
[stockWorkspaceOk, settingsWorkspaceOk, catalogWorkspaceOk].filter(Boolean).length !== 1) {
fail("KNOWN_FAILURE", "The package did not render one internally consistent initial workspace and menu selection.");
}