feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

@@ -50,7 +50,7 @@ test("legacy shell exposes the original stock, cut, playlist and playout control
assert.match(markup,
/class="[^"]*\bactive\b[^"]*"[^>]*data-tab-id="overseas"[\s\S]*?<span class="workspace-nav-label">해외<\/span>[\s\S]*?<\/button>/);
assert.match(markup,
/data-tab-id="tradingHalt"[\s\S]*?<span class="workspace-nav-label">정지<\/span>[\s\S]*?<\/button>/);
/data-tab-id="tradingHalt"[\s\S]*?<span class="workspace-nav-label">거래정지<\/span>[\s\S]*?<\/button>/);
});
test("web layer sends intent and does not own scene, DB identity or playlist state", () => {
@@ -91,37 +91,63 @@ test("cut rows are reconciled in place while the legacy pointer drag remains cap
assert.doesNotMatch(app, /cutList\.replaceChildren/);
});
test("program keeps cut selection active while append drag stays locked", () => {
const sourceStart = app.indexOf("function isCutSelectionLocked");
test("program keeps cut append available while structural playlist edits stay locked", () => {
const sourceStart = app.indexOf("function isOperatorMutationLocked");
const sourceEnd = app.indexOf("function dragDropPosition", sourceStart);
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
const isCutSelectionLocked = new Function(`
const locks = new Function(`
${app.slice(sourceStart, sourceEnd)}
return isCutSelectionLocked;
return {
isOperatorMutationLocked,
isPlaylistStructureMutationLocked,
isPlaylistProgramEnableMutationLocked,
canDeleteSelectedPlaylistRows,
isCutSelectionLocked
};
`)();
assert.equal(isCutSelectionLocked({
const programState = {
isBusy: false,
fixedSectionBatch: null,
playlist: [
{ rowId: "on-air", isSelected: false },
{ rowId: "tail", isSelected: true }
],
playout: {
phase: "program",
currentCueIndexZeroBased: 0,
isBusy: false,
outcomeUnknown: false,
isPlayCompletionPending: false,
isTakeOutCompletionPending: false
}
}), false);
assert.equal(isCutSelectionLocked({
isBusy: false,
fixedSectionBatch: null,
playout: {
phase: "program",
isBusy: true,
outcomeUnknown: false,
isPlayCompletionPending: false,
isTakeOutCompletionPending: false
}
}), true);
};
assert.equal(locks.isOperatorMutationLocked(programState), false,
"safe tail append and independent DB edits remain available");
assert.equal(locks.isPlaylistStructureMutationLocked(programState), true,
"reorder, mouse checkbox changes, and whole-list replacement remain blocked");
assert.equal(locks.isPlaylistProgramEnableMutationLocked(programState), false,
"legacy Space and all-checkbox enabled-state changes remain available in PROGRAM");
assert.equal(locks.canDeleteSelectedPlaylistRows(programState), true,
"a selected row after the on-air row can be deleted");
assert.equal(locks.isCutSelectionLocked(programState), false);
programState.playlist[0].isSelected = true;
programState.playlist[1].isSelected = false;
assert.equal(locks.canDeleteSelectedPlaylistRows(programState), false,
"the on-air row cannot be deleted");
programState.playout.isBusy = true;
assert.equal(locks.isOperatorMutationLocked(programState), true);
assert.equal(locks.isPlaylistProgramEnableMutationLocked(programState), true);
assert.equal(locks.isCutSelectionLocked(programState), true);
programState.playout.isBusy = false;
programState.playout.phase = "prepared";
assert.equal(locks.isPlaylistProgramEnableMutationLocked(programState), true,
"PREPARED remains locked without an original parity basis");
programState.playout.phase = "idle";
assert.equal(locks.isPlaylistProgramEnableMutationLocked(programState), false);
});
test("cut drag feedback follows the pointer and exposes allowed target state", () => {
@@ -444,10 +470,14 @@ test("playlist controls send intent for selection, enabled state, movement and d
app.lastIndexOf('} else if (event.key === " "'));
assert.match(
deleteBranch,
/if \(!playlistMutationLocked\) send\("delete-selected-playlist-rows"/);
/if \(!playlistDeleteSelectedLocked\) send\("delete-selected-playlist-rows"/);
assert.doesNotMatch(deleteBranch, /event\.preventDefault\(\)/);
assert.doesNotMatch(app, /send\("toggle-active-playlist-enabled"/);
assert.match(app, /enabled: enabled\.checked !== true/);
assert.match(app, /send\("toggle-active-playlist-enabled", \{\}\)/);
const spaceBranch = app.slice(
app.lastIndexOf('} else if (event.key === " "'),
app.lastIndexOf('} else if ((event.key === "Home"'));
assert.match(spaceBranch, /if \(!playlistProgramEnableMutationLocked\)/);
assert.doesNotMatch(spaceBranch, /set-playlist-enabled/);
assert.match(app, /if \(!playlistMutationLocked\)/);
assert.match(app, /select-playlist-boundary/);
assert.match(app, /event\.key === "Home"/);
@@ -460,13 +490,22 @@ test("playlist controls send intent for selection, enabled state, movement and d
const boundaryHelperStart = app.indexOf("function selectPlaylistBoundary");
const boundaryHelper = app.slice(
boundaryHelperStart,
app.indexOf("function clearDelayedClick", boundaryHelperStart));
app.indexOf("function movePlaylistKeyboardSelection", boundaryHelperStart));
assert.doesNotMatch(boundaryHelper, /\.focus\(/);
assert.match(boundaryHelper, /scrollIntoView/);
const arrowHelperStart = app.indexOf("function movePlaylistKeyboardSelection");
const arrowHelper = app.slice(
arrowHelperStart,
app.indexOf("function clearDelayedClick", arrowHelperStart));
assert.match(arrowHelper, /event\.key === "ArrowUp"/);
assert.match(arrowHelper, /next\.focus\(\{ preventScroll: true \}\)/);
assert.match(arrowHelper, /send\("select-playlist-row"/);
assert.match(app, /event\.key === "ArrowDown"/);
assert.match(app, /row\.isSelected/);
assert.match(app, /row\.isActive === true/);
assert.match(app, /enabled\.setAttribute\("aria-disabled", playlistMutationLocked/);
assert.match(app, /interactionDisabled = playlistMutationLocked/);
assert.match(app, /enabled\.setAttribute\("aria-disabled", playlistStructureMutationLocked/);
assert.match(app, /interactionDisabled = playlistStructureMutationLocked/);
assert.match(app, /playlistEnableAll\.disabled = playlistProgramEnableMutationLocked/);
assert.match(app, /playlistEnableAll\.indeterminate/);
});
@@ -581,9 +620,9 @@ test("playlist reorder starts only from the 30 px row header pointer handle", ()
element.classList.contains("playlist-data-row") &&
element.getAttribute("aria-selected") === "true");
}
if (selector === ".dragging, .drag-before, .drag-after") {
if (selector === ".dragging, .drag-allowed, .drag-before, .drag-after") {
return descendants.filter((element) =>
["dragging", "drag-before", "drag-after"]
["dragging", "drag-allowed", "drag-before", "drag-after"]
.some((name) => element.classList.contains(name)));
}
return [];
@@ -597,6 +636,7 @@ test("playlist reorder starts only from the 30 px row header pointer handle", ()
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragGesture = null;
let playlistMutationLocked = false;
let playlistStructureMutationLocked = false;
let cutDragSize = { width: 4, height: 4 };
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
@@ -833,6 +873,153 @@ test("operator catalog drag rejects an interactive child pointer origin", () =>
"catalog-draft-row-a");
});
test("operator catalog stock result rendering preserves keyed button identity", () => {
class MiniElement {
constructor(tagName) {
this.tagName = tagName;
this.dataset = {};
this.className = "";
this.attributes = new Map();
this.parentElement = null;
this.children = [];
this.children.item = index => this.children[index] ?? null;
}
append(...children) {
for (const child of children) this.insertBefore(child, null);
}
insertBefore(child, reference) {
let insertionIndex = reference == null
? this.children.length
: this.children.indexOf(reference);
if (insertionIndex < 0) throw new Error("reference is not a child");
if (child.parentElement) {
const oldParent = child.parentElement;
const oldIndex = oldParent.children.indexOf(child);
oldParent.children.splice(oldIndex, 1);
if (oldParent === this && oldIndex < insertionIndex) insertionIndex--;
}
this.children.splice(insertionIndex, 0, child);
child.parentElement = this;
return child;
}
querySelector(selector) {
if (!selector.startsWith(".")) return null;
const className = selector.slice(1);
return this.children.find(child =>
child.className.split(/\s+/).includes(className)) || null;
}
querySelectorAll(selector) {
if (selector === "button[data-operator-catalog-stock-result-id]") {
return this.children.filter(child =>
child.dataset.operatorCatalogStockResultId !== undefined);
}
return [];
}
setAttribute(name, value) { this.attributes.set(name, value); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
remove() {
if (!this.parentElement) return;
const siblings = this.parentElement.children;
siblings.splice(siblings.indexOf(this), 1);
this.parentElement = null;
}
}
const helperStart = app.indexOf("function reconcileOperatorCatalogStockResults");
const helperEnd = app.indexOf("function renderOperatorCatalog", helperStart);
assert.ok(helperStart >= 0 && helperEnd > helperStart);
const container = new MiniElement("div");
const document = { createElement: tagName => new MiniElement(tagName) };
const reconcile = new Function(
"operatorCatalogStockResults",
"document",
"configureRovingList",
`${app.slice(helperStart, helperEnd)}; return reconcileOperatorCatalogStockResults;`
)(container, document, () => {});
const rowA = {
resultId: "stock-a", displayName: "삼성전자", market: "KRX",
isSelected: false, isCompatible: true
};
const rowB = {
resultId: "stock-b", displayName: "SK하이닉스", market: "KRX",
isSelected: false, isCompatible: true
};
reconcile([rowA, rowB]);
const firstButton = container.children[0];
const secondButton = container.children[1];
reconcile([{ ...rowA, isSelected: true }, rowB]);
assert.strictEqual(container.children[0], firstButton);
assert.strictEqual(container.children[1], secondButton);
assert.equal(firstButton.className, "selected");
reconcile([rowB, rowA]);
assert.strictEqual(container.children[0], secondButton);
assert.strictEqual(container.children[1], firstButton);
reconcile([rowB]);
assert.deepEqual(Array.from(container.children), [secondButton]);
assert.equal(firstButton.parentElement, null);
});
async function runOperatorCatalogStockDoubleClickContract(interClickDelayMilliseconds) {
class DelegateTarget {
constructor() { this.listeners = new Map(); }
addEventListener(type, callback) { this.listeners.set(type, callback); }
fire(type, target, detail) {
let prevented = false;
this.listeners.get(type)({
target,
detail,
preventDefault() { prevented = true; }
});
return prevented;
}
}
const listenerStart = app.indexOf(
'operatorCatalogStockResults.addEventListener("dblclick"');
const listenerEnd = app.indexOf(
'operatorCatalogAddStock.addEventListener("click"', listenerStart);
assert.ok(listenerStart >= 0 && listenerEnd > listenerStart);
const stockResults = new DelegateTarget();
const sent = [];
new Function(
"operatorCatalogStockResults",
"send",
app.slice(listenerStart, listenerEnd)
)(stockResults, (type, payload) => sent.push({ type, payload }));
const row = {
disabled: false,
dataset: { operatorCatalogStockResultId: "stock-005930" },
closest(selector) {
return selector === "button[data-operator-catalog-stock-result-id]" ? this : null;
}
};
stockResults.fire("click", row, 1);
await new Promise(resolve => setTimeout(resolve, interClickDelayMilliseconds));
stockResults.fire("click", row, 2);
assert.equal(stockResults.fire("dblclick", row, 2), true);
assert.deepEqual(sent, [
{
type: "select-operator-catalog-stock",
payload: { resultId: "stock-005930" }
},
{
type: "activate-operator-catalog-stock",
payload: { resultId: "stock-005930", buyAmount: null }
}
]);
}
test("operator catalog fast stock double-click selects then activates exactly once", async () => {
await runOperatorCatalogStockDoubleClickContract(10);
});
test("operator catalog slow Windows stock double-click selects then activates exactly once", async () => {
await runOperatorCatalogStockDoubleClickContract(350);
});
test("market tabs send closed identity intents and reconcile C# order", () => {
assert.match(app, /send\("select-tab"/);
assert.match(app, /send\("hover-swap-tabs"/);
@@ -954,7 +1141,7 @@ test("market tab dragover sends once after crossing and does not poison the busy
assert.deepEqual(requests, ["target", "target", "target"]);
});
test("fixed catalog renders C# view and sends opaque leaf or section intent", () => {
test("fixed catalog sends opaque leaf and parent batch intents", () => {
assert.match(app, /state\.fixedCatalog/);
assert.match(app, /dataset\.actionId/);
assert.match(app, /dataset\.sectionIndex/);
@@ -1096,9 +1283,15 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re
}
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
removeAttribute(name) { this.attributes.delete(name); }
focus() { document.activeElement = this; }
scrollIntoView() { this.scrolled = true; }
closest(selector) { return selector === "[data-tree-node-key]" ? this : null; }
closest(selector) {
if (selector === "[data-tree-node-key]") return this;
if (selector === "details[data-tree-section-key]") return this.parentElement || null;
if (selector === "[data-roving-tree=\"true\"]") return this.tree || null;
return null;
}
}
class MiniDetails {
@@ -1111,6 +1304,7 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re
this.summary.querySelector = selector => selector === "[data-tree-disclosure]" ? {} : null;
this.leaf = new MiniNode({ actionId: leafId });
this.leaf.textContent = leafId;
this.leaf.parentElement = this;
}
querySelector(selector) {
return selector.startsWith(":scope > summary") ? this.summary : null;
@@ -1119,8 +1313,20 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re
}
class MiniTree {
constructor() { this.details = []; this.scrollTop = 0; }
replaceWith(details) { this.details = details; }
constructor() {
this.dataset = {};
this.attributes = new Map();
this.details = [];
this.scrollTop = 0;
}
replaceWith(details) {
this.details = details;
this.details.forEach(item => {
item.parentElement = this;
item.summary.tree = this;
item.leaf.tree = this;
});
}
querySelectorAll(selector) {
if (selector === "details" || selector === "details[data-tree-section-key]") {
return this.details;
@@ -1133,6 +1339,7 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re
if (selector === "[data-tree-node-key].tree-node-selected") {
return nodes.filter(node => node.classList.contains("tree-node-selected"));
}
if (selector === "[data-roving-tree=\"true\"]") return [];
return [];
}
querySelector(selector) {
@@ -1144,22 +1351,39 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re
contains(node) {
return this.details.some(details => details.summary === node || details.leaf === node);
}
setAttribute(name, value) { this.attributes.set(name, String(value)); }
removeAttribute(name) { this.attributes.delete(name); }
}
const categoryTree = new MiniTree();
const catalogExpandAll = { checked: false };
const harness = new Function(
"categoryTree", "catalogExpandAll", "document", "modalFocusManager", `
"categoryTree", "catalogExpandAll", "document", "modalFocusManager",
"configureRovingList", "setRovingListTabStop", `
let renderedTreeTabId = null;
let treeInteractionLocked = false;
let treeFocusOwnedBeforeRender = false;
let themeResultClickTimer = null;
let themeResultPendingSelection = null;
let comparisonTargetPendingSelection = null;
const treeUiStateByTab = new Map();
const pendingTreeResetTabs = new Set();
function clearDelayedClick() {}
function clearThemeResultPendingSelection() {}
function clearComparisonTargetPendingSelection() {}
${app.slice(helperStart, helperEnd)}
return { beginTreeRender, finishTreeRender, selectTreeNode };
`)(categoryTree, catalogExpandAll, document, { getActiveModal() { return null; } });
`)(
categoryTree,
catalogExpandAll,
document,
{ getActiveModal() { return null; } },
(list, rows, selected) => {
list.dataset.rovingList = "true";
rows.forEach(row => { row.dataset.rovingRow = "true"; });
if (selected) selected.tabIndex = 0;
},
() => true);
const state = tabId => ({
isBusy: false,
fixedSectionBatch: null,
@@ -1458,7 +1682,8 @@ test("named DB playlists and playout use separate closed native intents", () =>
assert.match(app, /dataset\.namedPlaylistDefinitionId/);
assert.match(app,
/beginNamedPlaylistSelection\(definition\.dataset\.namedPlaylistDefinitionId\)/);
assert.match(app, /dbLoadButton\.disabled = operatorMutationLocked \|\| busy \|\| !named/);
assert.match(app,
/dbLoadButton\.disabled = playlistStructureMutationLocked \|\| busy \|\| !named/);
assert.match(app, /named\.isWriteQuarantined/);
assert.doesNotMatch(app, /programCode|listText/i);
@@ -1481,6 +1706,45 @@ test("named DB playlists and playout use separate closed native intents", () =>
assert.doesNotMatch(playout, /fetch\s*\(|localStorage|sessionStorage|sceneCode|filePath/);
});
test("named playlist load and save send command-specific closed payloads", () => {
const start = app.indexOf("function beginNamedPlaylistCommand");
const end = app.indexOf("function isMatchingNamedPlaylistReceipt", start);
assert.ok(start >= 0 && end > start);
const sent = [];
const begin = new Function("send", `
let pendingNamedPlaylistCommand = null;
let lastCommandSequence = 40;
function isNamedPlaylistActionLocked() { return false; }
function lockNamedPlaylistControls() {}
${app.slice(start, end)}
return beginNamedPlaylistCommand;
`)((type, payload) => {
sent.push({ type, payload });
return true;
});
assert.equal(begin(
"load-named-playlist-by-id",
"named-definition-AAAAAAAAAAEF",
false), true);
assert.deepEqual(sent.pop(), {
type: "load-named-playlist-by-id",
payload: { definitionId: "named-definition-AAAAAAAAAAEF" }
});
assert.equal(begin(
"save-current-named-playlist-to",
"named-definition-AAAAAAAAAAEF",
true), true);
assert.deepEqual(sent.pop(), {
type: "save-current-named-playlist-to",
payload: {
definitionId: "named-definition-AAAAAAAAAAEF",
showSavedAlert: true
}
});
});
test("GraphE preserves name header sorting and directional find in native authority", () => {
for (const intent of [
"find-manual-financial", "toggle-manual-financial-name-sort"
@@ -1489,11 +1753,46 @@ test("GraphE preserves name header sorting and directional find in native author
assert.match(app, /dataset\.manualFindDirection/);
assert.match(app, /dataset\.manualNameSort/);
assert.match(app, /dataset\.manualGeneration/);
assert.match(app, /row\.isDataReadable === false/);
assert.match(app, /manual-financial-row-unreadable/);
assert.match(app, /manual-financial-row-legacy/);
assert.match(styles, /button\.manual-financial-row-unreadable/);
assert.match(styles, /button\.manual-financial-row-legacy/);
assert.match(app, /generation: generation/);
assert.match(app, /manual-financial-find-query[\s\S]*down\.click\(\)/);
assert.doesNotMatch(app, /(?:INPUT_PIE|INPUT_GROW|INPUT_SELL|INPUT_PROFIT)[\s\S]*manual-financial-find-query/);
});
test("GraphE raw editor preserves untouched legacy storage byte-for-byte", () => {
const start = app.indexOf("function blankManualRecord");
const end = app.indexOf("function cloneManualRecord", start);
assert.ok(start >= 0 && end > start);
const helpers = new Function(`
${app.slice(start, end)}
return { manualRecordFromRaw, preserveManualRawStorageValue };
`)();
const originals = ["", "_", "label_value_extra", " 12 ", "\u0001legacy", "\u0000tail"];
const record = helpers.manualRecordFromRaw({
screen: "sales",
stockName: "Alpha",
storageValues: originals
});
originals.forEach(function (original, index) {
assert.equal(
helpers.preserveManualRawStorageValue(record, index, "normalized_0"),
original);
});
record.rawDirtyStorageIndices.push(2);
assert.equal(
helpers.preserveManualRawStorageValue(record, 2, "label_changed"),
"label_changed");
assert.deepEqual(record.rawOriginalStorageValues, originals);
assert.match(app, /manualRawStorageIndex/);
assert.match(app, /send\("save-manual-financial-raw", \{ record: rawRecord \}\)/);
});
test("FSell and VI dialogs send opaque native intents and never own file or stock authority", () => {
for (const id of ["manual-list-modal", "manual-list-workspace", "manual-list-close"]) {
assert.match(markup, new RegExp("id=\\\"" + id + "\\\""));
@@ -1519,11 +1818,73 @@ test("FSell and VI dialogs send opaque native intents and never own file or stoc
assert.match(manualLists, /rowId: row\.rowId/);
assert.match(manualLists, /resultId: row\.resultId/);
assert.match(manualLists, /itemId: row\.itemId/);
assert.match(manualLists, /addEventListener\("click"[\s\S]*setTimeout[\s\S]*send\("select-manual-vi-result"/);
assert.match(manualLists,
/addEventListener\("click"[\s\S]*deferLegacySingleClick[\s\S]*send\("select-manual-vi-result"/);
assert.match(manualLists, /addEventListener\("dblclick"[\s\S]*send\("add-manual-vi-result"/);
assert.match(manualLists, /send\("search-manual-vi", \{ query: input\.value \}\)/);
assert.match(manualLists, /manual\.searchIsTruncated === true/);
assert.match(manualLists, /검색 결과 일부만 표시 중입니다\. 검색어를 더 구체적으로 입력해 주세요\./u);
assert.match(styles, /\.manual-list-status\.warning\s*\{/);
assert.doesNotMatch(manualLists, /if \(input\.value\.trim\(\)\)/);
assert.doesNotMatch(manualLists, /(?:rowVersion|stockCode|item\.code|filePath|directory|localStorage|fetch\s*\()/i);
});
test("ThemeA create editor changes KRX or NXT without clearing the current name draft", () => {
for (const id of [
"operator-catalog-new-market",
"operator-catalog-editor-market-field",
"operator-catalog-editor-market"
]) assert.match(markup, new RegExp("id=\\\"" + id + "\\\""));
const renderStart = app.indexOf("function renderOperatorCatalog");
const renderEnd = app.indexOf("function renderDialog", renderStart);
assert.ok(renderStart >= 0 && renderEnd > renderStart);
const render = app.slice(renderStart, renderEnd);
assert.match(render,
/const canChangeCreateThemeMarket = isTheme && catalog\.mode === "create"/);
assert.match(render,
/operatorCatalogEditorMarketField\.hidden = !canChangeCreateThemeMarket/);
assert.match(render,
/operatorCatalogEditorMarket\.disabled = !canChangeCreateThemeMarket/);
assert.match(app,
/root\.querySelectorAll\("button, input, select, textarea"\)[\s\S]*control\.disabled = true/);
assert.match(render,
/operatorCatalogEditorMarket\.value = catalog\.newThemeMarket === "nxt" \? "nxt" : "krx"/);
const changeStart = app.indexOf(
'operatorCatalogEditorMarket.addEventListener("change"');
const changeEnd = app.indexOf(
"operatorCatalogResults.addEventListener", changeStart);
assert.ok(changeStart >= 0 && changeEnd > changeStart);
const marketChange = app.slice(changeStart, changeEnd);
assert.match(marketChange, /operatorCatalogState\.mode !== "create"/);
assert.match(marketChange,
/operatorCatalogNameDraft = operatorCatalogName\.value/);
assert.match(marketChange,
/send\("change-create-theme-market", \{[\s\S]*market:[\s\S]*editorName: operatorCatalogName\.value\.trim\(\)/);
assert.doesNotMatch(marketChange, /operatorCatalogDraftKey\s*=\s*""/);
assert.doesNotMatch(marketChange, /operatorCatalogNameDraft\s*=\s*""/);
assert.doesNotMatch(marketChange, /begin-create-theme-catalog/);
const listCreateStart = app.indexOf(
'operatorCatalogNew.addEventListener("click"');
assert.ok(listCreateStart >= 0 && changeStart > listCreateStart);
const listCreate = app.slice(listCreateStart, changeStart);
assert.match(listCreate,
/send\("begin-create-theme-catalog", \{[\s\S]*operatorCatalogNewMarket\.value/);
});
test("UC3 keeps deferred world rows visible but disables ambiguous selection", () => {
const start = app.indexOf("function renderComparison");
const end = app.indexOf("function renderTheme", start);
const comparison = app.slice(start, end);
assert.match(comparison, /result\.disabled = row\.canSelect === false/);
assert.match(comparison, /row\.unavailableReason/);
assert.match(comparison, /deferredUnsafeRowCount/);
assert.match(comparison, /선택과 송출을 차단합니다/);
});
test("separator rows preserve the original drag gesture", () => {
assert.match(app, /element\.dataset\.cutDragEnabled = cutSelectionEnabled && !cutDragLocked/);
assert.match(app, /element\.draggable = false/);