"use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const test = require("node:test"); const repositoryRoot = path.resolve(__dirname, "..", ".."); const webRoot = path.join( repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web"); const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8"); const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8"); const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8"); const playout = fs.readFileSync(path.join(webRoot, "playout-ui.js"), "utf8"); function luminance(hex) { const channels = [1, 3, 5].map(index => Number.parseInt(hex.slice(index, index + 2), 16) / 255); const linear = channels.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; } function contrast(left, right) { const bright = Math.max(luminance(left), luminance(right)); const dark = Math.min(luminance(left), luminance(right)); return (bright + 0.05) / (dark + 0.05); } test("operator inputs retain a visible three-to-one boundary", () => { const match = styles.match(/--ui-border-strong:\s*(#[0-9a-f]{6})/i); assert.ok(match, "strong control border token must exist"); assert.ok(contrast(match[1], "#ffffff") >= 3, `control border contrast was ${contrast(match[1], "#ffffff").toFixed(2)}:1`); }); test("playlist exposes independent candidate, selection, and PGM states", () => { assert.match(app, /element\.dataset\.active = isCandidate \? "true" : "false"/); assert.match(app, /"aria-selected",\s*presentation\.isSelected \? "true" : "false"/s); assert.match(app, /if \(presentation\.isOnAir\) accessibleStates\.push\("현재 PGM"\)/); assert.match(app, /if \(isCandidate\) accessibleStates\.push\("송출 후보"\)/); assert.match(app, /if \(presentation\.isSelected\) accessibleStates\.push\("삭제 선택됨"\)/); for (const selector of [ '.playlist-data-row[data-active="true"]:not(.on-air)', '.playlist-data-row[aria-selected="true"]:not(.on-air)', '.playlist-data-row[data-active="true"][aria-selected="true"]:not(.on-air)', '.playlist-data-row.on-air', '.playlist-data-row.on-air[data-active="true"]', '.playlist-data-row.on-air[aria-selected="true"]', '.playlist-data-row.on-air[data-active="true"][aria-selected="true"]' ]) { assert.ok(styles.includes(selector), `${selector} must have an explicit visual state`); } assert.match(styles, /\.playlist-data-row\.on-air\s*\{[^}]*background:\s*#fdc8d2/i); }); test("keyboard focus, high contrast, and scroll affordances remain visible", () => { assert.match(styles, /\.playlist-data-row:focus-visible[\s\S]*?outline:\s*3px solid var\(--ui-accent\)/); assert.match(styles, /@media \(forced-colors:\s*active\)/); assert.match(styles, /\.playlist-data-row\[data-active="true"\]\s*\{\s*border-top:\s*3px solid Highlight/); assert.match(styles, /\.playlist-data-row\[aria-selected="true"\]\s*\{\s*border-right:\s*3px solid Highlight/); assert.match(styles, /\.playlist-data-row\.on-air\s*\{\s*border-left:\s*4px double Highlight/); assert.match(styles, /scrollbar-width:\s*auto/); assert.match(styles, /scrollbar-gutter:\s*stable/); assert.match(styles, /scroll-padding-top:\s*29px/); }); test("playout controls publish their real keyboard shortcuts", () => { assert.match(markup, /id="playout-take-in"[^>]*aria-label="TAKE IN"[^>]*aria-keyshortcuts="F8"/s); assert.match(markup, /id="playout-take-out"[^>]*aria-label="TAKE OUT"[^>]*aria-keyshortcuts="Escape"/s); assert.match(markup, /id="playout-next"[^>]*aria-label="NEXT"/s); }); test("dry-run mode reads as information and clipped status keeps its full tooltip", () => { assert.match(playout, /connection\.classList\.toggle\("dry-run", playout\.mode === "dryRun"\)/); assert.match(styles, /\.connection-state\.dry-run:not\(\.outcome-unknown\)\s*\{/); assert.match(playout, /status\.title = status\.textContent/); }); test("appearance themes expose explicit light, dark, and Windows-following palettes", () => { assert.match(styles, /:root\[data-color-theme="light"\],[\s\S]*?body\[data-color-theme="light"\]\s*\{\s*color-scheme:\s*light/); assert.match(styles, /: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, /\.playlist-state-legend \.legend-selected\s*\{\s*color:\s*var\(--ui-accent\)/); assert.match(styles, /\.playlist-state-legend \.legend-next\s*\{\s*color:\s*var\(--ui-warning\)/); assert.match(styles, /\.playlist-state-legend \.legend-on-air\s*\{\s*color:\s*var\(--ui-danger\)/); }); test("dark palette keeps text, actions, and semantic states readable", () => { const match = styles.match( /:root\[data-color-theme="dark"\],[\s\S]*?body\[data-color-theme="dark"\]\s*\{([\s\S]*?)\n\}/); assert.ok(match, "explicit dark palette must exist"); const token = name => { const value = match[1].match(new RegExp(`--${name}:\\s*(#[0-9a-f]{6})`, "i")); assert.ok(value, `${name} dark token must exist`); return value[1]; }; assert.ok(contrast(token("ui-text"), token("ui-surface")) >= 7); assert.ok(contrast(token("ui-text-muted"), token("ui-surface")) >= 4.5); assert.ok(contrast(token("ui-accent"), token("ui-canvas")) >= 4.5); assert.ok(contrast(token("ui-on-accent"), token("ui-accent")) >= 4.5); assert.ok(contrast(token("ui-on-accent"), token("ui-success")) >= 4.5); assert.ok(contrast(token("ui-danger"), token("ui-danger-soft")) >= 4.5); assert.ok(contrast(token("ui-warning"), token("ui-warning-soft")) >= 4.5); assert.match(styles, /\.playlist-data-row\.on-air\s*\{[^}]*background:\s*var\(--ui-on-air\)[^}]*var\(--ui-on-air-edge\)/s); }); test("light palette keeps hints and small semantic labels readable", () => { const match = styles.match(/:root\s*\{([\s\S]*?)\n\}/); assert.ok(match, "default light palette must exist"); const token = name => { const value = match[1].match(new RegExp(`--${name}:\\s*(#[0-9a-f]{6})`, "i")); assert.ok(value, `${name} light token must exist`); return value[1]; }; assert.ok(contrast(token("ui-text-subtle"), token("ui-surface")) >= 4.5); assert.ok(contrast(token("ui-text-subtle"), token("ui-surface-subtle")) >= 4.5); assert.ok(contrast(token("ui-success"), token("ui-success-soft")) >= 4.5); 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:"); 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); for (const mode of ["automatic", "compact", "cards"]) { 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.doesNotMatch(viewModeRules, /(?:^|[;{]\s*)(?:height|min-height|padding|margin|grid-template(?:-columns|-rows)?|display)\s*:/m); }); test("appearance controls and accessibility preferences retain clear feedback", () => { assert.match(styles, /\.settings-choice-list\s*\{/); assert.match(styles, /\.settings-choice-row\s*\{/); assert.match(styles, /\.settings-choice-row select\s*\{/); assert.match(styles, /@media \(forced-colors:\s*active\)[\s\S]*?\.settings-choice-row\s*\{\s*border-bottom-color:\s*CanvasText/); assert.match(styles, /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.settings-switch::after\s*\{\s*transition:\s*none !important/); }); 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]*?[\s\S]*?

[^<]+<\/h1>/); assert.doesNotMatch(markup, /현재 작업<\/small>/); assert.match(markup, /class="playout-connection-summary">[\s\S]*?id="playout-connection-state"/); assert.doesNotMatch(markup, /class="playout-connection-copy"/); assert.doesNotMatch(markup, /Tornado2<\/strong>/); assert.match(markup, /id="playout-connection-state"[^>]*role="status"[^>]*aria-label="Tornado2 연결 상태"[^>]*aria-live="polite"[^>]*aria-atomic="true">미연결<\/div>/); assert.match(styles, /\.workspace-header::before,\s*\.playlist-heading::before\s*\{\s*display:\s*none;/); assert.match(styles, /\.connection-state::before\s*\{/); assert.match(styles, /\.connection-state\.connected::before\s*\{/); assert.match(styles, /\.connection-state\.dry-run:not\(\.outcome-unknown\)::before\s*\{/); assert.match(styles, /\.workspace-surface\s*\{[^}]*grid-template-rows:\s*66px minmax\(0, 1fr\)/); assert.match(playout, /const nextConnectionText = connectionLabel\(playout\);[\s\S]*?if \(connection\.textContent !== nextConnectionText\)[\s\S]*?connection\.textContent = nextConnectionText/); });