import fs from "node:fs"; import path from "node:path"; import { createHash, randomBytes } from "node:crypto"; const exactTargetUrl = "https://legacy-parity.mbn.local/index.html"; const ctrlModifier = 2; const shiftModifier = 8; const legacyCutDoubleClickWindowMilliseconds = 500; const defaultObservationTimeoutMilliseconds = 45_000; const minimumObservationTimeoutMilliseconds = 5_000; const maximumObservationTimeoutMilliseconds = 900_000; const expectedWorkspaceIconByTabId = Object.freeze({ overseas: "overseas", exchange: "exchange", index: "index", kospiIndustry: "kospi", kosdaqIndustry: "kosdaq", comparison: "comparison", theme: "theme", overseasStocks: "overseas-stocks", expert: "expert", tradingHalt: "trading-halt" }); const acceptedArgumentNames = new Set([ "profile", "scope", "port", "output", "screenshot", "windows-input-request", "windows-input-ack", "recover-named-playlist-title", "timeout-ms" ]); const readOnlyForbiddenControlIds = Object.freeze([ "playout-prepare", "playout-take-in", "playout-next", "playout-take-out", "named-playlist-create-button", "named-playlist-delete-button", "named-playlist-confirmation-yes", "operator-catalog-save", "operator-catalog-delete" ]); const readOnlyAllowedOutboundMessageTypes = Object.freeze([ "ready", "search-stocks", "select-stock", "cut-pointer-down", "cut-key-down", "drop-selected-cuts", "select-tab", "hover-swap-tabs", "activate-playlist-row", "select-playlist-row", "move-selected-playlist-rows", "reorder-playlist-rows", "select-playlist-boundary", "delete-selected-playlist-rows", "set-playlist-enabled", "refresh-named-playlists" ]); const fullUiDbAllowedOutboundMessageTypes = Object.freeze([ ...readOnlyAllowedOutboundMessageTypes, "activate-fixed-action", "activate-fixed-section", "select-industry", "double-click-industry", "swap-industries", "set-industry-comparison-text", "activate-industry-action", "activate-industry-section", "search-overseas-industries", "search-overseas-stocks", "select-overseas-fixed-index", "select-overseas-industry", "select-overseas-stock", "activate-overseas-action", "search-comparison-domestic", "search-comparison-world", "select-comparison-domestic", "select-comparison-world", "select-comparison-fixed", "choose-comparison-domestic", "choose-comparison-world", "choose-comparison-fixed", "swap-comparison-targets", "clear-comparison-targets", "add-comparison-pair", "select-comparison-pair", "move-comparison-pair", "delete-selected-comparison-pair", "delete-all-comparison-pairs", "activate-comparison-action", "activate-comparison-section", "search-themes", "select-theme", "activate-theme-result", "set-theme-sort", "activate-theme-action", "begin-uc4-theme-catalog", "edit-uc4-selected-theme", "delete-uc4-selected-theme", "search-experts", "select-expert", "activate-expert-action", "begin-uc6-expert-catalog", "edit-uc6-selected-expert", "delete-uc6-selected-expert", "search-trading-halts", "select-trading-halt", "toggle-trading-halt-sort", "activate-trading-halt-action", "open-manual-financial", "close-manual-financial", "search-manual-financial", "find-manual-financial", "toggle-manual-financial-name-sort", "load-manual-financial", "activate-manual-financial-result", "begin-manual-financial-create", "search-manual-financial-stocks", "select-manual-financial-stock", "save-manual-financial", "save-manual-financial-raw", "delete-manual-financial", "delete-all-manual-financial", "activate-manual-financial-action", "open-manual-list", "close-manual-list", "refresh-manual-list", "set-manual-net-sell-cell", "search-manual-vi", "select-manual-vi-result", "add-manual-vi-result", "move-manual-vi-item", "delete-manual-vi-item", "delete-all-manual-vi-items", "confirm-manual-list", "open-operator-catalog", "close-operator-catalog", "search-operator-catalog", "select-operator-catalog-result", "begin-create-theme-catalog", "change-create-theme-market", "begin-create-expert-catalog", "search-operator-catalog-stocks", "select-operator-catalog-stock", "add-operator-catalog-stock", "activate-operator-catalog-stock", "move-operator-catalog-draft-row", "reorder-operator-catalog-draft-row", "remove-operator-catalog-draft-row", "select-operator-catalog-draft-row", "remove-active-operator-catalog-draft-row", "clear-operator-catalog-draft-rows", "set-operator-catalog-draft-buy-amount", "check-operator-catalog-theme-name", "save-operator-catalog", "confirm-native-dialog", "cancel-native-dialog", "dismiss-dialog", "select-named-playlist", "load-named-playlist-by-id", "create-named-playlist", "save-current-named-playlist-to", "delete-selected-named-playlist", "move-selected-playlist-rows", "delete-selected-playlist-rows", "delete-all-playlist-rows", "set-all-playlist-enabled", "set-moving-averages" ]); const playoutIntentMessageTypes = Object.freeze([ "prepare-playout", "take-in", "next-playout", "take-out", "choose-background", "toggle-background" ]); const databaseWriteIntentMessageTypes = Object.freeze([ "create-named-playlist", "delete-selected-named-playlist", "save-current-named-playlist-to", "save-manual-financial", "save-manual-financial-raw", "delete-manual-financial", "delete-all-manual-financial", "import-manual-lists", "confirm-manual-list", "add-comparison-pair", "move-comparison-pair", "delete-selected-comparison-pair", "delete-all-comparison-pairs", "save-operator-catalog", "delete-operator-catalog", "delete-uc4-selected-theme", "delete-uc6-selected-expert" ]); const forbiddenCdpMethods = new Set([ "Browser.close", "Page.close", "Runtime.terminateExecution", "Target.closeTarget" ]); class HarnessFailure extends Error { constructor(category, message) { super(`${category}: ${message}`); this.name = "HarnessFailure"; this.category = category; } } function failKnown(message) { throw new HarnessFailure("KNOWN_FAILURE", message); } function failUnknown(message) { throw new HarnessFailure("OUTCOME_UNKNOWN", message); } function windowsInputExchangePath(basePath, exchangeIndex) { if (!Number.isSafeInteger(exchangeIndex) || exchangeIndex < 1 || exchangeIndex > 3) { failKnown("The Windows input exchange index is outside the closed contract."); } if (exchangeIndex === 1) return basePath; const extension = path.extname(basePath); return path.join( path.dirname(basePath), `${path.basename(basePath, extension)}.exchange-${String(exchangeIndex).padStart(2, "0")}${extension}`); } function expectedWindowsInputExchangeCount(profile, scope) { if (profile === "dry-run-playout") return 3; return profile === "full-ui-db" && new Set(["all", "plist"]).has(scope) ? 2 : 1; } function parseArguments(argv) { if (argv.length === 0 || argv.length % 2 !== 0) { failKnown( "Usage: node scripts/Test-LegacyPackageInput.mjs --port " + "--output --screenshot " + "--windows-input-request " + "--windows-input-ack " + "[--profile read-only|dry-run-playout|full-ui-db|program-playlist-db] " + "[--scope all|plist|graphe|catalog|screens] " + "[--recover-named-playlist-title ] " + "[--timeout-ms ]"); } const values = new Map(); for (let index = 0; index < argv.length; index += 2) { const name = argv[index]; const value = argv[index + 1]; if (!name?.startsWith("--") || value === undefined || value.length === 0) { failKnown("Arguments must be non-empty --name value pairs."); } const key = name.slice(2); if (!acceptedArgumentNames.has(key)) { failKnown(`Unknown argument --${key}.`); } if (values.has(key)) { failKnown(`Argument --${key} was supplied more than once.`); } values.set(key, value); } if (!values.has("port") || !values.has("output") || !values.has("screenshot") || !values.has("windows-input-request") || !values.has("windows-input-ack")) { failKnown( "--port, --output, --screenshot, --windows-input-request, and " + "--windows-input-ack are required."); } const port = Number(values.get("port")); if (!Number.isInteger(port) || port < 1 || port > 65_535) { failKnown("--port must be an integer from 1 through 65535."); } const outputPath = path.resolve(values.get("output")); if (!path.isAbsolute(values.get("output")) || path.extname(outputPath).toLowerCase() !== ".json") { failKnown("--output must be an absolute .json path."); } const screenshotPath = path.resolve(values.get("screenshot")); if (!path.isAbsolute(values.get("screenshot")) || path.extname(screenshotPath).toLowerCase() !== ".png") { failKnown("--screenshot must be an absolute .png path."); } if (outputPath.toLowerCase() === screenshotPath.toLowerCase()) { failKnown("--output and --screenshot must name different files."); } const windowsInputRequestPath = path.resolve(values.get("windows-input-request")); const windowsInputAckPath = path.resolve(values.get("windows-input-ack")); for (const [name, candidate] of [ ["--windows-input-request", windowsInputRequestPath], ["--windows-input-ack", windowsInputAckPath] ]) { if (!path.isAbsolute(values.get(name.slice(2))) || path.extname(candidate).toLowerCase() !== ".json") { failKnown(`${name} must be an absolute .json path.`); } } const uniqueEvidencePaths = new Set([ outputPath.toLowerCase(), screenshotPath.toLowerCase(), windowsInputRequestPath.toLowerCase(), windowsInputAckPath.toLowerCase() ]); if (uniqueEvidencePaths.size !== 4) { failKnown("All result, screenshot, Windows-input request, and ack paths must differ."); } const timeoutMilliseconds = values.has("timeout-ms") ? Number(values.get("timeout-ms")) : defaultObservationTimeoutMilliseconds; if (!Number.isInteger(timeoutMilliseconds) || timeoutMilliseconds < minimumObservationTimeoutMilliseconds || timeoutMilliseconds > maximumObservationTimeoutMilliseconds) { failKnown( `--timeout-ms must be an integer from ${minimumObservationTimeoutMilliseconds} ` + `through ${maximumObservationTimeoutMilliseconds}.`); } if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath) || fs.existsSync(windowsInputRequestPath) || fs.existsSync(windowsInputAckPath)) { failKnown("An evidence path already exists; evidence is never overwritten."); } fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); fs.mkdirSync(path.dirname(windowsInputRequestPath), { recursive: true }); fs.mkdirSync(path.dirname(windowsInputAckPath), { recursive: true }); const profile = values.get("profile") || "read-only"; if (!new Set([ "read-only", "dry-run-playout", "full-ui-db", "program-playlist-db" ]).has(profile)) { failKnown( "--profile must be read-only, dry-run-playout, full-ui-db, or program-playlist-db."); } const scope = values.get("scope") || "all"; if (!new Set(["all", "plist", "graphe", "catalog", "screens"]).has(scope)) { failKnown("--scope must be all, plist, graphe, catalog, or screens."); } if (profile !== "full-ui-db" && scope !== "all") { failKnown("A non-DB-write run requires --scope all."); } const recoverNamedPlaylistTitle = values.get("recover-named-playlist-title") || null; if (recoverNamedPlaylistTitle !== null && (recoverNamedPlaylistTitle.length !== 26 || !/^CDX_P_[0-9]{13}_[0-9a-f]{6}$/.test(recoverNamedPlaylistTitle))) { failKnown( "--recover-named-playlist-title must be one exact generated CDX_P fixture title."); } if (recoverNamedPlaylistTitle !== null && (profile !== "full-ui-db" || !new Set(["all", "plist"]).has(scope))) { failKnown( "--recover-named-playlist-title requires full-ui-db scope all or plist."); } const windowsInputExchangeCount = expectedWindowsInputExchangeCount(profile, scope); for (let exchangeIndex = 2; exchangeIndex <= windowsInputExchangeCount; exchangeIndex += 1) { if (fs.existsSync(windowsInputExchangePath( windowsInputRequestPath, exchangeIndex)) || fs.existsSync(windowsInputExchangePath( windowsInputAckPath, exchangeIndex))) { failKnown("A derived Windows input exchange path already exists; evidence is never overwritten."); } } return { profile, scope, recoverNamedPlaylistTitle, port, outputPath, screenshotPath, windowsInputRequestPath, windowsInputAckPath, windowsInputExchangeCount, timeoutMilliseconds }; } function assertWithinHardDeadline(label) { if (externalCancellationRequested && !cleanupMode) { failUnknown(`The wrapper requested cancellation ${label}; no action is retried.`); } const deadline = cleanupMode ? cleanupDeadlineEpochMilliseconds : hardDeadlineEpochMilliseconds; if (Date.now() >= deadline) { failUnknown(`The global input deadline expired ${label}; no action is retried.`); } } async function sleep(milliseconds) { assertWithinHardDeadline("before waiting"); const activeDeadline = cleanupMode ? cleanupDeadlineEpochMilliseconds : hardDeadlineEpochMilliseconds; const remaining = activeDeadline - Date.now(); await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, remaining))); assertWithinHardDeadline("after waiting"); } function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex").toUpperCase(); } function errorProjection(error) { return { category: error instanceof HarnessFailure ? error.category : "HARNESS_ERROR", name: error?.name || "Error", message: String(error?.message || error) }; } const configuration = parseArguments(process.argv.slice(2)); const fullUiDbProfile = configuration.profile === "full-ui-db"; const dryRunPlayoutProfile = configuration.profile === "dry-run-playout"; const programPlaylistDbProfile = configuration.profile === "program-playlist-db"; const readOnlyProfile = configuration.profile === "read-only"; const databaseWriteProfile = fullUiDbProfile || programPlaylistDbProfile; const activeDryRunProfile = dryRunPlayoutProfile || programPlaylistDbProfile; const forbiddenControlIds = fullUiDbProfile ? Object.freeze([ "playout-background-file", "playout-prepare", "playout-take-in", "playout-next", "playout-take-out" ]) : programPlaylistDbProfile ? Object.freeze([ "playout-background-file", "playout-background-none", "playout-prepare", "playout-next", "db-load-button" ]) : dryRunPlayoutProfile ? Object.freeze(readOnlyForbiddenControlIds.filter(id => !new Set(["playout-take-in", "playout-next", "playout-take-out"]).has(id))) : readOnlyForbiddenControlIds; const allowedOutboundMessageTypes = fullUiDbProfile ? fullUiDbAllowedOutboundMessageTypes : programPlaylistDbProfile ? Object.freeze([ ...readOnlyAllowedOutboundMessageTypes, "select-named-playlist", "create-named-playlist", "save-current-named-playlist-to", "delete-selected-named-playlist", "dismiss-dialog", "take-in", "take-out" ]) : dryRunPlayoutProfile ? Object.freeze([ ...readOnlyAllowedOutboundMessageTypes, "choose-background", "toggle-background", "take-in", "next-playout", "take-out", "set-all-playlist-enabled", "toggle-active-playlist-enabled" ]) : readOnlyAllowedOutboundMessageTypes; const hardDeadlineEpochMilliseconds = Date.now() + configuration.timeoutMilliseconds; const startedAt = new Date().toISOString(); const evidence = { schemaVersion: 1, profile: configuration.profile, scope: configuration.scope, result: "RUNNING", startedAt, completedAt: null, target: { expectedUrl: exactTargetUrl, port: configuration.port, timeoutMilliseconds: configuration.timeoutMilliseconds, id: null, url: null }, safety: { requiredMode: "dryRun", requiredPhase: "idle", forbiddenControlIds, allowedOutboundMessageTypes, appCloseRequested: false, playoutIntentIssued: false, databaseWriteIntentIssued: false, approvedDatabaseWriteMessages: [], observedOutboundMessageTypes: [], blockedOutboundMessages: [], stateViolations: [], externalCancellationRequested: false }, fixture: { query: "삼성", stockDisplayName: "삼성출판사", databaseStockDisplayName: "1Q 삼성전자선물단일종목레버리지", namedPlaylistTitle: databaseWriteProfile ? `CDX_P_${Date.now()}_${randomBytes(3).toString("hex")}` : null, krxThemeTitle: fullUiDbProfile ? `CDX_K_${Date.now()}_${randomBytes(3).toString("hex")}` : null, nxtThemeTitle: fullUiDbProfile ? `CDX_N_${Date.now()}_${randomBytes(3).toString("hex")}` : null, expertTitle: fullUiDbProfile ? `CDX_E_${Date.now()}_${randomBytes(3).toString("hex")}` : null }, fullUiDb: fullUiDbProfile ? { result: "PENDING", screenChecks: [], databaseChecks: [], screenInventory: [], namedPlaylistRecovery: configuration.recoverNamedPlaylistTitle ? { targetTitle: configuration.recoverNamedPlaylistTitle, result: "pending", exactMatchCount: null, freshListVerified: false, writeCount: 0, mutationOutcome: null, exactAbsenceVerified: false } : null, cleanupVerified: false } : null, dryRunPlayout: dryRunPlayoutProfile ? { result: "PENDING", stagedEntryId: null, selectedEntryId: null, skippedEntryId: null, nextEntryId: null, programCutSelection: null, playlistCountBeforeSelection: null, playlistCountAfterDoubleClick: null, tailAppendedEntryId: null, programEnableParity: null, backgroundShortcutParity: null, shortcutParity: null } : null, programPlaylistDb: programPlaylistDbProfile ? { result: "PENDING", title: null, definitionId: null, currentEntryId: null, rowCount: null, createWriteCount: 0, saveWriteCount: 0, deleteWriteCount: 0, freshDefinitionReadbackVerified: false, exactAbsenceVerified: false, finalPhase: null } : null, checkpoints: [], inputs: [], warnings: [], windowsInput: { required: true, expectedExchangeCount: configuration.windowsInputExchangeCount, completedExchangeCount: 0, requestPath: configuration.windowsInputRequestPath, acknowledgementPath: configuration.windowsInputAckPath, requestSha256: null, acknowledgementSha256: null, exchanges: [], result: "PENDING" }, screenshot: null, finalSnapshot: null, error: null }; let socket = null; let nextRequestId = 1; const pendingRequests = new Map(); let externalCancellationRequested = false; let cancellationInputBuffer = ""; let pointerIsPressed = false; let pointerReleasePoint = null; let pointerReleaseModifiers = 0; let dragInterceptionEnabled = false; let activeInterceptedDragData = null; let probeInstalled = false; let cleanupMode = false; let cleanupDeadlineEpochMilliseconds = 0; let expectedJavaScriptDialog = null; let pendingDragIntercept = null; const unexpectedJavaScriptDialogs = []; process.stdin.setEncoding("utf8"); process.stdin.on("data", chunk => { cancellationInputBuffer += chunk; if (!cancellationInputBuffer.split(/\r?\n/u).some(line => line === "CANCEL")) { if (cancellationInputBuffer.length > 64) cancellationInputBuffer = ""; return; } externalCancellationRequested = true; evidence.safety.externalCancellationRequested = true; for (const pending of pendingRequests.values()) { clearTimeout(pending.timer); pending.reject(new HarnessFailure( "OUTCOME_UNKNOWN", `Wrapper cancellation interrupted ${pending.method}; the command is not retried.`)); } pendingRequests.clear(); if (pendingDragIntercept) { clearTimeout(pendingDragIntercept.timer); pendingDragIntercept.reject(new HarnessFailure( "OUTCOME_UNKNOWN", "Wrapper cancellation interrupted the intercepted drag; it is not retried.")); pendingDragIntercept = null; } }); if (typeof process.stdin.unref === "function") process.stdin.unref(); async function discoverExactTarget() { const deadline = Date.now() + Math.min( configuration.timeoutMilliseconds, 30_000); const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`; let lastFailure = null; while (Date.now() < deadline) { try { const response = await fetch(discoveryUrl, { redirect: "error", signal: AbortSignal.timeout(1_000) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const targets = await response.json(); if (!Array.isArray(targets)) { failKnown("CDP discovery did not return a target array."); } const exactTargets = targets.filter(target => target?.type === "page" && target.url === exactTargetUrl); if (exactTargets.length > 1) { failKnown("More than one exact LegacyParityApp WebView target exists."); } if (exactTargets.length === 1) { return exactTargets[0]; } } catch (error) { if (error instanceof HarnessFailure) throw error; lastFailure = String(error?.message || error); } await sleep(100); } failKnown(`The exact package WebView target was not found at ${discoveryUrl}` + (lastFailure ? ` (${lastFailure}).` : ".")); } function validateWebSocketTarget(target) { if (!target?.id || !target.webSocketDebuggerUrl) { failKnown("The exact target does not expose a WebSocket debugger URL."); } const endpoint = new URL(target.webSocketDebuggerUrl); if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || Number(endpoint.port) !== configuration.port || endpoint.pathname !== `/devtools/page/${target.id}`) { failKnown("The CDP endpoint is not the exact loopback page endpoint."); } return endpoint; } async function connect(endpoint) { if (typeof WebSocket !== "function") { failKnown("This Node runtime does not provide the WebSocket API."); } socket = new WebSocket(endpoint.href); await new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new HarnessFailure("OUTCOME_UNKNOWN", "CDP socket open timed out.")), 5_000); socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); socket.addEventListener("error", () => { clearTimeout(timer); reject(new HarnessFailure("OUTCOME_UNKNOWN", "CDP socket failed to open.")); }, { once: true }); }); socket.addEventListener("message", event => { const message = JSON.parse(String(event.data)); if (message.method === "Input.dragIntercepted") { const pending = pendingDragIntercept; if (pending) { pendingDragIntercept = null; clearTimeout(pending.timer); pending.resolve(message.params?.data || null); } return; } if (message.method === "Page.javascriptDialogOpening") { const dialog = message.params || {}; const expected = expectedJavaScriptDialog; if (!expected || dialog.type !== expected.type || (expected.message && dialog.message !== expected.message)) { unexpectedJavaScriptDialogs.push({ type: dialog.type || null, message: dialog.message || null, at: new Date().toISOString() }); return; } expectedJavaScriptDialog = null; void rpc("Page.handleJavaScriptDialog", { accept: expected.accept === true }).then(expected.resolve, expected.reject); return; } if (!Object.hasOwn(message, "id")) return; const pending = pendingRequests.get(message.id); if (!pending) return; pendingRequests.delete(message.id); clearTimeout(pending.timer); if (message.error) { pending.reject(new HarnessFailure( "KNOWN_FAILURE", `${pending.method} failed: ${message.error.message || "CDP error"}`)); } else { pending.resolve(message.result); } }); socket.addEventListener("close", () => { for (const pending of pendingRequests.values()) { clearTimeout(pending.timer); pending.reject(new HarnessFailure( "OUTCOME_UNKNOWN", `CDP socket closed while waiting for ${pending.method}.`)); } pendingRequests.clear(); if (pendingDragIntercept) { clearTimeout(pendingDragIntercept.timer); pendingDragIntercept.reject(new HarnessFailure( "OUTCOME_UNKNOWN", "CDP socket closed while waiting for the intercepted drag.")); pendingDragIntercept = null; } }); } function waitForDragIntercept(label) { if (pendingDragIntercept) { failKnown("A second intercepted drag was requested before the first completed."); } const promise = new Promise((resolve, reject) => { const timer = setTimeout(() => { pendingDragIntercept = null; reject(new HarnessFailure( "KNOWN_FAILURE", `${label} did not expose Chromium's intercepted drag data.`)); }, 5_000); pendingDragIntercept = { resolve, reject, timer }; }); // A pointer or transport failure can happen before the caller reaches its // await. Attach a handler immediately; the caller still observes the same // rejection when it awaits this promise during structured cleanup. void promise.catch(() => {}); return promise; } function cancelPendingDragIntercept(message) { const pending = pendingDragIntercept; if (!pending) return; pendingDragIntercept = null; clearTimeout(pending.timer); pending.reject(new HarnessFailure("KNOWN_FAILURE", message)); } function rpc(method, params = {}, timeoutMilliseconds = 10_000) { assertWithinHardDeadline(`before ${method}`); if (cleanupMode && method !== "Input.dispatchMouseEvent" && method !== "Input.dispatchDragEvent" && method !== "Input.setInterceptDrags" && method !== "Runtime.evaluate") { failKnown(`Cleanup refused unexpected CDP method ${method}.`); } if (forbiddenCdpMethods.has(method)) { evidence.safety.appCloseRequested = true; failKnown(`The harness refused forbidden CDP method ${method}.`); } if (!socket || socket.readyState !== WebSocket.OPEN) { failUnknown(`CDP socket is not open for ${method}.`); } const id = nextRequestId++; return new Promise((resolve, reject) => { const activeDeadline = cleanupMode ? cleanupDeadlineEpochMilliseconds : hardDeadlineEpochMilliseconds; const remaining = activeDeadline - Date.now(); const effectiveTimeout = cleanupMode ? Math.min(timeoutMilliseconds, 2_000, remaining) : Math.min(timeoutMilliseconds, remaining); const timer = setTimeout(() => { pendingRequests.delete(id); reject(new HarnessFailure( "OUTCOME_UNKNOWN", `${method} timed out; the command is not retried.`)); }, effectiveTimeout); pendingRequests.set(id, { method, resolve, reject, timer }); socket.send(JSON.stringify({ id, method, params })); }); } async function evaluate(expression) { const response = await rpc("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, userGesture: false }); if (response.exceptionDetails) { const description = response.exceptionDetails.exception?.description || response.exceptionDetails.text || "Runtime evaluation failed."; failKnown(description); } return response.result?.value; } function probeSnapshotExpression() { return `(() => { const probe = globalThis.__legacyPackageInputProbe; const state = probe?.states?.at(-1) || null; const pointerEvents = probe?.pointerEvents || []; const lastPointerDown = [...pointerEvents].reverse().find(event => event.type === "pointerdown") || null; const activePointerId = Number.isInteger(lastPointerDown?.pointerId) ? lastPointerDown.pointerId : null; const capturedCutPhysicalIndices = activePointerId === null ? [] : Array.from(document.querySelectorAll("#cut-list .cut-row")) .filter(row => typeof row.hasPointerCapture === "function" && row.hasPointerCapture(activePointerId)) .map(row => Number(row.dataset.physicalIndex)); const capturedPlaylistRowIds = activePointerId === null ? [] : Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row .playlist-row-header")) .filter(header => typeof header.hasPointerCapture === "function" && header.hasPointerCapture(activePointerId)) .map(header => header.closest(".playlist-data-row")?.dataset?.rowId || null) .filter(Boolean); const active = document.activeElement; const rowForActive = active?.closest?.(".playlist-data-row") || null; const cutDragFeedback = document.getElementById("cut-drag-feedback"); const cutDragFeedbackBounds = cutDragFeedback?.getBoundingClientRect() || null; const visibleModal = Array.from(document.querySelectorAll( "[role='dialog'], [role='alertdialog']")) .filter(element => !element.hidden && !element.closest("[hidden]")).at(-1) || null; const focusables = visibleModal ? Array.from(visibleModal.querySelectorAll( "button:not([disabled]), input:not([disabled]):not([type='hidden']), " + "select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])")) .filter(element => !element.hidden && !element.closest("[hidden]") && getComputedStyle(element).display !== "none" && getComputedStyle(element).visibility !== "hidden") : []; return { capturedAt: new Date().toISOString(), devicePixelRatio: window.devicePixelRatio, stateCount: probe?.stateCount ?? null, busyStateCount: probe?.busyStateCount ?? null, safety: { postMessageWrapped: probe?.postMessageWrapper != null && window.chrome?.webview?.postMessage === probe.postMessageWrapper, outboundMessageCount: probe?.outboundMessageCount ?? null, outboundMessages: (probe?.outboundMessages || []).map(message => ({ ...message })), approvedDatabaseWriteMessages: (probe?.approvedDatabaseWriteMessages || []).map(message => ({ ...message })), blockedOutboundMessages: (probe?.blockedOutboundMessages || []) .map(message => ({ ...message })), stateViolations: (probe?.stateViolations || []).map(violation => ({ ...violation, reasons: [...violation.reasons] })) }, pointerEvents: pointerEvents.slice(-40).map(event => ({ ...event })), state: state ? { revision: state.revision, isBusy: state.isBusy, searchText: state.searchText, searchResults: (state.searchResults || []).map(row => ({ index: row.index, displayName: row.displayName })), selectedStockIndex: state.selectedStockIndex, cutRows: (state.cutRows || []).map(row => ({ physicalIndex: row.physicalIndex, displayOrdinal: row.displayOrdinal, rawLabel: row.rawLabel, isSeparator: row.isSeparator, isSelected: row.isSelected, isFocused: row.isFocused })), playlist: (state.playlist || []).map(row => ({ rowId: row.rowId, isEnabled: row.isEnabled, isSelected: row.isSelected, isActive: row.isActive, marketText: row.marketText, stockName: row.stockName, graphicType: row.graphicType, subtype: row.subtype, pageText: row.pageText })), dialog: state.dialog, statusMessage: state.statusMessage, statusKind: state.statusKind, commandResult: state.commandResult, tabs: (state.tabs || []).map(tab => ({ ...tab })), movingAverages: state.movingAverages ? { ...state.movingAverages } : null, fixedCatalog: state.fixedCatalog ? structuredClone(state.fixedCatalog) : null, industry: state.industry ? structuredClone(state.industry) : null, overseas: state.overseas ? structuredClone(state.overseas) : null, comparison: state.comparison ? structuredClone(state.comparison) : null, theme: state.theme ? structuredClone(state.theme) : null, expert: state.expert ? structuredClone(state.expert) : null, tradingHalt: state.tradingHalt ? structuredClone(state.tradingHalt) : null, manualFinancial: state.manualFinancial ? structuredClone(state.manualFinancial) : null, manualLists: state.manualLists ? structuredClone(state.manualLists) : null, operatorCatalog: state.operatorCatalog ? structuredClone(state.operatorCatalog) : null, fixedSectionBatch: state.fixedSectionBatch ? structuredClone(state.fixedSectionBatch) : null, namedPlaylist: state.namedPlaylist ? { revision: state.namedPlaylist.revision, definitions: (state.namedPlaylist.definitions || []).map(definition => ({ definitionId: definition.definitionId, title: definition.title, isSelected: definition.isSelected })), isListTruncated: state.namedPlaylist.isListTruncated, selectedDefinitionId: state.namedPlaylist.selectedDefinitionId, selectedTitle: state.namedPlaylist.selectedTitle, rowCount: state.namedPlaylist.rowCount, listFreshness: state.namedPlaylist.listFreshness, documentFreshness: state.namedPlaylist.documentFreshness, isWriteInProgress: state.namedPlaylist.isWriteInProgress, isWriteQuarantined: state.namedPlaylist.isWriteQuarantined, canMutate: state.namedPlaylist.canMutate, canCreate: state.namedPlaylist.canCreate, canLoad: state.namedPlaylist.canLoad, canSave: state.namedPlaylist.canSave, canDelete: state.namedPlaylist.canDelete, lastMutationOutcome: state.namedPlaylist.lastMutationOutcome, statusMessage: state.namedPlaylist.statusMessage, statusKind: state.namedPlaylist.statusKind } : null, interactionMetrics: state.interactionMetrics, playout: state.playout ? { mode: state.playout.mode, connectionState: state.playout.connectionState, phase: state.playout.phase, isConnected: state.playout.isConnected, isCommandAvailable: state.playout.isCommandAvailable, liveTakeInAllowed: state.playout.liveTakeInAllowed, isPlayCompletionPending: state.playout.isPlayCompletionPending, isTakeOutCompletionPending: state.playout.isTakeOutCompletionPending, outcomeUnknown: state.playout.outcomeUnknown, preparedCode: state.playout.preparedCode, onAirCode: state.playout.onAirCode, currentEntryId: state.playout.currentEntryId, currentCueIndexZeroBased: state.playout.currentCueIndexZeroBased, pageIndexZeroBased: state.playout.pageIndexZeroBased, pageCount: state.playout.pageCount, nextKind: state.playout.nextKind, isBusy: state.playout.isBusy, refreshActive: state.playout.refreshActive, backgroundEnabled: state.playout.backgroundEnabled, backgroundFileName: state.playout.backgroundFileName, canChangeBackground: state.playout.canChangeBackground, message: state.playout.message } : null } : null, dom: { readyState: document.readyState, url: location.href, viewport: { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight }, bodyBusy: document.body.getAttribute("aria-busy"), connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(), playoutStatusText: (document.getElementById("playout-status")?.textContent || "").trim(), operatorStatusText: (document.getElementById("operator-status")?.textContent || "").trim(), legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true, legacyDialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim(), stockOptions: Array.from(document.querySelectorAll( "#stock-results button[data-result-index]")) .map(button => ({ index: Number(button.dataset.resultIndex), text: (button.textContent || "").trim(), selected: button.getAttribute("aria-selected") === "true", tabIndex: button.tabIndex, focused: button === active })), cuts: Array.from(document.querySelectorAll("#cut-list .cut-row")) .map(row => ({ physicalIndex: Number(row.dataset.physicalIndex), selected: row.getAttribute("aria-selected") === "true", tabIndex: row.tabIndex, focused: row === active, dragging: row.classList.contains("dragging") })), playlist: Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row")) .map(row => ({ rowId: row.dataset.rowId, selected: row.getAttribute("aria-selected") === "true", active: row.dataset.active === "true", current: row.getAttribute("aria-current") === "true", enabled: row.querySelector("input[type='checkbox']")?.checked === true, checkboxDisabled: row.querySelector("input[type='checkbox']")?.disabled === true, checkboxAriaDisabled: row.querySelector("input[type='checkbox']") ?.getAttribute("aria-disabled") === "true", dragging: row.classList.contains("dragging"), dragBefore: row.classList.contains("drag-before"), dragAfter: row.classList.contains("drag-after"), rowHeaderText: (row.querySelector(".playlist-row-header")?.textContent || "").trim(), rowHeaderWidth: row.querySelector(".playlist-row-header") ?.getBoundingClientRect().width ?? null, rowHeaderDragEnabled: row.querySelector(".playlist-row-header") ?.dataset?.dragEnabled === "true", focused: row === active || row.contains(active) })), cutDropHighlighted: document.getElementById("playlist-drop-zone") ?.classList.contains("cut-drag-over") === true, cutDragFeedback: cutDragFeedback ? { hidden: cutDragFeedback.hidden === true, dropAllowed: cutDragFeedback.dataset.dropAllowed || null, left: cutDragFeedbackBounds?.left ?? null, top: cutDragFeedbackBounds?.top ?? null, width: cutDragFeedbackBounds?.width ?? null, height: cutDragFeedbackBounds?.height ?? null } : null, cutPointerCapture: { pointerId: activePointerId, physicalIndices: capturedCutPhysicalIndices }, playlistPointerCapture: { pointerId: activePointerId, rowIds: capturedPlaylistRowIds }, namedModalHidden: document.getElementById("named-playlist-modal")?.hidden === true, namedConfirmationHidden: document.getElementById("named-playlist-confirmation")?.hidden === true, manualFinancialModalHidden: document.getElementById("manual-financial-modal")?.hidden === true, manualListModalHidden: document.getElementById("manual-list-modal")?.hidden === true, operatorCatalogModalHidden: document.getElementById("operator-catalog-modal")?.hidden === true, namedTitle: (document.getElementById("named-playlist-title")?.textContent || "").trim(), namedConfirmationMessage: (document.getElementById( "named-playlist-confirmation-message")?.textContent || "").trim(), namedConfirmDisabled: document.getElementById( "named-playlist-confirm-button")?.disabled === true, namedDefinitionCount: document.querySelectorAll( "#named-playlist-definitions button[data-named-playlist-definition-id]").length, playlistEnableAll: { checked: document.getElementById("playlist-enable-all")?.checked === true, indeterminate: document.getElementById("playlist-enable-all")?.indeterminate === true, disabled: document.getElementById("playlist-enable-all")?.disabled === true }, backgroundFileDisabled: document.getElementById("playout-background-file")?.disabled === true, backgroundNoneChecked: document.getElementById("playout-background-none")?.checked === true, backgroundNoneDisabled: document.getElementById("playout-background-none")?.disabled === true, backgroundNameValue: document.getElementById("playout-background-name")?.value ?? null, operatorCatalogStockQueryValue: document.getElementById("operator-catalog-stock-query")?.value ?? null, operatorCatalogNameValue: document.getElementById("operator-catalog-name")?.value ?? null, operatorCatalogEditorMarketValue: document.getElementById("operator-catalog-editor-market")?.value ?? null, operatorCatalogStockResultCount: document.querySelectorAll( "#operator-catalog-stock-results " + "button[data-operator-catalog-stock-result-id]").length, operatorCatalogStatusText: (document.getElementById("operator-catalog-status")?.textContent || "").trim(), activeElement: active ? { id: active.id || null, tagName: active.tagName, resultIndex: active.dataset?.resultIndex ?? null, physicalIndex: active.dataset?.physicalIndex ?? null, playlistRowId: rowForActive?.dataset?.rowId ?? null } : null, visibleModalRole: visibleModal?.getAttribute("role") || null, visibleModalLabelledBy: visibleModal?.getAttribute("aria-labelledby") || null, activeInsideVisibleModal: visibleModal ? visibleModal.contains(active) : false, modalFocusableIds: focusables.map(element => element.id || null) } }; })()`; } async function readSnapshot() { return await evaluate(probeSnapshotExpression()); } function assertSafety( snapshot, label, allowUiBusy = true, allowLegacyDialog = false, allowActiveDryRun = false) { if (!snapshot?.state?.playout) { failKnown(`Native playout state is missing at ${label}.`); } const state = snapshot.state; const playout = state.playout; if (snapshot.safety?.postMessageWrapped !== true) { failUnknown(`The outbound WebView safety gate is not installed at ${label}.`); } if (unexpectedJavaScriptDialogs.length > 0 || expectedJavaScriptDialog) { failUnknown(`An unexpected or unresolved JavaScript dialog exists at ${label}.`); } const outboundMessages = snapshot.safety.outboundMessages || []; const approvedDatabaseWriteMessages = snapshot.safety.approvedDatabaseWriteMessages || []; const blockedOutboundMessages = snapshot.safety.blockedOutboundMessages || []; const stateViolations = snapshot.safety.stateViolations || []; evidence.safety.observedOutboundMessageTypes = outboundMessages .map(message => message.type); evidence.safety.approvedDatabaseWriteMessages = approvedDatabaseWriteMessages .map(message => ({ ...message })); evidence.safety.blockedOutboundMessages = blockedOutboundMessages .map(message => ({ ...message })); evidence.safety.stateViolations = stateViolations.map(violation => ({ ...violation, reasons: [...violation.reasons] })); evidence.safety.playoutIntentIssued = outboundMessages.some(message => playoutIntentMessageTypes.includes(message.type)) || blockedOutboundMessages.some(message => message.category === "playout"); evidence.safety.databaseWriteIntentIssued = approvedDatabaseWriteMessages.length > 0 || blockedOutboundMessages.some(message => message.category === "database-write"); if (blockedOutboundMessages.length > 0) { failKnown(`The outbound WebView safety gate blocked an unapproved message at ${label}.`); } if (stateViolations.length > 0) { failUnknown(`A transient unsafe playout state was observed before ${label}.`); } if (playout.mode !== "dryRun") { failKnown(`Expected DryRun at ${label}, received ${String(playout.mode)}.`); } if (!allowActiveDryRun && (playout.phase !== "idle" || playout.preparedCode != null || playout.onAirCode != null)) { failKnown(`Playout left IDLE at ${label}.`); } if (playout.outcomeUnknown === true || (!allowActiveDryRun && playout.isPlayCompletionPending === true) || (!allowActiveDryRun && playout.isTakeOutCompletionPending === true) || (!allowActiveDryRun && playout.isBusy === true) || (!allowActiveDryRun && playout.refreshActive === true)) { failUnknown(`Playout became busy, pending, refreshing, or unknown at ${label}.`); } if (!allowUiBusy && state.isBusy === true) { failKnown(`The operator UI is still busy at ${label}.`); } if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete") { failKnown(`The exact package document is not ready at ${label}.`); } if (snapshot.dom.connectionText !== "DRY RUN") { failKnown(`The visible connection badge is not DRY RUN at ${label}.`); } if (!snapshot.dom.legacyDialogHidden && !allowLegacyDialog) { failKnown(`A legacy alert is visible at ${label}: ${snapshot.dom.legacyDialogText}`); } } async function waitFor(label, predicate, options = {}) { const timeoutMilliseconds = options.timeoutMilliseconds || Math.min(configuration.timeoutMilliseconds, 60_000); const deadline = Date.now() + timeoutMilliseconds; let lastSnapshot = null; while (Date.now() < deadline) { lastSnapshot = await readSnapshot(); if (lastSnapshot?.state?.playout) { assertSafety( lastSnapshot, label, options.allowUiBusy !== false, options.allowLegacyDialog === true, options.allowActiveDryRun === true); } if (predicate(lastSnapshot)) return lastSnapshot; await sleep(100); } failUnknown(`Timed out waiting for ${label}; no action was retried. Last snapshot: ` + JSON.stringify(lastSnapshot)); } async function checkpoint(name, details = null) { const snapshot = await readSnapshot(); assertSafety(snapshot, name, false); evidence.checkpoints.push({ name, details, snapshot }); return snapshot; } async function elementGeometry(expression, label, verticalFraction = 0.5) { const geometry = await evaluate(`(() => { const element = ${expression}; if (!element) return null; element.scrollIntoView({ block: "nearest", inline: "nearest" }); const rectangle = element.getBoundingClientRect(); return { x: rectangle.left + rectangle.width / 2, y: rectangle.top + rectangle.height * ${Number(verticalFraction)}, left: rectangle.left, top: rectangle.top, right: rectangle.right, bottom: rectangle.bottom, width: rectangle.width, height: rectangle.height, disabled: element.disabled === true, ariaDisabled: element.getAttribute("aria-disabled") }; })()`); if (!geometry || geometry.disabled || geometry.ariaDisabled === "true" || !Number.isFinite(geometry.x) || !Number.isFinite(geometry.y) || geometry.width <= 0 || geometry.height <= 0 || geometry.right <= 0 || geometry.bottom <= 0) { failKnown(`${label} is missing, disabled, or outside the rendered viewport.`); } return geometry; } async function readWorkspaceLayout() { return await evaluate(`(() => { function rectangle(element) { if (!element) return null; const bounds = element.getBoundingClientRect(); return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom, width: bounds.width, height: bounds.height }; } function icon(container) { const svg = container?.matches?.("svg.workspace-nav-icon[data-icon]") ? container : container?.querySelector?.("svg.workspace-nav-icon[data-icon]"); const bounds = rectangle(svg); let contentBounds = null; try { const box = svg?.getBBox?.(); contentBounds = box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null; } catch (_) { contentBounds = null; } const style = svg ? getComputedStyle(svg) : null; const painted = Boolean(contentBounds && contentBounds.width > 0 && contentBounds.height > 0); return { key: svg?.dataset?.icon || null, reference: svg?.querySelector("use")?.getAttribute("href") || null, bounds, painted, rendered: Boolean(bounds && bounds.width > 0 && bounds.height > 0 && painted && style && style.display !== "none" && style.visibility !== "hidden" && Number.parseFloat(style.opacity) > 0 && (style.stroke !== "none" || style.fill !== "none")) }; } function navigationItem(key, button) { const bounds = rectangle(button); const iconState = icon(button); const label = button?.querySelector(".workspace-nav-label"); const labelStyle = label ? getComputedStyle(label) : null; return { key, bounds, icon: iconState, labelPresent: Boolean(label), labelVisible: Boolean(labelStyle && labelStyle.display !== "none" && labelStyle.visibility !== "hidden"), iconCenterOffset: bounds && iconState.bounds ? Math.abs( iconState.bounds.left + iconState.bounds.width / 2 - (bounds.left + bounds.width / 2)) : null, iconCenterOffsetY: bounds && iconState.bounds ? Math.abs( iconState.bounds.top + iconState.bounds.height / 2 - (bounds.top + bounds.height / 2)) : null }; } const shell = document.querySelector(".operator-shell"); const region = document.getElementById("workspace-region"); const navigation = document.getElementById("workspace-navigation"); const surface = document.querySelector(".workspace-surface"); const toggle = document.getElementById("workspace-nav-toggle"); const stockTab = document.getElementById("workspace-stock-tab"); const settingsTab = document.getElementById("workspace-settings-tab"); const marketTabs = Array.from( document.querySelectorAll("#market-tabs > button[data-tab-id]")); const stock = document.getElementById("stock-workspace"); const catalog = document.getElementById("catalog-workspace"); const settings = document.getElementById("settings-workspace"); const splitter = document.getElementById("workspace-splitter"); const schedule = document.querySelector(".playlist-panel"); const headingIcon = document.querySelector(".workspace-heading-icon"); const publishedScheduleWidth = window.LegacyWorkspaceNavigation?.scheduleWidth?.(); let storedScheduleWidth = null; try { storedScheduleWidth = window.localStorage?.getItem( "mbn-stock-webview.schedule-width.v1") ?? null; } catch (_) { storedScheduleWidth = null; } const scheduleBounds = schedule?.getBoundingClientRect() || null; const scheduleHit = scheduleBounds && scheduleBounds.width > 0 && scheduleBounds.height > 0 ? document.elementFromPoint( scheduleBounds.left + Math.min(scheduleBounds.width - 2, scheduleBounds.width / 2), scheduleBounds.top + Math.min(scheduleBounds.height - 2, 90)) : null; return { viewport: { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight }, shell: rectangle(shell), region: rectangle(region), navigation: rectangle(navigation), surface: rectangle(surface), splitter: rectangle(splitter), splitterState: splitter ? { role: splitter.getAttribute("role"), orientation: splitter.getAttribute("aria-orientation"), controls: splitter.getAttribute("aria-controls"), valueMinimum: Number(splitter.getAttribute("aria-valuemin")), valueMaximum: Number(splitter.getAttribute("aria-valuemax")), valueNow: Number(splitter.getAttribute("aria-valuenow")), valueText: splitter.getAttribute("aria-valuetext"), tabIndex: splitter.tabIndex, focused: document.activeElement === splitter, dragging: splitter.classList.contains("dragging") } : null, scheduleWidthPercent: Number.isFinite(publishedScheduleWidth) ? publishedScheduleWidth : null, scheduleCssWidth: shell?.style?.getPropertyValue("--schedule-pane-width") || null, storedScheduleWidth, schedule: rectangle(schedule), scheduleHitOwned: Boolean(schedule && scheduleHit && schedule.contains(scheduleHit)), activeWorkspace: region?.dataset?.activeWorkspace || null, navigationExpanded: toggle?.getAttribute("aria-expanded") === "true", navigationCollapsedClass: region?.classList.contains("nav-collapsed") === true, title: (document.getElementById("workspace-title")?.textContent || "").trim(), activeMarketTab: region?.dataset?.activeMarketTab || null, currentMenuItemCount: document.querySelectorAll( "#workspace-navigation .workspace-nav-item[aria-current='page']").length, navigationItems: [ navigationItem("stock", stockTab), ...marketTabs.map(button => navigationItem(button.dataset.tabId, button)), navigationItem("settings", settingsTab) ], headingIcon: icon(headingIcon), marketTabs: marketTabs.map(button => ({ tabId: button.dataset.tabId, label: (button.querySelector(".workspace-nav-label")?.textContent || "").trim(), current: button.getAttribute("aria-current") || null, focused: document.activeElement === button, icon: icon(button) })), stock: { hidden: stock?.hidden === true, inert: stock?.hasAttribute("inert") === true, ariaHidden: stock?.getAttribute("aria-hidden") || null, tabCurrent: stockTab?.getAttribute("aria-current") || null, width: stock?.getBoundingClientRect().width ?? null, height: stock?.getBoundingClientRect().height ?? null }, catalog: { hidden: catalog?.hidden === true, inert: catalog?.hasAttribute("inert") === true, ariaHidden: catalog?.getAttribute("aria-hidden") || null, width: catalog?.getBoundingClientRect().width ?? null, height: catalog?.getBoundingClientRect().height ?? null }, settings: { hidden: settings?.hidden === true, inert: settings?.hasAttribute("inert") === true, ariaHidden: settings?.getAttribute("aria-hidden") || null, tabCurrent: settingsTab?.getAttribute("aria-current") || null, width: settings?.getBoundingClientRect().width ?? null, height: settings?.getBoundingClientRect().height ?? null } }; })()`); } function workspaceLayoutMatches(layout, workspace, expanded = null) { if (!layout || layout.activeWorkspace !== workspace) return false; if (expanded !== null && layout.navigationExpanded !== expanded) return false; if (layout.scheduleHitOwned !== true || layout.currentMenuItemCount !== 1 || layout.marketTabs?.length !== 10 || layout.navigationItems?.length !== 12) return false; const expectedIconForKey = key => key === "stock" ? "stock-cut" : key === "settings" ? "settings" : expectedWorkspaceIconByTabId[key]; if (layout.navigationItems.some(item => { const expectedIcon = expectedIconForKey(item.key); return !expectedIcon || item.icon?.key !== expectedIcon || item.icon.reference !== `#workspace-icon-${expectedIcon}` || item.icon.rendered !== true || item.labelPresent !== true || !Number.isFinite(item.iconCenterOffsetY) || item.iconCenterOffsetY > 1.5; })) return false; if (layout.navigationExpanded === true && layout.navigationItems.some(item => item.labelVisible !== true)) return false; if (layout.navigationExpanded === false && layout.navigationItems.some(item => item.labelVisible !== false || !Number.isFinite(item.iconCenterOffset) || item.iconCenterOffset > 1.5)) return false; const expectedHeadingIcon = workspace === "stock" ? "stock-cut" : workspace === "settings" ? "settings" : expectedWorkspaceIconByTabId[layout.activeMarketTab]; if (!expectedHeadingIcon || layout.headingIcon?.key !== expectedHeadingIcon || layout.headingIcon.reference !== `#workspace-icon-${expectedHeadingIcon}` || layout.headingIcon.rendered !== true) return false; const visible = view => view?.hidden === false && view.inert === false && view.ariaHidden === "false" && view.width > 0 && view.height > 0; const hidden = view => view?.hidden === true && view.inert === true && view.ariaHidden === "true"; const currentMarkets = layout.marketTabs.filter(tab => tab.current === "page"); if (workspace === "stock") { return visible(layout.stock) && hidden(layout.catalog) && hidden(layout.settings) && layout.stock.tabCurrent === "page" && layout.settings.tabCurrent === "false" && currentMarkets.length === 0; } if (workspace === "settings") { return hidden(layout.stock) && hidden(layout.catalog) && visible(layout.settings) && layout.stock.tabCurrent === "false" && layout.settings.tabCurrent === "page" && currentMarkets.length === 0; } if (workspace === "catalog") { return hidden(layout.stock) && visible(layout.catalog) && hidden(layout.settings) && layout.stock.tabCurrent === "false" && layout.settings.tabCurrent === "false" && currentMarkets.length === 1 && currentMarkets[0].tabId === layout.activeMarketTab; } return false; } async function waitForWorkspaceLayout(label, predicate) { const deadline = Math.min(Date.now() + 5_000, hardDeadlineEpochMilliseconds); let lastLayout = null; while (Date.now() < deadline) { lastLayout = await readWorkspaceLayout(); if (predicate(lastLayout)) return lastLayout; await sleep(25); } failKnown(`Timed out waiting for ${label}. Last layout: ${JSON.stringify(lastLayout)}`); } function assertWorkspaceAndScheduleGeometry(layout, label) { if (!layout?.shell || !layout.region || !layout.navigation || !layout.surface || !layout.splitter || layout.splitter.width <= 0 || layout.splitter.height <= 0 || !layout.schedule || layout.schedule.width <= 0 || layout.schedule.height <= 0 || layout.scheduleHitOwned !== true) { failKnown(`${label} does not expose the complete workspace, splitter, and schedule geometry: ` + JSON.stringify(layout)); } const widthRatio = layout.region.width / layout.schedule.width; const workspaceShare = layout.region.width / layout.shell.width; const scheduleShare = layout.schedule.width / layout.shell.width; const splitterState = layout.splitterState; if (!Number.isFinite(widthRatio) || widthRatio < 1.05 || widthRatio > 1.95 || !Number.isFinite(workspaceShare) || workspaceShare < 0.50 || workspaceShare > 0.67 || !Number.isFinite(scheduleShare) || scheduleShare < 0.33 || scheduleShare > 0.49 || splitterState?.role !== "separator" || splitterState.orientation !== "vertical" || splitterState.controls !== "workspace-region" || splitterState.tabIndex !== 0 || !Number.isFinite(splitterState.valueMinimum) || splitterState.valueMinimum < 34 || !Number.isFinite(splitterState.valueMaximum) || splitterState.valueMaximum > 48 || !Number.isFinite(splitterState.valueNow) || splitterState.valueNow < splitterState.valueMinimum || splitterState.valueNow > splitterState.valueMaximum || Math.abs(layout.region.right - layout.splitter.left) > 1.5 || Math.abs(layout.splitter.right - layout.schedule.left) > 1.5 || Math.abs(layout.schedule.right - layout.shell.right) > 1.5 || Math.abs(layout.splitter.top - layout.shell.top) > 1.5 || Math.abs(layout.splitter.bottom - layout.shell.bottom) > 1.5 || Math.abs(layout.schedule.top - layout.shell.top) > 1.5 || Math.abs(layout.schedule.bottom - layout.shell.bottom) > 1.5) { failKnown(`${label} does not preserve the adjustable workspace/schedule split.`); } return { widthRatio, workspaceShare, scheduleShare }; } function assertFixedSchedule(baseline, current, label) { for (const edge of ["left", "top", "right", "bottom", "width", "height"]) { if (Math.abs(baseline.schedule[edge] - current.schedule[edge]) > 1.5) { failKnown(`${label} moved or resized the always-visible schedule at ${edge}.`); } } assertWorkspaceAndScheduleGeometry(current, label); } function assertSplitterResizeResult(layout, expectedPercent, label) { assertWorkspaceAndScheduleGeometry(layout, label); const storedPercent = Number.parseFloat(layout.storedScheduleWidth); const cssPercent = Number.parseFloat(layout.scheduleCssWidth); const geometryPercent = layout.schedule.width / layout.shell.width * 100; if (!Number.isFinite(layout.scheduleWidthPercent) || Math.abs(layout.scheduleWidthPercent - expectedPercent) > 0.15 || !Number.isFinite(storedPercent) || Math.abs(storedPercent - layout.scheduleWidthPercent) > 0.01 || !Number.isFinite(cssPercent) || Math.abs(cssPercent - layout.scheduleWidthPercent) > 0.01 || Math.abs(geometryPercent - layout.scheduleWidthPercent) > 0.15 || !Number.isFinite(layout.splitterState?.valueNow) || Math.abs(layout.splitterState.valueNow - Number(layout.scheduleWidthPercent.toFixed(1))) > 0.001 || layout.splitterState.valueText !== `송출 스케줄 ${Math.round(layout.scheduleWidthPercent)}%` || layout.splitterState.focused !== true || layout.splitterState.dragging !== false) { failKnown(`${label} did not synchronize splitter geometry, ARIA, CSS, and local storage: ` + JSON.stringify(layout)); } return layout.scheduleWidthPercent; } async function dragWorkspaceSplitterToPercent(beforeLayout, targetPercent, label) { await waitForPointerReady(label); const start = { x: beforeLayout.splitter.left + beforeLayout.splitter.width / 2, y: beforeLayout.splitter.top + beforeLayout.splitter.height / 2 }; const target = { x: beforeLayout.shell.right - beforeLayout.shell.width * targetPercent / 100, y: start.y }; const before = await readSnapshot(); assertSafety(before, `${label} preflight`, false); evidence.inputs.push({ type: "workspace-splitter-drag", label, start, target, targetPercent }); try { await pressMouse(start, 0); await sleep(25); await moveMouse(target, 0, 1); await sleep(25); await releaseMouse(target, 0); } catch (error) { await cleanupDragInputSession(false); throw error; } const resized = await waitForWorkspaceLayout(label, layout => Number.isFinite(layout.scheduleWidthPercent) && Math.abs(layout.scheduleWidthPercent - targetPercent) <= 0.15 && Number.isFinite(Number.parseFloat(layout.storedScheduleWidth)) && Math.abs(Number.parseFloat(layout.storedScheduleWidth) - layout.scheduleWidthPercent) <= 0.01); const after = await readSnapshot(); assertSafety(after, label, false); if (after.safety.outboundMessages.length !== before.safety.outboundMessages.length) { failKnown(`${label} emitted a native WebView intent.`); } return resized; } async function exerciseWorkspaceSplitter(baseline) { const baselinePercent = baseline.scheduleWidthPercent; const minimum = Math.max(34, 600 / baseline.shell.width * 100); const maximum = Math.min(48, (baseline.shell.width - 780 - 8) / baseline.shell.width * 100); const range = maximum - minimum; if (!Number.isFinite(baselinePercent) || range < 3 || baselinePercent < minimum - 0.15 || baselinePercent > maximum + 0.15) { failKnown("The rendered window has no safe splitter resize range."); } const lowerTarget = minimum + range / 3; const upperTarget = maximum - range / 3; const pointerTarget = Math.abs(baselinePercent - lowerTarget) >= Math.abs(baselinePercent - upperTarget) ? lowerTarget : upperTarget; const pointerLayout = await dragWorkspaceSplitterToPercent( baseline, pointerTarget, "drag workspace splitter"); const pointerPercent = assertSplitterResizeResult( pointerLayout, pointerTarget, "workspace splitter pointer resize"); const leftExpected = Math.min(maximum, pointerPercent + 2); const rightExpected = Math.max(minimum, pointerPercent - 2); const keyboardKey = Math.abs(leftExpected - baselinePercent) >= Math.abs(rightExpected - baselinePercent) ? "ArrowLeft" : "ArrowRight"; const keyboardExpected = keyboardKey === "ArrowLeft" ? leftExpected : rightExpected; if (Math.abs(keyboardExpected - pointerPercent) < 0.25) { failKnown("The splitter has no safe keyboard resize step after pointer input."); } const beforeKeyboard = await readSnapshot(); assertSafety(beforeKeyboard, "workspace splitter keyboard resize preflight", false); await pressKey(keyboardKey, "resize workspace splitter from keyboard"); const keyboardLayout = await waitForWorkspaceLayout( "workspace splitter keyboard resize", layout => Number.isFinite(layout.scheduleWidthPercent) && Math.abs(layout.scheduleWidthPercent - keyboardExpected) <= 0.15 && Number.isFinite(Number.parseFloat(layout.storedScheduleWidth)) && Math.abs(Number.parseFloat(layout.storedScheduleWidth) - layout.scheduleWidthPercent) <= 0.01); const keyboardPercent = assertSplitterResizeResult( keyboardLayout, keyboardExpected, "workspace splitter keyboard resize"); const afterKeyboard = await readSnapshot(); assertSafety(afterKeyboard, "workspace splitter keyboard resize", false); if (afterKeyboard.safety.outboundMessages.length !== beforeKeyboard.safety.outboundMessages.length) { failKnown("The workspace splitter keyboard resize emitted a native WebView intent."); } const restoredLayout = await dragWorkspaceSplitterToPercent( keyboardLayout, baselinePercent, "restore workspace splitter width"); const restoredPercent = assertSplitterResizeResult( restoredLayout, baselinePercent, "restored workspace splitter width"); assertFixedSchedule(baseline, restoredLayout, "restored workspace splitter geometry"); return { baselinePercent, pointerPercent, keyboardKey, keyboardPercent, restoredPercent, storedPercent: Number.parseFloat(restoredLayout.storedScheduleWidth) }; } async function ensureWorkspace(workspace, label) { let layout = await readWorkspaceLayout(); if (!workspaceLayoutMatches(layout, workspace)) { let expression = null; if (workspace === "stock") { expression = 'document.getElementById("workspace-stock-tab")'; } else if (workspace === "settings") { expression = 'document.getElementById("workspace-settings-tab")'; } else if (workspace === "catalog") { const activeTabId = layout.activeMarketTab || layout.marketTabs?.[0]?.tabId; if (activeTabId) { expression = `document.querySelector('#market-tabs > button[data-tab-id="${activeTabId}"]')`; } } if (!expression) failKnown(`${label} requested an unknown workspace.`); await pointerClick(expression, label); layout = await waitForWorkspaceLayout(label, candidate => workspaceLayoutMatches(candidate, workspace)); } assertWorkspaceAndScheduleGeometry(layout, label); return layout; } async function verifyWorkspaceNavigationAndLayout(preflight) { let baseline = await ensureWorkspace("stock", "workspace stock preflight"); if (baseline.navigationExpanded !== true || baseline.navigationCollapsedClass === true) { await pointerClick( 'document.getElementById("workspace-nav-toggle")', "expand workspace menu before layout verification"); baseline = await waitForWorkspaceLayout("expanded workspace stock preflight", layout => workspaceLayoutMatches(layout, "stock", true) && layout.navigationCollapsedClass === false); } const firstMarket = baseline.marketTabs[0]; if (!firstMarket?.tabId || !firstMarket.label) { failKnown("The first graphic menu item is missing its native identity or label."); } const baselineRatio = assertWorkspaceAndScheduleGeometry( baseline, "expanded stock workspace preflight"); const splitterResize = await exerciseWorkspaceSplitter(baseline); await pointerClick( 'document.getElementById("workspace-nav-toggle")', "collapse workspace menu"); const collapsed = await waitForWorkspaceLayout("collapsed workspace menu", layout => workspaceLayoutMatches(layout, "stock", false) && layout.navigationCollapsedClass === true); if (collapsed.navigation.width >= baseline.navigation.width - 100 || collapsed.surface.width <= baseline.surface.width + 100) { failKnown("The pointer collapse did not transfer menu width to the active workspace."); } assertFixedSchedule(baseline, collapsed, "collapsed workspace menu"); const beforeGraphicSelection = await readSnapshot(); assertSafety(beforeGraphicSelection, "first graphic native selection preflight", false); await pointerClick( `document.querySelector('#market-tabs > button[data-tab-id="${firstMarket.tabId}"]')`, "select first graphic screen from collapsed menu"); const catalog = await waitForWorkspaceLayout("first graphic workspace", layout => workspaceLayoutMatches(layout, "catalog", false) && layout.title === firstMarket.label && layout.activeMarketTab === firstMarket.tabId); assertFixedSchedule(baseline, catalog, "first graphic workspace transition"); let nativeState = await waitFor("first graphic native selection", snapshot => snapshot.stateCount >= beforeGraphicSelection.stateCount + 2 && snapshot.busyStateCount > beforeGraphicSelection.busyStateCount && snapshot.state.isBusy === false && snapshot.state.tabs?.some(tab => tab.id === firstMarket.tabId && tab.isActive) === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick( 'document.getElementById("workspace-nav-toggle")', "expand workspace menu"); const expandedCatalog = await waitForWorkspaceLayout("expanded catalog workspace", layout => workspaceLayoutMatches(layout, "catalog", true) && layout.navigationCollapsedClass === false); if (Math.abs(expandedCatalog.navigation.width - baseline.navigation.width) > 1.5 || Math.abs(expandedCatalog.surface.width - baseline.surface.width) > 1.5) { failKnown("The pointer expand did not restore the original menu/workspace widths."); } assertFixedSchedule(baseline, expandedCatalog, "expanded catalog workspace"); const secondMarket = baseline.marketTabs[1]; if (!secondMarket?.tabId || secondMarket.tabId === firstMarket.tabId) { failKnown("The second graphic menu item is unavailable for vertical drag verification."); } const swappedMarketTabs = await dragMarketTab( firstMarket.tabId, secondMarket.tabId, "vertically reorder graphic menu items"); assertFixedSchedule(baseline, swappedMarketTabs.layout, "vertical graphic menu reorder"); const restoredMarketTabs = await dragMarketTab( firstMarket.tabId, secondMarket.tabId, "restore graphic menu item order"); assertFixedSchedule(baseline, restoredMarketTabs.layout, "restored graphic menu order"); if (!restoredMarketTabs.layout.marketTabs.every( (tab, index) => tab.tabId === baseline.marketTabs[index].tabId && tab.icon.key === baseline.marketTabs[index].icon.key && tab.icon.reference === baseline.marketTabs[index].icon.reference)) { failKnown("The vertical graphic menu drag did not restore the original native order."); } const beforeSettingsMessageCount = restoredMarketTabs.completed.safety.outboundMessages.length; await pointerClick( 'document.getElementById("workspace-settings-tab")', "open settings workspace"); const settings = await waitForWorkspaceLayout("settings workspace", layout => workspaceLayoutMatches(layout, "settings", true) && layout.title === "설정"); assertFixedSchedule(baseline, settings, "settings workspace transition"); nativeState = await readSnapshot(); assertSafety(nativeState, "settings workspace local transition", false); if (nativeState.safety.outboundMessages.length !== beforeSettingsMessageCount) { failKnown("The local Settings menu emitted a native WebView message."); } await pointerClick( 'document.getElementById("workspace-stock-tab")', "return to stock workspace"); const restoredStock = await waitForWorkspaceLayout("restored stock workspace", layout => workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷"); assertFixedSchedule(baseline, restoredStock, "restored stock workspace"); if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) { failKnown("The pointer-selected stock menu item did not retain keyboard focus."); } await pressKey("ArrowDown", "focus first graphic menu item"); if (await evaluate( `document.activeElement?.dataset?.tabId === ${JSON.stringify(firstMarket.tabId)}`) !== true) { failKnown("ArrowDown did not move focus to the first graphic menu item."); } const focusOnlyStock = await readWorkspaceLayout(); if (!workspaceLayoutMatches(focusOnlyStock, "stock", true)) { failKnown("ArrowDown changed the workspace before explicit keyboard activation."); } assertFixedSchedule(baseline, focusOnlyStock, "keyboard graphic focus"); const beforeKeyboardGraphicSelection = await readSnapshot(); assertSafety(beforeKeyboardGraphicSelection, "keyboard graphic native selection preflight", false); await pressKey("Enter", "activate first graphic screen from keyboard"); const keyboardCatalog = await waitForWorkspaceLayout("keyboard graphic workspace", layout => workspaceLayoutMatches(layout, "catalog", true) && layout.title === firstMarket.label && layout.activeMarketTab === firstMarket.tabId); assertFixedSchedule(baseline, keyboardCatalog, "keyboard graphic activation"); await waitFor("keyboard graphic native selection", snapshot => snapshot.stateCount >= beforeKeyboardGraphicSelection.stateCount + 2 && snapshot.busyStateCount > beforeKeyboardGraphicSelection.busyStateCount && snapshot.state.isBusy === false && snapshot.state.tabs?.some(tab => tab.id === firstMarket.tabId && tab.isActive) === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pressKey("ArrowUp", "focus stock workspace menu item"); if (await evaluate('document.activeElement?.id === "workspace-stock-tab"') !== true) { failKnown("ArrowUp did not return focus to the stock workspace menu item."); } await pressKey("Enter", "activate stock workspace from keyboard"); const keyboardStock = await waitForWorkspaceLayout("keyboard stock workspace", layout => workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷"); assertFixedSchedule(baseline, keyboardStock, "keyboard stock activation"); await pressKey("ArrowUp", "wrap focus to settings menu item"); if (await evaluate('document.activeElement?.id === "workspace-settings-tab"') !== true) { failKnown("ArrowUp did not wrap focus from Stock to Settings."); } const focusOnlyStockBeforeSettings = await readWorkspaceLayout(); if (!workspaceLayoutMatches(focusOnlyStockBeforeSettings, "stock", true)) { failKnown("Focusing Settings changed the workspace before keyboard activation."); } await pressKey("Enter", "activate settings workspace from keyboard"); const keyboardSettings = await waitForWorkspaceLayout("keyboard settings workspace", layout => workspaceLayoutMatches(layout, "settings", true) && layout.title === "설정"); assertFixedSchedule(baseline, keyboardSettings, "keyboard settings activation"); await pointerClick( 'document.getElementById("workspace-stock-tab")', "restore stock workspace after keyboard navigation"); const finalStock = await waitForWorkspaceLayout("final stock workspace", layout => workspaceLayoutMatches(layout, "stock", true) && layout.title === "종목·컷"); assertFixedSchedule(baseline, finalStock, "final stock workspace"); const after = await readSnapshot(); assertSafety(after, "workspace navigation/layout input", false); const navigationMessages = after.safety.outboundMessages.slice( preflight.safety.outboundMessages.length); const selectTabMessages = navigationMessages.filter( message => message.type === "select-tab"); const hoverSwapMessages = navigationMessages.filter( message => message.type === "hover-swap-tabs"); if (navigationMessages.length !== 4 || selectTabMessages.length !== 2 || hoverSwapMessages.length !== 2 || navigationMessages.some(message => message.type !== "select-tab" && message.type !== "hover-swap-tabs") || after.safety.blockedOutboundMessages.length !== preflight.safety.blockedOutboundMessages.length) { failKnown("Sidebar navigation emitted duplicate or unexpected native messages."); } await checkpoint("workspace-navigation-layout", { widthRatio: Number(baselineRatio.widthRatio.toFixed(4)), workspaceShare: Number(baselineRatio.workspaceShare.toFixed(4)), scheduleShare: Number(baselineRatio.scheduleShare.toFixed(4)), expandedNavigationWidth: baseline.navigation.width, collapsedNavigationWidth: collapsed.navigation.width, collapsedIconCenterOffsets: collapsed.navigationItems.map(item => ({ key: item.key, x: item.iconCenterOffset, y: item.iconCenterOffsetY })), schedule: baseline.schedule, splitterResize, graphicMenuCount: baseline.marketTabs.length, pointerTransitions: ["collapse", firstMarket.tabId, "expand", "settings", "stock"], verticalMenuDrag: [ `${firstMarket.tabId} -> ${secondMarket.tabId}`, `${firstMarket.tabId} -> ${secondMarket.tabId} (restore)` ], keyboardTransitions: [ "ArrowDown", `Enter ${firstMarket.tabId}`, "ArrowUp", "Enter stock", "ArrowUp wrap", "Enter settings" ], nativeNavigationMessages: navigationMessages.map(message => message.type) }); } async function verifyVisualCardPresentation() { const loaded = await selectMainTab( "overseas", "open fixed graphics for visual-card evidence"); assertSafety(loaded, "visual-card evidence preflight", false); const outboundBaseline = loaded.safety.outboundMessageCount; const metrics = await evaluate(`(() => { const surface = document.querySelector(".workspace-surface"); const button = document.querySelector( 'button[data-action-id="fixed-001"].visual-card'); const list = button?.closest('[data-visual-card-list="true"]'); const title = button?.querySelector(".visual-card-title"); const preview = button?.querySelector(".visual-card-preview"); const state = button?.querySelector(".visual-card-state"); const kicker = button?.querySelector(".visual-card-kicker"); const wordmark = document.querySelector("img.brand-wordmark"); const product = document.querySelector(".brand-product"); if (!surface || !button || !list || !title || !wordmark || !product) return null; const listStyle = getComputedStyle(list); const buttonStyle = getComputedStyle(button); const wordmarkBounds = wordmark.getBoundingClientRect(); const tracks = listStyle.gridTemplateColumns.trim() .split(/\\s+/u).filter(Boolean); return { viewMode: document.body.dataset.viewMode || null, context: list.dataset.visualCardContext || null, surfaceWidth: surface.getBoundingClientRect().width, listDisplay: listStyle.display, columnCount: tracks.length, cardCount: list.querySelectorAll("button.visual-card").length, cardWidth: button.getBoundingClientRect().width, cardHeight: button.getBoundingClientRect().height, cardRadius: Number.parseFloat(buttonStyle.borderRadius), title: title.textContent.trim(), ariaLabel: button.getAttribute("aria-label"), hasPreview: Boolean(preview), hasState: Boolean(state), hasKicker: Boolean(kicker), wordmarkSource: wordmark.getAttribute("src"), wordmarkAlt: wordmark.getAttribute("alt"), wordmarkComplete: wordmark.complete, wordmarkNaturalWidth: wordmark.naturalWidth, wordmarkNaturalHeight: wordmark.naturalHeight, wordmarkWidth: wordmarkBounds.width, wordmarkHeight: wordmarkBounds.height, brandProduct: product.textContent.trim() }; })()`); if (!metrics) failKnown("The fixed graphic visual-card structure is missing."); if (metrics.wordmarkSource !== "Brand/logo.png" || metrics.wordmarkAlt !== "매일경제TV" || metrics.wordmarkComplete !== true || metrics.wordmarkNaturalWidth !== 176 || metrics.wordmarkNaturalHeight !== 43 || metrics.wordmarkWidth < 140 || metrics.wordmarkHeight < 33 || metrics.brandProduct !== "V-STOCK") { failKnown("The packaged customer wordmark did not render as the verified V-STOCK lockup."); } if (metrics.viewMode === "cards") { const expectedColumns = metrics.surfaceWidth >= 824 ? 3 : metrics.surfaceWidth >= 524 ? 2 : 1; if (metrics.context !== "fixed-actions" || metrics.listDisplay !== "grid" || metrics.columnCount !== expectedColumns || metrics.cardCount < 3 || metrics.cardWidth < 160 || metrics.cardHeight < 61.5 || metrics.cardRadius < 9 || metrics.title !== "다우" || metrics.ariaLabel !== metrics.title || metrics.hasPreview || metrics.hasState || metrics.hasKicker) { failKnown("The rendered visual-card geometry is outside its responsive contract."); } await pointerClick( 'document.querySelector(\'button[data-action-id="fixed-001"] .visual-card-title\')', "select fixed graphic from its visual-card title"); await sleep(100); const selected = await evaluate(`(() => { const button = document.querySelector('button[data-action-id="fixed-001"]'); return button ? { selected: button.classList.contains("tree-node-selected"), ariaSelected: button.getAttribute("aria-selected"), focused: document.activeElement === button } : null; })()`); const afterSelection = await readSnapshot(); assertSafety(afterSelection, "visual-card nested-title selection", false); if (!selected?.selected || selected.ariaSelected !== "true" || selected.focused !== true || afterSelection.safety.outboundMessageCount !== outboundBaseline) { failKnown("A visual-card title click did not remain a selection-only input."); } } return await checkpoint("visual-card-presentation", { ...metrics, nestedTitleSelectionVerified: metrics.viewMode === "cards" }); } async function playlistDropGeometry(label) { const geometry = await evaluate(`(() => { const zone = document.getElementById("playlist-drop-zone"); if (!zone) return null; const zoneRectangle = zone.getBoundingClientRect(); const headerRectangle = zone.querySelector(".playlist-header") ?.getBoundingClientRect() || null; const viewport = { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight }; const inset = 8; const left = Math.max(0, zoneRectangle.left + inset); const right = Math.min(viewport.width - 1, zoneRectangle.right - inset); const top = Math.max( 0, (headerRectangle ? headerRectangle.bottom : zoneRectangle.top) + inset); const bottom = Math.min(viewport.height - 1, zoneRectangle.bottom - inset); const fractions = [0.5, 0.25, 0.75]; const points = right > left && bottom > top ? fractions.map(fraction => { const x = left + (right - left) / 2; const y = top + (bottom - top) * fraction; const hit = document.elementFromPoint(x, y); return { x, y, hitId: hit?.id || null, hitClass: hit?.className || null, insideDropZone: hit === zone || zone.contains(hit) }; }) : []; return { zone: { left: zoneRectangle.left, top: zoneRectangle.top, right: zoneRectangle.right, bottom: zoneRectangle.bottom, width: zoneRectangle.width, height: zoneRectangle.height }, headerBottom: headerRectangle?.bottom ?? null, viewport, visibleInterior: { left, top, right, bottom }, points }; })()`); if (!geometry || geometry.zone.width <= 0 || geometry.zone.height <= 0 || geometry.points.length === 0 || geometry.points.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.insideDropZone !== true)) { failKnown(`${label} has no verified visible interior drop point.`); } return geometry; } async function moveMouse(point, modifiers = 0, buttons = 0) { await rpc("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y, // Edge releases explicit pointer capture if a CDP mouseMoved event says // no button while its buttons bitmask says the left button is held. // Keep both fields consistent with the physical state during a drag. button: (buttons & 1) === 1 ? "left" : "none", buttons, modifiers, pointerType: "mouse" }); } async function pressMouse(point, modifiers = 0) { if (pointerIsPressed) failKnown("A second pointer press was attempted before release."); await moveMouse(point, modifiers, 0); await rpc("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, modifiers, pointerType: "mouse" }); pointerIsPressed = true; pointerReleasePoint = point; pointerReleaseModifiers = modifiers; } async function releaseMouse(point, modifiers = 0) { if (!pointerIsPressed) failKnown("A pointer release was attempted without a press."); await rpc("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, modifiers, pointerType: "mouse" }); pointerIsPressed = false; pointerReleasePoint = null; pointerReleaseModifiers = 0; } async function cleanupDragInputSession(cancelActiveDrag = true) { const failures = []; const point = pointerReleasePoint || { x: 1, y: 1 }; if (cancelActiveDrag && activeInterceptedDragData) { try { await rpc("Input.dispatchDragEvent", { type: "dragCancel", x: point.x, y: point.y, data: activeInterceptedDragData }); activeInterceptedDragData = null; } catch (error) { failures.push(`drag cancel: ${error.message}`); } } if (pointerIsPressed) { try { await releaseMouse(point, pointerReleaseModifiers); } catch (error) { failures.push(`pointer release: ${error.message}`); } } if (dragInterceptionEnabled) { try { await rpc("Input.setInterceptDrags", { enabled: false }); dragInterceptionEnabled = false; } catch (error) { failures.push(`drag interception disable: ${error.message}`); } } if (failures.length > 0) { failUnknown(`Input cleanup did not complete (${failures.join("; ")}).`); } } async function waitForPointerReady( label, allowLegacyDialog = false, allowActiveDryRun = false) { await waitFor(`${label} pointer readiness`, snapshot => snapshot.state?.isBusy === false && snapshot.dom.bodyBusy === "false", { allowUiBusy: true, allowLegacyDialog, allowActiveDryRun }); } async function pointerClick(expression, label, modifiers = 0, options = {}) { if (options.skipPointerReady !== true) { await waitForPointerReady( label, options.allowLegacyDialog === true, options.allowActiveDryRun === true); } const forbiddenId = await evaluate(`(() => { const element = ${expression}; return element?.closest?.("button")?.id || element?.id || null; })()`); if (forbiddenControlIds.includes(forbiddenId)) { failKnown(`The harness refused forbidden control #${forbiddenId}.`); } const point = await elementGeometry(expression, label); evidence.inputs.push({ type: "pointer-click", label, modifiers, point }); await pressMouse(point, modifiers); await sleep(25); await releaseMouse(point, modifiers); return point; } async function dragMarketTab(sourceTabId, targetTabId, label) { await waitForPointerReady(label); const beforeLayout = await readWorkspaceLayout(); const beforeOrder = beforeLayout.marketTabs.map(tab => tab.tabId); const sourceIndex = beforeOrder.indexOf(sourceTabId); const targetIndex = beforeOrder.indexOf(targetTabId); if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { failKnown(`${label} does not have two distinct visible market menu items.`); } const expectedOrder = beforeOrder.slice(); expectedOrder[sourceIndex] = targetTabId; expectedOrder[targetIndex] = sourceTabId; const source = await elementGeometry( `document.querySelector('#market-tabs > button[data-tab-id="${sourceTabId}"]')`, `${label} source`); const target = await elementGeometry( `document.querySelector('#market-tabs > button[data-tab-id="${targetTabId}"]')`, `${label} target`); const before = await readSnapshot(); assertSafety(before, `${label} preflight`, false); evidence.inputs.push({ type: "market-tab-drag", label, sourceTabId, targetTabId, source, target, beforeOrder, expectedOrder }); let dragData = null; let dragCompleted = false; let operationError = null; let intercepted = null; // Record the attempt before awaiting the acknowledgement. If Chromium // applies the command but its response is lost, bounded outer cleanup must // still send and confirm the idempotent disable command once. dragInterceptionEnabled = true; try { await rpc("Input.setInterceptDrags", { enabled: true }); intercepted = waitForDragIntercept(label); await pressMouse(source, 0); pointerReleasePoint = source; const direction = target.y >= source.y ? 1 : -1; await moveMouse({ x: source.x, y: source.y + direction * 10 }, 0, 1); dragData = await intercepted; activeInterceptedDragData = dragData; const marketItem = dragData?.items?.find(item => item.mimeType === "text/x-mbn-tab"); if (!marketItem || marketItem.data !== sourceTabId) { failKnown(`${label} did not preserve the closed market identity in drag data.`); } await rpc("Input.dispatchDragEvent", { type: "dragEnter", x: target.x, y: target.y, data: dragData }); await rpc("Input.dispatchDragEvent", { type: "dragOver", x: target.x, y: target.y, data: dragData }); await sleep(100); await rpc("Input.dispatchDragEvent", { type: "drop", x: target.x, y: target.y, data: dragData }); activeInterceptedDragData = null; dragCompleted = true; await releaseMouse(target, 0); } catch (error) { operationError = error; throw error; } finally { if (pendingDragIntercept) { cancelPendingDragIntercept( `${label} ended before Chromium supplied intercepted drag data.`); } if (intercepted) await intercepted.catch(() => null); try { await cleanupDragInputSession(!dragCompleted); } catch (cleanupError) { if (!operationError) throw cleanupError; // Keep the original operation failure. The outer cleanup switches to a // bounded cleanup mode and makes one final release/disable attempt. } } const completed = await waitFor(`${label} native swap`, snapshot => { const order = snapshot.state.tabs?.map(tab => tab.id) || []; return snapshot.safety.outboundMessages.length === before.safety.outboundMessages.length + 1 && snapshot.safety.outboundMessages.at(-1)?.type === "hover-swap-tabs" && order.length === expectedOrder.length && order.every((tabId, index) => tabId === expectedOrder[index]); }, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const completedLayout = await waitForWorkspaceLayout(`${label} menu order`, layout => layout.marketTabs.length === expectedOrder.length && layout.marketTabs.every((tab, index) => tab.tabId === expectedOrder[index])); assertWorkspaceAndScheduleGeometry(completedLayout, `${label} menu order`); return { completed, layout: completedLayout, beforeOrder, expectedOrder }; } async function dragOperatorCatalogRow(sourceRowId, targetRowId, position, label) { if (position !== "before" && position !== "after") { failKnown(`${label} has an invalid operator-catalog drop position.`); } let current = await readSnapshot(); if (current.state.operatorCatalog?.activeDraftRowId !== sourceRowId) { await pointerClick( `document.querySelector('[data-operator-catalog-row-id="${sourceRowId}"] strong')`, `${label} activate source row`); current = await waitFor(`${label} active source row`, snapshot => snapshot.state.operatorCatalog?.activeDraftRowId === sourceRowId, { timeoutMilliseconds: 30_000, allowUiBusy: true }); } await waitForPointerReady(label); const beforeOrder = (current.state.operatorCatalog?.draftRows || []) .map(row => row.rowId); const sourceIndex = beforeOrder.indexOf(sourceRowId); const targetIndex = beforeOrder.indexOf(targetRowId); if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { failKnown(`${label} does not have two distinct operator-catalog draft rows.`); } const expectedOrder = beforeOrder.filter(rowId => rowId !== sourceRowId); const remainingTargetIndex = expectedOrder.indexOf(targetRowId); expectedOrder.splice( remainingTargetIndex + (position === "after" ? 1 : 0), 0, sourceRowId); const source = await elementGeometry( `document.querySelector('[data-operator-catalog-row-id="${sourceRowId}"] strong')`, `${label} source`); const target = await elementGeometry( `document.querySelector('[data-operator-catalog-row-id="${targetRowId}"]')`, `${label} target`, position === "before" ? 0.25 : 0.75); const before = await readSnapshot(); assertSafety(before, `${label} preflight`, false); evidence.inputs.push({ type: "operator-catalog-row-drag", label, sourceRowId, targetRowId, position, source, target, beforeOrder, expectedOrder }); let dragCompleted = false; let operationError = null; let intercepted = null; dragInterceptionEnabled = true; try { await rpc("Input.setInterceptDrags", { enabled: true }); intercepted = waitForDragIntercept(label); await pressMouse(source, 0); pointerReleasePoint = source; await moveMouse({ x: source.x + 10, y: source.y }, 0, 1); const dragData = await intercepted; activeInterceptedDragData = dragData; const rowItem = dragData?.items?.find(item => item.mimeType === "text/x-mbn-operator-catalog-row"); if (!rowItem || rowItem.data !== sourceRowId) { failKnown(`${label} did not preserve the draft row identity in drag data.`); } await rpc("Input.dispatchDragEvent", { type: "dragEnter", x: target.x, y: target.y, data: dragData }); await rpc("Input.dispatchDragEvent", { type: "dragOver", x: target.x, y: target.y, data: dragData }); await sleep(100); await rpc("Input.dispatchDragEvent", { type: "drop", x: target.x, y: target.y, data: dragData }); activeInterceptedDragData = null; dragCompleted = true; await releaseMouse(target, 0); } catch (error) { operationError = error; throw error; } finally { if (pendingDragIntercept) { cancelPendingDragIntercept( `${label} ended before Chromium supplied intercepted drag data.`); } if (intercepted) await intercepted.catch(() => null); try { await cleanupDragInputSession(!dragCompleted); } catch (cleanupError) { if (!operationError) throw cleanupError; } } return await waitFor(`${label} native reorder`, snapshot => { const order = (snapshot.state.operatorCatalog?.draftRows || []) .map(row => row.rowId); return snapshot.safety.outboundMessages.length === before.safety.outboundMessages.length + 1 && snapshot.safety.outboundMessages.at(-1)?.type === "reorder-operator-catalog-draft-row" && order.length === expectedOrder.length && order.every((rowId, index) => rowId === expectedOrder[index]); }, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } const keyDefinitions = Object.freeze({ ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", virtualKey: 37 }, ArrowRight: { key: "ArrowRight", code: "ArrowRight", virtualKey: 39 }, ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 }, ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 }, Backspace: { key: "Backspace", code: "Backspace", virtualKey: 8 }, Delete: { key: "Delete", code: "Delete", virtualKey: 46 }, End: { key: "End", code: "End", virtualKey: 35 }, Enter: { key: "Enter", code: "Enter", virtualKey: 13 }, Escape: { key: "Escape", code: "Escape", virtualKey: 27 }, F8: { key: "F8", code: "F8", virtualKey: 119 }, Home: { key: "Home", code: "Home", virtualKey: 36 }, Space: { key: " ", code: "Space", virtualKey: 32 }, Tab: { key: "Tab", code: "Tab", virtualKey: 9 }, SelectAll: { key: "a", code: "KeyA", virtualKey: 65 } }); function authorizeDryRunPlayoutShortcut(definitionName, snapshot, label) { if (!dryRunPlayoutProfile || !new Set(["F8", "Escape"]).has(definitionName)) { failKnown(`${label} is not an authorized DryRun playout shortcut.`); } const expectedPhase = definitionName === "F8" ? "idle" : "program"; assertSafety( snapshot, `${label} authorization`, false, false, expectedPhase === "program"); const playout = snapshot.state.playout; if (playout.phase !== expectedPhase || playout.isCommandAvailable !== true || playout.isBusy === true || playout.outcomeUnknown === true || playout.isPlayCompletionPending === true || playout.isTakeOutCompletionPending === true || snapshot.state.isBusy === true || snapshot.dom.legacyDialogHidden !== true || snapshot.dom.namedModalHidden !== true || snapshot.dom.namedConfirmationHidden !== true || snapshot.dom.manualFinancialModalHidden !== true || snapshot.dom.manualListModalHidden !== true || snapshot.dom.operatorCatalogModalHidden !== true || pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) { failUnknown(`${label} does not have an exact known-safe DryRun state.`); } const authorization = { definitionName, expectedPhase, issuedAt: Date.now(), stateRevision: snapshot.state.revision, consumed: false }; evidence.inputs.push({ type: "dry-run-shortcut-authorization", label, key: definitionName, expectedPhase, stateRevision: snapshot.state.revision }); return authorization; } function consumeDryRunPlayoutShortcutAuthorization( definitionName, authorization, label) { if (!dryRunPlayoutProfile || !authorization || authorization.consumed === true || authorization.definitionName !== definitionName || authorization.expectedPhase !== (definitionName === "F8" ? "idle" : "program") || !Number.isSafeInteger(authorization.stateRevision) || !Number.isFinite(authorization.issuedAt) || Date.now() - authorization.issuedAt > 5_000) { failKnown(`${label} lacks a fresh one-shot DryRun shortcut authorization.`); } // Consume before dispatch. A partial key dispatch is never retried with the // same token, preserving the harness' OutcomeUnknown/no-duplicate boundary. authorization.consumed = true; } async function pressKey(definitionName, label, modifiers = 0, options = {}) { const definition = keyDefinitions[definitionName]; if (!definition) failKnown(`Unknown key definition ${definitionName}.`); if (new Set(["F2", "F3"]).has(definition.key)) { failKnown(`The harness refused playout shortcut ${definition.key}.`); } if (definition.key === "F8") { consumeDryRunPlayoutShortcutAuthorization( definitionName, options.dryRunPlayoutShortcutAuthorization, label); } else if (definition.key === "Escape" && options.dryRunPlayoutShortcutAuthorization) { consumeDryRunPlayoutShortcutAuthorization( definitionName, options.dryRunPlayoutShortcutAuthorization, label); } else if (definition.key === "Escape") { const modalState = await evaluate(`(() => ({ named: document.getElementById("named-playlist-modal")?.hidden === false, confirmation: document.getElementById("named-playlist-confirmation")?.hidden === false }))()`); if (!modalState?.named && !modalState?.confirmation) { failKnown("Escape is allowed only while the named-playlist modal owns input."); } } evidence.inputs.push({ type: "key", label, key: definition.key, modifiers }); const common = { key: definition.key, code: definition.code, windowsVirtualKeyCode: definition.virtualKey, nativeVirtualKeyCode: definition.virtualKey, modifiers }; await rpc("Input.dispatchKeyEvent", { type: "keyDown", ...common }); await rpc("Input.dispatchKeyEvent", { type: "keyUp", ...common }); } async function setSearchText(text) { await ensureWorkspace("stock", "show stock workspace before stock search"); await pointerClick( 'document.getElementById("stock-search")', "focus stock search"); await pressKey("SelectAll", "select existing stock query", ctrlModifier); await pressKey("Backspace", "clear existing stock query"); evidence.inputs.push({ type: "text", label: "stock query", text }); await rpc("Input.insertText", { text }); } async function replaceText(expression, text, label, options = {}) { await pointerClick(expression, `focus ${label}`, 0, options); const focused = await evaluate(`document.activeElement === (${expression})`); if (focused !== true) failKnown(`${label} did not receive pointer focus.`); await pressKey("SelectAll", `select existing ${label}`, ctrlModifier); await pressKey("Backspace", `clear existing ${label}`); evidence.inputs.push({ type: "text", label, text }); await rpc("Input.insertText", { text }); const actual = await evaluate(`(${expression})?.value ?? null`); if (actual !== text) { failKnown(`${label} did not retain the exact typed value.`); } } async function selectOptionValue(expression, value, label) { await pointerClick(expression, `focus ${label}`); const selection = await evaluate(`(() => { const select = ${expression}; if (!(select instanceof HTMLSelectElement)) return null; const targetIndex = [...select.options].findIndex(option => option.value === ${JSON.stringify(value)}); return { targetIndex, currentValue: select.value }; })()`); if (!selection || selection.targetIndex < 0) { failKnown(`${label} does not contain the requested option.`); } await pressKey("Home", `${label} first option`); for (let index = 0; index < selection.targetIndex; index++) { await pressKey("ArrowDown", `${label} next option ${index + 1}`); } await pressKey("Enter", `${label} commit option`); const actual = await evaluate(`(${expression})?.value ?? null`); if (actual !== value) failKnown(`${label} did not select the requested value.`); } async function respondToNativeDialog(label, confirmation, accept, options = {}) { const opened = await waitFor(`${label} dialog`, snapshot => snapshot.state?.dialog?.isConfirmation === confirmation && snapshot.dom.legacyDialogHidden === false, { timeoutMilliseconds: 30_000, allowUiBusy: true, allowLegacyDialog: true, allowActiveDryRun: options.allowActiveDryRun === true }); if (confirmation && accept !== true && accept !== false) { failKnown(`${label} confirmation response is not explicit.`); } await pointerClick( confirmation && accept === false ? 'document.getElementById("legacy-dialog-no")' : 'document.getElementById("legacy-dialog-ok")', `${label} ${confirmation ? (accept ? "yes" : "no") : "ok"}`, 0, { allowLegacyDialog: true, allowActiveDryRun: options.allowActiveDryRun === true }); await waitFor(`${label} dialog close`, snapshot => snapshot.state?.dialog == null && snapshot.dom.legacyDialogHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowLegacyDialog: true, allowActiveDryRun: options.allowActiveDryRun === true }); return opened; } async function pointerClickWithNativeDialog( expression, label, confirmation, message, accept, options = {}) { await pointerClick(expression, label, 0, options); const opened = await respondToNativeDialog( label, confirmation, accept, options); if (opened.state?.dialog?.message !== message || opened.dom.legacyDialogText !== message) { failKnown(`${label} native dialog text did not match the original.`); } return opened; } async function pointerDoubleClick( expression, label, modifiers = 0, interClickDelayMilliseconds = 50) { if (!Number.isInteger(interClickDelayMilliseconds) || interClickDelayMilliseconds < 0 || interClickDelayMilliseconds > 1_000) { failKnown(`${label} has an invalid double-click interval.`); } await waitForPointerReady(label); const point = await elementGeometry(expression, label); evidence.inputs.push({ type: "pointer-double-click", label, modifiers, interClickDelayMilliseconds, point }); await moveMouse(point, modifiers, 0); for (const clickCount of [1, 2]) { await rpc("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount, modifiers, pointerType: "mouse" }); await rpc("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount, modifiers, pointerType: "mouse" }); if (clickCount === 1) await sleep(interClickDelayMilliseconds); } } async function pointerClickWithJavaScriptDialog( expression, label, type, message, accept) { if (expectedJavaScriptDialog) { failUnknown(`Another JavaScript dialog was pending before ${label}.`); } await waitForPointerReady(label); let resolveDialog; let rejectDialog; const handled = new Promise((resolve, reject) => { resolveDialog = resolve; rejectDialog = reject; }); expectedJavaScriptDialog = { type, message, accept: accept === true, resolve: resolveDialog, reject: rejectDialog }; try { await pointerClick(expression, label, 0, { skipPointerReady: true }); await Promise.race([ handled, new Promise((_, reject) => setTimeout( () => reject(new HarnessFailure( "OUTCOME_UNKNOWN", `Timed out waiting for the JavaScript dialog at ${label}.`)), 10_000)) ]); } catch (error) { if (expectedJavaScriptDialog) expectedJavaScriptDialog = null; throw error; } } function selectedPhysicalIndices(snapshot) { return snapshot.state.cutRows .filter(row => row.isSelected) .map(row => row.physicalIndex); } function selectedPlaylistIds(snapshot) { return snapshot.state.playlist .filter(row => row.isSelected) .map(row => row.rowId); } function activePlaylistId(snapshot) { return snapshot.state.playlist.find(row => row.isActive)?.rowId || null; } function outboundMessagesAfter(snapshot, sequence) { if (!Number.isSafeInteger(sequence) || sequence < 0) { failKnown("An outbound-message sequence baseline is invalid."); } return (snapshot.safety?.outboundMessages || []) .filter(message => Number(message.sequence) > sequence); } function assertExactOutboundTypes(snapshot, sequence, expectedTypes, label) { const actual = outboundMessagesAfter(snapshot, sequence) .map(message => message.type); assertExactArray(actual, expectedTypes, `${label} outbound intents`); return outboundMessagesAfter(snapshot, sequence); } function focusedCutIndex(snapshot) { return snapshot.state.cutRows.find(row => row.isFocused)?.physicalIndex ?? null; } function assertExactArray(actual, expected, label) { if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) { failKnown(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`); } } function cssDragSize(snapshot) { const metrics = snapshot.state.interactionMetrics; const devicePixelRatio = snapshot.devicePixelRatio; if (!metrics || metrics.coordinateSpace !== "host-dip" || !Number.isInteger(metrics.cutDragWidthDips) || metrics.cutDragWidthDips <= 0 || !Number.isInteger(metrics.cutDragHeightDips) || metrics.cutDragHeightDips <= 0) { failKnown("Valid host-DIP cut drag metrics are missing."); } const hostScale = Number.isFinite(metrics.hostRasterizationScale) && metrics.hostRasterizationScale > 0 ? metrics.hostRasterizationScale : 1; const deviceScale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : hostScale; const candidateZoom = deviceScale / hostScale; const browserZoom = Number.isFinite(candidateZoom) && candidateZoom >= 0.25 && candidateZoom <= 5 ? candidateZoom : 1; return { width: metrics.cutDragWidthDips / browserZoom, height: metrics.cutDragHeightDips / browserZoom }; } async function dragCutsToPlaylist( physicalIndex, modifiers, expectedAppendCount, label, options = {}) { const before = await readSnapshot(); const allowActiveDryRun = options.allowActiveDryRun === true; assertSafety(before, `${label} preflight`, false, false, allowActiveDryRun); const beforeCount = before.state.playlist.length; const beforeRevision = before.state.revision; const sourceExpression = `document.querySelector('#cut-list .cut-row[data-physical-index="${physicalIndex}"]')`; const source = await elementGeometry(sourceExpression, `${label} source`); const dropGeometry = await playlistDropGeometry(`${label} target`); const dragInput = { type: "cut-drag", label, physicalIndex, modifiers, source, dropGeometry, targetSamples: [], acceptedTarget: null, expectedAppendCount }; evidence.inputs.push(dragInput); await pressMouse(source, modifiers); try { const held = await waitFor(`${label} pointer-down receipt`, snapshot => snapshot.state.revision > beforeRevision && focusedCutIndex(snapshot) === physicalIndex, { allowUiBusy: false, allowActiveDryRun }); const dragSize = cssDragSize(held); const inside = { x: source.x - dragSize.width / 2, y: source.y - dragSize.height / 2 }; const threshold = { x: Math.min(source.right - 4, source.x + Math.max(8, dragSize.width + 2)), y: source.y }; pointerReleasePoint = source; pointerReleaseModifiers = modifiers; await moveMouse(inside, modifiers, 1); await sleep(30); const beforeThreshold = await readSnapshot(); const sourceBeforeThreshold = beforeThreshold.dom.cuts.find(row => row.physicalIndex === physicalIndex); if (!sourceBeforeThreshold || sourceBeforeThreshold.dragging || beforeThreshold.dom.cutDropHighlighted || beforeThreshold.dom.cutDragFeedback?.hidden !== true) { failKnown(`${label} started inside the original drag rectangle.`); } await moveMouse(threshold, modifiers, 1); await sleep(50); const thresholdSnapshot = await readSnapshot(); const thresholdSource = thresholdSnapshot.dom.cuts.find(row => row.physicalIndex === physicalIndex); const capturedAtThreshold = thresholdSnapshot.dom.cutPointerCapture ?.physicalIndices?.includes(physicalIndex) === true; dragInput.threshold = { point: threshold, dragging: thresholdSource?.dragging === true, captured: capturedAtThreshold, feedback: thresholdSnapshot.dom.cutDragFeedback, pointerEvents: thresholdSnapshot.pointerEvents.slice(-12) }; if (!thresholdSource?.dragging) { failKnown( `${label} did not cross the Windows drag threshold on the source row; ` + `pointer diagnostics: ${JSON.stringify(dragInput.threshold.pointerEvents)}.`); } if (!capturedAtThreshold) { failKnown( `${label} crossed the drag threshold but WebView2 did not retain pointer ` + `capture on the source row; pointer diagnostics: ` + `${JSON.stringify(dragInput.threshold.pointerEvents)}.`); } if (thresholdSnapshot.dom.cutDropHighlighted) { failKnown(`${label} highlighted the remote drop zone while still on the source row.`); } const thresholdFeedback = thresholdSnapshot.dom.cutDragFeedback; if (!thresholdFeedback || thresholdFeedback.hidden || thresholdFeedback.dropAllowed !== "false" || !Number.isFinite(thresholdFeedback.left) || !Number.isFinite(thresholdFeedback.top) || Math.abs(thresholdFeedback.left - threshold.x) > 280 || Math.abs(thresholdFeedback.top - threshold.y) > 80) { failKnown(`${label} did not show blocked drag feedback beside the real pointer.`); } let overTarget = null; let acceptedTarget = null; for (const candidate of dropGeometry.points) { await moveMouse(candidate, modifiers, 1); await sleep(50); const sample = await readSnapshot(); const sourceAtCandidate = sample.dom.cuts.find(row => row.physicalIndex === physicalIndex); const sampleEvidence = { point: candidate, dragging: sourceAtCandidate?.dragging === true, highlighted: sample.dom.cutDropHighlighted, feedback: sample.dom.cutDragFeedback, captured: sample.dom.cutPointerCapture?.physicalIndices ?.includes(physicalIndex) === true, pointerEvents: sample.pointerEvents.slice(-12) }; dragInput.targetSamples.push(sampleEvidence); if (sampleEvidence.dragging && sampleEvidence.highlighted && sampleEvidence.captured && sampleEvidence.feedback?.hidden === false && sampleEvidence.feedback?.dropAllowed === "true") { overTarget = sample; acceptedTarget = candidate; break; } } if (!overTarget || !acceptedTarget) { failKnown( `${label} did not enter the real captured-pointer drop state; samples: ` + `${JSON.stringify(dragInput.targetSamples)}.`); } dragInput.acceptedTarget = acceptedTarget; await releaseMouse(acceptedTarget, modifiers); } finally { if (pointerIsPressed) { const safePoint = pointerReleasePoint || source; await releaseMouse(safePoint, pointerReleaseModifiers); } } const completed = await waitFor(`${label} append`, snapshot => snapshot.state.isBusy !== true && snapshot.state.playlist.length === beforeCount + expectedAppendCount, { allowUiBusy: false, allowActiveDryRun }); if (!completed.dom.cuts.every(row => !row.dragging) || completed.dom.cutDropHighlighted || completed.dom.cutDragFeedback?.hidden !== true) { failKnown(`${label} left drag visuals active after release.`); } return completed; } async function rowCellGeometry(rowId, label) { return await elementGeometry(`(() => { const row = Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row")) .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)}); return row?.children?.item(3) || null; })()`, label); } async function playlistRowHeaderGeometry(rowId, label, verticalFraction = 0.5) { return await elementGeometry(`(() => { const row = Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row")) .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)}); return row?.querySelector(".playlist-row-header") || null; })()`, label, verticalFraction); } async function playlistCheckboxGeometry(rowId, label) { return await elementGeometry(`(() => { const row = Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row")) .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)}); return row?.querySelector("input[type='checkbox']") || null; })()`, label); } async function disabledPlaylistCheckboxGeometry(rowId, label) { const geometry = await evaluate(`(() => { const row = Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row")) .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)}); const checkbox = row?.querySelector("input[type='checkbox']") || null; if (!checkbox) return null; checkbox.scrollIntoView({ block: "nearest", inline: "nearest" }); const rectangle = checkbox.getBoundingClientRect(); return { x: rectangle.left + rectangle.width / 2, y: rectangle.top + rectangle.height / 2, left: rectangle.left, top: rectangle.top, right: rectangle.right, bottom: rectangle.bottom, width: rectangle.width, height: rectangle.height, disabled: checkbox.disabled === true, ariaDisabled: checkbox.getAttribute("aria-disabled") === "true" }; })()`); if (!geometry || (!geometry.disabled && !geometry.ariaDisabled) || !Number.isFinite(geometry.x) || !Number.isFinite(geometry.y) || geometry.width <= 0 || geometry.height <= 0 || geometry.right <= 0 || geometry.bottom <= 0) { failKnown(`${label} is not a rendered disabled PROGRAM checkbox.`); } return geometry; } async function pointerClickDisabledPlaylistCheckbox(rowId, label) { await waitForPointerReady(label, false, true); const point = await disabledPlaylistCheckboxGeometry(rowId, label); evidence.inputs.push({ type: "disabled-playlist-checkbox-click", label, rowId, point }); await pressMouse(point, 0); await sleep(25); await releaseMouse(point, 0); return point; } async function pointerClickPlaylistRow(rowId, label, modifiers = 0) { const point = await rowCellGeometry(rowId, label); evidence.inputs.push({ type: "pointer-click", label, modifiers, point, rowId }); await pressMouse(point, modifiers); await sleep(25); await releaseMouse(point, modifiers); } async function dragPlaylistRow(sourceRowId, targetRowId, label) { const source = await playlistRowHeaderGeometry(sourceRowId, `${label} source`); const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75); evidence.inputs.push({ type: "playlist-row-drag", label, sourceRowId, targetRowId, position: "after", source, target }); await pressMouse(source, 0); try { pointerReleasePoint = source; await moveMouse({ x: source.x + 8, y: source.y }, 0, 1); await sleep(50); await moveMouse({ x: (source.x + target.x) / 2, y: (source.y + target.y) / 2 }, 0, 1); await sleep(50); await moveMouse(target, 0, 1); await sleep(75); const overTarget = await readSnapshot(); const sourceDom = overTarget.dom.playlist.find(row => row.rowId === sourceRowId); const targetDom = overTarget.dom.playlist.find(row => row.rowId === targetRowId); const captured = overTarget.dom.playlistPointerCapture?.rowIds ?.includes(sourceRowId) === true; if (!sourceDom?.dragging || !targetDom?.dragAfter || !captured) { failKnown(`${label} did not produce the captured-pointer row-header drag state.`); } await releaseMouse(target, 0); } finally { if (pointerIsPressed) { await releaseMouse(pointerReleasePoint || source, 0); } } } async function attemptCheckboxOriginDrag(sourceRowId, targetRowId, label) { const source = await playlistCheckboxGeometry(sourceRowId, `${label} checkbox`); const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75); evidence.inputs.push({ type: "playlist-checkbox-drag-attempt", label, sourceRowId, targetRowId, source, target }); await pressMouse(source, 0); try { pointerReleasePoint = source; await moveMouse({ x: source.x + 8, y: source.y }, 0, 1); await sleep(50); await moveMouse(target, 0, 1); await sleep(75); const during = await readSnapshot(); if (during.dom.playlist.some(row => row.dragging || row.dragBefore || row.dragAfter)) { failKnown(`${label} exposed a row drag visual from a checkbox origin.`); } await releaseMouse(target, 0); } finally { if (pointerIsPressed) { await releaseMouse(pointerReleasePoint || source, 0); } } } function readSmallJsonFile(filePath, label) { let stat; try { stat = fs.lstatSync(filePath); } catch (_) { failKnown(`${label} is unavailable.`); } if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16_384) { failKnown(`${label} is not a small regular file.`); } try { const bytes = fs.readFileSync(filePath); if (bytes.length !== stat.size) failKnown(`${label} changed while it was read.`); return { value: JSON.parse(bytes.toString("utf8")), bytes }; } catch (_) { failKnown(`${label} is not valid UTF-8 JSON.`); } } async function publishWindowsInputRequest(request, label, acknowledgementContract) { const exchangeIndex = evidence.windowsInput.exchanges.length + 1; if (request.exchangeIndex !== exchangeIndex || exchangeIndex > configuration.windowsInputExchangeCount) { failKnown(`${label} is outside the ordered Windows input exchange budget.`); } const requestPath = windowsInputExchangePath( configuration.windowsInputRequestPath, exchangeIndex); const acknowledgementPath = windowsInputExchangePath( configuration.windowsInputAckPath, exchangeIndex); if (fs.existsSync(requestPath) || fs.existsSync(acknowledgementPath)) { failKnown(`${label} would overwrite an existing Windows input exchange.`); } const requestBytes = Buffer.from(`${JSON.stringify(request, null, 2)}\n`, "utf8"); fs.writeFileSync(requestPath, requestBytes, { flag: "wx" }); evidence.inputs.push({ type: "windows-send-input-request", label, exchangeIndex, operation: request.operation }); while (!fs.existsSync(acknowledgementPath)) { await sleep(50); } const acknowledgement = readSmallJsonFile( acknowledgementPath, `Windows input acknowledgement ${exchangeIndex}`); const ack = acknowledgement.value; const expectedAcknowledgement = { schemaVersion: 1, exchangeIndex, token: request.token, result: "PASS", operation: request.operation, foregroundValidated: true, packageIdentityValidated: true, inputRetryCount: 0, ...acknowledgementContract }; const actualKeys = Object.keys(ack || {}).sort(); const expectedKeys = Object.keys(expectedAcknowledgement).sort(); if (!ack || JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys) || expectedKeys.some(key => ack[key] !== expectedAcknowledgement[key])) { failKnown(`${label} acknowledgement does not match the exact one-shot request.`); } const exchange = { exchangeIndex, operation: request.operation, requestPath, acknowledgementPath, requestSha256: sha256(requestBytes), acknowledgementSha256: sha256(acknowledgement.bytes), result: "PASS" }; evidence.windowsInput.exchanges.push(exchange); evidence.windowsInput.completedExchangeCount = exchangeIndex; if (exchangeIndex === 1) { evidence.windowsInput.requestSha256 = exchange.requestSha256; evidence.windowsInput.acknowledgementSha256 = exchange.acknowledgementSha256; } evidence.windowsInput.result = exchangeIndex === configuration.windowsInputExchangeCount ? "PASS" : "PENDING"; return { ack, exchange }; } async function requestWindowsInput( current, sourceRowId, targetRowId, expectedFirstRowId, rangeStartRowId, rangeEndRowId, label) { assertSafety(current, `${label} preflight`, false); const preflightSignature = JSON.stringify({ revision: current.state.revision, order: current.state.playlist.map(row => row.rowId), selected: selectedPlaylistIds(current), active: activePlaylistId(current), focused: current.dom.activeElement?.playlistRowId ?? null }); await sleep(350); let stabilized = await readSnapshot(); assertSafety(stabilized, `${label} stabilized preflight`, false); const stabilizedSignature = JSON.stringify({ revision: stabilized.state.revision, order: stabilized.state.playlist.map(row => row.rowId), selected: selectedPlaylistIds(stabilized), active: activePlaylistId(stabilized), focused: stabilized.dom.activeElement?.playlistRowId ?? null }); if (stabilizedSignature !== preflightSignature) { failKnown(`${label} state changed before the one-shot request was published.`); } current = stabilized; const source = await playlistRowHeaderGeometry(sourceRowId, `${label} source`); const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75); const rangeStart = await rowCellGeometry(rangeStartRowId, `${label} range start`); const rangeEnd = await rowCellGeometry(rangeEndRowId, `${label} range end`); const restoreSelection = await rowCellGeometry( targetRowId, `${label} future restored-selection position`); await sleep(100); const geometryGuard = await readSnapshot(); const guardedSource = await playlistRowHeaderGeometry( sourceRowId, `${label} guarded source`); const guardedTarget = await playlistRowHeaderGeometry( targetRowId, `${label} guarded target`, 0.75); const guardedRangeStart = await rowCellGeometry( rangeStartRowId, `${label} guarded range start`); const guardedRangeEnd = await rowCellGeometry( rangeEndRowId, `${label} guarded range end`); const guardedRestoreSelection = await rowCellGeometry( targetRowId, `${label} guarded future restored-selection position`); const geometryStable = ["x", "y", "left", "top", "right", "bottom"] .every(key => Math.abs(source[key] - guardedSource[key]) <= 0.5 && Math.abs(target[key] - guardedTarget[key]) <= 0.5 && Math.abs(rangeStart[key] - guardedRangeStart[key]) <= 0.5 && Math.abs(rangeEnd[key] - guardedRangeEnd[key]) <= 0.5 && Math.abs(restoreSelection[key] - guardedRestoreSelection[key]) <= 0.5); const guardSignature = JSON.stringify({ revision: geometryGuard.state.revision, order: geometryGuard.state.playlist.map(row => row.rowId), selected: selectedPlaylistIds(geometryGuard), active: activePlaylistId(geometryGuard), focused: geometryGuard.dom.activeElement?.playlistRowId ?? null }); if (!geometryStable || guardSignature !== stabilizedSignature) { failKnown(`${label} DOM geometry or native state changed before publication.`); } current = geometryGuard; // Move away from playlist controls before the native ownership guard runs. // This dismisses any transient app tooltip without changing selection or // publishing an operator intent, so source/target hit testing stays exact. const neutralPoint = { x: Math.max(8, current.dom.viewport.width / 2), y: 8 }; await moveMouse(neutralPoint); await sleep(500); const neutralGuard = await readSnapshot(); assertSafety(neutralGuard, `${label} neutral-pointer guard`, false); const neutralSignature = JSON.stringify({ revision: neutralGuard.state.revision, order: neutralGuard.state.playlist.map(row => row.rowId), selected: selectedPlaylistIds(neutralGuard), active: activePlaylistId(neutralGuard), focused: neutralGuard.dom.activeElement?.playlistRowId ?? null }); if (neutralSignature !== guardSignature) { failKnown(`${label} state changed while dismissing transient pointer UI.`); } current = neutralGuard; const token = randomBytes(16).toString("hex").toUpperCase(); const beforeOrder = current.state.playlist.map(row => row.rowId); const reorderedOrder = beforeOrder.filter(rowId => rowId !== sourceRowId); const targetIndex = reorderedOrder.indexOf(targetRowId); if (targetIndex < 0 || sourceRowId === targetRowId || beforeOrder.length !== 5 || new Set(beforeOrder).size !== beforeOrder.length || rangeStartRowId === rangeEndRowId || !beforeOrder.includes(rangeStartRowId) || !beforeOrder.includes(rangeEndRowId)) { failKnown(`${label} row identities are not safe for a one-row move.`); } reorderedOrder.splice(targetIndex + 1, 0, sourceRowId); if (reorderedOrder[0] !== expectedFirstRowId || reorderedOrder.at(-2) !== rangeStartRowId || reorderedOrder.at(-1) !== rangeEndRowId) { failKnown(`${label} computed an unexpected Home boundary row.`); } const afterOrder = reorderedOrder.filter(rowId => rowId !== rangeStartRowId && rowId !== rangeEndRowId); if (afterOrder.length !== 3 || !afterOrder.includes(sourceRowId)) { failKnown(`${label} did not compute an exact two-row cleanup result.`); } const outboundSequence = current.safety.outboundMessages.length; const request = { schemaVersion: 1, exchangeIndex: 1, token, targetUrl: exactTargetUrl, operation: "row-header-drag-home-shift-range-delete-restore", sourceRowId, targetRowId, rangeStartRowId, rangeEndRowId, position: "after", source: { x: source.x, y: source.y }, target: { x: target.x, y: target.y }, rangeStart: { x: rangeStart.x, y: rangeStart.y }, rangeEnd: { x: rangeEnd.x, y: rangeEnd.y }, restoreSelection: { x: restoreSelection.x, y: restoreSelection.y }, viewport: { ...current.dom.viewport }, devicePixelRatio: current.devicePixelRatio, beforeOrder, reorderedOrder, afterOrder, expectedRangeSelectedRowIds: [rangeStartRowId, rangeEndRowId], expectedSelectedRowId: sourceRowId, expectedActiveRowIdAfterHome: expectedFirstRowId, expectedFinalActiveRowId: sourceRowId, expectedFocusedRowId: sourceRowId, mouseDownCalls: 4, mouseUpCalls: 4, homeKeyCalls: 1, shiftKeyDownCalls: 1, shiftKeyUpCalls: 1, deleteKeyCalls: 1, inputRetryCount: 0 }; await publishWindowsInputRequest(request, label, { mouseDownCalls: 4, mouseUpCalls: 4, homeKeyCalls: 1, shiftKeyDownCalls: 1, shiftKeyUpCalls: 1, deleteKeyCalls: 1 }); const completed = await waitFor(label, snapshot => JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === JSON.stringify(afterOrder) && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([sourceRowId]) && activePlaylistId(snapshot) === sourceRowId && snapshot.dom.activeElement?.playlistRowId === sourceRowId, { timeoutMilliseconds: Math.min(configuration.timeoutMilliseconds, 15_000), allowUiBusy: false }); const messages = assertExactOutboundTypes(completed, outboundSequence, [ "activate-playlist-row", "reorder-playlist-rows", "select-playlist-boundary", "select-playlist-row", "select-playlist-row", "delete-selected-playlist-rows", "select-playlist-row" ], `${label} exact physical gesture sequence`); const selections = messages.filter(message => message.type === "select-playlist-row"); if (selections.length !== 3 || selections[0].rowId !== rangeStartRowId || selections[0].control !== false || selections[0].shift !== false || selections[1].rowId !== rangeEndRowId || selections[1].control !== false || selections[1].shift !== true || selections[2].rowId !== sourceRowId || selections[2].control !== false || selections[2].shift !== false) { failKnown(`${label} did not preserve the exact plain/Shift/plain selection sequence.`); } evidence.windowsInput.beforeOrder = beforeOrder; evidence.windowsInput.afterOrder = afterOrder; evidence.windowsInput.activeRowIdAfterHome = expectedFirstRowId; evidence.windowsInput.shiftRangeDeletedRowIds = [rangeStartRowId, rangeEndRowId]; evidence.windowsInput.finalActiveRowId = sourceRowId; await checkpoint("windows-sendinput-drag-home-shift-range-delete-restore", { sourceRowId, targetRowId, beforeOrder, reorderedOrder, afterOrder, expectedFirstRowId, deletedRowIds: [rangeStartRowId, rangeEndRowId] }); return completed; } async function requestPhysicalBackgroundShortcuts() { if (!dryRunPlayoutProfile) return; let current = await readSnapshot(); assertSafety(current, "physical F2/F3 preflight", false); if (current.state.playout.backgroundEnabled !== false || current.state.playout.canChangeBackground !== true || current.dom.backgroundFileDisabled !== false || current.dom.backgroundNoneChecked !== true || current.dom.backgroundNoneDisabled !== false || current.dom.backgroundNameValue !== "배경없음") { failKnown("The physical F2/F3 preflight is not exact IDLE background-none state."); } const f2OutboundSequence = current.safety.outboundMessages.length; const f2Token = randomBytes(16).toString("hex").toUpperCase(); await publishWindowsInputRequest({ schemaVersion: 1, exchangeIndex: 2, token: f2Token, targetUrl: exactTargetUrl, operation: "background-f2-picker-cancel", f2KeyCalls: 1, escapeKeyCalls: 1, inputRetryCount: 0 }, "Windows SendInput F2 picker and one Escape cancellation", { f2KeyCalls: 1, escapeKeyCalls: 1, pickerUniqueNew: true, pickerOwnerLinked: true, pickerSameProcess: true, pickerClass: "#32770", pickerTitle: "다음 장면 배경 선택", pickerModal: true, pickerClosedAfterEscape: true }); current = await waitFor("physical F2 picker cancellation receipt", snapshot => snapshot.state.isBusy !== true && snapshot.state.playout.phase === "idle" && snapshot.state.playout.backgroundEnabled === false && snapshot.state.playout.canChangeBackground === true && snapshot.dom.backgroundNoneChecked === true && snapshot.dom.backgroundNameValue === "배경없음" && outboundMessagesAfter(snapshot, f2OutboundSequence).length === 1, { timeoutMilliseconds: 15_000, allowUiBusy: false }); assertExactOutboundTypes( current, f2OutboundSequence, ["choose-background"], "physical F2 picker cancellation"); const f3OutboundSequence = current.safety.outboundMessages.length; const f3Revision = current.state.revision; const f3Token = randomBytes(16).toString("hex").toUpperCase(); await publishWindowsInputRequest({ schemaVersion: 1, exchangeIndex: 3, token: f3Token, targetUrl: exactTargetUrl, operation: "background-f3-none", f3KeyCalls: 1, inputRetryCount: 0 }, "Windows SendInput F3 fail-closed background-none", { f3KeyCalls: 1 }); current = await waitFor("physical F3 background-none receipt", snapshot => snapshot.state.revision > f3Revision && snapshot.state.isBusy !== true && snapshot.state.statusKind === "error" && snapshot.state.playout.phase === "idle" && snapshot.state.playout.backgroundEnabled === false && snapshot.state.playout.backgroundFileName === "" && snapshot.state.playout.canChangeBackground === true && snapshot.dom.backgroundNoneChecked === true && snapshot.dom.backgroundNoneDisabled === false && snapshot.dom.backgroundNameValue === "배경없음" && outboundMessagesAfter(snapshot, f3OutboundSequence).length === 1, { timeoutMilliseconds: 15_000, allowUiBusy: false }); assertExactOutboundTypes( current, f3OutboundSequence, ["toggle-background"], "physical F3 background-none fail-closed result"); evidence.dryRunPlayout.backgroundShortcutParity = { f2IntentCount: 1, f2KeyCalls: 1, escapeKeyCalls: 1, pickerOwnedAndCancelled: true, f3IntentCount: 1, f3KeyCalls: 1, defaultAssetAbsentFailClosed: true, finalBackgroundEnabled: false, finalBackgroundNoneChecked: true, finalPhase: current.state.playout.phase }; await checkpoint("windows-sendinput-f2-cancel-f3-background-none", { defaultAssetAbsentFailClosed: true, finalBackgroundEnabled: false }); } async function installProbe() { const token = `legacy-package-input-${Date.now()}-${Math.random().toString(16).slice(2)}`; const installed = await evaluate(`(() => { if (!window.chrome?.webview) { throw new Error("WebView2 bridge is unavailable."); } if (globalThis.__legacyPackageInputProbe) { throw new Error("A legacy package input probe is already installed; restart the app."); } const probe = { token: ${JSON.stringify(token)}, stateCount: 0, busyStateCount: 0, states: [], listener: null, pointerEventTypes: [ "pointerdown", "gotpointercapture", "pointermove", "pointerup", "pointercancel", "lostpointercapture" ], pointerEvents: [], pointerListener: null, originalPostMessage: null, postMessageWrapper: null, outboundMessageCount: 0, outboundMessages: [], approvedDatabaseWriteMessages: [], blockedOutboundMessages: [], stateViolations: [] }; const allowActiveDryRun = ${JSON.stringify(activeDryRunProfile)}; probe.listener = event => { if (event.data?.type !== "state" || !event.data.payload) return; const payload = event.data.payload; probe.stateCount += 1; if (payload.isBusy === true) probe.busyStateCount += 1; probe.states.push(structuredClone(payload)); if (probe.states.length > 750) probe.states.shift(); const playout = payload.playout; const reasons = []; if (!playout) { reasons.push("missing-playout"); } else { if (playout.mode !== "dryRun") reasons.push("mode"); if (!allowActiveDryRun && playout.phase !== "idle") reasons.push("phase"); if (!allowActiveDryRun && playout.preparedCode != null) reasons.push("prepared-code"); if (!allowActiveDryRun && playout.onAirCode != null) reasons.push("on-air-code"); if (playout.isConnected === true) reasons.push("connected"); if (playout.outcomeUnknown === true) reasons.push("outcome-unknown"); if (!allowActiveDryRun && playout.isPlayCompletionPending === true) { reasons.push("play-pending"); } if (!allowActiveDryRun && playout.isTakeOutCompletionPending === true) { reasons.push("take-out-pending"); } if (!allowActiveDryRun && playout.isBusy === true) reasons.push("playout-busy"); if (!allowActiveDryRun && playout.refreshActive === true) reasons.push("refresh-active"); } if (reasons.length > 0) { probe.stateViolations.push({ at: new Date().toISOString(), revision: payload.revision ?? null, reasons }); } }; probe.pointerListener = event => { const target = event.target instanceof Element ? event.target : null; const cut = target?.closest?.("#cut-list .cut-row") || null; const playlistRow = target?.closest?.("#playlist-rows .playlist-data-row") || null; const capturedCuts = Number.isInteger(event.pointerId) ? Array.from(document.querySelectorAll("#cut-list .cut-row")) .filter(row => typeof row.hasPointerCapture === "function" && row.hasPointerCapture(event.pointerId)) .map(row => Number(row.dataset.physicalIndex)) : []; probe.pointerEvents.push({ type: event.type, pointerId: event.pointerId, pointerType: event.pointerType, clientX: event.clientX, clientY: event.clientY, button: event.button, buttons: event.buttons, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, targetId: target?.id || null, targetClass: target?.className || null, cutPhysicalIndex: cut ? Number(cut.dataset.physicalIndex) : null, playlistRowId: playlistRow?.dataset?.rowId || null, capturedCutPhysicalIndices: capturedCuts, timeStamp: event.timeStamp }); if (probe.pointerEvents.length > 200) probe.pointerEvents.shift(); }; const allowedTypes = new Set(${JSON.stringify(allowedOutboundMessageTypes)}); const playoutTypes = new Set(${JSON.stringify(playoutIntentMessageTypes)}); const databaseWriteTypes = new Set(${JSON.stringify(databaseWriteIntentMessageTypes)}); probe.originalPostMessage = window.chrome.webview.postMessage; probe.postMessageWrapper = message => { const type = typeof message?.type === "string" ? message.type : null; probe.outboundMessageCount += 1; const record = { sequence: probe.outboundMessageCount, type, at: new Date().toISOString(), allowAppend: type === "cut-pointer-down" ? message?.payload?.allowAppend ?? null : null, enabled: type === "set-all-playlist-enabled" ? message?.payload?.enabled ?? null : null, rowId: type === "select-playlist-row" ? message?.payload?.rowId ?? null : null, control: type === "select-playlist-row" ? message?.payload?.control === true : null, shift: type === "select-playlist-row" ? message?.payload?.shift === true : null, definitionId: new Set([ "select-named-playlist", "load-named-playlist-by-id", "save-current-named-playlist-to" ]).has(type) ? message?.payload?.definitionId ?? null : null, title: type === "create-named-playlist" ? message?.payload?.title ?? null : null, requestId: typeof message?.payload?.requestId === "string" ? message.payload.requestId : null, emptyStockQuery: type === "search-operator-catalog-stocks" ? message?.payload?.query === "" : null, market: type === "change-create-theme-market" ? message?.payload?.market ?? null : null, editorName: type === "change-create-theme-market" ? message?.payload?.editorName ?? null : null }; probe.outboundMessages.push(record); if (probe.outboundMessages.length > 250) probe.outboundMessages.shift(); if (!type || !allowedTypes.has(type)) { const blocked = { ...record, category: playoutTypes.has(type) ? "playout" : databaseWriteTypes.has(type) ? "database-write" : "unapproved" }; probe.blockedOutboundMessages.push(blocked); return; } if (databaseWriteTypes.has(type)) { probe.approvedDatabaseWriteMessages.push(record); } return probe.originalPostMessage.call(window.chrome.webview, message); }; try { window.chrome.webview.postMessage = probe.postMessageWrapper; if (window.chrome.webview.postMessage !== probe.postMessageWrapper) { throw new Error("The outbound WebView safety gate could not be installed."); } globalThis.__legacyPackageInputProbe = probe; window.chrome.webview.addEventListener("message", probe.listener); probe.pointerEventTypes.forEach(type => document.addEventListener(type, probe.pointerListener, true)); window.chrome.webview.postMessage({ type: "ready", payload: {} }); } catch (error) { window.chrome.webview.postMessage = probe.originalPostMessage; delete globalThis.__legacyPackageInputProbe; throw error; } return { token: probe.token }; })()`); if (installed?.token !== token) failKnown("The state observer was not installed exactly once."); } async function removeProbe() { if (!socket || socket.readyState !== WebSocket.OPEN) return; const removed = await evaluate(`(() => { const probe = globalThis.__legacyPackageInputProbe; if (!probe) return false; if (probe.listener) { window.chrome.webview.removeEventListener("message", probe.listener); } if (probe.pointerListener) { probe.pointerEventTypes.forEach(type => document.removeEventListener(type, probe.pointerListener, true)); } if (probe.originalPostMessage) { if (window.chrome?.webview?.postMessage !== probe.postMessageWrapper) { throw new Error("The outbound WebView safety gate identity changed."); } window.chrome.webview.postMessage = probe.originalPostMessage; } delete globalThis.__legacyPackageInputProbe; return true; })()`); if (removed !== true) { failUnknown("The outbound WebView safety probe was not restored exactly once."); } probeInstalled = false; } async function captureScreenshot() { const response = await rpc("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false }, 20_000); if (!response?.data) failKnown("CDP did not return screenshot data."); const bytes = Buffer.from(response.data, "base64"); fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" }); evidence.screenshot = { path: configuration.screenshotPath, bytes: bytes.length, sha256: sha256(bytes) }; } async function executeSafeSequence() { await rpc("Runtime.enable"); await rpc("Page.enable"); await installProbe(); probeInstalled = true; const preflight = await waitFor("DryRun IDLE preflight", snapshot => snapshot?.state?.playout?.mode === "dryRun" && snapshot.state.playout.phase === "idle" && snapshot.state.isBusy !== true && snapshot.dom.connectionText === "DRY RUN", { allowUiBusy: false }); assertSafety(preflight, "DryRun IDLE preflight", false); if (preflight.state.playlist.length !== 0) { failKnown("The package input test requires an initially empty in-memory playlist."); } if (!preflight.dom.namedModalHidden || !preflight.dom.namedConfirmationHidden) { failKnown("A child modal was already open at preflight."); } await checkpoint("preflight"); await verifyWorkspaceNavigationAndLayout(preflight); await setSearchText(evidence.fixture.query); await pointerClick( 'document.getElementById("stock-search-button")', "submit stock search"); let current = await waitFor("stock search result", snapshot => snapshot?.state?.isBusy === false && snapshot.state.searchText === evidence.fixture.query && snapshot.state.searchResults.some(row => row.displayName === evidence.fixture.stockDisplayName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const exactStocks = current.state.searchResults.filter(row => row.displayName === evidence.fixture.stockDisplayName); if (exactStocks.length !== 1) { failKnown(`Expected exactly one ${evidence.fixture.stockDisplayName} row, received ${exactStocks.length}.`); } const exactStock = exactStocks[0]; const exactStockExpression = `document.querySelector('#stock-results button[data-result-index="${exactStock.index}"]')`; await pointerClick(exactStockExpression, `select ${evidence.fixture.stockDisplayName}`); current = await waitFor("exact stock selection", snapshot => snapshot.state.isBusy === false && snapshot.state.selectedStockIndex === exactStock.index, { allowUiBusy: false }); const selectedPosition = current.state.searchResults.findIndex(row => row.index === exactStock.index); const forward = selectedPosition < current.state.searchResults.length - 1; const firstKey = forward ? "ArrowDown" : "ArrowUp"; const returnKey = forward ? "ArrowUp" : "ArrowDown"; const neighbor = current.state.searchResults[selectedPosition + (forward ? 1 : -1)]; if (!neighbor) failKnown("The exact stock has no adjacent keyboard result."); await pressKey(firstKey, "move stock result selection to adjacent row"); current = await waitFor("adjacent stock keyboard selection", snapshot => snapshot.state.selectedStockIndex === neighbor.index && snapshot.dom.stockOptions.filter(row => row.selected).length === 1 && snapshot.dom.stockOptions.some(row => row.index === neighbor.index && row.selected && row.tabIndex === 0 && row.focused), { allowUiBusy: false }); await pressKey(returnKey, "return stock result selection"); current = await waitFor("returned stock keyboard selection", snapshot => snapshot.state.selectedStockIndex === exactStock.index && snapshot.dom.stockOptions.filter(row => row.selected).length === 1 && snapshot.dom.stockOptions.some(row => row.index === exactStock.index && row.selected && row.tabIndex === 0 && row.focused), { allowUiBusy: false }); await checkpoint("stock-search-and-keyboard", { resultCount: current.state.searchResults.length, exactStockIndex: exactStock.index }); const firstThreeCuts = current.state.cutRows.slice(0, 3); if (firstThreeCuts.length !== 3 || firstThreeCuts.some((row, index) => row.physicalIndex !== index || row.isSeparator === true)) { failKnown("The first three physical cuts are not the expected non-separator fixture."); } assertExactArray( firstThreeCuts.map(row => row.rawLabel), [ "1열판기본_예상체결가", "1열판기본_현재가", "1열판기본_시간외단일가" ], "first three physical cut labels"); await pointerClick( 'document.querySelector("#cut-list .cut-row[data-physical-index=\'0\']")', "select first cut"); current = await waitFor("first cut selection", snapshot => focusedCutIndex(snapshot) === 0 && JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([0]), { allowUiBusy: false }); await pressKey("ArrowDown", "move cut focus without changing selection", ctrlModifier); current = await waitFor("Ctrl ArrowDown cut focus", snapshot => focusedCutIndex(snapshot) === 1 && JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([0]), { allowUiBusy: false }); await pressKey("Space", "select focused cut with Space"); current = await waitFor("Space cut selection", snapshot => focusedCutIndex(snapshot) === 1 && JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([1]), { allowUiBusy: false }); await pressKey("ArrowDown", "extend cut selection with Shift ArrowDown", shiftModifier); current = await waitFor("Shift ArrowDown cut range", snapshot => focusedCutIndex(snapshot) === 2 && JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([1, 2]), { allowUiBusy: false }); await checkpoint("cut-keyboard", { selectedPhysicalIndices: selectedPhysicalIndices(current), focusedPhysicalIndex: focusedCutIndex(current) }); current = await dragCutsToPlaylist( 2, ctrlModifier | shiftModifier, 2, "multi-cut captured-pointer drag"); assertExactArray( current.state.playlist.map(row => row.stockName), [evidence.fixture.stockDisplayName, evidence.fixture.stockDisplayName], "two-cut playlist stock order"); assertExactArray( current.state.playlist.map(row => `${row.graphicType}|${row.subtype}`), ["1열판기본|현재가", "1열판기본|시간외단일가"], "two-cut playlist mapping"); await checkpoint("multi-cut-drag", { playlistRowIds: current.state.playlist.map(row => row.rowId) }); current = await dragCutsToPlaylist( 1, 0, 1, "single-cut captured-pointer drag"); if (current.state.playlist.length !== 3) { failKnown("The two cut drags did not create exactly three in-memory rows."); } assertExactArray( current.state.playlist.map(row => `${row.graphicType}|${row.subtype}`), [ "1열판기본|현재가", "1열판기본|시간외단일가", "1열판기본|현재가" ], "three-cut playlist mapping"); assertExactArray( current.dom.playlist.map(row => row.rowHeaderText), ["1", "2", "3"], "playlist row-header ordinals"); if (current.dom.playlist.some(row => !Number.isFinite(row.rowHeaderWidth) || Math.abs(row.rowHeaderWidth - 30) > 0.5 || row.rowHeaderDragEnabled !== true)) { failKnown("Playlist row-header geometry or drag availability differs from FarPoint."); } await checkpoint("single-cut-drag", { playlistRowIds: current.state.playlist.map(row => row.rowId) }); const [rowA, rowB, rowC] = current.state.playlist.map(row => row.rowId); await pointerClickPlaylistRow(rowA, "select playlist row A"); current = await waitFor("playlist row A selection", snapshot => activePlaylistId(snapshot) === rowA && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA]), { allowUiBusy: false }); await pointerClickPlaylistRow(rowB, "Ctrl-select playlist row B", ctrlModifier); current = await waitFor("playlist A+B selection", snapshot => activePlaylistId(snapshot) === rowB && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA, rowB]), { allowUiBusy: false }); await dragPlaylistRow(rowA, rowC, "single-source playlist row drag"); current = await waitFor("single-source playlist reorder", snapshot => JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === JSON.stringify([rowB, rowC, rowA]), { allowUiBusy: false }); assertExactArray(selectedPlaylistIds(current), [rowA], "post-drag deletion selection"); if (activePlaylistId(current) !== rowA || current.dom.activeElement?.playlistRowId !== rowA) { failKnown("Playlist drag did not retain the source as active and focused."); } await checkpoint("playlist-single-source-drag", { beforeOrder: [rowA, rowB, rowC], afterOrder: [rowB, rowC, rowA] }); assertExactArray( current.dom.playlist.map(row => row.rowHeaderText), ["1", "2", "3"], "reordered playlist row-header ordinals"); await pointerClick( 'document.getElementById("playlist-move-up")', "move selected playlist row with Up button"); current = await waitFor("playlist Up button reorder", snapshot => JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === JSON.stringify([rowB, rowA, rowC]) && activePlaylistId(snapshot) === rowA && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA]), { allowUiBusy: false }); await pointerClick( 'document.getElementById("playlist-move-down")', "restore selected playlist row with Down button"); current = await waitFor("playlist Down button reorder", snapshot => JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === JSON.stringify([rowB, rowC, rowA]) && activePlaylistId(snapshot) === rowA && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA]), { allowUiBusy: false }); await checkpoint("playlist-move-buttons", { restoredOrder: [rowB, rowC, rowA] }); const rowBCheckboxExpression = `document.querySelector('.playlist-data-row[data-row-id="${rowB}"] input[type="checkbox"]')`; await pointerClick(rowBCheckboxExpression, "disable playlist row with its checkbox"); current = await waitFor("individual playlist checkbox disabled row", snapshot => activePlaylistId(snapshot) === rowB && snapshot.state.playlist.some(row => row.rowId === rowB && row.isEnabled === false), { allowUiBusy: false }); await pointerClick(rowBCheckboxExpression, "restore playlist row with its checkbox"); current = await waitFor("individual playlist checkbox restored row", snapshot => activePlaylistId(snapshot) === rowB && snapshot.state.playlist.some(row => row.rowId === rowB && row.isEnabled === true), { allowUiBusy: false }); await checkpoint("playlist-individual-checkbox", { rowId: rowB }); await sleep(legacyCutDoubleClickWindowMilliseconds + 100); current = await dragCutsToPlaylist( 1, 0, 1, "stage direct selected-delete button row"); if (current.state.playlist.length !== 4) { failKnown("The direct selected-delete button fixture did not append exactly one row."); } const directDeleteRowId = current.state.playlist.at(-1)?.rowId; if (!directDeleteRowId || directDeleteRowId === rowA || directDeleteRowId === rowB || directDeleteRowId === rowC) { failKnown("The direct selected-delete button fixture has no unique tail row."); } await pointerClickPlaylistRow(directDeleteRowId, "select direct button delete fixture"); current = await waitFor("direct button delete fixture selection", snapshot => activePlaylistId(snapshot) === directDeleteRowId && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([directDeleteRowId]), { allowUiBusy: false }); await pointerClick( 'document.getElementById("playlist-delete-selected")', "delete selected playlist row with button"); current = await waitFor("selected-delete button removed exact fixture", snapshot => JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === JSON.stringify([rowB, rowC, rowA]) && !snapshot.state.playlist.some(row => row.rowId === directDeleteRowId), { allowUiBusy: false }); await pointerClickPlaylistRow(rowA, "restore row A selection after button delete"); current = await waitFor("row A selection restored after button delete", snapshot => activePlaylistId(snapshot) === rowA && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA]), { allowUiBusy: false }); await checkpoint("playlist-selected-delete-button", { deletedRowId: directDeleteRowId, restoredOrder: [rowB, rowC, rowA] }); const orderBeforeCheckboxAttempt = current.state.playlist.map(row => row.rowId); const enabledBeforeCheckboxAttempt = current.state.playlist.map(row => ({ rowId: row.rowId, enabled: row.isEnabled })); await attemptCheckboxOriginDrag(rowB, rowA, "checkbox-origin drag rejection"); current = await waitFor("checkbox-origin drag rejection", snapshot => snapshot.state.isBusy === false && activePlaylistId(snapshot) === rowB, { allowUiBusy: false }); assertExactArray( current.state.playlist.map(row => row.rowId), orderBeforeCheckboxAttempt, "checkbox-origin playlist order"); if (JSON.stringify(current.state.playlist.map(row => ({ rowId: row.rowId, enabled: row.isEnabled }))) !== JSON.stringify(enabledBeforeCheckboxAttempt)) { failKnown("Checkbox-origin drag changed a row's enabled state."); } assertExactArray(selectedPlaylistIds(current), [rowA], "checkbox-origin deletion selection"); if (current.dom.playlist.some(row => row.dragging || row.dragBefore || row.dragAfter)) { failKnown("Checkbox-origin drag left playlist drag visuals active."); } await checkpoint("playlist-checkbox-drag-rejected"); const focusBeforeBoundary = current.dom.activeElement; if (focusBeforeBoundary?.playlistRowId !== rowB) { failKnown("Checkbox-origin pointer did not leave focus inside source row B."); } await pressKey("End", "select last playlist boundary without moving focus"); current = await waitFor("playlist End boundary", snapshot => activePlaylistId(snapshot) === rowA, { allowUiBusy: false }); assertExactArray(selectedPlaylistIds(current), [rowA], "End deletion selection"); if (JSON.stringify(current.dom.activeElement) !== JSON.stringify(focusBeforeBoundary)) { failKnown("End moved DOM focus instead of only changing the F8 candidate."); } await pressKey("Home", "select first playlist boundary without moving focus"); current = await waitFor("playlist Home boundary", snapshot => activePlaylistId(snapshot) === rowB, { allowUiBusy: false }); assertExactArray(selectedPlaylistIds(current), [rowA], "Home deletion selection"); if (JSON.stringify(current.dom.activeElement) !== JSON.stringify(focusBeforeBoundary)) { failKnown("Home moved DOM focus instead of only changing the F8 candidate."); } await checkpoint("playlist-keyboard-focus-split"); await sleep(legacyCutDoubleClickWindowMilliseconds + 100); current = await dragCutsToPlaylist( 1, 0, 1, "stage first disposable Shift-delete row"); // MainForm detects a double-click from two MouseDown events at the exact // same cut coordinates within 500 ms, even when both gestures become drags. // Let that original window expire so fixture setup does not append once for // the second pointer-down and once again for its completed drop. await sleep(legacyCutDoubleClickWindowMilliseconds + 100); current = await dragCutsToPlaylist( 1, 0, 1, "stage second disposable Shift-delete row"); const stagedDeleteRows = current.state.playlist.slice(-2); if (current.state.playlist.length !== 5 || stagedDeleteRows.length !== 2 || stagedDeleteRows.some(row => row.graphicType !== "1열판기본" || row.subtype !== "현재가")) { failKnown("The disposable Shift-delete fixture is not the exact two-row tail."); } const [rowD, rowE] = stagedDeleteRows.map(row => row.rowId); current = await requestWindowsInput( current, rowB, rowA, rowC, rowD, rowE, "Windows SendInput drag, Home, Shift range, Delete, and restore"); if (!programPlaylistDbProfile) { await pointerClick( 'document.getElementById("db-load-button")', "open DB playlist load modal"); current = await waitFor("read-only named playlist load modal", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && snapshot.dom.namedConfirmationHidden === true && snapshot.dom.activeInsideVisibleModal === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.dom.namedTitle !== "DB 재생목록 불러오기") { failKnown(`Unexpected load-modal title ${current.dom.namedTitle}.`); } await pressKey("Escape", "child load modal Escape no-op"); await sleep(100); current = await readSnapshot(); assertSafety(current, "load modal Escape", false); if (current.dom.namedModalHidden || !current.dom.namedConfirmationHidden || !current.dom.activeInsideVisibleModal) { failKnown("Escape closed the read-only named-playlist child modal."); } const focusableCount = current.dom.modalFocusableIds.length; if (focusableCount < 2) failKnown("The named-playlist modal has fewer than two focusable controls."); for (let index = 0; index <= focusableCount; index += 1) { await pressKey("Tab", `load modal Shift+Tab cycle ${index + 1}`, shiftModifier); const tabSnapshot = await readSnapshot(); assertSafety(tabSnapshot, `load modal Shift+Tab cycle ${index + 1}`, false); if (!tabSnapshot.dom.activeInsideVisibleModal) { failKnown("Shift+Tab escaped the named-playlist modal focus trap."); } } await pointerClick( 'document.getElementById("named-playlist-cancel-button")', "close load modal with Cancel"); current = await waitFor("load modal close and focus restore", snapshot => snapshot.dom.namedModalHidden === true && snapshot.dom.activeElement?.id === "db-load-button", { allowUiBusy: false }); await checkpoint("named-playlist-load-modal-focus", { definitionCount: current.state.namedPlaylist?.definitions?.length ?? 0, focusableCount }); await pointerClick( 'document.getElementById("db-save-button")', "open DB playlist save modal"); current = await waitFor("read-only named playlist save modal", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && snapshot.dom.namedConfirmationHidden === true && snapshot.dom.activeInsideVisibleModal === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.dom.namedTitle !== "DB 재생목록 저장") { failKnown(`Unexpected save-modal title ${current.dom.namedTitle}.`); } if (current.dom.namedConfirmDisabled) { evidence.warnings.push( "Nested save confirmation was skipped because no existing definition was safely selectable."); await pointerClick( 'document.getElementById("named-playlist-cancel-button")', "close save modal without an existing definition"); current = await waitFor("save modal fixture skip close", snapshot => snapshot.dom.namedModalHidden === true && snapshot.dom.activeElement?.id === "db-save-button", { allowUiBusy: false }); } else { const namedBeforeConfirmation = { revision: current.state.namedPlaylist?.revision ?? null, lastMutationOutcome: current.state.namedPlaylist?.lastMutationOutcome ?? null, isWriteInProgress: current.state.namedPlaylist?.isWriteInProgress ?? null }; await pointerClick( 'document.getElementById("named-playlist-confirm-button")', "open save confirmation without confirming write"); current = await waitFor("nested save confirmation", snapshot => snapshot.dom.namedModalHidden === false && snapshot.dom.namedConfirmationHidden === false && snapshot.dom.namedConfirmationMessage === "저장하겠습니까?" && snapshot.dom.activeElement?.id === "named-playlist-confirmation-yes", { allowUiBusy: false }); await pressKey("Escape", "nested save confirmation Escape no-op"); await sleep(100); current = await readSnapshot(); assertSafety(current, "nested confirmation Escape", false); if (current.dom.namedConfirmationHidden || current.dom.activeElement?.id !== "named-playlist-confirmation-yes") { failKnown("Escape changed the nested save confirmation."); } await pressKey("Tab", "move nested confirmation focus to No"); current = await readSnapshot(); if (current.dom.activeElement?.id !== "named-playlist-confirmation-no") { failKnown("Tab did not move nested confirmation focus from Yes to No."); } await pressKey("Tab", "wrap nested confirmation focus to Yes"); current = await readSnapshot(); if (current.dom.activeElement?.id !== "named-playlist-confirmation-yes") { failKnown("Tab did not wrap nested confirmation focus back to Yes."); } await pointerClick( 'document.getElementById("named-playlist-confirmation-no")', "decline save confirmation"); current = await waitFor("declined save confirmation close", snapshot => snapshot.dom.namedModalHidden === true && snapshot.dom.namedConfirmationHidden === true && snapshot.dom.activeElement?.id === "db-save-button", { allowUiBusy: false }); const namedAfterConfirmation = { revision: current.state.namedPlaylist?.revision ?? null, lastMutationOutcome: current.state.namedPlaylist?.lastMutationOutcome ?? null, isWriteInProgress: current.state.namedPlaylist?.isWriteInProgress ?? null }; if (JSON.stringify(namedAfterConfirmation) !== JSON.stringify(namedBeforeConfirmation)) { failKnown("Declining the save confirmation changed named-playlist mutation state."); } } await checkpoint("named-playlist-save-confirmation-no-write"); } current = await checkpoint("final", { expectedPlaylistOrder: [rowC, rowA, rowB] }); assertExactArray( current.state.playlist.map(row => row.rowId), [rowC, rowA, rowB], "final playlist order"); assertExactArray(selectedPlaylistIds(current), [rowB], "final deletion selection"); if (!current.dom.namedModalHidden || !current.dom.namedConfirmationHidden) { failKnown("A named-playlist modal remained open at final state."); } if (readOnlyProfile) { current = await verifyVisualCardPresentation(); } evidence.finalSnapshot = current; } async function executeDryRunPlayoutSequence() { if (!dryRunPlayoutProfile) return; await requestPhysicalBackgroundShortcuts(); let current = await readSnapshot(); assertSafety(current, "DryRun playout sequence preflight", false); if (current.state.playlist.length < 3 || current.state.playlist.some(row => row.isEnabled !== true)) { failKnown("DryRun NEXT validation requires three enabled in-memory rows."); } const originalSelectedIds = selectedPlaylistIds(current); const originalActiveId = activePlaylistId(current); if (originalSelectedIds.length !== 1 || !originalActiveId) { failKnown("DryRun playout preflight lacks the exact legacy selection split."); } // The preceding interaction sequence leaves current/current/after-hours rows. // Physically move the session-dependent after-hours row between the two current // rows, TAKE IN the first current row, then disable the after-hours successor // with Space. NEXT must skip it and reach the second current row. Restore the // original order after TAKE OUT so the outer evidence seal remains deterministic. const originalOrder = current.state.playlist.map(row => row.rowId); if (current.state.playlist[0].subtype !== "현재가" || current.state.playlist[1].subtype !== "현재가" || current.state.playlist[2].subtype !== "시간외단일가") { failKnown("DryRun NEXT fixture is not current/current/after-hours."); } const selectedEntryId = originalOrder[0]; const nextEntryId = originalOrder[1]; const skippedEntryId = originalOrder[2]; const stagedEntryId = skippedEntryId; const playlistCount = current.state.playlist.length; evidence.dryRunPlayout.stagedEntryId = stagedEntryId; evidence.dryRunPlayout.selectedEntryId = selectedEntryId; evidence.dryRunPlayout.skippedEntryId = skippedEntryId; evidence.dryRunPlayout.nextEntryId = nextEntryId; evidence.dryRunPlayout.playlistCountBeforeSelection = playlistCount; await dragPlaylistRow( skippedEntryId, selectedEntryId, "place after-hours row before the known NEXT current row"); current = await waitFor("DryRun session-dependent row staging", snapshot => JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === JSON.stringify([selectedEntryId, skippedEntryId, nextEntryId]), { allowUiBusy: false }); // Move the F8 candidate away from the target first. If the following rapid row // click were dropped, TAKE IN would remain on this distinct staged row and the // selected-row assertion below would fail deterministically. await pointerClickPlaylistRow( stagedEntryId, "stage alternate playlist row before rapid DryRun TAKE IN"); current = await waitFor("alternate DryRun row staging", snapshot => activePlaylistId(snapshot) === stagedEntryId && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([stagedEntryId]), { allowUiBusy: false }); // Keep the real user timing: authorize F8 from the exact known IDLE snapshot, // release the row click, and immediately dispatch the physical key without a // state read in between. The one-shot authorization cannot be reused if either // key event becomes ambiguous. const takeInIntentBaseline = current.safety.outboundMessageCount; const f8Authorization = authorizeDryRunPlayoutShortcut( "F8", current, "rapid DryRun F8"); await pointerClickPlaylistRow( selectedEntryId, "rapid select playlist row before DryRun TAKE IN"); await pressKey( "F8", "rapid DryRun F8 after row selection", 0, { dryRunPlayoutShortcutAuthorization: f8Authorization }); current = await waitFor("rapid selected-row DryRun TAKE IN", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === selectedEntryId && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true, { allowUiBusy: true, allowActiveDryRun: true }); if (current.state.playout.nextKind !== "playlistNext") { failKnown("DryRun TAKE IN did not expose playlist NEXT from the selected row."); } const takeInIntents = outboundMessagesAfter(current, takeInIntentBaseline) .filter(message => message.type === "take-in"); if (takeInIntents.length !== 1 || outboundMessagesAfter(current, takeInIntentBaseline).at(-1)?.type !== "take-in") { failUnknown("DryRun F8 did not emit exactly one terminal TAKE IN intent."); } evidence.dryRunPlayout.shortcutParity = { f8IntentCount: 1, escapeIntentCount: 0, finalPhase: "pending" }; const selectableCut = current.state.cutRows.find(row => row.isSeparator !== true && row.isSelected !== true); if (!selectableCut) { failKnown("No alternate cut exists for PROGRAM selection validation."); } const cutExpression = `document.querySelector('#cut-list .cut-row[data-physical-index="${selectableCut.physicalIndex}"]')`; await pointerClick( cutExpression, "select alternate left cut during PROGRAM", 0, { allowActiveDryRun: true }); current = await waitFor("PROGRAM left cut selection", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playlist.length === playlistCount && snapshot.state.cutRows.some(row => row.physicalIndex === selectableCut.physicalIndex && row.isSelected === true && row.isFocused === true), { allowUiBusy: false, allowActiveDryRun: true }); evidence.dryRunPlayout.programCutSelection = selectableCut.physicalIndex; const dragCut = current.state.cutRows.find(row => row.isSeparator !== true && row.physicalIndex !== selectableCut.physicalIndex); if (!dragCut) { failKnown("No distinct cut exists for PROGRAM drag tail-append validation."); } const programDragIntentBaseline = current.safety.outboundMessageCount; current = await dragCutsToPlaylist( dragCut.physicalIndex, 0, 1, "PROGRAM captured-pointer safe-tail drag", { allowActiveDryRun: true }); const programDragAppendedEntryId = current.state.playlist.at(-1)?.rowId; if (!programDragAppendedEntryId || current.state.playout.phase !== "program" || current.state.playout.currentEntryId !== selectedEntryId || current.state.playlist.length !== playlistCount + 1) { failKnown("PROGRAM drag did not append one unique row after the on-air prefix."); } assertExactOutboundTypes( current, programDragIntentBaseline, ["cut-pointer-down", "drop-selected-cuts"], "PROGRAM captured-pointer tail append"); await pointerClickPlaylistRow( programDragAppendedEntryId, "select PROGRAM drag-appended future tail"); current = await waitFor("PROGRAM drag-appended tail selection", snapshot => snapshot.state.playout.phase === "program" && activePlaylistId(snapshot) === programDragAppendedEntryId && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([programDragAppendedEntryId]), { allowUiBusy: false, allowActiveDryRun: true }); const programDragDeleteBaseline = current.safety.outboundMessageCount; await pointerClick( 'document.getElementById("playlist-delete-selected")', "delete PROGRAM drag-appended future tail", 0, { allowActiveDryRun: true }); current = await waitFor("PROGRAM drag-appended tail cleanup", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === selectedEntryId && snapshot.state.playlist.length === playlistCount && !snapshot.state.playlist.some(row => row.rowId === programDragAppendedEntryId), { allowUiBusy: false, allowActiveDryRun: true }); assertExactOutboundTypes( current, programDragDeleteBaseline, ["delete-selected-playlist-rows"], "PROGRAM drag-appended tail cleanup"); evidence.dryRunPlayout.programDragParity = { cutPhysicalIndex: dragCut.physicalIndex, appendedEntryId: programDragAppendedEntryId, appendIntentCount: 1, cleanupDeleteIntentCount: 1, onAirEntryPreserved: true }; await sleep(legacyCutDoubleClickWindowMilliseconds + 100); await pointerClick( cutExpression, "restore alternate cut before PROGRAM double-click", 0, { allowActiveDryRun: true }); current = await waitFor("PROGRAM alternate cut restored", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.cutRows.some(row => row.physicalIndex === selectableCut.physicalIndex && row.isSelected === true && row.isFocused === true), { allowUiBusy: false, allowActiveDryRun: true }); await pointerClick( cutExpression, "PROGRAM safe-tail double-click first press", 0, { allowActiveDryRun: true }); await pointerClick( cutExpression, "PROGRAM safe-tail double-click second press", 0, { allowActiveDryRun: true }); current = await waitFor("PROGRAM safe-tail double-click append", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === selectedEntryId && snapshot.state.playlist.length === playlistCount + 1 && snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true, { allowUiBusy: true, allowActiveDryRun: true }); assertSafety(current, "PROGRAM safe-tail double-click", false, false, true); const recentProgramCutMessages = current.safety.outboundMessages .filter(message => message.type === "cut-pointer-down") .slice(-3); if (current.state.playlist.length !== playlistCount + 1 || recentProgramCutMessages.length !== 3 || recentProgramCutMessages.some(message => message.allowAppend !== true)) { failKnown("PROGRAM cut selection did not preserve safe tail-append authority."); } evidence.dryRunPlayout.playlistCountAfterDoubleClick = current.state.playlist.length; evidence.dryRunPlayout.tailAppendedEntryId = current.state.playlist[current.state.playlist.length - 1].rowId; const futureRowIds = current.state.playlist .slice(current.state.playout.currentCueIndexZeroBased + 1) .map(row => row.rowId); if (futureRowIds.length < 3 || futureRowIds[0] !== skippedEntryId || futureRowIds[1] !== nextEntryId || futureRowIds[2] !== evidence.dryRunPlayout.tailAppendedEntryId) { failKnown("PROGRAM enabled-state validation lacks the exact future-row tail."); } let enableIntentBaseline = current.safety.outboundMessageCount; await pointerClick( 'document.getElementById("playlist-enable-all")', "PROGRAM clear every enabled row", 0, { allowActiveDryRun: true }); current = await waitFor("PROGRAM all rows disabled state", snapshot => { return snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === selectedEntryId && snapshot.state.playlist.every(row => row.isEnabled === false) && snapshot.state.playout.nextKind === "endOfPlaylist" && snapshot.dom.playlistEnableAll.disabled === false && snapshot.dom.playlistEnableAll.checked === false && snapshot.dom.playlistEnableAll.indeterminate === false && snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true; }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); let enableIntents = assertExactOutboundTypes( current, enableIntentBaseline, ["set-all-playlist-enabled"], "PROGRAM clear every row"); if (enableIntents[0].enabled !== false) { failKnown("PROGRAM clear-all did not carry enabled=false."); } enableIntentBaseline = current.safety.outboundMessageCount; await pointerClick( 'document.getElementById("playlist-enable-all")', "PROGRAM restore every enabled row", 0, { allowActiveDryRun: true }); current = await waitFor("PROGRAM all rows enabled state", snapshot => { return snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === selectedEntryId && snapshot.state.playlist.every(row => row.isEnabled === true) && snapshot.state.playout.nextKind === "playlistNext" && snapshot.dom.playlistEnableAll.disabled === false && snapshot.dom.playlistEnableAll.checked === true && snapshot.dom.playlistEnableAll.indeterminate === false && snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true; }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); enableIntents = assertExactOutboundTypes( current, enableIntentBaseline, ["set-all-playlist-enabled"], "PROGRAM restore every row"); if (enableIntents[0].enabled !== true) { failKnown("PROGRAM restore-all did not carry enabled=true."); } // Original MainForm ignored a mouse press in the checkbox column during // PROGRAM, but the same row could be made active and toggled with Space. // Dispatch the blocked pointer path first and prove that no per-row enabled // intent escapes before using the focused row's real Space key event. enableIntentBaseline = current.safety.outboundMessageCount; await pointerClickDisabledPlaylistCheckbox( skippedEntryId, "PROGRAM disabled future-row mouse checkbox"); current = await waitFor("PROGRAM disabled checkbox focus only", snapshot => { const stateRow = snapshot.state.playlist.find(row => row.rowId === skippedEntryId); const domRow = snapshot.dom.playlist.find(row => row.rowId === skippedEntryId); return snapshot.state.playout.phase === "program" && stateRow?.isEnabled === true && stateRow?.isActive === true && domRow?.enabled === true && domRow?.checkboxAriaDisabled === true && domRow?.focused === true && snapshot.dom.activeElement?.playlistRowId === skippedEntryId && snapshot.state.playout.nextKind === "playlistNext" && snapshot.state.isBusy !== true; }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); const disabledCheckboxMessages = outboundMessagesAfter( current, enableIntentBaseline); if (disabledCheckboxMessages.some(message => new Set([ "set-playlist-enabled", "set-all-playlist-enabled", "toggle-active-playlist-enabled" ]).has(message.type))) { failKnown("PROGRAM mouse checkbox emitted an enabled-state mutation intent."); } enableIntentBaseline = current.safety.outboundMessageCount; await pressKey("Space", "PROGRAM future-row Space enabled toggle"); current = await waitFor("PROGRAM future-row Space disabled state", snapshot => { const row = snapshot.state.playlist.find(candidate => candidate.rowId === skippedEntryId); return snapshot.state.playout.phase === "program" && row?.isEnabled === false && row?.isActive === true && snapshot.state.playout.nextKind === "playlistNext" && snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true; }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); assertExactOutboundTypes( current, enableIntentBaseline, ["toggle-active-playlist-enabled"], "PROGRAM future-row Space toggle"); evidence.dryRunPlayout.programEnableParity = { currentEntryId: selectedEntryId, skippedEntryId, nextEntryId, futureRowCount: futureRowIds.length, mouseCheckboxDisabled: true, setAllIntentCount: 2, setAllAppliesEveryRow: true, toggleActiveIntentCount: 1, endOfPlaylistObserved: true, playlistNextRestored: true }; await pointerClick( 'document.getElementById("playout-next")', "DryRun NEXT skips the Space-disabled future row", 0, { allowActiveDryRun: true }); const revisionBeforeNext = current.state.revision; current = await waitFor("DryRun NEXT enabled successor after skipped row", snapshot => { if (snapshot.state.revision > revisionBeforeNext && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true && snapshot.state.statusKind === "error") { failKnown( `DryRun NEXT was safely rejected before playout: ${snapshot.state.statusMessage}`); } return snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === nextEntryId && snapshot.state.playlist.find(row => row.rowId === skippedEntryId) ?.isEnabled === false && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true; }, { allowUiBusy: true, allowActiveDryRun: true }); const takeOutIntentBaseline = current.safety.outboundMessageCount; const escapeAuthorization = authorizeDryRunPlayoutShortcut( "Escape", current, "DryRun Escape TAKE OUT"); await pressKey( "Escape", "DryRun Escape TAKE OUT after NEXT", 0, { dryRunPlayoutShortcutAuthorization: escapeAuthorization }); current = await waitFor("DryRun TAKE OUT IDLE", snapshot => snapshot.state.playout.phase === "idle" && snapshot.state.playout.currentEntryId == null && snapshot.state.playout.preparedCode == null && snapshot.state.playout.onAirCode == null && snapshot.state.playout.outcomeUnknown !== true && snapshot.state.playout.isPlayCompletionPending !== true && snapshot.state.playout.isTakeOutCompletionPending !== true && snapshot.state.playout.refreshActive !== true && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true, { allowUiBusy: true, allowActiveDryRun: true }); const takeOutIntents = outboundMessagesAfter(current, takeOutIntentBaseline) .filter(message => message.type === "take-out"); if (takeOutIntents.length !== 1 || outboundMessagesAfter(current, takeOutIntentBaseline).at(-1)?.type !== "take-out") { failUnknown("DryRun Escape did not emit exactly one terminal TAKE OUT intent."); } evidence.dryRunPlayout.shortcutParity.escapeIntentCount = 1; evidence.dryRunPlayout.shortcutParity.finalPhase = current.state.playout.phase; await dragPlaylistRow( skippedEntryId, nextEntryId, "restore original playlist order after DryRun TAKE OUT"); current = await waitFor("restore original playlist order after DryRun", snapshot => snapshot.state.playout.phase === "idle" && JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === JSON.stringify([ ...originalOrder, evidence.dryRunPlayout.tailAppendedEntryId ]), { allowUiBusy: false }); await pointerClickPlaylistRow( originalSelectedIds[0], "restore deletion selection after DryRun playout"); await pointerClick(`(() => { const row = Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row")) .find(candidate => candidate.dataset.rowId === ${JSON.stringify(originalActiveId)}); return row?.querySelector(".playlist-row-header") || null; })()`, "restore active playlist row after DryRun playout"); current = await waitFor("restore playlist state after DryRun playout", snapshot => snapshot.state.playout.phase === "idle" && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify(originalSelectedIds) && activePlaylistId(snapshot) === originalActiveId, { allowUiBusy: false }); evidence.dryRunPlayout.result = "PASS"; await checkpoint("dry-run-playout-selection-next", { stagedEntryId, selectedEntryId, skippedEntryId, nextEntryId, programCutPhysicalIndex: selectableCut.physicalIndex, playlistCount }); } function recordFullUiDbCheck(collection, name, details = null) { if (!evidence.fullUiDb) return; evidence.fullUiDb[collection].push({ name, at: new Date().toISOString(), details }); } function namedDefinitionExpression(title) { return `Array.from(document.querySelectorAll( "#named-playlist-definitions button[data-named-playlist-definition-id]")) .find(button => button.textContent === ${JSON.stringify(title)})`; } function playlistContentProjection(snapshot) { return snapshot.state.playlist.map(row => ({ isEnabled: row.isEnabled, marketText: row.marketText, stockName: row.stockName, graphicType: row.graphicType, subtype: row.subtype, pageText: row.pageText })); } function namedPlaylistCommandSequence(snapshot) { const sequence = snapshot?.state?.commandResult?.sequence; return Number.isSafeInteger(sequence) ? sequence : 0; } function completedNamedPlaylistRefreshAfter(snapshot, baselineSequence) { const result = snapshot?.state?.commandResult; return result?.command === "refresh-named-playlists" && result.succeeded === true && Number.isSafeInteger(result.sequence) && result.sequence > baselineSequence && snapshot.state.namedPlaylist?.listFreshness === "fresh"; } async function requestNamedPlaylistLoadDoubleClick(current, title, label) { assertSafety(current, `${label} preflight`, false); if (evidence.windowsInput.exchanges.length !== 1 || configuration.windowsInputExchangeCount !== 2 || current.dom.namedModalHidden !== false || current.dom.namedConfirmationHidden !== true || current.dom.namedTitle !== "DB 재생목록 불러오기" || current.state.namedPlaylist?.listFreshness !== "fresh") { failKnown(`${label} is outside the exact fresh PList load-modal state.`); } const matches = current.state.namedPlaylist.definitions .filter(definition => definition.title === title); if (matches.length !== 1 || typeof matches[0].definitionId !== "string" || matches[0].definitionId.length === 0 || matches[0].definitionId.length > 256 || /[\u0000-\u001f\u007f]/u.test(matches[0].definitionId)) { failKnown(`${label} does not have one safe opaque saved-list identity.`); } const definitionId = matches[0].definitionId; if (matches[0].isSelected === true || current.state.namedPlaylist.selectedDefinitionId === definitionId || current.state.namedPlaylist.selectedTitle === title) { failKnown(`${label} must start on the freshly read, unselected saved row.`); } const beforeContent = playlistContentProjection(current); if (beforeContent.length !== 3) { failKnown(`${label} requires the exact three-row in-memory fixture.`); } const beforeApprovedWriteCount = current.safety.approvedDatabaseWriteMessages.length; const outboundSequence = current.safety.outboundMessages.length; const point = await elementGeometry(namedDefinitionExpression(title), label); await sleep(100); const guarded = await readSnapshot(); const guardedPoint = await elementGeometry( namedDefinitionExpression(title), `${label} guarded target`); const geometryStable = ["x", "y", "left", "top", "right", "bottom"] .every(key => Math.abs(point[key] - guardedPoint[key]) <= 0.5); if (!geometryStable || guarded.dom.namedModalHidden !== false || guarded.dom.namedConfirmationHidden !== true || guarded.state.namedPlaylist?.definitions ?.filter(definition => definition.title === title && definition.definitionId === definitionId && definition.isSelected !== true).length !== 1 || guarded.state.namedPlaylist?.selectedDefinitionId === definitionId || guarded.state.namedPlaylist?.selectedTitle === title || JSON.stringify(playlistContentProjection(guarded)) !== JSON.stringify(beforeContent)) { failKnown(`${label} geometry or playlist content changed before publication.`); } const token = randomBytes(16).toString("hex").toUpperCase(); const request = { schemaVersion: 1, exchangeIndex: 2, token, targetUrl: exactTargetUrl, operation: "named-playlist-load-double-click", definitionId, definitionTitle: title, point: { x: point.x, y: point.y }, viewport: { ...guarded.dom.viewport }, devicePixelRatio: guarded.devicePixelRatio, expectedPlaylistContent: beforeContent, mouseDownCalls: 2, mouseUpCalls: 2, doubleClickIntervalMilliseconds: 100, inputRetryCount: 0 }; await publishWindowsInputRequest(request, label, { mouseDownCalls: 2, mouseUpCalls: 2, doubleClickIntervalMilliseconds: 100 }); const completed = await waitFor(label, snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true && snapshot.dom.namedConfirmationHidden === true && snapshot.state.namedPlaylist?.selectedTitle === title && snapshot.state.namedPlaylist?.rowCount === 3 && snapshot.state.commandResult?.command === "load-named-playlist-by-id" && snapshot.state.commandResult?.targetId === definitionId && snapshot.state.commandResult?.succeeded === true && JSON.stringify(playlistContentProjection(snapshot)) === JSON.stringify(beforeContent), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const outbound = outboundMessagesAfter(completed, outboundSequence); const loads = outbound.filter(message => message.type === "load-named-playlist-by-id"); const selections = outbound.filter(message => message.type === "select-named-playlist"); assertExactArray( outbound.map(message => message.type), ["select-named-playlist", "load-named-playlist-by-id"], `${label} outbound intents`); if (loads.length !== 1 || loads[0].definitionId !== definitionId || selections.length !== 1 || selections[0].definitionId !== definitionId || completed.safety.approvedDatabaseWriteMessages.length !== beforeApprovedWriteCount) { failKnown(`${label} did not select then load the clicked row with no database write.`); } await checkpoint("windows-sendinput-named-playlist-load-double-click", { definitionId, title, rowCount: beforeContent.length, startedUnselected: true, selectionIntentCount: selections.length, loadIntentCount: loads.length, contentRestored: true }); return completed; } function namedPlaylistDeleteWriteCount(snapshot) { return (snapshot?.safety?.approvedDatabaseWriteMessages || []) .filter(message => message.type === "delete-selected-named-playlist").length; } async function waitForNamedPlaylistDeleteDispatch( label, baselineWriteCount, options = {}) { const deadline = Date.now() + 5_000; let lastSnapshot = null; while (Date.now() < deadline) { lastSnapshot = await readSnapshot(); assertSafety( lastSnapshot, label, true, false, options.allowActiveDryRun === true); if (options.recovery === true) { assertNamedPlaylistRecoverySafe( lastSnapshot, label, baselineWriteCount, options.baselineCommandSequence ?? null); } const writeCount = namedPlaylistDeleteWriteCount(lastSnapshot) - baselineWriteCount; if (writeCount > 1 || writeCount < 0) { failUnknown(`${label} emitted an invalid delete-intent count; no action is retried.`); } if (writeCount === 1) return lastSnapshot; await sleep(50); } failKnown(`${label} produced no delete intent, so no database delete was started. ` + `Last snapshot: ${JSON.stringify(lastSnapshot)}`); } function assertNamedPlaylistRecoverySafe( snapshot, label, baselineWriteCount = null, baselineCommandSequence = null) { const named = snapshot?.state?.namedPlaylist; if (!named) failKnown(`Named-playlist state is missing at ${label}.`); if (named.isWriteQuarantined === true || named.lastMutationOutcome === "outcomeUnknown" || named.lastMutationOutcome === "quarantined") { failUnknown(`Named-playlist writes are quarantined or unknown at ${label}; no action is retried.`); } if (baselineWriteCount !== null) { const writeCount = namedPlaylistDeleteWriteCount(snapshot) - baselineWriteCount; if (writeCount < 0 || writeCount > 1) { failUnknown(`Named-playlist recovery emitted an invalid delete count at ${label}; no action is retried.`); } const commandResult = snapshot.state.commandResult; if (writeCount === 1 && baselineCommandSequence !== null && Number.isSafeInteger(commandResult?.sequence) && commandResult.sequence > baselineCommandSequence && commandResult.command === "delete-selected-named-playlist" && commandResult.succeeded === false) { failKnown(`Named-playlist recovery delete was rejected at ${label}; no action is retried.`); } } } async function waitForNamedPlaylistRecovery(label, predicate, options = {}) { return await waitFor(label, snapshot => { assertNamedPlaylistRecoverySafe( snapshot, label, options.baselineWriteCount ?? null, options.baselineCommandSequence ?? null); return predicate(snapshot); }, options); } async function recoverNamedPlaylistFixture() { const title = configuration.recoverNamedPlaylistTitle; const recovery = evidence.fullUiDb?.namedPlaylistRecovery; if (!title || !recovery) return; let current = await readSnapshot(); assertNamedPlaylistRecoverySafe(current, "PList recovery preflight"); const initialWriteCount = namedPlaylistDeleteWriteCount(current); if (initialWriteCount !== 0) { failUnknown("PList recovery found a prior delete intent in this one-shot run."); } const recoveryRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick( 'document.getElementById("db-load-button")', "PList recovery fresh list open"); current = await waitForNamedPlaylistRecovery( "PList recovery fresh list", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && snapshot.dom.namedConfirmationHidden === true && completedNamedPlaylistRefreshAfter(snapshot, recoveryRefreshBaseline), { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.namedPlaylist.isListTruncated !== false) { failKnown("PList recovery cannot prove an exact target against a truncated fresh list."); } recovery.freshListVerified = true; const exactMatches = current.state.namedPlaylist.definitions .filter(definition => definition.title === title); recovery.exactMatchCount = exactMatches.length; if (exactMatches.length > 1) { failKnown("PList recovery target is duplicated in the fresh DB list; no delete was attempted."); } if (current.state.namedPlaylist.canMutate !== true) { failKnown("PList recovery requires a writable fresh named-playlist list."); } if (exactMatches.length === 0) { if (namedPlaylistDeleteWriteCount(current) !== initialWriteCount) { failUnknown("PList recovery observed an unexpected delete intent before absence was sealed."); } recovery.result = "alreadyAbsent"; recovery.writeCount = 0; recovery.exactAbsenceVerified = true; recordFullUiDbCheck("databaseChecks", "PList recovery already absent", { targetTitle: title, writeCount: 0 }); await pointerClick( 'document.getElementById("named-playlist-cancel-button")', "PList recovery already-absent close"); current = await waitForNamedPlaylistRecovery( "PList recovery already-absent close", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true, { allowUiBusy: false, baselineWriteCount: initialWriteCount }); if (namedPlaylistDeleteWriteCount(current) !== initialWriteCount) { failUnknown("PList recovery emitted a delete after sealing an already-absent target."); } return; } const target = exactMatches[0]; await pointerClick(namedDefinitionExpression(title), "PList recovery exact target select"); current = await waitForNamedPlaylistRecovery( "PList recovery exact target selection", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && snapshot.state.namedPlaylist.selectedDefinitionId === target.definitionId && snapshot.state.namedPlaylist.selectedTitle === title && snapshot.state.namedPlaylist.canDelete === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const beforeDeleteWriteCount = namedPlaylistDeleteWriteCount(current); const beforeDeleteCommandSequence = Number.isSafeInteger( current.state.commandResult?.sequence) ? current.state.commandResult.sequence : 0; if (beforeDeleteWriteCount !== initialWriteCount) { failUnknown("PList recovery observed an unexpected delete before confirmation."); } await pointerClick( 'document.getElementById("named-playlist-delete-button")', "PList recovery open existing delete confirmation"); await waitForNamedPlaylistRecovery( "PList recovery existing delete confirmation", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && snapshot.dom.namedConfirmationHidden === false, { allowUiBusy: false, baselineWriteCount: beforeDeleteWriteCount, baselineCommandSequence: beforeDeleteCommandSequence }); await pointerClick( 'document.getElementById("named-playlist-confirmation-yes")', "PList recovery confirm exact delete once"); await waitForNamedPlaylistDeleteDispatch( "PList recovery physical delete confirmation", beforeDeleteWriteCount, { recovery: true, baselineCommandSequence: beforeDeleteCommandSequence }); current = await waitForNamedPlaylistRecovery( "PList recovery committed exact absence", snapshot => { const outcome = snapshot.state.namedPlaylist.lastMutationOutcome; const writeCount = namedPlaylistDeleteWriteCount(snapshot) - beforeDeleteWriteCount; const remaining = snapshot.state.namedPlaylist.definitions .filter(definition => definition.title === title); return snapshot.state.isBusy === false && snapshot.state.namedPlaylist.isWriteInProgress !== true && snapshot.state.namedPlaylist.isListTruncated === false && snapshot.dom.namedConfirmationHidden === true && writeCount === 1 && remaining.length === 0 && (outcome === "committedFresh" || outcome === "committedOptimistic"); }, { timeoutMilliseconds: 60_000, allowUiBusy: true, baselineWriteCount: beforeDeleteWriteCount, baselineCommandSequence: beforeDeleteCommandSequence }); const recoveryWriteCount = namedPlaylistDeleteWriteCount(current) - beforeDeleteWriteCount; const exactAbsence = !current.state.namedPlaylist.definitions .some(definition => definition.title === title); if (recoveryWriteCount !== 1 || !exactAbsence) { failUnknown("PList recovery did not seal one exact delete and absence; no action is retried."); } recovery.result = "deleted"; recovery.writeCount = recoveryWriteCount; recovery.mutationOutcome = current.state.namedPlaylist.lastMutationOutcome; recovery.exactAbsenceVerified = true; recordFullUiDbCheck("databaseChecks", "PList recovery exact delete/absence", { targetTitle: title, writeCount: recoveryWriteCount, mutationOutcome: recovery.mutationOutcome }); await pointerClick( 'document.getElementById("named-playlist-cancel-button")', "PList recovery close after exact delete"); await waitForNamedPlaylistRecovery( "PList recovery close after exact delete", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true, { allowUiBusy: false, baselineWriteCount: beforeDeleteWriteCount, baselineCommandSequence: beforeDeleteCommandSequence }); } async function executeNamedPlaylistCrud() { const title = evidence.fixture.namedPlaylistTitle; if (title === configuration.recoverNamedPlaylistTitle) { failKnown("The generated PList CRUD fixture collided with the explicit recovery target."); } let current = await readSnapshot(); if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { failKnown("The generated PList fixture already exists before mutation."); } const saveRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick('document.getElementById("db-save-button")', "PList open save dialog"); current = await waitFor("PList fresh definition read", snapshot => snapshot.dom.namedModalHidden === false && snapshot.state.isBusy === false && completedNamedPlaylistRefreshAfter(snapshot, saveRefreshBaseline), { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { failKnown("The generated PList fixture appeared during the preflight read."); } await replaceText('document.getElementById("named-playlist-new-title")', title, "PList unique title"); await pointerClick('document.getElementById("named-playlist-create-button")', "PList create definition"); current = await waitFor("PList definition create commit", snapshot => snapshot.state.isBusy === false && snapshot.state.namedPlaylist.definitions.some(row => row.title === title) && snapshot.state.namedPlaylist.lastMutationOutcome && snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const created = current.state.namedPlaylist.definitions.find(row => row.title === title); if (!created || current.state.namedPlaylist.selectedDefinitionId !== created.definitionId) { await pointerClick(namedDefinitionExpression(title), "PList select created definition"); current = await waitFor("PList created definition selection", snapshot => snapshot.state.isBusy === false && snapshot.state.namedPlaylist.selectedTitle === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } await pointerClick('document.getElementById("named-playlist-confirm-button")', "PList request row save"); await waitFor("PList save confirmation", snapshot => snapshot.dom.namedConfirmationHidden === false && snapshot.state.isBusy === false, { allowUiBusy: false }); await pointerClickWithNativeDialog( 'document.getElementById("named-playlist-confirmation-yes")', "PList confirm row save", false, "저장되었습니다", true); current = await waitFor("PList row save commit", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true && snapshot.state.namedPlaylist.selectedTitle === title && snapshot.state.namedPlaylist.rowCount === 3 && snapshot.state.namedPlaylist.lastMutationOutcome && snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", "PList create/save", { rowCount: current.state.namedPlaylist.rowCount }); const readbackRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick('document.getElementById("db-load-button")', "PList reopen for fresh readback"); current = await waitFor("PList fresh readback", snapshot => { const matches = snapshot.state.namedPlaylist?.definitions ?.filter(row => row.title === title) || []; return snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && completedNamedPlaylistRefreshAfter(snapshot, readbackRefreshBaseline) && matches.length === 1 && typeof matches[0].definitionId === "string" && matches[0].definitionId.length > 0 && matches[0].definitionId.length <= 256 && !/[\u0000-\u001f\u007f]/u.test(matches[0].definitionId) && matches[0].isSelected !== true && snapshot.state.namedPlaylist.selectedDefinitionId !== matches[0].definitionId && snapshot.state.namedPlaylist.selectedTitle !== title; }, { timeoutMilliseconds: 60_000, allowUiBusy: true }); current = await requestNamedPlaylistLoadDoubleClick( current, title, "PList physical saved-row double-click load"); recordFullUiDbCheck("databaseChecks", "PList physical double-click fresh readback", { rowCount: current.state.playlist.length, input: "Windows SendInput double-click", startedUnselected: true, selectionIntentCount: 1, loadIntentCount: 1, contentRestored: true }); const deleteRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick('document.getElementById("db-load-button")', "PList reopen for delete"); await waitFor("PList delete list read", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && completedNamedPlaylistRefreshAfter(snapshot, deleteRefreshBaseline) && snapshot.state.namedPlaylist.definitions.some(row => row.title === title), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(namedDefinitionExpression(title), "PList select delete target"); await waitFor("PList delete target selection", snapshot => snapshot.state.isBusy === false && snapshot.state.namedPlaylist.selectedTitle === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.getElementById("named-playlist-delete-button")', "PList open delete confirmation"); await waitFor("PList delete confirmation", snapshot => snapshot.dom.namedConfirmationHidden === false && snapshot.state.isBusy === false, { allowUiBusy: false }); const beforeDeleteWriteCount = namedPlaylistDeleteWriteCount(await readSnapshot()); await pointerClick('document.getElementById("named-playlist-confirmation-yes")', "PList confirm delete"); await waitForNamedPlaylistDeleteDispatch( "PList physical delete confirmation", beforeDeleteWriteCount); current = await waitFor("PList delete and absence", snapshot => snapshot.state.isBusy === false && !snapshot.state.namedPlaylist.definitions.some(row => row.title === title) && snapshot.state.namedPlaylist.lastMutationOutcome && snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.getElementById("named-playlist-cancel-button")', "PList close after delete"); await waitFor("PList final close", snapshot => snapshot.dom.namedModalHidden === true && snapshot.state.isBusy === false, { allowUiBusy: false }); recordFullUiDbCheck("databaseChecks", "PList delete/absence"); } async function executeProgramNamedPlaylistCrud() { if (!programPlaylistDbProfile) return; const activeOptions = { allowActiveDryRun: true }; const title = evidence.fixture.namedPlaylistTitle; let current = await readSnapshot(); assertSafety(current, "PROGRAM named-playlist preflight", false); if (typeof title !== "string" || title.length !== 26 || !/^CDX_P_[0-9]{13}_[0-9a-f]{6}$/.test(title) || current.state.playlist.length !== 3 || current.state.namedPlaylist.definitions.some(row => row.title === title)) { failKnown("PROGRAM named-playlist preflight is not the exact three-row unique fixture."); } const originalSelectedIds = selectedPlaylistIds(current); const originalActiveId = activePlaylistId(current); if (originalSelectedIds.length !== 1 || !originalActiveId) { failKnown("PROGRAM named-playlist preflight lacks the exact selection split."); } const currentEntryId = current.state.playlist[0].rowId; await pointerClickPlaylistRow( currentEntryId, "select PROGRAM named-playlist current row"); current = await waitFor("PROGRAM named-playlist current-row selection", snapshot => snapshot.state.playout.phase === "idle" && activePlaylistId(snapshot) === currentEntryId, { allowUiBusy: false }); const baselineWriteCount = current.safety.approvedDatabaseWriteMessages.length; await pointerClick( 'document.getElementById("playout-take-in")', "enter DryRun PROGRAM for named-playlist persistence"); current = await waitFor("DryRun PROGRAM named-playlist entry", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === currentEntryId && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); const saveRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick( 'document.getElementById("db-save-button")', "PROGRAM open named-playlist save dialog", 0, activeOptions); current = await waitFor("PROGRAM fresh named-playlist definition read", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === currentEntryId && snapshot.dom.namedModalHidden === false && completedNamedPlaylistRefreshAfter(snapshot, saveRefreshBaseline), { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { failKnown("PROGRAM generated named-playlist fixture already exists after fresh read."); } await replaceText( 'document.getElementById("named-playlist-new-title")', title, "PROGRAM named-playlist unique title", activeOptions); await pointerClick( 'document.getElementById("named-playlist-create-button")', "PROGRAM create named-playlist definition", 0, activeOptions); current = await waitFor("PROGRAM named-playlist create commit", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === currentEntryId && snapshot.state.namedPlaylist.definitions.filter(row => row.title === title).length === 1 && snapshot.state.namedPlaylist.lastMutationOutcome?.startsWith("committed") === true, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); const created = current.state.namedPlaylist.definitions.find(row => row.title === title); if (!created) failUnknown("PROGRAM named-playlist create returned no exact definition."); if (current.state.namedPlaylist.selectedDefinitionId !== created.definitionId) { await pointerClick( namedDefinitionExpression(title), "PROGRAM select created named-playlist definition", 0, activeOptions); current = await waitFor("PROGRAM created definition selection", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.namedPlaylist.selectedDefinitionId === created.definitionId, { allowUiBusy: false, allowActiveDryRun: true }); } await pointerClick( 'document.getElementById("named-playlist-confirm-button")', "PROGRAM request current-row save", 0, activeOptions); await waitFor("PROGRAM named-playlist save confirmation", snapshot => snapshot.state.playout.phase === "program" && snapshot.dom.namedConfirmationHidden === false, { allowUiBusy: false, allowActiveDryRun: true }); await pointerClickWithNativeDialog( 'document.getElementById("named-playlist-confirmation-yes")', "PROGRAM confirm named-playlist row save", false, "저장되었습니다", true, activeOptions); current = await waitFor("PROGRAM named-playlist save commit", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === currentEntryId && snapshot.dom.namedModalHidden === true && snapshot.state.namedPlaylist.selectedTitle === title && snapshot.state.namedPlaylist.rowCount === 3 && snapshot.state.namedPlaylist.documentFreshness === "fresh" && snapshot.state.namedPlaylist.lastMutationOutcome?.startsWith("committed") === true, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); const savedRowCount = current.state.namedPlaylist.rowCount; const readbackRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick( 'document.getElementById("db-save-button")', "PROGRAM reopen save dialog for fresh readback", 0, activeOptions); current = await waitFor("PROGRAM named-playlist fresh readback", snapshot => { const matches = snapshot.state.namedPlaylist?.definitions ?.filter(row => row.title === title) || []; return snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === currentEntryId && snapshot.dom.namedModalHidden === false && completedNamedPlaylistRefreshAfter(snapshot, readbackRefreshBaseline) && matches.length === 1 && snapshot.state.namedPlaylist.listFreshness === "fresh" && snapshot.state.namedPlaylist.documentFreshness === "none" && snapshot.state.namedPlaylist.rowCount === 0; }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); const readback = current.state.namedPlaylist.definitions.find(row => row.title === title); if (!readback || typeof readback.definitionId !== "string" || readback.definitionId.length === 0) { failKnown("PROGRAM fresh readback did not retain the created definition identity."); } await pointerClick( namedDefinitionExpression(title), "PROGRAM select fresh delete target", 0, activeOptions); await waitFor("PROGRAM fresh delete target selection", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.namedPlaylist.selectedDefinitionId === readback.definitionId, { allowUiBusy: false, allowActiveDryRun: true }); await pointerClick( 'document.getElementById("named-playlist-delete-button")', "PROGRAM open named-playlist delete confirmation", 0, activeOptions); await waitFor("PROGRAM named-playlist delete confirmation", snapshot => snapshot.state.playout.phase === "program" && snapshot.dom.namedConfirmationHidden === false, { allowUiBusy: false, allowActiveDryRun: true }); const beforeProgramDeleteWriteCount = namedPlaylistDeleteWriteCount(await readSnapshot()); await pointerClick( 'document.getElementById("named-playlist-confirmation-yes")', "PROGRAM confirm named-playlist delete", 0, activeOptions); await waitForNamedPlaylistDeleteDispatch( "PROGRAM physical named-playlist delete confirmation", beforeProgramDeleteWriteCount, activeOptions); current = await waitFor("PROGRAM named-playlist delete and absence", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === currentEntryId && snapshot.state.namedPlaylist.definitions.every(row => row.title !== title) && snapshot.state.namedPlaylist.lastMutationOutcome?.startsWith("committed") === true, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); await pointerClick( 'document.getElementById("named-playlist-cancel-button")', "PROGRAM close named-playlist dialog after delete", 0, activeOptions); current = await waitFor("PROGRAM named-playlist dialog final close", snapshot => snapshot.state.playout.phase === "program" && snapshot.dom.namedModalHidden === true && snapshot.dom.namedConfirmationHidden === true, { allowUiBusy: false, allowActiveDryRun: true }); const writes = current.safety.approvedDatabaseWriteMessages.slice(baselineWriteCount); const writeCounts = type => writes.filter(message => message.type === type).length; const createWriteCount = writeCounts("create-named-playlist"); const saveWriteCount = writeCounts("save-current-named-playlist-to"); const deleteWriteCount = writeCounts("delete-selected-named-playlist"); if (writes.length !== 3 || createWriteCount !== 1 || saveWriteCount !== 1 || deleteWriteCount !== 1) { failUnknown("PROGRAM named-playlist CRUD did not emit exactly one create/save/delete write."); } await pointerClick( 'document.getElementById("playout-take-out")', "leave DryRun PROGRAM after named-playlist CRUD", 0, activeOptions); current = await waitFor("PROGRAM named-playlist final IDLE", snapshot => snapshot.state.playout.phase === "idle" && snapshot.state.playout.currentEntryId == null && snapshot.state.playout.outcomeUnknown !== true && snapshot.state.namedPlaylist.definitions.every(row => row.title !== title) && snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true, // TAKE OUT was already sent exactly once. Permit the known DryRun PROGRAM // state while its state receipt crosses the WebView boundary; the predicate // still requires the final sealed state to be IDLE. allowActiveDryRun: true }); await pointerClickPlaylistRow( originalSelectedIds[0], "restore deletion selection after PROGRAM named-playlist CRUD"); await pointerClick(`(() => { const row = Array.from(document.querySelectorAll( "#playlist-rows .playlist-data-row")) .find(candidate => candidate.dataset.rowId === ${JSON.stringify(originalActiveId)}); return row?.querySelector(".playlist-row-header") || null; })()`, "restore active row after PROGRAM named-playlist CRUD"); current = await waitFor("restore playlist state after PROGRAM named-playlist CRUD", snapshot => snapshot.state.playout.phase === "idle" && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify(originalSelectedIds) && activePlaylistId(snapshot) === originalActiveId, { allowUiBusy: false }); evidence.programPlaylistDb = { result: "PASS", title, saveDefinitionId: created.definitionId, definitionId: readback.definitionId, currentEntryId, rowCount: savedRowCount, createWriteCount, saveWriteCount, deleteWriteCount, freshDefinitionReadbackVerified: true, exactAbsenceVerified: true, finalPhase: current.state.playout.phase }; await checkpoint("program-named-playlist-create-save-readback-delete", { title, currentEntryId, rowCount: 3 }); } const manualFinancialScreens = Object.freeze([ "revenue-composition", "growth-metrics", "sales", "operating-profit" ]); function manualScreenButtonExpression(screen) { return `document.querySelector('button[data-manual-screen="${screen}"]')`; } async function openManualFinancial(screen, label) { await ensureWorkspace("stock", `${label} stock workspace`); await pointerClick(manualScreenButtonExpression(screen), label); return await waitFor(`${label} open`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.screen === screen && snapshot.dom.manualFinancialModalHidden === false, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function closeManualFinancial(label) { await pointerClick('document.getElementById("manual-financial-exit")', label); await waitFor(`${label} complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial == null && snapshot.dom.manualFinancialModalHidden === true, { allowUiBusy: false }); } async function searchManualFinancialIdentity(screen, stockName, label) { await replaceText('document.getElementById("manual-financial-list-query")', stockName, `${label} list query`); await pointerClick('document.querySelector("button[data-manual-list-search]")', `${label} list search`); return await waitFor(`${label} list result`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.screen === screen && snapshot.state.manualFinancial.query === stockName, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function preflightManualFinancialIdentities() { const stockName = evidence.fixture.databaseStockDisplayName; for (const screen of manualFinancialScreens) { let current = await openManualFinancial(screen, `GraphE ${screen} preflight open`); current = await searchManualFinancialIdentity( screen, stockName, `GraphE ${screen} preflight`); if (current.state.manualFinancial.rows.some(row => row.stockName === stockName)) { failKnown(`GraphE ${screen} fixture stock already exists before any GraphE write.`); } await closeManualFinancial(`GraphE ${screen} preflight close`); } recordFullUiDbCheck("screenChecks", "GraphE four-screen identity preflight"); } async function fillManualFinancialRecord(screen) { if (screen === "revenue-composition") { await replaceText('document.querySelector("[data-manual-base-date]")', "2026Q3", "GraphE revenue base date"); const percentages = ["10", "20", "30", "20", "20"]; for (let index = 0; index < 5; index += 1) { await replaceText( `document.querySelector('[data-manual-revenue-label="${index}"]')`, `항목${index + 1}`, `GraphE revenue label ${index + 1}`); await replaceText( `document.querySelector('[data-manual-revenue-percentage="${index}"]')`, percentages[index], `GraphE revenue percentage ${index + 1}`); } return; } if (screen === "growth-metrics") { for (let index = 0; index < 4; index += 1) { await replaceText( `document.querySelector('[data-manual-growth-period="${index}"]')`, `2${index + 3}Q${index + 1}`, `GraphE growth period ${index + 1}`); } const series = [ "salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth" ]; for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) { for (let index = 0; index < 4; index += 1) { await replaceText( `document.querySelector('[data-manual-growth-series="${series[seriesIndex]}"]` + `[data-manual-growth-value="${index}"]')`, String(seriesIndex * 4 + index + 1), `GraphE growth value ${seriesIndex + 1}-${index + 1}`); } } return; } for (let index = 0; index < 6; index += 1) { await replaceText( `document.querySelector('[data-manual-quarter-label="${index}"]')`, `${index + 1}Q`, `GraphE ${screen} quarter ${index + 1}`); await replaceText( `document.querySelector('[data-manual-quarter-value="${index}"]')`, String((index + 1) * 10), `GraphE ${screen} value ${index + 1}`); } } function manualFinancialNonStockDraftExpression(screen) { if (screen === "revenue-composition") { return 'document.querySelector("[data-manual-base-date]")'; } if (screen === "growth-metrics") { return 'document.querySelector("[data-manual-growth-period=\\"0\\"]")'; } return 'document.querySelector("[data-manual-quarter-label=\\"0\\"]")'; } async function executeManualFinancialCrud() { const stockName = evidence.fixture.databaseStockDisplayName; await preflightManualFinancialIdentities(); for (const screen of manualFinancialScreens) { let current = await openManualFinancial(screen, `GraphE ${screen} create open`); await pointerClick('document.querySelector("button[data-manual-create]")', `GraphE ${screen} new input`); await waitFor(`GraphE ${screen} create form`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.screen === screen && snapshot.state.manualFinancial.selectedRecord == null, { allowUiBusy: false }); await replaceText('document.getElementById("manual-financial-stock-name")', stockName, `GraphE ${screen} stock name`); await pointerClick('document.querySelector("button[data-manual-stock-search]")', `GraphE ${screen} verify stock`); current = await waitFor(`GraphE ${screen} stock candidates`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.stockCandidates?.some(row => row.name === stockName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const candidates = current.state.manualFinancial.stockCandidates .filter(row => row.name === stockName); if (candidates.length !== 1) { failKnown(`GraphE ${screen} fixture stock is not unique in the master.`); } const candidateExpression = `document.querySelector('[data-manual-stock-result-id="${candidates[0].resultId}"]')`; const draftExpression = manualFinancialNonStockDraftExpression(screen); await replaceText( draftExpression, "SHOULD_CLEAR_ON_STOCK_CHANGE", `GraphE ${screen} pre-selection draft sentinel`); const beforeCandidateDoubleClick = await readSnapshot(); const beforeCandidateMessageSequence = beforeCandidateDoubleClick.safety.outboundMessageCount; if (!Number.isSafeInteger(beforeCandidateMessageSequence)) { failUnknown(`GraphE ${screen} outbound message sequence is unavailable.`); } await pointerDoubleClick( candidateExpression, `GraphE ${screen} select verified stock by slow Windows double-click`, 0, 350); current = await waitFor(`GraphE ${screen} verified stock`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.verifiedStock?.name === stockName, { allowUiBusy: false }); await sleep(350); current = await readSnapshot(); const candidateMessages = current.safety.outboundMessages.filter(message => Number.isSafeInteger(message.sequence) && message.sequence > beforeCandidateMessageSequence); if (candidateMessages.length !== 1 || candidateMessages[0].type !== "select-manual-financial-stock") { failKnown( `GraphE ${screen} candidate double-click emitted ${candidateMessages.length} ` + `outbound messages instead of one stock selection.`); } const focusedCandidateId = await evaluate( 'document.activeElement?.dataset?.manualStockResultId ?? null'); if (focusedCandidateId !== candidates[0].resultId) { failKnown(`GraphE ${screen} candidate focus was not preserved after double-click.`); } const remainingSentinel = await evaluate(`${draftExpression}?.value ?? null`); if (remainingSentinel !== "") { failKnown(`GraphE ${screen} stock change did not clear the prior create draft.`); } await fillManualFinancialRecord(screen); await pointerClickWithNativeDialog( 'document.querySelector("button[data-manual-save]")', `GraphE ${screen} save`, false, "저장되었습니다", true); current = await waitFor(`GraphE ${screen} create commit and readback`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.status === "writeCommitted" && snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName && snapshot.state.manualFinancial?.canDelete === true && snapshot.state.manualFinancial?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `GraphE ${screen} create/save`, { status: current.state.manualFinancial.status }); await closeManualFinancial(`GraphE ${screen} close after create`); await openManualFinancial(screen, `GraphE ${screen} fresh reopen`); current = await searchManualFinancialIdentity( screen, stockName, `GraphE ${screen} fresh readback`); const rows = current.state.manualFinancial.rows .filter(row => row.stockName === stockName); if (rows.length !== 1) { failKnown(`GraphE ${screen} fresh readback is not unique.`); } await replaceText('document.getElementById("manual-financial-find-query")', stockName, `GraphE ${screen} find query`); await pointerClick('document.querySelector("button[data-manual-find-direction=down]")', `GraphE ${screen} find down`); await waitFor(`GraphE ${screen} find down result`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.querySelector("button[data-manual-find-direction=up]")', `GraphE ${screen} find up`); await waitFor(`GraphE ${screen} find up result`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick('document.querySelector("button[data-manual-name-sort]")', `GraphE ${screen} toggle name sort`); await waitFor(`GraphE ${screen} sort complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.nameSortDirection === "descending", { allowUiBusy: false }); recordFullUiDbCheck("databaseChecks", `GraphE ${screen} fresh readback`, { rows: rows.length }); const actionIds = await clickAllEnabledPlaylistActions( "button[data-manual-action-id]", "manualActionId", `GraphE ${screen} action`); const beforeDoubleClick = await readSnapshot(); await pointerDoubleClick(dataButtonExpression("manualResultId", rows[0].resultId), `GraphE ${screen} row slow Windows double-click`, 0, 350); await waitFor(`GraphE ${screen} row double-click add`, snapshot => snapshot.state.playlist.length === beforeDoubleClick.state.playlist.length + 1 && snapshot.state.manualFinancial == null && snapshot.dom.manualFinancialModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("screenChecks", `GraphE ${screen} actions and row double-click`, { actionButtons: actionIds.length }); await openManualFinancial(screen, `GraphE ${screen} delete reopen`); current = await searchManualFinancialIdentity( screen, stockName, `GraphE ${screen} delete fresh search`); const deleteRows = current.state.manualFinancial.rows .filter(row => row.stockName === stockName); if (deleteRows.length !== 1) { failKnown(`GraphE ${screen} delete target is not unique after fresh reopen.`); } await pointerClick( dataButtonExpression("manualResultId", deleteRows[0].resultId), `GraphE ${screen} select delete target`); await waitFor(`GraphE ${screen} delete target loaded`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName && snapshot.state.manualFinancial?.canDelete === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClickWithNativeDialog( 'document.querySelector("button[data-manual-delete]")', `GraphE ${screen} delete`, true, "선택한 종목을 삭제하겠습니까?", true); current = await waitFor(`GraphE ${screen} delete commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.status === "writeCommitted" && snapshot.state.manualFinancial?.writesQuarantined !== true && !snapshot.state.manualFinancial.rows.some(row => row.stockName === stockName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClickWithNativeDialog( 'document.querySelector("button[data-manual-delete-all]")', `GraphE ${screen} delete-all cancel branch`, true, "모든 종목을 삭제하겠습니까?", false); recordFullUiDbCheck("databaseChecks", `GraphE ${screen} delete/absence`); await closeManualFinancial(`GraphE ${screen} close after delete`); } if (configuration.scope === "graphe") { await clearScreenValidationPlaylist(); } } async function selectMainTab(tabId, label) { const isLoaded = snapshot => snapshot.state.isBusy === false && snapshot.state.tabs?.some(tab => tab.id === tabId && tab.isActive) === true && (tabId !== "theme" || snapshot.state.theme != null) && (tabId !== "expert" || snapshot.state.expert != null); const layout = await readWorkspaceLayout(); const before = await readSnapshot(); assertSafety(before, `${label} preflight`); const alreadyActive = before.state.tabs?.some( tab => tab.id === tabId && tab.isActive) === true; const selectionRequired = !alreadyActive || layout.activeWorkspace !== "catalog" || layout.activeMarketTab !== tabId; if (selectionRequired) { await pointerClick( `document.querySelector('#market-tabs > button[data-tab-id="${tabId}"]')`, label); } const loaded = await waitFor(`${label} load`, snapshot => (!selectionRequired || (snapshot.stateCount >= before.stateCount + 2 && snapshot.busyStateCount > before.busyStateCount)) && isLoaded(snapshot), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const loadedLayout = await waitForWorkspaceLayout(`${label} workspace`, candidate => workspaceLayoutMatches(candidate, "catalog") && candidate.activeMarketTab === tabId); assertWorkspaceAndScheduleGeometry(loadedLayout, `${label} workspace`); return loaded; } async function openThemeCatalogList(label) { await selectMainTab("theme", `${label} select ThemeA tab`); await pointerClick('document.getElementById("uc4-theme-add")', `${label} open ThemeA catalog`); return await waitFor(`${label} ThemeA catalog list`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.isOpen === true && snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "list" && snapshot.state.operatorCatalog?.schemaValidated === true && snapshot.state.operatorCatalog?.writesQuarantined !== true && snapshot.dom.operatorCatalogModalHidden === false, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function closeOperatorCatalog(label, useEditorCancel = false) { await pointerClick( useEditorCancel ? 'document.getElementById("operator-catalog-cancel")' : 'document.getElementById("operator-catalog-close")', label); return await waitFor(`${label} complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.dom.operatorCatalogModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function searchOperatorCatalog(query, label) { await replaceText('document.getElementById("operator-catalog-query")', query, `${label} query`); const before = await readSnapshot(); await pointerClick('document.getElementById("operator-catalog-search")', `${label} submit`); return await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > before.state.revision && snapshot.state.operatorCatalog?.isOpen === true && snapshot.state.operatorCatalog?.mode === "list" && snapshot.state.operatorCatalog?.query === query && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function requireExactCatalogResult(query, label, expectedCount) { const current = await searchOperatorCatalog(query, label); const matches = current.state.operatorCatalog.results .filter(row => row.displayName === query); if (matches.length !== expectedCount) { failKnown(`${label} expected ${expectedCount} exact catalog result(s), ` + `received ${matches.length}.`); } return { current, matches }; } async function verifyBlankOperatorCatalogStockSearch(trigger, label) { if (!new Set(["button", "enter"]).has(trigger)) { failKnown(`${label} has an invalid blank-search trigger.`); } await replaceText( 'document.getElementById("operator-catalog-stock-query")', "", `${label} blank query`); const before = await readSnapshot(); const catalog = before.state.operatorCatalog; if (!catalog || !new Set(["create", "edit"]).has(catalog.mode) || before.dom.operatorCatalogStockQueryValue !== "" || before.dom.operatorCatalogModalHidden !== false) { failKnown(`${label} is not an open stock editor with a fully empty query.`); } const draftRowIds = (catalog.draftRows || []).map(row => row.rowId); const outboundBaseline = before.safety.outboundMessageCount; if (trigger === "button") { await pointerClick( 'document.getElementById("operator-catalog-stock-search")', `${label} blank search button`); } else { const focused = await evaluate( 'document.activeElement === document.getElementById("operator-catalog-stock-query")'); if (focused !== true) failKnown(`${label} blank query lost focus before Enter.`); await pressKey("Enter", `${label} blank search Enter`); } const searched = await waitFor(`${label} bounded all-stock results`, snapshot => { const currentCatalog = snapshot.state.operatorCatalog; const resultCount = currentCatalog?.stockResults?.length ?? 0; const currentDraftRowIds = (currentCatalog?.draftRows || []) .map(row => row.rowId); return snapshot.state.isBusy === false && snapshot.state.revision > before.state.revision && currentCatalog?.stockQuery === "" && resultCount > 1 && resultCount <= 10_000 && currentCatalog?.selectedStockResultId == null && currentCatalog?.statusKind === "information" && currentCatalog?.statusMessage === "종목 검색을 완료했습니다." && currentCatalog?.writesQuarantined !== true && snapshot.dom.operatorCatalogStockQueryValue === "" && snapshot.dom.operatorCatalogStockResultCount === resultCount && snapshot.dom.operatorCatalogStatusText.includes("종목 검색을 완료했습니다.") && currentDraftRowIds.length === draftRowIds.length && currentDraftRowIds.every((rowId, index) => rowId === draftRowIds[index]); }, { timeoutMilliseconds: 90_000, allowUiBusy: true }); const messages = assertExactOutboundTypes( searched, outboundBaseline, ["search-operator-catalog-stocks"], `${label} blank ${trigger} search`); if (messages[0].emptyStockQuery !== true) { failKnown(`${label} did not submit the exact empty stock query.`); } const resultCount = searched.state.operatorCatalog.stockResults.length; const truncated = searched.state.operatorCatalog.stockResultsAreTruncated === true; const truncationVisible = searched.dom.operatorCatalogStatusText .includes("종목 검색 결과 일부만 표시 중입니다."); if ((truncated && (resultCount !== 10_000 || !truncationVisible)) || (!truncated && truncationVisible)) { failKnown(`${label} did not display the bounded-result truncation state exactly.`); } recordFullUiDbCheck("screenChecks", `${label} bounded blank stock search`, { trigger, resultCount, maximumResultCount: 10_000, truncated, draftRowsPreserved: true }); return searched; } async function addOperatorCatalogStock( query, expectedDisplayName, label) { const before = await readSnapshot(); const beforeRows = before.state.operatorCatalog?.draftRows || []; await replaceText('document.getElementById("operator-catalog-stock-query")', query, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.getElementById("operator-catalog-stock-search")', `${label} search`); const searched = await waitFor(`${label} stock results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.operatorCatalog?.stockQuery === query && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const matches = searched.state.operatorCatalog.stockResults.filter(row => row.displayName === expectedDisplayName && row.isCompatible === true); if (matches.length !== 1) { failKnown(`${label} requires one exact compatible stock result, received ` + `${matches.length}.`); } const resultId = matches[0].resultId; await pointerClick( `document.querySelector('button[data-operator-catalog-stock-result-id="${resultId}"]')`, `${label} select`); await waitFor(`${label} selected`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.selectedStockResultId === resultId && snapshot.state.operatorCatalog?.canAddStock === true, { timeoutMilliseconds: 30_000, allowUiBusy: true }); await pointerClick('document.getElementById("operator-catalog-add-stock")', `${label} add`); const added = await waitFor(`${label} added`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.draftRows?.length === beforeRows.length + 1 && snapshot.state.operatorCatalog?.selectedStockResultId == null, { allowUiBusy: false }); const addedRows = added.state.operatorCatalog.draftRows.filter(row => !beforeRows.some(existing => existing.rowId === row.rowId)); if (addedRows.length !== 1) { failKnown(`${label} did not add exactly one new draft row.`); } return { current: added, row: addedRows[0] }; } async function addOperatorCatalogStockByDoubleClick( query, expectedDisplayName, label, interClickDelayMilliseconds = 50) { const before = await readSnapshot(); const beforeRows = before.state.operatorCatalog?.draftRows || []; await replaceText('document.getElementById("operator-catalog-stock-query")', query, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.getElementById("operator-catalog-stock-search")', `${label} search`); const searched = await waitFor(`${label} stock results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.operatorCatalog?.stockQuery === query && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const matches = searched.state.operatorCatalog.stockResults.filter(row => row.displayName === expectedDisplayName && row.isCompatible === true); if (matches.length !== 1) { failKnown(`${label} requires one exact compatible stock result, received ` + `${matches.length}.`); } const resultId = matches[0].resultId; await pointerDoubleClick( `document.querySelector('button[data-operator-catalog-stock-result-id="${resultId}"]')`, `${label} result double-click`, 0, interClickDelayMilliseconds); const added = await waitFor(`${label} double-click added`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.draftRows?.length === beforeRows.length + 1 && snapshot.state.operatorCatalog?.selectedStockResultId == null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const addedRows = added.state.operatorCatalog.draftRows.filter(row => !beforeRows.some(existing => existing.rowId === row.rowId)); if (addedRows.length !== 1) { failKnown(`${label} double-click did not add exactly one new draft row.`); } return { current: added, row: addedRows[0] }; } async function setOperatorCatalogBuyAmount(rowId, amount, label) { const expression = `document.querySelector('input[data-operator-catalog-buy-amount-row-id="${rowId}"]')`; await replaceText(expression, String(amount), label); await pointerClick('document.getElementById("operator-catalog-stock-query")', `${label} commit`); return await waitFor(`${label} state`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.draftRows?.some(row => row.rowId === rowId && row.buyAmount === amount) === true, { allowUiBusy: false }); } async function searchAndSelectMainTheme(title, expectedDisplayTitle, label) { await selectMainTab("theme", `${label} select ThemeA tab`); await replaceText('document.getElementById("theme-search")', title, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.querySelector("button[data-theme-search]")', `${label} search`); const searched = await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.theme?.query === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const matches = searched.state.theme.searchResults.filter(row => row.displayTitle === expectedDisplayTitle); if (matches.length !== 1) { failKnown(`${label} expected one exact ThemeA result, received ${matches.length}.`); } const resultId = matches[0].resultId; await pointerClick( `document.querySelector('button[data-theme-result-id="${resultId}"]')`, `${label} select`); const selected = await waitFor(`${label} preview`, snapshot => snapshot.state.isBusy === false && snapshot.state.theme?.selectedResultId === resultId && snapshot.state.theme?.selectedTheme?.displayTitle === expectedDisplayTitle, { timeoutMilliseconds: 60_000, allowUiBusy: true }); return { current: selected, resultId }; } async function assertMainThemeAbsent(title, expectedDisplayTitle, label) { await selectMainTab("theme", `${label} select ThemeA tab`); await replaceText('document.getElementById("theme-search")', title, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.querySelector("button[data-theme-search]")', `${label} search`); const current = await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.theme?.query === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.theme.searchResults.some(row => row.displayTitle === expectedDisplayTitle)) { failKnown(`${label} still returned the deleted ThemeA row.`); } return current; } async function verifyKrxThemeCreateMarketRoundTrip( title, draftRowId, label) { const before = await readSnapshot(); const catalog = before.state.operatorCatalog; if (catalog?.entity !== "theme" || catalog.mode !== "create" || catalog.newThemeMarket !== "krx" || catalog.editorName !== title || before.dom.operatorCatalogNameValue !== title || before.dom.operatorCatalogEditorMarketValue !== "krx" || !catalog.draftRows?.some(row => row.rowId === draftRowId) || !catalog.codeLabel) { failKnown(`${label} did not start from the exact named KRX draft.`); } const krxCodeBefore = catalog.codeLabel; const outboundSequenceBeforeNxt = before.safety.outboundMessageCount; await selectOptionValue( 'document.getElementById("operator-catalog-editor-market")', "nxt", `${label} switch editor market to NXT`); const nxt = await waitFor(`${label} NXT draft`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "create" && snapshot.state.operatorCatalog?.newThemeMarket === "nxt" && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.codeLabel?.length > 0 && snapshot.state.operatorCatalog?.codeLabel !== krxCodeBefore && snapshot.state.operatorCatalog?.draftRows?.length === 0 && snapshot.state.operatorCatalog?.statusKind === "warning" && snapshot.dom.operatorCatalogNameValue === title && snapshot.dom.operatorCatalogEditorMarketValue === "nxt", { timeoutMilliseconds: 60_000, allowUiBusy: true }); const nxtMessages = assertExactOutboundTypes( nxt, outboundSequenceBeforeNxt, ["change-create-theme-market"], `${label} NXT selection`); if ( nxtMessages[0]?.market !== "nxt" || nxtMessages[0]?.editorName !== title) { failKnown(`${label} NXT selection did not send one exact market-change intent.`); } const outboundSequenceBeforeKrx = nxt.safety.outboundMessageCount; await selectOptionValue( 'document.getElementById("operator-catalog-editor-market")', "krx", `${label} switch editor market back to KRX`); const restored = await waitFor(`${label} restored KRX draft`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "create" && snapshot.state.operatorCatalog?.newThemeMarket === "krx" && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.codeLabel?.length > 0 && snapshot.state.operatorCatalog?.draftRows?.length === 0 && snapshot.dom.operatorCatalogNameValue === title && snapshot.dom.operatorCatalogEditorMarketValue === "krx", { timeoutMilliseconds: 60_000, allowUiBusy: true }); const krxMessages = assertExactOutboundTypes( restored, outboundSequenceBeforeKrx, ["change-create-theme-market"], `${label} KRX selection`); if ( krxMessages[0]?.market !== "krx" || krxMessages[0]?.editorName !== title) { failKnown(`${label} KRX selection did not send one exact market-change intent.`); } recordFullUiDbCheck( "screenChecks", `${label} create-market NXT/KRX physical selection, name preservation, and draft cleanup`, { removedDraftRowId: draftRowId, namePreserved: true }); return restored; } async function createReadEditDeleteTheme( title, market, stockQuery, stockDisplayName) { const label = `ThemeA ${market.toUpperCase()} ${title}`; await openThemeCatalogList(`${label} create`); await requireExactCatalogResult(title, `${label} preflight`, 0); await selectOptionValue( 'document.getElementById("operator-catalog-new-market")', market, `${label} market`); await pointerClick('document.getElementById("operator-catalog-new")', `${label} new`); await waitFor(`${label} create editor`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "create" && snapshot.state.operatorCatalog?.newThemeMarket === market && snapshot.state.operatorCatalog?.codeLabel?.length > 0 && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await replaceText('document.getElementById("operator-catalog-name")', title, `${label} name`); await pointerClick('document.getElementById("operator-catalog-name-check")', `${label} duplicate check`); await respondToNativeDialog(`${label} duplicate check`, false, true); await waitFor(`${label} name available`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.checkedThemeName === title && snapshot.state.operatorCatalog?.themeNameAvailability === "available" && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (market === "krx") { await verifyBlankOperatorCatalogStockSearch( "button", `${label} ThemeA editor`); } let added = await addOperatorCatalogStockByDoubleClick( stockQuery, stockDisplayName, `${label} stock`, market === "krx" ? 350 : 50); if (market === "krx") { await verifyKrxThemeCreateMarketRoundTrip( title, added.row.rowId, `${label} create editor`); await pointerClick('document.getElementById("operator-catalog-name-check")', `${label} duplicate recheck after market round-trip`); await respondToNativeDialog( `${label} duplicate recheck after market round-trip`, false, true); await waitFor(`${label} name available after market round-trip`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.newThemeMarket === "krx" && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.checkedThemeName === title && snapshot.state.operatorCatalog?.themeNameAvailability === "available" && snapshot.state.operatorCatalog?.draftRows?.length === 0 && snapshot.dom.operatorCatalogNameValue === title && snapshot.dom.operatorCatalogEditorMarketValue === "krx" && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); added = await addOperatorCatalogStockByDoubleClick( stockQuery, stockDisplayName, `${label} stock re-add after market round-trip`, 350); } let expectedSavedRows = 1; let preservedRowIds = [added.row.rowId]; if (market === "krx") { const second = await addOperatorCatalogStock( "삼성전자", "삼성전자", `${label} second stock`); const third = await addOperatorCatalogStock( "SK하이닉스", "SK하이닉스", `${label} third stock`); await pointerClick( `document.querySelector('button[data-operator-catalog-move-row-id="${third.row.rowId}"]` + `[data-operator-catalog-move-direction="up"]')`, `${label} third row Up`); await waitFor(`${label} third row Up state`, snapshot => { const rows = snapshot.state.operatorCatalog?.draftRows || []; return rows[0]?.rowId === added.row.rowId && rows[1]?.rowId === third.row.rowId && rows[2]?.rowId === second.row.rowId; }, { allowUiBusy: true }); await pointerClick( `document.querySelector('button[data-operator-catalog-move-row-id="${third.row.rowId}"]` + `[data-operator-catalog-move-direction="down"]')`, `${label} third row Down`); await waitFor(`${label} third row Down state`, snapshot => { const rows = snapshot.state.operatorCatalog?.draftRows || []; return rows[0]?.rowId === added.row.rowId && rows[1]?.rowId === second.row.rowId && rows[2]?.rowId === third.row.rowId; }, { allowUiBusy: true }); await pointerClick( `document.querySelector('[data-operator-catalog-row-id="${third.row.rowId}"] strong')`, `${label} focus third row for Delete key`); await waitFor(`${label} active third row`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.activeDraftRowId === third.row.rowId, { timeoutMilliseconds: 30_000, allowUiBusy: true }); await pressKey("Delete", `${label} third row Delete key`); await waitFor(`${label} third row Delete-key state`, snapshot => { const rows = snapshot.state.operatorCatalog?.draftRows || []; return snapshot.state.isBusy === false && rows.length === 2 && rows[0]?.rowId === added.row.rowId && rows[1]?.rowId === second.row.rowId; }, { timeoutMilliseconds: 60_000, allowUiBusy: true }); expectedSavedRows = 2; preservedRowIds = [added.row.rowId, second.row.rowId]; } await pointerClick('document.getElementById("operator-catalog-delete-all")', `${label} Delete All cancel branch`); await respondToNativeDialog(`${label} Delete All cancel branch`, true, false); const afterCancel = await waitFor(`${label} draft preserved`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows && snapshot.state.operatorCatalog?.draftRows?.every((row, index) => row.rowId === preservedRowIds[index]) === true, { allowUiBusy: false }); if (afterCancel.state.operatorCatalog.canSave !== true) { failKnown(`${label} is not saveable after the cancelled Delete All branch.`); } await pointerClick('document.getElementById("operator-catalog-save")', `${label} save`); await waitFor(`${label} create commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.dom.operatorCatalogModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `${label} create/save`); await openThemeCatalogList(`${label} fresh read`); const fresh = await requireExactCatalogResult(title, `${label} fresh read`, 1); const catalogResultId = fresh.matches[0].resultId; await pointerClick( `document.querySelector('button[data-operator-catalog-result-id="${catalogResultId}"]')`, `${label} fresh read select`); const loaded = await waitFor(`${label} fresh read editor`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.selectedResultId === catalogResultId && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `${label} fresh readback`, { codeLabel: loaded.state.operatorCatalog.codeLabel, rows: loaded.state.operatorCatalog.draftRows.length }); await closeOperatorCatalog(`${label} close fresh read`, true); const expectedDisplayTitle = market === "nxt" ? `${title}(NXT)` : title; const selected = await searchAndSelectMainTheme( title, expectedDisplayTitle, `${label} UC4`); if (selected.current.state.theme.previewItems.length !== expectedSavedRows) { failKnown(`${label} UC4 preview did not contain every saved stock.`); } await pointerClick('document.getElementById("uc4-theme-edit")', `${label} UC4 edit`); await waitFor(`${label} UC4 edit loaded`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await closeOperatorCatalog(`${label} UC4 edit cancel`, true); recordFullUiDbCheck( "screenChecks", `${label} result double-click/up/down/Delete-key/select/edit`, { rows: expectedSavedRows }); await pointerClick('document.getElementById("uc4-theme-delete")', `${label} UC4 delete`); await respondToNativeDialog(`${label} UC4 delete`, true, true); await waitFor(`${label} delete commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.state.theme?.selectedTheme == null && !snapshot.state.theme?.searchResults?.some(row => row.displayTitle === expectedDisplayTitle), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await assertMainThemeAbsent(title, expectedDisplayTitle, `${label} fresh absence`); recordFullUiDbCheck("databaseChecks", `${label} delete/absence`); } async function searchAndSelectMainExpert(title, label) { await selectMainTab("expert", `${label} select EList tab`); await replaceText('document.getElementById("expert-search")', title, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.querySelector("button[data-expert-search]")', `${label} search`); const searched = await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.expert?.search?.query === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const matches = searched.state.expert.search.results.filter(row => row.expertName === title); if (matches.length !== 1) { failKnown(`${label} expected one exact EList result, received ${matches.length}.`); } const selectionId = matches[0].selectionId; await pointerClick( `document.querySelector('button[data-expert-selection-id="${selectionId}"]')`, `${label} select`); const selected = await waitFor(`${label} preview`, snapshot => snapshot.state.isBusy === false && snapshot.state.expert?.selectedExpert?.selectionId === selectionId && snapshot.state.expert?.selectedExpert?.expertName === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); return { current: selected, selectionId }; } async function assertMainExpertAbsent(title, label) { await selectMainTab("expert", `${label} select EList tab`); await replaceText('document.getElementById("expert-search")', title, `${label} query`); const beforeSearch = await readSnapshot(); await pointerClick('document.querySelector("button[data-expert-search]")', `${label} search`); const current = await waitFor(`${label} results`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeSearch.state.revision && snapshot.state.expert?.search?.query === title, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.expert.search.results.some(row => row.expertName === title)) { failKnown(`${label} still returned the deleted EList row.`); } return current; } async function createReadEditDeleteExpert(title) { const label = `EList ${title}`; await assertMainExpertAbsent(title, `${label} preflight`); await pointerClick('document.getElementById("uc6-expert-add")', `${label} UC6 add`); await waitFor(`${label} create editor`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.isOpen === true && snapshot.state.operatorCatalog?.entity === "expert" && snapshot.state.operatorCatalog?.mode === "create" && snapshot.state.operatorCatalog?.draftRows?.length === 0 && snapshot.state.operatorCatalog?.writesQuarantined !== true && snapshot.dom.operatorCatalogModalHidden === false, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await replaceText('document.getElementById("operator-catalog-name")', title, `${label} name`); await pointerClick('document.getElementById("operator-catalog-save")', `${label} create save`); await waitFor(`${label} create commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.dom.operatorCatalogModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `${label} create/save`); const created = await searchAndSelectMainExpert(title, `${label} fresh create read`); if (created.current.state.expert.preview.items.length !== 0) { failKnown(`${label} create readback unexpectedly contained recommendations.`); } recordFullUiDbCheck("databaseChecks", `${label} fresh create readback`, { rows: 0 }); await pointerClick('document.getElementById("uc6-expert-edit")', `${label} UC6 edit`); await waitFor(`${label} edit loaded`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.entity === "expert" && snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.draftRows?.length === 0 && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await verifyBlankOperatorCatalogStockSearch( "enter", `${label} existing UC6 editor`); const first = await addOperatorCatalogStockByDoubleClick( "삼성출판사", "삼성출판사", `${label} first stock`); const second = await addOperatorCatalogStock( "삼성전자", "삼성전자", `${label} second stock`); await setOperatorCatalogBuyAmount(first.row.rowId, 101, `${label} first buy amount`); await setOperatorCatalogBuyAmount(second.row.rowId, 202, `${label} second buy amount`); await pointerClick( `document.querySelector('button[data-operator-catalog-move-row-id="${second.row.rowId}"]` + `[data-operator-catalog-move-direction="up"]')`, `${label} second row Up`); await waitFor(`${label} second row Up state`, snapshot => snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === second.row.rowId && snapshot.state.operatorCatalog?.draftRows?.[1]?.rowId === first.row.rowId, { allowUiBusy: false }); await pointerClick( `document.querySelector('button[data-operator-catalog-move-row-id="${second.row.rowId}"]` + `[data-operator-catalog-move-direction="down"]')`, `${label} second row Down`); await waitFor(`${label} second row Down state`, snapshot => snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId && snapshot.state.operatorCatalog?.draftRows?.[1]?.rowId === second.row.rowId, { allowUiBusy: false }); await dragOperatorCatalogRow( second.row.rowId, first.row.rowId, "before", `${label} second row drag before first`); await dragOperatorCatalogRow( second.row.rowId, first.row.rowId, "after", `${label} second row drag after first`); await pointerClick( `document.querySelector('[data-operator-catalog-row-id="${second.row.rowId}"] strong')`, `${label} focus second row for Delete key`); await waitFor(`${label} active second row`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.activeDraftRowId === second.row.rowId, { timeoutMilliseconds: 30_000, allowUiBusy: true }); await pressKey("Delete", `${label} second row Delete key`); await waitFor(`${label} second row Delete-key state`, snapshot => snapshot.state.operatorCatalog?.draftRows?.length === 1 && snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const readded = await addOperatorCatalogStock( "삼성전자", "삼성전자", `${label} second stock re-add`); await setOperatorCatalogBuyAmount(readded.row.rowId, 202, `${label} re-added buy amount`); const readyToSave = await readSnapshot(); if (readyToSave.state.operatorCatalog?.canSave !== true || readyToSave.state.operatorCatalog?.draftRows?.length !== 2) { failKnown(`${label} edit is not saveable with exactly two recommendations.`); } await pointerClick('document.getElementById("operator-catalog-save")', `${label} edit save`); await respondToNativeDialog(`${label} edit save`, false, true); const closed = await waitFor(`${label} edit commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && snapshot.dom.operatorCatalogModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (closed.state.expert?.selectedExpert?.expertName !== title || closed.state.expert?.preview?.items?.length !== 2) { failKnown(`${label} known-committed edit did not refresh the two-row preview.`); } recordFullUiDbCheck( "screenChecks", `${label} UC6 result-double-click/add/edit/up/down/row-drag/Delete-key`); recordFullUiDbCheck("databaseChecks", `${label} edit/save`, { rows: 2 }); const fresh = await searchAndSelectMainExpert(title, `${label} fresh edit read`); const previewItems = fresh.current.state.expert.preview.items; if (previewItems.length !== 2 || previewItems[0].stockName !== "삼성출판사" || previewItems[0].buyAmount !== 101 || previewItems[1].stockName !== "삼성전자" || previewItems[1].buyAmount !== 202) { failKnown(`${label} fresh readback did not match the exact saved recommendations.`); } recordFullUiDbCheck("databaseChecks", `${label} fresh edit readback`, { rows: previewItems.length }); await pointerClick('document.getElementById("uc6-expert-delete")', `${label} UC6 delete`); await respondToNativeDialog(`${label} UC6 delete`, true, true); // Original UC6 removes the search row but deliberately retains the selected // expert and preview until the next search. The following fresh search is the // authoritative database-absence check. await waitFor(`${label} delete commit`, snapshot => snapshot.state.isBusy === false && snapshot.state.operatorCatalog == null && !snapshot.state.expert?.search?.results?.some(row => row.expertName === title), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await assertMainExpertAbsent(title, `${label} fresh absence`); recordFullUiDbCheck("databaseChecks", `${label} delete/absence`); } async function executeOperatorCatalogCrud() { await createReadEditDeleteTheme( evidence.fixture.krxThemeTitle, "krx", "삼성출판사", "삼성출판사"); await createReadEditDeleteTheme( evidence.fixture.nxtThemeTitle, "nxt", "삼성전자", "삼성전자(NXT)"); await createReadEditDeleteExpert(evidence.fixture.expertTitle); } async function captureVisibleScreenInventory(tabId) { const buttons = await evaluate(`Array.from(document.querySelectorAll("button")) .filter(button => { const rectangle = button.getBoundingClientRect(); return !button.hidden && !button.closest("[hidden]") && rectangle.width > 0 && rectangle.height > 0 && getComputedStyle(button).display !== "none" && getComputedStyle(button).visibility !== "hidden"; }) .map(button => ({ id: button.id || null, text: (button.textContent || "").trim(), disabled: button.disabled === true, data: Object.fromEntries(Object.entries(button.dataset)) }))`); evidence.fullUiDb.screenInventory.push({ tabId, at: new Date().toISOString(), buttons }); } function dataButtonExpression(datasetKey, value) { return `Array.from(document.querySelectorAll("button")) .find(button => button.dataset[${JSON.stringify(datasetKey)}] === ${JSON.stringify(value)})`; } function modalButtonByTextExpression(text) { return `Array.from(document.querySelectorAll( "#manual-list-workspace button:not([disabled])")) .find(button => button.textContent.trim() === ${JSON.stringify(text)})`; } async function waitForTabModel(tabId, stateProperty, label) { await selectMainTab(tabId, label); return await waitFor(`${label} model`, snapshot => snapshot.state.isBusy === false && snapshot.state[stateProperty] != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function clickAndWaitForPlaylistAdd(expression, label, minimumAdded = 1) { const before = await readSnapshot(); const beforeCount = before.state.playlist.length; await pointerClick(expression, label); return await waitFor(`${label} playlist add`, snapshot => snapshot.state.isBusy === false && snapshot.state.playlist.length >= beforeCount + minimumAdded, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function doubleClickAndWaitForPlaylistAdd(expression, label, minimumAdded = 1) { const before = await readSnapshot(); const beforeCount = before.state.playlist.length; await pointerDoubleClick(expression, label); return await waitFor(`${label} playlist add`, snapshot => snapshot.state.isBusy === false && snapshot.state.playlist.length >= beforeCount + minimumAdded, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function clickAllEnabledPlaylistActions(selector, datasetKey, label) { const actionIds = await evaluate(`Array.from(document.querySelectorAll( ${JSON.stringify(selector)})) .filter(button => !button.disabled) .map(button => button.dataset[${JSON.stringify(datasetKey)}])`); if (!Array.isArray(actionIds) || actionIds.length === 0 || actionIds.some(value => typeof value !== "string" || value.length === 0)) { failKnown(`${label} exposed no enabled action buttons.`); } for (const actionId of actionIds) { await clickAndWaitForPlaylistAdd( dataButtonExpression(datasetKey, actionId), `${label} ${actionId}`); } return actionIds; } async function doubleClickAllEnabledPlaylistActions(selector, datasetKey, label) { const actionIds = await evaluate(`Array.from(document.querySelectorAll( ${JSON.stringify(selector)})) .filter(button => !button.disabled) .map(button => button.dataset[${JSON.stringify(datasetKey)}])`); if (!Array.isArray(actionIds) || actionIds.length === 0 || actionIds.some(value => typeof value !== "string" || value.length === 0)) { failKnown(`${label} exposed no enabled action buttons.`); } for (const actionId of actionIds) { await doubleClickAndWaitForPlaylistAdd( dataButtonExpression(datasetKey, actionId), `${label} ${actionId}`); } return actionIds; } async function verifyFixedAndIndustryScreens() { await waitForTabModel("overseas", "fixedCatalog", "UC1 overseas tab"); await pointerClick('document.getElementById("catalog-expand-all")', "UC1 Expand off"); if (await evaluate('document.getElementById("catalog-expand-all").checked') !== false) { failKnown("UC1 Expand did not close the fixed tree."); } await pointerClick('document.getElementById("catalog-expand-all")', "UC1 Expand on"); if (await evaluate('document.getElementById("catalog-expand-all").checked') !== true) { failKnown("UC1 Expand did not reopen the fixed tree."); } await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-001"), "UC1 overseas leaf"); const beforeSection = await readSnapshot(); const fixedSection = beforeSection.state.fixedCatalog.sections[1]; if (!fixedSection || !Array.isArray(fixedSection.actions) || fixedSection.actions.length === 0) { failKnown("UC1 fixed section batch has no actions to verify."); } await pointerDoubleClick( `document.querySelector('summary[data-section-index="1"]')`, "UC1 fixed parent batch"); const afterSection = await waitFor("UC1 fixed parent batch add", snapshot => snapshot.state.isBusy === false && snapshot.state.fixedSectionBatch == null && snapshot.state.playlist.length === beforeSection.state.playlist.length + fixedSection.actions.length, { timeoutMilliseconds: 60_000, allowUiBusy: true }); assertSafety(afterSection, "UC1 fixed parent batch", false); if (afterSection.safety.outboundMessages.length !== beforeSection.safety.outboundMessages.length + 1) { failKnown("UC1 fixed parent double-click must send exactly one native batch intent."); } await waitForTabModel("exchange", "fixedCatalog", "UC1 exchange tab"); await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-079"), "UC1 exchange leaf"); await waitForTabModel("index", "fixedCatalog", "UC1 index tab"); await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-125"), "UC1 index leaf"); recordFullUiDbCheck( "screenChecks", "UC1 Expand, leaf activation, and parent batch activation"); for (const tabId of ["kospiIndustry", "kosdaqIndustry"]) { await waitForTabModel(tabId, "industry", `UC2 ${tabId} tab`); await pointerClick(dataButtonExpression("industryIndex", "0"), `UC2 ${tabId} first industry`); await waitFor(`UC2 ${tabId} first selection`, snapshot => snapshot.state.isBusy === false && snapshot.state.industry?.selectedIndex === 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("industryIndex", "0"), `UC2 ${tabId} first industry double-click`); await waitFor(`UC2 ${tabId} first comparison slot`, snapshot => snapshot.state.isBusy === false && Boolean(snapshot.state.industry?.firstName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("industryIndex", "1"), `UC2 ${tabId} second industry double-click`); const rotated = await waitFor(`UC2 ${tabId} comparison slots`, snapshot => snapshot.state.isBusy === false && Boolean(snapshot.state.industry?.firstName) && Boolean(snapshot.state.industry?.secondName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const firstName = rotated.state.industry.firstName; const secondName = rotated.state.industry.secondName; await pointerClick(dataButtonExpression("industrySwap", "true"), `UC2 ${tabId} swap`); await waitFor(`UC2 ${tabId} swapped slots`, snapshot => snapshot.state.industry?.firstName === secondName && snapshot.state.industry?.secondName === firstName, { allowUiBusy: false }); await doubleClickAndWaitForPlaylistAdd( dataButtonExpression("industryActionId", "one-column"), `UC2 ${tabId} one-column action`); } recordFullUiDbCheck("screenChecks", "UC2 KOSPI/KOSDAQ select, double-click, swap, action"); } function comparisonBaseline(snapshot) { return snapshot.state.comparison.savedPairs.map(row => ({ rowId: row.rowId, displayLabel: row.displayLabel, first: comparisonTargetIdentity(row.first), second: comparisonTargetIdentity(row.second) })); } function comparisonTargetIdentity(target) { return target == null ? null : { displayName: target.displayName, kind: target.kind, metadata: target.metadata }; } async function verifyComparisonScreen() { let current = await waitForTabModel("comparison", "comparison", "UC3 comparison tab"); const baseline = comparisonBaseline(current); await replaceText('document.getElementById("comparison-domestic-search")', "삼성", "UC3 domestic query"); await pointerClick(dataButtonExpression("comparisonSearch", "domestic"), "UC3 domestic search"); current = await waitFor("UC3 domestic results", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.domesticSearch?.query === "삼성" && snapshot.state.comparison.domesticSearch.results.length > 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const domesticId = current.state.comparison.domesticSearch.results[0].selectionId; const secondDomesticId = current.state.comparison.domesticSearch.results[1].selectionId; await pointerClick(dataButtonExpression("comparisonSelectionId", domesticId), "UC3 domestic result single-click"); await waitFor("UC3 domestic result selected", snapshot => snapshot.state.comparison?.domesticSearch?.selectedResultId === domesticId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await replaceText('document.getElementById("comparison-world-search")', "3M", "UC3 world query"); await pointerClick(dataButtonExpression("comparisonSearch", "world"), "UC3 world search"); current = await waitFor("UC3 world results", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.worldSearch?.query === "3M" && snapshot.state.comparison?.worldSearch?.status === "ready" && snapshot.state.comparison.worldSearch.results.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.comparison.firstTarget != null || current.state.comparison.secondTarget != null) { failKnown("UC3 fresh tab activation did not start with empty draft targets."); } await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", domesticId), "UC3 choose first domestic target by slow Windows double-click", 0, 350); await waitFor("UC3 first domestic target", snapshot => snapshot.state.comparison?.firstTarget != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", secondDomesticId), "UC3 choose second domestic target by slow Windows double-click", 0, 350); current = await waitFor("UC3 second domestic target", snapshot => snapshot.state.comparison?.firstTarget != null && snapshot.state.comparison?.secondTarget != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const firstTargetIdentity = comparisonTargetIdentity( current.state.comparison.firstTarget); const secondTargetIdentity = comparisonTargetIdentity( current.state.comparison.secondTarget); await pointerClick(dataButtonExpression("comparisonSwap", "true"), "UC3 swap draft"); await waitFor("UC3 swapped draft", snapshot => JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.firstTarget)) === JSON.stringify(secondTargetIdentity) && JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.secondTarget)) === JSON.stringify(firstTargetIdentity), { allowUiBusy: false }); await pointerClick(dataButtonExpression("comparisonSwap", "true"), "UC3 restore draft order"); await waitFor("UC3 restored draft", snapshot => JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.firstTarget)) === JSON.stringify(firstTargetIdentity) && JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.secondTarget)) === JSON.stringify(secondTargetIdentity), { allowUiBusy: false }); await pointerClick(dataButtonExpression("comparisonAdd", "true"), "UC3 add first temporary comparison pair"); current = await waitFor("UC3 first temporary pair commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.length === baseline.length + 1 && snapshot.state.comparison?.selectedPairId != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const firstTemporaryRowId = current.state.comparison.selectedPairId; await pointerClick(dataButtonExpression("comparisonAdd", "true"), "UC3 add second temporary comparison pair"); current = await waitFor("UC3 second temporary pair commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.length === baseline.length + 2 && snapshot.state.comparison?.selectedPairId != null && snapshot.state.comparison.selectedPairId !== firstTemporaryRowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const secondTemporaryRowId = current.state.comparison.selectedPairId; await pointerClick(dataButtonExpression("comparisonMove", "up"), "UC3 second temporary pair UP"); await waitFor("UC3 second temporary pair UP commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.[baseline.length]?.rowId === secondTemporaryRowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(dataButtonExpression("comparisonMove", "down"), "UC3 second temporary pair DOWN"); await waitFor("UC3 second temporary pair DOWN commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.at(-1)?.rowId === secondTemporaryRowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("comparisonTargetId", "kospi"), "UC3 fixed target slow Windows double-click", 0, 350); await waitFor("UC3 fixed target draft", snapshot => snapshot.state.comparison?.selectedFixedTargetId === "kospi" && snapshot.state.comparison?.firstTarget?.displayName === "코스피 지수", { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(dataButtonExpression("comparisonDelete", "all"), "UC3 DEL ALL"); await respondToNativeDialog("UC3 DEL ALL cancel", true, false); const comparisonActions = await doubleClickAllEnabledPlaylistActions( "button[data-comparison-action-id]", "comparisonActionId", "UC3 action"); await pointerClick(dataButtonExpression("comparisonDelete", "selected"), "UC3 delete second temporary pair"); current = await waitFor("UC3 second temporary pair delete commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.length === baseline.length + 1 && !snapshot.state.comparison.savedPairs.some( row => row.rowId === secondTemporaryRowId), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(dataButtonExpression("comparisonPairId", firstTemporaryRowId), "UC3 select first temporary pair"); await waitFor("UC3 first temporary pair selected", snapshot => snapshot.state.comparison?.selectedPairId === firstTemporaryRowId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(dataButtonExpression("comparisonDelete", "selected"), "UC3 delete first temporary pair"); current = await waitFor("UC3 first temporary pair delete commit", snapshot => snapshot.state.isBusy === false && snapshot.state.comparison?.savedPairs?.length === baseline.length && !snapshot.state.comparison.savedPairs.some( row => row.rowId === firstTemporaryRowId), { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (JSON.stringify(comparisonBaseline(current)) !== JSON.stringify(baseline)) { failKnown("UC3 baseline changed after deleting the temporary pair."); } await waitForTabModel("overseas", "fixedCatalog", "UC3 fresh-read detour"); current = await waitForTabModel("comparison", "comparison", "UC3 fresh-read return"); if (JSON.stringify(comparisonBaseline(current)) !== JSON.stringify(baseline)) { failKnown("UC3 persisted baseline did not survive fresh re-entry exactly."); } recordFullUiDbCheck("screenChecks", "UC3 all control buttons and temporary pair restore", { baselineRows: baseline.length, actionButtons: comparisonActions.length }); recordFullUiDbCheck("databaseChecks", "UC3 local pair save/fresh read/delete/restore", { rows: baseline.length }); } async function verifyThemeScreen() { let current = await waitForTabModel("theme", "theme", "UC4 theme tab"); await replaceText('document.getElementById("theme-search")', "", "UC4 theme query"); await pointerClick(dataButtonExpression("themeSearch", "true"), "UC4 theme search"); current = await waitFor("UC4 theme search results", snapshot => snapshot.state.isBusy === false && snapshot.state.theme?.searchResults?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const resultId = current.state.theme.searchResults[0].resultId; await pointerClick(dataButtonExpression("themeResultId", resultId), "UC4 theme row single-click"); await waitFor("UC4 theme preview", snapshot => snapshot.state.isBusy === false && snapshot.state.theme?.selectedResultId === resultId && snapshot.state.theme?.selectedTheme != null && snapshot.state.theme?.previewItems?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); for (const sortId of ["GAIN_DESC", "GAIN_ASC", "INPUT_ORDER"]) { await pointerClick( `document.querySelector('input[data-theme-sort][value="${sortId}"]')`, `UC4 theme sort ${sortId}`); await waitFor(`UC4 theme sort ${sortId} state`, snapshot => snapshot.state.theme?.sortId === sortId, { allowUiBusy: false }); } const beforeDouble = await readSnapshot(); await pointerDoubleClick(dataButtonExpression("themeResultId", resultId), "UC4 theme row slow Windows double-click", 0, 350); await waitFor("UC4 theme double-click add", snapshot => snapshot.state.playlist.length === beforeDouble.state.playlist.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const actions = await clickAllEnabledPlaylistActions( "button[data-theme-action-id]", "themeActionId", "UC4 action"); recordFullUiDbCheck("screenChecks", "UC4 search, row, sort, double-click, all actions", { actionButtons: actions.length }); } async function verifyOverseasScreen() { let current = await waitForTabModel("overseasStocks", "overseas", "UC5 overseas tab"); const fixedTarget = current.state.overseas.fixedIndexTargets[1] || current.state.overseas.fixedIndexTargets[0]; await pointerClick(dataButtonExpression("overseasTargetId", fixedTarget.targetId), "UC5 fixed index row"); await waitFor("UC5 fixed index selected", snapshot => snapshot.state.overseas?.selectedFixedIndexId === fixedTarget.targetId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const fixedActions = await clickAllEnabledPlaylistActions( "button[data-overseas-action-id]", "overseasActionId", "UC5 fixed action"); await replaceText('document.getElementById("overseas-industry-search")', "", "UC5 industry query"); await pointerClick(dataButtonExpression("overseasSearch", "industry"), "UC5 industry search"); current = await waitFor("UC5 industry results", snapshot => snapshot.state.isBusy === false && snapshot.state.overseas?.industry?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const industryId = current.state.overseas.industry.results[0].selectionId; await pointerClick(dataButtonExpression("overseasSelectionId", industryId), "UC5 industry result"); await waitFor("UC5 industry selected", snapshot => snapshot.state.overseas?.industry?.selectedId === industryId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const industryActionIds = current.state.overseas.actions .filter(action => action.source === "industry") .map(action => action.actionId); for (const actionId of industryActionIds) { await clickAndWaitForPlaylistAdd(dataButtonExpression("overseasActionId", actionId), `UC5 industry action ${actionId}`); } await replaceText('document.getElementById("overseas-stock-search")', "", "UC5 stock query"); await pointerClick(dataButtonExpression("overseasSearch", "stock"), "UC5 stock search"); current = await waitFor("UC5 stock results", snapshot => snapshot.state.isBusy === false && snapshot.state.overseas?.stock?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const stockId = current.state.overseas.stock.results[0].selectionId; await pointerClick(dataButtonExpression("overseasSelectionId", stockId), "UC5 stock result"); current = await waitFor("UC5 stock selected", snapshot => snapshot.state.overseas?.stock?.selectedId === stockId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const stockActionIds = current.state.overseas.actions .filter(action => action.source === "stock") .map(action => action.actionId); for (const actionId of stockActionIds) { await clickAndWaitForPlaylistAdd(dataButtonExpression("overseasActionId", actionId), `UC5 stock action ${actionId}`); } recordFullUiDbCheck("screenChecks", "UC5 fixed, industry, stock search/select/actions", { fixedActions: fixedActions.length, industryActions: industryActionIds.length, stockActions: stockActionIds.length }); } async function verifyExpertAndTradingHaltScreens() { let current = await waitForTabModel("expert", "expert", "UC6 expert tab"); await replaceText('document.getElementById("expert-search")', "", "UC6 expert query"); const expertSearchRevision = current.state.revision; await pointerClick(dataButtonExpression("expertSearch", "true"), "UC6 expert search"); current = await waitFor("UC6 expert results", snapshot => snapshot.state.isBusy === false && snapshot.state.revision > expertSearchRevision && snapshot.state.expert?.search?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const expertId = current.state.expert.search.results[0].selectionId; await pointerClick(dataButtonExpression("expertSelectionId", expertId), "UC6 expert row"); await waitFor("UC6 expert preview", snapshot => snapshot.state.isBusy === false && snapshot.state.expert?.selectedExpert?.selectionId === expertId && snapshot.state.expert?.preview?.items?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const expertActions = await clickAllEnabledPlaylistActions( "button[data-expert-action-id]", "expertActionId", "UC6 action"); recordFullUiDbCheck("screenChecks", "UC6 search, row, action", { actionButtons: expertActions.length }); current = await waitForTabModel("tradingHalt", "tradingHalt", "UC7 trading-halt tab"); await replaceText('document.getElementById("halt-search")', "", "UC7 halt query"); const haltSearchRevision = current.state.revision; await pointerClick(dataButtonExpression("haltSearch", "true"), "UC7 halt search"); current = await waitFor("UC7 halt results", snapshot => snapshot.state.isBusy === false && snapshot.state.revision > haltSearchRevision && snapshot.state.tradingHalt?.status === "ready" && snapshot.state.tradingHalt?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const automaticHaltId = current.state.tradingHalt.results[0].selectionId; const automaticHaltActions = current.state.tradingHalt.actions || []; if (current.state.tradingHalt.selected?.selectionId !== automaticHaltId || automaticHaltActions.length === 0 || automaticHaltActions.some(action => action.isAvailable !== true)) { failKnown("UC7 did not activate the first FarPoint row and actions after search."); } await clickAndWaitForPlaylistAdd( dataButtonExpression("haltActionId", automaticHaltActions[0].actionId), "UC7 action without an extra row click"); const beforeSort = current.state.tradingHalt.nameSort; await pointerClick(dataButtonExpression("haltSort", "true"), "UC7 halt sort"); current = await waitFor("UC7 halt sort state", snapshot => snapshot.state.tradingHalt?.nameSort !== beforeSort, { allowUiBusy: false }); const haltId = current.state.tradingHalt.results[0].selectionId; await pointerClick(dataButtonExpression("haltSelectionId", haltId), "UC7 halt row"); await waitFor("UC7 halt selection", snapshot => snapshot.state.tradingHalt?.selected?.selectionId === haltId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const haltActions = await clickAllEnabledPlaylistActions( "button[data-halt-action-id]", "haltActionId", "UC7 action"); recordFullUiDbCheck( "screenChecks", "UC7 search auto-first-row action, sort, explicit row, all actions", { actionButtons: haltActions.length }); } async function verifyGraphEActivationButtons() { await executeManualFinancialCrud(); } async function openFixedManualList(actionId, screen, audience, label) { await waitForTabModel("index", "fixedCatalog", `${label} index tab`); await pointerDoubleClick(dataButtonExpression("actionId", actionId), `${label} open`); return await waitFor(`${label} loaded`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === true && snapshot.state.manualLists?.screen === screen && (audience == null || snapshot.state.manualLists?.audience === audience) && snapshot.dom.manualListModalHidden === false && snapshot.state.manualLists?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function closeManualList(label) { await pointerClick('document.getElementById("manual-list-cancel")', label); await waitFor(`${label} complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === false && snapshot.dom.manualListModalHidden === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } function netSellProjection(snapshot) { return snapshot.state.manualLists.netRows.map(row => ({ leftName: row.leftName, leftAmount: row.leftAmount, rightName: row.rightName, rightAmount: row.rightAmount })); } function manualNetSellCellExpression(rowIndex, field) { const fieldIndex = ["leftName", "leftAmount", "rightName", "rightAmount"] .indexOf(field); if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 || fieldIndex < 0) { failKnown("The FSell cell fixture is outside the original five-by-four grid."); } return `document.querySelectorAll( "#manual-list-workspace .manual-list-table tbody tr")[${rowIndex}] ?.querySelectorAll("input")[${fieldIndex}] || null`; } async function replaceManualNetSellCell(rowIndex, field, value, expectedRows, label) { const expression = manualNetSellCellExpression(rowIndex, field); const before = await readSnapshot(); await replaceText(expression, value, label); // The original editor commits a cell when focus leaves it. Use a real Tab key // so this covers the packaged change event rather than assigning DOM state. await pressKey("Tab", `${label} commit by focus change`); return await waitFor(`${label} native draft`, snapshot => snapshot.state.isBusy === false && snapshot.state.revision > before.state.revision && snapshot.state.manualLists?.isOpen === true && snapshot.state.manualLists?.screen === "net-sell" && snapshot.state.manualLists?.netRowsAreFresh === false && snapshot.state.manualLists?.writesQuarantined !== true && JSON.stringify(netSellProjection(snapshot)) === JSON.stringify(expectedRows), { timeoutMilliseconds: 60_000, allowUiBusy: true }); } async function confirmManualNetSellAndVerifyReadback(expectedRows, label) { const before = await readSnapshot(); if (before.state.manualLists?.isOpen !== true || before.state.manualLists?.screen !== "net-sell" || before.state.manualLists?.canSave !== true || before.state.manualLists?.writesQuarantined === true) { failKnown(`${label} did not start from a saveable FSell editor.`); } await pointerClick(modalButtonByTextExpression("확인"), label); return await waitFor(`${label} one save and correlated readback`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === false && snapshot.dom.manualListModalHidden === true && snapshot.state.manualLists?.netRowsAreFresh === true && snapshot.state.manualLists?.writesQuarantined !== true && JSON.stringify(netSellProjection(snapshot)) === JSON.stringify(expectedRows) && snapshot.state.playlist.length === before.state.playlist.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } function viProjection(snapshot) { return snapshot.state.manualLists.viItems.map(row => ({ index: row.index, name: row.name })); } async function verifyManualListButtonsAndPersistence() { let current = await openFixedManualList( "fixed-245", "net-sell", "individual", "FSell individual"); const individualBaseline = netSellProjection(current); if (individualBaseline.length !== 5 || current.state.manualLists.canSave !== true) { failKnown("FSell individual baseline is not the original five-row saveable state."); } await pointerClickWithJavaScriptDialog( modalButtonByTextExpression("원본 1회 가져오기"), "FSell import cancel branch", "confirm", "원본 파일을 현재 빈 저장소로 한 번만 가져옵니다. 기존 파일은 덮어쓰지 않습니다.", false); const beforeRefresh = await readSnapshot(); await pointerClick(modalButtonByTextExpression("재조회"), "FSell refresh"); await waitFor("FSell refresh result", snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeRefresh.state.revision && snapshot.state.manualLists?.netRowsAreFresh === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); for (const [label, audience] of [["외국인", "foreign"], ["기관", "institution"], ["개인", "individual"]]) { await pointerClick(modalButtonByTextExpression(label), `FSell ${label} tab`); await waitFor(`FSell ${label} tab state`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.audience === audience && snapshot.state.manualLists?.netRows?.length === 5, { timeoutMilliseconds: 60_000, allowUiBusy: true }); } await closeManualList("FSell cancel button"); await openFixedManualList("fixed-246", "net-sell", "foreign", "FSell foreign entry"); await closeManualList("FSell foreign close"); await openFixedManualList("fixed-247", "net-sell", "institution", "FSell institution entry"); await closeManualList("FSell institution close"); current = await openFixedManualList( "fixed-245", "net-sell", "individual", "FSell save reopen"); if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(individualBaseline)) { failKnown("FSell changed before the isolated cell round-trip began."); } let temporaryLeftAmount = `${Date.now()}${randomBytes(3).readUIntBE(0, 3).toString().padStart(8, "0")}`; if (temporaryLeftAmount === individualBaseline[0].leftAmount) { temporaryLeftAmount += "0"; } const temporaryRows = individualBaseline.map(row => ({ ...row })); temporaryRows[0].leftAmount = temporaryLeftAmount; const fsellRoundTrip = { row: 1, field: "leftAmount", temporaryValue: temporaryLeftAmount, mutationSaveVerified: false, mutationFreshReadVerified: false, restoreSaveVerified: false, restoreFreshReadVerified: false }; evidence.fullUiDb.fsellCellRoundTrip = fsellRoundTrip; await replaceManualNetSellCell( 0, "leftAmount", temporaryLeftAmount, temporaryRows, "FSell row 1 left amount temporary input"); await confirmManualNetSellAndVerifyReadback( temporaryRows, "FSell temporary value confirm save"); fsellRoundTrip.mutationSaveVerified = true; current = await openFixedManualList( "fixed-245", "net-sell", "individual", "FSell temporary value fresh read"); if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(temporaryRows)) { failKnown("FSell fresh read did not preserve the physically typed temporary value."); } fsellRoundTrip.mutationFreshReadVerified = true; await replaceManualNetSellCell( 0, "leftAmount", individualBaseline[0].leftAmount, individualBaseline, "FSell row 1 left amount restore input"); await confirmManualNetSellAndVerifyReadback( individualBaseline, "FSell original value confirm restore"); fsellRoundTrip.restoreSaveVerified = true; current = await openFixedManualList( "fixed-245", "net-sell", "individual", "FSell restored fresh read"); if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(individualBaseline)) { failKnown("FSell final fresh read did not restore the exact five-row baseline."); } fsellRoundTrip.restoreFreshReadVerified = true; await closeManualList("FSell restored fresh read close"); recordFullUiDbCheck("screenChecks", "FSell all entry/tab/import/refresh/cancel/confirm buttons"); recordFullUiDbCheck( "databaseChecks", "FSell physical cell edit/save/fresh read/original restore/fresh read", { rows: 5, row: 1, field: "leftAmount", restored: true }); current = await openFixedManualList("fixed-262", "vi", null, "VIList entry"); const viBaseline = viProjection(current); if (viBaseline.length === 0 || current.state.manualLists.canSave !== true) { failKnown("VIList baseline is empty or not saveable."); } const beforeViRefresh = await readSnapshot(); await pointerClick(modalButtonByTextExpression("재조회"), "VIList refresh"); await waitFor("VIList refresh result", snapshot => snapshot.state.isBusy === false && snapshot.state.revision > beforeViRefresh.state.revision && snapshot.state.manualLists?.viItemsAreFresh === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await replaceText( 'document.querySelector("#manual-list-workspace .manual-list-search input")', "삼성출판사", "VIList stock query"); await pointerClick(modalButtonByTextExpression("검색"), "VIList search"); current = await waitFor("VIList search results", snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.searchResults?.some(row => row.displayName === "삼성출판사"), { timeoutMilliseconds: 60_000, allowUiBusy: true }); const viResult = current.state.manualLists.searchResults .find(row => row.displayName === "삼성출판사"); const viResultExpression = `Array.from(document.querySelectorAll( "#manual-list-workspace .manual-list-search-results button")) .find(button => button.textContent.startsWith("삼성출판사 ·"))`; await pointerClick(viResultExpression, "VIList result single-click"); await waitFor("VIList result selected", snapshot => snapshot.state.manualLists?.selectedSearchResultId === viResult.resultId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick( viResultExpression, "VIList result slow Windows double-click add", 0, 350); current = await waitFor("VIList temporary item add", snapshot => snapshot.state.manualLists?.viItems?.length === viBaseline.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); const temporaryItemId = current.state.manualLists.viItems.at(-1).itemId; await pointerClick( 'document.querySelector("#manual-list-workspace tbody tr:last-child button:nth-of-type(1)")', "VIList temporary item UP"); await waitFor("VIList temporary item UP state", snapshot => snapshot.state.manualLists?.viItems?.at(-2)?.itemId === temporaryItemId, { allowUiBusy: false }); await pointerClick( `Array.from(document.querySelectorAll("#manual-list-workspace tbody tr")) .find((row, index) => index === ${viBaseline.length - 1}) ?.querySelector("button:nth-of-type(2)")`, "VIList temporary item DOWN"); await waitFor("VIList temporary item DOWN state", snapshot => snapshot.state.manualLists?.viItems?.at(-1)?.itemId === temporaryItemId, { allowUiBusy: false }); await pointerClick( 'document.querySelector("#manual-list-workspace tbody tr:last-child button:nth-of-type(3)")', "VIList temporary item delete"); await waitFor("VIList temporary item delete state", snapshot => snapshot.state.manualLists?.viItems?.length === viBaseline.length && !snapshot.state.manualLists.viItems.some(row => row.itemId === temporaryItemId), { allowUiBusy: false }); await pointerClickWithNativeDialog( modalButtonByTextExpression("전체 삭제"), "VIList delete-all cancel branch", true, "모두 삭제하겠습니까?", false); const beforeViSave = await readSnapshot(); await pointerClick(modalButtonByTextExpression("확인"), "VIList confirm save"); await waitFor("VIList save, readback, add, close", snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === false && snapshot.dom.manualListModalHidden === true && snapshot.state.playlist.length === beforeViSave.state.playlist.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); current = await openFixedManualList("fixed-262", "vi", null, "VIList fresh read"); if (JSON.stringify(viProjection(current)) !== JSON.stringify(viBaseline)) { failKnown("VIList fresh read did not preserve the exact baseline."); } await closeManualList("VIList fresh read close"); recordFullUiDbCheck("screenChecks", "VIList search/select/double-click/up/down/delete/all/refresh/confirm buttons"); recordFullUiDbCheck("databaseChecks", "VIList trusted file save/fresh read", { rows: viBaseline.length }); } async function clearScreenValidationPlaylist() { const current = await readSnapshot(); if (current.state.playlist.length === 0) return; await pointerClickWithNativeDialog( 'document.getElementById("playlist-delete-all")', "screen validation playlist cleanup", true, "송출 리스트를 모두 삭제하겠습니까?", true); await waitFor("screen validation playlist cleanup state", snapshot => snapshot.state.isBusy === false && snapshot.state.playlist.length === 0, { allowUiBusy: false }); } async function executeScreenInventory() { await verifyFixedAndIndustryScreens(); await verifyComparisonScreen(); await verifyThemeScreen(); await verifyOverseasScreen(); await verifyExpertAndTradingHaltScreens(); await verifyGraphEActivationButtons(); await verifyManualListButtonsAndPersistence(); for (const [tabId, stateProperty] of [ ["overseas", "fixedCatalog"], ["exchange", "fixedCatalog"], ["index", "fixedCatalog"], ["kospiIndustry", "industry"], ["kosdaqIndustry", "industry"], ["comparison", "comparison"], ["theme", "theme"], ["overseasStocks", "overseas"], ["expert", "expert"], ["tradingHalt", "tradingHalt"] ]) { await waitForTabModel(tabId, stateProperty, `final screen inventory ${tabId}`); await captureVisibleScreenInventory(tabId); } await clearScreenValidationPlaylist(); recordFullUiDbCheck("screenChecks", "UC1-UC7 final packaged button inventory"); } async function executeFullUiDbSequence() { if (!fullUiDbProfile) return; if (configuration.scope === "all" || configuration.scope === "plist") { await recoverNamedPlaylistFixture(); await executeNamedPlaylistCrud(); } if (configuration.scope === "all" || configuration.scope === "graphe") { await executeManualFinancialCrud(); } if (configuration.scope === "all" || configuration.scope === "catalog") { await executeOperatorCatalogCrud(); } if (configuration.scope === "all" || configuration.scope === "screens") { await executeScreenInventory(); } evidence.fullUiDb.result = "PASS"; evidence.fullUiDb.cleanupVerified = true; const current = await readSnapshot(); if (!current.dom.namedModalHidden || !current.dom.namedConfirmationHidden || !current.dom.manualFinancialModalHidden || !current.dom.manualListModalHidden || !current.dom.operatorCatalogModalHidden) { failKnown("A child modal remained open after the full UI/DB sequence."); } evidence.finalSnapshot = current; } try { const target = await discoverExactTarget(); const endpoint = validateWebSocketTarget(target); evidence.target.id = target.id; evidence.target.url = target.url; await connect(endpoint); await executeSafeSequence(); await executeDryRunPlayoutSequence(); await executeProgramNamedPlaylistCrud(); await executeFullUiDbSequence(); await captureScreenshot(); const sealedSnapshot = await readSnapshot(); assertSafety(sealedSnapshot, "post-screenshot safety seal", false); evidence.finalSnapshot = sealedSnapshot; evidence.result = evidence.warnings.length === 0 ? "PASS" : "PASS_WITH_WARNING"; } catch (error) { evidence.result = "FAIL"; evidence.error = errorProjection(error); process.exitCode = 1; if (socket?.readyState === WebSocket.OPEN) { try { evidence.finalSnapshot = await readSnapshot(); } catch (snapshotError) { evidence.warnings.push(`Failure snapshot unavailable: ${snapshotError.message}`); } try { await captureScreenshot(); } catch (screenshotError) { evidence.warnings.push(`Failure screenshot unavailable: ${screenshotError.message}`); } } } finally { cleanupMode = true; cleanupDeadlineEpochMilliseconds = Date.now() + 5_000; const cleanupFailures = []; if ((pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) && socket?.readyState === WebSocket.OPEN) { try { await cleanupDragInputSession(true); } catch (inputCleanupError) { evidence.warnings.push(`Input cleanup failed: ${inputCleanupError.message}`); cleanupFailures.push(`input cleanup: ${inputCleanupError.message}`); } } else if ((pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) && socket?.readyState !== WebSocket.OPEN) { evidence.warnings.push( "Input cleanup failed: the CDP socket closed before drag/pointer restoration."); cleanupFailures.push("input cleanup: CDP socket closed"); } if (socket?.readyState === WebSocket.OPEN) { try { await removeProbe(); } catch (probeError) { evidence.warnings.push(`Probe cleanup failed: ${probeError.message}`); cleanupFailures.push(`probe restore: ${probeError.message}`); } socket.close(); } else if (probeInstalled) { evidence.warnings.push( "Probe cleanup failed: the CDP socket was closed before bridge restoration."); cleanupFailures.push("probe restore: CDP socket closed"); } if (cleanupFailures.length > 0 && evidence.result !== "FAIL") { evidence.result = "FAIL"; evidence.error = { category: "OUTCOME_UNKNOWN", name: "CleanupFailure", message: "The one-shot input cleanup did not complete." }; process.exitCode = 1; } evidence.completedAt = new Date().toISOString(); fs.writeFileSync( configuration.outputPath, `${JSON.stringify(evidence, null, 2)}\n`, { flag: "wx" }); } if (evidence.result === "FAIL") { console.error(JSON.stringify({ result: evidence.result, output: configuration.outputPath, screenshot: evidence.screenshot?.path || null, error: evidence.error })); } else { console.log(JSON.stringify({ result: evidence.result, output: configuration.outputPath, screenshot: evidence.screenshot })); }