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
+