373 lines
15 KiB
JavaScript
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);
|
|
});
|