"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(/ 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", 'value: typeof activeElement.value === "string" ? activeElement.value : null', "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", "const control = canRestoreFocus && saved.controlFocus && saved.controlFocus.id", "document.getElementById(saved.controlFocus.id)", "const canRestoreControl = control && categoryTree.contains(control)", "if (canRestoreFocus && !canRestoreControl && focused", "const controlWasRecreated = document.activeElement !== control", 'typeof saved.controlFocus.value === "string"', "control.value = saved.controlFocus.value", "if (controlWasRecreated) 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 requests the native saved dialog then 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", "namedPlaylistConfirmationYes.disabled = false", "namedPlaylistConfirmationNo.disabled = false", "syncModalFocus()" ]); const confirmationLock = section( "namedPlaylistConfirmationYes.disabled = pendingNamedPlaylistCommand", "if (deferredSelectionAction)"); assert.doesNotMatch(confirmationLock, /operatorMutationLocked|uiBusy/, "an unrelated PROGRAM refresh must not cancel a physical confirmation click"); assertOrdered(resolution, [ "namedPlaylistConfirmationModal.hidden = true", "syncModalFocus()", "if (!confirmed)", "confirmation.closeOnNo === true", "closeNamedPlaylist()", '"save-current-named-playlist-to"', "confirmation.showSavedAlert === true" ]); const commandStart = section( "function beginNamedPlaylistCommand", "function isMatchingNamedPlaylistReceipt"); assertOrdered(commandStart, [ "const payload = { definitionId: definitionId }", 'if (command === "save-current-named-playlist-to")', "payload.showSavedAlert = showSavedAlert === true", "pendingNamedPlaylistCommand = {", "send(command, payload)" ]); assert.doesNotMatch(commandStart, /send\(command,\s*\{/, "load and save must not share an open inline payload"); const renderNamed = section("function renderNamedPlaylist", "function clearOperatorCatalogDragVisuals"); assertOrdered(renderNamed, [ "isMatchingNamedPlaylistReceipt(", "pendingNamedPlaylistCommand", "commandResult", "pendingNamedPlaylistCommand = null", "closeNamedPlaylist()" ]); assert.doesNotMatch(renderNamed, /window\.alert/); }); 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, [ "const payload = { definitionId: definitionId }", 'if (command === "save-current-named-playlist-to")', "payload.showSavedAlert = showSavedAlert === true", "pendingNamedPlaylistCommand = {", "command: command", "targetId: definitionId", "definitionId: definitionId", "minimumSequence: lastCommandSequence + 1", "closeOnSuccess: true", "send(command, payload)", "lockNamedPlaylistControls()" ]); assert.doesNotMatch(locking, /send\(command,\s*\{/, "load and save must dispatch their closed command-specific payload"); 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 = playlistStructureMutationLocked/); 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); });