fix: complete legacy operator parity workflows

This commit is contained in:
2026-07-23 00:54:31 +09:00
parent 939c252d23
commit cf117de144
25 changed files with 2430 additions and 223 deletions

View File

@@ -42,9 +42,11 @@ const readOnlyAllowedOutboundMessageTypes = Object.freeze([
"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([
@@ -209,7 +211,7 @@ function failUnknown(message) {
}
function windowsInputExchangePath(basePath, exchangeIndex) {
if (!Number.isSafeInteger(exchangeIndex) || exchangeIndex < 1 || exchangeIndex > 2) {
if (!Number.isSafeInteger(exchangeIndex) || exchangeIndex < 1 || exchangeIndex > 3) {
failKnown("The Windows input exchange index is outside the closed contract.");
}
if (exchangeIndex === 1) return basePath;
@@ -220,6 +222,7 @@ function windowsInputExchangePath(basePath, exchangeIndex) {
}
function expectedWindowsInputExchangeCount(profile, scope) {
if (profile === "dry-run-playout") return 3;
return profile === "full-ui-db" && new Set(["all", "plist"]).has(scope) ? 2 : 1;
}
@@ -230,7 +233,7 @@ function parseArguments(argv) {
"--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] " +
"[--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>]");
@@ -324,8 +327,14 @@ function parseArguments(argv) {
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"]).has(profile)) {
failKnown("--profile must be read-only, dry-run-playout, or full-ui-db.");
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)) {
@@ -410,6 +419,9 @@ function errorProjection(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 databaseWriteProfile = fullUiDbProfile || programPlaylistDbProfile;
const activeDryRunProfile = dryRunPlayoutProfile || programPlaylistDbProfile;
const forbiddenControlIds = fullUiDbProfile
? Object.freeze([
"playout-background-file",
@@ -418,15 +430,36 @@ const forbiddenControlIds = fullUiDbProfile
"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",
@@ -468,7 +501,7 @@ const evidence = {
query: "삼성",
stockDisplayName: "삼성출판사",
databaseStockDisplayName: "1Q 삼성전자선물단일종목레버리지",
namedPlaylistTitle: fullUiDbProfile
namedPlaylistTitle: databaseWriteProfile
? `CDX_P_${Date.now()}_${randomBytes(3).toString("hex")}`
: null,
krxThemeTitle: fullUiDbProfile
@@ -508,8 +541,22 @@ const evidence = {
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: [],
@@ -935,6 +982,9 @@ function probeSnapshotExpression() {
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,
@@ -1030,6 +1080,14 @@ function probeSnapshotExpression() {
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:
@@ -2094,8 +2152,8 @@ async function setSearchText(text) {
await rpc("Input.insertText", { text });
}
async function replaceText(expression, text, label) {
await pointerClick(expression, `focus ${label}`);
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);
@@ -2128,14 +2186,15 @@ async function selectOptionValue(expression, value, label) {
if (actual !== value) failKnown(`${label} did not select the requested value.`);
}
async function respondToNativeDialog(label, confirmation, accept) {
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
allowLegacyDialog: true,
allowActiveDryRun: options.allowActiveDryRun === true
});
if (confirmation && accept !== true && accept !== false) {
failKnown(`${label} confirmation response is not explicit.`);
@@ -2146,13 +2205,17 @@ async function respondToNativeDialog(label, confirmation, accept) {
: 'document.getElementById("legacy-dialog-ok")',
`${label} ${confirmation ? (accept ? "yes" : "no") : "ok"}`,
0,
{ allowLegacyDialog: true });
{
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
allowLegacyDialog: true,
allowActiveDryRun: options.allowActiveDryRun === true
});
return opened;
}
@@ -2162,9 +2225,14 @@ async function pointerClickWithNativeDialog(
label,
confirmation,
message,
accept) {
await pointerClick(expression, label);
const opened = await respondToNativeDialog(label, confirmation, accept);
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.`);
@@ -2318,9 +2386,15 @@ function cssDragSize(snapshot) {
};
}
async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount, label) {
async function dragCutsToPlaylist(
physicalIndex,
modifiers,
expectedAppendCount,
label,
options = {}) {
const before = await readSnapshot();
assertSafety(before, `${label} preflight`, false);
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 =
@@ -2345,7 +2419,7 @@ async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount,
const held = await waitFor(`${label} pointer-down receipt`, snapshot =>
snapshot.state.revision > beforeRevision &&
focusedCutIndex(snapshot) === physicalIndex,
{ allowUiBusy: false });
{ allowUiBusy: false, allowActiveDryRun });
const dragSize = cssDragSize(held);
const inside = {
x: source.x - dragSize.width / 2,
@@ -2450,7 +2524,7 @@ async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount,
const completed = await waitFor(`${label} append`, snapshot =>
snapshot.state.isBusy !== true &&
snapshot.state.playlist.length === beforeCount + expectedAppendCount,
{ allowUiBusy: false });
{ allowUiBusy: false, allowActiveDryRun });
if (!completed.dom.cuts.every(row => !row.dragging) ||
completed.dom.cutDropHighlighted ||
completed.dom.cutDragFeedback?.hidden !== true) {
@@ -2887,6 +2961,108 @@ async function requestWindowsInput(
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(`(() => {
@@ -2920,7 +3096,7 @@ async function installProbe() {
blockedOutboundMessages: [],
stateViolations: []
};
const allowActiveDryRun = ${JSON.stringify(dryRunPlayoutProfile)};
const allowActiveDryRun = ${JSON.stringify(activeDryRunProfile)};
probe.listener = event => {
if (event.data?.type !== "state" || !event.data.payload) return;
const payload = event.data.payload;
@@ -3013,8 +3189,15 @@ async function installProbe() {
: null,
definitionId: new Set([
"select-named-playlist",
"load-named-playlist-by-id"
"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,
@@ -3300,6 +3483,79 @@ async function executeSafeSequence() {
["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,
@@ -3347,6 +3603,7 @@ async function executeSafeSequence() {
}
await checkpoint("playlist-keyboard-focus-split");
await sleep(legacyCutDoubleClickWindowMilliseconds + 100);
current = await dragCutsToPlaylist(
1,
0,
@@ -3379,9 +3636,10 @@ async function executeSafeSequence() {
rowE,
"Windows SendInput drag, Home, Shift range, Delete, and restore");
await pointerClick(
'document.getElementById("db-load-button")',
"open DB playlist load modal");
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 &&
@@ -3494,7 +3752,8 @@ async function executeSafeSequence() {
failKnown("Declining the save confirmation changed named-playlist mutation state.");
}
}
await checkpoint("named-playlist-save-confirmation-no-write");
await checkpoint("named-playlist-save-confirmation-no-write");
}
current = await checkpoint("final", {
expectedPlaylistOrder: [rowC, rowA, rowB]
@@ -3513,6 +3772,8 @@ async function executeSafeSequence() {
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 ||
@@ -3628,6 +3889,78 @@ async function executeDryRunPlayoutSequence() {
{ 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",
@@ -4023,6 +4356,40 @@ function namedPlaylistDeleteWriteCount(snapshot) {
.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,
@@ -4159,6 +4526,13 @@ async function recoverNamedPlaylistFixture() {
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 => {
@@ -4322,8 +4696,12 @@ async function executeNamedPlaylistCrud() {
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) &&
@@ -4338,6 +4716,271 @@ async function executeNamedPlaylistCrud() {
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",
@@ -6263,6 +6906,7 @@ try {
await connect(endpoint);
await executeSafeSequence();
await executeDryRunPlayoutSequence();
await executeProgramNamedPlaylistCrud();
await executeFullUiDbSequence();
await captureScreenshot();
const sealedSnapshot = await readSnapshot();