Complete legacy operator UI and playout migration
This commit is contained in:
446
tests/Web/operator-catalog-workflow.test.cjs
Normal file
446
tests/Web/operator-catalog-workflow.test.cjs
Normal file
@@ -0,0 +1,446 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const workflow = require("../../Web/operator-catalog-workflow.js");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const nativeBridge = fs.readFileSync(
|
||||
path.join(repositoryRoot, "MainWindow.OperatorCatalogs.cs"),
|
||||
"utf8");
|
||||
const playoutBridge = fs.readFileSync(
|
||||
path.join(repositoryRoot, "MainWindow.Playout.cs"),
|
||||
"utf8");
|
||||
const operatorGate = fs.readFileSync(
|
||||
path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Playout", "OperatorMutationGate.cs"),
|
||||
"utf8");
|
||||
|
||||
const krxSelection = Object.freeze({
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI 반도체",
|
||||
source: "oracle"
|
||||
});
|
||||
|
||||
const expertSelection = Object.freeze({
|
||||
expertCode: "0001",
|
||||
expertName: "홍길동",
|
||||
source: "oracle"
|
||||
});
|
||||
|
||||
function themePreview(overrides = {}) {
|
||||
return {
|
||||
requestId: "theme-preview-1",
|
||||
selection: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
title: "AI 반도체"
|
||||
},
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 3, itemCode: "D035720", itemName: "카카오" }
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function expertPreview(overrides = {}) {
|
||||
return {
|
||||
requestId: "expert-preview-1",
|
||||
selection: { expertCode: "0001", expertName: "홍길동" },
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
items: [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 4, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
|
||||
],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("bridge contract pins every independent request and event name", () => {
|
||||
assert.deepEqual(workflow.bridgeContract.requests, {
|
||||
themeList: "request-theme-catalog-list",
|
||||
expertList: "request-expert-catalog-list",
|
||||
schemaPreflight: "request-operator-catalog-schema",
|
||||
themeNextCode: "request-theme-catalog-next-code",
|
||||
expertNextCode: "request-expert-catalog-next-code",
|
||||
themeCreate: "create-theme-catalog",
|
||||
themeReplace: "replace-theme-catalog",
|
||||
themeDelete: "delete-theme-catalog",
|
||||
expertCreate: "create-expert-catalog",
|
||||
expertReplace: "replace-expert-catalog",
|
||||
expertDelete: "delete-expert-catalog"
|
||||
});
|
||||
assert.deepEqual(workflow.bridgeContract.events, {
|
||||
themeList: "theme-catalog-list-results",
|
||||
expertList: "expert-catalog-list-results",
|
||||
schemaPreflight: "operator-catalog-schema-results",
|
||||
themeNextCode: "theme-catalog-next-code-results",
|
||||
expertNextCode: "expert-catalog-next-code-results",
|
||||
mutation: "operator-catalog-mutation-results",
|
||||
error: "operator-catalog-error"
|
||||
});
|
||||
assert.deepEqual(workflow.limits, {
|
||||
maximumThemeItems: 240,
|
||||
maximumRecommendations: 100,
|
||||
maximumResults: 500,
|
||||
maximumQueryLength: 64
|
||||
});
|
||||
});
|
||||
|
||||
test("native partial pins the same contract and fail-closed write gates without a retry loop", () => {
|
||||
for (const requestType of Object.values(workflow.bridgeContract.requests)) {
|
||||
assert.match(nativeBridge, new RegExp(`"${requestType}"`));
|
||||
}
|
||||
for (const eventType of Object.values(workflow.bridgeContract.events)) {
|
||||
assert.match(nativeBridge, new RegExp(`"${eventType}"`));
|
||||
}
|
||||
assert.match(nativeBridge, /_operatorCatalogWriteGate\.WaitAsync\(0\)/);
|
||||
assert.match(nativeBridge, /_playoutCommandGate\.WaitAsync\(0, requestToken\)/);
|
||||
assert.match(nativeBridge, /_databaseActivityGate\.WaitAsync\(0, requestToken\)/);
|
||||
assert.match(nativeBridge, /CanStartOperatorCatalogWrite\(\)/);
|
||||
assert.match(nativeBridge,
|
||||
/CanStartOperatorMutation\(IsOperatorCatalogWriteQuarantined\(\)\)/);
|
||||
assert.match(playoutBridge, /private bool CanStartOperatorMutation\(/);
|
||||
assert.match(playoutBridge, /new OperatorMutationGateSnapshot\(/);
|
||||
assert.match(operatorGate, /!snapshot\.IsPlayCompletionPending/);
|
||||
assert.match(operatorGate, /snapshot\.PreparedSceneName/);
|
||||
assert.match(operatorGate, /snapshot\.OnAirSceneName/);
|
||||
assert.match(operatorGate, /snapshot\.PreparedCutCode/);
|
||||
assert.match(operatorGate, /snapshot\.OnAirCutCode/);
|
||||
assert.match(nativeBridge, /LatchOperatorCatalogWriteQuarantine/);
|
||||
assert.match(nativeBridge, /OUTCOME_UNKNOWN/);
|
||||
assert.match(nativeBridge, /OperatorCatalogMutationExecutor/);
|
||||
assert.doesNotMatch(nativeBridge, /MaximumRetryCount|Task\.Delay|while\s*\(/);
|
||||
});
|
||||
|
||||
test("list, schema and next-code requests are exact, normalized and bounded", () => {
|
||||
assert.deepEqual(workflow.createThemeCatalogListRequest(
|
||||
"theme-list-1", " AI ", "preMarket", 25), {
|
||||
requestId: "theme-list-1",
|
||||
query: "AI",
|
||||
nxtSession: "preMarket",
|
||||
maximumResults: 25
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertCatalogListRequest("expert-list-1", " 홍 "), {
|
||||
requestId: "expert-list-1",
|
||||
query: "홍"
|
||||
});
|
||||
assert.deepEqual(workflow.createSchemaPreflightRequest("schema-1"), {
|
||||
requestId: "schema-1"
|
||||
});
|
||||
assert.deepEqual(workflow.createThemeNextCodeRequest("next-1", "nxt"), {
|
||||
requestId: "next-1",
|
||||
market: "nxt"
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertNextCodeRequest("next-2"), {
|
||||
requestId: "next-2"
|
||||
});
|
||||
assert.throws(() => workflow.createThemeCatalogListRequest("bad id", "", "preMarket"), /request id/);
|
||||
assert.throws(() => workflow.createThemeCatalogListRequest("safe", "", "none"), /session/);
|
||||
assert.throws(() => workflow.createExpertCatalogListRequest("safe", "bad\u0000query"), /query/);
|
||||
assert.throws(() => workflow.createExpertCatalogListRequest("safe", "", 501), /limited/);
|
||||
});
|
||||
|
||||
test("theme selection plus complete preview becomes an editable explicit identity and reindexed draft", () => {
|
||||
const edit = workflow.createThemeEditDraft(krxSelection, themePreview());
|
||||
assert.deepEqual(edit, {
|
||||
expectedIdentity: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI 반도체"
|
||||
},
|
||||
draft: {
|
||||
market: "krx",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI 반도체",
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "D035720", itemName: "카카오" }
|
||||
]
|
||||
}
|
||||
});
|
||||
edit.draft.themeTitle = "AI·로봇";
|
||||
edit.draft.items.reverse();
|
||||
edit.draft.items.forEach((item, index) => { item.inputIndex = index; });
|
||||
const request = workflow.createThemeReplaceRequest("theme-replace-1", edit);
|
||||
assert.deepEqual(request, {
|
||||
requestId: "theme-replace-1",
|
||||
expectedIdentity: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI 반도체"
|
||||
},
|
||||
newTitle: "AI·로봇",
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "D035720", itemName: "카카오" },
|
||||
{ inputIndex: 1, itemCode: "P005930", itemName: "삼성전자" }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
test("theme edit conversion rejects truncated, stale, duplicate and market-mismatched previews", () => {
|
||||
assert.throws(() => workflow.createThemeEditDraft(
|
||||
krxSelection,
|
||||
themePreview({ truncated: true })), /complete/);
|
||||
assert.throws(() => workflow.createThemeEditDraft(
|
||||
krxSelection,
|
||||
themePreview({ selection: { ...themePreview().selection, themeCode: "00000002" } })), /correlated/);
|
||||
assert.throws(() => workflow.createThemeEditDraft(
|
||||
krxSelection,
|
||||
themePreview({
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "D035720", itemName: "삼성전자" }
|
||||
]
|
||||
})), /complete/);
|
||||
assert.equal(workflow.normalizeThemeDraft({
|
||||
market: "nxt",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "로봇(NXT)",
|
||||
items: []
|
||||
}), null);
|
||||
assert.equal(workflow.normalizeThemeDraft({
|
||||
market: "krx",
|
||||
themeCode: "00000001",
|
||||
themeTitle: "AI",
|
||||
items: [{ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" }]
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("new theme drafts require a valid title before create and delete keeps full identity", () => {
|
||||
const draft = workflow.createBlankThemeDraft("nxt", "00000003");
|
||||
assert.deepEqual(draft, {
|
||||
market: "nxt",
|
||||
themeCode: "00000003",
|
||||
themeTitle: "",
|
||||
items: []
|
||||
});
|
||||
assert.throws(() => workflow.createThemeCreateRequest("create-1", draft), /complete/);
|
||||
draft.themeTitle = "로봇";
|
||||
draft.items.push({ inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" });
|
||||
assert.deepEqual(workflow.createThemeCreateRequest("create-1", draft), {
|
||||
requestId: "create-1",
|
||||
draft
|
||||
});
|
||||
assert.deepEqual(workflow.createThemeDeleteRequest(
|
||||
"delete-1",
|
||||
{ market: "nxt", session: "afterMarket", themeCode: "00000003", themeTitle: "로봇" }), {
|
||||
requestId: "delete-1",
|
||||
identity: {
|
||||
market: "nxt",
|
||||
session: "afterMarket",
|
||||
themeCode: "00000003",
|
||||
themeTitle: "로봇"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("expert selection plus preview becomes editable and preserves buy amounts while reindexing", () => {
|
||||
const edit = workflow.createExpertEditDraft(expertSelection, expertPreview());
|
||||
assert.deepEqual(edit, {
|
||||
expectedIdentity: { expertCode: "0001", expertName: "홍길동" },
|
||||
draft: {
|
||||
expertCode: "0001",
|
||||
expertName: "홍길동",
|
||||
recommendations: [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 1, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
|
||||
]
|
||||
}
|
||||
});
|
||||
edit.draft.expertName = "홍길동 위원";
|
||||
assert.deepEqual(workflow.createExpertReplaceRequest("expert-replace-1", edit), {
|
||||
requestId: "expert-replace-1",
|
||||
expectedIdentity: { expertCode: "0001", expertName: "홍길동" },
|
||||
newName: "홍길동 위원",
|
||||
recommendations: edit.draft.recommendations
|
||||
});
|
||||
assert.throws(() => workflow.createExpertEditDraft(
|
||||
expertSelection,
|
||||
expertPreview({ truncated: true })), /complete/);
|
||||
assert.equal(workflow.normalizeExpertDraft({
|
||||
expertCode: "0001",
|
||||
expertName: "홍길동",
|
||||
recommendations: [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 1, stockCode: "005930", stockName: "다른이름", buyAmount: 1 }
|
||||
]
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("new expert create and delete requests carry exact draft and identity only", () => {
|
||||
const draft = workflow.createBlankExpertDraft("0002");
|
||||
assert.throws(() => workflow.createExpertCreateRequest("expert-create-1", draft), /complete/);
|
||||
draft.expertName = "김전문";
|
||||
draft.recommendations.push({
|
||||
playIndex: 0,
|
||||
stockCode: "005930",
|
||||
stockName: "삼성전자",
|
||||
buyAmount: 71000
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertCreateRequest("expert-create-1", draft), {
|
||||
requestId: "expert-create-1",
|
||||
draft
|
||||
});
|
||||
assert.deepEqual(workflow.createExpertDeleteRequest(
|
||||
"expert-delete-1",
|
||||
{ expertCode: "0002", expertName: "김전문" }), {
|
||||
requestId: "expert-delete-1",
|
||||
identity: { expertCode: "0002", expertName: "김전문" }
|
||||
});
|
||||
assert.throws(() => workflow.createExpertCreateRequest("safe", {
|
||||
...draft,
|
||||
recommendations: [{ ...draft.recommendations[0], buyAmount: 1.5 }]
|
||||
}), /complete/);
|
||||
});
|
||||
|
||||
test("read responses reject stale correlation, ambiguous identities and quarantined mutation flags", () => {
|
||||
const themeRequest = workflow.createThemeCatalogListRequest("theme-list-1", "", "preMarket", 10);
|
||||
const themeResult = {
|
||||
requestId: "theme-list-1",
|
||||
query: "",
|
||||
nxtSession: "preMarket",
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: [krxSelection]
|
||||
};
|
||||
assert.ok(workflow.normalizeThemeListResult(themeResult, themeRequest));
|
||||
assert.equal(workflow.normalizeThemeListResult({ ...themeResult, requestId: "stale" }, themeRequest), null);
|
||||
assert.equal(workflow.normalizeThemeListResult({
|
||||
...themeResult,
|
||||
canMutate: true,
|
||||
writeQuarantined: true
|
||||
}, themeRequest), null);
|
||||
|
||||
const expertRequest = workflow.createExpertCatalogListRequest("expert-list-1", "", 10);
|
||||
const expertResult = {
|
||||
requestId: "expert-list-1",
|
||||
query: "",
|
||||
retrievedAt: "2026-07-11T23:00:00+09:00",
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: [
|
||||
expertSelection,
|
||||
{ expertCode: "0002", expertName: "김전문", source: "oracle" }
|
||||
]
|
||||
};
|
||||
assert.ok(workflow.normalizeExpertListResult(expertResult, expertRequest));
|
||||
assert.equal(workflow.normalizeExpertListResult({
|
||||
...expertResult,
|
||||
results: [expertSelection, { expertCode: "0002", expertName: "홍길동", source: "oracle" }]
|
||||
}, expertRequest), null);
|
||||
});
|
||||
|
||||
test("schema and next-code results are exact and correlated", () => {
|
||||
const schemaRequest = workflow.createSchemaPreflightRequest("schema-1");
|
||||
assert.deepEqual(workflow.normalizeSchemaResult({
|
||||
requestId: "schema-1",
|
||||
validatedAt: "2026-07-11T23:00:00+09:00",
|
||||
validatedSources: ["oracle", "mariaDb"],
|
||||
themeCanMutate: true,
|
||||
expertCanMutate: true,
|
||||
writeQuarantined: false
|
||||
}, schemaRequest).validatedSources, ["oracle", "mariaDb"]);
|
||||
const themeNextRequest = workflow.createThemeNextCodeRequest("next-1", "krx");
|
||||
assert.ok(workflow.normalizeThemeNextCodeResult({
|
||||
requestId: "next-1",
|
||||
market: "krx",
|
||||
themeCode: "00000003",
|
||||
canMutate: true,
|
||||
writeQuarantined: false
|
||||
}, themeNextRequest));
|
||||
assert.equal(workflow.normalizeThemeNextCodeResult({
|
||||
requestId: "next-1",
|
||||
market: "nxt",
|
||||
themeCode: "00000003",
|
||||
canMutate: true,
|
||||
writeQuarantined: false
|
||||
}, themeNextRequest), null);
|
||||
assert.ok(workflow.normalizeExpertNextCodeResult({
|
||||
requestId: "next-2",
|
||||
expertCode: "0007",
|
||||
canMutate: false,
|
||||
writeQuarantined: true
|
||||
}, workflow.createExpertNextCodeRequest("next-2")));
|
||||
});
|
||||
|
||||
test("mutation coordinator permits one correlated write and latches process quarantine on unknown outcome", () => {
|
||||
const request = workflow.createThemeCreateRequest("create-1", {
|
||||
market: "krx",
|
||||
themeCode: "00000003",
|
||||
themeTitle: "로봇",
|
||||
items: []
|
||||
});
|
||||
const coordinator = workflow.createMutationCoordinator();
|
||||
assert.deepEqual(coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), {
|
||||
requestId: "create-1",
|
||||
entity: "theme",
|
||||
operation: "create",
|
||||
identityCode: "00000003"
|
||||
});
|
||||
assert.throws(() => coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), /already active/);
|
||||
assert.equal(coordinator.acceptResult({
|
||||
requestId: "stale",
|
||||
entity: "theme",
|
||||
operation: "create",
|
||||
identityCode: "00000003",
|
||||
operationId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
committedAt: "2026-07-11T23:00:00+09:00",
|
||||
writeQuarantined: false
|
||||
}), null);
|
||||
assert.ok(coordinator.getState().active);
|
||||
const error = coordinator.acceptError({
|
||||
requestId: "create-1",
|
||||
entity: "theme",
|
||||
operation: "create",
|
||||
identityCode: "00000003",
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "Restart and reconcile before another write.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true,
|
||||
writeQuarantined: true
|
||||
});
|
||||
assert.ok(error);
|
||||
assert.deepEqual(coordinator.getState(), { active: null, quarantined: true });
|
||||
assert.throws(() => coordinator.begin(workflow.bridgeContract.requests.themeCreate, request), /quarantined/);
|
||||
});
|
||||
|
||||
test("known rollback clears the active coordinator without inventing an automatic retry", () => {
|
||||
const request = workflow.createExpertDeleteRequest(
|
||||
"delete-1",
|
||||
{ expertCode: "0001", expertName: "홍길동" });
|
||||
const coordinator = workflow.createMutationCoordinator();
|
||||
coordinator.begin(workflow.bridgeContract.requests.expertDelete, request);
|
||||
assert.ok(coordinator.acceptError({
|
||||
requestId: "delete-1",
|
||||
entity: "expert",
|
||||
operation: "delete",
|
||||
identityCode: "0001",
|
||||
code: "CONFLICT",
|
||||
message: "Refresh before a new edit.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false,
|
||||
writeQuarantined: false
|
||||
}));
|
||||
assert.deepEqual(coordinator.getState(), { active: null, quarantined: false });
|
||||
coordinator.begin(workflow.bridgeContract.requests.expertDelete, request);
|
||||
coordinator.loseCorrelation();
|
||||
assert.deepEqual(coordinator.getState(), { active: null, quarantined: true });
|
||||
});
|
||||
Reference in New Issue
Block a user