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