feat: add configurable operator appearance and layout
This commit is contained in:
@@ -1278,7 +1278,16 @@ async function readWorkspaceLayout() {
|
||||
const stock = document.getElementById("stock-workspace");
|
||||
const catalog = document.getElementById("catalog-workspace");
|
||||
const settings = document.getElementById("settings-workspace");
|
||||
const splitter = document.getElementById("workspace-splitter");
|
||||
const schedule = document.querySelector(".playlist-panel");
|
||||
const publishedScheduleWidth = window.LegacyWorkspaceNavigation?.scheduleWidth?.();
|
||||
let storedScheduleWidth = null;
|
||||
try {
|
||||
storedScheduleWidth = window.localStorage?.getItem(
|
||||
"mbn-stock-webview.schedule-width.v1") ?? null;
|
||||
} catch (_) {
|
||||
storedScheduleWidth = null;
|
||||
}
|
||||
const scheduleBounds = schedule?.getBoundingClientRect() || null;
|
||||
const scheduleHit = scheduleBounds && scheduleBounds.width > 0 && scheduleBounds.height > 0
|
||||
? document.elementFromPoint(
|
||||
@@ -1294,6 +1303,24 @@ async function readWorkspaceLayout() {
|
||||
region: rectangle(region),
|
||||
navigation: rectangle(navigation),
|
||||
surface: rectangle(surface),
|
||||
splitter: rectangle(splitter),
|
||||
splitterState: splitter ? {
|
||||
role: splitter.getAttribute("role"),
|
||||
orientation: splitter.getAttribute("aria-orientation"),
|
||||
controls: splitter.getAttribute("aria-controls"),
|
||||
valueMinimum: Number(splitter.getAttribute("aria-valuemin")),
|
||||
valueMaximum: Number(splitter.getAttribute("aria-valuemax")),
|
||||
valueNow: Number(splitter.getAttribute("aria-valuenow")),
|
||||
valueText: splitter.getAttribute("aria-valuetext"),
|
||||
tabIndex: splitter.tabIndex,
|
||||
focused: document.activeElement === splitter,
|
||||
dragging: splitter.classList.contains("dragging")
|
||||
} : null,
|
||||
scheduleWidthPercent: Number.isFinite(publishedScheduleWidth)
|
||||
? publishedScheduleWidth
|
||||
: null,
|
||||
scheduleCssWidth: shell?.style?.getPropertyValue("--schedule-pane-width") || null,
|
||||
storedScheduleWidth,
|
||||
schedule: rectangle(schedule),
|
||||
scheduleHitOwned: Boolean(schedule && scheduleHit && schedule.contains(scheduleHit)),
|
||||
activeWorkspace: region?.dataset?.activeWorkspace || null,
|
||||
@@ -1380,21 +1407,36 @@ async function waitForWorkspaceLayout(label, predicate) {
|
||||
|
||||
function assertWorkspaceAndScheduleGeometry(layout, label) {
|
||||
if (!layout?.shell || !layout.region || !layout.navigation || !layout.surface ||
|
||||
!layout.splitter || layout.splitter.width <= 0 || layout.splitter.height <= 0 ||
|
||||
!layout.schedule || layout.schedule.width <= 0 || layout.schedule.height <= 0 ||
|
||||
layout.scheduleHitOwned !== true) {
|
||||
failKnown(`${label} does not expose the complete workspace and fixed schedule geometry: ` +
|
||||
failKnown(`${label} does not expose the complete workspace, splitter, and schedule geometry: ` +
|
||||
JSON.stringify(layout));
|
||||
}
|
||||
const widthRatio = layout.region.width / layout.schedule.width;
|
||||
const workspaceShare = layout.region.width / layout.shell.width;
|
||||
if (!Number.isFinite(widthRatio) || widthRatio < 1.60 || widthRatio > 1.90 ||
|
||||
!Number.isFinite(workspaceShare) || workspaceShare < 0.62 || workspaceShare > 0.70 ||
|
||||
const scheduleShare = layout.schedule.width / layout.shell.width;
|
||||
const splitterState = layout.splitterState;
|
||||
if (!Number.isFinite(widthRatio) || widthRatio < 1.05 || widthRatio > 1.95 ||
|
||||
!Number.isFinite(workspaceShare) || workspaceShare < 0.50 || workspaceShare > 0.67 ||
|
||||
!Number.isFinite(scheduleShare) || scheduleShare < 0.33 || scheduleShare > 0.49 ||
|
||||
splitterState?.role !== "separator" || splitterState.orientation !== "vertical" ||
|
||||
splitterState.controls !== "workspace-region" || splitterState.tabIndex !== 0 ||
|
||||
!Number.isFinite(splitterState.valueMinimum) || splitterState.valueMinimum < 34 ||
|
||||
!Number.isFinite(splitterState.valueMaximum) || splitterState.valueMaximum > 48 ||
|
||||
!Number.isFinite(splitterState.valueNow) ||
|
||||
splitterState.valueNow < splitterState.valueMinimum ||
|
||||
splitterState.valueNow > splitterState.valueMaximum ||
|
||||
Math.abs(layout.region.right - layout.splitter.left) > 1.5 ||
|
||||
Math.abs(layout.splitter.right - layout.schedule.left) > 1.5 ||
|
||||
Math.abs(layout.schedule.right - layout.shell.right) > 1.5 ||
|
||||
Math.abs(layout.splitter.top - layout.shell.top) > 1.5 ||
|
||||
Math.abs(layout.splitter.bottom - layout.shell.bottom) > 1.5 ||
|
||||
Math.abs(layout.schedule.top - layout.shell.top) > 1.5 ||
|
||||
Math.abs(layout.schedule.bottom - layout.shell.bottom) > 1.5) {
|
||||
failKnown(`${label} does not preserve the approximately 1.75:1 workspace/schedule split.`);
|
||||
failKnown(`${label} does not preserve the adjustable workspace/schedule split.`);
|
||||
}
|
||||
return { widthRatio, workspaceShare };
|
||||
return { widthRatio, workspaceShare, scheduleShare };
|
||||
}
|
||||
|
||||
function assertFixedSchedule(baseline, current, label) {
|
||||
@@ -1406,6 +1448,149 @@ function assertFixedSchedule(baseline, current, label) {
|
||||
assertWorkspaceAndScheduleGeometry(current, label);
|
||||
}
|
||||
|
||||
function assertSplitterResizeResult(layout, expectedPercent, label) {
|
||||
assertWorkspaceAndScheduleGeometry(layout, label);
|
||||
const storedPercent = Number.parseFloat(layout.storedScheduleWidth);
|
||||
const cssPercent = Number.parseFloat(layout.scheduleCssWidth);
|
||||
const geometryPercent = layout.schedule.width / layout.shell.width * 100;
|
||||
if (!Number.isFinite(layout.scheduleWidthPercent) ||
|
||||
Math.abs(layout.scheduleWidthPercent - expectedPercent) > 0.15 ||
|
||||
!Number.isFinite(storedPercent) ||
|
||||
Math.abs(storedPercent - layout.scheduleWidthPercent) > 0.01 ||
|
||||
!Number.isFinite(cssPercent) ||
|
||||
Math.abs(cssPercent - layout.scheduleWidthPercent) > 0.01 ||
|
||||
Math.abs(geometryPercent - layout.scheduleWidthPercent) > 0.15 ||
|
||||
!Number.isFinite(layout.splitterState?.valueNow) ||
|
||||
Math.abs(layout.splitterState.valueNow -
|
||||
Number(layout.scheduleWidthPercent.toFixed(1))) > 0.001 ||
|
||||
layout.splitterState.valueText !==
|
||||
`송출 스케줄 ${Math.round(layout.scheduleWidthPercent)}%` ||
|
||||
layout.splitterState.focused !== true || layout.splitterState.dragging !== false) {
|
||||
failKnown(`${label} did not synchronize splitter geometry, ARIA, CSS, and local storage: ` +
|
||||
JSON.stringify(layout));
|
||||
}
|
||||
return layout.scheduleWidthPercent;
|
||||
}
|
||||
|
||||
async function dragWorkspaceSplitterToPercent(beforeLayout, targetPercent, label) {
|
||||
await waitForPointerReady(label);
|
||||
const start = {
|
||||
x: beforeLayout.splitter.left + beforeLayout.splitter.width / 2,
|
||||
y: beforeLayout.splitter.top + beforeLayout.splitter.height / 2
|
||||
};
|
||||
const target = {
|
||||
x: beforeLayout.shell.right - beforeLayout.shell.width * targetPercent / 100,
|
||||
y: start.y
|
||||
};
|
||||
const before = await readSnapshot();
|
||||
assertSafety(before, `${label} preflight`, false);
|
||||
evidence.inputs.push({
|
||||
type: "workspace-splitter-drag",
|
||||
label,
|
||||
start,
|
||||
target,
|
||||
targetPercent
|
||||
});
|
||||
try {
|
||||
await pressMouse(start, 0);
|
||||
await sleep(25);
|
||||
await moveMouse(target, 0, 1);
|
||||
await sleep(25);
|
||||
await releaseMouse(target, 0);
|
||||
} catch (error) {
|
||||
await cleanupDragInputSession(false);
|
||||
throw error;
|
||||
}
|
||||
const resized = await waitForWorkspaceLayout(label, layout =>
|
||||
Number.isFinite(layout.scheduleWidthPercent) &&
|
||||
Math.abs(layout.scheduleWidthPercent - targetPercent) <= 0.15 &&
|
||||
Number.isFinite(Number.parseFloat(layout.storedScheduleWidth)) &&
|
||||
Math.abs(Number.parseFloat(layout.storedScheduleWidth) -
|
||||
layout.scheduleWidthPercent) <= 0.01);
|
||||
const after = await readSnapshot();
|
||||
assertSafety(after, label, false);
|
||||
if (after.safety.outboundMessages.length !== before.safety.outboundMessages.length) {
|
||||
failKnown(`${label} emitted a native WebView intent.`);
|
||||
}
|
||||
return resized;
|
||||
}
|
||||
|
||||
async function exerciseWorkspaceSplitter(baseline) {
|
||||
const baselinePercent = baseline.scheduleWidthPercent;
|
||||
const minimum = Math.max(34, 600 / baseline.shell.width * 100);
|
||||
const maximum = Math.min(48, (baseline.shell.width - 780 - 8) /
|
||||
baseline.shell.width * 100);
|
||||
const range = maximum - minimum;
|
||||
if (!Number.isFinite(baselinePercent) || range < 3 ||
|
||||
baselinePercent < minimum - 0.15 || baselinePercent > maximum + 0.15) {
|
||||
failKnown("The rendered window has no safe splitter resize range.");
|
||||
}
|
||||
|
||||
const lowerTarget = minimum + range / 3;
|
||||
const upperTarget = maximum - range / 3;
|
||||
const pointerTarget = Math.abs(baselinePercent - lowerTarget) >=
|
||||
Math.abs(baselinePercent - upperTarget)
|
||||
? lowerTarget
|
||||
: upperTarget;
|
||||
const pointerLayout = await dragWorkspaceSplitterToPercent(
|
||||
baseline,
|
||||
pointerTarget,
|
||||
"drag workspace splitter");
|
||||
const pointerPercent = assertSplitterResizeResult(
|
||||
pointerLayout,
|
||||
pointerTarget,
|
||||
"workspace splitter pointer resize");
|
||||
|
||||
const leftExpected = Math.min(maximum, pointerPercent + 2);
|
||||
const rightExpected = Math.max(minimum, pointerPercent - 2);
|
||||
const keyboardKey = Math.abs(leftExpected - baselinePercent) >=
|
||||
Math.abs(rightExpected - baselinePercent)
|
||||
? "ArrowLeft"
|
||||
: "ArrowRight";
|
||||
const keyboardExpected = keyboardKey === "ArrowLeft" ? leftExpected : rightExpected;
|
||||
if (Math.abs(keyboardExpected - pointerPercent) < 0.25) {
|
||||
failKnown("The splitter has no safe keyboard resize step after pointer input.");
|
||||
}
|
||||
const beforeKeyboard = await readSnapshot();
|
||||
assertSafety(beforeKeyboard, "workspace splitter keyboard resize preflight", false);
|
||||
await pressKey(keyboardKey, "resize workspace splitter from keyboard");
|
||||
const keyboardLayout = await waitForWorkspaceLayout(
|
||||
"workspace splitter keyboard resize",
|
||||
layout => Number.isFinite(layout.scheduleWidthPercent) &&
|
||||
Math.abs(layout.scheduleWidthPercent - keyboardExpected) <= 0.15 &&
|
||||
Number.isFinite(Number.parseFloat(layout.storedScheduleWidth)) &&
|
||||
Math.abs(Number.parseFloat(layout.storedScheduleWidth) -
|
||||
layout.scheduleWidthPercent) <= 0.01);
|
||||
const keyboardPercent = assertSplitterResizeResult(
|
||||
keyboardLayout,
|
||||
keyboardExpected,
|
||||
"workspace splitter keyboard resize");
|
||||
const afterKeyboard = await readSnapshot();
|
||||
assertSafety(afterKeyboard, "workspace splitter keyboard resize", false);
|
||||
if (afterKeyboard.safety.outboundMessages.length !==
|
||||
beforeKeyboard.safety.outboundMessages.length) {
|
||||
failKnown("The workspace splitter keyboard resize emitted a native WebView intent.");
|
||||
}
|
||||
|
||||
const restoredLayout = await dragWorkspaceSplitterToPercent(
|
||||
keyboardLayout,
|
||||
baselinePercent,
|
||||
"restore workspace splitter width");
|
||||
const restoredPercent = assertSplitterResizeResult(
|
||||
restoredLayout,
|
||||
baselinePercent,
|
||||
"restored workspace splitter width");
|
||||
assertFixedSchedule(baseline, restoredLayout, "restored workspace splitter geometry");
|
||||
return {
|
||||
baselinePercent,
|
||||
pointerPercent,
|
||||
keyboardKey,
|
||||
keyboardPercent,
|
||||
restoredPercent,
|
||||
storedPercent: Number.parseFloat(restoredLayout.storedScheduleWidth)
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureWorkspace(workspace, label) {
|
||||
let layout = await readWorkspaceLayout();
|
||||
if (!workspaceLayoutMatches(layout, workspace)) {
|
||||
@@ -1430,9 +1615,14 @@ async function ensureWorkspace(workspace, label) {
|
||||
}
|
||||
|
||||
async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
const baseline = await ensureWorkspace("stock", "workspace stock preflight");
|
||||
let baseline = await ensureWorkspace("stock", "workspace stock preflight");
|
||||
if (baseline.navigationExpanded !== true || baseline.navigationCollapsedClass === true) {
|
||||
failKnown("The workspace menu was not initially expanded.");
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-nav-toggle")',
|
||||
"expand workspace menu before layout verification");
|
||||
baseline = await waitForWorkspaceLayout("expanded workspace stock preflight", layout =>
|
||||
workspaceLayoutMatches(layout, "stock", true) &&
|
||||
layout.navigationCollapsedClass === false);
|
||||
}
|
||||
const firstMarket = baseline.marketTabs[0];
|
||||
if (!firstMarket?.tabId || !firstMarket.label) {
|
||||
@@ -1441,6 +1631,7 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
const baselineRatio = assertWorkspaceAndScheduleGeometry(
|
||||
baseline,
|
||||
"expanded stock workspace preflight");
|
||||
const splitterResize = await exerciseWorkspaceSplitter(baseline);
|
||||
|
||||
await pointerClick(
|
||||
'document.getElementById("workspace-nav-toggle")',
|
||||
@@ -1602,9 +1793,11 @@ async function verifyWorkspaceNavigationAndLayout(preflight) {
|
||||
await checkpoint("workspace-navigation-layout", {
|
||||
widthRatio: Number(baselineRatio.widthRatio.toFixed(4)),
|
||||
workspaceShare: Number(baselineRatio.workspaceShare.toFixed(4)),
|
||||
scheduleShare: Number(baselineRatio.scheduleShare.toFixed(4)),
|
||||
expandedNavigationWidth: baseline.navigation.width,
|
||||
collapsedNavigationWidth: collapsed.navigation.width,
|
||||
schedule: baseline.schedule,
|
||||
splitterResize,
|
||||
graphicMenuCount: baseline.marketTabs.length,
|
||||
pointerTransitions: ["collapse", firstMarket.tabId, "expand", "settings", "stock"],
|
||||
verticalMenuDrag: [
|
||||
@@ -2023,6 +2216,8 @@ async function dragOperatorCatalogRow(sourceRowId, targetRowId, position, label)
|
||||
}
|
||||
|
||||
const keyDefinitions = Object.freeze({
|
||||
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", virtualKey: 37 },
|
||||
ArrowRight: { key: "ArrowRight", code: "ArrowRight", virtualKey: 39 },
|
||||
ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 },
|
||||
ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 },
|
||||
Backspace: { key: "Backspace", code: "Backspace", virtualKey: 8 },
|
||||
|
||||
Reference in New Issue
Block a user