Files
MBN_STOCK_WEBVIEW/tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs

373 lines
15 KiB
JavaScript

"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);
});