diff --git a/Assets/StartupHero.png b/Assets/StartupHero.png new file mode 100644 index 0000000..3eb175e Binary files /dev/null and b/Assets/StartupHero.png differ diff --git a/scripts/Invoke-LegacyPackageAppearanceSmoke.ps1 b/scripts/Invoke-LegacyPackageAppearanceSmoke.ps1 index 2b30b12..d867522 100644 --- a/scripts/Invoke-LegacyPackageAppearanceSmoke.ps1 +++ b/scripts/Invoke-LegacyPackageAppearanceSmoke.ps1 @@ -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) diff --git a/scripts/Invoke-LegacyPackageInputSmoke.ps1 b/scripts/Invoke-LegacyPackageInputSmoke.ps1 index 62c1c3d..a65cc0c 100644 --- a/scripts/Invoke-LegacyPackageInputSmoke.ps1 +++ b/scripts/Invoke-LegacyPackageInputSmoke.ps1 @@ -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 ` diff --git a/scripts/Test-LegacyPackageInput.mjs b/scripts/Test-LegacyPackageInput.mjs index 4af4f14..4e5b110 100644 --- a/scripts/Test-LegacyPackageInput.mjs +++ b/scripts/Test-LegacyPackageInput.mjs @@ -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; } diff --git a/scripts/Test-LegacyPackageUiOnlySmoke.mjs b/scripts/Test-LegacyPackageUiOnlySmoke.mjs index 9f70c66..b494a79 100644 --- a/scripts/Test-LegacyPackageUiOnlySmoke.mjs +++ b/scripts/Test-LegacyPackageUiOnlySmoke.mjs @@ -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."); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj index 334cdf5..f3cf9f1 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -86,6 +86,8 @@ + + @@ -95,6 +97,7 @@ PreserveNewest + @@ -126,6 +129,12 @@ PreserveNewest PreserveNewest + + Web\Brand\%(Filename)%(Extension) + Web\Brand\%(Filename)%(Extension) + PreserveNewest + PreserveNewest +