using System.Text.RegularExpressions; namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; public sealed class LegacyWorkspaceLayoutContractTests { 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 Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css")); private static readonly string App = File.ReadAllText(Path.Combine(WebRoot, "app.js")); private static readonly string Playout = File.ReadAllText( Path.Combine(WebRoot, "playout-ui.js")); private static readonly string Navigation = File.ReadAllText( Path.Combine(WebRoot, "workspace-navigation.js")); [Fact] public void ShellUsesSidebarScreensBesidePersistentSchedule() { var workspaceStart = Markup.IndexOf("id=\"workspace-region\"", StringComparison.Ordinal); var navigationStart = Markup.IndexOf("id=\"workspace-navigation\"", StringComparison.Ordinal); var marketStart = Markup.IndexOf("id=\"market-tabs\"", StringComparison.Ordinal); var settingsTabStart = Markup.IndexOf("id=\"workspace-settings-tab\"", StringComparison.Ordinal); var stockStart = Markup.IndexOf("id=\"stock-workspace\"", StringComparison.Ordinal); var catalogStart = Markup.IndexOf("id=\"catalog-workspace\"", StringComparison.Ordinal); var settingsStart = Markup.IndexOf("id=\"settings-workspace\"", StringComparison.Ordinal); var splitterStart = Markup.IndexOf("id=\"workspace-splitter\"", StringComparison.Ordinal); var scheduleStart = Markup.IndexOf("class=\"playlist-panel\"", StringComparison.Ordinal); Assert.True(workspaceStart >= 0); Assert.True(navigationStart > workspaceStart); Assert.True(marketStart > navigationStart); Assert.True(settingsTabStart > marketStart); Assert.True(stockStart > settingsTabStart); Assert.True(catalogStart > stockStart); Assert.True(settingsStart > catalogStart); Assert.True(splitterStart > settingsStart); Assert.True(scheduleStart > splitterStart); Assert.Matches( @"<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*" + @"
]*><\/div>\s*" + @"
]*class=""workspace-splitter""" + @"[^>]*role=""separator""" + @"[^>]*aria-label=""작업 영역과 송출 스케줄 너비 조절""" + @"[^>]*aria-orientation=""vertical""" + @"[^>]*aria-controls=""workspace-region""" + @"[^>]*aria-valuemin=""34""[^>]*aria-valuemax=""48""" + @"[^>]*aria-valuenow=""39""[^>]*tabindex=""0""", Markup); Assert.Matches("id=\"catalog-workspace\"[^>]*hidden inert", Markup); Assert.Matches("id=\"settings-workspace\"[^>]*hidden inert", Markup); Assert.Contains("

송출 스케줄

", Markup, StringComparison.Ordinal); Assert.DoesNotContain("항상 표시", Markup, StringComparison.Ordinal); } [Fact] public void SidebarOwnsEachNativeMarketButtonAndKeepsSettingsOutsideTheirOrder() { var marketStart = Markup.IndexOf("
", marketStart, StringComparison.Ordinal); Assert.True(marketStart >= 0 && marketEnd > marketStart); var marketMarkup = Markup[marketStart..marketEnd]; var expected = new[] { "overseas", "exchange", "index", "kospiIndustry", "kosdaqIndustry", "comparison", "theme", "overseasStocks", "expert", "tradingHalt" }; var actual = Regex.Matches(marketMarkup, "data-tab-id=\"([^\"]+)\"") .Select(match => match.Groups[1].Value) .ToArray(); Assert.Equal(expected, actual); Assert.Equal(expected.Length, Regex.Matches(marketMarkup, "]*role=\"navigation\"[^>]*aria-label=", Markup); Assert.Matches( "id=\"workspace-nav-toggle\"[^>]*aria-expanded=\"true\"[\\s\\S]*?" + "aria-controls=\"workspace-nav-items workspace-nav-footer\"", Markup); Assert.Matches( "id=\"workspace-nav-items\"[^>]*role=\"group\"[^>]*aria-label=", Markup); Assert.Matches( "id=\"market-tabs\"[^>]*role=\"group\"[^>]*aria-label=", Markup); Assert.Contains( "id=\"workspace-nav-footer\" class=\"workspace-nav-footer\"", Markup, StringComparison.Ordinal); } [Fact] public void UnifiedOperatorThemePreservesAccessibleAndSemanticStates() { Assert.Contains( "Unified operator theme: visual-only rules; geometry and native contracts stay intact.", Styles, StringComparison.Ordinal); Assert.Contains("Compact broadcast-console components.", Styles, StringComparison.Ordinal); Assert.Single( Regex.Matches(Styles, @"(?m)^:root\s*\{").Cast()); Assert.Contains("--ui-canvas:", Styles, StringComparison.Ordinal); Assert.Contains("--ui-accent:", Styles, StringComparison.Ordinal); Assert.Contains("--ui-success:", Styles, StringComparison.Ordinal); Assert.Contains("--ui-danger:", Styles, StringComparison.Ordinal); Assert.Contains( "font-family: \"Segoe UI Variable\", \"Segoe UI\", \"Noto Sans KR\"", Styles, StringComparison.Ordinal); Assert.Matches( new Regex( @"button:focus-visible,[\s\S]*?outline:\s*2px solid var\(--ui-accent\)", RegexOptions.CultureInvariant), Styles); Assert.Contains(".playlist-data-row.on-air.selected", Styles, StringComparison.Ordinal); Assert.Contains(".playout-actions .take-in:not(:disabled)", Styles, StringComparison.Ordinal); Assert.Contains(".playout-actions .next:not(:disabled)", Styles, StringComparison.Ordinal); Assert.Contains(".playout-actions .take-out:not(:disabled)", Styles, StringComparison.Ordinal); Assert.Contains(".stock-result.selected", Styles, StringComparison.Ordinal); Assert.Contains(".playlist-heading-count", Styles, StringComparison.Ordinal); Assert.Contains(".playlist-rows:empty::after", Styles, StringComparison.Ordinal); Assert.Contains( ".playlist-data-row[data-active=\"true\"]:not(.on-air)", Styles, StringComparison.Ordinal); Assert.Contains( ".playlist-data-row[aria-selected=\"true\"]:not(.on-air)", Styles, StringComparison.Ordinal); Assert.Contains("@media (forced-colors: active)", Styles, StringComparison.Ordinal); Assert.Contains("scrollbar-gutter: stable", Styles, StringComparison.Ordinal); Assert.Contains("@media (prefers-reduced-motion: reduce)", Styles, StringComparison.Ordinal); Assert.Contains("aria-keyshortcuts=\"F8\"", Markup, StringComparison.Ordinal); Assert.Contains("aria-keyshortcuts=\"Escape\"", Markup, StringComparison.Ordinal); Assert.Contains( ".connection-state.dry-run:not(.outcome-unknown)", Styles, StringComparison.Ordinal); Assert.Contains("accessibleStates.push(\"현재 PGM\")", App, StringComparison.Ordinal); Assert.Contains("accessibleStates.push(\"송출 후보\")", App, StringComparison.Ordinal); Assert.Contains("accessibleStates.push(\"삭제 선택됨\")", App, StringComparison.Ordinal); Assert.Matches( new Regex( @"id=""stock-search-button""[\s\S]*?]*viewBox=""0 0 24 24""", RegexOptions.CultureInvariant), Markup); Assert.Matches( new Regex( @"class=""playlist-heading-icon""[\s\S]*?]*viewBox=""0 0 24 24""", RegexOptions.CultureInvariant), Markup); Assert.Matches( new Regex( @"id=""playlist-heading-count""[^>]*role=""status""[^>]*aria-live=""polite""[^>]*aria-atomic=""true""", RegexOptions.CultureInvariant), Markup); Assert.Contains("const nextHeadingCount = `", App, StringComparison.Ordinal); Assert.Contains( "if (playlistHeadingCount.textContent !== nextHeadingCount)", App, StringComparison.Ordinal); } [Fact] public void ScheduleHeaderAndToolbarExposeClearStatesAndExistingShortcuts() { Assert.Matches( @"class=""playlist-state-legend""[^>]*aria-label=""스케줄 상태""[\s\S]*?" + @"class=""legend-selected""[\s\S]*?>선택[\s\S]*?" + @"class=""legend-next""[\s\S]*?>다음[\s\S]*?" + @"class=""legend-on-air""[\s\S]*?>ON AIR", Markup); Assert.Contains(".playlist-state-legend .legend-selected", Styles, StringComparison.Ordinal); Assert.Contains(".playlist-state-legend .legend-next", Styles, StringComparison.Ordinal); Assert.Contains(".playlist-state-legend .legend-on-air", Styles, StringComparison.Ordinal); var toolbar = Slice( Markup, "
", "
]*aria-label=\"{group}\"", toolbar); } Assert.Contains("id=\"moving-average-5\"", toolbar, StringComparison.Ordinal); Assert.Contains("id=\"moving-average-20\"", toolbar, StringComparison.Ordinal); Assert.Contains("id=\"playlist-move-up\"", toolbar, StringComparison.Ordinal); Assert.Contains("id=\"playlist-move-down\"", toolbar, StringComparison.Ordinal); Assert.Matches("id=\"playout-background-none\"[^>]*aria-keyshortcuts=\"F3\"", toolbar); Assert.Matches("id=\"playout-background-file\"[^>]*aria-keyshortcuts=\"F2\"", toolbar); Assert.Matches("id=\"playlist-delete-selected\"[^>]*aria-keyshortcuts=\"Delete\"", toolbar); Assert.Contains("id=\"db-load-button\"", toolbar, StringComparison.Ordinal); Assert.Contains("id=\"db-save-button\"", toolbar, StringComparison.Ordinal); Assert.Contains(".playlist-toolbar-group", Styles, StringComparison.Ordinal); Assert.Contains(".playlist-toolbar-background", Styles, StringComparison.Ordinal); Assert.Contains(".playlist-toolbar-danger", Styles, StringComparison.Ordinal); Assert.Contains("aria-keyshortcuts=\"F8\"", Markup, StringComparison.Ordinal); Assert.Contains("aria-keyshortcuts=\"Escape\"", Markup, StringComparison.Ordinal); } [Fact] public void SearchBusyAndEmptyFeedbackRemainAccessibleAndNonBlocking() { Assert.Matches( @"id=""stock-search-meta""[^>]*role=""status""[^>]*aria-live=""polite""" + @"[^>]*aria-atomic=""true""[^>]*hidden", Markup); Assert.Contains(".search-meta", Styles, StringComparison.Ordinal); Assert.Contains(".stock-results:empty[data-empty-label]::after", Styles, StringComparison.Ordinal); Assert.Contains(".cut-list:empty[data-empty-label]::after", Styles, StringComparison.Ordinal); Assert.Contains("content: attr(data-empty-label);", Styles, StringComparison.Ordinal); Assert.Contains("body.busy::before", Styles, StringComparison.Ordinal); Assert.Contains("@keyframes operator-progress", Styles, StringComparison.Ordinal); Assert.Contains("body.busy .operator-shell { pointer-events: none; }", Styles, StringComparison.Ordinal); Assert.Contains("function renderStockSearchMeta(state)", App, StringComparison.Ordinal); Assert.Contains("label = \"검색 중\";", App, StringComparison.Ordinal); Assert.Contains("stockResults.dataset.emptyLabel = \"결과 없음\";", App, StringComparison.Ordinal); Assert.Contains("? \"컷 없음\"", App, StringComparison.Ordinal); Assert.Contains(": \"종목을 선택하세요\"", App, StringComparison.Ordinal); Assert.Contains("status.textContent = isBusy ? \"처리 중\"", App, StringComparison.Ordinal); Assert.Contains("status.dataset.state = statusState(playout, busy);", Playout, StringComparison.Ordinal); Assert.Contains("return \"outcome-unknown\";", Playout, StringComparison.Ordinal); Assert.Contains(".playout-status[data-state=\"outcome-unknown\"]", Styles, StringComparison.Ordinal); } [Fact] public void WorkspaceHeaderUsesModernSemanticLockupAndStableNativeStatusTarget() { Assert.Matches( @"class=""brand""[^>]*role=""img""[^>]*aria-label=""매일경제TV VRi""[\s\S]*?" + @"class=""brand-symbol""[\s\S]*?[\s\S]*?" + @"

종목·컷

", Markup); var header = Slice(Markup, "
", "
"); Assert.DoesNotContain("", header, StringComparison.Ordinal); Assert.DoesNotContain("playout-connection-copy", header, StringComparison.Ordinal); Assert.DoesNotContain("Tornado2", header, StringComparison.Ordinal); Assert.Matches( @"id=""playout-connection-state""[^>]*role=""status""" + @"[^>]*aria-label=""Tornado2 연결 상태""[^>]*aria-live=""polite""" + @"[^>]*aria-atomic=""true"">미연결
", Markup); Assert.Matches( @"\.workspace-header::before,\s*\.playlist-heading::before\s*\{\s*display:\s*none;\s*\}", Styles); Assert.Contains(".connection-state::before", Styles, StringComparison.Ordinal); Assert.Contains(".connection-state.connected::before", Styles, StringComparison.Ordinal); Assert.Contains( ".connection-state.dry-run:not(.outcome-unknown)::before", Styles, StringComparison.Ordinal); Assert.Contains( "if (connection.textContent !== nextConnectionText)", Playout, StringComparison.Ordinal); } [Fact] public void NavigationStoresOnlyApprovedLocalPresentationState() { Assert.Contains("entry.view.hidden = !isCurrent", Navigation, StringComparison.Ordinal); Assert.Contains( "entry.view.toggleAttribute(\"inert\", !isCurrent)", Navigation, StringComparison.Ordinal); Assert.Contains("settingsTab.addEventListener(\"click\"", Navigation, StringComparison.Ordinal); Assert.Contains("syncMarket(button.dataset.tabId)", Navigation, StringComparison.Ordinal); Assert.Contains("menuButton.click()", Navigation, StringComparison.Ordinal); Assert.Contains("pendingKeyboardMarketTabId", Navigation, StringComparison.Ordinal); Assert.Contains("pendingButton.focus()", Navigation, StringComparison.Ordinal); Assert.Contains("window.requestAnimationFrame(function", Navigation, StringComparison.Ordinal); Assert.Contains("options.authoritativeNativeRender === true", Navigation, StringComparison.Ordinal); Assert.Contains("hasPendingKeyboardMarketFocus", Navigation, StringComparison.Ordinal); Assert.Contains("document.addEventListener(\"pointerdown\"", Navigation, StringComparison.Ordinal); Assert.DoesNotContain("catalogWorkspace.contains(active)", Navigation, StringComparison.Ordinal); Assert.Contains("LegacyWorkspaceNavigation", Navigation, StringComparison.Ordinal); Assert.Contains("nav-collapsed", Navigation, StringComparison.Ordinal); Assert.DoesNotContain("postMessage", Navigation, StringComparison.Ordinal); Assert.DoesNotContain("sessionStorage", Navigation, StringComparison.Ordinal); var storageKeys = Regex.Matches( Navigation, "\"(mbn-stock-webview\\.[^\"]+)\"") .Select(match => match.Groups[1].Value) .ToArray(); Assert.Equal( new[] { "mbn-stock-webview.last-workspace.v1", "mbn-stock-webview.schedule-width.v1" }, storageKeys); Assert.Contains("window.localStorage && window.localStorage.getItem(key)", Navigation, StringComparison.Ordinal); Assert.Contains("window.localStorage.setItem(key, value)", Navigation, StringComparison.Ordinal); } [Fact] public void SplitterClampsPointerAndKeyboardResizingAndEscapeRestoresTheGesture() { foreach (var declaration in new[] { "const scheduleMinimumPercent = 34", "const scheduleMaximumPercent = 48", "const workspaceMinimumPixels = 780", "const scheduleMinimumPixels = 600", "const splitterPixels = 8" }) { Assert.Contains(declaration, Navigation, StringComparison.Ordinal); } Assert.Matches( @"const minimum = Math\.max\([\s\S]*?scheduleMinimumPercent," + @"[\s\S]*?scheduleMinimumPixels / width \* 100\)", Navigation); Assert.Matches( @"const maximum = Math\.min\([\s\S]*?scheduleMaximumPercent," + @"[\s\S]*?\(width - workspaceMinimumPixels - splitterPixels\) / width \* 100\)", Navigation); Assert.Contains( "return Math.min(bounds.maximum, Math.max(bounds.minimum, value))", Navigation, StringComparison.Ordinal); Assert.Contains( "shell.style.setProperty(\"--schedule-pane-width\", serialized)", Navigation, StringComparison.Ordinal); foreach (var attribute in new[] { "aria-valuemin", "aria-valuemax", "aria-valuenow", "aria-valuetext" }) { Assert.Contains($"splitter.setAttribute(\"{attribute}\"", Navigation, StringComparison.Ordinal); } Assert.Contains( "splitter.setAttribute(\"aria-valuemin\", bounds.minimum.toFixed(1))", Navigation, StringComparison.Ordinal); Assert.Contains( "splitter.setAttribute(\"aria-valuemax\", bounds.maximum.toFixed(1))", Navigation, StringComparison.Ordinal); Assert.Contains( "splitter.setAttribute(\"aria-valuenow\", scheduleWidthPercent.toFixed(1))", Navigation, StringComparison.Ordinal); Assert.DoesNotMatch( @"aria-valuemin""[\s\S]{0,80}Math\.ceil|" + @"aria-valuemax""[\s\S]{0,80}Math\.floor", Navigation); Assert.Contains( "event.button !== 0 || event.isPrimary === false || splitterGesture", Navigation, StringComparison.Ordinal); Assert.Contains("splitter.setPointerCapture?.(event.pointerId)", Navigation, StringComparison.Ordinal); foreach (var eventName in new[] { "pointerdown", "pointermove", "pointerup", "pointercancel", "lostpointercapture" }) { Assert.Contains($"splitter.addEventListener(\"{eventName}\"", Navigation, StringComparison.Ordinal); } Assert.Matches( @"event\.key === ""Escape"" && splitterGesture[\s\S]*?" + @"event\.stopImmediatePropagation\(\)[\s\S]*?finishSplitterGesture\(true\)", Navigation); Assert.Contains("event.key === \"ArrowLeft\") next += 2", Navigation, StringComparison.Ordinal); Assert.Contains("event.key === \"ArrowRight\") next -= 2", Navigation, StringComparison.Ordinal); Assert.Contains("event.key === \"Home\") next = numericScheduleBounds().minimum", Navigation, StringComparison.Ordinal); Assert.Contains("event.key === \"End\") next = numericScheduleBounds().maximum", Navigation, StringComparison.Ordinal); Assert.Matches( @"applyScheduleWidth\(\s*restore === true \? gesture\.startPercent" + @" : scheduleWidthPercent,[\s\S]*?restore !== true\)", Navigation); } [Fact] public void ExplicitMenuInputWinsOverDeferredLastWorkspaceRestore() { Assert.Matches( @"stockTab\.addEventListener\(""click"", function \(\) \{\s*" + @"startPreferenceApplied = true;\s*setWorkspace\(""stock""\);\s*\}\)", Navigation); Assert.Matches( @"settingsTab\.addEventListener\(""click"", function \(\) \{\s*" + @"startPreferenceApplied = true;\s*setWorkspace\(""settings""\);\s*\}\)", Navigation); Assert.Matches( @"marketTabs\.addEventListener\(""click"", function \(event\) \{" + @"[\s\S]*?if \(!button \|\| !marketTabs\.contains\(button\)\) return;\s*" + @"startPreferenceApplied = true;\s*syncMarket\(button\.dataset\.tabId\);\s*" + @"setWorkspace\(""catalog""\);", Navigation); } private static string Slice(string source, string startMarker, string endMarker) { var start = source.IndexOf(startMarker, StringComparison.Ordinal); Assert.True(start >= 0, $"Missing start marker: {startMarker}"); var end = source.IndexOf(endMarker, start, StringComparison.Ordinal); Assert.True(end > start, $"Missing end marker: {endMarker}"); return source[start..end]; } 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 could not be located."); } }