feat: advance legacy UI behavior parity

This commit is contained in:
2026-07-18 10:18:29 +09:00
parent d7ec571343
commit 1302b1b92f
71 changed files with 11244 additions and 1153 deletions

View File

@@ -10,6 +10,7 @@ const webRoot = path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParity
const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8");
const playout = fs.readFileSync(path.join(webRoot, "playout-ui.js"), "utf8");
const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
test("legacy shell exposes the original stock, cut, playlist and playout controls", () => {
for (const id of [
@@ -182,7 +183,11 @@ test("legacy child modal Escape is inert and cannot leak into the playout listen
"modalFocusManager",
"cutDragGesture",
"clearCutDragGesture",
"playlistDragGesture",
"clearPlaylistDragGesture",
"deferredPlaylistPlainSelectionRowId",
"legacyAlertDialog",
"legacyDialogIsConfirmation",
"send",
app.slice(appHandlerBodyStart, appHandlerEnd));
@@ -238,7 +243,11 @@ test("legacy child modal Escape is inert and cannot leak into the playout listen
{ getActiveModal() { return childDialog; } },
null,
() => {},
null,
() => {},
null,
legacyAlertDialog,
false,
(type) => sent.push(type));
assert.equal(modal.hidden, false);
assert.equal(escape.defaultPrevented, true);
@@ -263,13 +272,17 @@ test("legacy child modal Escape is inert and cannot leak into the playout listen
{ getActiveModal() { return legacyAlertDialog; } },
null,
() => {},
null,
() => {},
null,
legacyAlertDialog,
false,
(type) => sent.push(type));
assert.equal(alertEscape.defaultPrevented, true);
assert.deepEqual(sent, ["dismiss-dialog"]);
});
test("started cut drag owns playout keys and Escape cancels without TAKE OUT", () => {
test("started pointer drag owns playout keys and Escape cancels without TAKE OUT", () => {
const keydownMarker = 'document.addEventListener("keydown", function (event) {';
const start = app.lastIndexOf(keydownMarker) + keydownMarker.length;
const end = app.indexOf("\n });", start);
@@ -278,7 +291,11 @@ test("started cut drag owns playout keys and Escape cancels without TAKE OUT", (
"modalFocusManager",
"cutDragGesture",
"clearCutDragGesture",
"playlistDragGesture",
"clearPlaylistDragGesture",
"deferredPlaylistPlainSelectionRowId",
"legacyAlertDialog",
"legacyDialogIsConfirmation",
"send",
app.slice(start, end));
const sent = [];
@@ -296,11 +313,36 @@ test("started cut drag owns playout keys and Escape cancels without TAKE OUT", (
manager,
{ started: true },
() => { clears += 1; },
null,
() => {},
null,
{},
false,
(type) => sent.push(type));
assert.equal(event.defaultPrevented, true, key + " must stay inside drag loop");
}
assert.equal(clears, 1, "only Escape cancels the active drag");
const playlistEscape = {
key: "Escape",
repeat: false,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; }
};
let playlistClears = 0;
handle(
playlistEscape,
manager,
null,
() => {},
{ started: true },
() => { playlistClears += 1; },
"row-a",
{},
false,
(type) => sent.push(type));
assert.equal(playlistEscape.defaultPrevented, true);
assert.equal(playlistClears, 1);
assert.deepEqual(sent, []);
});
@@ -359,7 +401,12 @@ test("playlist reorder reconciles keyed DOM nodes without discarding focus", ()
assert.doesNotMatch(app, /playlistRows\.replaceChild/);
});
test("playlist native drag sends one source row and rejects checkbox drag", () => {
test("playlist reorder starts only from the 30 px row header pointer handle", () => {
assert.match(markup,
/playlist-header playlist-row">\s*<div aria-hidden="true"><\/div><div>송출<\/div>/u);
assert.match(styles,
/\.playlist-row\s*\{[^}]*grid-template-columns:\s*30px 40px 100px/u);
assert.match(styles, /\.playlist-data-row \.playlist-row-header/u);
class MiniClassList {
constructor() { this.names = new Set(); }
add(...names) { names.forEach((name) => this.names.add(name)); }
@@ -381,6 +428,8 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
this.attributes = new Map();
this.classList = new MiniClassList();
this.listeners = new Map();
this.bounds = { left: 0, top: 0, width: 30, height: 20, right: 30, bottom: 20 };
this.capturedPointerId = null;
}
set className(value) {
@@ -399,6 +448,12 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
return candidate === this || this.children.some((child) => child.contains(candidate));
}
closest(selector) {
if (selector.startsWith(".")) {
const className = selector.slice(1);
return this.classList.contains(className)
? this
: this.parentElement?.closest(selector) || null;
}
const tags = selector.split(",").map((value) => value.trim().toUpperCase());
return tags.includes(this.tagName)
? this
@@ -415,9 +470,25 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
listener.call(this, event);
}
}
getBoundingClientRect() { return { top: 0, height: 20 }; }
getBoundingClientRect() { return this.bounds; }
setPointerCapture(pointerId) { this.capturedPointerId = pointerId; }
hasPointerCapture(pointerId) { return this.capturedPointerId === pointerId; }
releasePointerCapture(pointerId) {
if (this.capturedPointerId === pointerId) this.capturedPointerId = null;
}
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
querySelector(selector) {
const descendants = this.querySelectorAll("*");
if (selector === "input") {
return descendants.find((element) => element.tagName === "INPUT") || null;
}
if (selector.startsWith(".")) {
return descendants.find((element) =>
element.classList.contains(selector.slice(1))) || null;
}
return null;
}
querySelectorAll(selector) {
const descendants = [];
const visit = (element) => {
@@ -427,6 +498,7 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
});
};
visit(this);
if (selector === "*") return descendants;
if (selector === '.playlist-data-row[aria-selected="true"]') {
return descendants.filter((element) =>
element.classList.contains("playlist-data-row") &&
@@ -441,20 +513,14 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
}
}
class MiniDataTransfer {
constructor() { this.values = new Map(); }
setData(type, value) { this.values.set(type, value); }
getData(type) { return this.values.get(type) || ""; }
}
const sourceStart = app.indexOf("function clearPlaylistDragVisuals");
const sourceEnd = app.indexOf("function renderPlaylist", sourceStart);
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
const createHarness = new Function("document", "playlistRows", "send", "playlistDragMime", `
let draggedPlaylistRowId = null;
const createHarness = new Function("document", "playlistRows", "send", "window", `
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragBlockedRowId = null;
let playlistDragGesture = null;
let playlistMutationLocked = false;
let cutDragSize = { width: 4, height: 4 };
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
@@ -462,29 +528,50 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
${app.slice(sourceStart, sourceEnd)}
return {
createPlaylistElement,
onPlaylistDragStart,
state: () => ({ draggedPlaylistRowId, deferredPlaylistPlainSelectionRowId })
state: () => ({ playlistDragGesture, deferredPlaylistPlainSelectionRowId })
};
`);
const document = { createElement: (tagName) => new MiniElement(tagName) };
let pointedElement = null;
const document = {
createElement: (tagName) => new MiniElement(tagName),
elementFromPoint: () => pointedElement
};
const playlistRows = new MiniElement("div");
playlistRows.bounds = { left: 0, top: 0, width: 500, height: 100,
right: 500, bottom: 100 };
const sent = [];
const legacyCutDrag = require(path.join(webRoot, "legacy-cut-drag.js"));
const harness = createHarness(
document, playlistRows, (type, payload) => sent.push({ type, payload }),
"text/x-mbn-playlist-row");
{ LegacyCutDrag: legacyCutDrag });
const source = harness.createPlaylistElement({ rowId: "row-a" });
source.bounds = { left: 0, top: 0, width: 500, height: 20, right: 500, bottom: 20 };
source.setAttribute("aria-selected", "true");
playlistRows.appendChild(source);
const selectedPeer = harness.createPlaylistElement({ rowId: "row-b" });
selectedPeer.bounds = { left: 0, top: 20, width: 500, height: 20,
right: 500, bottom: 40 };
selectedPeer.setAttribute("aria-selected", "true");
playlistRows.appendChild(selectedPeer);
const target = harness.createPlaylistElement({ rowId: "row-c" });
target.bounds = { left: 0, top: 40, width: 500, height: 20,
right: 500, bottom: 60 };
target.setAttribute("aria-selected", "false");
playlistRows.appendChild(target);
const checkbox = source.children[0].children[0];
const blockedTransfer = new MiniDataTransfer();
let checkboxDragPrevented = false;
const sourceHeader = source.children[0];
const checkbox = source.children[1].children[0];
const targetHeader = target.children[0];
sourceHeader.bounds = { left: 0, top: 0, width: 30, height: 20,
right: 30, bottom: 20 };
targetHeader.bounds = { left: 0, top: 40, width: 30, height: 20,
right: 30, bottom: 60 };
assert.equal(source.draggable, false);
assert.equal(checkbox.draggable, false);
assert.equal(source.listeners.has("pointermove"), false);
assert.equal(sourceHeader.listeners.has("pointermove"), true);
source.invoke("pointerdown", {
button: 0,
currentTarget: source,
@@ -497,44 +584,76 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
payload: { rowId: "row-a" }
}]);
sent.length = 0;
harness.onPlaylistDragStart({
currentTarget: source,
// Chromium reports the draggable row as dragstart.target even though
// pointerdown originated on the checkbox.
target: source,
dataTransfer: blockedTransfer,
preventDefault: () => { checkboxDragPrevented = true; }
});
assert.equal(checkboxDragPrevented, true);
assert.equal(blockedTransfer.getData("text/x-mbn-playlist-row"), "");
assert.equal(harness.state().playlistDragGesture, null);
const pointerDown = new Event("pointerdown", { cancelable: true });
Object.assign(pointerDown, { button: 0, ctrlKey: false, shiftKey: false });
source.dispatchEvent(pointerDown);
const dataPointerDown = {
button: 0, pointerId: 1, clientX: 100, clientY: 10,
ctrlKey: false, shiftKey: false, target: source, currentTarget: source
};
source.invoke("pointerdown", dataPointerDown);
assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, "row-a");
assert.equal(harness.state().playlistDragGesture, null);
source.invoke("pointerup", { ...dataPointerDown, button: 0 });
assert.deepEqual(sent, [{
type: "select-playlist-row",
payload: { rowId: "row-a", control: false, shift: false }
}]);
sent.length = 0;
const pointerDown = {
button: 0, pointerId: 7, clientX: 10, clientY: 10,
ctrlKey: false, shiftKey: false, target: sourceHeader,
stopPropagation() {}
};
sourceHeader.invoke("pointerdown", { ...pointerDown, currentTarget: sourceHeader });
assert.deepEqual(sent, [{
type: "activate-playlist-row",
payload: { rowId: "row-a" }
}]);
sent.length = 0;
assert.equal(sourceHeader.hasPointerCapture(7), true);
assert.equal(harness.state().playlistDragGesture.started, false);
// Crossing the system drag rectangle over a normal data cell starts the
// gesture but cannot establish a drop target.
pointedElement = target.children[2];
sourceHeader.invoke("pointermove", {
pointerId: 7, buttons: 1, clientX: 100, clientY: 45,
currentTarget: sourceHeader, preventDefault() {}
});
assert.equal(harness.state().playlistDragGesture.started, true);
assert.equal(harness.state().playlistDragGesture.targetRowId, null);
sourceHeader.invoke("pointerup", {
pointerId: 7, button: 0, clientX: 100, clientY: 45,
currentTarget: sourceHeader, preventDefault() {}
});
assert.deepEqual(sent, []);
const transfer = new MiniDataTransfer();
const dragStart = new Event("dragstart", { cancelable: true });
Object.assign(dragStart, { dataTransfer: transfer });
source.dispatchEvent(dragStart);
assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, null);
assert.equal(transfer.getData("text/x-mbn-playlist-row"), "row-a");
assert.deepEqual(sent, []);
const dragOver = new Event("dragover", { cancelable: true });
Object.assign(dragOver, { dataTransfer: transfer, clientY: 1 });
target.dispatchEvent(dragOver);
assert.equal(dragOver.defaultPrevented, true);
const drop = new Event("drop", { cancelable: true });
Object.assign(drop, { dataTransfer: transfer, clientY: 1 });
target.dispatchEvent(drop);
// A second gesture dropped on another row's 30 px header sends the existing
// stable-row C# contract exactly once.
sourceHeader.invoke("pointerdown", { ...pointerDown, pointerId: 8,
currentTarget: sourceHeader });
assert.deepEqual(sent, [{
type: "activate-playlist-row",
payload: { rowId: "row-a" }
}]);
sent.length = 0;
pointedElement = targetHeader;
sourceHeader.invoke("pointermove", {
pointerId: 8, buttons: 1, clientX: 10, clientY: 45,
currentTarget: sourceHeader, preventDefault() {}
});
sourceHeader.invoke("pointerup", {
pointerId: 8, button: 0, clientX: 10, clientY: 45,
currentTarget: sourceHeader, preventDefault() {}
});
assert.deepEqual(sent, [{
type: "reorder-playlist-rows",
payload: { sourceRowId: "row-a", targetRowId: "row-c", position: "before" }
}]);
assert.equal(Object.hasOwn(sent[0].payload, "selectedRowIds"), false);
assert.equal(sourceHeader.hasPointerCapture(8), false);
assert.equal(harness.state().playlistDragGesture, null);
});
test("cut drag uses the original pointer rectangle and drops only inside the playlist", () => {
@@ -1077,13 +1196,96 @@ test("fixed-262 ignores a single click and emits exactly one intent on double cl
});
test("industry workspace sends index and opaque action intents only", () => {
const renderStart = app.indexOf("function renderIndustry");
const renderEnd = app.indexOf("function renderOverseas", renderStart);
const industryRender = app.slice(renderStart, renderEnd);
assert.match(app, /state\.industry/);
assert.match(app, /dataset\.industryIndex/);
assert.match(app, /dataset\.industryActionId/);
assert.match(app, /send\("select-industry"/);
assert.match(app, /send\("double-click-industry"/);
assert.match(app, /send\("swap-industries"/);
assert.match(app, /send\("set-industry-comparison-text"/);
assert.match(app, /send\("activate-industry-action"/);
assert.doesNotMatch(app, /swap\.textContent = "↕ 교환"/);
assert.match(app,
/summary\.textContent = industry\.market === "kospi" \? "\[코스피\]" : "\[코스닥\]"/);
assert.match(app, /summary\.dataset\.industrySectionIndex = "0"/);
assert.doesNotMatch(app, /const sections = new Map\(\)/);
assert.doesNotMatch(industryRender, /first\.readOnly = true/);
assert.doesNotMatch(industryRender, /second\.readOnly = true/);
assert.match(industryRender, /first\.maxLength = 128/);
assert.match(industryRender, /second\.maxLength = 128/);
});
test("UC2 row selection is immediate and its state receipt preserves the double-click target", () => {
const helperStart = app.indexOf("function markIndustryRowSelected");
const helperEnd = app.indexOf("function renderIndustry", helperStart);
assert.ok(helperStart >= 0 && helperEnd > helperStart);
function row(index) {
return {
id: `industry-row-${index}`,
dataset: { industryIndex: String(index) },
list: null,
selected: false,
classList: {
toggle(_name, selected) { this.owner.selected = selected; },
owner: null
},
setAttribute(name, value) {
if (name === "aria-selected") this.ariaSelected = value;
},
closest(selector) {
return selector === ".industry-list" ? this.list : null;
}
};
}
const first = row(0);
const second = row(1);
first.classList.owner = first;
second.classList.owner = second;
const categoryTree = {
querySelectorAll() { return [first, second]; }
};
const industryList = {
focus(options) { this.focusOptions = options; },
setAttribute(name, value) {
if (name === "aria-activedescendant") this.activeDescendant = value;
}
};
first.list = industryList;
second.list = industryList;
const sent = [];
const helpers = new Function(
"categoryTree",
"send",
`
${app.slice(helperStart, helperEnd)}
return { selectIndustryRow, activateIndustryRow };
`)(
categoryTree,
(type, payload) => sent.push({ type, payload }));
helpers.selectIndustryRow(second);
assert.equal(second.selected, true);
assert.equal(first.selected, false);
assert.deepEqual(sent, [
{ type: "select-industry", payload: { index: 1 } }
]);
helpers.activateIndustryRow(second);
assert.deepEqual(sent[1], {
type: "double-click-industry", payload: { index: 1 }
});
const renderStart = app.indexOf("function renderIndustry");
const renderEnd = app.indexOf("function renderOverseas", renderStart);
const render = app.slice(renderStart, renderEnd);
assert.match(render, /structureKey === industryRenderStructureKey/);
assert.match(render, /existingWorkspace\.querySelectorAll/);
assert.match(render, /previousList\.scrollTop/);
assert.match(render, /list\.scrollTop = previousScrollTop/);
assert.match(render, /replacement\.focus\(\{ preventScroll: true \}\)/);
});
test("comparison workspace sends opaque target, pair and action intents", () => {
@@ -1101,6 +1303,13 @@ test("comparison workspace sends opaque target, pair and action intents", () =>
assert.match(app, /dataset\.comparisonSelectionId/);
assert.match(app, /dataset\.comparisonPairId/);
assert.match(app, /dataset\.comparisonActionId/);
for (const label of ["◀▶", "추가", "UP", "DOWN", "DEL", "DEL ALL"]) {
assert.match(app, new RegExp("\\\"" + label + "\\\""));
}
assert.doesNotMatch(app, /clear\.dataset\.comparisonClear/);
assert.doesNotMatch(app, /window\.confirm\("저장된 종목 비교쌍을 모두 삭제하겠습니까\?"\)/);
assert.match(app, /\? "confirm-native-dialog"/);
assert.match(app, /send\("cancel-native-dialog", \{\}\)/);
assert.doesNotMatch(app, /send\([^\n]*stockCode/);
});
@@ -1140,7 +1349,7 @@ test("theme expert and trading halt actions preserve original single click wirin
assert.doesNotMatch(doubleClickDelegate, new RegExp("send\\(\\\"" + intent + "\\\""));
assert.equal(app.split('send("' + intent + '"').length - 1, 1);
}
assert.match(doubleClickDelegate, /send\("double-click-industry"/);
assert.match(doubleClickDelegate, /activateIndustryRow\(industryRow\)/);
for (const intent of [
"choose-comparison-domestic", "choose-comparison-world", "choose-comparison-fixed"
]) {