feat: add configurable operator appearance and layout
This commit is contained in:
@@ -13,6 +13,8 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
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"));
|
||||
|
||||
@@ -26,6 +28,7 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
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);
|
||||
@@ -35,14 +38,27 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Assert.True(stockStart > settingsTabStart);
|
||||
Assert.True(catalogStart > stockStart);
|
||||
Assert.True(settingsStart > catalogStart);
|
||||
Assert.True(scheduleStart > settingsStart);
|
||||
Assert.True(splitterStart > settingsStart);
|
||||
Assert.True(scheduleStart > splitterStart);
|
||||
Assert.Matches(
|
||||
@"<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*<section class=""playlist-panel""",
|
||||
@"<\/section>\s*<\/div>\s*<\/section>\s*<\/section>\s*" +
|
||||
@"<div id=""workspace-splitter""[^>]*><\/div>\s*" +
|
||||
@"<section class=""playlist-panel""",
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
@"id=""workspace-splitter""[^>]*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("<small>항상 표시</small><h2>송출 스케줄</h2>", Markup,
|
||||
Assert.Contains("<div><h2>송출 스케줄</h2></div>", Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("항상 표시", Markup, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,12 +84,27 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkAndScheduleGiveTheAlwaysVisibleScheduleAWiderResponsiveShare()
|
||||
public void WorkAndScheduleExposeAnAccessibleResizableThreeColumnLayout()
|
||||
{
|
||||
Assert.Contains(
|
||||
"grid-template-columns: minmax(900px, 1.75fr) minmax(600px, 1fr);",
|
||||
Styles,
|
||||
Assert.Matches(
|
||||
@"\.operator-shell\s*\{[^}]*grid-template-columns:\s*" +
|
||||
@"minmax\(780px,\s*1fr\)\s+var\(--workspace-splitter-width\)\s+" +
|
||||
@"minmax\(600px,\s*var\(--schedule-pane-width,[^)]+\)\)",
|
||||
Styles);
|
||||
Assert.Matches(@"--workspace-splitter-width:\s*8px", Styles);
|
||||
Assert.Matches(
|
||||
@"\.workspace-splitter\s*\{[^}]*cursor:\s*col-resize" +
|
||||
@"[^}]*touch-action:\s*none",
|
||||
Styles);
|
||||
Assert.Matches(
|
||||
@"body\.resizing-workspace\s*\{[^}]*cursor:\s*col-resize" +
|
||||
@"[^}]*user-select:\s*none",
|
||||
Styles);
|
||||
Assert.Contains(".workspace-splitter:focus-visible", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"@media \(forced-colors:\s*active\)[\s\S]*?\.workspace-splitter",
|
||||
Styles);
|
||||
Assert.Contains(
|
||||
"grid-template-rows: 66px minmax(0, 1fr) auto;",
|
||||
Styles,
|
||||
@@ -116,12 +147,16 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModernOperatorThemePreservesAccessibleAndSemanticStates()
|
||||
public void UnifiedOperatorThemePreservesAccessibleAndSemanticStates()
|
||||
{
|
||||
Assert.Contains(
|
||||
"Modern operator theme: visual-only overrides; geometry and native contracts stay intact",
|
||||
"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<Match>());
|
||||
Assert.Contains("--ui-canvas:", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("--ui-accent:", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("--ui-success:", Styles, StringComparison.Ordinal);
|
||||
@@ -139,10 +174,6 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
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(
|
||||
"Contemporary density refinement: keep operator geometry, clarify visual hierarchy",
|
||||
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);
|
||||
@@ -188,6 +219,84 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleHeaderAndToolbarExposeClearStatesAndExistingShortcuts()
|
||||
{
|
||||
Assert.Matches(
|
||||
@"class=""playlist-state-legend""[^>]*aria-label=""스케줄 상태""[\s\S]*?" +
|
||||
@"class=""legend-selected""[\s\S]*?>선택</span>[\s\S]*?" +
|
||||
@"class=""legend-next""[\s\S]*?>다음</span>[\s\S]*?" +
|
||||
@"class=""legend-on-air""[\s\S]*?>ON AIR</span>",
|
||||
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,
|
||||
"<div class=\"playlist-toolbar\">",
|
||||
"<div id=\"playlist-drop-zone\" class=\"playlist-grid\"");
|
||||
foreach (var group in new[] { "표시", "순서", "배경", "DB", "삭제" })
|
||||
{
|
||||
Assert.Matches(
|
||||
$"class=\"playlist-toolbar-group[^\"]*\"[^>]*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()
|
||||
{
|
||||
@@ -198,23 +307,21 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Assert.Matches(
|
||||
@"class=""workspace-heading""[\s\S]*?class=""workspace-heading-icon""" +
|
||||
@"[\s\S]*?<div class=""workspace-heading-copy"">[\s\S]*?" +
|
||||
@"<small>현재 작업</small>[\s\S]*?id=""workspace-title""",
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
@"class=""playout-connection-summary""[\s\S]*?<small>송출 연결</small>" +
|
||||
@"[\s\S]*?<strong>Tornado2</strong>[\s\S]*?id=""playout-connection-state""",
|
||||
@"<h1 id=""workspace-title"">종목·컷</h1>",
|
||||
Markup);
|
||||
var header = Slice(Markup, "<header class=\"workspace-header\">", "</header>");
|
||||
Assert.DoesNotContain("<small>", header, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("playout-connection-copy", header, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<strong>Tornado2</strong>", header, StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"id=""playout-connection-state""[^>]*role=""status""" +
|
||||
@"[^>]*aria-label=""Tornado2 연결 상태""[^>]*aria-live=""polite""" +
|
||||
@"[^>]*aria-atomic=""true"">미연결</div>",
|
||||
Markup);
|
||||
Assert.Contains(".workspace-header::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-heading::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".connection-state::before", Styles, StringComparison.Ordinal);
|
||||
Assert.Matches(
|
||||
@"\.playout-connection-copy small\s*\{[^}]*color:\s*var\(--ui-text-muted\)",
|
||||
@"\.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",
|
||||
@@ -222,12 +329,12 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"if (connection.textContent !== nextConnectionText)",
|
||||
File.ReadAllText(Path.Combine(WebRoot, "playout-ui.js")),
|
||||
Playout,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigationChangesPresentationWithoutNativeOrStoredState()
|
||||
public void NavigationStoresOnlyApprovedLocalPresentationState()
|
||||
{
|
||||
Assert.Contains("entry.view.hidden = !isCurrent", Navigation, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
@@ -254,8 +361,143 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Assert.Contains("LegacyWorkspaceNavigation", Navigation, StringComparison.Ordinal);
|
||||
Assert.Contains("nav-collapsed", Navigation, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("postMessage", Navigation, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("localStorage", 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()
|
||||
|
||||
Reference in New Issue
Block a user