feat: restore legacy drag events and Tornado launch integration

This commit is contained in:
2026-07-15 15:35:25 +09:00
parent 6e0c275fdd
commit 06a16e5741
32 changed files with 3096 additions and 101 deletions

View File

@@ -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/);

View File

@@ -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) =>

View File

@@ -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)
{

View File

@@ -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);
}

View File

@@ -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 _,

View File

@@ -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);

View File

@@ -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.");
}
}

View File

@@ -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",

View File

@@ -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()
{

View File

@@ -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;
}
}
}
}