feat: modernize legacy operator interface
This commit is contained in:
89
tests/LegacyParityWeb/operator-visual-accessibility.test.cjs
Normal file
89
tests/LegacyParityWeb/operator-visual-accessibility.test.cjs
Normal file
@@ -0,0 +1,89 @@
|
||||
"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/);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ function option(index, selected) {
|
||||
const classes = new Set(selected ? ["selected"] : []);
|
||||
return {
|
||||
dataset: { resultIndex: String(index) },
|
||||
offsetHeight: 17,
|
||||
offsetHeight: 24,
|
||||
tabIndex: selected ? 0 : -1,
|
||||
focused: false,
|
||||
scrolled: false,
|
||||
@@ -60,7 +60,7 @@ function keyboardHarness(selectedIndex = -1, count = 30) {
|
||||
{ length: count },
|
||||
(_, index) => option(index, index === selectedIndex)));
|
||||
const stockResults = {
|
||||
clientHeight: 170,
|
||||
clientHeight: 240,
|
||||
querySelectorAll() { return options; }
|
||||
};
|
||||
const source = sliceBetween(
|
||||
@@ -140,7 +140,8 @@ test("stock option keydown consumes list navigation but lets Home and End bubble
|
||||
assert.doesNotMatch(factory, /stopPropagation|stopImmediatePropagation/);
|
||||
assert.doesNotMatch(factory, /dblclick|add-playlist|append-playlist|cut-pointer-down/);
|
||||
assert.match(styles,
|
||||
/\.stock-result \{[^}]*height: 17px;[^}]*line-height: 17px;/s);
|
||||
/\.stock-result \{[^}]*height: 24px;[^}]*line-height: 24px;/s);
|
||||
assert.match(styles, /\.stock-results \{ height: 242px; \}/);
|
||||
});
|
||||
|
||||
test("rendering keeps exactly one tab stop, falling back to the first unselected row", () => {
|
||||
|
||||
@@ -170,7 +170,7 @@ test("state rendering preserves stock and playlist focus like native controls",
|
||||
assert.doesNotMatch(app, /playlistRows\.replaceChildren/);
|
||||
});
|
||||
|
||||
test("playlist keeps the gray candidate separate from the pink on-air row and page", () => {
|
||||
test("playlist keeps candidate, selection, and pink on-air states independent", () => {
|
||||
const sourceStart = app.indexOf("function playlistPlayoutPresentation");
|
||||
const sourceEnd = app.indexOf("function presentCurrentPlaylistRow", sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
|
||||
@@ -52,6 +52,30 @@ test("layout uses a two-to-one work and schedule split with compact schedule col
|
||||
assert.match(styles, /\.playlist-row\s*\{[^}]*min-width:\s*600px/);
|
||||
});
|
||||
|
||||
test("modern operator theme keeps clear semantic states and accessible focus", () => {
|
||||
assert.match(styles,
|
||||
/Modern operator theme: visual-only overrides; geometry and native contracts stay intact/);
|
||||
for (const token of [
|
||||
"--ui-canvas",
|
||||
"--ui-accent",
|
||||
"--ui-success",
|
||||
"--ui-danger"
|
||||
]) {
|
||||
assert.match(styles, new RegExp(`${token}:`));
|
||||
}
|
||||
|
||||
assert.match(styles,
|
||||
/font-family:\s*"Segoe UI Variable",\s*"Segoe UI",\s*"Noto Sans KR"/);
|
||||
assert.match(styles, /button:focus-visible,[\s\S]*?outline:\s*2px solid var\(--ui-accent\)/);
|
||||
assert.match(styles, /\.playlist-data-row\.on-air\.selected\s*\{/);
|
||||
assert.match(styles, /\.playout-actions \.take-in:not\(:disabled\)\s*\{/);
|
||||
assert.match(styles, /\.playout-actions \.next:not\(:disabled\)\s*\{/);
|
||||
assert.match(styles, /\.playout-actions \.take-out:not\(:disabled\)\s*\{/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion:\s*reduce\)/);
|
||||
assert.match(markup, /id="stock-search-button"[\s\S]*?<svg[^>]*viewBox="0 0 24 24"/);
|
||||
assert.match(markup, /class="playlist-heading-icon"[\s\S]*?<svg[^>]*viewBox="0 0 24 24"/);
|
||||
});
|
||||
|
||||
test("workspace navigation is local presentation state and preserves native contracts", () => {
|
||||
assert.match(navigation, /entry\.view\.hidden = !isCurrent/);
|
||||
assert.match(navigation, /entry\.view\.toggleAttribute\("inert", !isCurrent\)/);
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
"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 Navigation = File.ReadAllText(
|
||||
Path.Combine(WebRoot, "workspace-navigation.js"));
|
||||
|
||||
@@ -54,6 +55,62 @@ public sealed class LegacyWorkspaceLayoutContractTests
|
||||
Styles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModernOperatorThemePreservesAccessibleAndSemanticStates()
|
||||
{
|
||||
Assert.Contains(
|
||||
"Modern operator theme: visual-only overrides; geometry and native contracts stay intact",
|
||||
Styles,
|
||||
StringComparison.Ordinal);
|
||||
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(
|
||||
".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]*?<svg[^>]*viewBox=""0 0 24 24""",
|
||||
RegexOptions.CultureInvariant),
|
||||
Markup);
|
||||
Assert.Matches(
|
||||
new Regex(
|
||||
@"class=""playlist-heading-icon""[\s\S]*?<svg[^>]*viewBox=""0 0 24 24""",
|
||||
RegexOptions.CultureInvariant),
|
||||
Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigationChangesPresentationWithoutNativeOrStoredState()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user