feat: restore legacy modal and drag behavior

This commit is contained in:
2026-07-16 01:33:48 +09:00
parent 83398044c6
commit d7ec571343
30 changed files with 3597 additions and 321 deletions

View File

@@ -0,0 +1,140 @@
"use strict";
const assert = require("node:assert/strict");
const path = require("node:path");
const test = require("node:test");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const cutDrag = require(path.join(
repositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"Web",
"legacy-cut-drag.js"));
test("creates the original asymmetric mouse-down rectangle", () => {
const rectangle = cutDrag.createDragRectangle(
{ x: 100, y: 80 },
{ width: 10, height: 6 });
assert.deepEqual(rectangle, {
left: 90,
top: 74,
width: 10,
height: 6,
right: 100,
bottom: 80
});
assert.equal(Object.isFrozen(rectangle), true);
});
test("matches Rectangle.Contains right/bottom-exclusive drag decisions", () => {
const down = { x: 100, y: 80 };
const rectangle = cutDrag.createDragRectangle(
down,
{ width: 10, height: 6 });
assert.equal(cutDrag.shouldStart(rectangle, down), true, "same point");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 101, y: 79 }), true, "right");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 99, y: 81 }), true, "down");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 89, y: 79 }), true, "left boundary crossed");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 99, y: 73 }), true, "top boundary crossed");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 90, y: 74 }), false, "inside upper-left");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 90, y: 79 }), false, "left is inclusive");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 99, y: 74 }), false, "top is inclusive");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 100, y: 79 }), true, "right is exclusive");
assert.equal(
cutDrag.shouldStart(rectangle, { x: 99, y: 80 }), true, "bottom is exclusive");
});
test("validates finite coordinates and positive finite drag sizes", () => {
const validSize = { width: 4, height: 4 };
for (const down of [
null,
{ x: Number.NaN, y: 0 },
{ x: 0, y: Number.POSITIVE_INFINITY },
{ x: "0", y: 0 }
]) {
assert.throws(
() => cutDrag.createDragRectangle(down, validSize),
TypeError);
}
for (const dragSize of [
null,
{ width: 0, height: 1 },
{ width: 1, height: -1 },
{ width: Number.NaN, height: 1 },
{ width: 1, height: Number.POSITIVE_INFINITY }
]) {
assert.throws(
() => cutDrag.createDragRectangle({ x: 0, y: 0 }, dragSize),
RangeError);
}
const rectangle = cutDrag.createDragRectangle(
{ x: 4, y: 4 },
validSize);
assert.throws(
() => cutDrag.shouldStart(rectangle, { x: Number.NaN, y: 0 }),
TypeError);
});
test("maps DIP metrics to CSS pixels at 100 and 150 percent host DPI", () => {
assert.deepEqual(cutDrag.toCssDragSize(
{ width: 4, height: 6 }, 1, 1),
{ width: 4, height: 6 });
assert.deepEqual(cutDrag.toCssDragSize(
{ width: 4, height: 6 }, 1.5, 1.5),
{ width: 4, height: 6 });
});
test("accounts for a retained WebView browser zoom in pointer CSS coordinates", () => {
assert.deepEqual(cutDrag.toCssDragSize(
{ width: 4, height: 6 }, 1.5, 1.875),
{ width: 3.2, height: 4.8 });
assert.deepEqual(cutDrag.toCssDragSize(
{ width: 4, height: 6 }, 1, 1.25),
{ width: 3.2, height: 4.8 });
});
test("keeps fractional CSS boundaries after retained zoom conversion", () => {
const size = cutDrag.toCssDragSize({ width: 4, height: 4 }, 1, 1.25);
const rectangle = cutDrag.createDragRectangle({ x: 10.5, y: 10.5 }, size);
assert.equal(cutDrag.shouldStart(rectangle, { x: 7.31, y: 7.31 }), false);
assert.equal(cutDrag.shouldStart(rectangle, { x: 7.29, y: 7.31 }), true);
assert.equal(cutDrag.shouldStart(rectangle, { x: 7.31, y: 7.29 }), true);
assert.equal(cutDrag.shouldStart(rectangle, { x: 10.5, y: 10.49 }), true);
});
test("uses a neutral zoom ratio for missing or abnormal scale measurements", () => {
for (const scales of [
[0, 0],
[Number.NaN, Number.NaN],
[1, Number.POSITIVE_INFINITY],
[1, 0.1],
[1, 10]
]) {
assert.deepEqual(cutDrag.toCssDragSize(
{ width: 4, height: 6 }, scales[0], scales[1]),
{ width: 4, height: 6 });
}
});
test("publishes the same API as a browser global", () => {
assert.equal(globalThis.LegacyCutDrag, cutDrag);
assert.equal(typeof cutDrag.createDragRectangle, "function");
assert.equal(typeof cutDrag.shouldStart, "function");
assert.equal(typeof cutDrag.toCssDragSize, "function");
});

View File

@@ -0,0 +1,294 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const vm = require("node:vm");
const repositoryRoot = path.resolve(__dirname, "..", "..");
const modulePath = path.join(repositoryRoot, "src",
"MBN_STOCK_WEBVIEW.LegacyParityApp", "Web", "legacy-modal-focus.js");
const modalFocus = require(modulePath);
class FakeElement {
constructor(documentObject, tagName, attributes) {
this.ownerDocument = documentObject;
this.tagName = String(tagName || "div").toUpperCase();
this.parentElement = null;
this.children = [];
this.hidden = false;
this.inert = false;
this.disabled = false;
this.style = { display: "block", visibility: "visible", contentVisibility: "visible" };
this.attributes = new Map();
Object.entries(attributes || {}).forEach(([name, value]) => {
this.attributes.set(name, String(value));
});
}
append(...elements) {
elements.forEach(element => {
element.parentElement = this;
this.children.push(element);
});
}
contains(element) {
return element === this || this.children.some(child => child.contains(element));
}
focus() {
this.ownerDocument.activeElement = this;
}
getAttribute(name) {
return this.attributes.has(name) ? this.attributes.get(name) : null;
}
hasAttribute(name) {
return this.attributes.has(name);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
querySelector(selector) {
const descendants = this.querySelectorAll("*");
if (selector.includes("data-modal-initial-focus")) {
return descendants.find(element =>
element.hasAttribute("data-modal-initial-focus") || element.hasAttribute("autofocus")) || null;
}
if (selector[0] === "#") {
return descendants.find(element => element.getAttribute("id") === selector.slice(1)) || null;
}
return null;
}
querySelectorAll() {
return this.children.flatMap(child => [child, ...child.querySelectorAll("*")]);
}
}
class FakeDocument {
constructor() {
this.body = new FakeElement(this, "body");
this.activeElement = this.body;
this.listeners = new Map();
this.defaultView = {
getComputedStyle(element) { return element.style; }
};
}
addEventListener(type, listener) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(listener);
}
removeEventListener(type, listener) {
if (this.listeners.has(type)) this.listeners.get(type).delete(listener);
}
contains(element) {
return this.body.contains(element);
}
querySelectorAll() {
return this.body.querySelectorAll("*").filter(element => {
const role = element.getAttribute("role");
return role === "dialog" || role === "alertdialog";
});
}
}
function element(documentObject, tagName, attributes) {
return new FakeElement(documentObject, tagName, attributes);
}
function modalFixture() {
const documentObject = new FakeDocument();
const opener = element(documentObject, "button", { id: "opener" });
const backdrop = element(documentObject, "div");
const dialog = element(documentObject, "section", { role: "dialog" });
const first = element(documentObject, "button", { id: "first" });
const preferred = element(documentObject, "input", { id: "preferred" });
const last = element(documentObject, "button", { id: "last" });
dialog.append(first, preferred, last);
backdrop.append(dialog);
documentObject.body.append(opener, backdrop);
backdrop.hidden = true;
opener.focus();
return { documentObject, opener, backdrop, dialog, first, preferred, last };
}
function keyEvent(key, shiftKey) {
return {
key,
shiftKey: !!shiftKey,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; }
};
}
test("exports the same API as a CommonJS module and a browser global", () => {
assert.equal(typeof modalFocus.create, "function");
const source = fs.readFileSync(modulePath, "utf8");
const context = { globalThis: {} };
vm.runInNewContext(source, context);
assert.equal(typeof context.globalThis.LegacyModalFocus.create, "function");
});
test("selects the last visible modal and ignores hidden modal ancestors", () => {
const fixture = modalFixture();
const alertBackdrop = element(fixture.documentObject, "div");
const alert = element(fixture.documentObject, "div", { role: "alertdialog" });
alertBackdrop.append(alert);
fixture.documentObject.body.append(alertBackdrop);
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), alert);
alertBackdrop.hidden = true;
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), null);
fixture.backdrop.hidden = false;
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), fixture.dialog);
});
test("focusable discovery excludes disabled, hidden, inert, and negative-tab controls", () => {
const fixture = modalFixture();
fixture.backdrop.hidden = false;
fixture.first.disabled = true;
fixture.preferred.setAttribute("tabindex", "-1");
const hiddenParent = element(fixture.documentObject, "div");
hiddenParent.hidden = true;
const hiddenButton = element(fixture.documentObject, "button");
hiddenParent.append(hiddenButton);
const inertParent = element(fixture.documentObject, "div", { inert: "" });
const inertButton = element(fixture.documentObject, "button");
inertParent.append(inertButton);
fixture.dialog.append(hiddenParent, inertParent);
assert.deepEqual(modalFocus.getFocusableElements(
fixture.dialog, fixture.documentObject), [fixture.last]);
});
test("open focuses the preferred control and close restores the opener", () => {
const fixture = modalFixture();
const manager = modalFocus.create({ document: fixture.documentObject });
fixture.backdrop.hidden = false;
manager.sync("#preferred");
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
assert.equal(manager.getActiveModal(), fixture.dialog);
fixture.backdrop.hidden = true;
manager.sync();
assert.equal(fixture.documentObject.activeElement, fixture.opener);
assert.equal(manager.getActiveModal(), null);
});
test("open falls back to the first enabled visible control", () => {
const fixture = modalFixture();
const manager = modalFocus.create({ document: fixture.documentObject });
fixture.backdrop.hidden = false;
manager.sync("#missing");
assert.equal(fixture.documentObject.activeElement, fixture.first);
});
test("Tab and Shift+Tab wrap inside the active modal without handling Escape", () => {
const fixture = modalFixture();
const manager = modalFocus.create({ document: fixture.documentObject });
fixture.backdrop.hidden = false;
manager.sync();
fixture.last.focus();
const forward = keyEvent("Tab", false);
assert.equal(manager.handleKeydown(forward), true);
assert.equal(forward.defaultPrevented, true);
assert.equal(fixture.documentObject.activeElement, fixture.first);
const backward = keyEvent("Tab", true);
assert.equal(manager.handleKeydown(backward), true);
assert.equal(backward.defaultPrevented, true);
assert.equal(fixture.documentObject.activeElement, fixture.last);
fixture.preferred.focus();
const middle = keyEvent("Tab", false);
assert.equal(manager.handleKeydown(middle), false);
assert.equal(middle.defaultPrevented, false);
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
const escape = keyEvent("Escape", false);
assert.equal(manager.handleKeydown(escape), false);
assert.equal(escape.defaultPrevented, false);
assert.equal(manager.getActiveModal(), fixture.dialog);
});
test("a dialog without controls retains Tab focus on its container", () => {
const documentObject = new FakeDocument();
const dialog = element(documentObject, "section", { role: "dialog" });
documentObject.body.append(dialog);
const manager = modalFocus.create({ document: documentObject });
manager.sync();
const tab = keyEvent("Tab", false);
assert.equal(manager.handleKeydown(tab), true);
assert.equal(tab.defaultPrevented, true);
assert.equal(dialog.getAttribute("tabindex"), "-1");
assert.equal(documentObject.activeElement, dialog);
});
test("closing a nested alert restores its modal opener before the root opener", () => {
const fixture = modalFixture();
const manager = modalFocus.create({ document: fixture.documentObject });
fixture.backdrop.hidden = false;
manager.sync("#preferred");
const alertBackdrop = element(fixture.documentObject, "div");
const alert = element(fixture.documentObject, "div", { role: "alertdialog" });
const alertOk = element(fixture.documentObject, "button", { id: "alert-ok" });
alert.append(alertOk);
alertBackdrop.append(alert);
fixture.documentObject.body.append(alertBackdrop);
manager.sync("#alert-ok");
assert.equal(fixture.documentObject.activeElement, alertOk);
alertBackdrop.hidden = true;
manager.sync();
assert.equal(manager.getActiveModal(), fixture.dialog);
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
fixture.backdrop.hidden = true;
manager.sync();
assert.equal(fixture.documentObject.activeElement, fixture.opener);
});
test("rememberOpener preserves the owner when application code changes focus during open", () => {
const fixture = modalFixture();
const manager = modalFocus.create({ document: fixture.documentObject });
manager.rememberOpener(fixture.dialog);
fixture.backdrop.hidden = false;
fixture.first.focus();
manager.sync("#preferred");
fixture.backdrop.hidden = true;
manager.sync();
assert.equal(fixture.documentObject.activeElement, fixture.opener);
});
test("an opener resolver restores a logically equivalent control after DOM replacement", () => {
const fixture = modalFixture();
const manager = modalFocus.create({ document: fixture.documentObject });
let currentOpener = fixture.opener;
manager.rememberOpener(fixture.dialog, () => currentOpener);
fixture.backdrop.hidden = false;
manager.sync("#preferred");
const replacement = element(fixture.documentObject, "button", { id: "opener" });
const openerIndex = fixture.documentObject.body.children.indexOf(fixture.opener);
fixture.opener.parentElement = null;
replacement.parentElement = fixture.documentObject.body;
fixture.documentObject.body.children[openerIndex] = replacement;
currentOpener = replacement;
fixture.backdrop.hidden = true;
manager.sync();
assert.equal(fixture.documentObject.activeElement, replacement);
assert.notEqual(fixture.documentObject.activeElement, fixture.opener);
});

View File

@@ -0,0 +1,372 @@
"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 app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8");
const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
function section(startMarker, endMarker) {
const start = app.indexOf(startMarker);
assert.notEqual(start, -1, "missing start marker: " + startMarker);
const end = app.indexOf(endMarker, start + startMarker.length);
assert.notEqual(end, -1, "missing end marker: " + endMarker);
return app.slice(start, end);
}
function assertOrdered(source, markers) {
let cursor = -1;
for (const marker of markers) {
const next = source.indexOf(marker, cursor + 1);
assert.ok(next > cursor, "expected marker in order: " + marker);
cursor = next;
}
}
test("modal focus support loads before app.js and is started once", () => {
const scripts = Array.from(markup.matchAll(/<script\s+src="([^"]+)"/g),
match => match[1]);
const focusIndex = scripts.indexOf("legacy-modal-focus.js");
const namedSelectionIndex = scripts.indexOf("legacy-named-playlist-selection.js");
const appIndex = scripts.indexOf("app.js");
assert.ok(focusIndex >= 0, "legacy modal focus module must be loaded");
assert.ok(namedSelectionIndex > focusIndex,
"named-playlist selection support must load after modal support");
assert.ok(appIndex > namedSelectionIndex,
"named-playlist selection support must load before app.js");
assert.ok(appIndex > focusIndex, "focus module must load before app.js");
assert.equal(scripts.filter(name => name === "legacy-modal-focus.js").length, 1);
assert.equal(scripts.filter(
name => name === "legacy-named-playlist-selection.js").length, 1);
assert.equal(scripts.filter(name => name === "app.js").length, 1);
assert.match(app, /window\.LegacyModalFocus\.create\(\{ document: document \}\)/);
assert.match(app, /window\.LegacyNamedPlaylistSelection\.create\(\)/);
assert.equal((app.match(/modalFocusManager\.start\(\)/g) || []).length, 1);
});
test("the one-button MessageBox stays visually above every child dialog", () => {
function zIndex(className) {
const match = styles.match(new RegExp(
"\\." + className + "\\s*\\{[^}]*z-index:\\s*(\\d+)", "s"));
assert.ok(match, "missing z-index for " + className);
return Number(match[1]);
}
const alertLayer = zIndex("dialog-backdrop");
assert.ok(alertLayer > zIndex("operator-catalog-backdrop"));
assert.ok(alertLayer > zIndex("named-playlist-confirmation-backdrop"));
});
test("modal open, render, and close paths preserve and restore focus", () => {
const namedOpen = section("function openNamedPlaylist", "function closeNamedPlaylist");
assertOrdered(namedOpen, [
"rememberModalOpener(namedPlaylistDialog)",
"namedPlaylistModal.hidden = false",
"syncModalFocus()"
]);
const namedClose = section("function closeNamedPlaylist", "function isNamedPlaylistModalLocked");
assertOrdered(namedClose, [
"namedPlaylistModal.hidden = true",
"syncModalFocus()"
]);
for (const [functionName, nextFunction, hiddenElement, dialogElement] of [
["renderManualFinancial", "isCanonicalNamedPlaylistTitle", "manualModal", "manualDialog"],
["renderManualLists", "openNamedPlaylist", "manualListModal", "manualListDialog"],
["renderOperatorCatalog", "renderDialog", "operatorCatalogModal", "operatorCatalogDialog"]
]) {
const body = section("function " + functionName, "function " + nextFunction);
assertOrdered(body, [
"if (" + hiddenElement + ".hidden) rememberModalOpener(" + dialogElement + ")",
hiddenElement + ".hidden = false"
]);
}
const dialogRender = section("function renderDialog", "function render(state)");
assertOrdered(dialogRender, [
"if (dialog.hidden) rememberModalOpener(legacyAlertDialog)",
"dialog.hidden = false"
]);
const rootRender = section("function render(state)", "searchButton.addEventListener");
assertOrdered(rootRender, ["renderDialog(state)", "syncModalFocus()"]);
assert.doesNotMatch(rootRender, /dialogOk\.focus\(/,
"focus must be delegated to the modal manager so nested opener state is retained");
});
test("Escape dismisses only the one-button alert and is inert for child dialogs", () => {
const keydown = section(
'document.addEventListener("keydown", function (event)',
'dialogOk.addEventListener("click"');
assertOrdered(keydown, [
'event.key === "Escape" && modalFocusManager.getActiveModal()',
"event.preventDefault()",
"modalFocusManager.getActiveModal() === legacyAlertDialog",
'send("dismiss-dialog", {})'
]);
assert.doesNotMatch(keydown,
/(?:closeNamedPlaylist|close-manual|close-operator)\s*\(/);
assert.match(markup,
/id="named-playlist-confirmation"[\s\S]*role="alertdialog"[\s\S]*id="named-playlist-confirmation-yes"[\s\S]*id="named-playlist-confirmation-no"/);
});
test("category-tree opener survives rerender by resolving its stable id or node key", () => {
const opener = section("function createModalOpenerReference", "function rememberModalOpener");
assertOrdered(opener, [
"categoryTree.contains(opener)",
"captureTreeUiState(renderedTreeTabId)",
"controlId: opener.id || null",
"nodeKey: node ? node.dataset.treeNodeKey : null",
"return function resolveTreeModalOpener()",
"document.getElementById(descriptor.controlId)",
"findTreeNodeByKey(descriptor.nodeKey)",
"resolved.setSelectionRange(descriptor.selectionStart, descriptor.selectionEnd)",
"return resolved"
]);
assert.match(app,
/modalFocusManager\.rememberOpener\(modal, createModalOpenerReference\(\)\)/);
const capture = section("function captureTreeUiState", "function beginTreeRender");
assertOrdered(capture, [
"categoryTree.contains(document.activeElement)",
"id: activeElement.id",
"selectionStart: Number.isInteger(activeElement.selectionStart)",
"selectionEnd: Number.isInteger(activeElement.selectionEnd)",
"focusKey: active ? active.dataset.treeNodeKey : previous.focusKey || null",
"controlFocus: activeElement ? controlFocus : previous.controlFocus || null"
]);
const finish = section("function finishTreeRender", "function send(type, payload)");
assertOrdered(finish, [
"const canRestoreFocus = treeFocusOwnedBeforeRender",
"if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id)",
"document.getElementById(saved.controlFocus.id)",
"categoryTree.contains(control)",
"control.focus({ preventScroll: true })",
"Number.isInteger(saved.controlFocus.selectionStart)",
"Number.isInteger(saved.controlFocus.selectionEnd)",
"control.setSelectionRange(",
"saved.controlFocus.selectionStart",
"saved.controlFocus.selectionEnd"
]);
});
test("named playlist save uses a nested Yes/No dialog then alerts and closes on exact success", () => {
const confirm = section(
'namedPlaylistConfirmButton.addEventListener("click"',
"manualButtons.forEach");
assertOrdered(confirm, [
"openNamedPlaylistConfirmation({",
'kind: "save"',
"definitionId: definitionId",
'message: "저장하겠습니까?"',
"showSavedAlert: true",
"closeOnNo: true"
]);
assert.doesNotMatch(confirm, /window\.confirm/);
const resolution = section(
"function resolveNamedPlaylistConfirmation",
"function renderNamedPlaylist");
const confirmationOpen = section(
"function openNamedPlaylistConfirmation",
"function restoreNamedPlaylistAfterConfirmation");
assertOrdered(confirmationOpen, [
"rememberModalOpener(namedPlaylistConfirmationDialog)",
"namedPlaylistConfirmationModal.hidden = false",
"syncModalFocus()"
]);
assertOrdered(resolution, [
"namedPlaylistConfirmationModal.hidden = true",
"syncModalFocus()",
"if (!confirmed)",
"confirmation.closeOnNo === true",
"closeNamedPlaylist()",
'"save-current-named-playlist-to"',
"confirmation.showSavedAlert === true"
]);
const renderNamed = section("function renderNamedPlaylist", "function clearOperatorCatalogDragVisuals");
assertOrdered(renderNamed, [
"isMatchingNamedPlaylistReceipt(",
"pendingNamedPlaylistCommand",
"commandResult",
"pendingNamedPlaylistCommand = null",
'window.alert("저장되었습니다")',
"closeNamedPlaylist()"
]);
});
test("named playlist double-click save confirms but never requests a success alert", () => {
const action = section(
"function runNamedPlaylistDoubleClick",
"function lockNamedPlaylistControls");
assertOrdered(action, [
'mode === "save"',
"openNamedPlaylistConfirmation({",
'kind: "save"',
"definitionId: definitionId",
'message: "저장하겠습니까?"',
"showSavedAlert: false",
"closeOnNo: true"
]);
const doubleClick = section(
'namedPlaylistDefinitions.addEventListener("dblclick"',
'namedPlaylistDefinitions.addEventListener("click"');
assertOrdered(doubleClick, [
"namedPlaylistSelection.isPending()",
"namedPlaylistSelection.defer(definitionId",
"mode: namedPlaylistMode",
"runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode)"
]);
assert.doesNotMatch(action + doubleClick,
/window\.(?:alert|confirm)|showSavedAlert\s*:\s*true/);
});
test("named playlist row selection is immediate and blocks wrong-target actions", () => {
const selectionStart = section(
"function beginNamedPlaylistSelection",
"function runNamedPlaylistDoubleClick");
assertOrdered(selectionStart, [
"namedPlaylistSelection.begin(",
"markNamedPlaylistVisualSelection(definitionId)",
"lockNamedPlaylistSelectionActions()",
'send("select-named-playlist", {',
"definitionId: definitionId",
"requestId: started.requestId"
]);
assert.doesNotMatch(selectionStart, /setTimeout|namedPlaylistResultClickTimer/);
const click = section(
'namedPlaylistDefinitions.addEventListener("click"',
'namedPlaylistNewTitle.addEventListener("input"');
assert.match(click,
/beginNamedPlaylistSelection\(definition\.dataset\.namedPlaylistDefinitionId\)/);
assert.doesNotMatch(click, /setTimeout|namedPlaylistResultClickTimer/);
const renderNamed = section(
"function renderNamedPlaylist",
"function clearOperatorCatalogDragVisuals");
assertOrdered(renderNamed, [
"const commandResult = state.commandResult",
"namedPlaylistSelection.reconcile(",
"commandResult",
"const deferredSelectionAction = selectionResult.deferredAction",
"const definitionLocked = isNamedPlaylistDefinitionLocked()",
"runNamedPlaylistDoubleClick("
]);
assert.match(app,
/function isNamedPlaylistActionLocked\(\)[\s\S]*namedPlaylistSelection\.isPending\(\)/);
});
test("named playlist delete uses the same confirmation but No keeps the child open", () => {
const remove = section(
'namedPlaylistDeleteButton.addEventListener("click"',
'namedPlaylistConfirmButton.addEventListener("click"');
assertOrdered(remove, [
"openNamedPlaylistConfirmation({",
'kind: "delete"',
'message: "프로그램이 삭제됩니다. 계속 하시겠습니까?"',
"closeOnNo: false"
]);
assert.doesNotMatch(remove, /window\.confirm/);
});
test("named playlist requests terminate only on their exact correlated receipt", () => {
const close = section("function closeNamedPlaylist", "function isNamedPlaylistModalLocked");
assertOrdered(close, [
"if (isNamedPlaylistModalLocked() || namedPlaylistSelection.isPending()) return false",
"namedPlaylistModal.hidden = true"
]);
const locking = section("function isNamedPlaylistModalLocked", "function renderNamedPlaylist");
for (const lockSource of [
"uiBusy",
"pendingNamedPlaylistCommand !== null",
"namedPlaylistConfirmation !== null",
"namedPlaylistState.isWriteInProgress === true"
]) {
assert.match(locking, new RegExp(lockSource.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
}
assertOrdered(locking, [
'const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++)',
"pendingNamedPlaylistCommand = {",
"command: type",
"targetId: requestId",
"minimumSequence: lastCommandSequence + 1",
"closeOnSuccess: false",
"Object.assign({}, payload || {}, { requestId: requestId })",
"send(type, correlatedPayload)",
"lockNamedPlaylistControls()"
]);
assertOrdered(locking, [
"pendingNamedPlaylistCommand = {",
"command: command",
"targetId: definitionId",
"definitionId: definitionId",
"minimumSequence: lastCommandSequence + 1",
"closeOnSuccess: true",
"send(command, { definitionId: definitionId })",
"lockNamedPlaylistControls()"
]);
const renderNamed = section("function renderNamedPlaylist", "function clearOperatorCatalogDragVisuals");
assertOrdered(renderNamed, [
"isMatchingNamedPlaylistReceipt(",
"pendingNamedPlaylistCommand",
"commandResult",
"pendingNamedPlaylistCommand = null",
"succeeded && completed.closeOnSuccess"
]);
assertOrdered(renderNamed, [
"const modalLocked = isNamedPlaylistActionLocked()",
"const closeLocked = isNamedPlaylistModalLocked() ||",
"namedPlaylistSelection.isPending()",
"namedPlaylistClose.disabled = closeLocked",
"namedPlaylistCancelButton.disabled = closeLocked"
]);
assert.match(renderNamed, /dbLoadButton\.disabled = operatorMutationLocked/);
assert.match(renderNamed, /dbSaveButton\.disabled = operatorMutationLocked/);
assert.match(locking,
/function isNamedPlaylistActionLocked\(\)[\s\S]*operatorMutationLocked/);
assert.doesNotMatch(app,
/isNamedPlaylistTerminalRejection|minimumRevision|namedPlaylistBusyCycleObserved/,
"uncorrelated state changes must never unlock a DB request");
});
test("named playlist receipt matcher consumes busy rejection but defers busy success", () => {
const helperSource = section(
"function isMatchingNamedPlaylistReceipt",
"function openNamedPlaylistConfirmation");
const matches = new Function(
helperSource + "\nreturn isMatchingNamedPlaylistReceipt;")();
const pending = {
minimumSequence: 7,
command: "create-named-playlist",
targetId: "named-ui-4"
};
const exact = {
sequence: 7,
command: "create-named-playlist",
targetId: "named-ui-4",
succeeded: true
};
assert.equal(matches(false, pending, exact), true);
assert.equal(matches(true, pending, exact), false);
assert.equal(matches(true, pending, { ...exact, succeeded: false }), true,
"an exact gate rejection must be consumed before another busy request overwrites it");
assert.equal(matches(false, pending, { ...exact, sequence: 6 }), false);
assert.equal(matches(false, pending, { ...exact, command: "refresh-named-playlists" }), false);
assert.equal(matches(false, pending, { ...exact, targetId: "named-ui-3" }), false);
});

View File

@@ -0,0 +1,108 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const selection = require("../../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-named-playlist-selection.js");
const first = "named-definition-first";
const second = "named-definition-second";
test("a second-row click blocks actions until native state selects that exact row", () => {
const workflow = selection.create();
const started = workflow.begin(second, first, "named-ui-1", 1);
assert.deepEqual(started, {
accepted: true,
shouldSend: true,
definitionId: second,
requestId: "named-ui-1"
});
assert.equal(workflow.isPending(), true);
assert.equal(workflow.pendingDefinitionId(), second);
assert.deepEqual(workflow.reconcile({
sequence: 1,
command: "refresh-named-playlists",
targetId: "named-ui-old",
succeeded: true
}, first), {
completed: false,
succeeded: false,
deferredAction: null
});
assert.equal(workflow.isPending(), true);
const completed = workflow.reconcile({
sequence: 1,
command: "select-named-playlist",
targetId: "named-ui-1",
succeeded: true
}, second);
assert.equal(completed.completed, true);
assert.equal(completed.succeeded, true);
assert.equal(workflow.isPending(), false);
});
test("repeated click and double click queue one exact action without a duplicate select", () => {
const workflow = selection.create();
assert.equal(workflow.begin(second, first, "named-ui-2", 2).shouldSend, true);
const repeated = workflow.begin(second, first);
const deferred = Object.freeze({ definitionId: second, mode: "save" });
assert.equal(repeated.accepted, true);
assert.equal(repeated.shouldSend, false);
assert.equal(repeated.requestId, "named-ui-2");
assert.equal(workflow.defer(second, deferred), true);
const completed = workflow.reconcile({
sequence: 2,
command: "select-named-playlist",
targetId: "named-ui-2",
succeeded: true
}, second);
assert.deepEqual(completed.deferredAction, deferred);
assert.equal(completed.succeeded, true);
});
test("a failed native selection clears the lock and discards the deferred mutation", () => {
const workflow = selection.create();
workflow.begin(second, first, "named-ui-3", 3);
workflow.defer(second, { definitionId: second, mode: "load" });
const failed = workflow.reconcile({
sequence: 3,
command: "select-named-playlist",
targetId: "named-ui-3",
succeeded: false
}, first);
assert.deepEqual(failed, {
completed: true,
succeeded: false,
deferredAction: null
});
assert.equal(workflow.isPending(), false);
});
test("an already selected row never creates a pending native request", () => {
const workflow = selection.create();
const current = workflow.begin(first, first);
assert.equal(current.accepted, true);
assert.equal(current.shouldSend, false);
assert.equal(current.requestId, null);
assert.equal(workflow.isPending(), false);
});
test("a different row cannot replace an in-flight selection target", () => {
const workflow = selection.create();
workflow.begin(second, first, "named-ui-4", 4);
const conflicting = workflow.begin(first, first);
assert.equal(conflicting.accepted, false);
assert.equal(conflicting.shouldSend, false);
assert.equal(workflow.pendingDefinitionId(), second);
});

View File

@@ -69,15 +69,19 @@ test("legacy stock double click is represented by pointer-down facts", () => {
assert.match(app, /stockResults\.querySelectorAll\("button\[data-result-index\]"\)/);
});
test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => {
test("cut rows are reconciled in place while the legacy pointer drag remains captured", () => {
assert.match(app, /dataset\.physicalIndex/);
assert.match(app, /function createCutElement/);
const cutFactory = app.slice(
app.indexOf("function createCutElement"),
app.indexOf("function clearPlaylistDragVisuals"));
assert.match(cutFactory, /if \(playlistMutationLocked \|\| !event\.dataTransfer\)/);
assert.match(cutFactory, /beginCutDragGesture\(element, event, position\)/);
assert.match(cutFactory, /addEventListener\("pointermove", onCutPointerMove\)/);
assert.match(cutFactory, /addEventListener\("lostpointercapture", onCutPointerCancel\)/);
assert.match(cutFactory, /const cutDragLocked = isOperatorMutationLocked\(state\)/);
assert.match(cutFactory, /element\.draggable = !cutDragLocked/);
assert.match(cutFactory, /element\.draggable = false/);
assert.match(cutFactory, /dataset\.cutDragEnabled = cutSelectionEnabled/);
assert.doesNotMatch(cutFactory, /addEventListener\("dragstart"/);
assert.doesNotMatch(app, /cutList\.replaceChildren/);
});
@@ -163,7 +167,7 @@ test("IDLE Escape keeps the emergency StopAll path available", () => {
/takeOut\.disabled[\s\S]{0,120}playout\.phase !== "prepared"/);
});
test("named-playlist modal shortcuts cannot leak into the playout listener", () => {
test("legacy child modal Escape is inert and cannot leak into the playout listener", () => {
const appScript = markup.indexOf('<script src="app.js"></script>');
const playoutScript = markup.indexOf('<script src="playout-ui.js"></script>');
assert.ok(appScript >= 0 && playoutScript > appScript);
@@ -175,8 +179,11 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
assert.ok(appHandlerStart >= 0 && appHandlerEnd > appHandlerBodyStart);
const handleAppKeydown = new Function(
"event",
"namedPlaylistModal",
"closeNamedPlaylist",
"modalFocusManager",
"cutDragGesture",
"clearCutDragGesture",
"legacyAlertDialog",
"send",
app.slice(appHandlerBodyStart, appHandlerEnd));
const playoutHandlerStart = playout.lastIndexOf(keydownMarker);
@@ -201,28 +208,39 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
const modal = { hidden: false };
for (const key of ["F2", "F3", "F8"]) {
const modalKey = {
key,
repeat: false,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; }
};
handlePlayoutKeydown(
{ key, repeat: false, defaultPrevented: false, preventDefault() {} },
modalKey,
() => !modal.hidden,
backgroundFile,
backgroundNone,
takeIn,
takeOut);
assert.equal(modalKey.defaultPrevented, true, key + " browser default must be consumed");
}
assert.deepEqual(clicks, []);
let closeCount = 0;
const escape = new Event("keydown", { cancelable: true });
Object.defineProperties(escape, {
key: { value: "Escape" },
repeat: { value: false }
});
handleAppKeydown(escape, modal, () => {
closeCount += 1;
modal.hidden = true;
});
assert.equal(closeCount, 1);
assert.equal(modal.hidden, true);
const childDialog = {};
const legacyAlertDialog = {};
const sent = [];
handleAppKeydown(
escape,
{ getActiveModal() { return childDialog; } },
null,
() => {},
legacyAlertDialog,
(type) => sent.push(type));
assert.equal(modal.hidden, false);
assert.equal(escape.defaultPrevented, true);
handlePlayoutKeydown(
@@ -233,6 +251,57 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
takeIn,
takeOut);
assert.deepEqual(clicks, []);
assert.deepEqual(sent, []);
const alertEscape = new Event("keydown", { cancelable: true });
Object.defineProperties(alertEscape, {
key: { value: "Escape" },
repeat: { value: false }
});
handleAppKeydown(
alertEscape,
{ getActiveModal() { return legacyAlertDialog; } },
null,
() => {},
legacyAlertDialog,
(type) => sent.push(type));
assert.equal(alertEscape.defaultPrevented, true);
assert.deepEqual(sent, ["dismiss-dialog"]);
});
test("started cut drag owns playout keys and Escape cancels without TAKE OUT", () => {
const keydownMarker = 'document.addEventListener("keydown", function (event) {';
const start = app.lastIndexOf(keydownMarker) + keydownMarker.length;
const end = app.indexOf("\n });", start);
const handle = new Function(
"event",
"modalFocusManager",
"cutDragGesture",
"clearCutDragGesture",
"legacyAlertDialog",
"send",
app.slice(start, end));
const sent = [];
let clears = 0;
const manager = { getActiveModal() { return null; } };
for (const key of ["F2", "F3", "F8", "Escape"]) {
const event = {
key,
repeat: false,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; }
};
handle(
event,
manager,
{ started: true },
() => { clears += 1; },
{},
(type) => sent.push(type));
assert.equal(event.defaultPrevented, true, key + " must stay inside drag loop");
}
assert.equal(clears, 1, "only Escape cancels the active drag");
assert.deepEqual(sent, []);
});
test("playlist controls send intent for selection, enabled state, movement and deletion", () => {
@@ -267,7 +336,7 @@ test("playlist controls send intent for selection, enabled state, movement and d
const homeEndBranchStart = app.lastIndexOf('} else if ((event.key === "Home"');
const homeEndBranch = app.slice(
homeEndBranchStart,
app.indexOf('} else if (event.key === "Escape"', homeEndBranchStart));
app.indexOf("\n });", homeEndBranchStart));
assert.doesNotMatch(homeEndBranch, /event\.preventDefault\(\)/);
const boundaryHelperStart = app.indexOf("function selectPlaylistBoundary");
const boundaryHelper = app.slice(
@@ -468,76 +537,26 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
assert.equal(Object.hasOwn(sent[0].payload, "selectedRowIds"), false);
});
test("playlist drop zone accepts only the exact selected-cut drag token", () => {
class MiniDataTransfer {
constructor() { this.values = new Map(); }
get types() { return [...this.values.keys()]; }
setData(type, value) { this.values.set(type, value); }
getData(type) { return this.values.get(type) || ""; }
}
const sourceStart = app.indexOf("function hasDragType");
const sourceEnd = app.indexOf(
'playlistMoveUp.addEventListener("click"', sourceStart);
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
const playlistDropZone = new EventTarget();
const sent = [];
const bindDropZone = new Function(
"playlistDropZone",
"send",
"selectedCutsDragToken",
"playlistMutationLocked",
app.slice(sourceStart, sourceEnd));
bindDropZone(
playlistDropZone,
(type, payload) => sent.push({ type, payload }),
"legacy-selected-cuts",
false);
const playlistTransfer = new MiniDataTransfer();
playlistTransfer.setData("text/x-mbn-playlist-row", "row-a");
const playlistDrop = new Event("drop", { cancelable: true, bubbles: true });
Object.assign(playlistDrop, { dataTransfer: playlistTransfer });
playlistDropZone.dispatchEvent(playlistDrop);
assert.equal(playlistDrop.defaultPrevented, false);
assert.deepEqual(sent, []);
const externalText = new MiniDataTransfer();
externalText.setData("text/plain", "external-text");
const externalDrop = new Event("drop", { cancelable: true, bubbles: true });
Object.assign(externalDrop, { dataTransfer: externalText });
playlistDropZone.dispatchEvent(externalDrop);
assert.equal(externalDrop.defaultPrevented, false);
assert.deepEqual(sent, []);
const cutTransfer = new MiniDataTransfer();
cutTransfer.setData("text/plain", "legacy-selected-cuts");
const cutOver = new Event("dragover", { cancelable: true });
Object.assign(cutOver, { dataTransfer: cutTransfer });
playlistDropZone.dispatchEvent(cutOver);
assert.equal(cutOver.defaultPrevented, true);
const cutDrop = new Event("drop", { cancelable: true, bubbles: true });
Object.assign(cutDrop, { dataTransfer: cutTransfer });
playlistDropZone.dispatchEvent(cutDrop);
assert.equal(cutDrop.defaultPrevented, true);
assert.deepEqual(sent, [{ type: "drop-selected-cuts", payload: {} }]);
const lockedDropZone = new EventTarget();
const lockedSent = [];
bindDropZone(
lockedDropZone,
(type, payload) => lockedSent.push({ type, payload }),
"legacy-selected-cuts",
true);
const lockedOver = new Event("dragover", { cancelable: true });
Object.assign(lockedOver, { dataTransfer: cutTransfer });
lockedDropZone.dispatchEvent(lockedOver);
assert.equal(lockedOver.defaultPrevented, false);
const lockedDrop = new Event("drop", { cancelable: true, bubbles: true });
Object.assign(lockedDrop, { dataTransfer: cutTransfer });
lockedDropZone.dispatchEvent(lockedDrop);
assert.equal(lockedDrop.defaultPrevented, false);
assert.deepEqual(lockedSent, []);
test("cut drag uses the original pointer rectangle and drops only inside the playlist", () => {
const start = app.indexOf("function pointInsideElement");
const end = app.indexOf("function legacyCutKey", start);
assert.ok(start >= 0 && end > start);
const drag = app.slice(start, end);
assert.match(drag, /LegacyCutDrag\.createDragRectangle\(position, cutDragSize\)/);
assert.match(drag, /LegacyCutDrag\.shouldStart\(gesture\.rectangle, pointerPosition\(event\)\)/);
assert.match(drag, /event\.buttons & 1/);
assert.match(drag, /pointInsideElement\(playlistDropZone, event\)/);
assert.match(drag, /send\("drop-selected-cuts", \{\}\)/);
assert.match(app, /state\.interactionMetrics/);
assert.match(app, /metrics\.coordinateSpace !== "host-dip"/);
assert.match(app, /LegacyCutDrag\.toCssDragSize/);
assert.match(app, /window\.devicePixelRatio/);
assert.match(app, /x: event\.clientX - bounds\.left/);
assert.match(app, /x: Math\.round\(position\.x\)/);
assert.doesNotMatch(app, /x: Math\.round\(event\.clientX - bounds\.left\)/);
assert.match(app,
/cutDragGesture\.rectangle = window\.LegacyCutDrag\.createDragRectangle/);
assert.doesNotMatch(app, /selectedCutsDragToken|setData\("text\/plain"/);
});
test("operator catalog drag rejects an interactive child pointer origin", () => {
@@ -931,14 +950,16 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re
const categoryTree = new MiniTree();
const catalogExpandAll = { checked: false };
const harness = new Function("categoryTree", "catalogExpandAll", "document", `
const harness = new Function(
"categoryTree", "catalogExpandAll", "document", "modalFocusManager", `
let renderedTreeTabId = null;
let treeInteractionLocked = false;
let treeFocusOwnedBeforeRender = false;
const treeUiStateByTab = new Map();
const pendingTreeResetTabs = new Set();
${app.slice(helperStart, helperEnd)}
return { beginTreeRender, finishTreeRender, selectTreeNode };
`)(categoryTree, catalogExpandAll, document);
`)(categoryTree, catalogExpandAll, document, { getActiveModal() { return null; } });
const state = tabId => ({
isBusy: false,
fixedSectionBatch: null,
@@ -1140,13 +1161,14 @@ test("theme expert and trading halt actions preserve original single click wirin
test("named DB playlists and playout use separate closed native intents", () => {
for (const intent of [
"refresh-named-playlists", "select-named-playlist",
"load-selected-named-playlist", "create-named-playlist",
"save-current-named-playlist", "delete-selected-named-playlist"
"load-named-playlist-by-id", "create-named-playlist",
"save-current-named-playlist-to", "delete-selected-named-playlist"
]) assert.match(app, new RegExp("\\\"" + intent + "\\\""));
assert.match(app, /state\.namedPlaylist/);
assert.match(app, /dataset\.namedPlaylistDefinitionId/);
assert.match(app, /definitionId: definition\.dataset\.namedPlaylistDefinitionId/);
assert.match(app, /dbLoadButton\.disabled = busy \|\| !named/);
assert.match(app,
/beginNamedPlaylistSelection\(definition\.dataset\.namedPlaylistDefinitionId\)/);
assert.match(app, /dbLoadButton\.disabled = operatorMutationLocked \|\| busy \|\| !named/);
assert.match(app, /named\.isWriteQuarantined/);
assert.doesNotMatch(app, /programCode|listText/i);
@@ -1155,8 +1177,14 @@ test("named DB playlists and playout use separate closed native intents", () =>
}
for (const intent of [
"prepare-playout", "take-in", "next-playout", "take-out",
"choose-background", "toggle-background", "set-fade-duration"
"choose-background", "toggle-background"
]) assert.match(playout, new RegExp("\\\"" + intent + "\\\""));
assert.doesNotMatch(playout, /post\("set-fade-duration"/);
assert.doesNotMatch(playout, /fadeDuration\.addEventListener/);
assert.match(playout, /fadeDuration\.disabled = true;/);
assert.match(playout, /fadeDuration\.value = String\(playout\.fadeDuration\);/);
assert.match(markup,
/id="playout-fade-duration" disabled hidden aria-hidden="true" tabindex="-1"/);
assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/);
assert.match(playout, /playout\.isPlayCompletionPending === true/);
assert.match(playout, /playout\.outcomeUnknown !== true/);
@@ -1207,8 +1235,9 @@ test("FSell and VI dialogs send opaque native intents and never own file or stoc
});
test("separator rows preserve the original drag gesture", () => {
assert.match(app, /element\.draggable = true/);
assert.doesNotMatch(app, /draggable = !row\.isSeparator/);
assert.match(app, /element\.dataset\.cutDragEnabled = cutSelectionEnabled \? "true" : "false"/);
assert.match(app, /element\.draggable = false/);
assert.doesNotMatch(app, /cutDragEnabled = !row\.isSeparator/);
});
test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => {

View File

@@ -219,6 +219,153 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Assert.True(saved.CommandResult?.Succeeded);
}
[Fact]
public async Task Save_to_opaque_id_treats_known_commit_with_failed_readback_as_success()
{
var persistence = new OperatorNamedPlaylistPersistence([])
{
FailNextLoad = true
};
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
var saved = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
Assert.Equal(1, persistence.ReplaceCalls);
Assert.Equal(
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic,
saved.NamedPlaylist?.LastMutationOutcome);
Assert.Equal(LegacyOperatorStatusKind.Warning, saved.StatusKind);
Assert.Equal("save-current-named-playlist-to", saved.CommandResult?.Command);
Assert.Equal(definitionId, saved.CommandResult?.TargetId);
Assert.True(saved.CommandResult?.Succeeded);
}
[Fact]
public async Task Save_to_opaque_id_does_not_reuse_a_stale_success_after_validation_rejection()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
var first = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
Assert.True(first.CommandResult?.Succeeded);
Assert.Equal(1, persistence.ReplaceCalls);
var action = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Overseas)
.First(candidate => candidate.Available);
for (var index = 0;
index <= LegacyNamedPlaylistWorkflowController.MaximumItems;
index++)
{
controller.ActivateFixedAction(action.Id);
}
var rejected = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
Assert.Equal(1, persistence.ReplaceCalls);
Assert.Equal(
LegacyNamedPlaylistMutationOutcome.CommittedFresh,
rejected.NamedPlaylist?.LastMutationOutcome);
Assert.Equal(LegacyOperatorStatusKind.Warning, rejected.StatusKind);
Assert.Equal("save-current-named-playlist-to", rejected.CommandResult?.Command);
Assert.Equal(definitionId, rejected.CommandResult?.TargetId);
Assert.False(rejected.CommandResult?.Succeeded);
}
[Theory]
[InlineData("refresh-named-playlists")]
[InlineData("select-named-playlist")]
[InlineData("create-named-playlist")]
[InlineData("delete-selected-named-playlist")]
[InlineData("load-named-playlist-by-id")]
[InlineData("save-current-named-playlist-to")]
public void Native_dispatch_failure_emits_exact_terminal_receipt(
string command)
{
var controller = CreateController(
new OperatorNamedPlaylistPersistence([]));
const string definitionId = "named-definition-opaque";
var failed = controller.ReportNamedPlaylistCommandFailure(
command,
definitionId,
"The native request was rejected before completion.");
Assert.Equal(command, failed.CommandResult?.Command);
Assert.Equal(definitionId, failed.CommandResult?.TargetId);
Assert.False(failed.CommandResult?.Succeeded);
Assert.Equal(LegacyOperatorStatusKind.Error, failed.StatusKind);
}
[Fact]
public void Native_dispatch_failure_rejects_unrelated_command_names()
{
var controller = CreateController(
new OperatorNamedPlaylistPersistence([]));
Assert.Throws<ArgumentOutOfRangeException>(() =>
controller.ReportNamedPlaylistCommandFailure(
"load-selected-named-playlist",
"named-definition-opaque",
"Rejected."));
}
[Theory]
[InlineData("refresh-named-playlists")]
[InlineData("select-named-playlist")]
[InlineData("create-named-playlist")]
[InlineData("delete-selected-named-playlist")]
public void Busy_cycle_completion_echoes_exact_dispatch_id(string command)
{
var controller = CreateController(
new OperatorNamedPlaylistPersistence([]));
var completed = controller.CompleteNamedPlaylistDispatch(
command,
"named-ui-42",
succeeded: true);
Assert.Equal(command, completed.CommandResult?.Command);
Assert.Equal("named-ui-42", completed.CommandResult?.TargetId);
Assert.True(completed.CommandResult?.Succeeded);
}
[Fact]
public async Task Ambiguous_native_save_failure_latches_no_retry_quarantine()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
const string definitionId = "named-definition-opaque";
var failed = controller.ReportNamedPlaylistWriteOutcomeUnknown(
"save-current-named-playlist-to",
definitionId,
"The accepted save ended without a definitive database outcome.");
Assert.Equal("save-current-named-playlist-to", failed.CommandResult?.Command);
Assert.Equal(definitionId, failed.CommandResult?.TargetId);
Assert.False(failed.CommandResult?.Succeeded);
Assert.True(failed.NamedPlaylist?.IsWriteQuarantined);
Assert.Equal(
LegacyNamedPlaylistMutationOutcome.OutcomeUnknown,
failed.NamedPlaylist?.LastMutationOutcome);
var listed = await controller.RefreshNamedPlaylistsAsync();
controller.SelectNamedPlaylist(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
var blocked = await controller.SaveCurrentNamedPlaylistAsync();
Assert.Equal(0, persistence.ReplaceCalls);
Assert.Equal(
LegacyNamedPlaylistMutationOutcome.Quarantined,
blocked.NamedPlaylist?.LastMutationOutcome);
}
private static LegacyOperatorController CreateController(
INamedPlaylistPersistenceService persistence) =>
new(
@@ -259,6 +406,8 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
public IReadOnlyList<NamedPlaylistStoredItem>? LastReplaceItems { get; private set; }
public bool FailNextLoad { get; set; }
public Task<NamedPlaylistListResult> ListAsync(
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
CancellationToken cancellationToken = default)
@@ -282,6 +431,13 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
CancellationToken cancellationToken = default)
{
++LoadCalls;
if (FailNextLoad)
{
FailNextLoad = false;
return Task.FromException<NamedPlaylistDocument>(
new NamedPlaylistDataException("The committed save could not be read back."));
}
if (!_exists || !string.Equals(programCode, ProgramCode, StringComparison.Ordinal))
{
return Task.FromException<NamedPlaylistDocument>(

View File

@@ -101,8 +101,11 @@ public sealed class LegacyNamedPlaylistWorkflowTests
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, created.Outcome);
Assert.Equal("00000003", persistence.LastCreatedProgramCode);
Assert.Equal("새 프로그램", created.Snapshot.SelectedTitle);
Assert.Equal(3, created.Snapshot.Definitions.Count);
Assert.Equal(
created.Snapshot.Definitions[0].DefinitionId,
created.Snapshot.SelectedDefinitionId);
var firstTitleAfterCreate = created.Snapshot.Definitions[0].Title;
Assert.Empty(created.Snapshot.Rows);
Assert.DoesNotContain(
typeof(LegacyNamedPlaylistProgramDraft).GetProperties(),
@@ -113,14 +116,45 @@ public sealed class LegacyNamedPlaylistWorkflowTests
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, deleted.Outcome);
Assert.Equal(1, persistence.DeleteCalls);
Assert.Equal("00000003", persistence.LastDeletedProgramCode);
Assert.Null(deleted.Snapshot.SelectedDefinitionId);
Assert.NotEqual("00000003", persistence.LastDeletedProgramCode);
Assert.Equal(
deleted.Snapshot.Definitions[0].DefinitionId,
deleted.Snapshot.SelectedDefinitionId);
Assert.DoesNotContain(
deleted.Snapshot.Definitions,
definition => string.Equals(definition.Title, "새 프로그램", StringComparison.Ordinal));
definition => string.Equals(
definition.Title,
firstTitleAfterCreate,
StringComparison.Ordinal));
Assert.Contains(
deleted.Snapshot.Definitions,
definition => string.Equals(
definition.Title,
"새 프로그램",
StringComparison.Ordinal));
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, deleted.Snapshot.ListFreshness);
}
[Fact]
public async Task EveryNonEmptyListRebind_SelectsFirstDefinitionLikeWinFormsPList()
{
var persistence = CreateSeededPersistence();
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
var opened = await controller.RefreshAsync();
Assert.Equal(opened.Definitions[0].DefinitionId, opened.SelectedDefinitionId);
var second = opened.Definitions[1];
controller.SelectDefinition(second.DefinitionId);
var refreshed = await controller.RefreshAsync();
Assert.Equal(refreshed.Definitions[0].DefinitionId, refreshed.SelectedDefinitionId);
Assert.NotEqual(second.Title, refreshed.SelectedTitle);
Assert.True(refreshed.CanLoad);
Assert.True(refreshed.CanDelete);
}
[Fact]
public async Task KnownRollback_PreservesLoadedRowsAndNeverRetries()
{

View File

@@ -448,35 +448,44 @@ public sealed class LegacyBridgeProtocolTests
}
[Fact]
public void ParseNamedPlaylistIntents_AcceptsOnlyOpaqueIdsTitlesAndEmptyCommands()
public void ParseNamedPlaylistIntents_AcceptsOnlyOpaqueIdsTitlesAndDispatchIds()
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"refresh-named-playlists\",\"payload\":{}}",
"{\"type\":\"refresh-named-playlists\",\"payload\":{\"requestId\":\"named-ui-1\"}}",
out var refresh,
out _));
Assert.IsType<LegacyRefreshNamedPlaylistsIntent>(refresh);
Assert.Equal(
"named-ui-1",
Assert.IsType<LegacyRefreshNamedPlaylistsIntent>(refresh).RequestId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\"}}",
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\",\"requestId\":\"named-ui-2\"}}",
out var selection,
out _));
Assert.Equal(
"named-definition-AAAAAAAAAAAB",
Assert.IsType<LegacySelectNamedPlaylistIntent>(selection).DefinitionId);
var selectionIntent = Assert.IsType<LegacySelectNamedPlaylistIntent>(selection);
Assert.Equal("named-definition-AAAAAAAAAAAB", selectionIntent.DefinitionId);
Assert.Equal("named-ui-2", selectionIntent.RequestId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\"아침 방송 A^B\"}}",
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\"아침 방송 A^B\",\"requestId\":\"named-ui-2\"}}",
out var create,
out _));
var createIntent = Assert.IsType<LegacyCreateNamedPlaylistIntent>(create);
Assert.Equal("아침 방송 A^B", createIntent.Title);
Assert.Equal("named-ui-2", createIntent.RequestId);
Assert.True(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"delete-selected-named-playlist\",\"payload\":{\"requestId\":\"named-ui-3\"}}",
out var delete,
out _));
Assert.Equal(
"아침 방송 A^B",
Assert.IsType<LegacyCreateNamedPlaylistIntent>(create).Title);
"named-ui-3",
Assert.IsType<LegacyDeleteSelectedNamedPlaylistIntent>(delete).RequestId);
foreach (var type in new[]
{
"load-selected-named-playlist",
"save-current-named-playlist",
"delete-selected-named-playlist"
"save-current-named-playlist"
})
{
Assert.True(LegacyBridgeProtocol.TryParseIntent(
@@ -490,12 +499,24 @@ public sealed class LegacyBridgeProtocolTests
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\",\"programCode\":\"00000001\"}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\"}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"save-current-named-playlist\",\"payload\":{\"rows\":[]}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\" padded \"}}",
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\" padded \",\"requestId\":\"named-ui-4\"}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"refresh-named-playlists\",\"payload\":{}}",
out _,
out _));
Assert.False(LegacyBridgeProtocol.TryParseIntent(
"{\"type\":\"delete-selected-named-playlist\",\"payload\":{}}",
out _,
out _));
}

View File

@@ -0,0 +1,110 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyInteractionMetricsBridgeTests
{
[Fact]
public void SerializeState_ProjectsExplicitSystemDragMetricsWithCamelCaseNames()
{
var json = LegacyBridgeProtocol.SerializeState(
CreateSnapshot(),
interactionMetrics: new LegacyInteractionMetrics(7, 9, 96, 1.5));
using var document = JsonDocument.Parse(json);
var payload = document.RootElement.GetProperty("payload");
var metrics = payload.GetProperty("interactionMetrics");
Assert.Equal(7, metrics.GetProperty("cutDragWidthDips").GetInt32());
Assert.Equal(9, metrics.GetProperty("cutDragHeightDips").GetInt32());
Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString());
Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32());
Assert.Equal(1.5d, metrics.GetProperty("hostRasterizationScale").GetDouble());
Assert.False(metrics.TryGetProperty("CutDragWidthDips", out _));
Assert.False(metrics.TryGetProperty("CutDragHeightDips", out _));
}
[Theory]
[InlineData(6, 9, 144u, 4, 6)]
[InlineData(5, 5, 120u, 4, 4)]
[InlineData(7, 7, 168u, 4, 4)]
public void FromPixelsAtDpi_ConvertsWindowsPixelsToCssPixelCoordinates(
int physicalWidth,
int physicalHeight,
uint dpi,
int expectedCssWidth,
int expectedCssHeight)
{
var metrics = LegacyInteractionMetrics.FromPixelsAtDpi(
physicalWidth,
physicalHeight,
dpi);
Assert.Equal(expectedCssWidth, metrics.CutDragWidthDips);
Assert.Equal(expectedCssHeight, metrics.CutDragHeightDips);
Assert.Equal(dpi, metrics.SourceDpi);
}
[Fact]
public void FromPixelsAtDpi_RejectsUnknownDpiAndInvalidAxesSafely()
{
var unknownDpi = LegacyInteractionMetrics.FromPixelsAtDpi(8, 8, 0, 1.5);
Assert.Equal(4, unknownDpi.CutDragWidthDips);
Assert.Equal(4, unknownDpi.CutDragHeightDips);
Assert.Equal(1.5d, unknownDpi.HostRasterizationScale);
var metrics = LegacyInteractionMetrics.FromPixelsAtDpi(0, -1, 192);
Assert.Equal(4, metrics.CutDragWidthDips);
Assert.Equal(4, metrics.CutDragHeightDips);
Assert.Equal(192u, metrics.SourceDpi);
}
[Fact]
public void SerializeState_NormalizesInvalidSystemMetricsPerAxisToFourPixels()
{
var json = LegacyBridgeProtocol.SerializeState(
CreateSnapshot(),
interactionMetrics: new LegacyInteractionMetrics(0, -1, 0, double.NaN));
using var document = JsonDocument.Parse(json);
var metrics = document.RootElement
.GetProperty("payload")
.GetProperty("interactionMetrics");
Assert.Equal(4, metrics.GetProperty("cutDragWidthDips").GetInt32());
Assert.Equal(4, metrics.GetProperty("cutDragHeightDips").GetInt32());
Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString());
Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32());
Assert.Equal(1d, metrics.GetProperty("hostRasterizationScale").GetDouble());
}
[Fact]
public void SerializeState_ExistingCallShapeReceivesSafeDefaultMetrics()
{
var json = LegacyBridgeProtocol.SerializeState(CreateSnapshot());
using var document = JsonDocument.Parse(json);
var metrics = document.RootElement
.GetProperty("payload")
.GetProperty("interactionMetrics");
Assert.Equal(4, metrics.GetProperty("cutDragWidthDips").GetInt32());
Assert.Equal(4, metrics.GetProperty("cutDragHeightDips").GetInt32());
Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString());
Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32());
Assert.Equal(1d, metrics.GetProperty("hostRasterizationScale").GetDouble());
}
private static LegacyOperatorSnapshot CreateSnapshot() => new(
1,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Neutral);
}

View File

@@ -17,7 +17,7 @@ public sealed class LegacyHiddenNonOriginalControlsTests
"styles.css"));
[Fact]
public void Non_original_shortcuts_are_hidden_but_original_dissolve_control_is_visible()
public void Non_original_shortcuts_and_original_hidden_dissolve_panel_are_not_operator_controls()
{
Assert.Contains("class=\"manual-list-actions\" aria-label=\"수동 송출 목록\" hidden",
Markup,
@@ -25,13 +25,14 @@ public sealed class LegacyHiddenNonOriginalControlsTests
Assert.Contains("id=\"playout-prepare\" class=\"prepare\" type=\"button\" disabled hidden",
Markup,
StringComparison.Ordinal);
Assert.Contains("class=\"fade-control\" for=\"playout-fade-duration\">DissolveTime</label>",
Assert.Contains("class=\"fade-control\" for=\"playout-fade-duration\" hidden",
Markup,
StringComparison.Ordinal);
Assert.Contains("id=\"playout-fade-duration\" disabled aria-label=\"DissolveTime\"",
Assert.Contains("aria-hidden=\"true\">DissolveTime</label>",
Markup,
StringComparison.Ordinal);
Assert.DoesNotContain("id=\"playout-fade-duration\" disabled aria-label=\"DissolveTime\" hidden",
Assert.Contains(
"id=\"playout-fade-duration\" disabled hidden aria-hidden=\"true\" tabindex=\"-1\"",
Markup,
StringComparison.Ordinal);
Assert.Contains("*[hidden] { display: none !important; }", Styles,

View File

@@ -0,0 +1,137 @@
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyInteractionMetricsNativeContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string AppRoot = Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp");
private static readonly string Window = File.ReadAllText(
Path.Combine(AppRoot, "MainWindow.xaml.cs"));
[Fact]
public void NativeWindowReadsBothWindowsDragThresholdsInCssDipCoordinates()
{
Assert.Contains("private const int SmCxDrag = 68;", Window,
StringComparison.Ordinal);
Assert.Contains("private const int SmCyDrag = 69;", Window,
StringComparison.Ordinal);
Assert.Contains("[DllImport(\"user32.dll\", ExactSpelling = true)]", Window,
StringComparison.Ordinal);
Assert.Contains(
"GetSystemMetricsForDpi(\n" +
" SmCxDrag,\n" +
" LegacyInteractionMetrics.CssPixelsPerInch)",
Window.Replace("\r\n", "\n", StringComparison.Ordinal),
StringComparison.Ordinal);
Assert.Contains(
"GetSystemMetricsForDpi(\n" +
" SmCyDrag,\n" +
" LegacyInteractionMetrics.CssPixelsPerInch)",
Window.Replace("\r\n", "\n", StringComparison.Ordinal),
StringComparison.Ordinal);
Assert.DoesNotContain("GetSystemMetrics(", Window, StringComparison.Ordinal);
Assert.Contains("LegacyInteractionMetrics.FromPixelsAtDpi(", Window,
StringComparison.Ordinal);
Assert.Contains("logical DIP coordinate space used by XAML", Window,
StringComparison.Ordinal);
Assert.Contains("Root.XamlRoot?.RasterizationScale", Window,
StringComparison.Ordinal);
}
[Fact]
public void NativeWindowRefreshesMetricsAfterRuntimeSettingsAndDpiChanges()
{
Assert.Contains("interactionMetrics: _interactionMetrics", Window,
StringComparison.Ordinal);
Assert.Contains("private const uint WmSettingChange = 0x001A;", Window,
StringComparison.Ordinal);
Assert.Contains("private const uint WmDisplayChange = 0x007E;", Window,
StringComparison.Ordinal);
Assert.Contains("private const uint WmDpiChanged = 0x02E0;", Window,
StringComparison.Ordinal);
Assert.Contains("AttachInteractionMetricsRefresh();", Window,
StringComparison.Ordinal);
Assert.Contains("DetachInteractionMetricsRefresh();", Window,
StringComparison.Ordinal);
Assert.Contains("SetWindowSubclass(", Window, StringComparison.Ordinal);
Assert.Contains("RemoveWindowSubclass(", Window, StringComparison.Ordinal);
Assert.Contains(
"message is WmSettingChange or WmDisplayChange or WmDpiChanged",
Window,
StringComparison.Ordinal);
Assert.Contains("QueueInteractionMetricsRefresh();", Window,
StringComparison.Ordinal);
Assert.Contains("DispatcherQueuePriority.High", Window,
StringComparison.Ordinal);
Assert.True(
CountOccurrences(Window, "RefreshInteractionMetrics();") >= 3,
"Metrics must be refreshed at setup, runtime change, and state posting.");
Assert.DoesNotContain(
"private readonly LegacyInteractionMetrics _interactionMetrics",
Window,
StringComparison.Ordinal);
}
[Fact]
public void NativeWindowDisablesFutureWebZoomAndFallsBackWithoutPrivateApis()
{
Assert.Contains(
"coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;",
Window,
StringComparison.Ordinal);
Assert.Contains("coreWebView.Settings.IsZoomControlEnabled = false;", Window,
StringComparison.Ordinal);
Assert.Contains("coreWebView.Settings.IsPinchZoomEnabled = false;", Window,
StringComparison.Ordinal);
Assert.DoesNotContain("CoreWebView2Controller.ZoomFactor", Window,
StringComparison.Ordinal);
Assert.Contains("catch (DllNotFoundException)", Window,
StringComparison.Ordinal);
Assert.Contains("catch (EntryPointNotFoundException)", Window,
StringComparison.Ordinal);
Assert.Contains("catch (BadImageFormatException)", Window,
StringComparison.Ordinal);
Assert.Contains("catch (PlatformNotSupportedException)", Window,
StringComparison.Ordinal);
Assert.Equal(
4,
CountOccurrences(Window, "return CreateFallbackInteractionMetrics();"));
Assert.Contains(
"LegacyInteractionMetrics.Default with\n" +
" {\n" +
" HostRasterizationScale = ReadHostRasterizationScale()",
Window.Replace("\r\n", "\n", StringComparison.Ordinal),
StringComparison.Ordinal);
}
private static int CountOccurrences(string value, string search)
{
var count = 0;
var index = 0;
while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += search.Length;
}
return count;
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Repository root was not found.");
}
}

View File

@@ -0,0 +1,100 @@
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyNamedPlaylistDispatchFailureContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string MainWindow = File.ReadAllText(Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MainWindow.xaml.cs"));
[Fact]
public void NativeRejectionsAndUnexpectedExceptionsTerminateCorrelatedCommands()
{
Assert.Contains(
"PostState(IsNamedPlaylistModalIntent(intent)",
MainWindow,
StringComparison.Ordinal);
Assert.Contains(
"var failureState = ReportIntentFailure(",
MainWindow,
StringComparison.Ordinal);
Assert.Contains(
"ObserveOperatorMutationQuarantine(failureState);",
MainWindow,
StringComparison.Ordinal);
Assert.Contains(
"writeOutcomeMayBeUnknown: dispatchStarted",
MainWindow,
StringComparison.Ordinal);
Assert.Contains(
"_controller.ReportNamedPlaylistWriteOutcomeUnknown(",
MainWindow,
StringComparison.Ordinal);
Assert.Contains("RefreshNamedPlaylistsForDispatchAsync(", MainWindow,
StringComparison.Ordinal);
Assert.Contains("SelectNamedPlaylistForDispatch(", MainWindow,
StringComparison.Ordinal);
Assert.Contains("CreateNamedPlaylistForDispatchAsync(", MainWindow,
StringComparison.Ordinal);
Assert.Contains("DeleteNamedPlaylistForDispatchAsync(", MainWindow,
StringComparison.Ordinal);
Assert.Contains("_controller.CompleteNamedPlaylistDispatch(", MainWindow,
StringComparison.Ordinal);
Assert.Contains(
"_controller.ReportNamedPlaylistCommandFailure(\n" +
" \"load-named-playlist-by-id\"",
MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal),
StringComparison.Ordinal);
Assert.Contains(
"_controller.ReportNamedPlaylistCommandFailure(\n" +
" \"save-current-named-playlist-to\"",
MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal),
StringComparison.Ordinal);
}
[Fact]
public void SelectionUsesAnExactReceiptWithoutDisablingTheSecondDoubleClick()
{
var normalized = MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal);
Assert.Contains(
"LegacySelectNamedPlaylistIntent selection =>\n" +
" SelectNamedPlaylistForDispatch(",
normalized,
StringComparison.Ordinal);
Assert.Contains(
"intent is LegacyRefreshNamedPlaylistsIntent or\n" +
" LegacySelectNamedPlaylistIntent or",
normalized,
StringComparison.Ordinal);
Assert.DoesNotContain(
"LegacyRefreshNamedPlaylistsIntent or\n" +
" LegacySelectNamedPlaylistIntent or",
normalized,
StringComparison.Ordinal);
Assert.Contains(
"\"select-named-playlist\",\n" +
" requestId,\n" +
" succeeded",
normalized,
StringComparison.Ordinal);
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Repository root was not found.");
}
}

View File

@@ -14,7 +14,7 @@ public sealed class LegacyPlayoutWebContractTests
Path.Combine(WebRoot, "playout-ui.js"));
[Fact]
public void ShellExposesOriginalPlayoutBackgroundAndFadeControls()
public void ShellExposesBackgroundControlsButKeepsOriginalHiddenFadeStateInert()
{
foreach (var id in new[]
{
@@ -40,10 +40,11 @@ public sealed class LegacyPlayoutWebContractTests
var fadeMarkup = ExtractElement(Markup, "playout-fade-duration", "</select>");
Assert.Equal(20, CountOccurrences(fadeMarkup, "<option value="));
Assert.Contains(
"class=\"fade-control\" for=\"playout-fade-duration\">DissolveTime</label>",
"class=\"fade-control\" for=\"playout-fade-duration\" hidden",
Markup,
StringComparison.Ordinal);
Assert.DoesNotContain(" hidden", fadeMarkup, StringComparison.Ordinal);
Assert.Contains("hidden aria-hidden=\"true\" tabindex=\"-1\"", fadeMarkup,
StringComparison.Ordinal);
for (var index = 0; index < 20; index++)
{
Assert.Contains(
@@ -65,8 +66,7 @@ public sealed class LegacyPlayoutWebContractTests
"next-playout",
"take-out",
"choose-background",
"toggle-background",
"set-fade-duration"
"toggle-background"
})
{
Assert.Contains($"\"{intent}\"", Script, StringComparison.Ordinal);
@@ -82,6 +82,14 @@ public sealed class LegacyPlayoutWebContractTests
Assert.Contains("post(\"take-in\", {})", Script, StringComparison.Ordinal);
Assert.Contains("post(\"next-playout\", {})", Script, StringComparison.Ordinal);
Assert.Contains("post(\"take-out\", {})", Script, StringComparison.Ordinal);
Assert.DoesNotContain("post(\"set-fade-duration\"", Script,
StringComparison.Ordinal);
Assert.DoesNotContain("fadeDuration.addEventListener", Script,
StringComparison.Ordinal);
Assert.Contains("fadeDuration.disabled = true;", Script,
StringComparison.Ordinal);
Assert.Contains("fadeDuration.value = String(playout.fadeDuration);", Script,
StringComparison.Ordinal);
}
[Fact]
@@ -92,7 +100,11 @@ public sealed class LegacyPlayoutWebContractTests
Assert.Contains("event.key === \"F3\"", Script, StringComparison.Ordinal);
Assert.Contains("event.key === \"F8\"", Script, StringComparison.Ordinal);
Assert.Contains("event.key === \"Escape\"", Script, StringComparison.Ordinal);
Assert.Contains("event.defaultPrevented || event.repeat || hasOpenDialog()", Script,
Assert.Contains("if (event.defaultPrevented) return;", Script,
StringComparison.Ordinal);
Assert.Contains("if (hasOpenDialog())", Script,
StringComparison.Ordinal);
Assert.Contains("if (legacyShortcut) event.preventDefault();", Script,
StringComparison.Ordinal);
}

View File

@@ -56,18 +56,36 @@ public sealed class LegacyRowDoubleClickParityTests
var doubleClick = Extract(
"namedPlaylistDefinitions.addEventListener(\"dblclick\"",
"namedPlaylistDefinitions.addEventListener(\"click\"");
var action = Extract(
"function runNamedPlaylistDoubleClick",
"function lockNamedPlaylistControls");
var confirmation = Extract(
"function resolveNamedPlaylistConfirmation",
"function renderNamedPlaylist");
var matcher = Extract(
"function isMatchingNamedPlaylistReceipt",
"function openNamedPlaylistConfirmation");
var render = Extract("function renderNamedPlaylist", "function renderOperatorCatalog");
Assert.Contains("\"load-named-playlist-by-id\"", doubleClick,
Assert.Contains("namedPlaylistSelection.defer", doubleClick,
StringComparison.Ordinal);
Assert.Contains("\"save-current-named-playlist-to\"", doubleClick,
Assert.Contains("runNamedPlaylistDoubleClick", doubleClick,
StringComparison.Ordinal);
Assert.Contains("\"load-named-playlist-by-id\"", action,
StringComparison.Ordinal);
Assert.Contains("openNamedPlaylistConfirmation", action,
StringComparison.Ordinal);
Assert.Contains("message: \"저장하겠습니까?\"", action,
StringComparison.Ordinal);
Assert.Contains("showSavedAlert: false", action, StringComparison.Ordinal);
Assert.Contains("closeOnNo: true", action, StringComparison.Ordinal);
Assert.DoesNotContain("window.confirm", action + doubleClick, StringComparison.Ordinal);
Assert.Contains("\"save-current-named-playlist-to\"", confirmation,
StringComparison.Ordinal);
Assert.Contains("window.confirm", doubleClick, StringComparison.Ordinal);
Assert.Contains("definitionId: definitionId", doubleClick, StringComparison.Ordinal);
Assert.DoesNotContain("select-named-playlist", doubleClick, StringComparison.Ordinal);
Assert.Contains("commandResult.succeeded === true", render, StringComparison.Ordinal);
Assert.Contains("commandResult.targetId === pendingNamedPlaylistCommand.definitionId",
render,
Assert.Contains("commandResult.targetId === pending.targetId",
matcher,
StringComparison.Ordinal);
Assert.Contains("closeNamedPlaylist()", render, StringComparison.Ordinal);
}

View File

@@ -161,8 +161,11 @@ public sealed class LegacyWebContractTests
Assert.Contains("event.dataTransfer.setData(playlistDragMime, rowId)", App,
StringComparison.Ordinal);
Assert.DoesNotContain("selectedRowIds", App, StringComparison.Ordinal);
Assert.Contains("element.draggable = !cutDragLocked", App, StringComparison.Ordinal);
Assert.Contains("if (playlistMutationLocked || !event.dataTransfer)", App,
Assert.Contains("element.draggable = false", App, StringComparison.Ordinal);
Assert.Contains("beginCutDragGesture(element, event, position)", App,
StringComparison.Ordinal);
Assert.Contains("window.LegacyCutDrag.shouldStart", App, StringComparison.Ordinal);
Assert.Contains("if (playlistMutationLocked || !rowId || !event.dataTransfer", App,
StringComparison.Ordinal);
Assert.Contains("if (!playlistMutationLocked) send(\"delete-selected-playlist-rows\"", App,
StringComparison.Ordinal);
@@ -181,7 +184,7 @@ public sealed class LegacyWebContractTests
"} else if ((event.key === \"Home\"",
StringComparison.Ordinal);
var homeEndBranchEnd = App.IndexOf(
"} else if (event.key === \"Escape\"",
"\n });",
homeEndBranchStart,
StringComparison.Ordinal);
Assert.True(homeEndBranchStart >= 0 && homeEndBranchEnd > homeEndBranchStart);
@@ -348,9 +351,9 @@ public sealed class LegacyWebContractTests
{
"refresh-named-playlists",
"select-named-playlist",
"load-selected-named-playlist",
"load-named-playlist-by-id",
"create-named-playlist",
"save-current-named-playlist",
"save-current-named-playlist-to",
"delete-selected-named-playlist"
})
{
@@ -359,9 +362,16 @@ public sealed class LegacyWebContractTests
Assert.Contains("state.namedPlaylist", App, StringComparison.Ordinal);
Assert.Contains("dataset.namedPlaylistDefinitionId", App, StringComparison.Ordinal);
Assert.Contains("definitionId: definition.dataset.namedPlaylistDefinitionId", App,
Assert.Contains(
"beginNamedPlaylistSelection(definition.dataset.namedPlaylistDefinitionId)", App,
StringComparison.Ordinal);
Assert.Contains(
"dbLoadButton.disabled = operatorMutationLocked || busy || !named",
App,
StringComparison.Ordinal);
Assert.Contains("requestId: requestId", App, StringComparison.Ordinal);
Assert.Contains("commandResult.targetId === pending.targetId", App,
StringComparison.Ordinal);
Assert.Contains("dbLoadButton.disabled = busy || !named", App, StringComparison.Ordinal);
Assert.Contains("named.isWriteQuarantined", App, StringComparison.Ordinal);
Assert.DoesNotContain("programCode", App, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("listText", App, StringComparison.OrdinalIgnoreCase);
@@ -375,8 +385,13 @@ public sealed class LegacyWebContractTests
[Fact]
public void SeparatorRowsPreserveOriginalDragGesture()
{
Assert.Contains("element.draggable = true", App, StringComparison.Ordinal);
Assert.DoesNotContain("draggable = !row.isSeparator", App, StringComparison.Ordinal);
Assert.Contains(
"element.dataset.cutDragEnabled = cutSelectionEnabled ? \"true\" : \"false\"",
App,
StringComparison.Ordinal);
Assert.Contains("element.draggable = false", App, StringComparison.Ordinal);
Assert.DoesNotContain("cutDragEnabled = !row.isSeparator", App,
StringComparison.Ordinal);
}
[Fact]