Files
MBN_STOCK_WEBVIEW/tests/Web/operator-catalog-workflow.test.cjs

517 lines
19 KiB
JavaScript

"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 stockMasterValidation = fs.readFileSync(
path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.Core", "Data",
"StockMasterIdentityValidation.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.stockMasterContract, {
request: "search-stocks",
result: "stock-search-results",
error: "stock-search-error"
});
assert.deepEqual(workflow.limits, {
maximumThemeItems: 240,
maximumRecommendations: 100,
maximumResults: 500,
maximumQueryLength: 64
});
});
test("stock master search accepts only complete unique typed identities", () => {
const request = workflow.createStockMasterSearchRequest("stock-master-1", " 삼성 ", 25);
assert.deepEqual(request, {
requestId: "stock-master-1",
query: "삼성",
maximumResults: 25
});
const base = {
requestId: request.requestId,
query: request.query,
retrievedAt: "2026-07-12T10:00:00+09:00",
totalRowCount: 1,
truncated: false,
results: [{
market: "kospi", source: "oracle", name: "삼성전자", code: "005930"
}]
};
assert.ok(workflow.normalizeStockMasterSearchResult(base, request));
assert.equal(workflow.normalizeStockMasterSearchResult({ ...base, truncated: true }, request), null);
assert.equal(workflow.normalizeStockMasterSearchResult({
...base,
results: [{ ...base.results[0], extra: true }]
}, request), null);
assert.equal(workflow.normalizeStockMasterSearchResult({
...base,
totalRowCount: 2,
results: [
base.results[0],
{ market: "kosdaq", source: "oracle", name: "삼성전자", code: "123456" }
]
}, request), null);
assert.equal(workflow.normalizeStockMasterSearchResult({
...base,
requestId: "stale"
}, request), null);
});
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.match(nativeBridge, /LegacyStockMasterIdentityValidationService/);
assert.match(nativeBridge,
/await validationBody\(requestToken\)[\s\S]*mutationDispatched = true;/s);
assert.match(stockMasterValidation, /STOCK_MASTER_VERIFY_KOSPI/);
assert.match(stockMasterValidation, /STOCK_MASTER_VERIFY_KOSDAQ/);
assert.match(stockMasterValidation, /STOCK_MASTER_VERIFY_NXT_KOSPI/);
assert.match(stockMasterValidation, /STOCK_MASTER_VERIFY_NXT_KOSDAQ/);
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("existing theme edits preserve legacy three through eight digit codes", () => {
const edit = workflow.createThemeEditDraft(
{ ...krxSelection, themeCode: "007" },
{
...themePreview(),
selection: { ...themePreview().selection, themeCode: "007" }
});
edit.draft.themeTitle = "수정 테마";
assert.equal(
workflow.createThemeReplaceRequest("legacy-theme-replace", edit)
.expectedIdentity.themeCode,
"007");
assert.throws(() => workflow.createThemeCreateRequest(
"legacy-theme-create",
edit.draft), /eight-digit/i);
});
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 });
});