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:
@@ -85,6 +85,10 @@ test("original operator settings are recoverable and restored byte-for-byte", ()
|
||||
assert.match(wrapper, /restoration is one-shot and was already attempted/u);
|
||||
assert.match(wrapper, /Get-LegacyApplicationProcesses\)\.Count -ne 0/u);
|
||||
assert.match(wrapper, /\[IO\.File\]::Replace\(/u);
|
||||
assert.match(wrapper, /\$replacementBackupPath/u);
|
||||
assert.match(wrapper, /\[IO\.File\]::Delete\(\$replacementBackupPath\)/u);
|
||||
assert.doesNotMatch(wrapper,
|
||||
/\[IO\.File\]::Replace\(\$temporaryPath,\s*\$OperatorSettingsPath,\s*\$null/u);
|
||||
assert.match(wrapper, /\[IO\.File\]::Delete\(\$OperatorSettingsPath\)/u);
|
||||
assert.match(wrapper, /exactOriginalBytesRestored = \$true/u);
|
||||
assert.match(wrapper, /recoveryCopyRemoved/u);
|
||||
@@ -96,6 +100,12 @@ test("original operator settings are recoverable and restored byte-for-byte", ()
|
||||
assert.doesNotMatch(wrapper, /C:\\Users\\[^\\]+/iu);
|
||||
});
|
||||
|
||||
test("default appearance evidence directory has one extension separator", () => {
|
||||
assert.match(wrapper, /\[IO\.Path\]::GetFileNameWithoutExtension\(\$Output\)/u);
|
||||
assert.doesNotMatch(wrapper,
|
||||
/\[IO\.Path\]::ChangeExtension\(\$Output,\s*\$null\)\s*\+\s*'\.exchanges'/u);
|
||||
});
|
||||
|
||||
test("appearance evidence is bounded, sealed, and never overwritten", () => {
|
||||
assert.match(harness, /fs\.writeFileSync\(requestPath, requestBytes, \{ flag: "wx" \}\)/u);
|
||||
assert.match(harness, /fs\.writeFileSync\(configuration\.screenshotPath, bytes, \{ flag: "wx" \}\)/u);
|
||||
|
||||
@@ -95,8 +95,9 @@ test("appearance themes expose explicit light, dark, and Windows-following palet
|
||||
/:root\[data-color-theme="dark"\],[\s\S]*?body\[data-color-theme="dark"\]\s*\{[\s\S]*?color-scheme:\s*dark/);
|
||||
assert.match(styles,
|
||||
/@media \(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?:root\[data-color-theme="system"\],[\s\S]*?body\[data-color-theme="system"\]/);
|
||||
assert.match(styles, /--ui-brand:\s*#dc5826/i);
|
||||
assert.match(styles, /\.brand-lockup strong\s*\{[^}]*color:\s*var\(--ui-brand\)/s);
|
||||
assert.match(styles, /--ui-brand:\s*#f47b20/i);
|
||||
assert.match(styles, /\.brand-wordmark-plate\s*\{[^}]*background:\s*#fff/s);
|
||||
assert.match(styles, /\.brand-wordmark\s*\{[^}]*filter:\s*none/s);
|
||||
assert.match(styles,
|
||||
/\.playlist-state-legend \.legend-selected\s*\{\s*color:\s*var\(--ui-accent\)/);
|
||||
assert.match(styles,
|
||||
@@ -141,8 +142,8 @@ test("light palette keeps hints and small semantic labels readable", () => {
|
||||
assert.ok(contrast(token("ui-warning"), token("ui-warning-soft")) >= 4.5);
|
||||
});
|
||||
|
||||
test("view modes change catalog paint without changing operator row geometry", () => {
|
||||
const start = styles.indexOf("/* View-mode presentation is paint-only:");
|
||||
test("visual card mode creates a responsive graphic-action grid without touching operator rows", () => {
|
||||
const start = styles.indexOf("/* View modes affect graphic actions only.");
|
||||
const end = styles.indexOf("/* End view-mode presentation. */", start);
|
||||
assert.ok(start >= 0 && end > start, "view-mode CSS must have an auditable boundary");
|
||||
const viewModeRules = styles.slice(start, end);
|
||||
@@ -151,11 +152,43 @@ test("view modes change catalog paint without changing operator row geometry", (
|
||||
assert.ok(viewModeRules.includes(`data-view-mode="${mode}"`),
|
||||
`${mode} mode must have explicit presentation rules`);
|
||||
}
|
||||
assert.match(viewModeRules, /\.catalog-section/);
|
||||
assert.match(viewModeRules, /\.operator-catalog-results button/);
|
||||
assert.doesNotMatch(viewModeRules, /\.playlist-(?:row|data-row|grid)/);
|
||||
assert.match(viewModeRules, /\[data-visual-card-list="true"\]\s*\{/);
|
||||
assert.match(viewModeRules, /grid-template-columns:\s*minmax\(0, 1fr\)/);
|
||||
assert.match(viewModeRules,
|
||||
/@container operator-workspace \(min-width:\s*520px\)[\s\S]*?repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
assert.match(viewModeRules,
|
||||
/@container operator-workspace \(min-width:\s*820px\)[\s\S]*?repeat\(3, minmax\(0, 1fr\)\)/);
|
||||
assert.match(viewModeRules,
|
||||
/data-visual-card-context="comparison-actions"[\s\S]*?repeat\(auto-fit, minmax\(min\(230px, 100%\), 1fr\)\)/);
|
||||
assert.match(viewModeRules,
|
||||
/data-visual-card-context="theme-actions"[\s\S]*?repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
assert.doesNotMatch(viewModeRules,
|
||||
/(?:^|[;{]\s*)(?:height|min-height|padding|margin|grid-template(?:-columns|-rows)?|display)\s*:/m);
|
||||
/data-visual-card-context="expert-actions"[\s\S]{0,180}?repeat\([23],/);
|
||||
assert.match(viewModeRules, /button\.visual-card\s*\{[\s\S]*?min-height:\s*62px/);
|
||||
assert.doesNotMatch(viewModeRules, /grid-auto-rows:\s*minmax\(62px/);
|
||||
assert.match(viewModeRules,
|
||||
/\.comparison-actions\s*\{\s*display:\s*block;/);
|
||||
assert.match(viewModeRules,
|
||||
/@container operator-workspace \(max-width:\s*759px\)[\s\S]*?\.comparison-actions\s*\{\s*display:\s*grid;/);
|
||||
assert.doesNotMatch(viewModeRules, /\.visual-card-(?:kicker|preview|state)/,
|
||||
"generic previews, repeated category labels, and decorative state tiles add no information");
|
||||
assert.match(viewModeRules,
|
||||
/button\.visual-card:is\(\.selected,[\s\S]*?box-shadow:\s*inset 3px 0 var\(--ui-accent\)/);
|
||||
assert.match(viewModeRules,
|
||||
/summary\[data-tree-root\]\.tree-node-selected/);
|
||||
assert.match(viewModeRules,
|
||||
/summary\[data-tree-root\]:focus-visible\s*\{[\s\S]*?outline:\s*3px solid var\(--ui-accent\)/);
|
||||
assert.match(viewModeRules,
|
||||
/button\.visual-card:is\(\.unavailable, :disabled\):is\(/);
|
||||
assert.match(viewModeRules, /@container operator-workspace \(max-width:\s*759px\)/);
|
||||
assert.match(viewModeRules, /body\[data-view-mode="compact"\]/);
|
||||
assert.doesNotMatch(viewModeRules,
|
||||
/(?:#|\.)(?:stock-results|stock-result|cut-list|cut-row|playlist-row|playlist-data-row|playlist-grid|operator-catalog-draft|operator-catalog-results|operator-catalog-stock-results|overseas-period-actions|overseas-result-area)\b/);
|
||||
assert.doesNotMatch(viewModeRules, /grid-auto-flow:\s*dense|\border\s*:/);
|
||||
const cardButtonRule = viewModeRules.match(/button\.visual-card\s*\{([\s\S]*?)\n\}/);
|
||||
assert.ok(cardButtonRule, "visual cards need one stable geometry rule");
|
||||
assert.doesNotMatch(cardButtonRule[1], /transform\s*:/,
|
||||
"hover or selection must not move the double-click hit target");
|
||||
});
|
||||
|
||||
test("appearance controls and accessibility preferences retain clear feedback", () => {
|
||||
@@ -170,7 +203,8 @@ test("appearance controls and accessibility preferences retain clear feedback",
|
||||
|
||||
test("workspace header keeps one concise title and the live native connection state", () => {
|
||||
assert.match(markup,
|
||||
/class="brand"[^>]*role="img"[^>]*aria-label="매일경제TV VRi"[\s\S]*?class="brand-symbol"[\s\S]*?<svg/);
|
||||
/class="brand"[\s\S]*?class="brand-wordmark-plate"[\s\S]*?<img class="brand-wordmark" src="Brand\/logo\.png" width="176" height="43"[\s\S]*?alt="매일경제TV" draggable="false">[\s\S]*?class="brand-divider" aria-hidden="true"[\s\S]*?class="brand-product">V-STOCK<\/span>/);
|
||||
assert.doesNotMatch(markup, /class="brand-symbol"|class="brand-lockup"|>VRi</);
|
||||
assert.match(markup,
|
||||
/class="workspace-heading"[\s\S]*?class="workspace-heading-icon"[\s\S]*?<div class="workspace-heading-copy">[\s\S]*?<h1 id="workspace-title">[^<]+<\/h1>/);
|
||||
assert.doesNotMatch(markup, /<small>현재 작업<\/small>/);
|
||||
|
||||
@@ -1514,8 +1514,12 @@ test("industry workspace sends index and opaque action intents only", () => {
|
||||
assert.match(app, /send\("activate-industry-action"/);
|
||||
assert.doesNotMatch(app, /swap\.textContent = "↕ 교환"/);
|
||||
assert.match(app,
|
||||
/summary\.textContent = industry\.market === "kospi" \? "\[코스피\]" : "\[코스닥\]"/);
|
||||
/summary\.textContent = industry\.market === "kospi" \? "코스피" : "코스닥"/);
|
||||
assert.match(app, /summary\.dataset\.industrySectionIndex = "0"/);
|
||||
assert.match(industryRender, /markVisualCardList\(actionCards, "industry-actions"\)/);
|
||||
assert.match(industryRender, /setVisualCardContent\(button, \{/);
|
||||
assert.match(industryRender, /button\.dataset\.industryActionId = action\.actionId/);
|
||||
assert.doesNotMatch(industryRender, /meta:\s*action\.category/);
|
||||
assert.doesNotMatch(app, /const sections = new Map\(\)/);
|
||||
assert.doesNotMatch(industryRender, /first\.readOnly = true/);
|
||||
assert.doesNotMatch(industryRender, /second\.readOnly = true/);
|
||||
@@ -1523,6 +1527,43 @@ test("industry workspace sends index and opaque action intents only", () => {
|
||||
assert.match(industryRender, /second\.maxLength = 128/);
|
||||
});
|
||||
|
||||
test("visual card renderers stay text-first and exclude narrow overseas actions", () => {
|
||||
const helperStart = app.indexOf("function setVisualCardContent");
|
||||
const helperEnd = app.indexOf("function treeLeafIdentity", helperStart);
|
||||
assert.ok(helperStart >= 0 && helperEnd > helperStart);
|
||||
const helper = app.slice(helperStart, helperEnd);
|
||||
assert.match(helper, /button\.setAttribute\("aria-label", titleText\)/);
|
||||
assert.doesNotMatch(helper,
|
||||
/visual-card-(?:kicker|preview|state)|visualCardPreview|content\.preview/,
|
||||
"a card without unique media must not invent a repeated preview or state ornament");
|
||||
|
||||
const cardRenderers = [
|
||||
["fixed", "function renderFixedCatalog", "function markIndustryRowSelected"],
|
||||
["industry", "function renderIndustry", "function updateOverseasWorkspace"],
|
||||
["comparison", "function renderComparison", "function themeActionIsClickable"],
|
||||
["theme", "function renderTheme", "function updateExpertWorkspace"],
|
||||
["expert", "function renderExpert", "function updateTradingHaltWorkspace"],
|
||||
["halt", "function renderTradingHalt", "const manualFinancialReferenceImages"]
|
||||
];
|
||||
for (const [name, startMarker, endMarker] of cardRenderers) {
|
||||
const start = app.indexOf(startMarker);
|
||||
const end = app.indexOf(endMarker, start);
|
||||
assert.ok(start >= 0 && end > start, `${name} renderer slice must exist`);
|
||||
const renderer = app.slice(start, end);
|
||||
assert.match(renderer, /setVisualCardContent\(button, \{/,
|
||||
`${name} action buttons must use the visual-card helper`);
|
||||
assert.doesNotMatch(renderer, /\bkicker:|\bpreview:\s*true/,
|
||||
`${name} cards must not repeat their section label or a generic preview`);
|
||||
}
|
||||
|
||||
const overseasStart = app.indexOf("function renderOverseas");
|
||||
const overseasEnd = app.indexOf("function updateComparisonWorkspace", overseasStart);
|
||||
const overseas = app.slice(overseasStart, overseasEnd);
|
||||
assert.match(overseas,
|
||||
/button\.dataset\.overseasActionId = action\.actionId;\s*button\.textContent = label;/);
|
||||
assert.doesNotMatch(overseas, /setVisualCardContent|markVisualCardList/);
|
||||
});
|
||||
|
||||
test("UC2 row selection is immediate and its state receipt preserves the double-click target", () => {
|
||||
const helperStart = app.indexOf("function markIndustryRowSelected");
|
||||
const helperEnd = app.indexOf("function renderIndustry", helperStart);
|
||||
@@ -1616,6 +1657,15 @@ test("comparison workspace sends opaque target, pair and action intents", () =>
|
||||
assert.match(app, /\? "confirm-native-dialog"/);
|
||||
assert.match(app, /send\("cancel-native-dialog", \{\}\)/);
|
||||
assert.doesNotMatch(app, /send\([^\n]*stockCode/);
|
||||
assert.match(app, /function comparisonUnavailableText\(reason\)/);
|
||||
assert.match(app, /국내 종목 두 개를 선택해야 합니다/);
|
||||
assert.match(app, /:\s*reason;\s*\n\s*}\s*\n\s*}\s*\n\s*\n\s*function renderComparison/);
|
||||
const comparisonStart = app.indexOf("function renderComparison");
|
||||
const comparisonEnd = app.indexOf("function renderTheme", comparisonStart);
|
||||
const comparisonRender = app.slice(comparisonStart, comparisonEnd);
|
||||
assert.doesNotMatch(comparisonRender, /meta:\s*action\.unavailableReason/);
|
||||
assert.match(comparisonRender,
|
||||
/comparisonUnavailableText\(action\.unavailableReason\)/);
|
||||
});
|
||||
|
||||
test("theme workspace sends query, opaque selection, sort and action intents", () => {
|
||||
@@ -1672,6 +1722,90 @@ test("theme expert and trading halt actions preserve original single click wirin
|
||||
assert.match(clickDelegate, /send\("select-trading-halt"/);
|
||||
});
|
||||
|
||||
test("visual card child spans preserve single and double click intent counts", () => {
|
||||
const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"');
|
||||
const clickStart = app.indexOf('categoryTree.addEventListener("click"', doubleClickStart + 1);
|
||||
const changeStart = app.indexOf('categoryTree.addEventListener("change"', clickStart + 1);
|
||||
assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart);
|
||||
|
||||
const listeners = {};
|
||||
const selected = [];
|
||||
const sent = [];
|
||||
const categoryTree = {
|
||||
addEventListener(type, listener) { listeners[type] = listener; },
|
||||
querySelector() { return null; }
|
||||
};
|
||||
const send = (type, payload) => {
|
||||
sent.push({ type, payload });
|
||||
return true;
|
||||
};
|
||||
const install = new Function(
|
||||
"categoryTree", "send", "treeInteractionLocked", "selectTreeNode",
|
||||
app.slice(doubleClickStart, changeStart));
|
||||
install(categoryTree, send, false, node => selected.push(node));
|
||||
|
||||
function nestedTarget(button, actionSelector, treeNode) {
|
||||
return {
|
||||
className: "visual-card-title",
|
||||
closest(selector) {
|
||||
if (treeNode && (selector === "[data-tree-node-key]" ||
|
||||
selector === ".tree-item[data-tree-node-key]")) return button;
|
||||
return selector === actionSelector ? button : null;
|
||||
}
|
||||
};
|
||||
}
|
||||
function inputEvent(target) {
|
||||
return {
|
||||
target,
|
||||
detail: 1,
|
||||
preventDefault() {}
|
||||
};
|
||||
}
|
||||
|
||||
for (const action of [
|
||||
["button[data-action-id]", "actionId", "fixed-card", "activate-fixed-action"],
|
||||
["button[data-industry-action-id]", "industryActionId", "industry-card",
|
||||
"activate-industry-action"],
|
||||
["button[data-comparison-action-id]", "comparisonActionId", "comparison-card",
|
||||
"activate-comparison-action"]
|
||||
]) {
|
||||
const [selector, datasetKey, id, intent] = action;
|
||||
const button = {
|
||||
dataset: { [datasetKey]: id, treeNodeKey: id },
|
||||
disabled: false,
|
||||
getAttribute() { return null; }
|
||||
};
|
||||
const target = nestedTarget(button, selector, true);
|
||||
const beforeSelected = selected.length;
|
||||
const beforeSent = sent.length;
|
||||
listeners.click(inputEvent(target));
|
||||
assert.equal(selected.length, beforeSelected + 1);
|
||||
assert.equal(sent.length, beforeSent, `${id} single click must only select`);
|
||||
listeners.dblclick(inputEvent(target));
|
||||
assert.equal(sent.length, beforeSent + 1);
|
||||
assert.deepEqual(sent.at(-1), { type: intent, payload: { actionId: id } });
|
||||
}
|
||||
|
||||
for (const action of [
|
||||
["button[data-theme-action-id]", "themeActionId", "theme-card",
|
||||
"activate-theme-action"],
|
||||
["button[data-expert-action-id]", "expertActionId", "expert-card",
|
||||
"activate-expert-action"],
|
||||
["button[data-halt-action-id]", "haltActionId", "halt-card",
|
||||
"activate-trading-halt-action"]
|
||||
]) {
|
||||
const [selector, datasetKey, id, intent] = action;
|
||||
const button = { dataset: { [datasetKey]: id }, disabled: false };
|
||||
const target = nestedTarget(button, selector, false);
|
||||
const before = sent.length;
|
||||
listeners.click(inputEvent(target));
|
||||
assert.equal(sent.length, before + 1);
|
||||
assert.deepEqual(sent.at(-1), { type: intent, payload: { actionId: id } });
|
||||
listeners.dblclick(inputEvent(target));
|
||||
assert.equal(sent.length, before + 1, `${id} double click must not duplicate its click intent`);
|
||||
}
|
||||
});
|
||||
|
||||
test("named DB playlists and playout use separate closed native intents", () => {
|
||||
for (const intent of [
|
||||
"refresh-named-playlists", "select-named-playlist",
|
||||
|
||||
@@ -65,6 +65,7 @@ function pendingMenuFocusHarness() {
|
||||
return tabId === "theme" ? pendingButton : null;
|
||||
}
|
||||
function refreshCurrentMenu() {}
|
||||
function syncHeadingIcon() {}
|
||||
function marketLabel() { return "테마"; }
|
||||
${source}
|
||||
return { syncMarket, hasPendingKeyboardMarketFocus };
|
||||
@@ -262,6 +263,46 @@ test("package input physically resizes the splitter, verifies persistence, and r
|
||||
/ArrowRight: \{ key: "ArrowRight", code: "ArrowRight", virtualKey: 39 \}/);
|
||||
});
|
||||
|
||||
test("read-only package smoke seals the real visual-card screen and nested title selection", () => {
|
||||
const start = packageInputHarness.indexOf(
|
||||
"async function verifyVisualCardPresentation()");
|
||||
const end = packageInputHarness.indexOf(
|
||||
"async function playlistDropGeometry", start);
|
||||
assert.ok(start >= 0 && end > start,
|
||||
"visual-card package verification must be a bounded helper");
|
||||
const visual = packageInputHarness.slice(start, end);
|
||||
for (const contract of [
|
||||
/selectMainTab\(\s*"overseas"/,
|
||||
/button\[data-action-id="fixed-001"\]\.visual-card/,
|
||||
/getComputedStyle\(list\)/,
|
||||
/columnCount:\s*tracks\.length/,
|
||||
/cardHeight:\s*button\.getBoundingClientRect\(\)\.height/,
|
||||
/ariaLabel:\s*button\.getAttribute\("aria-label"\)/,
|
||||
/hasPreview:\s*Boolean\(preview\)/,
|
||||
/wordmarkSource:\s*wordmark\.getAttribute\("src"\)/,
|
||||
/wordmarkNaturalWidth:\s*wordmark\.naturalWidth/,
|
||||
/metrics\.wordmarkNaturalWidth !== 176/,
|
||||
/metrics\.brandProduct !== "V-STOCK"/,
|
||||
/metrics\.hasPreview \|\|\s*metrics\.hasState \|\| metrics\.hasKicker/,
|
||||
/select fixed graphic from its visual-card title/,
|
||||
/outboundMessageCount !== outboundBaseline/,
|
||||
/checkpoint\("visual-card-presentation"/
|
||||
]) assert.match(visual, contract);
|
||||
assert.match(packageInputHarness,
|
||||
/if \(readOnlyProfile\) \{\s*current = await verifyVisualCardPresentation\(\);/);
|
||||
});
|
||||
|
||||
test("UI-only package smoke verifies the local customer wordmark is loaded and visible", () => {
|
||||
assert.match(uiOnlySmokeHarness,
|
||||
/const brandWordmark = document\.querySelector\("img\.brand-wordmark"\)/);
|
||||
assert.match(uiOnlySmokeHarness,
|
||||
/source:\s*brandWordmark\?\.getAttribute\("src"\)/);
|
||||
assert.match(uiOnlySmokeHarness, /naturalWidth:\s*brandWordmark\?\.naturalWidth/);
|
||||
assert.match(uiOnlySmokeHarness, /shell\.brand\.naturalWidth !== 176/);
|
||||
assert.match(uiOnlySmokeHarness, /shell\.brand\.naturalHeight !== 43/);
|
||||
assert.match(uiOnlySmokeHarness, /shell\.brand\.product !== "V-STOCK"/);
|
||||
});
|
||||
|
||||
test("unified operator theme keeps clear semantic states and concise feedback", () => {
|
||||
assert.match(styles,
|
||||
/Unified operator theme: visual-only rules; geometry and native contracts stay intact/);
|
||||
@@ -327,6 +368,116 @@ test("the complete sidebar is one labelled and collapsible navigation region", (
|
||||
assert.match(markup, /id="workspace-nav-footer" class="workspace-nav-footer"/);
|
||||
});
|
||||
|
||||
test("sidebar icons form one semantic SVG family and the heading follows the selected menu", () => {
|
||||
const mappings = [
|
||||
["id=\"workspace-stock-tab\"", "stock-cut"],
|
||||
["data-tab-id=\"overseas\"", "overseas"],
|
||||
["data-tab-id=\"exchange\"", "exchange"],
|
||||
["data-tab-id=\"index\"", "index"],
|
||||
["data-tab-id=\"kospiIndustry\"", "kospi"],
|
||||
["data-tab-id=\"kosdaqIndustry\"", "kosdaq"],
|
||||
["data-tab-id=\"comparison\"", "comparison"],
|
||||
["data-tab-id=\"theme\"", "theme"],
|
||||
["data-tab-id=\"overseasStocks\"", "overseas-stocks"],
|
||||
["data-tab-id=\"expert\"", "expert"],
|
||||
["data-tab-id=\"tradingHalt\"", "trading-halt"],
|
||||
["id=\"workspace-settings-tab\"", "settings"]
|
||||
];
|
||||
for (const [anchor, icon] of mappings) {
|
||||
const start = markup.indexOf(anchor);
|
||||
const end = markup.indexOf("</button>", start);
|
||||
assert.ok(start >= 0 && end > start, `missing menu button: ${anchor}`);
|
||||
const button = markup.slice(start, end);
|
||||
assert.match(button, new RegExp(`data-icon="${icon}"`));
|
||||
assert.match(button, new RegExp(`href="#workspace-icon-${icon}"`));
|
||||
}
|
||||
|
||||
const symbolIds = Array.from(
|
||||
markup.matchAll(/<symbol id="workspace-icon-([^"]+)" viewBox="0 0 24 24">/g),
|
||||
match => match[1]);
|
||||
assert.deepEqual(symbolIds, [
|
||||
"menu",
|
||||
"stock-cut",
|
||||
"overseas",
|
||||
"exchange",
|
||||
"index",
|
||||
"kospi",
|
||||
"kosdaq",
|
||||
"comparison",
|
||||
"theme",
|
||||
"overseas-stocks",
|
||||
"expert",
|
||||
"trading-halt",
|
||||
"settings"
|
||||
]);
|
||||
|
||||
const kospi = markup.slice(
|
||||
markup.indexOf("<symbol id=\"workspace-icon-kospi\""),
|
||||
markup.indexOf("</symbol>", markup.indexOf("<symbol id=\"workspace-icon-kospi\"")));
|
||||
const kosdaq = markup.slice(
|
||||
markup.indexOf("<symbol id=\"workspace-icon-kosdaq\""),
|
||||
markup.indexOf("</symbol>", markup.indexOf("<symbol id=\"workspace-icon-kosdaq\"")));
|
||||
assert.match(kospi, /M4\.5 18V6h4a4 4 0 0 1 0 8h-4/);
|
||||
assert.match(kospi, /M15 6h4M17 6v12M15 18h4/);
|
||||
assert.match(kosdaq, /M2\.3 18V6h1\.6c2\.8 0 4\.3 2\.2 4\.3 6/);
|
||||
assert.match(kosdaq, /M9 18l2\.5-12H13l2\.5 12M10 14h4\.5/);
|
||||
assert.match(kosdaq, /<ellipse cx="19\.2" cy="12" rx="3\.1" ry="6" stroke-width="1\.65">/);
|
||||
assert.match(kosdaq, /m19\.5 14\.5 3\.2 4/);
|
||||
assert.doesNotMatch(kospi + kosdaq, /<text\b/);
|
||||
assert.notEqual(kospi, kosdaq);
|
||||
|
||||
assert.match(styles, /\.workspace-icon-sprite\s*\{[\s\S]*?width:\s*0;[\s\S]*?height:\s*0;/);
|
||||
assert.match(styles, /\.workspace-nav-icon\s*\{\s*shape-rendering:\s*geometricPrecision;\s*\}/);
|
||||
assert.match(navigation,
|
||||
/const titleIcon = document\.querySelector\("\.workspace-heading-icon"\)/);
|
||||
assert.match(navigation,
|
||||
/function syncHeadingIcon\(button\)[\s\S]*?source\.cloneNode\(true\)[\s\S]*?titleIcon\.replaceChildren\(next\)/);
|
||||
assert.match(navigation,
|
||||
/activeWorkspace = name;[\s\S]*?refreshCurrentMenu\(\);[\s\S]*?syncHeadingIcon\(selectedButton\);[\s\S]*?title\.textContent/);
|
||||
assert.match(navigation,
|
||||
/if \(activeWorkspace === "catalog"\)[\s\S]*?syncHeadingIcon\(button\);[\s\S]*?title\.textContent = marketLabel\(button\)/);
|
||||
});
|
||||
|
||||
test("package smokes verify semantic icon painting, heading identity, and collapsed centering", () => {
|
||||
for (const harness of [packageInputHarness, uiOnlySmokeHarness]) {
|
||||
for (const [tabId, icon] of [
|
||||
["overseas", "overseas"],
|
||||
["exchange", "exchange"],
|
||||
["index", "index"],
|
||||
["kospiIndustry", "kospi"],
|
||||
["kosdaqIndustry", "kosdaq"],
|
||||
["comparison", "comparison"],
|
||||
["theme", "theme"],
|
||||
["overseasStocks", "overseas-stocks"],
|
||||
["expert", "expert"],
|
||||
["tradingHalt", "trading-halt"]
|
||||
]) {
|
||||
assert.match(harness, new RegExp(`${tabId}: "${icon}"`));
|
||||
}
|
||||
assert.match(harness, /svg\?\.getBBox\?\.\(\)/);
|
||||
assert.match(harness, /reference:\s*svg\?\.querySelector\("use"\)\?\.getAttribute\("href"\)/);
|
||||
assert.match(harness, /Number\.parseFloat\(style\.opacity\) > 0/);
|
||||
assert.match(harness, /style\.stroke !== "none" \|\| style\.fill !== "none"/);
|
||||
assert.match(harness, /rendered:\s*Boolean\(/);
|
||||
assert.match(harness, /expectedHeadingIcon/);
|
||||
assert.match(harness, /#workspace-icon-\$\{expectedHeadingIcon\}/);
|
||||
}
|
||||
|
||||
assert.match(packageInputHarness, /navigationItems:\s*\[/);
|
||||
assert.match(packageInputHarness, /iconCenterOffset:/);
|
||||
assert.match(packageInputHarness, /iconCenterOffsetY:/);
|
||||
assert.match(packageInputHarness, /item\.iconCenterOffset > 1\.5/);
|
||||
assert.match(packageInputHarness, /item\.iconCenterOffsetY > 1\.5/);
|
||||
assert.match(packageInputHarness, /item\.labelVisible !== false/);
|
||||
assert.match(packageInputHarness, /collapsedIconCenterOffsets:/);
|
||||
assert.match(packageInputHarness,
|
||||
/tab\.icon\.key === baseline\.marketTabs\[index\]\.icon\.key/);
|
||||
|
||||
assert.match(uiOnlySmokeHarness, /layout\.stockTabIcon\.rendered === true/);
|
||||
assert.match(uiOnlySmokeHarness, /layout\.settingsTabIcon\.rendered === true/);
|
||||
assert.match(uiOnlySmokeHarness, /tab\.icon\.rendered === true/);
|
||||
});
|
||||
|
||||
test("workspace navigation stores only the approved local presentation preferences", () => {
|
||||
assert.match(navigation, /entry\.view\.hidden = !isCurrent/);
|
||||
assert.match(navigation, /entry\.view\.toggleAttribute\("inert", !isCurrent\)/);
|
||||
|
||||
@@ -110,9 +110,10 @@ public sealed class LegacyOperatorSettingsWebContractTests
|
||||
Assert.Contains("value=\"light\">라이트", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"dark\">다크", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-view-mode\"", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("<strong>그래픽 목록</strong>", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"automatic\">자동", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"compact\">컴팩트", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"cards\">카드", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"compact\">간결한 목록", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"cards\">비주얼 카드", appearance, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"operator-start-workspace\"", appearance,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"stockCut\">종목·컷", appearance, StringComparison.Ordinal);
|
||||
|
||||
@@ -21,6 +21,10 @@ public sealed class LegacyPackageAppearanceSmokeContractTests
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Start-ExactAppearanceApplication $registration 2", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Wait-StartupIntroDismissed $application $Registration.Executable",
|
||||
Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(2, CountOccurrences(Wrapper, "Close-ExactAppearanceApplication $"));
|
||||
Assert.DoesNotContain("Stop-Process", Wrapper,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
@@ -141,6 +145,13 @@ public sealed class LegacyPackageAppearanceSmokeContractTests
|
||||
Assert.Contains("restoration is one-shot and was already attempted", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[IO.File]::Replace(", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("$replacementBackupPath", Wrapper, StringComparison.Ordinal);
|
||||
Assert.Contains("[IO.File]::Delete($replacementBackupPath)", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"[IO.File]::Replace($temporaryPath, $OperatorSettingsPath, $null",
|
||||
Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[IO.File]::Delete($OperatorSettingsPath)", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("exactOriginalBytesRestored = $true", Wrapper,
|
||||
@@ -154,6 +165,10 @@ public sealed class LegacyPackageAppearanceSmokeContractTests
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("retried. Preserve the recovery evidence", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[IO.Path]::GetFileNameWithoutExtension($Output)", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("[IO.Path]::ChangeExtension($Output, $null)", Wrapper,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -834,6 +834,11 @@ public sealed class LegacyPackageInputSmokeContractTests
|
||||
"delete-selected-playlist-rows",
|
||||
"selections[1].shift !== true",
|
||||
"shiftRangeDeletedRowIds = [rangeStartRowId, rangeEndRowId]");
|
||||
Assert.Contains("neutral-pointer guard", request, StringComparison.Ordinal);
|
||||
Assert.Contains("await moveMouse(neutralPoint)", request,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("neutralSignature !== guardSignature", request,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Input.dispatchMouseEvent", request,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
@@ -934,6 +939,39 @@ public sealed class LegacyPackageInputSmokeContractTests
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartupIntroMustBeDismissedBeforeThePhysicalInputHarnessStarts()
|
||||
{
|
||||
var wait = Slice(
|
||||
Runner,
|
||||
"function Wait-StartupIntroDismissed",
|
||||
"function Assert-NoExistingContentDialog");
|
||||
Assert.Contains("[datetime]::UtcNow.AddSeconds(4)", wait,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("매일경제TV V-STOCK 시작 화면", wait,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[Windows.Automation.PropertyCondition]::new(", wait,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("$intro.Current.IsOffscreen", wait,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Assert-ExactApplicationStillRunning", wait,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Assert-NoTornadoConnection", wait,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("remained visible beyond its four-second bound", wait,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var launch = Slice(
|
||||
Runner,
|
||||
"$script:Phase = 'package launch'",
|
||||
"$harnessRun = Invoke-NodeHarnessUnderTcpWatch");
|
||||
AssertInOrder(
|
||||
launch,
|
||||
"Wait-ExactLoopbackListener",
|
||||
"Wait-StartupIntroDismissed",
|
||||
"$script:Phase = 'real input harness'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugAppXMustMatchTheLatestMsixAndCurrentWebSourceBeforeLaunch()
|
||||
{
|
||||
@@ -946,7 +984,10 @@ public sealed class LegacyPackageInputSmokeContractTests
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[IO.Compression.ZipFile]::OpenRead($msix)", guard,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("'AppxManifest.xml'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Assets/StartupHero.png'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/app.js'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/Brand/logo.png'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/index.html'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/playout-ui.js'", guard, StringComparison.Ordinal);
|
||||
Assert.Contains("'Web/styles.css'", guard, StringComparison.Ordinal);
|
||||
@@ -956,7 +997,20 @@ public sealed class LegacyPackageInputSmokeContractTests
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("registered Debug AppX is stale", guard,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Web\\app.js", guard, StringComparison.Ordinal);
|
||||
foreach (var relativeSource in new[]
|
||||
{
|
||||
"Web\\app.js",
|
||||
"Web\\index.html",
|
||||
"Web\\styles.css",
|
||||
"Web\\workspace-navigation.js"
|
||||
})
|
||||
{
|
||||
Assert.Contains(relativeSource, guard, StringComparison.Ordinal);
|
||||
}
|
||||
Assert.Contains("$sourcePath = Join-Path $projectRoot $relativeSource", guard,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("$installedPath = Join-Path $InstallLocation $relativeSource", guard,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Assert-DebugAppXMatchesLatestPackage ([string]$package.InstallLocation)",
|
||||
Runner,
|
||||
|
||||
@@ -107,6 +107,40 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomerWordmarkIsProjectedFromTheAllowlistedLegacyAssetIntoTheTrustedWebRoot()
|
||||
{
|
||||
var project = XDocument.Load(ProjectPath);
|
||||
var brandAsset = Assert.Single(project.Descendants("LegacyBrandAsset"));
|
||||
Assert.Equal(
|
||||
"$(LegacyResSourceRoot)\\logo.png",
|
||||
brandAsset.Attribute("Include")?.Value);
|
||||
|
||||
var brandContent = Assert.Single(
|
||||
project.Descendants("Content"),
|
||||
element => string.Equals(
|
||||
element.Attribute("Include")?.Value,
|
||||
"@(LegacyBrandAsset)",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Equal(
|
||||
"Web\\Brand\\%(Filename)%(Extension)",
|
||||
brandContent.Element("Link")?.Value);
|
||||
Assert.Equal(
|
||||
"Web\\Brand\\%(Filename)%(Extension)",
|
||||
brandContent.Element("TargetPath")?.Value);
|
||||
Assert.Equal(
|
||||
"PreserveNewest",
|
||||
brandContent.Element("CopyToOutputDirectory")?.Value);
|
||||
Assert.Equal(
|
||||
"PreserveNewest",
|
||||
brandContent.Element("CopyToPublishDirectory")?.Value);
|
||||
|
||||
var packagedResNames = project.Descendants("LegacyPackagedResAsset")
|
||||
.Select(element => Path.GetFileName(element.Attribute("Include")?.Value))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("logo.png", packagedResNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebViewIgnoresItsPersistentCacheBeforeExactTrustedNavigation()
|
||||
{
|
||||
@@ -266,6 +300,7 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
|
||||
"$(LegacyCutsSourceRoot)\\%(LegacyRequiredScene.Identity).t2s",
|
||||
"$(LegacyCutsSourceRoot)\\%(LegacyRequiredBuiltInAsset.Identity)",
|
||||
"$(LegacyResSourceRoot)\\MmoneyCoder.ini",
|
||||
"$(LegacyResSourceRoot)\\logo.png",
|
||||
"$(LegacyResSourceRoot)\\종목.ini",
|
||||
"$(LegacyResSourceRoot)\\업종_코스피.ini",
|
||||
"$(LegacyResSourceRoot)\\업종_코스닥.ini",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||
|
||||
public sealed class LegacySidebarIconContractTests
|
||||
{
|
||||
private static readonly string RepositoryRoot = FindRepositoryRoot();
|
||||
private static readonly string WebRoot = Path.Combine(
|
||||
RepositoryRoot,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp",
|
||||
"Web");
|
||||
private static readonly string Markup = File.ReadAllText(
|
||||
Path.Combine(WebRoot, "index.html"));
|
||||
private static readonly string Navigation = File.ReadAllText(
|
||||
Path.Combine(WebRoot, "workspace-navigation.js"));
|
||||
private static readonly string Styles = File.ReadAllText(
|
||||
Path.Combine(WebRoot, "styles.css"));
|
||||
private static readonly string PackageInputSmoke = File.ReadAllText(
|
||||
Path.Combine(RepositoryRoot, "scripts", "Test-LegacyPackageInput.mjs"));
|
||||
private static readonly string UiOnlySmoke = File.ReadAllText(
|
||||
Path.Combine(RepositoryRoot, "scripts", "Test-LegacyPackageUiOnlySmoke.mjs"));
|
||||
|
||||
[Fact]
|
||||
public void EverySidebarDestinationUsesOneClosedSemanticSvgIcon()
|
||||
{
|
||||
var mappings = new (string Anchor, string Icon)[]
|
||||
{
|
||||
("id=\"workspace-stock-tab\"", "stock-cut"),
|
||||
("data-tab-id=\"overseas\"", "overseas"),
|
||||
("data-tab-id=\"exchange\"", "exchange"),
|
||||
("data-tab-id=\"index\"", "index"),
|
||||
("data-tab-id=\"kospiIndustry\"", "kospi"),
|
||||
("data-tab-id=\"kosdaqIndustry\"", "kosdaq"),
|
||||
("data-tab-id=\"comparison\"", "comparison"),
|
||||
("data-tab-id=\"theme\"", "theme"),
|
||||
("data-tab-id=\"overseasStocks\"", "overseas-stocks"),
|
||||
("data-tab-id=\"expert\"", "expert"),
|
||||
("data-tab-id=\"tradingHalt\"", "trading-halt"),
|
||||
("id=\"workspace-settings-tab\"", "settings")
|
||||
};
|
||||
|
||||
foreach (var (anchor, icon) in mappings)
|
||||
{
|
||||
var button = Slice(Markup, anchor, "</button>");
|
||||
Assert.Contains($"data-icon=\"{icon}\"", button, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
$"href=\"#workspace-icon-{icon}\"",
|
||||
button,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<img", button, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var sprite = Slice(
|
||||
Markup,
|
||||
"<svg class=\"workspace-icon-sprite\"",
|
||||
"</svg>");
|
||||
Assert.DoesNotContain("<text", sprite, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("http://", sprite, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("https://", sprite, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KospiAndKosdaqUseDistinctFontIndependentMonograms()
|
||||
{
|
||||
var kospi = Slice(
|
||||
Markup,
|
||||
"<symbol id=\"workspace-icon-kospi\"",
|
||||
"</symbol>");
|
||||
var kosdaq = Slice(
|
||||
Markup,
|
||||
"<symbol id=\"workspace-icon-kosdaq\"",
|
||||
"</symbol>");
|
||||
|
||||
Assert.Contains(
|
||||
"M4.5 18V6h4a4 4 0 0 1 0 8h-4",
|
||||
kospi,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"M15 6h4M17 6v12M15 18h4",
|
||||
kospi,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"M2.3 18V6h1.6c2.8 0 4.3 2.2 4.3 6",
|
||||
kosdaq,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"M9 18l2.5-12H13l2.5 12M10 14h4.5",
|
||||
kosdaq,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"<ellipse cx=\"19.2\" cy=\"12\" rx=\"3.1\" ry=\"6\" stroke-width=\"1.65\">",
|
||||
kosdaq,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("m19.5 14.5 3.2 4", kosdaq, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<text", kospi + kosdaq, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.NotEqual(kospi, kosdaq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadingClonesTheSelectedIconAndThemeStylingStaysVectorNative()
|
||||
{
|
||||
Assert.Contains(
|
||||
"const titleIcon = document.querySelector(\".workspace-heading-icon\")",
|
||||
Navigation,
|
||||
StringComparison.Ordinal);
|
||||
var sync = Slice(
|
||||
Navigation,
|
||||
"function syncHeadingIcon(button)",
|
||||
"function refreshCurrentMenu()");
|
||||
Assert.Contains("source.cloneNode(true)", sync, StringComparison.Ordinal);
|
||||
Assert.Contains("titleIcon.replaceChildren(next)", sync, StringComparison.Ordinal);
|
||||
|
||||
var workspace = Slice(
|
||||
Navigation,
|
||||
"function setWorkspace(name)",
|
||||
"function applyStartPreference(mode)");
|
||||
AssertInOrder(
|
||||
workspace,
|
||||
"activeWorkspace = name;",
|
||||
"refreshCurrentMenu();",
|
||||
"syncHeadingIcon(selectedButton);",
|
||||
"title.textContent");
|
||||
|
||||
Assert.Contains(
|
||||
".workspace-icon-sprite",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
".workspace-nav-icon { shape-rendering: geometricPrecision; }",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
".workspace-nav-toggle svg, .workspace-nav-item svg",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("stroke: currentColor;", Styles, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackageSmokesProveIconsArePaintedMappedAndCentered()
|
||||
{
|
||||
var mappings = new (string TabId, string Icon)[]
|
||||
{
|
||||
("overseas", "overseas"),
|
||||
("exchange", "exchange"),
|
||||
("index", "index"),
|
||||
("kospiIndustry", "kospi"),
|
||||
("kosdaqIndustry", "kosdaq"),
|
||||
("comparison", "comparison"),
|
||||
("theme", "theme"),
|
||||
("overseasStocks", "overseas-stocks"),
|
||||
("expert", "expert"),
|
||||
("tradingHalt", "trading-halt")
|
||||
};
|
||||
|
||||
foreach (var harness in new[] { PackageInputSmoke, UiOnlySmoke })
|
||||
{
|
||||
foreach (var (tabId, icon) in mappings)
|
||||
{
|
||||
Assert.Contains(
|
||||
$"{tabId}: \"{icon}\"",
|
||||
harness,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.Contains("svg?.getBBox?.()", harness, StringComparison.Ordinal);
|
||||
Assert.Contains("reference:", harness, StringComparison.Ordinal);
|
||||
Assert.Contains("Number.parseFloat(style.opacity) > 0",
|
||||
harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("style.stroke !== \"none\" || style.fill !== \"none\"",
|
||||
harness,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("rendered: Boolean(", harness, StringComparison.Ordinal);
|
||||
Assert.Contains("expectedHeadingIcon", harness, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.Contains("navigationItems:", PackageInputSmoke, StringComparison.Ordinal);
|
||||
Assert.Contains("iconCenterOffset:", PackageInputSmoke, StringComparison.Ordinal);
|
||||
Assert.Contains("iconCenterOffsetY:", PackageInputSmoke, StringComparison.Ordinal);
|
||||
Assert.Contains("item.iconCenterOffset > 1.5", PackageInputSmoke, StringComparison.Ordinal);
|
||||
Assert.Contains("item.iconCenterOffsetY > 1.5",
|
||||
PackageInputSmoke,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("collapsedIconCenterOffsets:", PackageInputSmoke, StringComparison.Ordinal);
|
||||
Assert.Contains("tab.icon.key === baseline.marketTabs[index].icon.key",
|
||||
PackageInputSmoke,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("layout.stockTabIcon.rendered === true",
|
||||
UiOnlySmoke,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("layout.settingsTabIcon.rendered === true",
|
||||
UiOnlySmoke,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("tab.icon.rendered === true",
|
||||
UiOnlySmoke,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Slice(string value, string start, string end)
|
||||
{
|
||||
var startIndex = value.IndexOf(start, StringComparison.Ordinal);
|
||||
Assert.True(startIndex >= 0, $"Required marker was not found: {start}");
|
||||
var endIndex = value.IndexOf(
|
||||
end,
|
||||
startIndex + start.Length,
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(endIndex > startIndex, $"Required marker was not found: {end}");
|
||||
return value[startIndex..(endIndex + end.Length)];
|
||||
}
|
||||
|
||||
private static void AssertInOrder(string value, params string[] needles)
|
||||
{
|
||||
var previous = -1;
|
||||
foreach (var needle in needles)
|
||||
{
|
||||
var current = value.IndexOf(needle, previous + 1, StringComparison.Ordinal);
|
||||
Assert.True(current > previous, $"Required ordered marker was not found: {needle}");
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||
|
||||
public sealed class LegacyStartupIntroNativeContractTests
|
||||
{
|
||||
private static readonly string RepositoryRoot = FindRepositoryRoot();
|
||||
private static readonly string AppRoot = Path.Combine(
|
||||
RepositoryRoot,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp");
|
||||
private static readonly string WindowXaml = File.ReadAllText(
|
||||
Path.Combine(AppRoot, "MainWindow.xaml"));
|
||||
private static readonly string Window = File.ReadAllText(
|
||||
Path.Combine(AppRoot, "MainWindow.xaml.cs"));
|
||||
private static readonly string StartupIntro = File.ReadAllText(
|
||||
Path.Combine(AppRoot, "MainWindow.StartupIntro.cs"));
|
||||
private static readonly string Manifest = File.ReadAllText(
|
||||
Path.Combine(AppRoot, "Package.appxmanifest"));
|
||||
private static readonly string Project = File.ReadAllText(
|
||||
Path.Combine(AppRoot, "MBN_STOCK_WEBVIEW.LegacyParityApp.csproj"));
|
||||
|
||||
[Fact]
|
||||
public void IntroUsesOnlyPackagedBrandAssetsAndCompactCopy()
|
||||
{
|
||||
var overlay = Slice(
|
||||
WindowXaml,
|
||||
"x:Name=\"LoadingOverlay\"",
|
||||
"<InfoBar");
|
||||
|
||||
Assert.Contains(
|
||||
"ms-appx:///Assets/Square150x150Logo.scale-200.png",
|
||||
overlay,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"ms-appx:///Assets/StartupHero.png",
|
||||
overlay,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"ms-appx:///Web/Brand/logo.png",
|
||||
overlay,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("V-STOCK", overlay, StringComparison.Ordinal);
|
||||
Assert.Contains("증권정보 송출 시스템", overlay, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("http://", overlay, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("https://", overlay, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("원본 호환 화면을 준비하고 있습니다.", overlay,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeneratedBroadcastHeroIsAProjectPackagedWidePng()
|
||||
{
|
||||
const string heroInclude =
|
||||
"<Content Include=\"..\\..\\Assets\\StartupHero.png\" Link=\"Assets\\StartupHero.png\" />";
|
||||
Assert.Contains(heroInclude, Project, StringComparison.Ordinal);
|
||||
|
||||
var heroPath = Path.Combine(RepositoryRoot, "Assets", "StartupHero.png");
|
||||
var bytes = File.ReadAllBytes(heroPath);
|
||||
Assert.True(bytes.Length > 500_000);
|
||||
Assert.Equal(
|
||||
new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 },
|
||||
bytes[..8]);
|
||||
Assert.True(bytes.Length >= 24);
|
||||
var width = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(16, 4));
|
||||
var height = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(20, 4));
|
||||
Assert.True(width >= 1600);
|
||||
Assert.True(height >= 900);
|
||||
Assert.True((double)width / height > 1.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntroUsesTheDeepNavyMbnAccentAndShortNativeMotion()
|
||||
{
|
||||
var overlay = Slice(
|
||||
WindowXaml,
|
||||
"x:Name=\"LoadingOverlay\"",
|
||||
"<InfoBar");
|
||||
|
||||
Assert.Contains("#FF08182B", WindowXaml, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("#FFF47B20", overlay, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(
|
||||
"x:Name=\"StartupIntroEnterStoryboard\"",
|
||||
WindowXaml,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"x:Name=\"StartupIntroExitStoryboard\"",
|
||||
WindowXaml,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("IsIndeterminate=\"True\"", overlay, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"AutomationProperties.Name=\"매일경제TV V-STOCK 시작 화면\"",
|
||||
overlay,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"AutomationProperties.AccessibilityView=\"Content\"",
|
||||
overlay,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryStartupOutcomeKeepsItsCancellableTwoSecondHold()
|
||||
{
|
||||
Assert.Contains(
|
||||
"TimeSpan.FromMilliseconds(2050)",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"TimeSpan.FromMilliseconds(180)",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("UISettings", StartupIntro, StringComparison.Ordinal);
|
||||
Assert.Contains(".AnimationsEnabled", StartupIntro, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Interlocked.CompareExchange(",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_lifetimeCancellation.Token",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Task.Delay(", StartupIntro, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"skipMinimumDuration",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"if (!_startupIntroAnimationsEnabled)",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebReadyPostsTheFirstStateBeforeEveryOutcomeUsesTheSameDismissal()
|
||||
{
|
||||
var dispatch = Slice(
|
||||
Window,
|
||||
"private async Task ProcessIntentAsync(",
|
||||
"private static bool IsNamedPlaylistModalIntent(");
|
||||
var finalPost = RequiredIndex(dispatch, "PostState(state);");
|
||||
var readyDismiss = RequiredIndex(
|
||||
dispatch,
|
||||
"if (intent is LegacyReadyIntent)",
|
||||
finalPost);
|
||||
var normalDismiss = RequiredIndex(
|
||||
dispatch,
|
||||
"BeginDismissStartupIntro();",
|
||||
readyDismiss);
|
||||
Assert.True(finalPost < readyDismiss);
|
||||
Assert.True(readyDismiss < normalDismiss);
|
||||
|
||||
var failurePost = RequiredIndex(dispatch, "PostState(failureState);");
|
||||
var immediateDismiss = RequiredIndex(
|
||||
dispatch,
|
||||
"BeginDismissStartupIntro();",
|
||||
failurePost);
|
||||
Assert.True(failurePost < immediateDismiss);
|
||||
|
||||
var navigation = Slice(
|
||||
Window,
|
||||
"private void OnNavigationCompleted(",
|
||||
"private static void OnNewWindowRequested(");
|
||||
Assert.Contains("if (!args.IsSuccess)", navigation, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"BeginDismissStartupIntro();",
|
||||
navigation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"LoadingOverlay.Visibility = Visibility.Collapsed;",
|
||||
navigation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntroBlocksInputUntilItsOneShotDismissalCompletes()
|
||||
{
|
||||
var browser = Slice(WindowXaml, "<WebView2", "/>");
|
||||
Assert.Contains("x:Name=\"Browser\"", browser, StringComparison.Ordinal);
|
||||
Assert.Contains("IsHitTestVisible=\"False\"", browser, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"LoadingOverlay.Visibility = Visibility.Collapsed;",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"LoadingOverlay.IsHitTestVisible = false;",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Browser.IsHitTestVisible = true;",
|
||||
StartupIntro,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
var loaded = Slice(
|
||||
Window,
|
||||
"private async void OnRootLoaded(",
|
||||
"private void ConfigureWindow()");
|
||||
Assert.Contains("StartStartupIntro();", loaded, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MsixSystemSplashMatchesTheNativeIntroCanvas()
|
||||
{
|
||||
Assert.Contains(
|
||||
"BackgroundColor=\"#08182B\"",
|
||||
Manifest,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(
|
||||
"<uap:SplashScreen Image=\"Assets\\SplashScreen.png\" />",
|
||||
Manifest,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Slice(string value, string start, string end)
|
||||
{
|
||||
var startIndex = RequiredIndex(value, start);
|
||||
var endIndex = RequiredIndex(value, end, startIndex + start.Length);
|
||||
return value[startIndex..endIndex];
|
||||
}
|
||||
|
||||
private static int RequiredIndex(string value, string needle, int startIndex = 0)
|
||||
{
|
||||
var index = value.IndexOf(needle, startIndex, StringComparison.Ordinal);
|
||||
Assert.True(index >= 0, $"Required marker was not found: {needle}");
|
||||
return index;
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
}
|
||||
@@ -301,9 +301,15 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
public void WorkspaceHeaderUsesModernSemanticLockupAndStableNativeStatusTarget()
|
||||
{
|
||||
Assert.Matches(
|
||||
@"class=""brand""[^>]*role=""img""[^>]*aria-label=""매일경제TV VRi""[\s\S]*?" +
|
||||
@"class=""brand-symbol""[\s\S]*?<svg",
|
||||
@"class=""brand""[\s\S]*?class=""brand-wordmark-plate""[\s\S]*?" +
|
||||
@"<img class=""brand-wordmark"" src=""Brand/logo\.png"" width=""176"" height=""43""[\s\S]*?" +
|
||||
@"alt=""매일경제TV"" draggable=""false"">[\s\S]*?" +
|
||||
@"class=""brand-divider"" aria-hidden=""true""[\s\S]*?" +
|
||||
@"class=""brand-product"">V-STOCK</span>",
|
||||
Markup);
|
||||
Assert.DoesNotContain("class=\"brand-symbol\"", Markup, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("class=\"brand-lockup\"", Markup, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(">VRi<", Markup, StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"class=""workspace-heading""[\s\S]*?class=""workspace-heading-icon""" +
|
||||
@"[\s\S]*?<div class=""workspace-heading-copy"">[\s\S]*?" +
|
||||
@@ -491,6 +497,70 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Navigation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisualCardModeReflowsOnlyGraphicActionsAndKeepsNativeIntentButtons()
|
||||
{
|
||||
var viewModes = Slice(
|
||||
Styles,
|
||||
"/* View modes affect graphic actions only.",
|
||||
"/* End view-mode presentation. */");
|
||||
|
||||
Assert.Contains("[data-visual-card-list=\"true\"]", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"@container operator-workspace (min-width: 520px)",
|
||||
viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("grid-template-columns: repeat(2, minmax(0, 1fr))", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("@container operator-workspace (min-width: 820px)", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("grid-template-columns: repeat(3, minmax(0, 1fr))", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("button.visual-card", viewModes, StringComparison.Ordinal);
|
||||
Assert.Contains("min-height: 62px", viewModes, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("grid-auto-rows: minmax(62px", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".visual-card-preview", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".visual-card-kicker", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".visual-card-state", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Matches(@"\.comparison-actions\s*\{\s*display:\s*block;", viewModes);
|
||||
Assert.Contains(
|
||||
"summary[data-tree-root].tree-node-selected",
|
||||
viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("@container operator-workspace (max-width: 759px)", viewModes,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".stock-result", viewModes, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".cut-row", viewModes, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".playlist-data-row", viewModes, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".operator-catalog-draft", viewModes, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".overseas-period-actions", viewModes, StringComparison.Ordinal);
|
||||
Assert.Matches(@"\.industry-action-tree\s*\{\s*overflow:\s*auto;", viewModes);
|
||||
|
||||
Assert.Contains("function setVisualCardContent(button, options)", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("button.dataset.actionId = action.id", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("button.dataset.industryActionId = action.actionId", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("button.dataset.comparisonActionId = action.actionId", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("button.dataset.themeActionId = action.actionId", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("button.dataset.expertActionId = action.actionId", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("button.dataset.haltActionId = action.actionId", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"button\.dataset\.overseasActionId = action\.actionId;\s*" +
|
||||
@"button\.textContent = label;",
|
||||
App);
|
||||
}
|
||||
|
||||
private static string Slice(string source, string startMarker, string endMarker)
|
||||
{
|
||||
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
|
||||
|
||||
Reference in New Issue
Block a user