"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 harness = fs.readFileSync(path.join( repositoryRoot, "scripts", "Test-LegacyPackageInput.mjs"), "utf8"); const runner = fs.readFileSync(path.join( repositoryRoot, "scripts", "Invoke-LegacyPackageInputSmoke.ps1"), "utf8"); function section(source, startMarker, endMarker) { const start = source.indexOf(startMarker); assert.notEqual(start, -1, "missing start marker: " + startMarker); const end = source.indexOf(endMarker, start + startMarker.length); assert.ok(end > start, "missing end marker: " + endMarker); return source.slice(start, end); } function assertInOrder(source, markers) { let cursor = 0; for (const marker of markers) { const index = source.indexOf(marker, cursor); assert.ok(index >= cursor, "missing or out-of-order marker: " + marker); cursor = index + marker.length; } } test("DryRun PROGRAM uses the original all, mouse, Space, and NEXT split", () => { const sequence = section( harness, "async function executeDryRunPlayoutSequence", "function recordFullUiDbCheck"); assertInOrder(sequence, [ "PROGRAM clear every enabled row", "snapshot.state.playlist.every(row => row.isEnabled === false)", "snapshot.state.playout.nextKind === \"endOfPlaylist\"", "PROGRAM restore every enabled row", "snapshot.state.playlist.every(row => row.isEnabled === true)", "pointerClickDisabledPlaylistCheckbox(", "checkboxAriaDisabled === true", "pressKey(\"Space\"", "[\"toggle-active-playlist-enabled\"]", "DryRun NEXT skips the Space-disabled future row", "currentEntryId === nextEntryId" ]); assert.match(sequence, /\["set-all-playlist-enabled"\]/u); assert.match(sequence, /setAllIntentCount: 2/u); assert.match(sequence, /toggleActiveIntentCount: 1/u); assert.doesNotMatch(sequence, /send\("set-playlist-enabled"/u); }); test("F8 and Escape are physical one-shot keys only in known DryRun phases", () => { const authorization = section( harness, "function authorizeDryRunPlayoutShortcut", "async function setSearchText"); assert.match(authorization, /new Set\(\["F8", "Escape"\]\)/u); assert.match(authorization, /definitionName === "F8" \? "idle" : "program"/u); assert.match(authorization, /playout\.outcomeUnknown === true/u); assert.match(authorization, /playout\.isPlayCompletionPending === true/u); assert.match(authorization, /playout\.isTakeOutCompletionPending === true/u); assert.match(authorization, /authorization\.consumed === true/u); assert.match(authorization, /authorization\.consumed = true/u); const sequence = section( harness, "async function executeDryRunPlayoutSequence", "function recordFullUiDbCheck"); assertInOrder(sequence, [ "const f8Authorization = authorizeDryRunPlayoutShortcut(", "\"rapid DryRun F8 after row selection\"", "takeInIntents.length !== 1", "const escapeAuthorization = authorizeDryRunPlayoutShortcut(", "\"DryRun Escape TAKE OUT after NEXT\"", "takeOutIntents.length !== 1", "finalPhase = current.state.playout.phase" ]); }); test("ThemeA button and existing UC6 Enter expose a bounded blank stock list", () => { const blankSearch = section( harness, "async function verifyBlankOperatorCatalogStockSearch", "async function addOperatorCatalogStock"); assertInOrder(blankSearch, [ "`${label} blank query`", "trigger === \"button\"", "pressKey(\"Enter\"", "currentCatalog?.stockQuery === \"\"", "resultCount > 1 && resultCount <= 10_000", "statusMessage === \"종목 검색을 완료했습니다.\"", "operatorCatalogStockResultCount === resultCount", "messages[0].emptyStockQuery !== true", "resultCount !== 10_000 || !truncationVisible", "draftRowsPreserved: true" ]); const theme = section( harness, "async function createReadEditDeleteTheme", "async function searchAndSelectMainExpert"); assertInOrder(theme, [ "`${label} ThemeA editor`", "addOperatorCatalogStockByDoubleClick(" ]); const expert = section( harness, "async function createReadEditDeleteExpert", "async function executeOperatorCatalogCrud"); assertInOrder(expert, [ "snapshot.state.operatorCatalog?.mode === \"edit\"", "`${label} existing UC6 editor`", "addOperatorCatalogStockByDoubleClick(" ]); }); test("KRX ThemeA create physically round-trips NXT while preserving the name", () => { const allowed = section( harness, "const fullUiDbAllowedOutboundMessageTypes", "const playoutIntentMessageTypes"); assert.match(allowed, /"change-create-theme-market"/u); const roundTrip = section( harness, "async function verifyKrxThemeCreateMarketRoundTrip", "async function createReadEditDeleteTheme"); assertInOrder(roundTrip, [ "operatorCatalogEditorMarketValue !== \"krx\"", "draftRows?.some(row => row.rowId === draftRowId)", "switch editor market to NXT", "newThemeMarket === \"nxt\"", "editorName === title", "draftRows?.length === 0", "statusKind === \"warning\"", "operatorCatalogNameValue === title", "change-create-theme-market", "switch editor market back to KRX", "newThemeMarket === \"krx\"", "operatorCatalogNameValue === title", "removedDraftRowId: draftRowId" ]); const create = section( harness, "async function createReadEditDeleteTheme", "async function searchAndSelectMainExpert"); assertInOrder(create, [ "let added = await addOperatorCatalogStockByDoubleClick(", "await verifyKrxThemeCreateMarketRoundTrip(", "duplicate recheck after market round-trip", "name available after market round-trip", "stock re-add after market round-trip", "let expectedSavedRows = 1" ]); }); test("playlist Shift-click deletion is one physical Windows exchange and restores three rows", () => { const setup = section( harness, "async function executeSafeSequence", "async function executeDryRunPlayoutSequence"); assertInOrder(setup, [ '"stage first disposable Shift-delete row"', "await sleep(legacyCutDoubleClickWindowMilliseconds + 100)", '"stage second disposable Shift-delete row"' ]); assert.match(harness, /const legacyCutDoubleClickWindowMilliseconds = 500;/u); const request = section( harness, "async function requestWindowsInput(", "async function installProbe"); assertInOrder(request, [ "beforeOrder.length !== 5", "reorderedOrder.at(-2) !== rangeStartRowId", "const afterOrder = reorderedOrder.filter", 'operation: "row-header-drag-home-shift-range-delete-restore"', "expectedRangeSelectedRowIds: [rangeStartRowId, rangeEndRowId]", "mouseDownCalls: 4", "shiftKeyDownCalls: 1", "deleteKeyCalls: 1", '"delete-selected-playlist-rows"', "selections[1].shift !== true", "shiftRangeDeletedRowIds = [rangeStartRowId, rangeEndRowId]" ]); assert.doesNotMatch(request, /Input\.dispatchMouseEvent/u); const native = section( runner, "public static void SendShiftClickRangeThenDeleteAndRestore(", "public static void SendApplicationClick("); assertInOrder(native, [ "SendKeyboard(VirtualKeyShift, false)", "SendMouseButton(MouseLeftDown)", "SendKeyboard(VirtualKeyShift, true)", "SendKeyboard(VirtualKeyDelete, false)", "SendKeyboard(VirtualKeyDelete, true)", "SendMouseMove(restore.X, restore.Y)" ]); }); test("PList fresh readback uses the second physical double-click exchange", () => { const refresh = section( harness, "function namedPlaylistCommandSequence", "async function requestNamedPlaylistLoadDoubleClick"); assertInOrder(refresh, [ "Number.isSafeInteger(sequence)", 'result?.command === "refresh-named-playlists"', "result.succeeded === true", "result.sequence > baselineSequence", 'listFreshness === "fresh"' ]); const request = section( harness, "async function requestNamedPlaylistLoadDoubleClick", "function namedPlaylistDeleteWriteCount"); assertInOrder(request, [ "configuration.windowsInputExchangeCount !== 2", 'current.dom.namedTitle !== "DB 재생목록 불러오기"', "must start on the freshly read, unselected saved row", "const beforeContent = playlistContentProjection(current)", 'operation: "named-playlist-load-double-click"', "mouseDownCalls: 2", "doubleClickIntervalMilliseconds: 100", 'snapshot.state.commandResult?.command === "load-named-playlist-by-id"', "JSON.stringify(playlistContentProjection(snapshot))", '["select-named-playlist", "load-named-playlist-by-id"]', "loads.length !== 1", "selections.length !== 1", "approvedDatabaseWriteMessages.length !== beforeApprovedWriteCount", "startedUnselected: true", "selectionIntentCount: selections.length", "contentRestored: true" ]); assert.doesNotMatch(request, /pointerDoubleClick|Input\.dispatchMouseEvent/u); const crud = section( harness, "async function executeNamedPlaylistCrud", "const manualFinancialScreens"); assertInOrder(crud, [ "PList reopen for fresh readback", "completedNamedPlaylistRefreshAfter(snapshot, readbackRefreshBaseline)", "matches[0].isSelected !== true", "requestNamedPlaylistLoadDoubleClick(", '"PList physical saved-row double-click load"', '"PList physical double-click fresh readback"', "PList reopen for delete", "completedNamedPlaylistRefreshAfter(snapshot, deleteRefreshBaseline)" ]); assert.doesNotMatch(crud, /PList select fresh readback|PList load fresh rows/u); const native = section( runner, "public static void SendApplicationDoubleClick(", "public static IntPtr ReadForegroundWindow("); assert.match(native, /for \(int click = 0; click < 2; click\+\+\)/u); assert.match(native, /SendMouseButton\(MouseLeftDown\)/u); assert.match(native, /SendMouseButton\(MouseLeftUp\)/u); assert.doesNotMatch(native, /Input\.dispatchMouseEvent/u); });