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:
@@ -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)
|
||||
|
||||
@@ -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 `
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user