Add the MBN-branded startup intro, semantic sidebar icons, and refined card-based operator visuals. Strengthen packaged UI and input smoke coverage for the updated experience.
7425 lines
309 KiB
JavaScript
7425 lines
309 KiB
JavaScript
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 <port> " +
|
|
"--output <absolute-result.json> --screenshot <absolute-screenshot.png> " +
|
|
"--windows-input-request <absolute-request.json> " +
|
|
"--windows-input-ack <absolute-ack.json> " +
|
|
"[--profile read-only|dry-run-playout|full-ui-db|program-playlist-db] " +
|
|
"[--scope all|plist|graphe|catalog|screens] " +
|
|
"[--recover-named-playlist-title <CDX_P fixture>] " +
|
|
"[--timeout-ms <milliseconds>]");
|
|
}
|
|
|
|
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
|
|
}));
|
|
}
|