447 lines
16 KiB
JavaScript
447 lines
16 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 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 });
|
|
});
|