Complete legacy operator UI and playout migration
This commit is contained in:
502
Web/expert-workflow.js
Normal file
502
Web/expert-workflow.js
Normal file
@@ -0,0 +1,502 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnExpertWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_FADE_DURATION = 6;
|
||||
const DEFAULT_MAXIMUM_RESULTS = 100;
|
||||
const MAXIMUM_RESULTS = 500;
|
||||
const MAXIMUM_QUERY_LENGTH = 64;
|
||||
const DEFAULT_MAXIMUM_PREVIEW_ITEMS = 100;
|
||||
const MAXIMUM_PREVIEW_ITEMS = 100;
|
||||
const MAXIMUM_PAGE_COUNT = 20;
|
||||
const PAGE_SIZE = 5;
|
||||
const SCHEMA_VERSION = 1;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const expertCodePattern = /^[0-9]{4}$/;
|
||||
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
searchRequestType: "search-experts",
|
||||
searchResultType: "expert-search-results",
|
||||
searchErrorType: "expert-search-error",
|
||||
previewRequestType: "request-expert-preview",
|
||||
previewResultType: "expert-preview-results",
|
||||
previewErrorType: "expert-preview-error",
|
||||
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
|
||||
maximumResults: MAXIMUM_RESULTS,
|
||||
maximumQueryLength: MAXIMUM_QUERY_LENGTH,
|
||||
defaultMaximumPreviewItems: DEFAULT_MAXIMUM_PREVIEW_ITEMS,
|
||||
maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS
|
||||
});
|
||||
|
||||
const sourceContract = Object.freeze({
|
||||
originalControl: "Control/UC6.cs",
|
||||
provider: "oracle",
|
||||
expertTable: "EXPERT_LIST",
|
||||
recommendationTable: "RECOMMEND_LIST",
|
||||
originalGroupLabel: "전문가 추천",
|
||||
originalCutLabel: "5단 표그래프",
|
||||
originalValueLabel: "현재가",
|
||||
builderKey: "s5074",
|
||||
cutCode: "5074",
|
||||
resolverGroup: "PAGED_EXPERT",
|
||||
pageSize: PAGE_SIZE,
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT
|
||||
});
|
||||
|
||||
const actions = Object.freeze([
|
||||
Object.freeze({
|
||||
id: "five-row-current",
|
||||
label: "전문가 추천 · 5단 표그래프 · 현재가",
|
||||
builderKey: "s5074",
|
||||
code: "5074",
|
||||
aliases: Object.freeze(["5074"]),
|
||||
pageSize: PAGE_SIZE
|
||||
})
|
||||
]);
|
||||
const action = actions[0];
|
||||
const trustedPreviewResponses = new WeakSet();
|
||||
|
||||
function hasExactKeys(value, keys) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length &&
|
||||
actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value, keys) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) &&
|
||||
Object.keys(value).every(key => keys.includes(key));
|
||||
}
|
||||
|
||||
function isSafeCanonicalText(value, maximumLength) {
|
||||
return typeof value === "string" &&
|
||||
value.length > 0 && value.length <= maximumLength &&
|
||||
value === value.trim() && !unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function isSafeRequestId(value) {
|
||||
return isSafeCanonicalText(value, 128) && identifierPattern.test(value);
|
||||
}
|
||||
|
||||
function normalizeQuery(value) {
|
||||
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
|
||||
const query = value.trim();
|
||||
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query)
|
||||
? query
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeExpertIdentity(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName"]) ||
|
||||
!expertCodePattern.test(value.expertCode) ||
|
||||
!isSafeCanonicalText(value.expertName, 128)) return null;
|
||||
return Object.freeze({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpert(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) ||
|
||||
value.source !== "oracle") return null;
|
||||
const identity = normalizeExpertIdentity({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
return identity && Object.freeze({ ...identity, source: "oracle" });
|
||||
}
|
||||
|
||||
function createExpertSearchRequest(requestId, query = "", maximumResults) {
|
||||
if (!isSafeRequestId(requestId)) {
|
||||
throw new TypeError("A safe expert-search request id is required.");
|
||||
}
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
if (normalizedQuery === null) {
|
||||
throw new TypeError(`An expert query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
|
||||
}
|
||||
if (maximumResults !== undefined &&
|
||||
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
||||
throw new RangeError(`Expert results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
|
||||
}
|
||||
const request = { requestId, query: normalizedQuery };
|
||||
if (maximumResults !== undefined) request.maximumResults = maximumResults;
|
||||
return Object.freeze(request);
|
||||
}
|
||||
|
||||
function compareExperts(left, right) {
|
||||
if (left.expertName < right.expertName) return -1;
|
||||
if (left.expertName > right.expertName) return 1;
|
||||
if (left.expertCode < right.expertCode) return -1;
|
||||
if (left.expertCode > right.expertCode) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeExpertSearchResponse(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
|
||||
]) || !isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" ||
|
||||
!Array.isArray(value.results) || value.results.length !== value.totalRowCount) return null;
|
||||
|
||||
let expected = null;
|
||||
if (expectedRequest !== undefined) {
|
||||
try {
|
||||
expected = createExpertSearchRequest(
|
||||
expectedRequest?.requestId,
|
||||
expectedRequest?.query,
|
||||
expectedRequest?.maximumResults);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const bound = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
|
||||
if (value.requestId !== expected.requestId || value.query !== expected.query ||
|
||||
value.results.length > bound || (value.truncated && value.results.length !== bound)) return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (const raw of value.results) {
|
||||
const expert = normalizeExpert(raw);
|
||||
if (!expert || codes.has(expert.expertCode) || names.has(expert.expertName) ||
|
||||
(results.length && compareExperts(results[results.length - 1], expert) > 0)) return null;
|
||||
codes.add(expert.expertCode);
|
||||
names.add(expert.expertName);
|
||||
results.push(expert);
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
query: value.query,
|
||||
retrievedAt: value.retrievedAt,
|
||||
totalRowCount: value.totalRowCount,
|
||||
truncated: value.truncated,
|
||||
results: Object.freeze(results)
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpertSearchError(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
|
||||
!isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
||||
!isSafeCanonicalText(value.message, 512)) return null;
|
||||
if (expectedRequest) {
|
||||
let expected;
|
||||
try {
|
||||
expected = createExpertSearchRequest(
|
||||
expectedRequest.requestId,
|
||||
expectedRequest.query,
|
||||
expectedRequest.maximumResults);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (value.requestId !== expected.requestId || value.query !== expected.query) return null;
|
||||
}
|
||||
return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message });
|
||||
}
|
||||
|
||||
function createExpertPreviewRequest(requestId, expertValue, maximumItems) {
|
||||
if (!isSafeRequestId(requestId)) {
|
||||
throw new TypeError("A safe expert-preview request id is required.");
|
||||
}
|
||||
const expert = normalizeExpert(expertValue);
|
||||
if (!expert) throw new TypeError("A typed Oracle expert is required.");
|
||||
if (maximumItems !== undefined &&
|
||||
(!Number.isInteger(maximumItems) || maximumItems < 1 || maximumItems > MAXIMUM_PREVIEW_ITEMS)) {
|
||||
throw new RangeError(`Expert preview items must be limited from 1 through ${MAXIMUM_PREVIEW_ITEMS}.`);
|
||||
}
|
||||
const request = {
|
||||
requestId,
|
||||
expertCode: expert.expertCode,
|
||||
expertName: expert.expertName
|
||||
};
|
||||
if (maximumItems !== undefined) request.maximumItems = maximumItems;
|
||||
return Object.freeze(request);
|
||||
}
|
||||
|
||||
function normalizeRecommendation(value) {
|
||||
if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
|
||||
!Number.isInteger(value.playIndex) || value.playIndex < 0 || value.playIndex > 9999 ||
|
||||
typeof value.stockCode !== "string" || !stockCodePattern.test(value.stockCode) ||
|
||||
!isSafeCanonicalText(value.stockName, 200) ||
|
||||
!Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0) return null;
|
||||
return Object.freeze({
|
||||
playIndex: value.playIndex,
|
||||
stockCode: value.stockCode,
|
||||
stockName: value.stockName,
|
||||
buyAmount: value.buyAmount
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpertPreviewResponse(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
|
||||
]) || !isSafeRequestId(value.requestId) ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_PREVIEW_ITEMS || typeof value.truncated !== "boolean" ||
|
||||
!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
|
||||
const selection = normalizeExpertIdentity(value.selection);
|
||||
if (!selection) return null;
|
||||
|
||||
if (expectedRequest !== undefined) {
|
||||
let expected;
|
||||
try {
|
||||
expected = createExpertPreviewRequest(
|
||||
expectedRequest?.requestId,
|
||||
{
|
||||
expertCode: expectedRequest?.expertCode,
|
||||
expertName: expectedRequest?.expertName,
|
||||
source: "oracle"
|
||||
},
|
||||
expectedRequest?.maximumItems);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const bound = expected.maximumItems ?? DEFAULT_MAXIMUM_PREVIEW_ITEMS;
|
||||
if (value.requestId !== expected.requestId ||
|
||||
selection.expertCode !== expected.expertCode ||
|
||||
selection.expertName !== expected.expertName ||
|
||||
value.items.length > bound || (value.truncated && value.items.length !== bound)) return null;
|
||||
}
|
||||
|
||||
const items = [];
|
||||
const indexes = new Set();
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (const raw of value.items) {
|
||||
const item = normalizeRecommendation(raw);
|
||||
if (!item || indexes.has(item.playIndex) || codes.has(item.stockCode) || names.has(item.stockName) ||
|
||||
(items.length && items[items.length - 1].playIndex >= item.playIndex)) return null;
|
||||
indexes.add(item.playIndex);
|
||||
codes.add(item.stockCode);
|
||||
names.add(item.stockName);
|
||||
items.push(item);
|
||||
}
|
||||
const result = Object.freeze({
|
||||
requestId: value.requestId,
|
||||
selection,
|
||||
retrievedAt: value.retrievedAt,
|
||||
totalRowCount: value.totalRowCount,
|
||||
truncated: value.truncated,
|
||||
items: Object.freeze(items)
|
||||
});
|
||||
trustedPreviewResponses.add(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeExpertPreviewError(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, ["requestId", "selection", "message"]) ||
|
||||
!isSafeRequestId(value.requestId) || !isSafeCanonicalText(value.message, 512)) return null;
|
||||
const selection = normalizeExpertIdentity(value.selection);
|
||||
if (!selection) return null;
|
||||
if (expectedRequest) {
|
||||
let expected;
|
||||
try {
|
||||
expected = createExpertPreviewRequest(
|
||||
expectedRequest.requestId,
|
||||
{
|
||||
expertCode: expectedRequest.expertCode,
|
||||
expertName: expectedRequest.expertName,
|
||||
source: "oracle"
|
||||
},
|
||||
expectedRequest.maximumItems);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (value.requestId !== expected.requestId ||
|
||||
selection.expertCode !== expected.expertCode ||
|
||||
selection.expertName !== expected.expertName) return null;
|
||||
}
|
||||
return Object.freeze({ requestId: value.requestId, selection, message: value.message });
|
||||
}
|
||||
|
||||
function createSelectableExpert(expertValue, previewValue) {
|
||||
const expert = normalizeExpert(expertValue);
|
||||
if (!expert || !previewValue || typeof previewValue !== "object" ||
|
||||
!trustedPreviewResponses.has(previewValue) ||
|
||||
!normalizeExpertIdentity(previewValue.selection) ||
|
||||
previewValue.selection.expertCode !== expert.expertCode ||
|
||||
previewValue.selection.expertName !== expert.expertName ||
|
||||
!Number.isInteger(previewValue.totalRowCount) || previewValue.totalRowCount < 0 ||
|
||||
previewValue.totalRowCount > MAXIMUM_PREVIEW_ITEMS ||
|
||||
typeof previewValue.truncated !== "boolean") return null;
|
||||
return Object.freeze({
|
||||
...expert,
|
||||
itemCount: previewValue.totalRowCount,
|
||||
previewTruncated: previewValue.truncated
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSelectableExpert(value) {
|
||||
if (!hasExactKeys(value, [
|
||||
"expertCode", "expertName", "source", "itemCount", "previewTruncated"
|
||||
]) || !Number.isInteger(value.itemCount) || value.itemCount < 0 ||
|
||||
value.itemCount > MAXIMUM_PREVIEW_ITEMS || typeof value.previewTruncated !== "boolean") return null;
|
||||
const expert = normalizeExpert({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName,
|
||||
source: value.source
|
||||
});
|
||||
return expert && Object.freeze({
|
||||
...expert,
|
||||
itemCount: value.itemCount,
|
||||
previewTruncated: value.previewTruncated
|
||||
});
|
||||
}
|
||||
|
||||
function calculatePagePreview(expertValue) {
|
||||
const expert = normalizeSelectableExpert(expertValue);
|
||||
if (!expert) return null;
|
||||
return Object.freeze({
|
||||
itemCount: expert.itemCount,
|
||||
accessibleItemCount: expert.itemCount,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageCount: Math.ceil(expert.itemCount / PAGE_SIZE),
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
||||
isTruncated: expert.previewTruncated
|
||||
});
|
||||
}
|
||||
|
||||
function buildExpertSelection(expertValue) {
|
||||
const expert = normalizeSelectableExpert(expertValue);
|
||||
if (!expert) throw new TypeError("A previewed expert selection is required.");
|
||||
return Object.freeze({
|
||||
builderKey: action.builderKey,
|
||||
code: action.code,
|
||||
aliases: action.aliases,
|
||||
selection: Object.freeze({
|
||||
groupCode: "PAGED_EXPERT",
|
||||
subject: expert.expertName,
|
||||
graphicType: "INPUT_ORDER",
|
||||
subtype: "CURRENT",
|
||||
dataCode: expert.expertCode
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!hasOnlyKeys(options, ["id", "fadeDuration"]) || !isSafeRequestId(options.id)) {
|
||||
throw new TypeError("Safe expert playlist options are required.");
|
||||
}
|
||||
const fadeDuration = options.fadeDuration === undefined
|
||||
? DEFAULT_FADE_DURATION
|
||||
: options.fadeDuration;
|
||||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||||
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
||||
}
|
||||
return Object.freeze({ id: options.id, fadeDuration });
|
||||
}
|
||||
|
||||
function createExpertPlaylistEntry(expertValue, options) {
|
||||
const expert = normalizeSelectableExpert(expertValue);
|
||||
if (!expert) throw new TypeError("A previewed expert selection is required.");
|
||||
if (expert.itemCount === 0) throw new RangeError("An expert with no recommendation rows cannot create a cut.");
|
||||
const normalizedOptions = requireOptions(options);
|
||||
const mapping = buildExpertSelection(expert);
|
||||
const pagePreview = calculatePagePreview(expert);
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: mapping.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: [...mapping.aliases],
|
||||
title: expert.expertName,
|
||||
detail: action.label,
|
||||
category: "expert",
|
||||
source: expert.source,
|
||||
expertCode: expert.expertCode,
|
||||
expertName: expert.expertName,
|
||||
itemCount: expert.itemCount,
|
||||
previewTruncated: expert.previewTruncated,
|
||||
pageSize: pagePreview.pageSize,
|
||||
pageCount: pagePreview.pageCount,
|
||||
selection: mapping.selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: mapping.selection.graphicType,
|
||||
subtype: mapping.selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-expert-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
actionId: action.id,
|
||||
expert,
|
||||
pagePreview
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function sameStringArray(left, right) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
|
||||
left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function sameSelection(left, right) {
|
||||
return left && right && ["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
||||
.every(key => typeof left[key] === "string" && left[key] === right[key]);
|
||||
}
|
||||
|
||||
function samePagePreview(left, right) {
|
||||
return left && right && [
|
||||
"itemCount", "accessibleItemCount", "pageSize", "pageCount",
|
||||
"maximumPageCount", "isTruncated"
|
||||
].every(key => left[key] === right[key]);
|
||||
}
|
||||
|
||||
function restoreExpertPlaylistEntry(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
||||
typeof value.enabled !== "boolean") return null;
|
||||
const operator = value.operator;
|
||||
if (!hasExactKeys(operator, ["source", "schemaVersion", "actionId", "expert", "pagePreview"]) ||
|
||||
operator.source !== "legacy-expert-workflow" || operator.schemaVersion !== SCHEMA_VERSION ||
|
||||
operator.actionId !== action.id || !normalizeSelectableExpert(operator.expert)) return null;
|
||||
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createExpertPlaylistEntry(operator.expert, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scalars = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "source",
|
||||
"expertCode", "expertName", "itemCount", "previewTruncated", "pageSize",
|
||||
"pageCount", "fadeDuration", "graphicType", "subtype"
|
||||
];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!sameStringArray(value.aliases, recreated.aliases) ||
|
||||
!sameSelection(value.selection, recreated.selection) ||
|
||||
!samePagePreview(operator.pagePreview, recreated.operator.pagePreview)) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContract,
|
||||
sourceContract,
|
||||
actions,
|
||||
normalizeExpertIdentity,
|
||||
normalizeExpert,
|
||||
createExpertSearchRequest,
|
||||
normalizeExpertSearchResponse,
|
||||
normalizeExpertSearchError,
|
||||
createExpertPreviewRequest,
|
||||
normalizeRecommendation,
|
||||
normalizeExpertPreviewResponse,
|
||||
normalizeExpertPreviewError,
|
||||
createSelectableExpert,
|
||||
calculatePagePreview,
|
||||
buildExpertSelection,
|
||||
createExpertPlaylistEntry,
|
||||
restoreExpertPlaylistEntry,
|
||||
refreshExpertPlaylistEntry: restoreExpertPlaylistEntry
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user