feat: restore legacy drag events and Tornado launch integration
This commit is contained in:
@@ -82,6 +82,7 @@ test("playlist controls send intent for selection, enabled state, movement and d
|
||||
"select-playlist-row",
|
||||
"set-playlist-enabled",
|
||||
"set-all-playlist-enabled",
|
||||
"reorder-playlist-rows",
|
||||
"move-selected-playlist-rows",
|
||||
"delete-selected-playlist-rows",
|
||||
"delete-all-playlist-rows"
|
||||
@@ -108,9 +109,314 @@ test("playlist reorder reconciles keyed DOM nodes without discarding focus", ()
|
||||
assert.doesNotMatch(app, /playlistRows\.replaceChild/);
|
||||
});
|
||||
|
||||
test("playlist native drag preserves a multi-selection and rejects checkbox drag", () => {
|
||||
class MiniClassList {
|
||||
constructor() { this.names = new Set(); }
|
||||
add(...names) { names.forEach((name) => this.names.add(name)); }
|
||||
remove(...names) { names.forEach((name) => this.names.delete(name)); }
|
||||
contains(name) { return this.names.has(name); }
|
||||
toggle(name, force) {
|
||||
if (force === undefined ? !this.names.has(name) : force) this.names.add(name);
|
||||
else this.names.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
class MiniElement extends EventTarget {
|
||||
constructor(tagName) {
|
||||
super();
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.dataset = {};
|
||||
this.children = [];
|
||||
this.parentElement = null;
|
||||
this.attributes = new Map();
|
||||
this.classList = new MiniClassList();
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.classList = new MiniClassList();
|
||||
String(value).split(/\s+/).filter(Boolean)
|
||||
.forEach((name) => this.classList.add(name));
|
||||
}
|
||||
|
||||
get className() { return [...this.classList.names].join(" "); }
|
||||
appendChild(child) {
|
||||
child.parentElement = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
contains(candidate) {
|
||||
return candidate === this || this.children.some((child) => child.contains(candidate));
|
||||
}
|
||||
closest(selector) {
|
||||
const tags = selector.split(",").map((value) => value.trim().toUpperCase());
|
||||
return tags.includes(this.tagName)
|
||||
? this
|
||||
: this.parentElement?.closest(selector) || null;
|
||||
}
|
||||
focus() {}
|
||||
addEventListener(type, listener, options) {
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, []);
|
||||
this.listeners.get(type).push(listener);
|
||||
super.addEventListener(type, listener, options);
|
||||
}
|
||||
invoke(type, event) {
|
||||
for (const listener of this.listeners.get(type) || []) {
|
||||
listener.call(this, event);
|
||||
}
|
||||
}
|
||||
getBoundingClientRect() { return { top: 0, height: 20 }; }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
querySelectorAll(selector) {
|
||||
const descendants = [];
|
||||
const visit = (element) => {
|
||||
element.children.forEach((child) => {
|
||||
descendants.push(child);
|
||||
visit(child);
|
||||
});
|
||||
};
|
||||
visit(this);
|
||||
if (selector === '.playlist-data-row[aria-selected="true"]') {
|
||||
return descendants.filter((element) =>
|
||||
element.classList.contains("playlist-data-row") &&
|
||||
element.getAttribute("aria-selected") === "true");
|
||||
}
|
||||
if (selector === ".dragging, .drag-before, .drag-after") {
|
||||
return descendants.filter((element) =>
|
||||
["dragging", "drag-before", "drag-after"]
|
||||
.some((name) => element.classList.contains(name)));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
let deferredPlaylistPlainSelectionRowId = null;
|
||||
let playlistDragBlockedRowId = null;
|
||||
let playlistMutationLocked = false;
|
||||
function dragDropPosition(event, element) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
|
||||
}
|
||||
${app.slice(sourceStart, sourceEnd)}
|
||||
return {
|
||||
createPlaylistElement,
|
||||
onPlaylistDragStart,
|
||||
state: () => ({ draggedPlaylistRowId, deferredPlaylistPlainSelectionRowId })
|
||||
};
|
||||
`);
|
||||
const document = { createElement: (tagName) => new MiniElement(tagName) };
|
||||
const playlistRows = new MiniElement("div");
|
||||
const sent = [];
|
||||
const harness = createHarness(
|
||||
document, playlistRows, (type, payload) => sent.push({ type, payload }),
|
||||
"text/x-mbn-playlist-row");
|
||||
const source = harness.createPlaylistElement({ rowId: "row-a" });
|
||||
source.setAttribute("aria-selected", "true");
|
||||
playlistRows.appendChild(source);
|
||||
const selectedPeer = harness.createPlaylistElement({ rowId: "row-b" });
|
||||
selectedPeer.setAttribute("aria-selected", "true");
|
||||
playlistRows.appendChild(selectedPeer);
|
||||
const target = harness.createPlaylistElement({ rowId: "row-c" });
|
||||
target.setAttribute("aria-selected", "false");
|
||||
playlistRows.appendChild(target);
|
||||
|
||||
const checkbox = source.children[0].children[0];
|
||||
const blockedTransfer = new MiniDataTransfer();
|
||||
let checkboxDragPrevented = false;
|
||||
source.invoke("pointerdown", {
|
||||
button: 0,
|
||||
currentTarget: source,
|
||||
target: checkbox,
|
||||
ctrlKey: false,
|
||||
shiftKey: false
|
||||
});
|
||||
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"), "");
|
||||
|
||||
const pointerDown = new Event("pointerdown", { cancelable: true });
|
||||
Object.assign(pointerDown, { button: 0, ctrlKey: false, shiftKey: false });
|
||||
source.dispatchEvent(pointerDown);
|
||||
assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, "row-a");
|
||||
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);
|
||||
assert.deepEqual(sent, [{
|
||||
type: "reorder-playlist-rows",
|
||||
payload: { sourceRowId: "row-a", targetRowId: "row-c", position: "before" }
|
||||
}]);
|
||||
});
|
||||
|
||||
test("playlist drop zone accepts only the exact selected-cut drag token", () => {
|
||||
class MiniDataTransfer {
|
||||
constructor() { this.values = new Map(); }
|
||||
get types() { return [...this.values.keys()]; }
|
||||
setData(type, value) { this.values.set(type, value); }
|
||||
getData(type) { return this.values.get(type) || ""; }
|
||||
}
|
||||
|
||||
const sourceStart = app.indexOf("function hasDragType");
|
||||
const sourceEnd = app.indexOf(
|
||||
'playlistMoveUp.addEventListener("click"', sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const playlistDropZone = new EventTarget();
|
||||
const sent = [];
|
||||
new Function(
|
||||
"playlistDropZone",
|
||||
"send",
|
||||
"selectedCutsDragToken",
|
||||
app.slice(sourceStart, sourceEnd))(
|
||||
playlistDropZone,
|
||||
(type, payload) => sent.push({ type, payload }),
|
||||
"legacy-selected-cuts");
|
||||
|
||||
const playlistTransfer = new MiniDataTransfer();
|
||||
playlistTransfer.setData("text/x-mbn-playlist-row", "row-a");
|
||||
const playlistDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(playlistDrop, { dataTransfer: playlistTransfer });
|
||||
playlistDropZone.dispatchEvent(playlistDrop);
|
||||
assert.equal(playlistDrop.defaultPrevented, false);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const externalText = new MiniDataTransfer();
|
||||
externalText.setData("text/plain", "external-text");
|
||||
const externalDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(externalDrop, { dataTransfer: externalText });
|
||||
playlistDropZone.dispatchEvent(externalDrop);
|
||||
assert.equal(externalDrop.defaultPrevented, false);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const cutTransfer = new MiniDataTransfer();
|
||||
cutTransfer.setData("text/plain", "legacy-selected-cuts");
|
||||
const cutOver = new Event("dragover", { cancelable: true });
|
||||
Object.assign(cutOver, { dataTransfer: cutTransfer });
|
||||
playlistDropZone.dispatchEvent(cutOver);
|
||||
assert.equal(cutOver.defaultPrevented, true);
|
||||
const cutDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(cutDrop, { dataTransfer: cutTransfer });
|
||||
playlistDropZone.dispatchEvent(cutDrop);
|
||||
assert.equal(cutDrop.defaultPrevented, true);
|
||||
assert.deepEqual(sent, [{ type: "drop-selected-cuts", payload: {} }]);
|
||||
});
|
||||
|
||||
test("operator catalog drag rejects an interactive child pointer origin", () => {
|
||||
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 clearOperatorCatalogDragVisuals");
|
||||
const sourceEnd = app.indexOf("function renderOperatorCatalog", sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const createHarness = new Function(
|
||||
"operatorCatalogDraftRows",
|
||||
"send",
|
||||
"operatorCatalogDragMime",
|
||||
`
|
||||
let draggedOperatorCatalogRowId = null;
|
||||
let operatorCatalogDragBlockedRowId = null;
|
||||
let operatorCatalogMutationLocked = false;
|
||||
function dragDropPosition(event, element) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
|
||||
}
|
||||
${app.slice(sourceStart, sourceEnd)}
|
||||
return {
|
||||
onOperatorCatalogPointerDown,
|
||||
onOperatorCatalogDragStart,
|
||||
state: () => ({ draggedOperatorCatalogRowId, operatorCatalogDragBlockedRowId })
|
||||
};
|
||||
`);
|
||||
const harness = createHarness(
|
||||
{ querySelectorAll: () => [] },
|
||||
() => {},
|
||||
"text/x-mbn-operator-catalog-row");
|
||||
const row = {
|
||||
dataset: { operatorCatalogRowId: "catalog-draft-row-a" },
|
||||
classList: { add() {}, remove() {} },
|
||||
closest: () => null
|
||||
};
|
||||
const input = { closest: () => input };
|
||||
harness.onOperatorCatalogPointerDown({
|
||||
button: 0,
|
||||
currentTarget: row,
|
||||
target: input
|
||||
});
|
||||
const blockedTransfer = new MiniDataTransfer();
|
||||
let prevented = false;
|
||||
harness.onOperatorCatalogDragStart({
|
||||
currentTarget: row,
|
||||
target: row,
|
||||
dataTransfer: blockedTransfer,
|
||||
preventDefault: () => { prevented = true; }
|
||||
});
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(
|
||||
blockedTransfer.getData("text/x-mbn-operator-catalog-row"),
|
||||
"");
|
||||
assert.deepEqual(harness.state(), {
|
||||
draggedOperatorCatalogRowId: null,
|
||||
operatorCatalogDragBlockedRowId: null
|
||||
});
|
||||
|
||||
harness.onOperatorCatalogPointerDown({
|
||||
button: 0,
|
||||
currentTarget: row,
|
||||
target: row
|
||||
});
|
||||
const allowedTransfer = new MiniDataTransfer();
|
||||
harness.onOperatorCatalogDragStart({
|
||||
currentTarget: row,
|
||||
target: row,
|
||||
dataTransfer: allowedTransfer,
|
||||
preventDefault: () => { throw new Error("non-interactive drag was rejected"); }
|
||||
});
|
||||
assert.equal(
|
||||
allowedTransfer.getData("text/x-mbn-operator-catalog-row"),
|
||||
"catalog-draft-row-a");
|
||||
});
|
||||
|
||||
test("market tabs send closed identity intents and reconcile C# order", () => {
|
||||
assert.match(app, /send\("select-tab"/);
|
||||
assert.match(app, /send\("swap-tabs"/);
|
||||
assert.match(app, /send\("hover-swap-tabs"/);
|
||||
assert.match(app, /expectedSourceIndex: sourceIndex/);
|
||||
assert.match(app, /expectedTargetIndex: targetIndex/);
|
||||
assert.doesNotMatch(app, /send\("swap-tabs"/);
|
||||
assert.match(app, /dataset\.tabId/);
|
||||
assert.match(app, /const existingTabs = new Map\(\)/);
|
||||
assert.match(app, /marketTabs\.insertBefore/);
|
||||
|
||||
@@ -5,6 +5,40 @@ namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyThemeCatalogPersistenceServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ThemeMarket.Krx, DataSourceKind.Oracle, 0, false)]
|
||||
[InlineData(ThemeMarket.Krx, DataSourceKind.Oracle, 2, true)]
|
||||
[InlineData(ThemeMarket.Nxt, DataSourceKind.MariaDb, 0, false)]
|
||||
[InlineData(ThemeMarket.Nxt, DataSourceKind.MariaDb, 2, true)]
|
||||
public async Task ThemeNameExistsAsync_UsesOneParameterizedExactReadOnTheSelectedMarket(
|
||||
ThemeMarket market,
|
||||
DataSourceKind expectedSource,
|
||||
long count,
|
||||
bool expected)
|
||||
{
|
||||
var query = new CatalogRecordingQueryExecutor(_ => ThemeCountTable(count));
|
||||
var service = new LegacyThemeCatalogPersistenceService(query);
|
||||
var title = new string('가', 128);
|
||||
|
||||
var exists = await service.ThemeNameExistsAsync(market, title);
|
||||
|
||||
Assert.Equal(expected, exists);
|
||||
var call = Assert.Single(query.Calls);
|
||||
Assert.Equal(expectedSource, call.Source);
|
||||
Assert.Equal(
|
||||
market == ThemeMarket.Krx
|
||||
? LegacyThemeCatalogPersistenceService.NameExistsKrxQueryName
|
||||
: LegacyThemeCatalogPersistenceService.NameExistsNxtQueryName,
|
||||
call.TableName);
|
||||
Assert.Contains("COUNT(*) THEME_COUNT", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("s.SB_TITLE = ", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(title, call.Spec.Sql, StringComparison.Ordinal);
|
||||
var parameter = Assert.Single(call.Spec.Parameters);
|
||||
Assert.Equal("theme_title", parameter.Name);
|
||||
Assert.Equal(title, parameter.Value);
|
||||
Assert.Equal(DbType.String, parameter.DbType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestNextCodeAsync_UsesReadOnlyProviderSpecificQueries()
|
||||
{
|
||||
@@ -341,6 +375,14 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
|
||||
private static CatalogRecordingQueryExecutor UnusedQuery() =>
|
||||
new(_ => throw new InvalidOperationException("No read query was expected."));
|
||||
|
||||
private static DataTable ThemeCountTable(long count)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("THEME_COUNT", typeof(long));
|
||||
table.Rows.Add(count);
|
||||
return table;
|
||||
}
|
||||
|
||||
private static OperatorCatalogDbParameter Parameter(
|
||||
OperatorCatalogDbCommand command,
|
||||
string name) =>
|
||||
|
||||
@@ -213,6 +213,30 @@ public sealed class LegacyOperatorControllerTests
|
||||
Assert.Single(swapped.Tabs, tab => tab.IsActive).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TabHoverSwapUsesExpectedPositionsSoDuplicateDeliveryCannotSwapBack()
|
||||
{
|
||||
var controller = new LegacyOperatorController(Lookup());
|
||||
|
||||
var first = await controller.SwapTabsAtExpectedPositionsAsync(
|
||||
LegacyOperatorTabId.Theme,
|
||||
LegacyOperatorTabId.Exchange,
|
||||
expectedSourceIndex: 6,
|
||||
expectedTargetIndex: 1);
|
||||
Assert.Equal(LegacyOperatorTabId.Theme, first.Tabs![1].Id);
|
||||
Assert.Equal(LegacyOperatorTabId.Exchange, first.Tabs[6].Id);
|
||||
|
||||
var revision = first.Revision;
|
||||
var duplicate = await controller.SwapTabsAtExpectedPositionsAsync(
|
||||
LegacyOperatorTabId.Theme,
|
||||
LegacyOperatorTabId.Exchange,
|
||||
expectedSourceIndex: 6,
|
||||
expectedTargetIndex: 1);
|
||||
Assert.Equal(revision, duplicate.Revision);
|
||||
Assert.Equal(LegacyOperatorTabId.Theme, duplicate.Tabs![1].Id);
|
||||
Assert.Equal(LegacyOperatorTabId.Exchange, duplicate.Tabs[6].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaylistKeyboardCommandsUseActiveRowAndBoundaries()
|
||||
{
|
||||
@@ -626,6 +650,65 @@ public sealed class LegacyOperatorControllerTests
|
||||
after.Playlist.Select(row => row.RowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaylistDragMovesNoncontiguousSelectionAsOneOrderedBlockAndIsIdempotent()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5, 6);
|
||||
var initial = controller.Current.Playlist.ToArray();
|
||||
|
||||
controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false);
|
||||
controller.SelectPlaylistRow(initial[3].RowId, control: true, shift: false);
|
||||
var moved = controller.ReorderPlaylistRows(
|
||||
initial[3].RowId,
|
||||
initial[5].RowId,
|
||||
LegacyDragDropPosition.Before);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
initial[0].RowId,
|
||||
initial[2].RowId,
|
||||
initial[4].RowId,
|
||||
initial[1].RowId,
|
||||
initial[3].RowId,
|
||||
initial[5].RowId
|
||||
],
|
||||
moved.Playlist.Select(row => row.RowId));
|
||||
Assert.Equal(
|
||||
[initial[1].RowId, initial[3].RowId],
|
||||
moved.Playlist.Where(row => row.IsSelected).Select(row => row.RowId));
|
||||
|
||||
var revision = moved.Revision;
|
||||
var duplicate = controller.ReorderPlaylistRows(
|
||||
initial[3].RowId,
|
||||
initial[5].RowId,
|
||||
LegacyDragDropPosition.Before);
|
||||
Assert.Equal(revision, duplicate.Revision);
|
||||
Assert.Equal(
|
||||
moved.Playlist.Select(row => row.RowId),
|
||||
duplicate.Playlist.Select(row => row.RowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlaylistDragFromUnselectedRowMovesOnlyThatRowAndSelectsIt()
|
||||
{
|
||||
var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4);
|
||||
var initial = controller.Current.Playlist.ToArray();
|
||||
controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false);
|
||||
controller.SelectPlaylistRow(initial[1].RowId, control: true, shift: false);
|
||||
|
||||
var moved = controller.ReorderPlaylistRows(
|
||||
initial[3].RowId,
|
||||
initial[0].RowId,
|
||||
LegacyDragDropPosition.Before);
|
||||
|
||||
Assert.Equal(
|
||||
[initial[3].RowId, initial[0].RowId, initial[1].RowId, initial[2].RowId],
|
||||
moved.Playlist.Select(row => row.RowId));
|
||||
Assert.Equal(
|
||||
initial[3].RowId,
|
||||
Assert.Single(moved.Playlist, row => row.IsSelected).RowId);
|
||||
}
|
||||
|
||||
private static async Task<LegacyOperatorController> ControllerWithPlaylistAsync(
|
||||
params int[] physicalCutRows)
|
||||
{
|
||||
|
||||
@@ -59,6 +59,77 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
Assert.Equal(LegacyOperatorCatalogMode.List, deleted.Mode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ThemeMarket.Krx)]
|
||||
[InlineData(ThemeMarket.Nxt)]
|
||||
public async Task ThemeA_name_check_is_read_only_and_scoped_to_the_selected_market(
|
||||
ThemeMarket market)
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
var creating = await workflow.BeginCreateThemeAsync(market);
|
||||
Assert.True(creating.CanCheckThemeName);
|
||||
|
||||
fixture.ThemePersistence.NameExists = false;
|
||||
var available = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");
|
||||
|
||||
Assert.Equal(LegacyThemeNameAvailability.Available, available.ThemeNameAvailability);
|
||||
Assert.Equal("AI 반도체", available.CheckedThemeName);
|
||||
Assert.Equal("사용 가능한 테마명입니다.", available.StatusMessage);
|
||||
Assert.Equal(market, fixture.ThemePersistence.LastNameCheckMarket);
|
||||
Assert.Equal("AI 반도체", fixture.ThemePersistence.LastCheckedName);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
|
||||
fixture.ThemePersistence.NameExists = true;
|
||||
var duplicate = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");
|
||||
|
||||
Assert.Equal(LegacyThemeNameAvailability.Duplicate, duplicate.ThemeNameAvailability);
|
||||
Assert.Equal("이미 사용중인 테마명입니다.", duplicate.StatusMessage);
|
||||
Assert.Equal(2, fixture.ThemePersistence.NameExistsCalls);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ThemeMarket.Krx)]
|
||||
[InlineData(ThemeMarket.Nxt)]
|
||||
public async Task ThemeA_edit_name_check_preserves_original_self_duplicate_behavior(
|
||||
ThemeMarket market)
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var item = ThemeItem(market, "AI 반도체");
|
||||
fixture.ThemeSelection.SearchItems = [item];
|
||||
fixture.ThemePersistence.NameExists = true;
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenThemeForEditAsync(item.Identity);
|
||||
|
||||
var result = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");
|
||||
|
||||
Assert.Equal(LegacyThemeNameAvailability.Duplicate, result.ThemeNameAvailability);
|
||||
Assert.Equal(market, fixture.ThemePersistence.LastNameCheckMarket);
|
||||
Assert.Equal("AI 반도체", fixture.ThemePersistence.LastCheckedName);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThemeA_name_check_reports_indeterminate_when_exact_query_fails()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
|
||||
await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
|
||||
fixture.ThemePersistence.NameExistsException = new InvalidOperationException("offline");
|
||||
|
||||
var result = await workflow.CheckThemeNameAvailabilityAsync("새 테마");
|
||||
|
||||
Assert.Equal(LegacyThemeNameAvailability.Indeterminate, result.ThemeNameAvailability);
|
||||
Assert.Equal("새 테마", result.CheckedThemeName);
|
||||
Assert.Equal(LegacyOperatorCatalogStatusKind.Error, result.StatusKind);
|
||||
Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EList_preserves_buy_amount_and_CSharp_owned_reorder_in_one_create()
|
||||
{
|
||||
@@ -103,6 +174,47 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EList_drag_reorder_moves_to_arbitrary_position_and_duplicate_is_no_op()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
fixture.StockSearch.Items =
|
||||
[
|
||||
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "First", "000001"),
|
||||
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "Second", "000002"),
|
||||
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "Third", "000003")
|
||||
];
|
||||
var workflow = fixture.CreateWorkflow();
|
||||
await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
|
||||
await workflow.BeginCreateExpertAsync();
|
||||
var stocks = await workflow.SearchStocksAsync("stock");
|
||||
foreach (var stock in stocks.StockResults)
|
||||
{
|
||||
workflow.SelectStock(stock.ResultId);
|
||||
workflow.AddSelectedStock(10_000);
|
||||
}
|
||||
|
||||
var initial = workflow.Current.DraftRows.ToArray();
|
||||
var moved = workflow.ReorderDraftRow(
|
||||
initial[0].RowId,
|
||||
initial[2].RowId,
|
||||
LegacyDragDropPosition.After);
|
||||
Assert.Equal(
|
||||
[initial[1].RowId, initial[2].RowId, initial[0].RowId],
|
||||
moved.DraftRows.Select(row => row.RowId));
|
||||
Assert.Equal([0, 1, 2], moved.DraftRows.Select(row => row.Position));
|
||||
|
||||
var revision = moved.Revision;
|
||||
var duplicate = workflow.ReorderDraftRow(
|
||||
initial[0].RowId,
|
||||
initial[2].RowId,
|
||||
LegacyDragDropPosition.After);
|
||||
Assert.Equal(revision, duplicate.Revision);
|
||||
Assert.Equal(
|
||||
moved.DraftRows.Select(row => row.RowId),
|
||||
duplicate.DraftRows.Select(row => row.RowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uc4_and_Uc6_edit_entries_resolve_only_the_native_selected_identity()
|
||||
{
|
||||
@@ -197,27 +309,29 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
|
||||
private sealed class FakeThemeSelection : IThemeSelectionService
|
||||
{
|
||||
internal int SearchCalls { get; private set; }
|
||||
|
||||
internal int PreviewCalls { get; private set; }
|
||||
|
||||
internal IReadOnlyList<ThemeSelectorItem> SearchItems { get; set; } =
|
||||
[
|
||||
ThemeItem(ThemeMarket.Krx, "AI 반도체")
|
||||
];
|
||||
|
||||
public Task<ThemeSearchResult> SearchAsync(
|
||||
string query,
|
||||
ThemeSession nxtSession,
|
||||
int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new ThemeSearchResult(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SearchCalls++;
|
||||
return Task.FromResult(new ThemeSearchResult(
|
||||
query,
|
||||
nxtSession,
|
||||
DateTimeOffset.UtcNow,
|
||||
[
|
||||
new ThemeSelectorItem(
|
||||
new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
"00000001",
|
||||
"AI 반도체"),
|
||||
DataSourceKind.Oracle)
|
||||
],
|
||||
SearchItems,
|
||||
false));
|
||||
}
|
||||
|
||||
public Task<ThemePreviewResult> PreviewAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
@@ -270,8 +384,31 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
|
||||
internal Exception? CreateException { get; set; }
|
||||
|
||||
internal int NameExistsCalls { get; private set; }
|
||||
|
||||
internal bool NameExists { get; set; }
|
||||
|
||||
internal Exception? NameExistsException { get; set; }
|
||||
|
||||
internal ThemeMarket? LastNameCheckMarket { get; private set; }
|
||||
|
||||
internal string? LastCheckedName { get; private set; }
|
||||
|
||||
public bool CanMutate => true;
|
||||
|
||||
public Task<bool> ThemeNameExistsAsync(
|
||||
ThemeMarket market,
|
||||
string themeTitle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
NameExistsCalls++;
|
||||
LastNameCheckMarket = market;
|
||||
LastCheckedName = themeTitle;
|
||||
return NameExistsException is null
|
||||
? Task.FromResult(NameExists)
|
||||
: Task.FromException<bool>(NameExistsException);
|
||||
}
|
||||
|
||||
public Task<string> SuggestNextCodeAsync(
|
||||
ThemeMarket market,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
@@ -391,4 +528,15 @@ public sealed class LegacyOperatorCatalogWorkflowTests
|
||||
Guid.NewGuid(),
|
||||
kind,
|
||||
DateTimeOffset.UtcNow));
|
||||
|
||||
private static ThemeSelectorItem ThemeItem(ThemeMarket market, string title) =>
|
||||
new(
|
||||
new ThemeSelectionIdentity(
|
||||
market,
|
||||
market == ThemeMarket.Nxt
|
||||
? ThemeSession.PreMarket
|
||||
: ThemeSession.NotApplicable,
|
||||
market == ThemeMarket.Nxt ? "N0000001" : "00000001",
|
||||
title),
|
||||
market == ThemeMarket.Nxt ? DataSourceKind.MariaDb : DataSourceKind.Oracle);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,31 @@ public sealed class LegacyBridgeProtocolTests
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePlaylistReorder_UsesOnlyOpaqueRowsAndClosedDropPosition()
|
||||
{
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"reorder-playlist-rows\",\"payload\":{\"sourceRowId\":\"legacy-stock-00000001\",\"targetRowId\":\"legacy-stock-00000004\",\"position\":\"before\"}}",
|
||||
out var parsed,
|
||||
out _));
|
||||
|
||||
var reorder = Assert.IsType<LegacyReorderPlaylistRowsIntent>(parsed);
|
||||
Assert.Equal("legacy-stock-00000001", reorder.SourceRowId);
|
||||
Assert.Equal("legacy-stock-00000004", reorder.TargetRowId);
|
||||
Assert.Equal(LegacyDragDropPosition.Before, reorder.Position);
|
||||
|
||||
foreach (var invalid in new[]
|
||||
{
|
||||
"{\"type\":\"reorder-playlist-rows\",\"payload\":{\"sourceRowId\":\"../row\",\"targetRowId\":\"legacy-stock-00000004\",\"position\":\"before\"}}",
|
||||
"{\"type\":\"reorder-playlist-rows\",\"payload\":{\"sourceRowId\":\"legacy-stock-00000001\",\"targetRowId\":\"legacy-stock-00000001\",\"position\":\"before\"}}",
|
||||
"{\"type\":\"reorder-playlist-rows\",\"payload\":{\"sourceRowId\":\"legacy-stock-00000001\",\"targetRowId\":\"legacy-stock-00000004\",\"position\":\"middle\"}}",
|
||||
"{\"type\":\"reorder-playlist-rows\",\"payload\":{\"sourceRowId\":\"legacy-stock-00000001\",\"targetRowId\":\"legacy-stock-00000004\",\"position\":\"before\",\"targetIndex\":3}}"
|
||||
})
|
||||
{
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(invalid, out _, out _));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAllPlaylistEnabled_RequiresOnlyBooleanState()
|
||||
{
|
||||
@@ -96,6 +121,19 @@ public sealed class LegacyBridgeProtocolTests
|
||||
Assert.Equal(LegacyOperatorTabId.Theme, parsedSwap.Source);
|
||||
Assert.Equal(LegacyOperatorTabId.Exchange, parsedSwap.Target);
|
||||
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"hover-swap-tabs\",\"payload\":{\"source\":\"theme\",\"target\":\"exchange\",\"expectedSourceIndex\":6,\"expectedTargetIndex\":1}}",
|
||||
out var hoverSwap,
|
||||
out _));
|
||||
var parsedHover = Assert.IsType<LegacyHoverSwapTabsIntent>(hoverSwap);
|
||||
Assert.Equal(6, parsedHover.ExpectedSourceIndex);
|
||||
Assert.Equal(1, parsedHover.ExpectedTargetIndex);
|
||||
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"hover-swap-tabs\",\"payload\":{\"source\":\"theme\",\"target\":\"exchange\",\"expectedSourceIndex\":6,\"expectedTargetIndex\":10}}",
|
||||
out _,
|
||||
out _));
|
||||
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"select-tab\",\"payload\":{\"tabId\":\"scene5001\"}}",
|
||||
out _,
|
||||
|
||||
@@ -41,6 +41,10 @@ public sealed class LegacyOperatorCatalogBridgeTests
|
||||
Assert.IsType<LegacyMoveOperatorCatalogDraftRowIntent>(ParseRequired(
|
||||
"move-operator-catalog-draft-row",
|
||||
"""{"rowId":"catalog-draft-0123456789abcdef","direction":"up"}"""));
|
||||
var reorder = Assert.IsType<LegacyReorderOperatorCatalogDraftRowIntent>(ParseRequired(
|
||||
"reorder-operator-catalog-draft-row",
|
||||
"""{"sourceRowId":"catalog-draft-0123456789abcdef","targetRowId":"catalog-draft-fedcba9876543210","position":"after"}"""));
|
||||
Assert.Equal(LegacyDragDropPosition.After, reorder.Position);
|
||||
Assert.IsType<LegacyRemoveOperatorCatalogDraftRowIntent>(ParseRequired(
|
||||
"remove-operator-catalog-draft-row",
|
||||
"""{"rowId":"catalog-draft-0123456789abcdef"}"""));
|
||||
@@ -55,6 +59,11 @@ public sealed class LegacyOperatorCatalogBridgeTests
|
||||
{"type":"select-operator-catalog-stock","payload":{"stockCode":"005930"}}
|
||||
""",
|
||||
out _));
|
||||
Assert.False(Parse(
|
||||
"""
|
||||
{"type":"reorder-operator-catalog-draft-row","payload":{"sourceRowId":"catalog-draft-0123456789abcdef","targetRowId":"catalog-draft-fedcba9876543210","position":"after","playIndex":1}}
|
||||
""",
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,6 +81,10 @@ public sealed class LegacyOperatorCatalogBridgeTests
|
||||
Assert.Equal(70_000, Assert.IsType<LegacyAddOperatorCatalogStockIntent>(ParseRequired(
|
||||
"add-operator-catalog-stock",
|
||||
"""{"buyAmount":70000}""")).BuyAmount);
|
||||
Assert.Equal("AI 반도체", Assert.IsType<LegacyCheckOperatorCatalogThemeNameIntent>(
|
||||
ParseRequired(
|
||||
"check-operator-catalog-theme-name",
|
||||
"""{"name":"AI 반도체"}""")).Name);
|
||||
Assert.Equal("김전문", Assert.IsType<LegacySaveOperatorCatalogIntent>(ParseRequired(
|
||||
"save-operator-catalog",
|
||||
"""{"name":"김전문"}""")).Name);
|
||||
@@ -84,6 +97,16 @@ public sealed class LegacyOperatorCatalogBridgeTests
|
||||
{"type":"save-operator-catalog","payload":{"name":"김전문","expertCode":"0001"}}
|
||||
""",
|
||||
out _));
|
||||
Assert.False(Parse(
|
||||
"""
|
||||
{"type":"check-operator-catalog-theme-name","payload":{"name":"AI 반도체","market":"krx"}}
|
||||
""",
|
||||
out _));
|
||||
Assert.False(Parse(
|
||||
"""
|
||||
{"type":"check-operator-catalog-theme-name","payload":{"name":" AI 반도체"}}
|
||||
""",
|
||||
out _));
|
||||
Assert.False(Parse(
|
||||
"""
|
||||
{"type":"add-operator-catalog-stock","payload":{"buyAmount":1.5}}
|
||||
@@ -114,6 +137,8 @@ public sealed class LegacyOperatorCatalogBridgeTests
|
||||
"catalog-result-0123456789abcdef",
|
||||
"00000001",
|
||||
"AI 반도체",
|
||||
LegacyThemeNameAvailability.Duplicate,
|
||||
"AI 반도체",
|
||||
[
|
||||
new LegacyOperatorCatalogDraftRowView(
|
||||
"catalog-draft-0123456789abcdef",
|
||||
@@ -139,6 +164,7 @@ public sealed class LegacyOperatorCatalogBridgeTests
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"편집 중",
|
||||
LegacyOperatorCatalogStatusKind.Information);
|
||||
var snapshot = new LegacyOperatorSnapshot(
|
||||
@@ -157,6 +183,10 @@ public sealed class LegacyOperatorCatalogBridgeTests
|
||||
|
||||
Assert.Contains("catalog-result-0123456789abcdef", json, StringComparison.Ordinal);
|
||||
Assert.Contains("codeLabel", json, StringComparison.Ordinal);
|
||||
Assert.Contains("themeNameAvailability", json, StringComparison.Ordinal);
|
||||
Assert.Contains("checkedThemeName", json, StringComparison.Ordinal);
|
||||
Assert.Contains("canCheckThemeName", json, StringComparison.Ordinal);
|
||||
Assert.Contains("duplicate", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("themeCode", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("expertCode", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("stockCode", json, StringComparison.Ordinal);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||
|
||||
public sealed class LegacyDevelopmentLiveStartupContractTests
|
||||
{
|
||||
private const string ParityProjectGuid =
|
||||
"{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}";
|
||||
private const string PrototypeProjectGuid =
|
||||
"{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}";
|
||||
private const string ExactArgument = "--development-live";
|
||||
|
||||
private static readonly string RepositoryRoot = FindRepositoryRoot();
|
||||
private static readonly string ParityRoot = Path.Combine(
|
||||
RepositoryRoot,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp");
|
||||
|
||||
[Fact]
|
||||
public void VisualStudioProfilesPutDevelopmentLiveFirstAndKeepExplicitDryRun()
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(
|
||||
ParityRoot,
|
||||
"Properties",
|
||||
"launchSettings.json")));
|
||||
var profiles = document.RootElement.GetProperty("profiles")
|
||||
.EnumerateObject()
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(2, profiles.Length);
|
||||
Assert.Equal(
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)",
|
||||
profiles[0].Name);
|
||||
Assert.Equal("MsixPackage", profiles[0].Value.GetProperty("commandName").GetString());
|
||||
Assert.Equal(ExactArgument,
|
||||
profiles[0].Value.GetProperty("commandLineArgs").GetString());
|
||||
Assert.Equal(
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)",
|
||||
profiles[1].Name);
|
||||
Assert.Equal("MsixPackage", profiles[1].Value.GetProperty("commandName").GetString());
|
||||
Assert.Equal(string.Empty,
|
||||
profiles[1].Value.GetProperty("commandLineArgs").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedSolutionLaunchStartsOnlyTheLegacyParityApp()
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
"MBN_STOCK_WEBVIEW.slnLaunch")));
|
||||
var profile = Assert.Single(document.RootElement.EnumerateArray());
|
||||
Assert.Equal("Legacy Parity App (VS F5)", profile.GetProperty("Name").GetString());
|
||||
var project = Assert.Single(profile.GetProperty("Projects").EnumerateArray());
|
||||
Assert.Equal(
|
||||
"src\\MBN_STOCK_WEBVIEW.LegacyParityApp\\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj",
|
||||
project.GetProperty("Path").GetString());
|
||||
Assert.Equal("Start", project.GetProperty("Action").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalDebugDeployTargetsParityInsteadOfThePrototype()
|
||||
{
|
||||
var solution = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
"MBN_STOCK_WEBVIEW.sln"));
|
||||
|
||||
Assert.Contains(
|
||||
$"{ParityProjectGuid}.Debug|x64.Deploy.0 = Debug|x64",
|
||||
solution,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
$"{PrototypeProjectGuid}.Debug|x64.Deploy.0 = Debug|x64",
|
||||
solution,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppAppliesCompileTimeBuildGateBeforeConstructingMainWindow()
|
||||
{
|
||||
var app = File.ReadAllText(Path.Combine(ParityRoot, "App.xaml.cs"));
|
||||
var bootstrap = app.IndexOf(
|
||||
"DevelopmentLiveLaunchBootstrap.TryApply(",
|
||||
StringComparison.Ordinal);
|
||||
var constructWindow = app.IndexOf("_window = new MainWindow();", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(bootstrap >= 0);
|
||||
Assert.True(constructWindow > bootstrap);
|
||||
Assert.Contains("#if DEBUG", app, StringComparison.Ordinal);
|
||||
Assert.Contains("const bool isDebugBuild = true;", app, StringComparison.Ordinal);
|
||||
Assert.Contains("const bool isDebugBuild = false;", app, StringComparison.Ordinal);
|
||||
Assert.Contains("args.Arguments", app, StringComparison.Ordinal);
|
||||
Assert.Contains("Environment.GetCommandLineArgs()", app, StringComparison.Ordinal);
|
||||
Assert.Contains("ResolveLaunchArguments(", app, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackedExampleContainsOnlyNonUsableHashPlaceholders()
|
||||
{
|
||||
var example = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
"Config",
|
||||
"playout.development-live.example.json"));
|
||||
using var document = JsonDocument.Parse(example);
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal("Live", root.GetProperty("mode").GetString());
|
||||
Assert.StartsWith("<64-HEX-", root.GetProperty("nativeSha256").GetString());
|
||||
Assert.StartsWith("<64-HEX-", root.GetProperty("interopSha256").GetString());
|
||||
Assert.DoesNotContain(":\\", example, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealDevelopmentAuthorizationFileIsIgnoredEverywhere()
|
||||
{
|
||||
var ignore = File.ReadAllText(Path.Combine(RepositoryRoot, ".gitignore"));
|
||||
|
||||
Assert.Contains("Config/playout.development-live.local.json", ignore,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("**/playout.development-live.local.json", ignore,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ public sealed class LegacyOperatorCatalogParityWebTests
|
||||
"operator-catalog-new",
|
||||
"operator-catalog-results",
|
||||
"operator-catalog-name",
|
||||
"operator-catalog-name-check",
|
||||
"operator-catalog-name-status",
|
||||
"operator-catalog-stock-query",
|
||||
"operator-catalog-stock-results",
|
||||
"operator-catalog-add-stock",
|
||||
@@ -75,11 +77,20 @@ public sealed class LegacyOperatorCatalogParityWebTests
|
||||
}
|
||||
|
||||
Assert.Contains("operator-catalog-draft-row", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(">중복확인</button>", Markup, StringComparison.Ordinal);
|
||||
Assert.Contains("operator-catalog-name-field small.available", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("operator-catalog-backdrop[hidden]", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains("operatorCatalogStockResults.addEventListener(\"dblclick\"", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("dataset.operatorCatalogBuyAmountRowId = row.rowId", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("dataset.operatorCatalogRowId = row.rowId", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("event.dataTransfer.setData(operatorCatalogDragMime, sourceRowId)", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(".operator-catalog-draft-row.drag-before", Styles,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("remove.textContent = isTheme ? \"삭제\" : \"종목삭제\"", App,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -98,9 +109,11 @@ public sealed class LegacyOperatorCatalogParityWebTests
|
||||
"select-operator-catalog-stock",
|
||||
"add-operator-catalog-stock",
|
||||
"activate-operator-catalog-stock",
|
||||
"reorder-operator-catalog-draft-row",
|
||||
"move-operator-catalog-draft-row",
|
||||
"remove-operator-catalog-draft-row",
|
||||
"set-operator-catalog-draft-buy-amount",
|
||||
"check-operator-catalog-theme-name",
|
||||
"save-operator-catalog",
|
||||
"delete-operator-catalog"
|
||||
})
|
||||
@@ -116,6 +129,14 @@ public sealed class LegacyOperatorCatalogParityWebTests
|
||||
new Regex("(?:themeCode|expertCode|stockCode|itemCode|expectedIdentity|SB_LIST|EXPERT_LIST)"),
|
||||
catalogSection);
|
||||
Assert.DoesNotContain("send(\"save-operator-catalog\"", App[..start], StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"send(\"check-operator-catalog-theme-name\", { name: name });",
|
||||
App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"send(\"check-operator-catalog-theme-name\", { name: name,",
|
||||
App,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -139,6 +160,8 @@ public sealed class LegacyOperatorCatalogParityWebTests
|
||||
Assert.Contains("LegacyBeginUc6ExpertCatalogIntent", MainWindow, StringComparison.Ordinal);
|
||||
Assert.Contains("LegacyEditUc6SelectedExpertIntent", MainWindow, StringComparison.Ordinal);
|
||||
Assert.Contains("LegacyDeleteUc6SelectedExpertIntent", MainWindow, StringComparison.Ordinal);
|
||||
Assert.Contains("LegacyCheckOperatorCatalogThemeNameIntent", MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("LegacySaveOperatorCatalogIntent", MainWindow, StringComparison.Ordinal);
|
||||
Assert.Contains("LegacyDeleteOperatorCatalogIntent", MainWindow, StringComparison.Ordinal);
|
||||
Assert.Contains("_intentGate.WaitAsync(", MainWindow, StringComparison.Ordinal);
|
||||
@@ -159,6 +182,7 @@ public sealed class LegacyOperatorCatalogParityWebTests
|
||||
"LegacyDeleteUc6SelectedExpertIntent",
|
||||
"LegacyAddOperatorCatalogStockIntent",
|
||||
"LegacyActivateOperatorCatalogStockIntent",
|
||||
"LegacyReorderOperatorCatalogDraftRowIntent",
|
||||
"LegacyMoveOperatorCatalogDraftRowIntent",
|
||||
"LegacyRemoveOperatorCatalogDraftRowIntent",
|
||||
"LegacySetOperatorCatalogDraftBuyAmountIntent",
|
||||
|
||||
@@ -106,6 +106,7 @@ public sealed class LegacyWebContractTests
|
||||
{
|
||||
"select-playlist-row",
|
||||
"set-playlist-enabled",
|
||||
"reorder-playlist-rows",
|
||||
"move-selected-playlist-rows",
|
||||
"delete-selected-playlist-rows",
|
||||
"delete-all-playlist-rows"
|
||||
@@ -119,6 +120,14 @@ public sealed class LegacyWebContractTests
|
||||
Assert.Contains("event.key === \"Delete\"", App, StringComparison.Ordinal);
|
||||
Assert.Contains("row.isSelected", App, StringComparison.Ordinal);
|
||||
Assert.Contains("enabled.disabled = state.isBusy === true", App, StringComparison.Ordinal);
|
||||
Assert.Contains("event.target.closest(\"input, button\")", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("preserveSelectedBlock", App, StringComparison.Ordinal);
|
||||
Assert.Contains("deferredPlaylistPlainSelectionRowId", App, StringComparison.Ordinal);
|
||||
Assert.Contains("event.dataTransfer.setData(playlistDragMime, rowId)", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-data-row.drag-before", Styles, StringComparison.Ordinal);
|
||||
Assert.Contains(".playlist-data-row.drag-after", Styles, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -131,6 +140,19 @@ public sealed class LegacyWebContractTests
|
||||
Assert.DoesNotContain("playlistRows.replaceChild", App, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarketTabDragSwapsOnHoverThroughExpectedCSharpPositions()
|
||||
{
|
||||
Assert.Contains("send(\"hover-swap-tabs\"", App, StringComparison.Ordinal);
|
||||
Assert.Contains("expectedSourceIndex: sourceIndex", App, StringComparison.Ordinal);
|
||||
Assert.Contains("expectedTargetIndex: targetIndex", App, StringComparison.Ordinal);
|
||||
Assert.Contains("event.dataTransfer.setData(\"text/x-mbn-tab\"", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("marketTabs.addEventListener(\"dragover\"", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("send(\"swap-tabs\"", App, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedCatalogUsesOpaqueCSharpActionAndSectionIntents()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class DevelopmentLiveLaunchBootstrapTests
|
||||
{
|
||||
private const string NativeSha256 =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private const string InteropSha256 =
|
||||
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
|
||||
[Theory]
|
||||
[InlineData("--development-live", "ignored", "--development-live")]
|
||||
[InlineData("unexpected", "--development-live", "unexpected")]
|
||||
[InlineData(" ", "--development-live", " ")]
|
||||
public void ResolveLaunchArguments_PreservesNonEmptyActivationArguments(
|
||||
string activationArguments,
|
||||
string processArgument,
|
||||
string expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
|
||||
activationArguments,
|
||||
["app.exe", processArgument]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void ResolveLaunchArguments_UsesOneProcessArgumentWhenActivationIsEmpty(
|
||||
string? activationArguments)
|
||||
{
|
||||
Assert.Equal(
|
||||
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
|
||||
DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
|
||||
activationArguments,
|
||||
["app.exe", DevelopmentLiveLaunchBootstrap.ExactLaunchArgument]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveLaunchArguments_RejectsMissingOrAdditionalProcessArguments()
|
||||
{
|
||||
Assert.Null(DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments("", ["app.exe"]));
|
||||
Assert.Null(DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments(
|
||||
"",
|
||||
["app.exe", DevelopmentLiveLaunchBootstrap.ExactLaunchArgument, "extra"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultPath_IsASeparateLocalApplicationDataFile()
|
||||
{
|
||||
var expected = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"Config",
|
||||
"playout.development-live.local.json");
|
||||
|
||||
Assert.Equal(expected, DevelopmentLiveLaunchBootstrap.DefaultPath);
|
||||
Assert.NotEqual(PlayoutOptionsLoader.DefaultPath,
|
||||
DevelopmentLiveLaunchBootstrap.DefaultPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("--development-live ")]
|
||||
[InlineData(" --development-live")]
|
||||
[InlineData("--DEVELOPMENT-LIVE")]
|
||||
[InlineData("--development-live --another-option")]
|
||||
public void NonExactArgument_SkipsWithoutReadingOrMutating(string? arguments)
|
||||
{
|
||||
var platform = FakePlatform.Valid();
|
||||
|
||||
var result = DevelopmentLiveLaunchBootstrap.TryApply(
|
||||
arguments,
|
||||
isDebugBuild: true,
|
||||
"ignored.json",
|
||||
platform);
|
||||
|
||||
Assert.Equal(DevelopmentLiveBootstrapStatus.Skipped, result.Status);
|
||||
Assert.Equal("development-live-not-requested", result.Code);
|
||||
Assert.Equal(0, platform.ReadCount);
|
||||
Assert.Empty(platform.Environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleaseBuild_SkipsExactArgumentWithoutReadingOrMutating()
|
||||
{
|
||||
var platform = FakePlatform.Valid();
|
||||
|
||||
var result = DevelopmentLiveLaunchBootstrap.TryApply(
|
||||
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
|
||||
isDebugBuild: false,
|
||||
"ignored.json",
|
||||
platform);
|
||||
|
||||
Assert.Equal(DevelopmentLiveBootstrapStatus.Skipped, result.Status);
|
||||
Assert.Equal("development-live-debug-only", result.Code);
|
||||
Assert.Equal(0, platform.ReadCount);
|
||||
Assert.Empty(platform.Environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugExactArgumentAndStrictFile_ApplyAllProcessGates()
|
||||
{
|
||||
var platform = FakePlatform.Valid();
|
||||
|
||||
var result = DevelopmentLiveLaunchBootstrap.TryApply(
|
||||
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
|
||||
isDebugBuild: true,
|
||||
"authorization.json",
|
||||
platform);
|
||||
|
||||
Assert.True(result.IsApplied);
|
||||
Assert.Equal(DevelopmentLiveBootstrapStatus.Applied, result.Status);
|
||||
Assert.Equal("development-live-applied", result.Code);
|
||||
Assert.Equal(1, platform.ReadCount);
|
||||
Assert.Equal(
|
||||
NativeSha256.ToUpperInvariant(),
|
||||
platform.Environment[
|
||||
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable]);
|
||||
Assert.Equal(
|
||||
InteropSha256,
|
||||
platform.Environment[
|
||||
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable]);
|
||||
Assert.Equal(
|
||||
DevelopmentLiveLaunchBootstrap.RequiredMode,
|
||||
platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable]);
|
||||
Assert.Equal(
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
|
||||
platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidAuthorizationJson))]
|
||||
public void InvalidOrNonCanonicalAuthorization_IsRejectedWithoutEnvironmentMutation(
|
||||
string json)
|
||||
{
|
||||
var platform = new FakePlatform(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
var result = DevelopmentLiveLaunchBootstrap.TryApply(
|
||||
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
|
||||
isDebugBuild: true,
|
||||
"authorization.json",
|
||||
platform);
|
||||
|
||||
Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status);
|
||||
Assert.Equal("development-live-authorization-invalid", result.Code);
|
||||
Assert.Empty(platform.Environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedAuthorization_IsRejectedBeforeParsing()
|
||||
{
|
||||
var platform = new FakePlatform(
|
||||
new byte[DevelopmentLiveLaunchBootstrap.MaximumAuthorizationFileBytes + 1]);
|
||||
|
||||
var result = DevelopmentLiveLaunchBootstrap.TryApply(
|
||||
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
|
||||
isDebugBuild: true,
|
||||
"authorization.json",
|
||||
platform);
|
||||
|
||||
Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status);
|
||||
Assert.Equal("development-live-authorization-invalid", result.Code);
|
||||
Assert.Empty(platform.Environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PermissionFailure_IsFailClosedAndDoesNotLeakDetails()
|
||||
{
|
||||
var platform = FakePlatform.Valid();
|
||||
platform.ReadException = new UnauthorizedAccessException("sensitive local path");
|
||||
|
||||
var result = DevelopmentLiveLaunchBootstrap.TryApply(
|
||||
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
|
||||
isDebugBuild: true,
|
||||
"sensitive local path",
|
||||
platform);
|
||||
|
||||
Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status);
|
||||
Assert.Equal("development-live-authorization-unavailable", result.Code);
|
||||
Assert.DoesNotContain("sensitive", result.Code, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(platform.Environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentFailure_RestoresEveryPreviousProcessValue()
|
||||
{
|
||||
var platform = FakePlatform.Valid();
|
||||
platform.Environment[
|
||||
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable] = "old-native";
|
||||
platform.Environment[
|
||||
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable] = "old-interop";
|
||||
platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable] = "DryRun";
|
||||
platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable] =
|
||||
"old-authorization";
|
||||
platform.FailOnceOnSetName = DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable;
|
||||
|
||||
var result = DevelopmentLiveLaunchBootstrap.TryApply(
|
||||
DevelopmentLiveLaunchBootstrap.ExactLaunchArgument,
|
||||
isDebugBuild: true,
|
||||
"authorization.json",
|
||||
platform);
|
||||
|
||||
Assert.Equal(DevelopmentLiveBootstrapStatus.Rejected, result.Status);
|
||||
Assert.Equal("development-live-environment-failed", result.Code);
|
||||
Assert.Equal(
|
||||
"old-native",
|
||||
platform.Environment[
|
||||
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable]);
|
||||
Assert.Equal(
|
||||
"old-interop",
|
||||
platform.Environment[
|
||||
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable]);
|
||||
Assert.Equal(
|
||||
"DryRun",
|
||||
platform.Environment[DevelopmentLiveLaunchBootstrap.ModeEnvironmentVariable]);
|
||||
Assert.Equal(
|
||||
"old-authorization",
|
||||
platform.Environment[PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable]);
|
||||
}
|
||||
|
||||
public static TheoryData<string> InvalidAuthorizationJson() => new()
|
||||
{
|
||||
"{}",
|
||||
ValidJson().Replace("\"schemaVersion\": 1", "\"schemaVersion\": 2"),
|
||||
ValidJson().Replace("\"mode\": \"Live\"", "\"mode\": \"live\""),
|
||||
ValidJson().Replace(
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
|
||||
"I_AUTHORIZE_SOMETHING_ELSE"),
|
||||
ValidJson().Replace(NativeSha256, new string('A', 63)),
|
||||
ValidJson().Replace(InteropSha256, new string('G', 64)),
|
||||
ValidJson().Replace(
|
||||
"\"interopSha256\"",
|
||||
"\"unexpected\": true, \"interopSha256\""),
|
||||
ValidJson().Replace(
|
||||
"\"mode\": \"Live\"",
|
||||
"\"mode\": \"Live\", \"mode\": \"Live\""),
|
||||
ValidJson().Replace("\"mode\"", "\"Mode\""),
|
||||
ValidJson().TrimEnd('}') + ",}",
|
||||
ValidJson().Replace("{", "{/* comment */", StringComparison.Ordinal),
|
||||
"[]",
|
||||
"null"
|
||||
};
|
||||
|
||||
private static string ValidJson() =>
|
||||
$$"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"mode": "Live",
|
||||
"authorization": "{{PlayoutOptionsLoader.RequiredLiveAuthorizationValue}}",
|
||||
"nativeSha256": "{{NativeSha256}}",
|
||||
"interopSha256": "{{InteropSha256}}"
|
||||
}
|
||||
""";
|
||||
|
||||
private sealed class FakePlatform(byte[] authorizationBytes) :
|
||||
IDevelopmentLiveBootstrapPlatform
|
||||
{
|
||||
public Dictionary<string, string?> Environment { get; } =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public Exception? ReadException { get; set; }
|
||||
|
||||
public string? FailOnceOnSetName { get; set; }
|
||||
|
||||
public int ReadCount { get; private set; }
|
||||
|
||||
public static FakePlatform Valid() =>
|
||||
new(Encoding.UTF8.GetBytes(ValidJson()));
|
||||
|
||||
public byte[] ReadAuthorizationFile(string path, int maximumBytes)
|
||||
{
|
||||
ReadCount++;
|
||||
if (ReadException is not null)
|
||||
{
|
||||
throw ReadException;
|
||||
}
|
||||
|
||||
return authorizationBytes;
|
||||
}
|
||||
|
||||
public string? GetProcessEnvironmentVariable(string name) =>
|
||||
Environment.GetValueOrDefault(name);
|
||||
|
||||
public void SetProcessEnvironmentVariable(string name, string? value)
|
||||
{
|
||||
if (string.Equals(name, FailOnceOnSetName, StringComparison.Ordinal))
|
||||
{
|
||||
FailOnceOnSetName = null;
|
||||
throw new SecurityException("simulated environment denial");
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
Environment.Remove(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
Environment[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user