feat: restore legacy named playlist workflows
This commit is contained in:
@@ -25,9 +25,9 @@
|
|||||||
<PublishProfile>Properties\PublishProfiles\win-x64.pubxml</PublishProfile>
|
<PublishProfile>Properties\PublishProfiles\win-x64.pubxml</PublishProfile>
|
||||||
<PublishTrimmed>false</PublishTrimmed>
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
<PublishReadyToRun>false</PublishReadyToRun>
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
<ApplicationDisplayVersion>1.0.3</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>1.0.4</ApplicationDisplayVersion>
|
||||||
<ApplicationVersion>4</ApplicationVersion>
|
<ApplicationVersion>5</ApplicationVersion>
|
||||||
<Version>1.0.3</Version>
|
<Version>1.0.4</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
137
MainWindow.NamedForeignStockRestore.cs
Normal file
137
MainWindow.NamedForeignStockRestore.cs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW;
|
||||||
|
|
||||||
|
public sealed partial class MainWindow
|
||||||
|
{
|
||||||
|
private LegacyNamedForeignStockRestoreService? _namedForeignStockRestoreService;
|
||||||
|
|
||||||
|
private async Task PostNamedForeignStockRestoreAsync(
|
||||||
|
string requestId,
|
||||||
|
IReadOnlyList<NamedPlaylistStoredItem> items,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = items
|
||||||
|
.Select(static item => new LegacyNamedForeignStockRestoreRow(
|
||||||
|
item.ItemIndex,
|
||||||
|
item.IsEnabled,
|
||||||
|
item.Selection.GroupCode,
|
||||||
|
item.Selection.Subject,
|
||||||
|
item.Selection.GraphicType,
|
||||||
|
item.Selection.Subtype,
|
||||||
|
item.Page?.ToString() ?? string.Empty,
|
||||||
|
item.Selection.DataCode))
|
||||||
|
.Where(LegacyNamedForeignStockRestoreService.IsCandidate)
|
||||||
|
.ToArray();
|
||||||
|
if (candidates.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runtime = _databaseRuntime;
|
||||||
|
if (runtime is null)
|
||||||
|
{
|
||||||
|
PostNamedForeignStockRestoreError(
|
||||||
|
requestId,
|
||||||
|
"DATABASE_UNAVAILABLE",
|
||||||
|
"현재 해외종목 master DB를 사용할 수 없어 저장 행을 재검증하지 못했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = _namedForeignStockRestoreService ??=
|
||||||
|
new LegacyNamedForeignStockRestoreService(runtime.Executor);
|
||||||
|
var result = await service.ResolveAsync(candidates, cancellationToken);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
PostMessage("named-foreign-stock-restore-results", new
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
result.RetrievedAt,
|
||||||
|
totalRowCount = result.Rows.Count,
|
||||||
|
rows = result.Rows.Select(ToNamedForeignStockRestoreWireRow)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0 &&
|
||||||
|
!_lifetimeCancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
PostNamedForeignStockRestoreError(
|
||||||
|
requestId,
|
||||||
|
"PLAYOUT_PRIORITY",
|
||||||
|
"송출 명령 우선 처리로 해외종목 재검증을 취소했습니다. 자동 재시도하지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (DatabaseOperationException)
|
||||||
|
{
|
||||||
|
PostNamedForeignStockRestoreError(
|
||||||
|
requestId,
|
||||||
|
"DATABASE_UNAVAILABLE",
|
||||||
|
"현재 해외종목 master DB에서 저장 행을 재검증하지 못했습니다.");
|
||||||
|
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
PostNamedForeignStockRestoreError(
|
||||||
|
requestId,
|
||||||
|
"RESTORE_FAILED",
|
||||||
|
"저장된 해외종목 행을 현재의 안전한 종목 선택으로 복원하지 못했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ToNamedForeignStockRestoreWireRow(
|
||||||
|
LegacyNamedForeignStockRestoreOutcome outcome)
|
||||||
|
{
|
||||||
|
if (outcome.IsResolved)
|
||||||
|
{
|
||||||
|
var identity = outcome.Identity!;
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
outcome.ItemIndex,
|
||||||
|
status = "resolved",
|
||||||
|
identity.ActionId,
|
||||||
|
identity.InputName,
|
||||||
|
identity.Symbol,
|
||||||
|
identity.NationCode,
|
||||||
|
identity.ForeignDomesticCode,
|
||||||
|
identity.BuilderKey,
|
||||||
|
identity.CutCode,
|
||||||
|
identity.ValueType,
|
||||||
|
identity.PeriodDays
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
outcome.ItemIndex,
|
||||||
|
status = "failed",
|
||||||
|
failure = outcome.Failure switch
|
||||||
|
{
|
||||||
|
LegacyNamedForeignStockRestoreFailure.InvalidRow => "INVALID_ROW",
|
||||||
|
LegacyNamedForeignStockRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
|
||||||
|
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
|
||||||
|
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid =>
|
||||||
|
"DATABASE_DATA_INVALID",
|
||||||
|
_ => "RESTORE_FAILED"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PostNamedForeignStockRestoreError(
|
||||||
|
string requestId,
|
||||||
|
string code,
|
||||||
|
string message)
|
||||||
|
{
|
||||||
|
PostMessage("named-foreign-stock-restore-error", new
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
retryable = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
141
MainWindow.NamedKrxThemeRestore.cs
Normal file
141
MainWindow.NamedKrxThemeRestore.cs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW;
|
||||||
|
|
||||||
|
public sealed partial class MainWindow
|
||||||
|
{
|
||||||
|
private LegacyNamedKrxThemeRestoreService? _namedKrxThemeRestoreService;
|
||||||
|
|
||||||
|
private async Task PostNamedKrxThemeRestoreAsync(
|
||||||
|
string requestId,
|
||||||
|
IReadOnlyList<NamedPlaylistStoredItem> items,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = items
|
||||||
|
.Select(static item => new LegacyNamedKrxThemeRestoreRow(
|
||||||
|
item.ItemIndex,
|
||||||
|
item.IsEnabled,
|
||||||
|
item.Selection.GroupCode,
|
||||||
|
item.Selection.Subject,
|
||||||
|
item.Selection.GraphicType,
|
||||||
|
item.Selection.Subtype,
|
||||||
|
item.Page?.ToString() ?? string.Empty,
|
||||||
|
item.Selection.DataCode))
|
||||||
|
.Where(LegacyNamedKrxThemeRestoreService.IsCandidate)
|
||||||
|
.ToArray();
|
||||||
|
if (candidates.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runtime = _databaseRuntime;
|
||||||
|
if (runtime is null)
|
||||||
|
{
|
||||||
|
PostNamedKrxThemeRestoreError(
|
||||||
|
requestId,
|
||||||
|
"DATABASE_UNAVAILABLE",
|
||||||
|
"현재 Oracle DB를 사용할 수 없어 저장된 KRX 테마를 재검증하지 못했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = _namedKrxThemeRestoreService ??=
|
||||||
|
new LegacyNamedKrxThemeRestoreService(runtime.Executor);
|
||||||
|
var result = await service.ResolveAsync(candidates, cancellationToken);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
PostMessage("named-krx-theme-restore-results", new
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
result.RetrievedAt,
|
||||||
|
totalRowCount = result.Rows.Count,
|
||||||
|
rows = result.Rows.Select(ToNamedKrxThemeRestoreWireRow)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0 &&
|
||||||
|
!_lifetimeCancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
PostNamedKrxThemeRestoreError(
|
||||||
|
requestId,
|
||||||
|
"PLAYOUT_PRIORITY",
|
||||||
|
"송출 명령 우선 처리로 KRX 테마 재검증을 취소했습니다. 자동 재시도하지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (DatabaseOperationException)
|
||||||
|
{
|
||||||
|
PostNamedKrxThemeRestoreError(
|
||||||
|
requestId,
|
||||||
|
"DATABASE_UNAVAILABLE",
|
||||||
|
"현재 Oracle DB에서 KRX 테마를 재검증하지 못했습니다.");
|
||||||
|
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
PostNamedKrxThemeRestoreError(
|
||||||
|
requestId,
|
||||||
|
"RESTORE_FAILED",
|
||||||
|
"저장된 KRX 테마 행을 안전한 현재 선택으로 복원하지 못했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ToNamedKrxThemeRestoreWireRow(
|
||||||
|
LegacyNamedKrxThemeRestoreOutcome outcome)
|
||||||
|
{
|
||||||
|
if (outcome.IsResolved)
|
||||||
|
{
|
||||||
|
var identity = outcome.Identity!;
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
outcome.ItemIndex,
|
||||||
|
status = "resolved",
|
||||||
|
identity.ActionId,
|
||||||
|
identity.BuilderKey,
|
||||||
|
identity.CutCode,
|
||||||
|
identity.PageSize,
|
||||||
|
identity.Sort,
|
||||||
|
identity.ThemeTitle,
|
||||||
|
identity.StoredThemeCode,
|
||||||
|
identity.CurrentThemeCode,
|
||||||
|
identity.PreviewItemCount,
|
||||||
|
identity.PreviewIsTruncated,
|
||||||
|
identity.CodeWasRemapped
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
outcome.ItemIndex,
|
||||||
|
status = "failed",
|
||||||
|
failure = outcome.Failure switch
|
||||||
|
{
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.InvalidRow => "INVALID_ROW",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid =>
|
||||||
|
"DATABASE_DATA_INVALID",
|
||||||
|
_ => "RESTORE_FAILED"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PostNamedKrxThemeRestoreError(
|
||||||
|
string requestId,
|
||||||
|
string code,
|
||||||
|
string message)
|
||||||
|
{
|
||||||
|
PostMessage("named-krx-theme-restore-error", new
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
retryable = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,13 +126,22 @@ public sealed partial class MainWindow
|
|||||||
requestId,
|
requestId,
|
||||||
result.Items,
|
result.Items,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await PostNamedForeignStockRestoreAsync(
|
||||||
|
requestId,
|
||||||
|
result.Items,
|
||||||
|
cancellationToken);
|
||||||
await PostNamedForeignIndexCandleRestoreAsync(
|
await PostNamedForeignIndexCandleRestoreAsync(
|
||||||
requestId,
|
requestId,
|
||||||
result.Items,
|
result.Items,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await PostNamedKrxThemeRestoreAsync(
|
||||||
|
requestId,
|
||||||
|
result.Items,
|
||||||
|
cancellationToken);
|
||||||
// Keep all named restore batches correlated to this load. NXT
|
// Keep all named restore batches correlated to this load. NXT
|
||||||
// theme verification runs last because it can perform both an
|
// theme verification runs last because it uses MariaDB after
|
||||||
// exact identity lookup and a bounded item preview per title.
|
// the Oracle-backed identity batches and performs a bounded
|
||||||
|
// item preview per title.
|
||||||
await PostNamedNxtThemeRestoreAsync(
|
await PostNamedNxtThemeRestoreAsync(
|
||||||
requestId,
|
requestId,
|
||||||
result.Items,
|
result.Items,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="Wickedness.MBNStockWebView"
|
Name="Wickedness.MBNStockWebView"
|
||||||
Publisher="CN=Comtrophy"
|
Publisher="CN=Comtrophy"
|
||||||
Version="1.0.3.0" />
|
Version="1.0.4.0" />
|
||||||
|
|
||||||
<mp:PhoneIdentity PhoneProductId="b4129399-858c-4b31-90d7-28a0f6c677d0" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
<mp:PhoneIdentity PhoneProductId="b4129399-858c-4b31-90d7-28a0f6c677d0" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||||
|
|
||||||
|
|||||||
549
Web/app.js
549
Web/app.js
@@ -35,8 +35,12 @@
|
|||||||
if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable.");
|
if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable.");
|
||||||
const namedComparisonRestoreWorkflow = globalThis.MbnNamedComparisonRestoreWorkflow;
|
const namedComparisonRestoreWorkflow = globalThis.MbnNamedComparisonRestoreWorkflow;
|
||||||
if (!namedComparisonRestoreWorkflow) throw new Error("Named comparison restore workflow module is unavailable.");
|
if (!namedComparisonRestoreWorkflow) throw new Error("Named comparison restore workflow module is unavailable.");
|
||||||
|
const namedForeignStockRestoreWorkflow = globalThis.MbnNamedForeignStockRestoreWorkflow;
|
||||||
|
if (!namedForeignStockRestoreWorkflow) throw new Error("Named foreign-stock restore workflow module is unavailable.");
|
||||||
const foreignIndexCandleRestoreWorkflow = globalThis.MbnLegacyForeignIndexCandleWorkflow;
|
const foreignIndexCandleRestoreWorkflow = globalThis.MbnLegacyForeignIndexCandleWorkflow;
|
||||||
if (!foreignIndexCandleRestoreWorkflow) throw new Error("Legacy foreign-index candle restore workflow module is unavailable.");
|
if (!foreignIndexCandleRestoreWorkflow) throw new Error("Legacy foreign-index candle restore workflow module is unavailable.");
|
||||||
|
const namedKrxThemeRestoreWorkflow = globalThis.MbnNamedKrxThemeRestoreWorkflow;
|
||||||
|
if (!namedKrxThemeRestoreWorkflow) throw new Error("Named KRX-theme restore workflow module is unavailable.");
|
||||||
const namedNxtThemeRestoreWorkflow = globalThis.MbnNamedNxtThemeRestoreWorkflow;
|
const namedNxtThemeRestoreWorkflow = globalThis.MbnNamedNxtThemeRestoreWorkflow;
|
||||||
if (!namedNxtThemeRestoreWorkflow) throw new Error("Named NXT-theme restore workflow module is unavailable.");
|
if (!namedNxtThemeRestoreWorkflow) throw new Error("Named NXT-theme restore workflow module is unavailable.");
|
||||||
const legacyNamedRowWorkflow = globalThis.MbnLegacyNamedRowWorkflow;
|
const legacyNamedRowWorkflow = globalThis.MbnLegacyNamedRowWorkflow;
|
||||||
@@ -223,7 +227,9 @@
|
|||||||
guardByEntryId: new Map(),
|
guardByEntryId: new Map(),
|
||||||
manualRestoreFinalized: true,
|
manualRestoreFinalized: true,
|
||||||
comparisonRestorePending: null,
|
comparisonRestorePending: null,
|
||||||
|
foreignStockRestorePending: null,
|
||||||
foreignIndexCandleRestorePending: null,
|
foreignIndexCandleRestorePending: null,
|
||||||
|
krxThemeRestorePending: null,
|
||||||
nxtThemeRestorePending: null,
|
nxtThemeRestorePending: null,
|
||||||
selectAfterRefresh: ""
|
selectAfterRefresh: ""
|
||||||
},
|
},
|
||||||
@@ -703,7 +709,9 @@
|
|||||||
restoreRecords.every(record => record.status === "error");
|
restoreRecords.every(record => record.status === "error");
|
||||||
if (state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
|
if (state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
|
||||||
state.namedPlaylist.comparisonRestorePending ||
|
state.namedPlaylist.comparisonRestorePending ||
|
||||||
|
state.namedPlaylist.foreignStockRestorePending ||
|
||||||
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
||||||
|
state.namedPlaylist.krxThemeRestorePending ||
|
||||||
state.namedPlaylist.nxtThemeRestorePending ||
|
state.namedPlaylist.nxtThemeRestorePending ||
|
||||||
(restoreRecords.length > 0 && !(allowFailedManualRestore && failedRestoreOnly))) {
|
(restoreRecords.length > 0 && !(allowFailedManualRestore && failedRestoreOnly))) {
|
||||||
showToast("DB 플레이리스트 복원과 현재 identity 재검증이 끝날 때까지 순서를 변경할 수 없습니다.");
|
showToast("DB 플레이리스트 복원과 현재 identity 재검증이 끝날 때까지 순서를 변경할 수 없습니다.");
|
||||||
@@ -2423,7 +2431,10 @@
|
|||||||
let invalid = false;
|
let invalid = false;
|
||||||
state.playlist = state.playlist.map(item => {
|
state.playlist = state.playlist.map(item => {
|
||||||
if (item?.operator?.source !== "legacy-theme-workflow") return item;
|
if (item?.operator?.source !== "legacy-theme-workflow") return item;
|
||||||
const refreshed = namedNxtThemeRestoreWorkflow.isMaterializedEntry(item)
|
const namedKrx = namedKrxThemeRestoreWorkflow.isMaterializedEntry(item);
|
||||||
|
const refreshed = namedKrx
|
||||||
|
? item
|
||||||
|
: namedNxtThemeRestoreWorkflow.isMaterializedEntry(item)
|
||||||
? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date())
|
? namedNxtThemeRestoreWorkflow.refreshDynamicSession(item, new Date())
|
||||||
: themeWorkflow.refreshThemePlaylistEntry(item);
|
: themeWorkflow.refreshThemePlaylistEntry(item);
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
@@ -2436,10 +2447,10 @@
|
|||||||
invalid = true;
|
invalid = true;
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
// A native-verified named NXT entry already has the complete theme
|
// Native-verified named theme entries already have the complete operator
|
||||||
// operator shape. Do not clone it here: its in-memory verification mark
|
// shape. Do not clone them here: the in-memory verification mark must
|
||||||
// must survive until PREPARE/save validation.
|
// survive until PREPARE/save validation.
|
||||||
return namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed)
|
return namedKrx || namedNxtThemeRestoreWorkflow.isMaterializedEntry(refreshed)
|
||||||
? refreshed
|
? refreshed
|
||||||
: { ...definition, ...refreshed };
|
: { ...definition, ...refreshed };
|
||||||
});
|
});
|
||||||
@@ -3532,6 +3543,10 @@
|
|||||||
state.candleOptions.invalidIds = [...result.invalidIds];
|
state.candleOptions.invalidIds = [...result.invalidIds];
|
||||||
state.playlist = [...result.playlist].map(item => {
|
state.playlist = [...result.playlist].map(item => {
|
||||||
if (item?.builderKey !== candleOptionsWorkflow.candleBuilderKey) return item;
|
if (item?.builderKey !== candleOptionsWorkflow.candleBuilderKey) return item;
|
||||||
|
// The named foreign-stock entry carries a strict native identity proof
|
||||||
|
// and original seven-field signature. Keep that exact branded object;
|
||||||
|
// catalog decoration would make its save round-trip unverifiable.
|
||||||
|
if (item.operator?.source === namedForeignStockRestoreWorkflow.source) return item;
|
||||||
const definition = catalog.find(value =>
|
const definition = catalog.find(value =>
|
||||||
value.builderKey === item.builderKey && sceneAliases(value).includes(String(item.code || "")));
|
value.builderKey === item.builderKey && sceneAliases(value).includes(String(item.code || "")));
|
||||||
return definition ? { ...definition, ...item, enabled: item.enabled !== false } : item;
|
return definition ? { ...definition, ...item, enabled: item.enabled !== false } : item;
|
||||||
@@ -3832,6 +3847,92 @@
|
|||||||
showToast("플레이리스트에 추가했습니다.");
|
showToast("플레이리스트에 추가했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recreatePlaylistEntryWithEnabled(item, enabled) {
|
||||||
|
if (!item || typeof item !== "object" || typeof item.enabled !== "boolean" ||
|
||||||
|
typeof enabled !== "boolean") return null;
|
||||||
|
if (item.enabled === enabled) return item;
|
||||||
|
|
||||||
|
const operator = item.operator;
|
||||||
|
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(item) ||
|
||||||
|
operator?.namedKrxRestore !== undefined) {
|
||||||
|
return namedKrxThemeRestoreWorkflow.withEnabled(item, enabled);
|
||||||
|
}
|
||||||
|
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(item) ||
|
||||||
|
operator?.namedNxtRestore !== undefined) {
|
||||||
|
return namedNxtThemeRestoreWorkflow.withEnabled(item, enabled);
|
||||||
|
}
|
||||||
|
if (operator?.source === namedForeignStockRestoreWorkflow.source) {
|
||||||
|
return namedForeignStockRestoreWorkflow.withEnabled(item, enabled);
|
||||||
|
}
|
||||||
|
if (operator?.source === "legacy-foreign-index-candle-workflow") {
|
||||||
|
return foreignIndexCandleRestoreWorkflow.withEnabled(item, enabled);
|
||||||
|
}
|
||||||
|
if (operator?.source === "manual-financial-workflow") {
|
||||||
|
return manualFinancialWorkflow.withEnabled(item, enabled);
|
||||||
|
}
|
||||||
|
if (operator?.source === "legacy-manual-lists-workflow") {
|
||||||
|
return manualListsWorkflow.withEnabled(item, enabled);
|
||||||
|
}
|
||||||
|
return { ...item, enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlistEntryManualEditBlockReason(item) {
|
||||||
|
if (!item || typeof item !== "object") return "missing-entry";
|
||||||
|
if (item.operator?.source) return "trusted-workflow-entry";
|
||||||
|
if (state.namedPlaylist.guardByEntryId.has(item.id)) return "named-playlist-entry";
|
||||||
|
if (state.manualFinancialRestore.entries.has(item.id)) return "manual-restore-entry";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManualSceneSelection(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"];
|
||||||
|
if (Object.keys(value).length !== keys.length || keys.some(key => !(key in value))) return null;
|
||||||
|
const selection = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
if (typeof value[key] !== "string") return null;
|
||||||
|
const text = value[key].trim();
|
||||||
|
if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return null;
|
||||||
|
selection[key] = text;
|
||||||
|
}
|
||||||
|
return Object.freeze(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recreatePlaylistEntryWithSceneSelection(item, selection, code, fadeDuration) {
|
||||||
|
if (playlistEntryManualEditBlockReason(item)) return null;
|
||||||
|
const normalizedSelection = normalizeManualSceneSelection(selection);
|
||||||
|
if (!normalizedSelection || typeof code !== "string" || !sceneAliases(item).includes(code) ||
|
||||||
|
!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) return null;
|
||||||
|
return { ...item, selection: normalizedSelection, code, fadeDuration };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recreatePlaylistEntryWithLiveSelection(item, selection) {
|
||||||
|
if (playlistEntryManualEditBlockReason(item)) return null;
|
||||||
|
const normalizedSelection = normalizeManualSceneSelection(selection);
|
||||||
|
if (!normalizedSelection || (!normalizedSelection.subject && !normalizedSelection.dataCode)) return null;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
selection: normalizedSelection,
|
||||||
|
subject: normalizedSelection.subject,
|
||||||
|
dataCode: normalizedSelection.dataCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacePlaylistEntryAt(index, replacement) {
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length ||
|
||||||
|
!replacement || replacement.id !== state.playlist[index]?.id) return false;
|
||||||
|
const playlist = [...state.playlist];
|
||||||
|
playlist[index] = replacement;
|
||||||
|
state.playlist = playlist;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacePlaylistEntryEnabledAt(index, enabled) {
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= state.playlist.length) return null;
|
||||||
|
const replacement = recreatePlaylistEntryWithEnabled(state.playlist[index], enabled);
|
||||||
|
return replacement && replacePlaylistEntryAt(index, replacement) ? replacement : null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPlaylist() {
|
function renderPlaylist() {
|
||||||
renderGlobalCandleOptions();
|
renderGlobalCandleOptions();
|
||||||
elements.playlistBody.replaceChildren();
|
elements.playlistBody.replaceChildren();
|
||||||
@@ -3874,8 +3975,14 @@
|
|||||||
checkbox.checked = item.enabled !== false;
|
checkbox.checked = item.enabled !== false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.enabled = checkbox.checked;
|
const replacement = replacePlaylistEntryEnabledAt(index, checkbox.checked);
|
||||||
addLog(`${item.code} ${checkbox.checked ? "송출 포함" : "송출 제외"}`);
|
if (!replacement) {
|
||||||
|
checkbox.checked = item.enabled !== false;
|
||||||
|
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderPlaylist();
|
||||||
|
addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
|
||||||
});
|
});
|
||||||
checkCell.append(checkbox);
|
checkCell.append(checkbox);
|
||||||
|
|
||||||
@@ -3999,7 +4106,8 @@
|
|||||||
|
|
||||||
function renderSceneSelectionForm(item) {
|
function renderSceneSelectionForm(item) {
|
||||||
const selection = item?.selection || {};
|
const selection = item?.selection || {};
|
||||||
const disabled = !item || isPlaylistSnapshotLocked() || Boolean(item.operator?.source);
|
const disabled = !item || isPlaylistSnapshotLocked() ||
|
||||||
|
Boolean(playlistEntryManualEditBlockReason(item));
|
||||||
elements.sceneGroupCode.value = String(selection.groupCode || "");
|
elements.sceneGroupCode.value = String(selection.groupCode || "");
|
||||||
elements.sceneSubject.value = String(selection.subject || "");
|
elements.sceneSubject.value = String(selection.subject || "");
|
||||||
elements.sceneGraphicType.value = String(selection.graphicType || "");
|
elements.sceneGraphicType.value = String(selection.graphicType || "");
|
||||||
@@ -4033,6 +4141,11 @@
|
|||||||
if (!requirePlaylistEditable()) return;
|
if (!requirePlaylistEditable()) return;
|
||||||
const item = state.playlist[state.currentIndex];
|
const item = state.playlist[state.currentIndex];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
if (playlistEntryManualEditBlockReason(item)) {
|
||||||
|
renderSceneSelectionForm(item);
|
||||||
|
showToast("검증된 항목은 조회값을 직접 편집할 수 없습니다. 원본 입력 화면에서 다시 생성하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const values = [
|
const values = [
|
||||||
elements.sceneGroupCode.value,
|
elements.sceneGroupCode.value,
|
||||||
elements.sceneSubject.value,
|
elements.sceneSubject.value,
|
||||||
@@ -4054,18 +4167,21 @@
|
|||||||
showToast("허용되지 않은 CUT alias입니다.");
|
showToast("허용되지 않은 CUT alias입니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.selection = {
|
const replacement = recreatePlaylistEntryWithSceneSelection(item, {
|
||||||
groupCode: values[0].trim(),
|
groupCode: values[0].trim(),
|
||||||
subject: values[1].trim(),
|
subject: values[1].trim(),
|
||||||
graphicType: values[2].trim(),
|
graphicType: values[2].trim(),
|
||||||
subtype: values[3].trim(),
|
subtype: values[3].trim(),
|
||||||
dataCode: values[4].trim()
|
dataCode: values[4].trim()
|
||||||
};
|
}, cutAlias, fadeDuration);
|
||||||
item.code = cutAlias;
|
if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
|
||||||
item.fadeDuration = fadeDuration;
|
renderSceneSelectionForm(item);
|
||||||
|
showToast("장면 조회값을 안전하게 적용할 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
updatePreview();
|
updatePreview();
|
||||||
addLog(`${item.code} 장면 조회값 적용`);
|
addLog(`${replacement.code} 장면 조회값 적용`);
|
||||||
showToast("장면 조회값을 적용했습니다.");
|
showToast("장면 조회값을 적용했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4131,7 +4247,12 @@
|
|||||||
if (!requirePlaylistEditable()) return;
|
if (!requirePlaylistEditable()) return;
|
||||||
if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다.");
|
if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다.");
|
||||||
const enable = state.playlist.some(item => item.enabled === false);
|
const enable = state.playlist.some(item => item.enabled === false);
|
||||||
state.playlist.forEach(item => { item.enabled = enable; });
|
const replacements = state.playlist.map(item => recreatePlaylistEntryWithEnabled(item, enable));
|
||||||
|
if (replacements.some(item => !item)) {
|
||||||
|
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.playlist = replacements;
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`);
|
addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`);
|
||||||
}
|
}
|
||||||
@@ -4222,9 +4343,13 @@
|
|||||||
if (!requirePlaylistEditable()) return;
|
if (!requirePlaylistEditable()) return;
|
||||||
const item = state.playlist[state.currentIndex];
|
const item = state.playlist[state.currentIndex];
|
||||||
if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요.");
|
if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요.");
|
||||||
item.enabled = item.enabled === false;
|
const replacement = replacePlaylistEntryEnabledAt(state.currentIndex, item.enabled === false);
|
||||||
|
if (!replacement) {
|
||||||
|
showToast("검증된 항목의 송출 여부를 안전하게 변경할 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
addLog(`${item.code} ${item.enabled ? "송출 포함" : "송출 제외"}`);
|
addLog(`${replacement.code} ${replacement.enabled ? "송출 포함" : "송출 제외"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePlaylist() {
|
function savePlaylist() {
|
||||||
@@ -4284,6 +4409,16 @@
|
|||||||
? [{ ...themeDefinition, ...restoredThemeEntry }]
|
? [{ ...themeDefinition, ...restoredThemeEntry }]
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
const restoredNamedForeignStockEntry =
|
||||||
|
namedForeignStockRestoreWorkflow.restorePlaylistEntry(item);
|
||||||
|
if (restoredNamedForeignStockEntry) {
|
||||||
|
const overseasDefinition = catalog.find(definition =>
|
||||||
|
definition.builderKey === restoredNamedForeignStockEntry.builderKey &&
|
||||||
|
sceneAliases(definition).includes(restoredNamedForeignStockEntry.code));
|
||||||
|
return overseasDefinition
|
||||||
|
? [{ ...overseasDefinition, ...restoredNamedForeignStockEntry }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
const restoredOverseasEntry = overseasWorkflow.restoreOverseasPlaylistEntry(item);
|
const restoredOverseasEntry = overseasWorkflow.restoreOverseasPlaylistEntry(item);
|
||||||
if (restoredOverseasEntry) {
|
if (restoredOverseasEntry) {
|
||||||
const overseasDefinition = catalog.find(definition =>
|
const overseasDefinition = catalog.find(definition =>
|
||||||
@@ -4690,7 +4825,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.namedPlaylist.comparisonRestorePending ||
|
if (state.namedPlaylist.comparisonRestorePending ||
|
||||||
|
state.namedPlaylist.foreignStockRestorePending ||
|
||||||
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
||||||
|
state.namedPlaylist.krxThemeRestorePending ||
|
||||||
state.namedPlaylist.nxtThemeRestorePending) return;
|
state.namedPlaylist.nxtThemeRestorePending) return;
|
||||||
const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration);
|
const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration);
|
||||||
const materialized = materializeNamedRestoration(restoration);
|
const materialized = materializeNamedRestoration(restoration);
|
||||||
@@ -4868,10 +5005,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addThemeNamedCandidates(candidates, rawRow, itemIndex, now) {
|
function addThemeNamedCandidates(candidates, rawRow, itemIndex, now) {
|
||||||
// Historical NXT codes are not authoritative. Keep every original
|
// Historical theme codes are not authoritative. Keep every original KRX
|
||||||
// `테마_NXT` row untrusted until the correlated native MariaDB batch maps
|
// and NXT row untrusted until its correlated native batch maps the exact
|
||||||
// its exact title to the current unique code and proves a live item.
|
// title to one current code and validates the item preview.
|
||||||
if (rawRow.groupCode === "테마_NXT") return;
|
if (rawRow.groupCode === "테마" || rawRow.groupCode === "테마_NXT") return;
|
||||||
let theme;
|
let theme;
|
||||||
let sort;
|
let sort;
|
||||||
let actionIds = themeWorkflow.actions.map(action => action.id);
|
let actionIds = themeWorkflow.actions.map(action => action.id);
|
||||||
@@ -4994,6 +5131,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveNamedPlaylistRawRow(rawRow, itemIndex) {
|
function resolveNamedPlaylistRawRow(rawRow, itemIndex) {
|
||||||
|
// The stored theme code is historical evidence, not a playable identity.
|
||||||
|
// Route both legacy theme groups only through their correlated native
|
||||||
|
// verifiers; no stock/default candidate may accept the stale code first.
|
||||||
|
if (rawRow?.groupCode === "테마" || rawRow?.groupCode === "테마_NXT") return null;
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
const now = namedNowForRawRow(rawRow);
|
const now = namedNowForRawRow(rawRow);
|
||||||
addFixedNamedCandidates(candidates, rawRow, itemIndex, now);
|
addFixedNamedCandidates(candidates, rawRow, itemIndex, now);
|
||||||
@@ -5053,7 +5194,9 @@
|
|||||||
for (const row of restoration.rows) {
|
for (const row of restoration.rows) {
|
||||||
const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" ||
|
const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" ||
|
||||||
row.entry?.operator?.source === "legacy-manual-lists-workflow" ||
|
row.entry?.operator?.source === "legacy-manual-lists-workflow" ||
|
||||||
|
namedKrxThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
|
||||||
namedNxtThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
|
namedNxtThemeRestoreWorkflow.isMaterializedEntry(row.entry) ||
|
||||||
|
namedForeignStockRestoreWorkflow.isMaterializedEntry(row.entry) ||
|
||||||
foreignIndexCandleRestoreWorkflow.isMaterializedEntry(row.entry);
|
foreignIndexCandleRestoreWorkflow.isMaterializedEntry(row.entry);
|
||||||
const entry = row.trusted
|
const entry = row.trusted
|
||||||
? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText })
|
? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText })
|
||||||
@@ -5099,7 +5242,10 @@
|
|||||||
state.namedPlaylist.pagePlanError = null;
|
state.namedPlaylist.pagePlanError = null;
|
||||||
state.namedPlaylist.manualRestoreFinalized = true;
|
state.namedPlaylist.manualRestoreFinalized = true;
|
||||||
state.namedPlaylist.comparisonRestorePending = null;
|
state.namedPlaylist.comparisonRestorePending = null;
|
||||||
|
clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
|
||||||
|
state.namedPlaylist.foreignStockRestorePending = null;
|
||||||
state.namedPlaylist.foreignIndexCandleRestorePending = null;
|
state.namedPlaylist.foreignIndexCandleRestorePending = null;
|
||||||
|
state.namedPlaylist.krxThemeRestorePending = null;
|
||||||
state.namedPlaylist.nxtThemeRestorePending = null;
|
state.namedPlaylist.nxtThemeRestorePending = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5173,7 +5319,9 @@
|
|||||||
}
|
}
|
||||||
return manualFields;
|
return manualFields;
|
||||||
}
|
}
|
||||||
const selection = namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
|
const selection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
|
||||||
|
namedNxtThemeRestoreWorkflow.legacySelectionForEntry(entry) ||
|
||||||
|
namedForeignStockRestoreWorkflow.legacySelectionForEntry(entry) ||
|
||||||
legacyNamedRowWorkflow.legacySelectionForEntry(entry);
|
legacyNamedRowWorkflow.legacySelectionForEntry(entry);
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
throw new Error("이 항목은 원본 PList와 상호 운용되는 7필드 서명이 아직 없습니다.");
|
throw new Error("이 항목은 원본 PList와 상호 운용되는 7필드 서명이 아직 없습니다.");
|
||||||
@@ -5191,6 +5339,12 @@
|
|||||||
pageText: fields[5],
|
pageText: fields[5],
|
||||||
dataCode: fields[6]
|
dataCode: fields[6]
|
||||||
};
|
};
|
||||||
|
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
||||||
|
if (!namedKrxThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
|
||||||
|
throw new Error("KRX 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
||||||
if (!namedNxtThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
|
if (!namedNxtThemeRestoreWorkflow.matchesRaw(entry, rawRow)) {
|
||||||
throw new Error("NXT 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
|
throw new Error("NXT 테마의 과거 code와 현재 송출 code를 안전하게 왕복할 수 없습니다.");
|
||||||
@@ -5215,6 +5369,15 @@
|
|||||||
}
|
}
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
if (entry.operator?.source === "legacy-named-foreign-stock-workflow") {
|
||||||
|
const restoredForeignStock = namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry);
|
||||||
|
if (!restoredForeignStock || restoredForeignStock.builderKey !== entry.builderKey ||
|
||||||
|
restoredForeignStock.code !== entry.code ||
|
||||||
|
!namedForeignStockRestoreWorkflow.matchesRaw(restoredForeignStock, rawRow)) {
|
||||||
|
throw new Error("해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다.");
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
const roundTrip = resolveNamedPlaylistRawRow(rawRow, itemIndex);
|
const roundTrip = resolveNamedPlaylistRawRow(rawRow, itemIndex);
|
||||||
if (!roundTrip || roundTrip.builderKey !== entry.builderKey || roundTrip.code !== entry.code) {
|
if (!roundTrip || roundTrip.builderKey !== entry.builderKey || roundTrip.code !== entry.code) {
|
||||||
throw new Error("원본 PList 7필드 서명을 현재 항목으로 유일하게 복원할 수 없습니다.");
|
throw new Error("원본 PList 7필드 서명을 현재 항목으로 유일하게 복원할 수 없습니다.");
|
||||||
@@ -5225,6 +5388,27 @@
|
|||||||
function isTrustedNamedPlaylistEntry(entry, itemIndex = 0) {
|
function isTrustedNamedPlaylistEntry(entry, itemIndex = 0) {
|
||||||
const guard = state.namedPlaylist.guardByEntryId.get(entry?.id);
|
const guard = state.namedPlaylist.guardByEntryId.get(entry?.id);
|
||||||
if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false;
|
if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false;
|
||||||
|
if (namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
||||||
|
if (!guard || !guard.trusted) return false;
|
||||||
|
try {
|
||||||
|
const legacySelection = namedKrxThemeRestoreWorkflow.legacySelectionForEntry(entry);
|
||||||
|
const fields = namedPlaylistWorkflow.toLegacySevenFields(
|
||||||
|
{ ...entry, selection: legacySelection },
|
||||||
|
namedPageTextForEntry(entry));
|
||||||
|
return namedKrxThemeRestoreWorkflow.matchesRaw(entry, {
|
||||||
|
itemIndex,
|
||||||
|
enabled: fields[0] === "1",
|
||||||
|
groupCode: fields[1],
|
||||||
|
subject: fields[2],
|
||||||
|
graphicType: fields[3],
|
||||||
|
subtype: fields[4],
|
||||||
|
pageText: fields[5],
|
||||||
|
dataCode: fields[6]
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
if (namedNxtThemeRestoreWorkflow.isMaterializedEntry(entry)) {
|
||||||
if (!guard || !guard.trusted) return false;
|
if (!guard || !guard.trusted) return false;
|
||||||
try {
|
try {
|
||||||
@@ -5248,19 +5432,24 @@
|
|||||||
}
|
}
|
||||||
if (entry?.operator?.source === "legacy-foreign-index-candle-workflow" &&
|
if (entry?.operator?.source === "legacy-foreign-index-candle-workflow" &&
|
||||||
(!guard || !guard.trusted)) return false;
|
(!guard || !guard.trusted)) return false;
|
||||||
|
if (entry?.operator?.source === "legacy-named-foreign-stock-workflow" &&
|
||||||
|
(!guard || !guard.trusted)) return false;
|
||||||
if (namedManualRestoreWorkflow.isTrustedEntryForSave(entry, itemIndex)) return true;
|
if (namedManualRestoreWorkflow.isTrustedEntryForSave(entry, itemIndex)) return true;
|
||||||
const selection = entry?.selection;
|
const selection = entry?.selection;
|
||||||
if (!selection) return false;
|
if (!selection) return false;
|
||||||
const now = namedNowForRawRow(selection);
|
const now = namedNowForRawRow(selection);
|
||||||
const restored = entry.operator?.source === "legacy-foreign-index-candle-workflow"
|
const restored = entry.operator?.source === "legacy-foreign-index-candle-workflow"
|
||||||
? [foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
|
? [foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
|
||||||
|
: entry.operator?.source === "legacy-named-foreign-stock-workflow"
|
||||||
|
? [namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry)].filter(Boolean)
|
||||||
: normalizeStoredPlaylist([entry], { now });
|
: normalizeStoredPlaylist([entry], { now });
|
||||||
if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey ||
|
if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey ||
|
||||||
restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false;
|
restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false;
|
||||||
if ([
|
if ([
|
||||||
"legacy-comparison-workflow",
|
"legacy-comparison-workflow",
|
||||||
"legacy-industry-workflow",
|
"legacy-industry-workflow",
|
||||||
"legacy-foreign-index-candle-workflow"
|
"legacy-foreign-index-candle-workflow",
|
||||||
|
"legacy-named-foreign-stock-workflow"
|
||||||
]
|
]
|
||||||
.includes(entry.operator?.source)) {
|
.includes(entry.operator?.source)) {
|
||||||
const legacySelection = legacyNamedRowWorkflow.legacySelectionForEntry(entry);
|
const legacySelection = legacyNamedRowWorkflow.legacySelectionForEntry(entry);
|
||||||
@@ -5287,7 +5476,9 @@
|
|||||||
? comparisonWorkflow.restoreComparisonPlaylistEntry(entry)
|
? comparisonWorkflow.restoreComparisonPlaylistEntry(entry)
|
||||||
: entry.operator.source === "legacy-industry-workflow"
|
: entry.operator.source === "legacy-industry-workflow"
|
||||||
? industryWorkflow.restoreIndustryPlaylistEntry(entry)
|
? industryWorkflow.restoreIndustryPlaylistEntry(entry)
|
||||||
: foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry);
|
: entry.operator.source === "legacy-foreign-index-candle-workflow"
|
||||||
|
? foreignIndexCandleRestoreWorkflow.restorePlaylistEntry(entry)
|
||||||
|
: namedForeignStockRestoreWorkflow.restorePlaylistEntry(entry);
|
||||||
return Boolean(restoredClosedEntry && restoredClosedEntry.builderKey === entry.builderKey &&
|
return Boolean(restoredClosedEntry && restoredClosedEntry.builderKey === entry.builderKey &&
|
||||||
restoredClosedEntry.code === entry.code &&
|
restoredClosedEntry.code === entry.code &&
|
||||||
legacyNamedRowWorkflow.matchesCanonicalEntry(restoredClosedEntry, rawRow));
|
legacyNamedRowWorkflow.matchesCanonicalEntry(restoredClosedEntry, rawRow));
|
||||||
@@ -5319,7 +5510,9 @@
|
|||||||
function namedPlaylistBusy() {
|
function namedPlaylistBusy() {
|
||||||
return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
|
return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending ||
|
||||||
state.namedPlaylist.comparisonRestorePending ||
|
state.namedPlaylist.comparisonRestorePending ||
|
||||||
|
state.namedPlaylist.foreignStockRestorePending ||
|
||||||
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
state.namedPlaylist.foreignIndexCandleRestorePending ||
|
||||||
|
state.namedPlaylist.krxThemeRestorePending ||
|
||||||
state.namedPlaylist.nxtThemeRestorePending ||
|
state.namedPlaylist.nxtThemeRestorePending ||
|
||||||
state.manualFinancialRestore.entries.size > 0);
|
state.manualFinancialRestore.entries.size > 0);
|
||||||
}
|
}
|
||||||
@@ -5330,7 +5523,9 @@
|
|||||||
const pending = workflow.pending || workflow.pagePlanPending;
|
const pending = workflow.pending || workflow.pagePlanPending;
|
||||||
const freshRestorePending = state.manualFinancialRestore.entries.size > 0 ||
|
const freshRestorePending = state.manualFinancialRestore.entries.size > 0 ||
|
||||||
Boolean(workflow.comparisonRestorePending) ||
|
Boolean(workflow.comparisonRestorePending) ||
|
||||||
|
Boolean(workflow.foreignStockRestorePending) ||
|
||||||
Boolean(workflow.foreignIndexCandleRestorePending) ||
|
Boolean(workflow.foreignIndexCandleRestorePending) ||
|
||||||
|
Boolean(workflow.krxThemeRestorePending) ||
|
||||||
Boolean(workflow.nxtThemeRestorePending);
|
Boolean(workflow.nxtThemeRestorePending);
|
||||||
const busy = Boolean(pending) || freshRestorePending;
|
const busy = Boolean(pending) || freshRestorePending;
|
||||||
const selected = selectedNamedPlaylistDefinition();
|
const selected = selectedNamedPlaylistDefinition();
|
||||||
@@ -5619,6 +5814,16 @@
|
|||||||
});
|
});
|
||||||
return pending ? [pending] : [];
|
return pending ? [pending] : [];
|
||||||
});
|
});
|
||||||
|
const pendingForeignStocks = restoration.rows.flatMap(row => {
|
||||||
|
if (row.trusted) return [];
|
||||||
|
const pending = namedForeignStockRestoreWorkflow.classify(row.rawRow, {
|
||||||
|
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
|
||||||
|
fadeDuration: DEFAULT_FADE_DURATION,
|
||||||
|
ma5: state.candleOptions.ma5,
|
||||||
|
ma20: state.candleOptions.ma20
|
||||||
|
});
|
||||||
|
return pending ? [pending] : [];
|
||||||
|
});
|
||||||
const pendingForeignIndexCandles = restoration.rows.flatMap(row => {
|
const pendingForeignIndexCandles = restoration.rows.flatMap(row => {
|
||||||
if (row.trusted) return [];
|
if (row.trusted) return [];
|
||||||
const pending = foreignIndexCandleRestoreWorkflow.classify(row.rawRow, {
|
const pending = foreignIndexCandleRestoreWorkflow.classify(row.rawRow, {
|
||||||
@@ -5627,6 +5832,14 @@
|
|||||||
});
|
});
|
||||||
return pending ? [pending] : [];
|
return pending ? [pending] : [];
|
||||||
});
|
});
|
||||||
|
const pendingKrxThemes = restoration.rows.flatMap(row => {
|
||||||
|
if (row.trusted) return [];
|
||||||
|
const pending = namedKrxThemeRestoreWorkflow.classify(row.rawRow, {
|
||||||
|
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
|
||||||
|
fadeDuration: DEFAULT_FADE_DURATION
|
||||||
|
});
|
||||||
|
return pending ? [pending] : [];
|
||||||
|
});
|
||||||
const pendingNxtThemes = restoration.rows.flatMap(row => {
|
const pendingNxtThemes = restoration.rows.flatMap(row => {
|
||||||
if (row.trusted) return [];
|
if (row.trusted) return [];
|
||||||
const pending = namedNxtThemeRestoreWorkflow.classify(row.rawRow, {
|
const pending = namedNxtThemeRestoreWorkflow.classify(row.rawRow, {
|
||||||
@@ -5637,7 +5850,9 @@
|
|||||||
});
|
});
|
||||||
state.namedPlaylist.manualRestoreFinalized =
|
state.namedPlaylist.manualRestoreFinalized =
|
||||||
pendingNamedManual.length === 0 && pendingNamedComparison.length === 0 &&
|
pendingNamedManual.length === 0 && pendingNamedComparison.length === 0 &&
|
||||||
pendingForeignIndexCandles.length === 0 && pendingNxtThemes.length === 0;
|
pendingForeignStocks.length === 0 &&
|
||||||
|
pendingForeignIndexCandles.length === 0 && pendingKrxThemes.length === 0 &&
|
||||||
|
pendingNxtThemes.length === 0;
|
||||||
if (state.namedPlaylist.manualRestoreFinalized) {
|
if (state.namedPlaylist.manualRestoreFinalized) {
|
||||||
restoration = prepareNamedPagePreflight(restoration);
|
restoration = prepareNamedPagePreflight(restoration);
|
||||||
}
|
}
|
||||||
@@ -5652,12 +5867,26 @@
|
|||||||
rows: Object.freeze(pendingNamedComparison)
|
rows: Object.freeze(pendingNamedComparison)
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
state.namedPlaylist.foreignStockRestorePending = pendingForeignStocks.length
|
||||||
|
? {
|
||||||
|
requestId: load.requestId,
|
||||||
|
rows: Object.freeze(pendingForeignStocks),
|
||||||
|
timeoutId: null
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
armNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
|
||||||
state.namedPlaylist.foreignIndexCandleRestorePending = pendingForeignIndexCandles.length
|
state.namedPlaylist.foreignIndexCandleRestorePending = pendingForeignIndexCandles.length
|
||||||
? Object.freeze({
|
? Object.freeze({
|
||||||
requestId: load.requestId,
|
requestId: load.requestId,
|
||||||
rows: Object.freeze(pendingForeignIndexCandles)
|
rows: Object.freeze(pendingForeignIndexCandles)
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
state.namedPlaylist.krxThemeRestorePending = pendingKrxThemes.length
|
||||||
|
? Object.freeze({
|
||||||
|
requestId: load.requestId,
|
||||||
|
rows: Object.freeze(pendingKrxThemes)
|
||||||
|
})
|
||||||
|
: null;
|
||||||
state.namedPlaylist.nxtThemeRestorePending = pendingNxtThemes.length
|
state.namedPlaylist.nxtThemeRestorePending = pendingNxtThemes.length
|
||||||
? Object.freeze({
|
? Object.freeze({
|
||||||
requestId: load.requestId,
|
requestId: load.requestId,
|
||||||
@@ -5776,6 +6005,115 @@
|
|||||||
error?.message || "비교 행의 현재 종목 마스터 재검증을 완료하지 못했습니다.");
|
error?.message || "비교 행의 현재 종목 마스터 재검증을 완료하지 못했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearNamedForeignStockRestoreTimeout(record) {
|
||||||
|
if (record?.timeoutId !== null && record?.timeoutId !== undefined) {
|
||||||
|
clearTimeout(record.timeoutId);
|
||||||
|
record.timeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function armNamedForeignStockRestoreTimeout(record) {
|
||||||
|
if (!record) return;
|
||||||
|
clearNamedForeignStockRestoreTimeout(record);
|
||||||
|
record.timeoutId = setTimeout(() => {
|
||||||
|
if (state.namedPlaylist.foreignStockRestorePending !== record) return;
|
||||||
|
failNamedForeignStockRestore(
|
||||||
|
record,
|
||||||
|
"해외종목 identity 재검증 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.");
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failNamedForeignStockRestore(record, message) {
|
||||||
|
if (!record || state.namedPlaylist.foreignStockRestorePending !== record) return;
|
||||||
|
clearNamedForeignStockRestoreTimeout(record);
|
||||||
|
state.namedPlaylist.foreignStockRestorePending = null;
|
||||||
|
state.namedPlaylist.error = message;
|
||||||
|
state.namedPlaylist.status = "error";
|
||||||
|
finalizeNamedManualRestores();
|
||||||
|
renderPlaylist();
|
||||||
|
renderNamedPlaylist();
|
||||||
|
renderPlayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNamedForeignStockRestoreResults(payload) {
|
||||||
|
const record = state.namedPlaylist.foreignStockRestorePending;
|
||||||
|
if (!record || payload?.requestId !== record.requestId) return;
|
||||||
|
const response = namedForeignStockRestoreWorkflow.normalizeResponse(
|
||||||
|
payload,
|
||||||
|
record.requestId,
|
||||||
|
record.rows);
|
||||||
|
if (!response) {
|
||||||
|
failNamedForeignStockRestore(
|
||||||
|
record,
|
||||||
|
"Native Bridge의 해외종목 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const restoration = state.namedPlaylist.restoration;
|
||||||
|
if (!restoration || isPlaylistSnapshotLocked()) {
|
||||||
|
failNamedForeignStockRestore(
|
||||||
|
record,
|
||||||
|
"해외종목 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...restoration.rows];
|
||||||
|
let restoredCount = 0;
|
||||||
|
const failures = new Map();
|
||||||
|
for (let index = 0; index < response.rows.length; index += 1) {
|
||||||
|
const pending = record.rows[index];
|
||||||
|
const outcome = response.rows[index];
|
||||||
|
if (outcome.status === "failed") {
|
||||||
|
failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const storedRow = rows[pending.itemIndex];
|
||||||
|
const entry = namedForeignStockRestoreWorkflow.materialize(pending, outcome);
|
||||||
|
if (!storedRow || storedRow.trusted || !entry ||
|
||||||
|
!namedForeignStockRestoreWorkflow.isMaterializedEntry(entry) ||
|
||||||
|
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
|
||||||
|
entry.enabled !== storedRow.rawRow.enabled ||
|
||||||
|
!namedForeignStockRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
|
||||||
|
failures.set("MATERIALIZATION_MISMATCH",
|
||||||
|
(failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows[pending.itemIndex] = Object.freeze({
|
||||||
|
...storedRow,
|
||||||
|
entry,
|
||||||
|
trusted: true
|
||||||
|
});
|
||||||
|
state.playlist[pending.itemIndex] = entry;
|
||||||
|
restoredCount += 1;
|
||||||
|
}
|
||||||
|
state.namedPlaylist.restoration = Object.freeze({
|
||||||
|
...restoration,
|
||||||
|
rows: Object.freeze(rows)
|
||||||
|
});
|
||||||
|
clearNamedForeignStockRestoreTimeout(record);
|
||||||
|
state.namedPlaylist.foreignStockRestorePending = null;
|
||||||
|
addLog(`DB 해외종목 재검증 · 복원 ${restoredCount}건 · 차단 ${response.rows.length - restoredCount}건`);
|
||||||
|
if (failures.size) {
|
||||||
|
const summary = [...failures.entries()]
|
||||||
|
.map(([code, count]) => `${code} ${count}`)
|
||||||
|
.join(" · ");
|
||||||
|
addLog(`DB 해외종목 차단 사유 · ${summary}`);
|
||||||
|
}
|
||||||
|
finalizeNamedManualRestores();
|
||||||
|
renderPlaylist();
|
||||||
|
updatePreview();
|
||||||
|
renderNamedPlaylist();
|
||||||
|
renderPlayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNamedForeignStockRestoreError(payload) {
|
||||||
|
const record = state.namedPlaylist.foreignStockRestorePending;
|
||||||
|
if (!record || payload?.requestId !== record.requestId) return;
|
||||||
|
const error = namedForeignStockRestoreWorkflow.normalizeError(payload, record.requestId);
|
||||||
|
failNamedForeignStockRestore(
|
||||||
|
record,
|
||||||
|
error?.message || "해외종목의 현재 Oracle identity 재검증을 완료하지 못했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
function failNamedForeignIndexCandleRestore(record, message) {
|
function failNamedForeignIndexCandleRestore(record, message) {
|
||||||
if (!record || state.namedPlaylist.foreignIndexCandleRestorePending !== record) return;
|
if (!record || state.namedPlaylist.foreignIndexCandleRestorePending !== record) return;
|
||||||
state.namedPlaylist.foreignIndexCandleRestorePending = null;
|
state.namedPlaylist.foreignIndexCandleRestorePending = null;
|
||||||
@@ -5865,6 +6203,97 @@
|
|||||||
error?.message || "해외지수 캔들의 현재 master 재검증을 완료하지 못했습니다.");
|
error?.message || "해외지수 캔들의 현재 master 재검증을 완료하지 못했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function failNamedKrxThemeRestore(record, message) {
|
||||||
|
if (!record || state.namedPlaylist.krxThemeRestorePending !== record) return;
|
||||||
|
state.namedPlaylist.krxThemeRestorePending = null;
|
||||||
|
state.namedPlaylist.error = message;
|
||||||
|
state.namedPlaylist.status = "error";
|
||||||
|
finalizeNamedManualRestores();
|
||||||
|
renderPlaylist();
|
||||||
|
renderNamedPlaylist();
|
||||||
|
renderPlayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNamedKrxThemeRestoreResults(payload) {
|
||||||
|
const record = state.namedPlaylist.krxThemeRestorePending;
|
||||||
|
if (!record || payload?.requestId !== record.requestId) return;
|
||||||
|
const response = namedKrxThemeRestoreWorkflow.normalizeResponse(
|
||||||
|
payload,
|
||||||
|
record.requestId,
|
||||||
|
record.rows);
|
||||||
|
if (!response) {
|
||||||
|
failNamedKrxThemeRestore(
|
||||||
|
record,
|
||||||
|
"Native Bridge의 KRX 테마 재검증 응답이 현재 DB 목록과 일치하지 않습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const restoration = state.namedPlaylist.restoration;
|
||||||
|
if (!restoration || isPlaylistSnapshotLocked()) {
|
||||||
|
failNamedKrxThemeRestore(
|
||||||
|
record,
|
||||||
|
"KRX 테마 재검증 중 플레이리스트 상태가 바뀌어 결과를 적용하지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...restoration.rows];
|
||||||
|
let restoredCount = 0;
|
||||||
|
let remappedCount = 0;
|
||||||
|
const failures = new Map();
|
||||||
|
for (let index = 0; index < response.rows.length; index += 1) {
|
||||||
|
const pending = record.rows[index];
|
||||||
|
const outcome = response.rows[index];
|
||||||
|
if (outcome.status === "failed") {
|
||||||
|
failures.set(outcome.failure, (failures.get(outcome.failure) || 0) + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const storedRow = rows[pending.itemIndex];
|
||||||
|
const entry = namedKrxThemeRestoreWorkflow.materialize(pending, outcome);
|
||||||
|
if (!storedRow || storedRow.trusted || !entry ||
|
||||||
|
!namedKrxThemeRestoreWorkflow.isMaterializedEntry(entry) ||
|
||||||
|
state.playlist[pending.itemIndex]?.id !== pending.entry.id ||
|
||||||
|
entry.enabled !== storedRow.rawRow.enabled ||
|
||||||
|
!namedKrxThemeRestoreWorkflow.matchesRaw(entry, storedRow.rawRow)) {
|
||||||
|
failures.set("MATERIALIZATION_MISMATCH",
|
||||||
|
(failures.get("MATERIALIZATION_MISMATCH") || 0) + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows[pending.itemIndex] = Object.freeze({
|
||||||
|
...storedRow,
|
||||||
|
entry,
|
||||||
|
trusted: true
|
||||||
|
});
|
||||||
|
state.playlist[pending.itemIndex] = entry;
|
||||||
|
restoredCount += 1;
|
||||||
|
if (outcome.codeWasRemapped) remappedCount += 1;
|
||||||
|
}
|
||||||
|
state.namedPlaylist.restoration = Object.freeze({
|
||||||
|
...restoration,
|
||||||
|
rows: Object.freeze(rows)
|
||||||
|
});
|
||||||
|
state.namedPlaylist.krxThemeRestorePending = null;
|
||||||
|
addLog(`DB KRX 테마 재검증 · 복원 ${restoredCount}건 · code 갱신 ${remappedCount}건 · 차단 ${response.rows.length - restoredCount}건`);
|
||||||
|
if (failures.size) {
|
||||||
|
const summary = [...failures.entries()]
|
||||||
|
.map(([code, count]) => `${code} ${count}`)
|
||||||
|
.join(" · ");
|
||||||
|
addLog(`DB KRX 테마 차단 사유 · ${summary}`);
|
||||||
|
}
|
||||||
|
finalizeNamedManualRestores();
|
||||||
|
renderPlaylist();
|
||||||
|
updatePreview();
|
||||||
|
renderNamedPlaylist();
|
||||||
|
renderPlayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNamedKrxThemeRestoreError(payload) {
|
||||||
|
const record = state.namedPlaylist.krxThemeRestorePending;
|
||||||
|
if (!record || payload?.requestId !== record.requestId) return;
|
||||||
|
const error = namedKrxThemeRestoreWorkflow.normalizeError(payload, record.requestId);
|
||||||
|
failNamedKrxThemeRestore(
|
||||||
|
record,
|
||||||
|
error?.message || "KRX 테마의 현재 Oracle identity 재검증을 완료하지 못했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
function failNamedNxtThemeRestore(record, message) {
|
function failNamedNxtThemeRestore(record, message) {
|
||||||
if (!record || state.namedPlaylist.nxtThemeRestorePending !== record) return;
|
if (!record || state.namedPlaylist.nxtThemeRestorePending !== record) return;
|
||||||
state.namedPlaylist.nxtThemeRestorePending = null;
|
state.namedPlaylist.nxtThemeRestorePending = null;
|
||||||
@@ -6084,7 +6513,48 @@
|
|||||||
function handleNamedPlaylistError(payload) {
|
function handleNamedPlaylistError(payload) {
|
||||||
const error = namedPlaylistWorkflow.normalizeError(payload);
|
const error = namedPlaylistWorkflow.normalizeError(payload);
|
||||||
const pending = state.namedPlaylist.pending;
|
const pending = state.namedPlaylist.pending;
|
||||||
if (!error || !pending || error.requestId !== pending.requestId || error.operation !== pending.operation) return;
|
if (!error) return;
|
||||||
|
if (!pending || error.requestId !== pending.requestId || error.operation !== pending.operation) {
|
||||||
|
// A load result is posted before its correlated restore batches. If the
|
||||||
|
// native read is then canceled (for example by playout priority), the
|
||||||
|
// primary load is no longer pending but one or more restore batches can
|
||||||
|
// still be waiting. Close every matching batch exactly once so PREPARE
|
||||||
|
// stays blocked by the untrusted rows instead of an orphaned busy flag.
|
||||||
|
if (error.operation !== "load") return;
|
||||||
|
const restoreRecords = [
|
||||||
|
state.namedPlaylist.comparisonRestorePending,
|
||||||
|
state.namedPlaylist.foreignStockRestorePending,
|
||||||
|
state.namedPlaylist.foreignIndexCandleRestorePending,
|
||||||
|
state.namedPlaylist.krxThemeRestorePending,
|
||||||
|
state.namedPlaylist.nxtThemeRestorePending
|
||||||
|
];
|
||||||
|
if (!restoreRecords.some(record => record?.requestId === error.requestId)) return;
|
||||||
|
if (state.namedPlaylist.comparisonRestorePending?.requestId === error.requestId) {
|
||||||
|
state.namedPlaylist.comparisonRestorePending = null;
|
||||||
|
}
|
||||||
|
if (state.namedPlaylist.foreignStockRestorePending?.requestId === error.requestId) {
|
||||||
|
clearNamedForeignStockRestoreTimeout(state.namedPlaylist.foreignStockRestorePending);
|
||||||
|
state.namedPlaylist.foreignStockRestorePending = null;
|
||||||
|
}
|
||||||
|
if (state.namedPlaylist.foreignIndexCandleRestorePending?.requestId === error.requestId) {
|
||||||
|
state.namedPlaylist.foreignIndexCandleRestorePending = null;
|
||||||
|
}
|
||||||
|
if (state.namedPlaylist.krxThemeRestorePending?.requestId === error.requestId) {
|
||||||
|
state.namedPlaylist.krxThemeRestorePending = null;
|
||||||
|
}
|
||||||
|
if (state.namedPlaylist.nxtThemeRestorePending?.requestId === error.requestId) {
|
||||||
|
state.namedPlaylist.nxtThemeRestorePending = null;
|
||||||
|
}
|
||||||
|
state.namedPlaylist.status = "error";
|
||||||
|
state.namedPlaylist.error = `${error.message} [${error.code}] 복원 배치는 자동 재시도하지 않습니다.`;
|
||||||
|
finalizeNamedManualRestores();
|
||||||
|
renderPlaylist();
|
||||||
|
renderNamedPlaylist();
|
||||||
|
renderPlayout();
|
||||||
|
addLog(`DB 플레이리스트 LOAD 후속 복원 종료 · ${error.code}`);
|
||||||
|
showToast(state.namedPlaylist.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.namedPlaylist.pending = null;
|
state.namedPlaylist.pending = null;
|
||||||
state.namedPlaylist.status = "error";
|
state.namedPlaylist.status = "error";
|
||||||
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || error.writeQuarantined;
|
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || error.writeQuarantined;
|
||||||
@@ -7132,11 +7602,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyLiveRowToCurrentCut(table, row, columns) {
|
function applyLiveRowToCurrentCut(table, row, columns) {
|
||||||
|
if (!requirePlaylistEditable()) return;
|
||||||
const item = state.playlist[state.currentIndex];
|
const item = state.playlist[state.currentIndex];
|
||||||
if (!item) {
|
if (!item) {
|
||||||
showToast("먼저 적용할 플레이리스트 장면을 선택하세요.");
|
showToast("먼저 적용할 플레이리스트 장면을 선택하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (playlistEntryManualEditBlockReason(item)) {
|
||||||
|
showToast("검증된 항목에는 DB 행을 직접 덮어쓸 수 없습니다. 원본 입력 화면에서 다시 생성하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const values = liveRowValues(row, columns);
|
const values = liveRowValues(row, columns);
|
||||||
const subject = firstLiveValue(values, [
|
const subject = firstLiveValue(values, [
|
||||||
@@ -7152,18 +7627,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousSelection = item.selection || {};
|
const previousSelection = item.selection || {};
|
||||||
item.selection = {
|
const replacement = recreatePlaylistEntryWithLiveSelection(item, {
|
||||||
groupCode: liveSelectionGroup(table, item),
|
groupCode: liveSelectionGroup(table, item),
|
||||||
subject: subject || dataCode,
|
subject: subject || dataCode,
|
||||||
graphicType: String(previousSelection.graphicType || item.graphicType || ""),
|
graphicType: String(previousSelection.graphicType || item.graphicType || ""),
|
||||||
subtype: String(previousSelection.subtype || item.subtype || ""),
|
subtype: String(previousSelection.subtype || item.subtype || ""),
|
||||||
dataCode
|
dataCode
|
||||||
};
|
});
|
||||||
item.subject = item.selection.subject;
|
if (!replacement || !replacePlaylistEntryAt(state.currentIndex, replacement)) {
|
||||||
item.dataCode = dataCode;
|
showToast("선택한 DB 행을 현재 장면에 안전하게 적용할 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
updatePreview();
|
updatePreview();
|
||||||
addLog(`${item.code} DB 선택 적용 · ${item.selection.subject}`);
|
addLog(`${replacement.code} DB 선택 적용 · ${replacement.selection.subject}`);
|
||||||
showToast("선택한 DB 행을 현재 장면에 적용했습니다.");
|
showToast("선택한 DB 행을 현재 장면에 적용했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7490,12 +7967,24 @@
|
|||||||
case "named-comparison-restore-error":
|
case "named-comparison-restore-error":
|
||||||
handleNamedComparisonRestoreError(message.payload);
|
handleNamedComparisonRestoreError(message.payload);
|
||||||
break;
|
break;
|
||||||
|
case "named-foreign-stock-restore-results":
|
||||||
|
handleNamedForeignStockRestoreResults(message.payload);
|
||||||
|
break;
|
||||||
|
case "named-foreign-stock-restore-error":
|
||||||
|
handleNamedForeignStockRestoreError(message.payload);
|
||||||
|
break;
|
||||||
case "named-foreign-index-candle-restore-results":
|
case "named-foreign-index-candle-restore-results":
|
||||||
handleNamedForeignIndexCandleRestoreResults(message.payload);
|
handleNamedForeignIndexCandleRestoreResults(message.payload);
|
||||||
break;
|
break;
|
||||||
case "named-foreign-index-candle-restore-error":
|
case "named-foreign-index-candle-restore-error":
|
||||||
handleNamedForeignIndexCandleRestoreError(message.payload);
|
handleNamedForeignIndexCandleRestoreError(message.payload);
|
||||||
break;
|
break;
|
||||||
|
case "named-krx-theme-restore-results":
|
||||||
|
handleNamedKrxThemeRestoreResults(message.payload);
|
||||||
|
break;
|
||||||
|
case "named-krx-theme-restore-error":
|
||||||
|
handleNamedKrxThemeRestoreError(message.payload);
|
||||||
|
break;
|
||||||
case "named-nxt-theme-restore-results":
|
case "named-nxt-theme-restore-results":
|
||||||
handleNamedNxtThemeRestoreResults(message.payload);
|
handleNamedNxtThemeRestoreResults(message.payload);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"legacy-fixed-workflow",
|
"legacy-fixed-workflow",
|
||||||
"legacy-industry-workflow",
|
"legacy-industry-workflow",
|
||||||
"legacy-overseas-workflow",
|
"legacy-overseas-workflow",
|
||||||
|
"legacy-named-foreign-stock-workflow",
|
||||||
"legacy-trading-halt-workflow"
|
"legacy-trading-halt-workflow"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -84,6 +85,24 @@
|
|||||||
options);
|
options);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "legacy-named-foreign-stock-workflow": {
|
||||||
|
const action = adapters.overseas.stockActions.find(item => item.id === operator.actionId);
|
||||||
|
if (operator.kind !== "stock" || action?.kind !== "candle" ||
|
||||||
|
!operator.rawSelection || typeof operator.verifiedAt !== "string") return null;
|
||||||
|
const canonical = adapters.overseas.createOverseasStockPlaylistEntry(
|
||||||
|
operator.identity,
|
||||||
|
action.id,
|
||||||
|
options);
|
||||||
|
recreated = {
|
||||||
|
...canonical,
|
||||||
|
operator: Object.freeze({
|
||||||
|
...operator,
|
||||||
|
identity: canonical.operator.identity,
|
||||||
|
movingAverages: canonical.operator.movingAverages
|
||||||
|
})
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "legacy-trading-halt-workflow": {
|
case "legacy-trading-halt-workflow": {
|
||||||
const action = adapters.tradingHalt.getAction(operator.actionId);
|
const action = adapters.tradingHalt.getAction(operator.actionId);
|
||||||
if (action?.kind !== "candle") return null;
|
if (action?.kind !== "candle") return null;
|
||||||
|
|||||||
@@ -570,7 +570,9 @@
|
|||||||
<script src="manual-financial-workflow.js"></script>
|
<script src="manual-financial-workflow.js"></script>
|
||||||
<script src="named-manual-restore-workflow.js"></script>
|
<script src="named-manual-restore-workflow.js"></script>
|
||||||
<script src="named-comparison-restore-workflow.js"></script>
|
<script src="named-comparison-restore-workflow.js"></script>
|
||||||
|
<script src="named-foreign-stock-restore-workflow.js"></script>
|
||||||
<script src="legacy-foreign-index-candle-workflow.js"></script>
|
<script src="legacy-foreign-index-candle-workflow.js"></script>
|
||||||
|
<script src="named-krx-theme-restore-workflow.js"></script>
|
||||||
<script src="named-nxt-theme-restore-workflow.js"></script>
|
<script src="named-nxt-theme-restore-workflow.js"></script>
|
||||||
<script src="legacy-named-row-workflow.js"></script>
|
<script src="legacy-named-row-workflow.js"></script>
|
||||||
<script src="operator-catalog-workflow.js"></script>
|
<script src="operator-catalog-workflow.js"></script>
|
||||||
|
|||||||
@@ -308,6 +308,14 @@
|
|||||||
}) : null;
|
}) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withEnabled(entry, enabled) {
|
||||||
|
if (!materializedEntries.has(entry) || typeof enabled !== "boolean") return null;
|
||||||
|
if (entry.enabled === enabled) return entry;
|
||||||
|
const replacement = Object.freeze({ ...entry, enabled });
|
||||||
|
materializedEntries.add(replacement);
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
function matchesRaw(entry, raw) {
|
function matchesRaw(entry, raw) {
|
||||||
const signature = legacySelectionForEntry(entry);
|
const signature = legacySelectionForEntry(entry);
|
||||||
return Boolean(signature && raw && typeof raw.enabled === "boolean" &&
|
return Boolean(signature && raw && typeof raw.enabled === "boolean" &&
|
||||||
@@ -333,6 +341,7 @@
|
|||||||
normalizeError,
|
normalizeError,
|
||||||
materialize,
|
materialize,
|
||||||
restorePlaylistEntry,
|
restorePlaylistEntry,
|
||||||
|
withEnabled,
|
||||||
legacySelectionForEntry,
|
legacySelectionForEntry,
|
||||||
matchesRaw,
|
matchesRaw,
|
||||||
isPending,
|
isPending,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
const foreignIndexCandleWorkflow = typeof module === "object" && module.exports
|
const foreignIndexCandleWorkflow = typeof module === "object" && module.exports
|
||||||
? require("./legacy-foreign-index-candle-workflow.js")
|
? require("./legacy-foreign-index-candle-workflow.js")
|
||||||
: root.MbnLegacyForeignIndexCandleWorkflow;
|
: root.MbnLegacyForeignIndexCandleWorkflow;
|
||||||
|
const foreignStockWorkflow = typeof module === "object" && module.exports
|
||||||
|
? require("./named-foreign-stock-restore-workflow.js")
|
||||||
|
: root.MbnNamedForeignStockRestoreWorkflow;
|
||||||
const api = Object.freeze(factory(
|
const api = Object.freeze(factory(
|
||||||
stockWorkflow,
|
stockWorkflow,
|
||||||
fixedWorkflow,
|
fixedWorkflow,
|
||||||
@@ -29,7 +32,8 @@
|
|||||||
expertWorkflow,
|
expertWorkflow,
|
||||||
comparisonWorkflow,
|
comparisonWorkflow,
|
||||||
industryWorkflow,
|
industryWorkflow,
|
||||||
foreignIndexCandleWorkflow));
|
foreignIndexCandleWorkflow,
|
||||||
|
foreignStockWorkflow));
|
||||||
if (typeof module === "object" && module.exports) module.exports = api;
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
if (root) root.MbnLegacyNamedRowWorkflow = api;
|
if (root) root.MbnLegacyNamedRowWorkflow = api;
|
||||||
})(typeof globalThis === "object" ? globalThis : this, function (
|
})(typeof globalThis === "object" ? globalThis : this, function (
|
||||||
@@ -39,11 +43,13 @@
|
|||||||
expertWorkflow,
|
expertWorkflow,
|
||||||
comparisonWorkflow,
|
comparisonWorkflow,
|
||||||
industryWorkflow,
|
industryWorkflow,
|
||||||
foreignIndexCandleWorkflow) {
|
foreignIndexCandleWorkflow,
|
||||||
|
foreignStockWorkflow) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
if (!stockWorkflow || !fixedWorkflow || !themeWorkflow || !expertWorkflow ||
|
if (!stockWorkflow || !fixedWorkflow || !themeWorkflow || !expertWorkflow ||
|
||||||
!comparisonWorkflow || !industryWorkflow || !foreignIndexCandleWorkflow) {
|
!comparisonWorkflow || !industryWorkflow || !foreignIndexCandleWorkflow ||
|
||||||
|
!foreignStockWorkflow) {
|
||||||
throw new Error("Legacy named-row dependencies are unavailable.");
|
throw new Error("Legacy named-row dependencies are unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +388,8 @@
|
|||||||
break;
|
break;
|
||||||
case "legacy-foreign-index-candle-workflow":
|
case "legacy-foreign-index-candle-workflow":
|
||||||
return foreignIndexCandleWorkflow.matchesRaw(entry, rawRow);
|
return foreignIndexCandleWorkflow.matchesRaw(entry, rawRow);
|
||||||
|
case "legacy-named-foreign-stock-workflow":
|
||||||
|
return foreignStockWorkflow.matchesRaw(entry, rawRow);
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -415,6 +423,8 @@
|
|||||||
return industryWorkflow.restoreIndustryPlaylistEntry(entry);
|
return industryWorkflow.restoreIndustryPlaylistEntry(entry);
|
||||||
case "legacy-foreign-index-candle-workflow":
|
case "legacy-foreign-index-candle-workflow":
|
||||||
return foreignIndexCandleWorkflow.restorePlaylistEntry(entry);
|
return foreignIndexCandleWorkflow.restorePlaylistEntry(entry);
|
||||||
|
case "legacy-named-foreign-stock-workflow":
|
||||||
|
return foreignStockWorkflow.restorePlaylistEntry(entry);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -445,6 +455,8 @@
|
|||||||
return industryFixedLegacySignature(trusted.operator);
|
return industryFixedLegacySignature(trusted.operator);
|
||||||
case "legacy-foreign-index-candle-workflow":
|
case "legacy-foreign-index-candle-workflow":
|
||||||
return foreignIndexCandleWorkflow.legacySelectionForEntry(trusted);
|
return foreignIndexCandleWorkflow.legacySelectionForEntry(trusted);
|
||||||
|
case "legacy-named-foreign-stock-workflow":
|
||||||
|
return foreignStockWorkflow.legacySelectionForEntry(trusted);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -997,6 +997,14 @@
|
|||||||
return !!value && trustedPlaylistEntries.has(value);
|
return !!value && trustedPlaylistEntries.has(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withEnabled(entry, enabled) {
|
||||||
|
if (!trustedPlaylistEntries.has(entry) || typeof enabled !== "boolean") return null;
|
||||||
|
if (entry.enabled === enabled) return entry;
|
||||||
|
const replacement = Object.freeze({ ...entry, enabled });
|
||||||
|
trustedPlaylistEntries.add(replacement);
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bridgeContract,
|
bridgeContract,
|
||||||
sourceContract,
|
sourceContract,
|
||||||
@@ -1028,6 +1036,7 @@
|
|||||||
createRestoreLoadRequest,
|
createRestoreLoadRequest,
|
||||||
createRestoreSelectionRequest,
|
createRestoreSelectionRequest,
|
||||||
materializeRestoredPlaylistEntry,
|
materializeRestoredPlaylistEntry,
|
||||||
|
withEnabled,
|
||||||
isPlaylistEntryPlayable
|
isPlaylistEntryPlayable
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -441,6 +441,16 @@
|
|||||||
return recreated;
|
return recreated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withEnabled(entry, enabled) {
|
||||||
|
const freshRead = trustedPlaylistEntries.get(entry);
|
||||||
|
if (!freshRead || typeof enabled !== "boolean") return null;
|
||||||
|
if (entry.enabled === enabled) return entry;
|
||||||
|
const replacement = restorePlaylistEntry({ ...entry, enabled });
|
||||||
|
if (!replacement) return null;
|
||||||
|
trustedPlaylistEntries.set(replacement, freshRead);
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bridgeContract,
|
bridgeContract,
|
||||||
audiences,
|
audiences,
|
||||||
@@ -471,6 +481,7 @@
|
|||||||
createViPlaylistEntry,
|
createViPlaylistEntry,
|
||||||
isPlaylistEntryPlayable,
|
isPlaylistEntryPlayable,
|
||||||
restorePlaylistEntry,
|
restorePlaylistEntry,
|
||||||
|
withEnabled,
|
||||||
refreshPlaylistEntry: restorePlaylistEntry
|
refreshPlaylistEntry: restorePlaylistEntry
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
402
Web/named-foreign-stock-restore-workflow.js
Normal file
402
Web/named-foreign-stock-restore-workflow.js
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const overseas = typeof module === "object" && module.exports
|
||||||
|
? require("./overseas-workflow.js")
|
||||||
|
: root.MbnOverseasWorkflow;
|
||||||
|
const api = Object.freeze(factory(overseas));
|
||||||
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
|
if (root) root.MbnNamedForeignStockRestoreWorkflow = api;
|
||||||
|
})(typeof globalThis === "object" ? globalThis : this, function (overseas) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
if (!overseas) throw new Error("Overseas workflow is unavailable.");
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
const SOURCE = "legacy-named-foreign-stock-workflow";
|
||||||
|
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||||
|
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||||
|
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||||
|
const pendingValues = new WeakSet();
|
||||||
|
const resolvedValues = new WeakSet();
|
||||||
|
const materializedEntries = new WeakSet();
|
||||||
|
const failureCodes = new Set([
|
||||||
|
"INVALID_ROW",
|
||||||
|
"IDENTITY_NOT_FOUND",
|
||||||
|
"IDENTITY_AMBIGUOUS",
|
||||||
|
"DATABASE_DATA_INVALID",
|
||||||
|
"RESTORE_FAILED"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const actionProfiles = Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
actionId: "stock-current",
|
||||||
|
graphicType: "1열판기본",
|
||||||
|
subtype: "",
|
||||||
|
builderKey: "s5001",
|
||||||
|
cutCode: "5001",
|
||||||
|
valueType: "CURRENT",
|
||||||
|
periodDays: null
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
actionId: "stock-candle-5d",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "5일",
|
||||||
|
builderKey: "s8010",
|
||||||
|
cutCode: "8061",
|
||||||
|
valueType: "PRICE",
|
||||||
|
periodDays: 5
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
actionId: "stock-candle-20d",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "20일",
|
||||||
|
builderKey: "s8010",
|
||||||
|
cutCode: "8040",
|
||||||
|
valueType: "PRICE",
|
||||||
|
periodDays: 20
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
actionId: "stock-candle-60d",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "60일",
|
||||||
|
builderKey: "s8010",
|
||||||
|
cutCode: "8046",
|
||||||
|
valueType: "PRICE",
|
||||||
|
periodDays: 60
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
actionId: "stock-candle-120d",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "120일",
|
||||||
|
builderKey: "s8010",
|
||||||
|
cutCode: "8051",
|
||||||
|
valueType: "PRICE",
|
||||||
|
periodDays: 120
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
actionId: "stock-candle-240d",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "240일",
|
||||||
|
builderKey: "s8010",
|
||||||
|
cutCode: "8056",
|
||||||
|
valueType: "PRICE",
|
||||||
|
periodDays: 240
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
const profilesByAction = new Map(actionProfiles.map(value => [value.actionId, value]));
|
||||||
|
const profilesByRaw = new Map(actionProfiles.map(value => [
|
||||||
|
`${value.graphicType}\u0000${value.subtype}`,
|
||||||
|
value
|
||||||
|
]));
|
||||||
|
|
||||||
|
const bridgeContract = Object.freeze({
|
||||||
|
resultType: "named-foreign-stock-restore-results",
|
||||||
|
errorType: "named-foreign-stock-restore-error"
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExactKeys(value, keys) {
|
||||||
|
if (!isPlainObject(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 safeText(value, maximumLength, allowEmpty = false) {
|
||||||
|
return typeof value === "string" && value.length <= maximumLength &&
|
||||||
|
(allowEmpty || value.length > 0) && value === value.trim() &&
|
||||||
|
!value.includes("^") && !unsafeTextPattern.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOptions(options) {
|
||||||
|
if (!hasExactKeys(options, ["id", "fadeDuration", "ma5", "ma20"]) ||
|
||||||
|
!requestIdPattern.test(options.id) ||
|
||||||
|
!Number.isInteger(options.fadeDuration) ||
|
||||||
|
options.fadeDuration < 0 || options.fadeDuration > 60 ||
|
||||||
|
typeof options.ma5 !== "boolean" || typeof options.ma20 !== "boolean") return null;
|
||||||
|
return Object.freeze({ ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawSelection(raw) {
|
||||||
|
return Object.freeze({
|
||||||
|
groupCode: raw.groupCode,
|
||||||
|
subject: raw.subject,
|
||||||
|
graphicType: raw.graphicType,
|
||||||
|
subtype: raw.subtype,
|
||||||
|
dataCode: raw.dataCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileForRaw(raw) {
|
||||||
|
return profilesByRaw.get(`${raw?.graphicType}\u0000${raw?.subtype}`) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRaw(raw) {
|
||||||
|
return isPlainObject(raw) && Number.isInteger(raw.itemIndex) &&
|
||||||
|
raw.itemIndex >= 0 && raw.itemIndex < 1000 &&
|
||||||
|
typeof raw.enabled === "boolean" && raw.groupCode === "해외종목" &&
|
||||||
|
safeText(raw.subject, 200) && raw.pageText === "1/1" && raw.dataCode === "" &&
|
||||||
|
safeText(raw.graphicType, 256) && safeText(raw.subtype, 256, true) &&
|
||||||
|
safeText(raw.pageText, 9) && safeText(raw.dataCode, 1024, true) &&
|
||||||
|
Boolean(profileForRaw(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify(raw, options) {
|
||||||
|
const normalizedOptions = requireOptions(options);
|
||||||
|
const profile = profileForRaw(raw);
|
||||||
|
if (!normalizedOptions || !safeRaw(raw) || !profile) return null;
|
||||||
|
const movingAverages = Object.freeze(profile.builderKey === "s8010"
|
||||||
|
? { ma5: normalizedOptions.ma5, ma20: normalizedOptions.ma20 }
|
||||||
|
: { ma5: false, ma20: false });
|
||||||
|
const pending = Object.freeze({
|
||||||
|
itemIndex: raw.itemIndex,
|
||||||
|
enabled: raw.enabled,
|
||||||
|
actionId: profile.actionId,
|
||||||
|
builderKey: profile.builderKey,
|
||||||
|
cutCode: profile.cutCode,
|
||||||
|
valueType: profile.valueType,
|
||||||
|
periodDays: profile.periodDays,
|
||||||
|
inputName: raw.subject,
|
||||||
|
entry: Object.freeze({ id: normalizedOptions.id }),
|
||||||
|
fadeDuration: normalizedOptions.fadeDuration,
|
||||||
|
movingAverages,
|
||||||
|
rawSelection: rawSelection(raw)
|
||||||
|
});
|
||||||
|
pendingValues.add(pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutcome(value, pending, verifiedAt) {
|
||||||
|
if (!pendingValues.has(pending) || !isPlainObject(value) ||
|
||||||
|
value.itemIndex !== pending.itemIndex) return null;
|
||||||
|
if (value.status === "failed") {
|
||||||
|
if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) ||
|
||||||
|
!failureCodes.has(value.failure)) return null;
|
||||||
|
return Object.freeze({ ...value });
|
||||||
|
}
|
||||||
|
if (value.status !== "resolved" || !hasExactKeys(value, [
|
||||||
|
"itemIndex", "status", "actionId", "inputName", "symbol", "nationCode",
|
||||||
|
"foreignDomesticCode", "builderKey", "cutCode", "valueType", "periodDays"
|
||||||
|
]) || value.actionId !== pending.actionId || value.inputName !== pending.inputName ||
|
||||||
|
value.foreignDomesticCode !== "1" || value.builderKey !== pending.builderKey ||
|
||||||
|
value.cutCode !== pending.cutCode || value.valueType !== pending.valueType ||
|
||||||
|
value.periodDays !== pending.periodDays) return null;
|
||||||
|
const identity = overseas.normalizeStock({
|
||||||
|
inputName: value.inputName,
|
||||||
|
symbol: value.symbol,
|
||||||
|
nationCode: value.nationCode,
|
||||||
|
fdtc: value.foreignDomesticCode
|
||||||
|
});
|
||||||
|
if (!identity) return null;
|
||||||
|
const resolved = Object.freeze({
|
||||||
|
itemIndex: value.itemIndex,
|
||||||
|
status: "resolved",
|
||||||
|
actionId: value.actionId,
|
||||||
|
inputName: identity.inputName,
|
||||||
|
symbol: identity.symbol,
|
||||||
|
nationCode: identity.nationCode,
|
||||||
|
foreignDomesticCode: identity.fdtc,
|
||||||
|
builderKey: value.builderKey,
|
||||||
|
cutCode: value.cutCode,
|
||||||
|
valueType: value.valueType,
|
||||||
|
periodDays: value.periodDays,
|
||||||
|
verifiedAt
|
||||||
|
});
|
||||||
|
resolvedValues.add(resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponse(value, expectedRequestId, pendingRows) {
|
||||||
|
if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) ||
|
||||||
|
pendingRows.length < 1 || pendingRows.length > 1000 ||
|
||||||
|
pendingRows.some(pending => !pendingValues.has(pending)) ||
|
||||||
|
!hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) ||
|
||||||
|
value.requestId !== expectedRequestId || typeof value.retrievedAt !== "string" ||
|
||||||
|
!isoTimestampPattern.test(value.retrievedAt) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||||
|
value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) ||
|
||||||
|
value.rows.length !== pendingRows.length) return null;
|
||||||
|
const rows = [];
|
||||||
|
for (let index = 0; index < pendingRows.length; index += 1) {
|
||||||
|
const outcome = normalizeOutcome(value.rows[index], pendingRows[index], value.retrievedAt);
|
||||||
|
if (!outcome) return null;
|
||||||
|
rows.push(outcome);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
requestId: value.requestId,
|
||||||
|
retrievedAt: value.retrievedAt,
|
||||||
|
rows: Object.freeze(rows)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeError(value, expectedRequestId) {
|
||||||
|
return requestIdPattern.test(expectedRequestId) &&
|
||||||
|
hasExactKeys(value, ["requestId", "code", "message", "retryable"]) &&
|
||||||
|
value.requestId === expectedRequestId &&
|
||||||
|
["PLAYOUT_PRIORITY", "DATABASE_UNAVAILABLE", "RESTORE_FAILED"].includes(value.code) &&
|
||||||
|
safeText(value.message, 1024) && value.retryable === false
|
||||||
|
? Object.freeze({ ...value })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customOperator(canonical, raw, verifiedAt) {
|
||||||
|
return Object.freeze({
|
||||||
|
source: SOURCE,
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
kind: "stock",
|
||||||
|
actionId: canonical.operator.actionId,
|
||||||
|
identity: canonical.operator.identity,
|
||||||
|
movingAverages: canonical.operator.movingAverages,
|
||||||
|
rawSelection: raw,
|
||||||
|
verifiedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntry(pending, outcome) {
|
||||||
|
const canonical = overseas.createOverseasStockPlaylistEntry({
|
||||||
|
inputName: outcome.inputName,
|
||||||
|
symbol: outcome.symbol,
|
||||||
|
nationCode: outcome.nationCode,
|
||||||
|
fdtc: outcome.foreignDomesticCode
|
||||||
|
}, outcome.actionId, {
|
||||||
|
id: pending.entry.id,
|
||||||
|
fadeDuration: pending.fadeDuration,
|
||||||
|
ma5: pending.movingAverages.ma5,
|
||||||
|
ma20: pending.movingAverages.ma20
|
||||||
|
});
|
||||||
|
if (canonical.builderKey !== outcome.builderKey || canonical.code !== outcome.cutCode) return null;
|
||||||
|
return Object.freeze({
|
||||||
|
...canonical,
|
||||||
|
enabled: pending.enabled,
|
||||||
|
operator: customOperator(canonical, pending.rawSelection, outcome.verifiedAt)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialize(pending, outcome) {
|
||||||
|
if (!pendingValues.has(pending) || !resolvedValues.has(outcome) ||
|
||||||
|
outcome.itemIndex !== pending.itemIndex || outcome.actionId !== pending.actionId) return null;
|
||||||
|
let entry;
|
||||||
|
try {
|
||||||
|
entry = buildEntry(pending, outcome);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!entry) return null;
|
||||||
|
materializedEntries.add(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSelection(left, right) {
|
||||||
|
return hasExactKeys(left, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) &&
|
||||||
|
hasExactKeys(right, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) &&
|
||||||
|
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
||||||
|
.every(key => left[key] === right[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restorePlaylistEntry(value) {
|
||||||
|
if (!hasExactKeys(value, [
|
||||||
|
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
|
||||||
|
"symbol", "nationCode", "fdtc", "selection", "enabled", "fadeDuration",
|
||||||
|
"graphicType", "subtype", "operator"
|
||||||
|
]) || typeof value.enabled !== "boolean" ||
|
||||||
|
!isPlainObject(value.operator) || !hasExactKeys(value.operator, [
|
||||||
|
"source", "schemaVersion", "kind", "actionId", "identity", "movingAverages",
|
||||||
|
"rawSelection", "verifiedAt"
|
||||||
|
]) || value.operator.source !== SOURCE ||
|
||||||
|
value.operator.schemaVersion !== SCHEMA_VERSION || value.operator.kind !== "stock" ||
|
||||||
|
!profilesByAction.has(value.operator.actionId) ||
|
||||||
|
!hasExactKeys(value.operator.identity, ["inputName", "symbol", "nationCode", "fdtc"]) ||
|
||||||
|
!hasExactKeys(value.operator.movingAverages, ["ma5", "ma20"]) ||
|
||||||
|
typeof value.operator.movingAverages.ma5 !== "boolean" ||
|
||||||
|
typeof value.operator.movingAverages.ma20 !== "boolean" ||
|
||||||
|
typeof value.operator.verifiedAt !== "string" ||
|
||||||
|
!isoTimestampPattern.test(value.operator.verifiedAt) ||
|
||||||
|
!Number.isFinite(Date.parse(value.operator.verifiedAt))) return null;
|
||||||
|
const identity = overseas.normalizeStock(value.operator.identity);
|
||||||
|
const raw = value.operator.rawSelection;
|
||||||
|
const profile = profileForRaw(raw);
|
||||||
|
if (!identity || !profile || profile.actionId !== value.operator.actionId ||
|
||||||
|
!hasExactKeys(raw, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) ||
|
||||||
|
raw.groupCode !== "해외종목" || raw.subject !== identity.inputName || raw.dataCode !== "" ||
|
||||||
|
!safeText(raw.subject, 200) ||
|
||||||
|
(profile.builderKey !== "s8010" &&
|
||||||
|
(value.operator.movingAverages.ma5 || value.operator.movingAverages.ma20))) return null;
|
||||||
|
|
||||||
|
let canonical;
|
||||||
|
try {
|
||||||
|
canonical = overseas.createOverseasStockPlaylistEntry(
|
||||||
|
identity,
|
||||||
|
profile.actionId,
|
||||||
|
{
|
||||||
|
id: value.id,
|
||||||
|
fadeDuration: value.fadeDuration,
|
||||||
|
ma5: value.operator.movingAverages.ma5,
|
||||||
|
ma20: value.operator.movingAverages.ma20
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rebuilt = Object.freeze({
|
||||||
|
...canonical,
|
||||||
|
enabled: value.enabled,
|
||||||
|
operator: customOperator(canonical, raw, value.operator.verifiedAt)
|
||||||
|
});
|
||||||
|
const scalarKeys = [
|
||||||
|
"id", "builderKey", "code", "title", "detail", "category", "market", "symbol",
|
||||||
|
"nationCode", "fdtc", "enabled", "fadeDuration", "graphicType", "subtype"
|
||||||
|
];
|
||||||
|
if (!scalarKeys.every(key => value[key] === rebuilt[key]) ||
|
||||||
|
!Array.isArray(value.aliases) || value.aliases.length !== 1 ||
|
||||||
|
value.aliases[0] !== rebuilt.code || !sameSelection(value.selection, rebuilt.selection) ||
|
||||||
|
JSON.stringify(value.operator) !== JSON.stringify(rebuilt.operator)) return null;
|
||||||
|
materializedEntries.add(rebuilt);
|
||||||
|
return rebuilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacySelectionForEntry(entry) {
|
||||||
|
const restored = restorePlaylistEntry(entry);
|
||||||
|
return restored ? restored.operator.rawSelection : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnabled(entry, enabled) {
|
||||||
|
if (!materializedEntries.has(entry) || typeof enabled !== "boolean") return null;
|
||||||
|
if (entry.enabled === enabled) return entry;
|
||||||
|
const replacement = Object.freeze({ ...entry, enabled });
|
||||||
|
materializedEntries.add(replacement);
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRaw(entry, raw) {
|
||||||
|
const signature = legacySelectionForEntry(entry);
|
||||||
|
return Boolean(signature && safeRaw(raw) && entry.enabled === raw.enabled &&
|
||||||
|
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
||||||
|
.every(key => signature[key] === raw[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPending(value) {
|
||||||
|
return pendingValues.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMaterializedEntry(value) {
|
||||||
|
return materializedEntries.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: SOURCE,
|
||||||
|
bridgeContract,
|
||||||
|
actionProfiles,
|
||||||
|
classify,
|
||||||
|
normalizeResponse,
|
||||||
|
normalizeError,
|
||||||
|
materialize,
|
||||||
|
restorePlaylistEntry,
|
||||||
|
withEnabled,
|
||||||
|
legacySelectionForEntry,
|
||||||
|
matchesRaw,
|
||||||
|
isPending,
|
||||||
|
isMaterializedEntry
|
||||||
|
};
|
||||||
|
});
|
||||||
279
Web/named-krx-theme-restore-workflow.js
Normal file
279
Web/named-krx-theme-restore-workflow.js
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const themes = typeof module === "object" && module.exports
|
||||||
|
? require("./theme-workflow.js")
|
||||||
|
: root.MbnThemeWorkflow;
|
||||||
|
const api = Object.freeze(factory(themes));
|
||||||
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
|
if (root) root.MbnNamedKrxThemeRestoreWorkflow = api;
|
||||||
|
})(typeof globalThis === "object" ? globalThis : this, function (themes) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
if (!themes) throw new Error("Theme workflow is unavailable.");
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
const MAXIMUM_PREVIEW_ITEMS = 240;
|
||||||
|
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||||
|
const themeCodePattern = /^[0-9]{3,8}$/;
|
||||||
|
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||||
|
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||||
|
const pendingValues = new WeakSet();
|
||||||
|
const resolvedValues = new WeakSet();
|
||||||
|
const materializedValues = new WeakSet();
|
||||||
|
const failureCodes = new Set([
|
||||||
|
"INVALID_ROW",
|
||||||
|
"UNSUPPORTED_ACTION",
|
||||||
|
"IDENTITY_NOT_FOUND",
|
||||||
|
"IDENTITY_AMBIGUOUS",
|
||||||
|
"PREVIEW_EMPTY",
|
||||||
|
"DATABASE_DATA_INVALID",
|
||||||
|
"RESTORE_FAILED"
|
||||||
|
]);
|
||||||
|
const profiles = Object.freeze({
|
||||||
|
"five-row-current": Object.freeze({ builderKey: "s5074", cutCode: "5074", pageSize: 5 }),
|
||||||
|
"five-row-expected": Object.freeze({ builderKey: "s5074", cutCode: "5074", pageSize: 5 }),
|
||||||
|
"six-row-current": Object.freeze({ builderKey: "s5077", cutCode: "5077", pageSize: 6 }),
|
||||||
|
"twelve-row-current": Object.freeze({ builderKey: "s5088", cutCode: "5088", pageSize: 12 })
|
||||||
|
});
|
||||||
|
const subtypes = Object.freeze({
|
||||||
|
"테마-현재가(입력순)": Object.freeze({ valueKind: "CURRENT", sort: "INPUT_ORDER" }),
|
||||||
|
"테마-현재가(상승률순)": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_DESC" }),
|
||||||
|
"테마-현재가(하락률순)": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_ASC" }),
|
||||||
|
"테마-현재가": Object.freeze({ valueKind: "CURRENT", sort: "GAIN_ASC" }),
|
||||||
|
"테마-예상체결가(입력순)": Object.freeze({ valueKind: "EXPECTED", sort: "INPUT_ORDER" }),
|
||||||
|
"테마-예상체결가(상승률순)": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_DESC" }),
|
||||||
|
"테마-예상체결가(하락률순)": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_ASC" }),
|
||||||
|
"테마-예상체결가": Object.freeze({ valueKind: "EXPECTED", sort: "GAIN_ASC" })
|
||||||
|
});
|
||||||
|
const actionIds = Object.freeze({
|
||||||
|
"5단 표그래프|CURRENT": "five-row-current",
|
||||||
|
"5단 표그래프|EXPECTED": "five-row-expected",
|
||||||
|
"6종목 현재가|CURRENT": "six-row-current",
|
||||||
|
"12종목 현재가|CURRENT": "twelve-row-current"
|
||||||
|
});
|
||||||
|
|
||||||
|
const bridgeContract = Object.freeze({
|
||||||
|
resultType: "named-krx-theme-restore-results",
|
||||||
|
errorType: "named-krx-theme-restore-error"
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExactKeys(value, keys) {
|
||||||
|
if (!isPlainObject(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 safeText(value, maximumLength, allowEmpty = false) {
|
||||||
|
return typeof value === "string" && value.length <= maximumLength &&
|
||||||
|
(allowEmpty || value.length > 0) && value === value.trim() &&
|
||||||
|
!value.includes("^") && !unsafeTextPattern.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRaw(raw) {
|
||||||
|
return isPlainObject(raw) && typeof raw.enabled === "boolean" &&
|
||||||
|
Number.isInteger(raw.itemIndex) && raw.itemIndex >= 0 && raw.itemIndex < 1000 &&
|
||||||
|
safeText(raw.groupCode, 256) && safeText(raw.subject, 256, true) &&
|
||||||
|
safeText(raw.graphicType, 256, true) && safeText(raw.subtype, 256, true) &&
|
||||||
|
safeText(raw.pageText, 9, true) && safeText(raw.dataCode, 256, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedResolvedProfile(rawSelection) {
|
||||||
|
const subtype = subtypes[rawSelection?.subtype];
|
||||||
|
const actionId = subtype
|
||||||
|
? actionIds[`${rawSelection.graphicType}|${subtype.valueKind}`]
|
||||||
|
: null;
|
||||||
|
return actionId ? Object.freeze({ actionId, sort: subtype.sort }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route every exact group row. Malformed and unsupported rows remain a
|
||||||
|
// correlated native request so a stale code can never escape via fallback.
|
||||||
|
function classify(raw, options = {}) {
|
||||||
|
if (!safeRaw(raw) || raw.groupCode !== "테마" ||
|
||||||
|
!requestIdPattern.test(options.id) || !Number.isInteger(options.fadeDuration) ||
|
||||||
|
options.fadeDuration < 0 || options.fadeDuration > 60) return null;
|
||||||
|
const pending = Object.freeze({
|
||||||
|
itemIndex: raw.itemIndex,
|
||||||
|
enabled: raw.enabled,
|
||||||
|
fadeDuration: options.fadeDuration,
|
||||||
|
entry: Object.freeze({ id: options.id }),
|
||||||
|
rawSelection: Object.freeze({
|
||||||
|
groupCode: raw.groupCode,
|
||||||
|
subject: raw.subject,
|
||||||
|
graphicType: raw.graphicType,
|
||||||
|
subtype: raw.subtype,
|
||||||
|
dataCode: raw.dataCode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
pendingValues.add(pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutcome(value, pending) {
|
||||||
|
if (!pendingValues.has(pending) || !isPlainObject(value) ||
|
||||||
|
value.itemIndex !== pending.itemIndex) return null;
|
||||||
|
if (value.status === "failed") {
|
||||||
|
if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) ||
|
||||||
|
!failureCodes.has(value.failure)) return null;
|
||||||
|
return Object.freeze({ ...value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
"itemIndex", "status", "actionId", "builderKey", "cutCode", "pageSize", "sort",
|
||||||
|
"themeTitle", "storedThemeCode", "currentThemeCode", "previewItemCount",
|
||||||
|
"previewIsTruncated", "codeWasRemapped"
|
||||||
|
];
|
||||||
|
if (value.status !== "resolved" || !hasExactKeys(value, keys)) return null;
|
||||||
|
const profile = profiles[value.actionId];
|
||||||
|
const expected = expectedResolvedProfile(pending.rawSelection);
|
||||||
|
if (!profile || !expected || value.actionId !== expected.actionId ||
|
||||||
|
value.sort !== expected.sort || value.builderKey !== profile.builderKey ||
|
||||||
|
value.cutCode !== profile.cutCode || value.pageSize !== profile.pageSize ||
|
||||||
|
!safeText(value.themeTitle, 128) || value.themeTitle.includes("(NXT)") ||
|
||||||
|
pending.rawSelection.subject !== value.themeTitle ||
|
||||||
|
!themeCodePattern.test(value.storedThemeCode) ||
|
||||||
|
value.storedThemeCode !== pending.rawSelection.dataCode ||
|
||||||
|
!themeCodePattern.test(value.currentThemeCode) ||
|
||||||
|
value.codeWasRemapped !== (value.storedThemeCode !== value.currentThemeCode) ||
|
||||||
|
!Number.isInteger(value.previewItemCount) || value.previewItemCount < 1 ||
|
||||||
|
value.previewItemCount > MAXIMUM_PREVIEW_ITEMS ||
|
||||||
|
typeof value.previewIsTruncated !== "boolean" ||
|
||||||
|
value.previewIsTruncated && value.previewItemCount !== MAXIMUM_PREVIEW_ITEMS) return null;
|
||||||
|
const normalized = Object.freeze({ ...value });
|
||||||
|
resolvedValues.add(normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponse(value, expectedRequestId, pendingRows) {
|
||||||
|
if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) ||
|
||||||
|
pendingRows.length < 1 || pendingRows.length > 1000 ||
|
||||||
|
pendingRows.some(pending => !pendingValues.has(pending)) ||
|
||||||
|
!hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) ||
|
||||||
|
value.requestId !== expectedRequestId || typeof value.retrievedAt !== "string" ||
|
||||||
|
!isoTimestampPattern.test(value.retrievedAt) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||||
|
value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) ||
|
||||||
|
value.rows.length !== pendingRows.length) return null;
|
||||||
|
const rows = [];
|
||||||
|
for (let index = 0; index < pendingRows.length; index += 1) {
|
||||||
|
const outcome = normalizeOutcome(value.rows[index], pendingRows[index]);
|
||||||
|
if (!outcome) return null;
|
||||||
|
rows.push(outcome);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
requestId: value.requestId,
|
||||||
|
retrievedAt: value.retrievedAt,
|
||||||
|
rows: Object.freeze(rows)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeError(value, expectedRequestId) {
|
||||||
|
return requestIdPattern.test(expectedRequestId) &&
|
||||||
|
hasExactKeys(value, ["requestId", "code", "message", "retryable"]) &&
|
||||||
|
value.requestId === expectedRequestId && safeText(value.code, 64) &&
|
||||||
|
safeText(value.message, 1024) && value.retryable === false
|
||||||
|
? Object.freeze({ ...value })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialize(pending, outcome) {
|
||||||
|
if (!pendingValues.has(pending) || !resolvedValues.has(outcome) ||
|
||||||
|
outcome?.status !== "resolved" ||
|
||||||
|
outcome.itemIndex !== pending.itemIndex) return null;
|
||||||
|
try {
|
||||||
|
const created = themes.createThemePlaylistEntry(
|
||||||
|
outcome.actionId,
|
||||||
|
{
|
||||||
|
market: "krx",
|
||||||
|
themeCode: outcome.currentThemeCode,
|
||||||
|
title: outcome.themeTitle,
|
||||||
|
session: null,
|
||||||
|
itemCount: outcome.previewItemCount
|
||||||
|
},
|
||||||
|
outcome.sort,
|
||||||
|
{ id: pending.entry.id, fadeDuration: pending.fadeDuration });
|
||||||
|
const entry = themes.restoreThemePlaylistEntry({
|
||||||
|
...created,
|
||||||
|
enabled: pending.enabled,
|
||||||
|
operator: Object.freeze({
|
||||||
|
...created.operator,
|
||||||
|
namedKrxRestore: Object.freeze({
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
rawSelection: pending.rawSelection,
|
||||||
|
currentThemeCode: outcome.currentThemeCode,
|
||||||
|
previewItemCount: outcome.previewItemCount,
|
||||||
|
previewIsTruncated: outcome.previewIsTruncated,
|
||||||
|
codeWasRemapped: outcome.codeWasRemapped
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!entry) return null;
|
||||||
|
// The generic theme restore validates and recreates the closed entry.
|
||||||
|
// Add the correlated native proof only after that round-trip succeeds.
|
||||||
|
const materialized = Object.freeze({
|
||||||
|
...entry,
|
||||||
|
operator: Object.freeze({
|
||||||
|
...entry.operator,
|
||||||
|
namedKrxRestore: Object.freeze({
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
rawSelection: pending.rawSelection,
|
||||||
|
currentThemeCode: outcome.currentThemeCode,
|
||||||
|
previewItemCount: outcome.previewItemCount,
|
||||||
|
previewIsTruncated: outcome.previewIsTruncated,
|
||||||
|
codeWasRemapped: outcome.codeWasRemapped
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
materializedValues.add(materialized);
|
||||||
|
return materialized;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRaw(entry, raw) {
|
||||||
|
if (!materializedValues.has(entry) || !safeRaw(raw)) return false;
|
||||||
|
const saved = entry.operator?.namedKrxRestore?.rawSelection;
|
||||||
|
return saved && raw.enabled === entry.enabled &&
|
||||||
|
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
||||||
|
.every(key => saved[key] === raw[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacySelectionForEntry(entry) {
|
||||||
|
if (!materializedValues.has(entry)) return null;
|
||||||
|
return entry.operator.namedKrxRestore.rawSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnabled(entry, enabled) {
|
||||||
|
if (!materializedValues.has(entry) || typeof enabled !== "boolean") return null;
|
||||||
|
if (entry.enabled === enabled) return entry;
|
||||||
|
const replacement = Object.freeze({ ...entry, enabled });
|
||||||
|
materializedValues.add(replacement);
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPending(value) {
|
||||||
|
return pendingValues.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMaterializedEntry(value) {
|
||||||
|
return materializedValues.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bridgeContract,
|
||||||
|
classify,
|
||||||
|
normalizeResponse,
|
||||||
|
normalizeError,
|
||||||
|
materialize,
|
||||||
|
withEnabled,
|
||||||
|
matchesRaw,
|
||||||
|
legacySelectionForEntry,
|
||||||
|
isPending,
|
||||||
|
isMaterializedEntry
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -240,6 +240,14 @@
|
|||||||
return entry.operator.namedNxtRestore.rawSelection;
|
return entry.operator.namedNxtRestore.rawSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withEnabled(entry, enabled) {
|
||||||
|
if (!materializedValues.has(entry) || typeof enabled !== "boolean") return null;
|
||||||
|
if (entry.enabled === enabled) return entry;
|
||||||
|
const replacement = Object.freeze({ ...entry, enabled });
|
||||||
|
materializedValues.add(replacement);
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
function isPending(value) {
|
function isPending(value) {
|
||||||
return pendingValues.has(value);
|
return pendingValues.has(value);
|
||||||
}
|
}
|
||||||
@@ -255,6 +263,7 @@
|
|||||||
normalizeError,
|
normalizeError,
|
||||||
materialize,
|
materialize,
|
||||||
refreshDynamicSession,
|
refreshDynamicSession,
|
||||||
|
withEnabled,
|
||||||
matchesRaw,
|
matchesRaw,
|
||||||
legacySelectionForEntry,
|
legacySelectionForEntry,
|
||||||
isPending,
|
isPending,
|
||||||
|
|||||||
@@ -13,7 +13,12 @@
|
|||||||
const MAXIMUM_QUERY_LENGTH = 64;
|
const MAXIMUM_QUERY_LENGTH = 64;
|
||||||
const SCHEMA_VERSION = 1;
|
const SCHEMA_VERSION = 1;
|
||||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||||
const symbolPattern = /^[A-Za-z0-9@._$:-]{1,64}$/;
|
// F_SYMB is provider identity data, not executable syntax. Current Oracle
|
||||||
|
// contains Hangul-letter symbols with internal ASCII spaces; keep letters and
|
||||||
|
// numbers Unicode-aware and allow only that observed space plus a closed
|
||||||
|
// punctuation set. Canonical trim and this pattern still exclude edge space,
|
||||||
|
// `|`, `^`, controls and arbitrary punctuation from the dataCode identity.
|
||||||
|
const symbolPattern = /^[\p{L}\p{N}@._$:\x20-]{1,64}$/u;
|
||||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||||
|
|
||||||
const bridgeContracts = Object.freeze({
|
const bridgeContracts = Object.freeze({
|
||||||
|
|||||||
64
docs/EXTERNAL_ASSET_SEARCH_AUDIT.md
Normal file
64
docs/EXTERNAL_ASSET_SEARCH_AUDIT.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 외부 송출 자산 검색 감사 (2026-07-12)
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
현재 장비의 `D:` 및 `E:`에서 아래 운영 대상 15개와 정확히 일치하는 실제
|
||||||
|
파일은 발견되지 않았다. 압축파일은 추출하지 않고 목차만 읽었으며, 그 안에서도
|
||||||
|
동일 이름의 항목은 발견되지 않았다. 따라서 다른 프로젝트의 `.vrv`나 테스트
|
||||||
|
fixture를 승인 Cuts 또는 배경 root에 복사해 대체해서는 안 된다.
|
||||||
|
|
||||||
|
- `큐브배경.vrv`
|
||||||
|
- `기본.vrv`
|
||||||
|
- `20201008_미국.vrv`
|
||||||
|
- `20201008_독일.vrv`
|
||||||
|
- `20201008_영국.vrv`
|
||||||
|
- `20201008_프랑스.vrv`
|
||||||
|
- `20201008_일본.vrv`
|
||||||
|
- `20201008_중국.vrv`
|
||||||
|
- `20201008_홍콩.vrv`
|
||||||
|
- `20201008_대만.vrv`
|
||||||
|
- `20201008_싱가포르.vrv`
|
||||||
|
- `20201008_태국.vrv`
|
||||||
|
- `20201008_필리핀.vrv`
|
||||||
|
- `20201008_말레이시아.vrv`
|
||||||
|
- `20201008_인도네시아.vrv`
|
||||||
|
|
||||||
|
## 검색 범위와 결과
|
||||||
|
|
||||||
|
검색일은 2026-07-12, 대상 볼륨은 로컬 `D:`와 `E:`다. 검색은 읽기 전용으로
|
||||||
|
수행했고 `$RECYCLE.BIN`과 `System Volume Information`은 최종 수치에서
|
||||||
|
제외했다. 조사 중 자산 파일 쓰기·복사·추출은 0건이다.
|
||||||
|
|
||||||
|
| 범위 | 조사 결과 | 정확한 대상 일치 |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| `D:` 실제 `.vrv` | 233개 | 0개 |
|
||||||
|
| `E:` 실제 `.vrv` | 452개 | 0개 |
|
||||||
|
| `D:` ZIP | 2,967개 중 2,955개, 215,416 entry 읽음 | 0개 |
|
||||||
|
| `E:` ZIP | 7,313개 중 7,310개, 863,696 entry 읽음 | 0개 |
|
||||||
|
| `D:` 7z/RAR/TAR/TGZ | 93개 중 92개, 1,093 entry 읽음 | 0개 |
|
||||||
|
| `E:` 7z/RAR/TAR/TGZ | 5개 모두, 10,858 entry 읽음 | 0개 |
|
||||||
|
|
||||||
|
`MBN`, `V-Stock`, `매일경제`, `증권정보`, `MBN_STOCK` 경로명으로 별도 확인한
|
||||||
|
결과도 `D:`의 무관한 `MBN Candle그래프.PNG` 1개뿐이었고 `E:`는 0개였다.
|
||||||
|
|
||||||
|
읽지 못한 ZIP은 `D:`의 게임기 BIOS 12개, `E:`의 만화책 2개 및 아마야구 PSD
|
||||||
|
묶음 1개다. 읽지 못한 기타 archive 1개는 `D:`의 Adobe Photoshop 설치
|
||||||
|
RAR이다. 파일 경로와 용도가 이번 MBN 송출 자산과 무관하므로 이 예외가 대상
|
||||||
|
자산 존재 가능성을 남기지는 않는다.
|
||||||
|
|
||||||
|
## C: 선행 검색과의 합산 판정
|
||||||
|
|
||||||
|
선행 검색에서는 `C:`의 사용자/K3D/Tornado/ProgramData/Program Files 범위에
|
||||||
|
실제 대상 자산이 없었고, `C:\Tornado2`에는 무관한 `Weather.vrv` 1개만 있었다.
|
||||||
|
임시 폴더에서 발견된 대상명 1-byte 파일 14개는 자동 테스트 fixture이므로 운영
|
||||||
|
자산으로 사용할 수 없다.
|
||||||
|
|
||||||
|
이에 따라 현재 외부 의존성 판정은 그대로다.
|
||||||
|
|
||||||
|
- `s5006`: 승인 Cuts의 `Video\큐브배경.vrv` 제공 전 차단
|
||||||
|
- `s6001`: 승인 Cuts의 국가별 `Video\20201008_<국가>.vrv` 13개 제공 전 차단
|
||||||
|
- F2/F3 기본 배경: 별도 신뢰 배경 root의 `기본.vrv` 제공 전 차단
|
||||||
|
|
||||||
|
자산은 원 제작/운영 보관처에서 승인된 원본으로 제공받아야 하며, 제공 후 기존
|
||||||
|
cut coverage와 opened-handle/reparse/hardlink 검사를 다시 통과해야 한다.
|
||||||
|
외부에서 승인된 원본이 제공되기 전에는 위 세 경로를 모두 fail closed로 유지한다.
|
||||||
@@ -29,17 +29,17 @@
|
|||||||
| LF-006 | P1 | 시스템 종료 확인 | 완료 | `MainWindow.CloseConfirmation.cs`, `EnsureCloseConfirmationAttached`, `OnAppWindowClosing`, `BuildCloseConfirmationMessage`, `DetachCloseConfirmation`; 연결부 `MainWindow.xaml.cs:54-63,101-112,OnClosed`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:384-408` | 모든 시스템 close를 먼저 취소하고 기본 버튼이 취소인 확인창을 연다. PREPARED/PROGRAM, command pending, OutcomeUnknown에 맞는 경고를 표시하며 종료 과정에서 TAKE OUT이나 반대 명령을 자동 실행하지 않는다. | Web 통합 테스트를 실행하고 패키지 앱에서 idle, prepared/on-air 경고, 취소, 명시적 종료를 수동 확인한다. |
|
| LF-006 | P1 | 시스템 종료 확인 | 완료 | `MainWindow.CloseConfirmation.cs`, `EnsureCloseConfirmationAttached`, `OnAppWindowClosing`, `BuildCloseConfirmationMessage`, `DetachCloseConfirmation`; 연결부 `MainWindow.xaml.cs:54-63,101-112,OnClosed`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:384-408` | 모든 시스템 close를 먼저 취소하고 기본 버튼이 취소인 확인창을 연다. PREPARED/PROGRAM, command pending, OutcomeUnknown에 맞는 경고를 표시하며 종료 과정에서 TAKE OUT이나 반대 명령을 자동 실행하지 않는다. | Web 통합 테스트를 실행하고 패키지 앱에서 idle, prepared/on-air 경고, 취소, 명시적 종료를 수동 확인한다. |
|
||||||
| LF-007 | P1 | 패키지 앱 단일 인스턴스 | 완료 | `App.xaml.cs:9-57`, `OnLaunched`, `OnMainInstanceActivated`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:72-88` | `AppInstance.FindOrRegisterForKey`가 MainWindow와 DB/WebView/playout runtime 생성 전에 실행된다. 두 번째 인스턴스는 activation을 primary로 전달한 뒤 종료하며 기존 프로세스를 이름으로 찾아 강제 종료하지 않는다. | 통합 테스트 후 설치된 MSIX를 두 번 실행해 프로세스 하나, 창 하나, 기존 창 활성화를 확인한다. |
|
| LF-007 | P1 | 패키지 앱 단일 인스턴스 | 완료 | `App.xaml.cs:9-57`, `OnLaunched`, `OnMainInstanceActivated`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:72-88` | `AppInstance.FindOrRegisterForKey`가 MainWindow와 DB/WebView/playout runtime 생성 전에 실행된다. 두 번째 인스턴스는 activation을 primary로 전달한 뒤 종료하며 기존 프로세스를 이름으로 찾아 강제 종료하지 않는다. | 통합 테스트 후 설치된 MSIX를 두 번 실행해 프로세스 하나, 창 하나, 기존 창 활성화를 확인한다. |
|
||||||
| LF-008 | P1 | 운영자 모달의 전역 단축키 격리 | 완료 | `Web/app.js:6808-6845`, `hasOpenOperatorModal`, `isolateOperatorModalShortcut`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:410-429` | GraphE, FSell/VI, ThemeA/EList 등 modal이 열려 있으면 F2/F3/F8/Escape, Home/End/Delete/Space, Ctrl+K가 workspace 송출·playlist handler로 전달되지 않는다. F8/Escape에는 modal을 먼저 닫으라는 안내를 표시한다. | Web 통합 테스트 후 각 modal 입력 필드에서 해당 키를 눌러 playout command와 playlist mutation이 발생하지 않는지 확인한다. |
|
| LF-008 | P1 | 운영자 모달의 전역 단축키 격리 | 완료 | `Web/app.js:6808-6845`, `hasOpenOperatorModal`, `isolateOperatorModalShortcut`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:410-429` | GraphE, FSell/VI, ThemeA/EList 등 modal이 열려 있으면 F2/F3/F8/Escape, Home/End/Delete/Space, Ctrl+K가 workspace 송출·playlist handler로 전달되지 않는다. F8/Escape에는 modal을 먼저 닫으라는 안내를 표시한다. | Web 통합 테스트 후 각 modal 입력 필드에서 해당 키를 눌러 playout command와 playlist mutation이 발생하지 않는지 확인한다. |
|
||||||
| LF-009 | P1 | PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원 | 적용 중 | 원본 `MBN_STOCK_N/Form/PList.cs:81-124`, `data_load`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs:442-540,742-787`, `LoadAsync`/`ParseItem`; `Web/named-playlist-workflow.js:334-412`, `restoreRawRows`; `Web/app.js:4317-4619,5017-5052` | 운영 Oracle read-only 집계에서 활성 정의 23개와 연결된 2,213행은 모두 정확히 7필드였고 canonical ASCII group은 0행, malformed/null은 0행이었다. 즉 전 행이 기존 한국어 형식이므로 legacy→canonical 복원이 필수다. 삭제된 정의에 연결된 orphan 88개 프로그램/9,369행은 현재 UI load 대상이 아니다. 닫힌 매퍼와 fresh identity 재검증을 보강 중이며 전체 2,213행의 unique/blocked 판정이 끝나기 전에는 완료로 보지 않는다. | 실제 read-only PList 행을 원본과 새 앱에서 같은 프로그램 코드로 읽어 `DC_TITLE`과 caret 7개 필드를 글자 단위로 비교한다. `<60>` 또는 mojibake가 없어야 하며 trusted restore와 fresh page plan 전에는 PREPARE가 계속 차단돼야 한다. raw 값은 로그·문서에 출력하지 않고 분류 건수만 기록한다. |
|
| LF-009 | P1 | PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원 | 적용 중 | 원본 `MBN_STOCK_N/Form/PList.cs:81-124`, `data_load`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs`, `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs`, `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`; KRX/해외종목/manual 전용 restore와 테스트; 상세 `docs/NAMED_PLAYLIST_READ_AUDIT.md` | 실제 Oracle 활성 정의 23개와 2,213행 모두 정확히 7필드이고 원문 재결합, 한국어 제목·필드, enabled, page 공백 제거 및 typed persistence가 일치했다. 기본 감사는 KRX 94행 current-code remap과 해외종목 78/78을 포함해 `selectionMapped=1,581`, 차단 632다. 별도 GraphE fresh 감사가 manual 36행 중 22행을 materialize하고 저장값 무효 9행·identity 중복 5행을 차단해 두 증거의 통합 결과는 `selectionMapped=1,603`, 차단 610이다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니다. | 집중 테스트, Release 실제 read-only DB 감사와 전체 `REAL_DB_SMOKE: PASS`. FSell/VI PList 행은 0건이지만 운영 production read는 FSell 3×5행, VI 9항목·2 page이고 파일 전후 불변이다. raw 값·인증정보 출력, DB write, Tornado/PGM 명령은 0건이다. 남은 KRX/NXT/live-data·GraphE identity/data·unique mapper 차단을 해소하고 scene/asset/package/PGM gate를 별도로 통과하기 전에는 전체 복원 완료로 올리지 않는다. |
|
||||||
| LF-010 | P1 | 원본 F2/F3 배경 디렉터리 동등성 | 코드 적용 / 외부자산·패키지 검증 대기 | 원본 `MBN_STOCK_N/MainForm.cs:4008-4054`, `btnback_Click`, `ckback_CheckedChanged`; 현재 `MainWindow.Background.cs`, `PlayoutSceneCompositionFactory`, `TrustedPlayoutAssetPath`, `ValidatedPlayoutOptions`; 문서 `docs/TRUSTED_BACKGROUND_ROOT.md` | Cuts gate를 유지한 채 별도 `LegacyBackgroundDirectory`를 추가했고, 생략 시 원본처럼 `SceneDirectory\..\배경`을 계산한다. F2는 유효할 때만 picker 전 `기본.vrv`를 복원하고 F3-on은 항상 기본 파일을 다시 검증한다. root escape·조상/파일 reparse·hardlink·누락·opened-handle final path를 fail closed로 검사하며 Cuts fallback은 없다. 이 장비의 원본 `bin\Debug\배경`과 `기본.vrv`는 실제로 없어 현재 기본 배경은 잠긴다. | Core Debug/Release 1,259/1,259, Playout Debug/Release 387/387과 앱 Debug/Release x64 build를 통과했다. signed 패키지에서 별도 root 상태 메시지와 F2/F3를 확인하고, 자산 제공 뒤 다음 PREPARE mutation/PGM은 승인 회차에서 검증한다. |
|
| LF-010 | P1 | 원본 F2/F3 배경 디렉터리 동등성 | 코드 적용 / 외부자산·패키지 검증 대기 | 원본 `MBN_STOCK_N/MainForm.cs:4008-4054`, `btnback_Click`, `ckback_CheckedChanged`; 현재 `MainWindow.Background.cs`, `PlayoutSceneCompositionFactory`, `TrustedPlayoutAssetPath`, `ValidatedPlayoutOptions`; 문서 `docs/TRUSTED_BACKGROUND_ROOT.md` | Cuts gate를 유지한 채 별도 `LegacyBackgroundDirectory`를 추가했고, 생략 시 원본처럼 `SceneDirectory\..\배경`을 계산한다. F2는 유효할 때만 picker 전 `기본.vrv`를 복원하고 F3-on은 항상 기본 파일을 다시 검증한다. root escape·조상/파일 reparse·hardlink·누락·opened-handle final path를 fail closed로 검사하며 Cuts fallback은 없다. 이 장비의 원본 `bin\Debug\배경`과 `기본.vrv`는 실제로 없어 현재 기본 배경은 잠긴다. | Core Debug/Release 1,259/1,259, Playout Debug/Release 387/387과 앱 Debug/Release x64 build를 통과했다. signed 패키지에서 별도 root 상태 메시지와 F2/F3를 확인하고, 자산 제공 뒤 다음 PREPARE mutation/PGM은 승인 회차에서 검증한다. |
|
||||||
| LF-011 | P1 | `종목비교.dat` 기존 저장쌍 이전 | 적용 중 | 원본 `MBN_STOCK_N/Control/UC3.cs:34-55,181-185`, `UC3_Load`, `Data_Load`, `File_Save`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs`, `MainWindow.LegacyComparisonImport.cs`, `Web/comparison-import-workflow.js`, `Web/app.js`; 테스트 `LegacyComparisonPairImportServiceTests.cs`, `comparison-import-workflow.test.cjs`, `comparison-import-app-integration.test.cjs` | 원본 파일은 CP949 358바이트, 비어 있지 않은 8행, 각 행 정확히 9필드이며 0~3열만 값이 있다는 감사 fixture를 SHA-256 `1EE76BC4…BFC0D2F2`로 고정했다. Web이 경로를 전달하지 않는 고정 read-only source, reparse/크기/2회 quiescent snapshot 검사, strict CP949/9필드 parser, 현재 Oracle/MariaDB 종목·해외 identity exact read, 고정 시장명과 해외 동명이름 충돌 차단, 전체 중복·500쌍 경계, 명시적 버튼과 one-time marker/rollback을 구현했다. 원본은 수정하지 않고 성공 뒤 WebView private `localStorage` schema만 사용한다. 이 장비의 실제 설정으로 동일 서비스 read-only smoke를 실행해 8행 전부와 SHA-256이 일치하고 모든 identity가 해소됨을 확인했다. 다만 패키지 UI 버튼→localStorage→재시작 marker까지의 1회 실행은 아직 하지 않았으므로 완료로 올리지 않는다. | 실제 패키지에서 명시적 버튼 1회로 8행을 모두 fresh DB 재검증하고 local schema/hash/순서를 확인한다. 같은 profile의 두 번째 실행은 marker로 차단되고, 원본 변경·손상·동명이름·기존쌍 중복은 부분 저장 없이 전체 fail closed여야 한다. |
|
| LF-011 | P1 | `종목비교.dat` 기존 저장쌍 이전 | 완료 | 원본 `MBN_STOCK_N/Control/UC3.cs:34-55,181-185`, `UC3_Load`, `Data_Load`, `File_Save`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs`, `MainWindow.LegacyComparisonImport.cs`, `Web/comparison-import-workflow.js`, `Web/app.js`; 테스트 `LegacyComparisonPairImportServiceTests.cs`, `comparison-import-workflow.test.cjs`, `comparison-import-app-integration.test.cjs`; 실행 증거 `docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md` | 원본 CP949 358바이트·8행·9필드와 SHA-256 `1EE76BC4…BFC0D2F2`를 고정하고, 고정 read-only source·strict parser·현재 Oracle/MariaDB exact identity·전체 원자 저장·one-time marker를 적용했다. 설치된 signed 1.0.3 package에서 빈 상태 `0 PAIRS`를 확인한 뒤 명시적 버튼을 정확히 한 번 실행해 8쌍을 저장했다. 정상 종료·재시작 후에도 `8 PAIRS`, 원본 순서의 8행과 완료형 `원본 8쌍 가져옴` gate가 유지됐고, 원본 파일 SHA-256은 불변이었다. 원본·DB·Tornado에는 쓰기나 명령을 보내지 않았다. | Core 20/20, Web 12/12, 실제 read-only DB identity 8/8, package 0→8 및 재시작 marker 영속성 PASS. 손상·동명이름·기존쌍 중복·부분 실패는 계속 전체 fail closed이며 package family data를 초기화해 같은 회차를 재현하지 않는다. |
|
||||||
| LF-012 | P1 | FSell/VI 원본 `Data`의 안전한 이전과 설정 문서 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.cs:338-339`, `FSell.cs:31-88`, `VIList.cs:31-34,137-139`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyManualOperatorDataImport.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/LegacyManualOperatorDataImporter.cs`, `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, `Web/manual-lists-ui.js` | UserProfile 아래 고정 원본 경로만 read-only로 두 번 읽고 strict CP949, FSell 파일별 5행+종료 레코드, VI 9열·최대 100건·4,000바이트를 검사한다. 명시적 확인 뒤 private store가 완전히 비어 있을 때만 4개 staged 파일을 no-overwrite로 옮기고 production parser readback 후 v1 marker를 마지막에 기록한다. rollback을 확정하지 못하면 OutcomeUnknown quarantine이며 자동 재시도하지 않는다. 실제 source smoke는 FSell 15행·VI 9건·aggregate SHA-256 `fd39a363…a2114`였고, 이 장비의 private 4개 파일은 이미 원본과 각각 byte-identical이어서 `DestinationNotEmpty`로 source read 전 중단했으며 전후 SHA가 같았다. | 새 빈 package profile fixture에서 버튼→4파일→marker→재시작 차단을 확인한다. 현재 운영 profile에서는 기존 동일 파일을 덮어쓰거나 marker를 임의 생성하지 않는다. |
|
| LF-012 | P1 | FSell/VI 원본 `Data`의 안전한 이전과 설정 문서 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.cs:338-339`, `FSell.cs:31-88`, `VIList.cs:31-34,137-139`; importer `LegacyManualOperatorDataImport`, `LegacyManualOperatorDataImporter`; production read `S5025TrustedManualFileDataSource`, `ViTrustedManualFileStore`; 감사 `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`, `docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md` | 고정 원본 경로·strict CP949·source/hash 재검증·no-overwrite staged commit·marker-last·OutcomeUnknown quarantine를 유지한다. 격리 fixture는 FSell 15행·VI 9건, aggregate SHA-256 `fd39a363…a2114`, 첫 실행 성공과 두 번째 `AlreadyImported`를 확인했다. 운영 profile은 이미 원본과 byte-identical한 4파일이 있어 importer가 `DestinationNotEmpty`로 차단되며 marker/intent를 만들지 않는다. production opened-handle read는 FSell 3 audience 각각 5행, VI 9항목·2 page와 안정된 두 번째 version을 확인했고 감사 전후 4파일은 불변이다. 현재 PList에는 FSell/VI 행이 0건이다. | 새 빈 signed package profile의 명시적 UI importer 검증은 남아 있다. 현재 운영 profile의 동일 파일을 덮어쓰거나 marker를 임의 생성하지 않는다. PList에 향후 FSell/VI 행이 생기면 같은 fresh source/version gate를 거치며 scene data·asset·PREPARE·PGM은 별도 검증한다. |
|
||||||
| LF-013 | P1 | generic native `bridge-error` 표시 | 완료 | producer `MainWindow.xaml.cs`, `MainWindow.Playout.cs`; consumer `Web/app.js` `handleBridgeError`/`handleNativeMessage`; 테스트 `operator-ui-app-integration.test.cjs` | bounded toast/log로 소비하며 malformed status/quarantine payload에서 playout state를 변경하지 않는다. 전체 Web 371/371과 signed 1.0.3 패키지 반영을 확인했다. | 새 native error producer를 추가할 때마다 같은 bounded consumer 계약을 유지한다. |
|
| LF-013 | P1 | generic native `bridge-error` 표시 | 완료 | producer `MainWindow.xaml.cs`, `MainWindow.Playout.cs`; consumer `Web/app.js` `handleBridgeError`/`handleNativeMessage`; 테스트 `operator-ui-app-integration.test.cjs` | bounded toast/log로 소비하며 malformed status/quarantine payload에서 playout state를 변경하지 않는다. 전체 Web 371/371과 signed 1.0.3 패키지 반영을 확인했다. | 새 native error producer를 추가할 때마다 같은 bounded consumer 계약을 유지한다. |
|
||||||
| LF-014 | P2 | 영구 운영 로그 | 완료 | 원본 `MBN_STOCK_N/MainForm.cs:84-180`; 현재 `NativeOperatorLogWriter.cs`, `NativeOperatorLogTransitions.cs`, `MainWindow.NativeOperatorLog.cs` | `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Logs\Log_MMdd.log`에 폐쇄 event/state만 기록하며 4MiB/31일, 중복 억제, reparse/handle 검증, single terminal/OutcomeUnknown 규칙을 적용한다. signed 1.0.3 package context에서 Startup→DryRunReady→Oracle/MariaDB Healthy→Shutdown→재시작이 같은 파일에 잔존했고 SQL·경로·인증·requestId 금지 문자열은 0건이었다. | 운영 회차에서도 로그를 명령 재시도 근거로 사용하지 않고 authoritative 출력과 함께 판독한다. |
|
| LF-014 | P2 | 영구 운영 로그 | 완료 | 원본 `MBN_STOCK_N/MainForm.cs:84-180`; 현재 `NativeOperatorLogWriter.cs`, `NativeOperatorLogTransitions.cs`, `MainWindow.NativeOperatorLog.cs` | `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Logs\Log_MMdd.log`에 폐쇄 event/state만 기록하며 4MiB/31일, 중복 억제, reparse/handle 검증, single terminal/OutcomeUnknown 규칙을 적용한다. signed 1.0.3 package context에서 Startup→DryRunReady→Oracle/MariaDB Healthy→Shutdown→재시작이 같은 파일에 잔존했고 SQL·경로·인증·requestId 금지 문자열은 0건이었다. | 운영 회차에서도 로그를 명령 재시도 근거로 사용하지 않고 authoritative 출력과 함께 판독한다. |
|
||||||
| LF-015 | P2 | 호출자가 없는 native bridge handler | 완료 | `MainWindow.xaml.cs`, `OnWebMessageReceived`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs` | Web 호출자가 없던 `open-external`과 `reload` message case를 native allowlist에서 제거했다. 외부 navigation/new-window 처리와 WebView process failure의 내부 reload 복구 메서드는 별도 native event 경로로 유지한다. | 정적 계약 테스트에서 두 message case와 Web 발신 literal이 모두 없음을 확인했다. |
|
| LF-015 | P2 | 호출자가 없는 native bridge handler | 완료 | `MainWindow.xaml.cs`, `OnWebMessageReceived`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs` | Web 호출자가 없던 `open-external`과 `reload` message case를 native allowlist에서 제거했다. 외부 navigation/new-window 처리와 WebView process failure의 내부 reload 복구 메서드는 별도 native event 경로로 유지한다. | 정적 계약 테스트에서 두 message case와 Web 발신 literal이 모두 없음을 확인했다. |
|
||||||
| LF-016 | P2 | 실제 preview 이미지·영상 | 외부자산 필요 | `docs/MIGRATION.md:73-74`; 원본 `MBN_STOCK_N/MainForm.cs:1448-1462,1894-1911,2919-2921` | 현재 preview는 안전한 DTO/mutation 텍스트 중심이다. 실제 이미지·영상 연결은 후속 자산이다. 다만 원본 thumbnail 호출과 watcher는 주석 또는 미호출이므로 활성 원본 동등성의 P0/P1 blocker로 보지 않는다. | 승인된 preview 자산이 생기면 asset root와 노출 정보 검사를 거쳐 패키지 화면에서 확인한다. |
|
| LF-016 | P2 | 실제 preview 이미지·영상 | 외부자산 필요 | `docs/MIGRATION.md:73-74`; 원본 `MBN_STOCK_N/MainForm.cs:1448-1462,1894-1911,2919-2921` | 현재 preview는 안전한 DTO/mutation 텍스트 중심이다. 실제 이미지·영상 연결은 후속 자산이다. 다만 원본 thumbnail 호출과 watcher는 주석 또는 미호출이므로 활성 원본 동등성의 P0/P1 blocker로 보지 않는다. | 승인된 preview 자산이 생기면 asset root와 노출 정보 검사를 거쳐 패키지 화면에서 확인한다. |
|
||||||
| LF-017 | P1 | 모든 업무 탭에서 계속 보이는 종목 검색·31컷 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.Designer.cs:2022-2036,2454-2462`; 현재 `Web/index.html`의 독립 `stock-panel`, `Web/app.js`의 `renderStockWorkflow`; 테스트 `operator-ui-app-integration.test.cjs` | 종목 검색·31컷을 catalog panel 밖의 독립 열로 옮기고 모든 업무 메뉴에서 항상 표시한다. 1920 브라우저에서 11개 메뉴를 이동해 검색어, 표시, 31개 행이 유지되고 문서 가로 overflow가 없음을 확인했다. | signed MSIX에서 실제 DB 검색 결과·선택 종목이 모든 탭 전환 뒤 유지되고 컷 추가가 같은 identity를 쓰는지 확인한 뒤 완료로 올린다. |
|
| LF-017 | P1 | 모든 업무 탭에서 계속 보이는 종목 검색·31컷 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.Designer.cs:2022-2036,2454-2462`; 현재 `Web/index.html`의 독립 `stock-panel`, `Web/app.js`의 `renderStockWorkflow`; 테스트 `operator-ui-app-integration.test.cjs` | 종목 검색·31컷을 catalog panel 밖의 독립 열로 옮기고 모든 업무 메뉴에서 항상 표시한다. 1920 브라우저에서 11개 메뉴를 이동해 검색어, 표시, 31개 행이 유지되고 문서 가로 overflow가 없음을 확인했다. | signed MSIX에서 실제 DB 검색 결과·선택 종목이 모든 탭 전환 뒤 유지되고 컷 추가가 같은 identity를 쓰는지 확인한 뒤 완료로 올린다. |
|
||||||
| LF-018 | P2 | 원본 트리·컷 목록 일괄 추가 제스처 | 적용 중 | 원본 부모 double-click `MBN_STOCK_N/Control/UC1.cs:141-180`; 소스 컷 Ctrl/Shift 선택 `MBN_STOCK_N/MainForm.cs:3427-3525,3534-3549,3635-3643`; 현재 `Web/legacy-batch-selection-workflow.js`, `Web/app.js`; 테스트 `legacy-batch-selection-*.test.cjs` | 종목 31컷의 plain/Ctrl/Shift/Ctrl+Shift 선택, 단일 double-click, `선택 컷 추가`를 포팅했다. 해외·환율·지수 fixed source의 섹션 `전체 추가`는 모든 자식을 먼저 materialize한 뒤 원본 순서로 한 번에 commit하며, 수동/지원불가가 한 건이라도 있으면 부분 추가 없이 전체 차단한다. 실제 브라우저에서 5개 선택, 해외 43개 순서 추가, 수동 혼합 섹션 0건 추가를 확인했다. | signed MSIX 확인 뒤 업종·비교·테마 등 전용 source group의 부모 전체 추가가 원본에서 실제로 필요한 범위를 계속 분리 포팅한다. source drag/drop 자체는 오조작 검증 뒤 결정한다. |
|
| LF-018 | P2 | 원본 트리·컷 목록 일괄 추가 제스처 | 적용 중 | 원본 부모 double-click `MBN_STOCK_N/Control/UC1.cs:141-180`; 소스 컷 Ctrl/Shift 선택 `MBN_STOCK_N/MainForm.cs:3427-3525,3534-3549,3635-3643`; 현재 `Web/legacy-batch-selection-workflow.js`, `Web/app.js`; 테스트 `legacy-batch-selection-*.test.cjs` | 종목 31컷의 plain/Ctrl/Shift/Ctrl+Shift 선택, 단일 double-click, `선택 컷 추가`를 포팅했다. 해외·환율·지수 fixed source의 섹션 `전체 추가`는 모든 자식을 먼저 materialize한 뒤 원본 순서로 한 번에 commit하며, 수동/지원불가가 한 건이라도 있으면 부분 추가 없이 전체 차단한다. 실제 브라우저에서 5개 선택, 해외 43개 순서 추가, 수동 혼합 섹션 0건 추가를 확인했다. | signed MSIX 확인 뒤 업종·비교·테마 등 전용 source group의 부모 전체 추가가 원본에서 실제로 필요한 범위를 계속 분리 포팅한다. source drag/drop 자체는 오조작 검증 뒤 결정한다. |
|
||||||
| LF-019 | P1 | 저장 PList의 `테마_NXT` stale code 복원 | 적용 중 | 원본 `Control/UC4.cs`, `Scene/s5074.cs`, `s5077.cs`, `s5088.cs`; 현재 `LegacyNamedNxtThemeRestoreService`, `MainWindow.NamedNxtThemeRestore.cs`, `Web/named-nxt-theme-restore-workflow.js`; 상세 `docs/NAMED_NXT_THEME_RESTORE.md` | 원본처럼 제목 exact lookup으로 현재 Maria code를 찾고 현재가 전용 5/6/12행, 정렬 fallback, 00~12 PRE/13~23 AFTER, fresh PageN을 닫힌 batch로 복원한다. 실제 활성 48행/24제목은 모두 current identity가 유일하고 저장 code 48개가 전부 stale였지만 현재 preview가 전부 비어 있어 48행 모두 `PreviewEmpty`로 정상 차단됐다. KRX 테마는 후보가 아니다. | Maria `SB_ITEM`/`v_all_stock` live 연계가 복구된 뒤 같은 read-only audit에서 한 행 이상 live item을 확인하고, fresh page preflight와 Debug/Release/MSIX UI를 검증한다. 실제 PGM은 별도 승인 회차가 필요하다. |
|
| LF-019 | P1 | 저장 PList의 `테마_NXT` stale code 복원 | 코드 완료 / DB view 정합성 대기 | 원본 `Control/UC4.cs`, `Scene/s5074.cs`, `s5077.cs`, `s5088.cs`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedNxtThemeRestore.cs`, `MainWindow.NamedNxtThemeRestore.cs`, `Web/named-nxt-theme-restore-workflow.js`; 테스트 `LegacyNamedNxtThemeRestoreServiceTests.cs`, `named-nxt-theme-restore-workflow.test.cjs`, `named-nxt-theme-restore-app-integration.test.cjs`; 상세 `docs/NAMED_NXT_THEME_RESTORE.md`, `docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md` | 제목 exact lookup, 현재 Maria code, 5/6/12행, 정렬 fallback, 00~12 PRE/13~23 AFTER와 fresh PageN을 닫힌 batch로 복원한다. 실제 48행/24제목은 current identity가 유일하고 저장 code는 전부 stale다. `SB_ITEM` 446행과 NXT master 446행은 존재하지만 원본과 새 앱이 공통으로 쓰는 `v_all_stock` 조인은 0행이어서 48행 모두 `PreviewEmpty`로 차단된다. 통합 PList 결과에서도 `selectionMapped`로 계산하지 않았고 추측성 직접-table fallback을 적용하지 않았다. | Core Debug/Release와 실제 read-only DB smoke PASS, DB write·Tornado/PGM 명령 0건. DBA가 `v_all_stock`의 NXT 포함 의도를 확인해 승인된 변경·rollback으로 복구하거나 직접-table 경로를 별도 승인한 뒤 fresh page preflight/MSIX/PGM을 검증한다. |
|
||||||
| LF-020 | P2 | 원본 초기 업무 화면 `해외` | 완료 | 원본 `MainForm.Designer.cs` 첫 TabPage `해외`; 현재 `Web/index.html`, `Web/app.js`; 테스트 `operator-ui-app-integration.test.cjs` | dashboard는 진단 화면으로 유지하되 초기 active identity/제목/요청을 해외로 맞춰다. 실제 브라우저에서 해외만 active, 최초 요청 1회, dashboard 진입·복귀를 확인했고 signed 1.0.3에 같은 Web 31개가 byte 일치했다. | 후속 탭 추가 시에도 초기 identity를 순번이 아닌 `data-market`으로 유지한다. |
|
| LF-020 | P2 | 원본 초기 업무 화면 `해외` | 완료 | 원본 `MainForm.Designer.cs` 첫 TabPage `해외`; 현재 `Web/index.html`, `Web/app.js`; 테스트 `operator-ui-app-integration.test.cjs` | dashboard는 진단 화면으로 유지하되 초기 active identity/제목/요청을 해외로 맞춰다. 실제 브라우저에서 해외만 active, 최초 요청 1회, dashboard 진입·복귀를 확인했고 signed 1.0.3에 같은 Web 31개가 byte 일치했다. | 후속 탭 추가 시에도 초기 identity를 순번이 아닌 `data-market`으로 유지한다. |
|
||||||
| LF-021 | P2 | 업무 탭 드래그 순서 변경 | 완료 | 원본 `MainForm.cs:3183-3312`; 현재 `Web/market-nav-reorder.js`, `Web/app.js`; 테스트 `market-nav-reorder*.test.cjs` | dashboard는 첫 위치에 고정하고 10개 업무 identity만 drag 교환/Alt+위·아래로 이동한다. active view/DB/playlist는 불변이고 입력·modal·PREPARE 잠금에서 fail closed하며 세션 종료 시 기본 순서로 돌아간다. 실제 브라우저에서 `Alt+Down` 후 active 해외 유지와 reload 초기화를 확인했다. | signed 1.0.3 패키지에 Web 31개 일치로 반영했다. |
|
| LF-021 | P2 | 업무 탭 드래그 순서 변경 | 완료 | 원본 `MainForm.cs:3183-3312`; 현재 `Web/market-nav-reorder.js`, `Web/app.js`; 테스트 `market-nav-reorder*.test.cjs` | dashboard는 첫 위치에 고정하고 10개 업무 identity만 drag 교환/Alt+위·아래로 이동한다. active view/DB/playlist는 불변이고 입력·modal·PREPARE 잠금에서 fail closed하며 세션 종료 시 기본 순서로 돌아간다. 실제 브라우저에서 `Alt+Down` 후 active 해외 유지와 reload 초기화를 확인했다. | signed 1.0.3 패키지에 Web 31개 일치로 반영했다. |
|
||||||
| LF-022 | P2 | GraphE 위/아래 찾기 | 완료 | 원본 `GraphE.cs:338-379,605-611`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `위로 찾기`/`아래로 찾기` 버튼과 Enter 다음 찾기를 포팅했다. trim·대소문자 무시 Contains, 현재 행 전/후, no-wrap, 모든 pending·잠금·hidden 차단을 유지한다. 원본의 `i > 0` 첫 행 누락은 명백한 off-by-one으로 보고 첫 행도 정상 후보에 포함했다. | GraphE 집중 20/20, 전체 Web 371/371과 signed 1.0.3 Web 일치 통과. |
|
| LF-022 | P2 | GraphE 위/아래 찾기 | 완료 | 원본 `GraphE.cs:338-379,605-611`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `위로 찾기`/`아래로 찾기` 버튼과 Enter 다음 찾기를 포팅했다. trim·대소문자 무시 Contains, 현재 행 전/후, no-wrap, 모든 pending·잠금·hidden 차단을 유지한다. 원본의 `i > 0` 첫 행 누락은 명백한 off-by-one으로 보고 첫 행도 정상 후보에 포함했다. | GraphE 집중 20/20, 전체 Web 371/371과 signed 1.0.3 Web 일치 통과. |
|
||||||
@@ -47,26 +47,33 @@
|
|||||||
| LF-024 | P2 | GraphE 종목명 헤더 정렬 | 완료 | 원본 `GraphE.cs:258-280`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 세션 로컬 display order로 적용했다. 선택 snapshot/native/playlist는 불변이고 위/아래 찾기는 현재 표시 순서를 따른다. 새 화면/목록에서 오름차순으로 초기화한다. | 네 모드·한글/ASCII·빈/단일·pending·정렬된 find 집중 20/20, 전체 Web 371/371 통과. |
|
| LF-024 | P2 | GraphE 종목명 헤더 정렬 | 완료 | 원본 `GraphE.cs:258-280`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 세션 로컬 display order로 적용했다. 선택 snapshot/native/playlist는 불변이고 위/아래 찾기는 현재 표시 순서를 따른다. 새 화면/목록에서 오름차순으로 초기화한다. | 네 모드·한글/ASCII·빈/단일·pending·정렬된 find 집중 20/20, 전체 Web 371/371 통과. |
|
||||||
| LF-025 | P2 | UC7 거래정지 종목명 헤더 정렬 | 완료 | 원본 `Control/UC7.cs:173-188`; 현재 `Web/trading-halt-workflow.js`, `Web/app.js`, `Web/index.html`; 테스트 `trading-halt-name-sort-app-integration.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 표시 order에만 적용했다. 동명은 market+stockCode로 결정적으로 정렬하고 선택 identity를 유지하며 새 검색은 server 순서로 초기화한다. DB/native/playlist 변경은 없다. | 집중 18/18, 전체 Web 371/371, 실제 브라우저에서 접근 라벨/가로 overflow 0, signed 1.0.3 Web 일치 통과. |
|
| LF-025 | P2 | UC7 거래정지 종목명 헤더 정렬 | 완료 | 원본 `Control/UC7.cs:173-188`; 현재 `Web/trading-halt-workflow.js`, `Web/app.js`, `Web/index.html`; 테스트 `trading-halt-name-sort-app-integration.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 표시 order에만 적용했다. 동명은 market+stockCode로 결정적으로 정렬하고 선택 identity를 유지하며 새 검색은 server 순서로 초기화한다. DB/native/playlist 변경은 없다. | 집중 18/18, 전체 Web 371/371, 실제 브라우저에서 접근 라벨/가로 overflow 0, signed 1.0.3 Web 일치 통과. |
|
||||||
|
|
||||||
## 활성 PLAY_LIST 2,213행 재분류
|
## 활성 PLAY_LIST 2,213행 read-only 재분류
|
||||||
|
|
||||||
운영 Oracle의 활성 프로그램 정의 23개와 연결된 2,213행을 원문을 출력하지 않는 read-only 집계로 다시 분류했다. 삭제된 정의에 매달린 orphan 데이터 88개 프로그램/9,369행은 현재 PList의 정상 load 대상이 아니므로 아래 수치에 포함하지 않았다.
|
운영 Oracle의 활성 프로그램 정의 23개와 연결된 2,213행을 원문을 출력하지 않는
|
||||||
|
감사로 다시 분류했다. 원본 `PList.data_load`의 page 공백 제거를 제외하면 모든
|
||||||
|
필드를 그대로 보존했고, 현재 persistence 결과와 2,213/2,213 일치했다.
|
||||||
|
|
||||||
| 복원 분류 | 건수 | 판정 |
|
| route | 건수 | 현재 결과 |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| 기존 닫힌 매퍼와 fresh 검증으로 복원 | 1,573 | stock/fixed/expert/GraphE 등 기존 계약과 현재 master를 모두 통과하는 행 |
|
| 기존 닫힌 매퍼 | 1,250 | hash 고정 원본 INI와 현재 exact signature로 selection 1,250개 매핑 |
|
||||||
| DB 없이 추가 복원 가능 | 57 | 업종 fixed 24, 고정 시장·지수 2열판 33 |
|
| 업종 fixed 닫힌 매퍼 | 24 | selection 24개 매핑 |
|
||||||
| 현재 DB exact lookup 뒤 추가 복원 가능 | 228 | NXT theme 48(복원 경로 적용, 현재 preview empty로 전부 차단), 비교 graph 41, 해외종목 78, 해외지수 candle 61 |
|
| 비교 fresh identity | 99 | selection 74개 매핑, 시장 identity 유실 25개 차단 |
|
||||||
| 현재 identity를 안전하게 증명할 수 없음 | 325 | KRX theme 300, 원본이 시장을 저장하지 않은 종목 2열판 25. 이름이나 과거 코드를 추정하지 않고 재선택이 필요함 |
|
| 해외지수 candle fresh identity | 61 | selection 61개 매핑 |
|
||||||
| whitelist 불일치로 자동 복원 금지 | 30 | `s5001` fixed-nearest 기존 집계가 재감사 후보 40건과 달라 명시적 CASE 확정 전까지 차단 |
|
| NXT theme fresh identity | 48 | validated preview empty 48 차단 |
|
||||||
| 합계 | 2,213 | raw 행은 closed mapping과 fresh identity가 모두 성공해야만 playable entry가 됨 |
|
| KRX theme fresh identity | 587 | current-code remap 94, identity 없음 271, 저장 행 무효 16, preview empty 193, 지원 불가 action 13 |
|
||||||
|
| 해외종목 fresh identity | 78 | exact 입력명에서 unique symbol/nation을 correlated materialize해 78개 매핑 |
|
||||||
|
| GraphE fresh materialization | 36 | 별도 double-read row-version·stock identity·cut 계약으로 22개 매핑, 저장값 무효 9·identity 중복 5 차단. FSell/VI PList행은 0 |
|
||||||
|
| unique closed mapper 없음 | 30 | 원본 328-action runtime hash와 현재 exact signature 중 단일 후보가 없어 nearest scene 추정 없이 차단 |
|
||||||
|
| 합계 | 2,213 | 기본 감사 `selectionMapped=1,581`/차단 632; 별도 GraphE proof를 합치면 `selectionMapped=1,603`/차단 610 |
|
||||||
|
|
||||||
테마 635행은 별도로 다음과 같이 판정했다. KRX exact 문법 375행 중 현재 제목이 유일한 287행만 즉시 신뢰할 수 있고 88행은 stale이다. 정렬 토큰이 없는 199행과 기타 문법 13행은 원본 `s5074/s5077/s5088`의 최종 분기와 같이 모두 `GAIN_ASC` 의미지만, 현재 `SB_LIST`에서 제목이 사라져 playable하지 않다. NXT 48행은 24개 저장 제목으로 구성되고 raw grammar는 `5단 표그래프 / 테마-현재가(입력순)` 한 종류다. 48행 모두 제목의 현재 Maria identity는 유일하고 저장 code가 현재 code와 다르지만, 현재 validated preview가 0건이어서 전부 `PreviewEmpty`로 차단한다. live item이 생긴 경우에만 현재 code를 송출 selection에 사용하며, 원본과 같이 PREPARE 직전 로컬 시각 00~12시는 PRE, 13~23시는 AFTER로 다시 결정한다.
|
`unique=2,213`은 전부 송출 가능하다는 뜻이 아니라 모든 행이 단일 route의
|
||||||
|
selection 결과 또는 명시적 차단 사유를 가졌다는 뜻이다. `selectionMapped`도
|
||||||
비교 후보 99행은 고정 지수쌍 33, 시장쌍이 보존된 KRX graph 41, 시장이 사라진 `종목` 2열판 25로 확정했다. 앞의 74행만 closed target 또는 Oracle의 시장+이름 exact lookup으로 복원한다. 마지막 25행은 오늘의 master에서 이름이 우연히 유일해도 원본 행이 KOSPI/KOSDAQ/NXT/해외 구분을 보존하지 않았으므로 자동 복원하지 않는다. NXT graph는 원본 UC1 자체가 지원하지 않았고 현재 graph loader도 Oracle KRX pair만 허용하므로 계속 차단한다.
|
장면 데이터·자산, package context, PREPARE 또는 PGM 성공이 아니다. 이들은 집계
|
||||||
|
밖의 별도 gate다. KRX/NXT theme은
|
||||||
해외지수 candle 61행은 UC5 고정 이름 세 개(Dow 22, Nasdaq 21, S&P500 18)와 5/20/60/120일 기간만 사용하며 240일 활성 행은 없다. 이름과 기간은 닫힌 표이지만 원본 LIST_TEXT가 symbol/nation을 저장하지 않으므로 DB-free로 분류하지 않는다. Oracle master에서 `F_INPUT_NAME`, expected symbol, `F_NATC=US`, `F_FDTC=0`의 exact identity를 다시 확인한 뒤에만 `s8010/PRICE` 선택을 발급한다.
|
원본 `s5074/s5077/s5088`처럼 제목 exact lookup을 사용하며, 저장 code를 가장 가까운
|
||||||
|
현재 code로 추정하지 않는다. 비교 25행도 현재 이름이 우연히 유일하더라도 저장
|
||||||
이 수치는 “문자열을 해석할 수 있음”과 “현재 데이터로 송출할 수 있음”을 분리한다. identity 미확정 325행과 whitelist 미확정 30행에는 `R...` 임시 code나 가장 가까운 장면을 넣지 않는다. 화면에는 실패 행을 유지하고 PREPARE를 차단하며, 운영자가 현재 항목으로 다시 선택해야 한다.
|
행이 시장을 보존하지 않았으므로 계속 차단한다. 상세 SQL 경계와 결과는
|
||||||
|
[`NAMED_PLAYLIST_READ_AUDIT.md`](NAMED_PLAYLIST_READ_AUDIT.md)에 기록했다.
|
||||||
|
|
||||||
## s6001 asset 검사 보강 기준
|
## s6001 asset 검사 보강 기준
|
||||||
|
|
||||||
|
|||||||
136
docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md
Normal file
136
docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# 원본 one-time importer 안전 감사
|
||||||
|
|
||||||
|
기준일은 2026-07-12이며, 설치된 패키지는
|
||||||
|
`Wickedness.MBNStockWebView_1.0.3.0_x64__qbv3jkvsn3aj0`이다. 이 감사에서는
|
||||||
|
원본 `Data`를 읽기 전용으로 사용했고 Oracle/MariaDB에는 SELECT만 실행했다.
|
||||||
|
운영 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`와 Tornado2에는 쓰기나
|
||||||
|
명령을 보내지 않았다. 비교쌍에 한해서는 설치된 signed package의 명시적
|
||||||
|
버튼을 한 번 눌러 WebView2 `localStorage`에 8쌍과 one-time marker를 같은
|
||||||
|
envelope로 저장했다.
|
||||||
|
|
||||||
|
## 감사 시점 운영 profile 분류
|
||||||
|
|
||||||
|
FSell/VI 저장소에는 marker와 intent 없이 원본과 byte 단위로 같은 네 파일이
|
||||||
|
이미 있다.
|
||||||
|
|
||||||
|
| 파일 | 길이 | SHA-256 | 고정 원본과 일치 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| `개인.dat` | 56 | `F953366A5D9F940CBE54F05C256F8BFB0AF4674C8F4D46CCBF839A63113342FA` | 예 |
|
||||||
|
| `외국인.dat` | 58 | `45F62038E3216E80FCF22F4E923056858E9C3537BF0DC46BC359148E82DD7CD8` | 예 |
|
||||||
|
| `기관.dat` | 27 | `BC4D62D734E701744E10DD830A2D6B9AEBEBEB795BB4DABC77CDE6577F016EB7` | 예 |
|
||||||
|
| `VI발동.dat` | 276 | `46AFBA07D2C4AF50717450582DEB10EC10367701D09161BECF5E32342AD4AA45` | 예 |
|
||||||
|
|
||||||
|
- `.legacy-manual-operator-import.v1.json`: 없음
|
||||||
|
- `.legacy-manual-operator-import.v1.in-progress.json`: 없음
|
||||||
|
- importer staging 파일: 없음
|
||||||
|
- startup 상태: marker가 없는 기존 원본 파일을 그대로 쓰는 `ReadyLegacyData`
|
||||||
|
- 현재 버튼 실행 판정: `DestinationNotEmpty`, `OutcomeUnknown=false`
|
||||||
|
|
||||||
|
따라서 현재 profile에서 `원본 데이터 가져오기`를 누르면 네 파일을 덮어쓰지
|
||||||
|
않고 source read 전에 전체 import가 중단된다. marker를 임의로 만들어
|
||||||
|
`AlreadyImported`로 바꾸거나 파일을 지워 import를 재현하면 안 된다.
|
||||||
|
|
||||||
|
다음 명령은 운영 profile을 쓰지 않고 이 판정을 다시 출력한다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj `
|
||||||
|
-c Release -p:Platform=x64 -- --manual-import-audit
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 결과:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MANUAL_IMPORT_AUDIT: PASS sourceFiles=4/4 operatingFiles=4/4 sourceByteIdentical=true marker=false intent=false staged=false runtime=ReadyLegacyData decision=DestinationNotEmpty outcomeUnknown=false writesAttempted=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## 격리 FSell/VI 실제 원본 import
|
||||||
|
|
||||||
|
`--manual-import-fixture`는 사용자가 destination을 지정할 수 없게 닫혀 있다.
|
||||||
|
고정 원본을 읽고 `%TEMP%\MBN_STOCK_WEBVIEW-manual-import-fixture-<GUID>`에만
|
||||||
|
쓴다. 전체 실행은 1분 timeout으로 제한되며 timeout/cancel 뒤 자동 재시도하지
|
||||||
|
않는다. 다음 항목을 모두 확인한 뒤 자신이 만든 prefix의 non-reparse directory만
|
||||||
|
삭제한다.
|
||||||
|
|
||||||
|
1. 4파일 stage 및 no-overwrite commit
|
||||||
|
2. production parser read-back
|
||||||
|
3. marker-last commit과 intent 제거
|
||||||
|
4. 재시작 guard의 `ReadyImportedData`
|
||||||
|
5. 같은 fixture의 두 번째 실행 `AlreadyImported`, `OutcomeUnknown=false`
|
||||||
|
6. 원본 4파일과 운영 import 대상 6항목의 실행 전후 SHA-256 불변
|
||||||
|
7. 원본 aggregate SHA-256 `fd39a363…a2114`, FSell 15행과 VI 9건 고정
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj `
|
||||||
|
-c Release -p:Platform=x64 -- --manual-import-fixture
|
||||||
|
```
|
||||||
|
|
||||||
|
실제 결과:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MANUAL_IMPORT_FIXTURE: PASS rows=15 vi=9 sourceSha256=fd39a3639e574a19f174225e8e65234d6bdd4c58700bb5d10230a779a03a2114 state=ReadyImportedData secondAttempt=AlreadyImported outcomeUnknown=false sourceUnchanged=true operatingImportSetUnchanged=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## `종목비교.dat` 분류
|
||||||
|
|
||||||
|
- 고정 원본: 358바이트, 8행
|
||||||
|
- SHA-256: `1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2`
|
||||||
|
- 실제 Oracle/MariaDB read-only 재검증: 8/8 identity 해소
|
||||||
|
- 실행 경로: `비교` 화면의 `원본 비교쌍 가져오기`
|
||||||
|
- 성공 저장 위치: package WebView2 `localStorage`의 단일 comparison envelope
|
||||||
|
|
||||||
|
감사 초기에 package WebView2가 닫힌 상태에서 LevelDB에는 87바이트
|
||||||
|
`VERSION=1` log 외 comparison key byte가 없었고 비교 화면은 `0 PAIRS`였다.
|
||||||
|
signed 1.0.3 패키지에서 `원본 비교쌍 가져오기`를 명시적으로 한 번 실행한
|
||||||
|
결과, native가 고정 원본의 SHA-256과 8행을 다시 읽고 현재 DB에서 모든
|
||||||
|
identity를 해소한 뒤 화면이 `8 PAIRS`와 `원본 8쌍 가져옴`으로 바뀌었다.
|
||||||
|
부분 저장이나 `복구 필요` 상태는 발생하지 않았다.
|
||||||
|
|
||||||
|
이 성공은 같은 package profile의 단일 comparison envelope에 pair list와
|
||||||
|
marker를 함께 기록한 결과다. 앱을 정상 종료한 뒤 설치된 signed 1.0.3을
|
||||||
|
다시 실행해 다음 상태를 확인했다.
|
||||||
|
|
||||||
|
- 비교 화면: `8 PAIRS`
|
||||||
|
- 저장 비교쌍: 원본 순서의 8개 항목 유지
|
||||||
|
- one-time gate: 완료형 `원본 8쌍 가져옴`, 다시 실행할 수 없는 상태
|
||||||
|
- 고정 원본 SHA-256: `1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2`로 불변
|
||||||
|
|
||||||
|
따라서 package UI 저장과 재시작 marker 영속성은 PASS다. 실행 중 LevelDB는
|
||||||
|
process lock 상태이므로 raw 파일 검색으로 live logical state를 단정하거나
|
||||||
|
파일을 복사·수정하지 않았다.
|
||||||
|
|
||||||
|
정규 DB smoke에는 실제 importer를 추가했다. 아래 명령은 원본을 읽고 8개
|
||||||
|
identity를 fresh SELECT로 해소하지만 package `localStorage`에는 쓰지 않는다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj `
|
||||||
|
-c Release -p:Platform=x64 --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
확인 결과는 다음과 같다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
LegacyComparisonImport: rows=8, sha256=1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2, identities=fresh-read-only
|
||||||
|
REAL_DB_SMOKE: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 package profile에서는 실제 0→8 import와 정상 종료 후 재시작 marker
|
||||||
|
검증을 완료했으므로 이를 재현하려고 package family 데이터를 초기화하지
|
||||||
|
않는다. 빈 profile의 실패 fixture가 추가로 필요하면 별도 test package
|
||||||
|
identity를 사용한다. 운영 identity에서 `Remove-AppxPackage`, app data reset,
|
||||||
|
LevelDB 파일 복사·삭제로 빈 profile을 만들지 않는다.
|
||||||
|
|
||||||
|
## 차단 판정표
|
||||||
|
|
||||||
|
| 상태 | FSell/VI 판정 | 비교쌍 판정 | 자동 재시도 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 대상 파일 없음, source 정상 | import 가능 | marker 없고 envelope 정상일 때 가능 | 명시적 버튼만 |
|
||||||
|
| 기존 4파일, marker 없음 | `DestinationNotEmpty` | 해당 없음 | 금지 |
|
||||||
|
| 유효 marker 있음 | `AlreadyImported` | 버튼이 `원본 N쌍 가져옴`으로 비활성 | 금지 |
|
||||||
|
| intent 있음 | `InterruptedImport`, `OutcomeUnknown=true` | 해당 없음 | 금지, 수동 대조 |
|
||||||
|
| marker/4파일 불일치 | `InvalidImportedState` | 해당 없음 | 금지, 수동 대조 |
|
||||||
|
| split/손상 envelope | 해당 없음 | `복구 필요`, 전체 차단 | 금지, local state 대조 |
|
||||||
|
| DB timeout/identity 오류 | 해당 없음 | 저장 전 전체 취소 | 금지 |
|
||||||
|
|
||||||
|
FSell/VI 전용 화면은 원본 운영 항목의 개인·외국인·기관 또는 VI 수동 항목을
|
||||||
|
열어 진입하며, header의 `원본 데이터 가져오기`가 importer 버튼이다. 비교쌍은
|
||||||
|
상단 `비교` 업무 탭 안의 `원본 비교쌍 가져오기` 버튼으로 진입한다.
|
||||||
@@ -70,8 +70,8 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일
|
|||||||
|
|
||||||
- 화면/계약 구현: MainForm과 UC1~UC7의 종목·고정·업종·비교·테마·해외·전문가·거래정지 workflow, GraphE·FSell·VIList·PList/AList·ThemeA·EList 전용 화면과 native bridge
|
- 화면/계약 구현: MainForm과 UC1~UC7의 종목·고정·업종·비교·테마·해외·전문가·거래정지 workflow, GraphE·FSell·VIList·PList/AList·ThemeA·EList 전용 화면과 native bridge
|
||||||
- 자동 계약 검증: 328 fixed + 44 industry + 31 stock + 9 comparison의 412 action 매트릭스, typed DTO, 저장·복원 fail-closed 경계
|
- 자동 계약 검증: 328 fixed + 44 industry + 31 stock + 9 comparison의 412 action 매트릭스, typed DTO, 저장·복원 fail-closed 경계
|
||||||
- 적용 중: PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원. 기존 운영 DB 행을 원본과 문자 단위로 대조하기 전에는 완료로 판정하지 않음
|
- 적용 중: PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 원문 round-trip과 typed persistence가 전부 일치했다. KRX·해외종목 및 별도 GraphE fresh proof를 합쳐 `selectionMapped=1,603`, 차단 610이며 이 수치는 scene data·asset·PREPARE·PGM 성공을 뜻하지 않는다.
|
||||||
- 화면/계약 구현·운영 검증 대기: GraphE·FSell·VI 항목을 포함한 manual named save→reload→fresh identity/version/page plan 복원, ThemeA/EList의 KRX/NXT·동명이름·코드 identity 보존
|
- 화면/계약 구현·운영 검증 대기: GraphE 36행 중 22행은 fresh row-version/stock identity로 복원되고 14행은 저장값 무효·identity 중복으로 차단된다. FSell/VI PList행은 현재 0건이며 production source read만 FSell 3×5행, VI 9항목·2 page로 확인했다. ThemeA/EList 운영 write와 PGM은 별도 승인 범위다.
|
||||||
- 코드 적용·운영 검증 대기: 원본 `Data\종목비교.dat`, FSell, VI를 고정 read-only source에서 strict parser·hash·fresh identity·durable intent로 검증한 뒤 private 저장소로 이전하는 one-time importer. 실제 package profile의 명시적 1회 실행은 아직 수행하지 않음
|
- 코드 적용·운영 검증 대기: 원본 `Data\종목비교.dat`, FSell, VI를 고정 read-only source에서 strict parser·hash·fresh identity·durable intent로 검증한 뒤 private 저장소로 이전하는 one-time importer. 실제 package profile의 명시적 1회 실행은 아직 수행하지 않음
|
||||||
- 외부자산 필요: `s5006`의 `Video\큐브배경.vrv`, `s6001` 해외지수의 국가별 영상 13개. 현재 alias-only cut 검사를 asset-ready 근거로 사용하지 않음
|
- 외부자산 필요: `s5006`의 `Video\큐브배경.vrv`, `s6001` 해외지수의 국가별 영상 13개. 현재 alias-only cut 검사를 asset-ready 근거로 사용하지 않음
|
||||||
- 운영 동등성 미검증: GraphE, PList/AList, ThemeA, EList의 실제 Oracle DB-W와 FSell/VI 운영 파일 write/read-back
|
- 운영 동등성 미검증: GraphE, PList/AList, ThemeA, EList의 실제 Oracle DB-W와 FSell/VI 운영 파일 write/read-back
|
||||||
|
|||||||
118
docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md
Normal file
118
docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Named manual restore read-only audit
|
||||||
|
|
||||||
|
감사일: 2026-07-12 (KST)
|
||||||
|
|
||||||
|
범위는 운영 Oracle/MariaDB의 `SELECT`와
|
||||||
|
`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`의 production read path뿐이다.
|
||||||
|
원본 저장소, 운영 DB, 운영 파일, import marker/intent, Tornado2에는 쓰기나 명령을
|
||||||
|
보내지 않았다. 감사 출력에는 program code, title, stock name/code, VI 이름 목록,
|
||||||
|
파일 경로, 해시 또는 인증 정보를 포함하지 않는다.
|
||||||
|
|
||||||
|
## 현재 PList 문법 분포
|
||||||
|
|
||||||
|
실제 `DC_LIST`/`PLAY_LIST` 2,213행을 lossless parser로 읽고
|
||||||
|
`LegacyPListAuditRoute.ManualFreshMaterialization` 36행을 독립 closed grammar로 다시
|
||||||
|
분류했다.
|
||||||
|
|
||||||
|
| 종류 | 행 | 세부 분포 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| GraphE | 36 | 주요매출 구성 12, 매출액 12, 영업이익 12 |
|
||||||
|
| GraphE 성장성 지표 | 0 | 현재 저장 행 없음 |
|
||||||
|
| FSell 순매도 | 0 | 현재 저장 행 없음 |
|
||||||
|
| VI 수동 목록 | 0 | 현재 저장 행 없음 |
|
||||||
|
| 합계 | 36 | 역사형 36, canonical형 0 |
|
||||||
|
|
||||||
|
따라서 이전 감사 문구의 `GraphE/FSell/VI 36행`은 “세 manual 복원 경로가 차단
|
||||||
|
대상”이라는 범주명이었고, 실제 36행의 내용은 전부 GraphE였다. FSell/VI 경로는
|
||||||
|
구현돼 있으나 현재 named PList에는 해당 행이 없다.
|
||||||
|
|
||||||
|
분류기는 Web의 `named-manual-restore-workflow.js`와 같은 두 문법을 닫힌 값으로
|
||||||
|
허용한다.
|
||||||
|
|
||||||
|
| 종류 | 역사형 | fresh materialize 후 canonical형 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GraphE | 한국어 시장 + 원본 GraphE 화면명 + 종목 표시명 + `1/1` | canonical 시장 + canonical 화면명 + 동일 표시명 + `1/1` |
|
||||||
|
| FSell | 수동 순매도 3개 audience + 지수/순매도 + `1/1` | `INDIVIDUAL`/`FOREIGN`/`INSTITUTION` + `MANUAL_NET_SELL` |
|
||||||
|
| VI | 쉼표 구분 이름 목록 + 계산된 `1/N` | `PAGED_VI` + reserved snapshot subject + lowercase SHA-256 version |
|
||||||
|
|
||||||
|
## 실제 fresh materialization 결과
|
||||||
|
|
||||||
|
GraphE는 브라우저/네이티브 복원 순서를 그대로 축약해 다음 증거를 요구했다.
|
||||||
|
|
||||||
|
1. `LegacyManualFinancialScreenService.GetAsync` 첫 조회
|
||||||
|
2. 현재 종목 마스터 검색(브라우저와 같은 최대 200건, truncated 금지)
|
||||||
|
3. 같은 GraphE identity를 두 번째로 조회
|
||||||
|
4. 두 조회의 64자리 Oracle row version이 동일한지 확인
|
||||||
|
5. 시장·provider·종목 code가 유일한지 확인
|
||||||
|
6. `ManualFinancialCutContracts.CreateSelection` 결과가 화면별 cut 계약과 일치하는지 확인
|
||||||
|
|
||||||
|
| 화면 | 후보 | fresh materialize 가능 | 차단 |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| 주요매출 구성 | 12 | 5 | 7 (`GRAPH_E_DATA_INVALID`) |
|
||||||
|
| 매출액 | 12 | 6 | 5 (`GRAPH_E_AMBIGUOUS`), 1 (`GRAPH_E_DATA_INVALID`) |
|
||||||
|
| 영업이익 | 12 | 11 | 1 (`GRAPH_E_DATA_INVALID`) |
|
||||||
|
| 합계 | 36 | 22 | 14 |
|
||||||
|
|
||||||
|
`GRAPH_E_DATA_INVALID` 9행은 production GraphE parser가 현재 INPUT_* 저장값을
|
||||||
|
typed record로 만들 수 없는 경우다. `GRAPH_E_AMBIGUOUS` 5행은 같은 화면에서
|
||||||
|
하나의 저장 identity가 둘 이상 조회되어 원본 schema의 이름-only identity로는
|
||||||
|
어느 행인지 결정할 수 없는 경우다. 원시 이름과 저장값은 출력하지 않았다.
|
||||||
|
|
||||||
|
22행의 성공은 “현재 raw PList 행을 fresh GraphE selection으로 복원 가능”하다는
|
||||||
|
뜻이다. 각 scene의 시세 조회, cut asset, PREPARE 또는 Tornado/PGM 송출 성공을
|
||||||
|
의미하지 않는다.
|
||||||
|
|
||||||
|
## FSell/VI production source 확인
|
||||||
|
|
||||||
|
현재 named PList 후보는 0행이지만 향후 저장/복원을 막는 source 문제인지 별도로
|
||||||
|
검사했다.
|
||||||
|
|
||||||
|
- runtime guard: `ReadyLegacyData`
|
||||||
|
- FSell: 세 audience 모두 production CP949 parser로 opened-handle read 성공,
|
||||||
|
audience마다 정확히 5행
|
||||||
|
- VI: production opened-handle read 성공, 9개 항목, 2 page, versioned 두 번째 read 일치
|
||||||
|
- 운영 디렉터리: 4개 data entry가 감사 전후 이름·크기·생성/수정 시각·SHA-256 모두 동일
|
||||||
|
- marker/intent: 감사 전후 생성 없음
|
||||||
|
- writes attempted: `false`
|
||||||
|
|
||||||
|
`ReadyLegacyData`인 현재 profile에 byte-identical 4파일이 이미 존재하므로 importer를
|
||||||
|
실행하거나 완료 marker를 임의로 생성하지 않는다. 이번 결과는 기존 trusted store의
|
||||||
|
read 가능성만 증명한다.
|
||||||
|
|
||||||
|
## Native request 대응
|
||||||
|
|
||||||
|
| 복원 종류 | fresh proof에 사용되는 native request |
|
||||||
|
| --- | --- |
|
||||||
|
| GraphE | `request-manual-financial-load` → `search-stocks` → `request-manual-financial-selection` |
|
||||||
|
| FSell | `request-manual-net-sell-data` |
|
||||||
|
| VI | `request-vi-manual-list` 후 반환 version을 reserved snapshot reference로 고정 |
|
||||||
|
|
||||||
|
구현 API는
|
||||||
|
`src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs`의 closed classifier와
|
||||||
|
pure fresh-proof evaluator, 실행 경계는
|
||||||
|
`tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`의
|
||||||
|
`NamedManualRestoreReadOnlyAudit.RunAsync`다. 실행 경계는 root smoke에서 다음처럼
|
||||||
|
호출할 수 있다.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await NamedManualRestoreReadOnlyAudit.RunAsync(
|
||||||
|
runtime.Executor,
|
||||||
|
NamedManualRestoreReadOnlyAudit.DefaultTrustedDirectory(),
|
||||||
|
cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
집중 Core 테스트 21건은 역사형/canonical GraphE·FSell·VI grammar, NXT 표시명 검색,
|
||||||
|
GraphE double-read version, master identity 중복, FSell 5행, VI version/name/page drift를
|
||||||
|
검증한다.
|
||||||
|
|
||||||
|
## 남은 조치
|
||||||
|
|
||||||
|
1. `GRAPH_E_DATA_INVALID` 9행은 운영자가 승인한 snapshot을 먼저 만들고 각 INPUT_*
|
||||||
|
원본 필드 형식을 확인한다.
|
||||||
|
2. `GRAPH_E_AMBIGUOUS` 5행은 이름-only identity 중 어느 행을 유지할지 운영자가
|
||||||
|
결정해야 한다. 감사 코드가 임의 삭제하거나 첫 행을 선택하지 않는다.
|
||||||
|
3. 수정은 별도의 운영 DB write 승인, 대상 identity, rollback 계획과 OutcomeUnknown
|
||||||
|
규칙을 갖춘 회차에서만 수행한다.
|
||||||
|
4. 수정 후 같은 read-only 감사를 다시 실행해 36/36 fresh materialization을 확인한다.
|
||||||
|
5. 그 다음 scene data/asset DryRun을 확인하고, 실제 PGM TAKE IN은 별도 Gate A/B
|
||||||
|
승인 회차에서만 수행한다.
|
||||||
178
docs/NAMED_PLAYLIST_READ_AUDIT.md
Normal file
178
docs/NAMED_PLAYLIST_READ_AUDIT.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# PList 기존 데이터 읽기 전용 동등성 감사
|
||||||
|
|
||||||
|
기준일: 2026-07-12
|
||||||
|
|
||||||
|
원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\Form\PList.cs`
|
||||||
|
감사 구현:
|
||||||
|
[LegacyPListReadAuditParser](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs),
|
||||||
|
[NamedPlaylistReadOnlyDbAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs),
|
||||||
|
[NamedManualRestoreReadOnlyAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs)
|
||||||
|
|
||||||
|
## 원본 읽기 의미
|
||||||
|
|
||||||
|
원본 `PList.data_load`는 `PLAY_LIST`를 `ITEM_INDEX` 순으로 읽고
|
||||||
|
`LIST_TEXT`를 `^`로 나눈 뒤 FarPoint의 0~6열에 넣는다. 첫 필드는 `1`일 때만
|
||||||
|
활성이고, 여섯 번째 필드인 PageN 문자열에서만 ASCII 공백을 제거한다. 다른
|
||||||
|
필드는 공백·한글·기호를 포함해 변경하지 않는다.
|
||||||
|
|
||||||
|
새 감사도 이 경계를 그대로 사용한다.
|
||||||
|
|
||||||
|
- 정확히 7필드인지 검사한다.
|
||||||
|
- 원문을 다시 `^`로 결합했을 때 DB 문자열과 code-unit 단위로 같아야 한다.
|
||||||
|
- 페이지 표시값 이외의 필드는 정규화하지 않는다.
|
||||||
|
- 프로그램 코드는 8자리 숫자, `ITEM_INDEX`는 0부터 연속이어야 한다.
|
||||||
|
- 제어문자, U+FFFD replacement 문자와 알려진 UTF-8/codepage 손상 신호를
|
||||||
|
거부한다.
|
||||||
|
- 같은 프로그램을 현재 `LegacyNamedPlaylistPersistenceService.LoadAsync`로
|
||||||
|
다시 읽어 활성값, 5개 selection 필드와 페이지 표시값을 모두 대조한다.
|
||||||
|
|
||||||
|
감사 오류와 정상 출력에는 `DC_TITLE`, `LIST_TEXT`, 종목명, 테마명, 프로그램
|
||||||
|
코드 또는 인증정보를 포함하지 않는다.
|
||||||
|
|
||||||
|
## 실제 Oracle/MariaDB 결과
|
||||||
|
|
||||||
|
실제 운영 연결에 고정 SQL과 bind 변수로 `SELECT`만 실행했다. DB write,
|
||||||
|
Tornado2 명령과 원본 저장소 변경은 수행하지 않았다.
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---:|
|
||||||
|
| 활성 `DC_LIST` 정의 | 23 |
|
||||||
|
| 연결된 `PLAY_LIST` 행 | 2,213 |
|
||||||
|
| 정확히 7필드로 parse | 2,213 |
|
||||||
|
| 원문 무손상 round-trip | 2,213 |
|
||||||
|
| replacement/mojibake 신호 | 0 |
|
||||||
|
| 비연속 `ITEM_INDEX` 프로그램 | 0 |
|
||||||
|
| 현재 persistence 결과 불일치 | 0 |
|
||||||
|
| 단일 route와 결과로 분류 | 2,213 |
|
||||||
|
| 기본 감사에서 `selectionMapped` | 1,581 |
|
||||||
|
| 기본 감사에서 안전 차단 | 632 |
|
||||||
|
| 별도 GraphE fresh proof에서 추가 `selectionMapped` | 22 |
|
||||||
|
| 별도 GraphE fresh proof에서 안전 차단 | 14 |
|
||||||
|
| 두 read-only 증거를 합친 `selectionMapped` | 1,603 |
|
||||||
|
| 두 read-only 증거를 합친 안전 차단 | 610 |
|
||||||
|
|
||||||
|
`단일 route와 결과로 분류`는 모든 행이 송출 가능하다는 뜻이 아니다. 기본
|
||||||
|
`NamedPlaylistReadOnlyDbAudit`는 KRX·해외종목 복원까지 적용해 1,581행을 단일
|
||||||
|
current selection으로 매핑하고 632행을 차단한다. 이 632행에는 별도 production
|
||||||
|
source 증거가 필요한 manual 36행이 들어 있다. `NamedManualRestoreReadOnlyAudit`가
|
||||||
|
그 36행을 독립적으로 다시 읽어 GraphE 22행을 fresh selection으로 만들고 14행을
|
||||||
|
차단하므로, 두 read-only 증거를 행별로 합친 결과는 1,603행 `selectionMapped`,
|
||||||
|
610행 차단이다.
|
||||||
|
|
||||||
|
`selectionMapped`는 raw 7필드에서 현재 typed selection을 하나 만들었다는 뜻만
|
||||||
|
갖는다. 각 장면의 시세·재무 데이터 결과, 컷·배경 자산, package context,
|
||||||
|
PREPARE 성공과 Tornado2/PGM 출력은 포함하지 않으며 각각 별도 gate를 통과해야
|
||||||
|
한다.
|
||||||
|
|
||||||
|
### route 집계
|
||||||
|
|
||||||
|
| route | 전체 | `selectionMapped` | 차단 |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| 기존 닫힌 매퍼 | 1,250 | 1,250 | 0 |
|
||||||
|
| 업종 fixed 닫힌 매퍼 | 24 | 24 | 0 |
|
||||||
|
| 비교 fresh identity | 99 | 74 | 25 |
|
||||||
|
| 해외지수 candle fresh identity | 61 | 61 | 0 |
|
||||||
|
| NXT theme fresh identity | 48 | 0 | 48 |
|
||||||
|
| KRX theme fresh identity | 587 | 94 | 493 |
|
||||||
|
| 해외종목 fresh identity | 78 | 78 | 0 |
|
||||||
|
| GraphE fresh materialization | 36 | 22 | 14 |
|
||||||
|
| unique closed mapper 없음 | 30 | 0 | 30 |
|
||||||
|
| 합계 | 2,213 | 1,603 | 610 |
|
||||||
|
|
||||||
|
기본 감사 수치 1,581은 기존 닫힌 매퍼 1,250행, 업종 fixed 24행, 비교
|
||||||
|
74행, 해외지수 candle 61행, KRX theme 94행 및 해외종목 78행의 합이다.
|
||||||
|
GraphE 22행은 별도 fresh proof의 성공분이다. 현재 PList의 manual 36행은 모두
|
||||||
|
GraphE이고 FSell/VI 저장 행은 0건이다.
|
||||||
|
|
||||||
|
기존 fixed 매퍼는 임의 문자열 허용으로 판정하지 않았다. 원본 runtime
|
||||||
|
`해외.ini`·`환율.ini`·`지수.ini`를 각각 현재 Web 계약에 고정된 SHA-256으로
|
||||||
|
검증하고, 원본 `MainForm`과 같이 각 줄을 trim한 뒤 현재
|
||||||
|
`fixedLegacySignature`의 exact 5필드 규칙으로 변환했다. 원본 328 action에서
|
||||||
|
현재 명시적으로 unavailable인 수동 6개를 제외하고, 같은 raw 서명으로 겹치는
|
||||||
|
후보는 unique로 인정하지 않았다. 이 exact whitelist와 일치하지 않은 30행은
|
||||||
|
가까운 `s5001` 등을 추정하지 않고 차단했다.
|
||||||
|
|
||||||
|
### fresh restore 결과와 차단 사유
|
||||||
|
|
||||||
|
| 사유 | 행 | 의미 |
|
||||||
|
|---|---:|---|
|
||||||
|
| comparison market identity 유실 | 25 | 원본 2열판이 KOSPI/KOSDAQ/NXT/해외 구분을 저장하지 않았다. |
|
||||||
|
| KRX theme identity 없음 | 271 | 원본 제목 exact lookup으로 현재 유일한 KRX identity를 찾을 수 없다. |
|
||||||
|
| KRX theme 저장 행 무효 | 16 | 닫힌 5·6·12행 문법과 필수 field를 만족하지 않는다. |
|
||||||
|
| KRX theme preview empty | 193 | 현재 제목/code는 찾았지만 validated live item이 없다. |
|
||||||
|
| KRX theme 지원 불가 action | 13 | 현재 5·6·12행 장면 계약으로 유일하게 해석할 수 없다. |
|
||||||
|
| GraphE 현재 저장값 무효 | 9 | production GraphE parser가 현재 INPUT_* 저장값을 typed record로 만들 수 없다. |
|
||||||
|
| GraphE 이름 identity 중복 | 5 | 원본 이름-only identity로 현재 저장 행 하나를 결정할 수 없다. |
|
||||||
|
| NXT theme preview empty | 48 | 현재 title/code 매핑은 성공했으나 MariaDB validated preview가 비어 있다. |
|
||||||
|
| unique closed mapper 없음 | 30 | hash가 고정된 328-action runtime 계약의 어느 단일 서명과도 일치하지 않아 nearest scene을 추정하지 않는다. |
|
||||||
|
|
||||||
|
KRX theme 587행 중 94행은 제목 exact lookup, 현재 code 및 validated preview를
|
||||||
|
같은 correlated batch에서 확인해 모두 현재 code로 remap했다(`codeRemapped=94`).
|
||||||
|
나머지 493행은 위 네 KRX 사유로 차단했다. 해외종목 78행은 현재 Oracle의 exact
|
||||||
|
입력명에서 유일한 symbol/nation을 얻고 original action 문법을 보존해 78/78을
|
||||||
|
매핑했다. 두 경로 모두 timeout/불명확 응답을 재시도하지 않으며 raw 이름·code를
|
||||||
|
감사 출력에 기록하지 않는다.
|
||||||
|
|
||||||
|
GraphE 36행의 별도 감사는 같은 identity를 두 번 읽어 64자리 row version이
|
||||||
|
변하지 않는지 확인하고 현재 종목 master의 유일한 identity와 cut 계약을 함께
|
||||||
|
검증했다. 22행만 materialize했고 저장값 무효 9행과 identity 중복 5행은 첫 후보를
|
||||||
|
고르지 않고 차단했다. 상세 경계는
|
||||||
|
[manual read-only 감사](NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md)에 있다.
|
||||||
|
|
||||||
|
FSell/VI는 현재 PList 후보가 0행이지만 운영 production source를 opened-handle로
|
||||||
|
별도 읽었다. FSell은 3 audience마다 5행, VI는 9항목·2 page였고 두 번째 VI read의
|
||||||
|
version도 같았다. 네 운영 파일의 이름·크기·시각·SHA-256은 감사 전후 동일했고
|
||||||
|
import marker/intent를 만들거나 파일을 쓰지 않았다.
|
||||||
|
|
||||||
|
## 구현과 자동 근거
|
||||||
|
|
||||||
|
- KRX theme:
|
||||||
|
[Core restore](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs),
|
||||||
|
[native bridge](../MainWindow.NamedKrxThemeRestore.cs),
|
||||||
|
[Web workflow](../Web/named-krx-theme-restore-workflow.js),
|
||||||
|
[Core tests](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedKrxThemeRestoreServiceTests.cs),
|
||||||
|
[Web tests](../tests/Web/named-krx-theme-restore-workflow.test.cjs),
|
||||||
|
[app integration](../tests/Web/named-krx-theme-restore-app-integration.test.cjs)
|
||||||
|
- 해외종목:
|
||||||
|
[Core restore](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs),
|
||||||
|
[native bridge](../MainWindow.NamedForeignStockRestore.cs),
|
||||||
|
[Web workflow](../Web/named-foreign-stock-restore-workflow.js),
|
||||||
|
[Core tests](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedForeignStockRestoreServiceTests.cs),
|
||||||
|
[Web tests](../tests/Web/named-foreign-stock-restore-workflow.test.cjs),
|
||||||
|
[app integration](../tests/Web/named-foreign-stock-restore-app-integration.test.cjs)
|
||||||
|
- manual:
|
||||||
|
[closed grammar/fresh proof](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs),
|
||||||
|
[Web workflow](../Web/named-manual-restore-workflow.js),
|
||||||
|
[read-only runner](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs),
|
||||||
|
[Core tests](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedManualRestoreAuditTests.cs),
|
||||||
|
[Web tests](../tests/Web/named-manual-restore-workflow.test.cjs)
|
||||||
|
|
||||||
|
## 반복 실행
|
||||||
|
|
||||||
|
전체 실제 DB smoke에 이 감사가 포함된다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj `
|
||||||
|
-c Release --no-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
감사는 실패 시 원문을 출력하지 않고 boundary 이름만 남긴다. timeout 또는
|
||||||
|
불명확 결과를 재시도하거나 반대 명령으로 보정하지 않는다.
|
||||||
|
|
||||||
|
## 자동 검증
|
||||||
|
|
||||||
|
`LegacyPListReadAuditParserTests`는 다음을 고정한다.
|
||||||
|
|
||||||
|
- 한글·공백·빈 필드를 포함한 7필드 무손상 round-trip
|
||||||
|
- 페이지 필드에만 적용되는 원본 ASCII 공백 제거
|
||||||
|
- field 수, enabled 값, 프로그램 코드, item index 경계
|
||||||
|
- replacement와 codepage 손상 신호 차단
|
||||||
|
- typed persistence 대조와 연속 index
|
||||||
|
- comparison/업종/해외지수/NXT·KRX theme/해외종목/manual route 분리
|
||||||
|
- KRX current-code correlated remap과 5·6·12행 문법, 해외종목 symbol/nation
|
||||||
|
correlation, GraphE double-read row-version·identity 중복 fail-closed
|
||||||
|
- exact stock label과 nearest-label 거부, 알 수 없는 행의 `Unmapped` fail-closed
|
||||||
|
|
||||||
|
실제 기본 감사, 별도 manual fresh 감사와 전체 DB smoke는
|
||||||
|
`REAL_DB_SMOKE: PASS`로 끝났다. DB write와 Tornado2/PGM 명령은 0건이며 raw 값이나
|
||||||
|
인증정보도 출력하지 않았다.
|
||||||
72
docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md
Normal file
72
docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# NXT 테마 복원 read-only 진단
|
||||||
|
|
||||||
|
검증일: 2026-07-12 (KST)
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
활성 NXT 테마 플레이리스트 48행(현재 고유 테마 24개)이 `PreviewEmpty`로
|
||||||
|
차단되는 원인은 마이그레이션의 테마 제목/코드 선택이나 DB endpoint 차이가
|
||||||
|
아니다. 현재 MariaDB의 `SB_ITEM`에는 해당 테마 구성종목 446행이 있고 그
|
||||||
|
446행 모두 폐쇄된 X/T 종목코드 형식을 만족하지만, 원본 장면이 사용하는
|
||||||
|
`v_all_stock` 조인에는 단 한 행도 결합되지 않는다.
|
||||||
|
|
||||||
|
같은 prefix 제거 규칙으로 기반 NXT 테이블을 확인하면 다음과 같다.
|
||||||
|
|
||||||
|
| read-only 집계 | 행 수 |
|
||||||
|
|---|---:|
|
||||||
|
| current theme `SB_ITEM` | 446 |
|
||||||
|
| `N_STOCK` 결합 | 187 |
|
||||||
|
| `N_KOSDAQ_STOCK` 결합 | 259 |
|
||||||
|
| 두 master 결합 합계 | 446 |
|
||||||
|
| `N_ONLINE` 결합 | 151 |
|
||||||
|
| `N_KOSDAQ_ONLINE` 결합 | 169 |
|
||||||
|
| 원본 `v_all_stock` 결합 | 0 |
|
||||||
|
| non-stop / live 결합 | 0 / 0 |
|
||||||
|
|
||||||
|
즉 구성종목은 두 NXT master에 전부 존재하고 그중 320행은 online 테이블에도
|
||||||
|
존재한다. 현재 `v_all_stock` 정의/데이터 범위가 이 NXT master 행을 포함하지
|
||||||
|
않는 것이 직접 원인이다.
|
||||||
|
|
||||||
|
## 원본 동등성 확인
|
||||||
|
|
||||||
|
읽기 전용으로 조사한 원본 `s5074`, `s5077`, `s5088`은 모두 NXT 구성종목을
|
||||||
|
다음 조건으로 읽는다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SUBSTRING(SB_ITEM.SB_I_CODE, 2, 12) = v_all_stock.F_STOCK_CODE
|
||||||
|
```
|
||||||
|
|
||||||
|
새 `LegacyThemeSelectionService`와
|
||||||
|
`LegacyNamedNxtThemeRestoreService`도 같은 prefix 제거 규칙, 같은
|
||||||
|
`F_STOP_GUBUN = 'N'` 조건을 사용한다. 원본 설정과 현재 runtime 설정의
|
||||||
|
host/port/database를 비밀값 없이 정규화해 비교한 결과 SHA-256과 값이
|
||||||
|
동일했고, 실제 `DATABASE()`도 현재 runtime database와 일치했다. 서버는
|
||||||
|
MariaDB 10.5 계열이다.
|
||||||
|
|
||||||
|
따라서 다른 DB를 바라보는 마이그레이션 오류가 아니며, 현 데이터 상태에서는
|
||||||
|
원본 프로그램도 같은 `v_all_stock` 경로로 NXT 테마 구성종목을 얻지 못한다.
|
||||||
|
|
||||||
|
## 안전 결정
|
||||||
|
|
||||||
|
- 운영 DB write는 수행하지 않았다.
|
||||||
|
- endpoint, 계정, 비밀번호, 테마/종목 제목과 코드는 출력하거나 기록하지 않았다.
|
||||||
|
- exact, 양쪽 prefix 제거, right-six 후보도 모두 0이어서 코드 형식 추측
|
||||||
|
fallback의 근거가 없다.
|
||||||
|
- `N_STOCK`/`N_KOSDAQ_STOCK` 직접 fallback은 데이터를 찾지만 원본 SQL과 다른
|
||||||
|
동작이므로 자동 적용하지 않았다.
|
||||||
|
|
||||||
|
복구는 DBA가 `v_all_stock`의 NXT 포함 의도를 확인하고 승인된 변경/롤백
|
||||||
|
계획으로 view를 교정하거나, 제품 요구사항으로 원본과 다른 NXT master 직접
|
||||||
|
경로를 명시적으로 승인한 뒤 진행해야 한다. 그 전까지 복원은 `PreviewEmpty`로
|
||||||
|
fail-closed 상태를 유지한다.
|
||||||
|
|
||||||
|
## 재현
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj `
|
||||||
|
-c Release --no-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
스모크는 식별자를 출력하지 않고 endpoint equality, aggregate join 수,
|
||||||
|
분류(`VAllStockExcludesCurrentNxtMasterRows`)만 출력한다. 전체 결과는
|
||||||
|
`REAL_DB_SMOKE: PASS`여야 한다.
|
||||||
@@ -218,11 +218,11 @@
|
|||||||
| `PList_Load`, `Get_FileName_Folder` | `DC_LIST`의 프로그램 제목·코드를 조회한다. | native `request-named-playlist-list`; `named-playlist-workflow.js` | 화면 통합 완료 |
|
| `PList_Load`, `Get_FileName_Folder` | `DC_LIST`의 프로그램 제목·코드를 조회한다. | native `request-named-playlist-list`; `named-playlist-workflow.js` | 화면 통합 완료 |
|
||||||
| `AList_Load`, `GetNextCode`, `btnok_Click` | 다음 8자리 코드와 프로그램명을 생성해 `DC_LIST`에 추가한다. | native `create-named-playlist` | 화면 통합 완료. parameterized transaction 사용. |
|
| `AList_Load`, `GetNextCode`, `btnok_Click` | 다음 8자리 코드와 프로그램명을 생성해 `DC_LIST`에 추가한다. | native `create-named-playlist` | 화면 통합 완료. parameterized transaction 사용. |
|
||||||
| `PList.data_save`, double-click/확인 | 기존 `PLAY_LIST`를 지운 뒤 enabled와 6개 표시 필드를 `LIST_TEXT` 및 ITEM_INDEX 순서로 저장한다. | native `replace-named-playlist`; trusted entry serialization | 화면/계약 구현. raw·untrusted entry는 저장 또는 PREPARE 불가. GraphE·FSell·VI 등 manual 항목의 실제 save→reload→fresh identity/version 보존은 미검증. |
|
| `PList.data_save`, double-click/확인 | 기존 `PLAY_LIST`를 지운 뒤 enabled와 6개 표시 필드를 `LIST_TEXT` 및 ITEM_INDEX 순서로 저장한다. | native `replace-named-playlist`; trusted entry serialization | 화면/계약 구현. raw·untrusted entry는 저장 또는 PREPARE 불가. GraphE·FSell·VI 등 manual 항목의 실제 save→reload→fresh identity/version 보존은 미검증. |
|
||||||
| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면/계약 구현. raw DB row는 builder/selection/page를 재검증할 때까지 PREPARE 불가. 기존 한국어 `DC_TITLE`/7필드 `LIST_TEXT` 무손상 복원은 적용 중. |
|
| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면/계약 구현. 실제 2,213행의 한국어 `DC_TITLE`/7필드 `LIST_TEXT` round-trip과 typed persistence는 전부 일치했다. KRX·해외종목·GraphE를 포함한 통합 read-only 증거는 `selectionMapped=1,603`, 차단 610이며 raw DB row는 scene data/asset/page를 재검증할 때까지 PREPARE 불가다. |
|
||||||
| `PList.button1_Click` | 프로그램 정의 삭제 | native `delete-named-playlist` | 화면 통합 완료. 실제 운영 DB-W 없음. |
|
| `PList.button1_Click` | 프로그램 정의 삭제 | native `delete-named-playlist` | 화면 통합 완료. 실제 운영 DB-W 없음. |
|
||||||
| 취소 | 아무 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. |
|
| 취소 | 아무 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. |
|
||||||
|
|
||||||
구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트다. 실제 운영 DB write, 기존 한국어 데이터의 원본 대조, manual named save의 fresh restore 및 named playlist 전용 PGM 검증은 아직 없다.
|
구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, KRX/NXT/해외종목/manual 전용 restore, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트이며 실제 기존 데이터의 read-only 대조는 `docs/NAMED_PLAYLIST_READ_AUDIT.md`에 있다. 실제 운영 DB write와 named playlist 전용 scene/asset/PREPARE/PGM 검증은 아직 없다.
|
||||||
|
|
||||||
### ThemeA — 테마 추가·편집
|
### ThemeA — 테마 추가·편집
|
||||||
|
|
||||||
@@ -281,8 +281,8 @@
|
|||||||
| 자동 테스트 | Core 1,259개, Infrastructure 173개, Playout 387개가 Debug/Release x64에서 각각 통과했고 Web workflow·bridge 371/371도 통과했다. 412 action의 폐쇄형 매트릭스, 계약·경계·복원 실패와 native fail-closed gate를 검증한다. |
|
| 자동 테스트 | Core 1,259개, Infrastructure 173개, Playout 387개가 Debug/Release x64에서 각각 통과했고 Web workflow·bridge 371/371도 통과했다. 412 action의 폐쇄형 매트릭스, 계약·경계·복원 실패와 native fail-closed gate를 검증한다. |
|
||||||
| 실제 운영 DB write | **미실행.** GraphE, named playlist, ThemeA, EList의 mutation executor는 fake/계약 테스트로 검증했으며 운영 DB에는 쓰지 않았다. FSell/VI는 테스트용 격리 저장소만 사용했다. |
|
| 실제 운영 DB write | **미실행.** GraphE, named playlist, ThemeA, EList의 mutation executor는 fake/계약 테스트로 검증했으며 운영 DB에는 쓰지 않았다. FSell/VI는 테스트용 격리 저장소만 사용했다. |
|
||||||
| 실제 Tornado2 PGM | 제한된 승인 회차에서 `5001`, `5074` page 1·2, TAKE OUT을 확인했다. 이는 전체 412 action, GraphE, FSell, VI, ThemeA, EList, 이름 있는 playlist 각각의 UI 동등성 검증이 아니다. |
|
| 실제 Tornado2 PGM | 제한된 승인 회차에서 `5001`, `5074` page 1·2, TAKE OUT을 확인했다. 이는 전체 412 action, GraphE, FSell, VI, ThemeA, EList, 이름 있는 playlist 각각의 UI 동등성 검증이 아니다. |
|
||||||
| PList legacy 복원 | raw restore와 fail-closed materialization 계약은 구현했다. 실제 기존 `DC_TITLE`/`LIST_TEXT`의 한국어 무손상 복원은 적용 중이며 완료 증거가 없다. |
|
| PList legacy 복원 | 실제 `DC_TITLE`/`LIST_TEXT` 2,213행의 한국어·7필드 무손상과 typed persistence 일치를 확인했다. 기본 `selectionMapped=1,581`/차단 632와 별도 GraphE 22/14를 합친 결과는 1,603/610이다. selection 매핑은 scene data·asset·PREPARE·PGM 완료 증거가 아니다. |
|
||||||
| manual named save | GraphE·FSell·VI descriptor 계약과 복원 테스트는 있다. 실제 Oracle save→reload→fresh manual identity/version→page plan→PGM은 미검증이다. |
|
| manual named save | 현재 legacy PList manual 36행은 모두 GraphE이며 fresh proof로 22행을 복원하고 저장값 무효 9행·identity 중복 5행을 차단했다. FSell/VI PList행은 0건이고 production source read는 FSell 3×5행, VI 9항목·2 page로 전후 불변이다. 실제 Oracle save→reload와 PGM은 미검증이다. |
|
||||||
| ThemeA/EList identity | expected identity·상관 응답 계약은 구현했다. 실제 운영 DB의 KRX/NXT·동명이름·코드 경계와 정확한 선택 행 replace/delete는 미검증이다. |
|
| ThemeA/EList identity | expected identity·상관 응답 계약은 구현했다. 실제 운영 DB의 KRX/NXT·동명이름·코드 경계와 정확한 선택 행 replace/delete는 미검증이다. |
|
||||||
| legacy file import | `종목비교.dat`·FSell·VI importer는 strict parser, source/hash 재검증, durable intent/marker, rollback·OutcomeUnknown 차단까지 구현했다. 실제 패키지 profile의 명시적 1회 실행은 미검증이며 기존 파일을 덮어쓰지 않는다. |
|
| legacy file import | `종목비교.dat`·FSell·VI importer는 strict parser, source/hash 재검증, durable intent/marker, rollback·OutcomeUnknown 차단까지 구현했다. 실제 패키지 profile의 명시적 1회 실행은 미검증이며 기존 파일을 덮어쓰지 않는다. |
|
||||||
| 남은 실환경 검증 | 다섯 전용 화면의 Web 연결과 signed `1.0.3.0` Release MSIX package-context 실행은 화면/계약 검증 범위에서 확인됐다. 실제 입력→운영 DB write→재조회와 각 전용 scene PGM 검증은 별도 승인·테스트 데이터·롤백 계획이 있는 회차에서만 수행한다. 운영 DB write 또는 새 Live TAKE IN을 승인 없이 실행하지 않는다. |
|
| 남은 실환경 검증 | 다섯 전용 화면의 Web 연결과 signed `1.0.3.0` Release MSIX package-context 실행은 화면/계약 검증 범위에서 확인됐다. 실제 입력→운영 DB write→재조회와 각 전용 scene PGM 검증은 별도 승인·테스트 데이터·롤백 계획이 있는 회차에서만 수행한다. 운영 DB write 또는 새 Live TAKE IN을 승인 없이 실행하지 않는다. |
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ $scripts = @(
|
|||||||
(Join-Path $repositoryRoot 'Web\manual-financial-workflow.js'),
|
(Join-Path $repositoryRoot 'Web\manual-financial-workflow.js'),
|
||||||
(Join-Path $repositoryRoot 'Web\named-manual-restore-workflow.js'),
|
(Join-Path $repositoryRoot 'Web\named-manual-restore-workflow.js'),
|
||||||
(Join-Path $repositoryRoot 'Web\named-comparison-restore-workflow.js'),
|
(Join-Path $repositoryRoot 'Web\named-comparison-restore-workflow.js'),
|
||||||
|
(Join-Path $repositoryRoot 'Web\named-foreign-stock-restore-workflow.js'),
|
||||||
(Join-Path $repositoryRoot 'Web\legacy-foreign-index-candle-workflow.js'),
|
(Join-Path $repositoryRoot 'Web\legacy-foreign-index-candle-workflow.js'),
|
||||||
|
(Join-Path $repositoryRoot 'Web\named-krx-theme-restore-workflow.js'),
|
||||||
(Join-Path $repositoryRoot 'Web\named-nxt-theme-restore-workflow.js'),
|
(Join-Path $repositoryRoot 'Web\named-nxt-theme-restore-workflow.js'),
|
||||||
(Join-Path $repositoryRoot 'Web\legacy-named-row-workflow.js'),
|
(Join-Path $repositoryRoot 'Web\legacy-named-row-workflow.js'),
|
||||||
(Join-Path $repositoryRoot 'Web\operator-catalog-workflow.js'),
|
(Join-Path $repositoryRoot 'Web\operator-catalog-workflow.js'),
|
||||||
@@ -129,7 +131,11 @@ if ($appScript -notmatch 'normalizeScenePreviewFields' -or
|
|||||||
throw 'Web playout must render bounded authoritative scene data and complete page state.'
|
throw 'Web playout must render bounded authoritative scene data and complete page state.'
|
||||||
}
|
}
|
||||||
if ($appScript -notmatch 'checkbox\.checked = item\.enabled !== false' -or
|
if ($appScript -notmatch 'checkbox\.checked = item\.enabled !== false' -or
|
||||||
$appScript -notmatch 'item\.enabled = checkbox\.checked' -or
|
$appScript -notmatch 'replacePlaylistEntryEnabledAt\(index, checkbox\.checked\)' -or
|
||||||
|
$appScript -match 'item\.enabled\s*=(?!=)' -or
|
||||||
|
$appScript -notmatch 'namedKrxThemeRestoreWorkflow\.withEnabled' -or
|
||||||
|
$appScript -notmatch 'namedNxtThemeRestoreWorkflow\.withEnabled' -or
|
||||||
|
$appScript -notmatch 'namedForeignStockRestoreWorkflow\.withEnabled' -or
|
||||||
$appScript -notmatch 'isPlaylistSnapshotLocked' -or
|
$appScript -notmatch 'isPlaylistSnapshotLocked' -or
|
||||||
$appScript -notmatch 'pending: Boolean\(state\.playout\.pending\)' -or
|
$appScript -notmatch 'pending: Boolean\(state\.playout\.pending\)' -or
|
||||||
$appScript -notmatch 'outcomeUnknown: state\.playout\.sessionQuarantined') {
|
$appScript -notmatch 'outcomeUnknown: state\.playout\.sessionQuarantined') {
|
||||||
@@ -142,6 +148,13 @@ if ($appScript -notmatch 'toggleAllEntriesEnabled' -or
|
|||||||
$appScript -notmatch 'event\.key === "End"') {
|
$appScript -notmatch 'event\.key === "End"') {
|
||||||
throw 'Legacy playlist enabled-all, delete-all, range, Home and End controls are incomplete.'
|
throw 'Legacy playlist enabled-all, delete-all, range, Home and End controls are incomplete.'
|
||||||
}
|
}
|
||||||
|
if ($appScript -match '\bitem\.[A-Za-z_$][A-Za-z0-9_$]*\s*=(?!=)' -or
|
||||||
|
$appScript -notmatch 'playlistEntryManualEditBlockReason' -or
|
||||||
|
$appScript -notmatch 'recreatePlaylistEntryWithSceneSelection' -or
|
||||||
|
$appScript -notmatch 'recreatePlaylistEntryWithLiveSelection' -or
|
||||||
|
$appScript -notmatch 'replacePlaylistEntryAt\(state\.currentIndex, replacement\)') {
|
||||||
|
throw 'Playlist scene and live-row edits must use immutable replacement and protect branded entries.'
|
||||||
|
}
|
||||||
if ($appScript -notmatch 'postNative\("playout-timeout-quarantine", \{ requestId, command \}\)' -or
|
if ($appScript -notmatch 'postNative\("playout-timeout-quarantine", \{ requestId, command \}\)' -or
|
||||||
$appScript -notmatch 'browserCorrelationQuarantined') {
|
$appScript -notmatch 'browserCorrelationQuarantined') {
|
||||||
throw 'Web response timeouts must latch the process-lifetime native quarantine.'
|
throw 'Web response timeouts must latch the process-lifetime native quarantine.'
|
||||||
@@ -163,7 +176,7 @@ if ($indexMarkup -notmatch 'id="globalMa5"' -or
|
|||||||
throw 'The original global 5-day/20-day candle options are not synchronized across every candle workflow.'
|
throw 'The original global 5-day/20-day candle options are not synchronized across every candle workflow.'
|
||||||
}
|
}
|
||||||
foreach ($asset in @(
|
foreach ($asset in @(
|
||||||
'market-nav-reorder.js', 'manual-lists-workflow.js', 'manual-financial-workflow.js', 'named-manual-restore-workflow.js', 'legacy-foreign-index-candle-workflow.js', 'named-nxt-theme-restore-workflow.js', 'legacy-named-row-workflow.js', 'operator-catalog-workflow.js',
|
'market-nav-reorder.js', 'manual-lists-workflow.js', 'manual-financial-workflow.js', 'named-manual-restore-workflow.js', 'named-foreign-stock-restore-workflow.js', 'legacy-foreign-index-candle-workflow.js', 'named-krx-theme-restore-workflow.js', 'named-nxt-theme-restore-workflow.js', 'legacy-named-row-workflow.js', 'operator-catalog-workflow.js',
|
||||||
'manual-lists-ui.js', 'manual-financial-ui.js', 'operator-catalog-ui.js')) {
|
'manual-lists-ui.js', 'manual-financial-ui.js', 'operator-catalog-ui.js')) {
|
||||||
if ($indexMarkup -notmatch [regex]::Escape("<script src=`"$asset`"></script>")) {
|
if ($indexMarkup -notmatch [regex]::Escape("<script src=`"$asset`"></script>")) {
|
||||||
throw "Operator UI script is not loaded: $asset"
|
throw "Operator UI script is not loaded: $asset"
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
public enum LegacyNamedForeignStockRestoreFailure
|
||||||
|
{
|
||||||
|
InvalidRow,
|
||||||
|
IdentityNotFound,
|
||||||
|
IdentityAmbiguous,
|
||||||
|
DatabaseDataInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-sensitive reason token for read-only diagnostics. No database value is
|
||||||
|
/// carried, so aggregate audits can distinguish schema, correlation and symbol
|
||||||
|
/// contract failures without printing an input name or symbol.
|
||||||
|
/// </summary>
|
||||||
|
public enum LegacyNamedForeignStockDatabaseIssue
|
||||||
|
{
|
||||||
|
SchemaOrRowBound,
|
||||||
|
ValueType,
|
||||||
|
InputNameMismatch,
|
||||||
|
InputNameInvalid,
|
||||||
|
SymbolEmpty,
|
||||||
|
SymbolTooLong,
|
||||||
|
SymbolNotCanonical,
|
||||||
|
SymbolDataDelimiter,
|
||||||
|
SymbolCaret,
|
||||||
|
SymbolUnsupportedSlash,
|
||||||
|
SymbolUnsupportedPlus,
|
||||||
|
SymbolUnsupportedAmpersand,
|
||||||
|
SymbolUnsupportedApostrophe,
|
||||||
|
SymbolUnsupportedComma,
|
||||||
|
SymbolUnsupportedParenthesis,
|
||||||
|
SymbolUnsupportedHash,
|
||||||
|
SymbolUnsupportedPercent,
|
||||||
|
SymbolUnsupportedAsterisk,
|
||||||
|
SymbolUnsupportedEquals,
|
||||||
|
SymbolUnsupportedBackslash,
|
||||||
|
SymbolUnsupportedAsciiPunctuation,
|
||||||
|
SymbolNonAsciiFullWidth,
|
||||||
|
SymbolNonAsciiHangul,
|
||||||
|
SymbolNonAsciiCjk,
|
||||||
|
SymbolNonAsciiWhitespace,
|
||||||
|
SymbolNonAsciiLetterOrNumber,
|
||||||
|
SymbolNonAsciiPunctuation,
|
||||||
|
SymbolNonAsciiOther,
|
||||||
|
NationInvalid,
|
||||||
|
ForeignDomesticInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One untrusted seven-field PLAY_LIST row from the original UC5 overseas-stock
|
||||||
|
/// selector. The row has an input name but did not persist its symbol or nation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LegacyNamedForeignStockRestoreRow(
|
||||||
|
int ItemIndex,
|
||||||
|
bool IsEnabled,
|
||||||
|
string GroupCode,
|
||||||
|
string Subject,
|
||||||
|
string GraphicType,
|
||||||
|
string Subtype,
|
||||||
|
string PageText,
|
||||||
|
string DataCode);
|
||||||
|
|
||||||
|
public sealed record LegacyNamedForeignStockRestoreIdentity(
|
||||||
|
string ActionId,
|
||||||
|
string InputName,
|
||||||
|
string Symbol,
|
||||||
|
string NationCode,
|
||||||
|
string ForeignDomesticCode,
|
||||||
|
string BuilderKey,
|
||||||
|
string CutCode,
|
||||||
|
string ValueType,
|
||||||
|
int? PeriodDays);
|
||||||
|
|
||||||
|
public sealed record LegacyNamedForeignStockRestoreOutcome(
|
||||||
|
int ItemIndex,
|
||||||
|
LegacyNamedForeignStockRestoreIdentity? Identity,
|
||||||
|
LegacyNamedForeignStockRestoreFailure? Failure,
|
||||||
|
LegacyNamedForeignStockDatabaseIssue? DatabaseIssue = null)
|
||||||
|
{
|
||||||
|
public bool IsResolved =>
|
||||||
|
Identity is not null && Failure is null && DatabaseIssue is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LegacyNamedForeignStockRestoreResult(
|
||||||
|
DateTimeOffset RetrievedAt,
|
||||||
|
IReadOnlyList<LegacyNamedForeignStockRestoreOutcome> Rows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the original UC5 `해외종목` rows against today's Oracle master.
|
||||||
|
/// Only an exact F_INPUT_NAME that identifies one distinct US/TW symbol is
|
||||||
|
/// accepted. Queries are bound and read-only, and this service never retries.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LegacyNamedForeignStockRestoreService
|
||||||
|
{
|
||||||
|
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
|
||||||
|
public const string QueryName = "LEGACY_NAMED_FOREIGN_STOCK_RESTORE";
|
||||||
|
|
||||||
|
private const string ExpectedGroupCode = "해외종목";
|
||||||
|
private const string ExpectedPageText = "1/1";
|
||||||
|
private const string ForeignDomesticCode = "1";
|
||||||
|
private const string InputNameColumn = "F_INPUT_NAME";
|
||||||
|
private const string SymbolColumn = "F_SYMB";
|
||||||
|
private const string NationCodeColumn = "F_NATC";
|
||||||
|
private const string ForeignDomesticColumn = "F_FDTC";
|
||||||
|
private const int MaximumInputNameLength = 200;
|
||||||
|
private const int MaximumSymbolLength = 64;
|
||||||
|
|
||||||
|
private const string IdentitySql = """
|
||||||
|
SELECT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
|
||||||
|
FROM T_WORLD_IX_EQ_MASTER
|
||||||
|
WHERE F_FDTC = '1'
|
||||||
|
AND F_NATC IN ('US', 'TW')
|
||||||
|
AND F_INPUT_NAME = :input_name
|
||||||
|
ORDER BY F_SYMB, F_NATC
|
||||||
|
)
|
||||||
|
WHERE ROWNUM <= 2
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, ActionProfile> CandleActions =
|
||||||
|
new Dictionary<string, ActionProfile>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["5일"] = new("stock-candle-5d", "s8010", "8061", "PRICE", 5),
|
||||||
|
["20일"] = new("stock-candle-20d", "s8010", "8040", "PRICE", 20),
|
||||||
|
["60일"] = new("stock-candle-60d", "s8010", "8046", "PRICE", 60),
|
||||||
|
["120일"] = new("stock-candle-120d", "s8010", "8051", "PRICE", 120),
|
||||||
|
["240일"] = new("stock-candle-240d", "s8010", "8056", "PRICE", 240)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly ActionProfile CurrentAction =
|
||||||
|
new("stock-current", "s5001", "5001", "CURRENT", PeriodDays: null);
|
||||||
|
|
||||||
|
private readonly IDataQueryExecutor _executor;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public LegacyNamedForeignStockRestoreService(IDataQueryExecutor executor)
|
||||||
|
: this(executor, TimeProvider.System)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal LegacyNamedForeignStockRestoreService(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||||
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes only the exact legacy group and closed UC5 action grammar. The
|
||||||
|
/// canonical FOREIGN_STOCK format is deliberately handled by its existing
|
||||||
|
/// identity-bearing workflow and cannot collide with this restore pass.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsCandidate(LegacyNamedForeignStockRestoreRow? row) =>
|
||||||
|
row is not null &&
|
||||||
|
row.ItemIndex is >= 0 and < MaximumRows &&
|
||||||
|
string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) &&
|
||||||
|
IsCanonicalText(row.Subject, MaximumInputNameLength, allowEmpty: false) &&
|
||||||
|
TryGetAction(row.GraphicType, row.Subtype, out _) &&
|
||||||
|
string.Equals(row.PageText, ExpectedPageText, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(row.DataCode, string.Empty, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public async Task<LegacyNamedForeignStockRestoreResult> ResolveAsync(
|
||||||
|
IReadOnlyList<LegacyNamedForeignStockRestoreRow> rows,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
if (rows.Count is < 1 or > MaximumRows)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(rows),
|
||||||
|
$"A named foreign-stock restore must contain 1 through {MaximumRows} rows.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemIndexes = new HashSet<int>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
|
||||||
|
!itemIndexes.Add(row.ItemIndex))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Named foreign-stock restore rows must have unique bounded item indexes.",
|
||||||
|
nameof(rows));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var identityCache = new Dictionary<string, IdentityResolution>(StringComparer.Ordinal);
|
||||||
|
var outcomes = new List<LegacyNamedForeignStockRestoreOutcome>(rows.Count);
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
outcomes.Add(await ResolveRowAsync(row, identityCache, cancellationToken)
|
||||||
|
.ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyNamedForeignStockRestoreResult(
|
||||||
|
_timeProvider.GetLocalNow(),
|
||||||
|
outcomes.AsReadOnly());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LegacyNamedForeignStockRestoreOutcome> ResolveRowAsync(
|
||||||
|
LegacyNamedForeignStockRestoreRow row,
|
||||||
|
IDictionary<string, IdentityResolution> identityCache,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsCandidate(row) ||
|
||||||
|
!TryGetAction(row.GraphicType, row.Subtype, out var action))
|
||||||
|
{
|
||||||
|
return Failed(row.ItemIndex, LegacyNamedForeignStockRestoreFailure.InvalidRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!identityCache.TryGetValue(row.Subject, out var resolution))
|
||||||
|
{
|
||||||
|
resolution = await ResolveIdentityAsync(row.Subject, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
identityCache.Add(row.Subject, resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.Failure.HasValue)
|
||||||
|
{
|
||||||
|
return Failed(
|
||||||
|
row.ItemIndex,
|
||||||
|
resolution.Failure.Value,
|
||||||
|
resolution.DatabaseIssue);
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = resolution.Identity!;
|
||||||
|
return new LegacyNamedForeignStockRestoreOutcome(
|
||||||
|
row.ItemIndex,
|
||||||
|
new LegacyNamedForeignStockRestoreIdentity(
|
||||||
|
action.ActionId,
|
||||||
|
identity.InputName,
|
||||||
|
identity.Symbol,
|
||||||
|
identity.NationCode,
|
||||||
|
ForeignDomesticCode,
|
||||||
|
action.BuilderKey,
|
||||||
|
action.CutCode,
|
||||||
|
action.ValueType,
|
||||||
|
action.PeriodDays),
|
||||||
|
Failure: null,
|
||||||
|
DatabaseIssue: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IdentityResolution> ResolveIdentityAsync(
|
||||||
|
string inputName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var spec = new DataQuerySpec(
|
||||||
|
IdentitySql,
|
||||||
|
[new DataQueryParameter("input_name", inputName, DbType.String)]);
|
||||||
|
spec.ValidateFor(DataSourceKind.Oracle);
|
||||||
|
|
||||||
|
var table = await _executor.ExecuteAsync(
|
||||||
|
DataSourceKind.Oracle,
|
||||||
|
QueryName,
|
||||||
|
spec,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
return ValidateIdentityTable(table, inputName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IdentityResolution ValidateIdentityTable(DataTable? table, string inputName)
|
||||||
|
{
|
||||||
|
if (table is null || table.Columns.Count != 4 || table.Rows.Count > 2 ||
|
||||||
|
!HasExactStringColumn(table.Columns[0], InputNameColumn) ||
|
||||||
|
!HasExactStringColumn(table.Columns[1], SymbolColumn) ||
|
||||||
|
!HasExactStringColumn(table.Columns[2], NationCodeColumn) ||
|
||||||
|
!HasExactStringColumn(table.Columns[3], ForeignDomesticColumn))
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.SchemaOrRowBound);
|
||||||
|
}
|
||||||
|
|
||||||
|
var identities = new List<WorldStockSearchItem>(table.Rows.Count);
|
||||||
|
foreach (DataRow row in table.Rows)
|
||||||
|
{
|
||||||
|
if (row[0] is not string foundInputName ||
|
||||||
|
row[1] is not string symbol ||
|
||||||
|
row[2] is not string nationCode ||
|
||||||
|
row[3] is not string foreignDomestic)
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.ValueType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(foundInputName, inputName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.InputNameMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCanonicalText(foundInputName, MaximumInputNameLength, allowEmpty: false))
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.InputNameInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolIssue = GetSymbolIssue(symbol);
|
||||||
|
if (symbolIssue.HasValue)
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(symbolIssue.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nationCode is not ("US" or "TW"))
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.NationInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(foreignDomestic, ForeignDomesticCode, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.ForeignDomesticInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
identities.Add(new WorldStockSearchItem(foundInputName, symbol, nationCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
return identities.Count switch
|
||||||
|
{
|
||||||
|
0 => new IdentityResolution(
|
||||||
|
Identity: null,
|
||||||
|
LegacyNamedForeignStockRestoreFailure.IdentityNotFound),
|
||||||
|
1 => new IdentityResolution(identities[0], Failure: null),
|
||||||
|
2 => new IdentityResolution(
|
||||||
|
Identity: null,
|
||||||
|
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous),
|
||||||
|
_ => InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.SchemaOrRowBound)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetAction(
|
||||||
|
string? graphicType,
|
||||||
|
string? subtype,
|
||||||
|
out ActionProfile action)
|
||||||
|
{
|
||||||
|
action = null!;
|
||||||
|
if (string.Equals(graphicType, "1열판기본", StringComparison.Ordinal) &&
|
||||||
|
string.Equals(subtype, string.Empty, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
action = CurrentAction;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(graphicType, "캔들그래프", StringComparison.Ordinal) &&
|
||||||
|
subtype is not null &&
|
||||||
|
CandleActions.TryGetValue(subtype, out action!);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsCanonicalBoundSymbol(string? value) =>
|
||||||
|
value is not null && GetSymbolIssue(value) is null;
|
||||||
|
|
||||||
|
private static LegacyNamedForeignStockDatabaseIssue? GetSymbolIssue(string value)
|
||||||
|
{
|
||||||
|
if (value.Length == 0)
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Length > MaximumSymbolLength)
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolTooLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rune in value.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (rune.Value == '|')
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rune.Value == '^')
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolCaret;
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = Rune.GetUnicodeCategory(rune);
|
||||||
|
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||||
|
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||||
|
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle currently contains a small set of provider symbols with
|
||||||
|
// Hangul letters and internal ASCII spaces. They are safe identity
|
||||||
|
// data: the runtime passes the symbol only as a bound parameter,
|
||||||
|
// canonical validation rejects edge whitespace, and serialized
|
||||||
|
// identity uses '|' (not a letter/number/space) as its delimiter.
|
||||||
|
if (Rune.IsLetterOrDigit(rune))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rune.Value > 0x7F)
|
||||||
|
{
|
||||||
|
if (rune.Value is >= 0xFF01 and <= 0xFF5E)
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiFullWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rune.Value is >= 0xAC00 and <= 0xD7AF or
|
||||||
|
>= 0x1100 and <= 0x11FF or
|
||||||
|
>= 0x3130 and <= 0x318F)
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiHangul;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rune.Value is >= 0x3400 and <= 0x9FFF or
|
||||||
|
>= 0xF900 and <= 0xFAFF)
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiCjk;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Rune.IsWhiteSpace(rune))
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiWhitespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category is UnicodeCategory.ConnectorPunctuation or
|
||||||
|
UnicodeCategory.DashPunctuation or
|
||||||
|
UnicodeCategory.OpenPunctuation or
|
||||||
|
UnicodeCategory.ClosePunctuation or
|
||||||
|
UnicodeCategory.InitialQuotePunctuation or
|
||||||
|
UnicodeCategory.FinalQuotePunctuation or
|
||||||
|
UnicodeCategory.OtherPunctuation)
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiPunctuation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther;
|
||||||
|
}
|
||||||
|
|
||||||
|
var character = (char)rune.Value;
|
||||||
|
if (character == '/')
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedSlash;
|
||||||
|
}
|
||||||
|
|
||||||
|
var punctuationIssue = character switch
|
||||||
|
{
|
||||||
|
'+' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPlus,
|
||||||
|
'&' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAmpersand,
|
||||||
|
'\'' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedApostrophe,
|
||||||
|
',' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedComma,
|
||||||
|
'(' or ')' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedParenthesis,
|
||||||
|
'#' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedHash,
|
||||||
|
'%' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPercent,
|
||||||
|
'*' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsterisk,
|
||||||
|
'=' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedEquals,
|
||||||
|
'\\' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedBackslash,
|
||||||
|
_ => (LegacyNamedForeignStockDatabaseIssue?)null
|
||||||
|
};
|
||||||
|
if (punctuationIssue.HasValue)
|
||||||
|
{
|
||||||
|
return punctuationIssue.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' '))
|
||||||
|
{
|
||||||
|
return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCanonicalText(string? value, int maximumLength, bool allowEmpty)
|
||||||
|
{
|
||||||
|
if (value is null || value.Length > maximumLength ||
|
||||||
|
(!allowEmpty && value.Length == 0) ||
|
||||||
|
!string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rune in value.EnumerateRunes())
|
||||||
|
{
|
||||||
|
var category = Rune.GetUnicodeCategory(rune);
|
||||||
|
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||||
|
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||||
|
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !value.Contains('^');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasExactStringColumn(DataColumn column, string name) =>
|
||||||
|
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
|
||||||
|
column.DataType == typeof(string);
|
||||||
|
|
||||||
|
private static LegacyNamedForeignStockRestoreOutcome Failed(
|
||||||
|
int itemIndex,
|
||||||
|
LegacyNamedForeignStockRestoreFailure failure,
|
||||||
|
LegacyNamedForeignStockDatabaseIssue? databaseIssue = null) =>
|
||||||
|
new(itemIndex, Identity: null, failure, databaseIssue);
|
||||||
|
|
||||||
|
private static IdentityResolution InvalidDatabaseData(
|
||||||
|
LegacyNamedForeignStockDatabaseIssue issue) =>
|
||||||
|
new(
|
||||||
|
Identity: null,
|
||||||
|
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid,
|
||||||
|
issue);
|
||||||
|
|
||||||
|
private sealed record ActionProfile(
|
||||||
|
string ActionId,
|
||||||
|
string BuilderKey,
|
||||||
|
string CutCode,
|
||||||
|
string ValueType,
|
||||||
|
int? PeriodDays);
|
||||||
|
|
||||||
|
private sealed record IdentityResolution(
|
||||||
|
WorldStockSearchItem? Identity,
|
||||||
|
LegacyNamedForeignStockRestoreFailure? Failure,
|
||||||
|
LegacyNamedForeignStockDatabaseIssue? DatabaseIssue = null);
|
||||||
|
}
|
||||||
487
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs
Normal file
487
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
public enum LegacyNamedKrxThemeRestoreFailure
|
||||||
|
{
|
||||||
|
InvalidRow,
|
||||||
|
UnsupportedAction,
|
||||||
|
IdentityNotFound,
|
||||||
|
IdentityAmbiguous,
|
||||||
|
PreviewEmpty,
|
||||||
|
DatabaseDataInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One untrusted seven-field PLAY_LIST row routed to the KRX-theme verifier.
|
||||||
|
/// Every original field remains evidence; a successful restore uses the
|
||||||
|
/// current Oracle identity for scene selection.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LegacyNamedKrxThemeRestoreRow(
|
||||||
|
int ItemIndex,
|
||||||
|
bool IsEnabled,
|
||||||
|
string GroupCode,
|
||||||
|
string Subject,
|
||||||
|
string GraphicType,
|
||||||
|
string Subtype,
|
||||||
|
string PageText,
|
||||||
|
string DataCode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A closed current KRX identity. StoredThemeCode is evidence only and must
|
||||||
|
/// never be used by PREPARE after this identity has been resolved.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LegacyNamedKrxThemeRestoreIdentity(
|
||||||
|
string ActionId,
|
||||||
|
string BuilderKey,
|
||||||
|
string CutCode,
|
||||||
|
int PageSize,
|
||||||
|
string Sort,
|
||||||
|
string ThemeTitle,
|
||||||
|
string StoredThemeCode,
|
||||||
|
string CurrentThemeCode,
|
||||||
|
int PreviewItemCount,
|
||||||
|
bool PreviewIsTruncated)
|
||||||
|
{
|
||||||
|
public bool CodeWasRemapped =>
|
||||||
|
!string.Equals(StoredThemeCode, CurrentThemeCode, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LegacyNamedKrxThemeRestoreOutcome(
|
||||||
|
int ItemIndex,
|
||||||
|
LegacyNamedKrxThemeRestoreIdentity? Identity,
|
||||||
|
LegacyNamedKrxThemeRestoreFailure? Failure,
|
||||||
|
string? ObservedCurrentThemeCode = null)
|
||||||
|
{
|
||||||
|
public bool IsResolved => Identity is not null && Failure is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LegacyNamedKrxThemeRestoreResult(
|
||||||
|
DateTimeOffset RetrievedAt,
|
||||||
|
IReadOnlyList<LegacyNamedKrxThemeRestoreOutcome> Rows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rehydrates legacy `테마` rows through exact, bound Oracle reads. The old
|
||||||
|
/// s5074/s5077/s5088 constructors looked the code up by exact title immediately
|
||||||
|
/// before loading data. This service closes that lookup to one title and one
|
||||||
|
/// code, validates the selected items through LegacyThemeSelectionService, and
|
||||||
|
/// retains the stale stored code solely for lossless PLAY_LIST persistence.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class LegacyNamedKrxThemeRestoreService
|
||||||
|
{
|
||||||
|
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
|
||||||
|
public const string IdentityQueryName = "LEGACY_NAMED_KRX_THEME_IDENTITY";
|
||||||
|
|
||||||
|
private const string ExpectedGroupCode = "테마";
|
||||||
|
private const int MaximumTitleLength = 128;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, SubtypeProfile> Subtypes =
|
||||||
|
new Dictionary<string, SubtypeProfile>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["테마-현재가(입력순)"] = new("CURRENT", "INPUT_ORDER"),
|
||||||
|
["테마-현재가(상승률순)"] = new("CURRENT", "GAIN_DESC"),
|
||||||
|
["테마-현재가(하락률순)"] = new("CURRENT", "GAIN_ASC"),
|
||||||
|
// All three original scene constructors fall through to their
|
||||||
|
// final loss-rate branch when no parenthesized token is present.
|
||||||
|
["테마-현재가"] = new("CURRENT", "GAIN_ASC"),
|
||||||
|
["테마-예상체결가(입력순)"] = new("EXPECTED", "INPUT_ORDER"),
|
||||||
|
["테마-예상체결가(상승률순)"] = new("EXPECTED", "GAIN_DESC"),
|
||||||
|
["테마-예상체결가(하락률순)"] = new("EXPECTED", "GAIN_ASC"),
|
||||||
|
["테마-예상체결가"] = new("EXPECTED", "GAIN_ASC")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, ActionProfile> Actions =
|
||||||
|
new Dictionary<string, ActionProfile>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[ActionKey("5단 표그래프", "CURRENT")] =
|
||||||
|
new("five-row-current", "s5074", "5074", 5),
|
||||||
|
[ActionKey("5단 표그래프", "EXPECTED")] =
|
||||||
|
new("five-row-expected", "s5074", "5074", 5),
|
||||||
|
[ActionKey("6종목 현재가", "CURRENT")] =
|
||||||
|
new("six-row-current", "s5077", "5077", 6),
|
||||||
|
[ActionKey("12종목 현재가", "CURRENT")] =
|
||||||
|
new("twelve-row-current", "s5088", "5088", 12)
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string IdentitySql = """
|
||||||
|
SELECT THEME_TITLE, THEME_CODE, THEME_MARKET, CODE_IDENTITY_COUNT
|
||||||
|
FROM (
|
||||||
|
SELECT s.SB_TITLE THEME_TITLE,
|
||||||
|
s.SB_CODE THEME_CODE,
|
||||||
|
s.SB_MARKET THEME_MARKET,
|
||||||
|
TO_CHAR((SELECT COUNT(*)
|
||||||
|
FROM SB_LIST d
|
||||||
|
WHERE d.SB_MARKET = 'KRX'
|
||||||
|
AND d.SB_CODE = s.SB_CODE), 'FM999999990')
|
||||||
|
CODE_IDENTITY_COUNT
|
||||||
|
FROM SB_LIST s
|
||||||
|
WHERE s.SB_MARKET = 'KRX'
|
||||||
|
AND s.SB_TITLE = :theme_title
|
||||||
|
ORDER BY s.SB_CODE
|
||||||
|
)
|
||||||
|
WHERE ROWNUM <= 2
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static readonly string[] IdentityColumns =
|
||||||
|
["THEME_TITLE", "THEME_CODE", "THEME_MARKET", "CODE_IDENTITY_COUNT"];
|
||||||
|
|
||||||
|
private readonly IDataQueryExecutor _executor;
|
||||||
|
private readonly LegacyThemeSelectionService _themeSelectionService;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public LegacyNamedKrxThemeRestoreService(IDataQueryExecutor executor)
|
||||||
|
: this(executor, TimeProvider.System)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal LegacyNamedKrxThemeRestoreService(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||||
|
_themeSelectionService = new LegacyThemeSelectionService(_executor);
|
||||||
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes every exact `테마` group row, including malformed grammar, so
|
||||||
|
/// native verification returns an explicit closed result rather than
|
||||||
|
/// allowing the stale code through the generic mapper.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsCandidate(LegacyNamedKrxThemeRestoreRow? row) =>
|
||||||
|
row is not null &&
|
||||||
|
row.ItemIndex is >= 0 and < MaximumRows &&
|
||||||
|
string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public async Task<LegacyNamedKrxThemeRestoreResult> ResolveAsync(
|
||||||
|
IReadOnlyList<LegacyNamedKrxThemeRestoreRow> rows,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
if (rows.Count is < 1 or > MaximumRows)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(rows),
|
||||||
|
$"A KRX-theme restore must contain 1 through {MaximumRows} rows.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexes = new HashSet<int>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
|
||||||
|
!indexes.Add(row.ItemIndex))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"KRX-theme restore rows must have unique bounded item indexes.",
|
||||||
|
nameof(rows));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifications = new Dictionary<string, ThemeVerification>(StringComparer.Ordinal);
|
||||||
|
var outcomes = new List<LegacyNamedKrxThemeRestoreOutcome>(rows.Count);
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
outcomes.Add(await ResolveRowAsync(row, verifications, cancellationToken)
|
||||||
|
.ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyNamedKrxThemeRestoreResult(
|
||||||
|
_timeProvider.GetLocalNow(),
|
||||||
|
outcomes.AsReadOnly());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LegacyNamedKrxThemeRestoreOutcome> ResolveRowAsync(
|
||||||
|
LegacyNamedKrxThemeRestoreRow row,
|
||||||
|
IDictionary<string, ThemeVerification> verifications,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var classification = Classify(row);
|
||||||
|
if (classification.Failure.HasValue)
|
||||||
|
{
|
||||||
|
return Failed(row.ItemIndex, classification.Failure.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = classification.Title!;
|
||||||
|
if (!verifications.TryGetValue(title, out var verification))
|
||||||
|
{
|
||||||
|
verification = await VerifyThemeAsync(title, cancellationToken).ConfigureAwait(false);
|
||||||
|
verifications.Add(title, verification);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verification.Failure.HasValue)
|
||||||
|
{
|
||||||
|
return Failed(
|
||||||
|
row.ItemIndex,
|
||||||
|
verification.Failure.Value,
|
||||||
|
verification.CurrentThemeCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = classification.Action!;
|
||||||
|
return new LegacyNamedKrxThemeRestoreOutcome(
|
||||||
|
row.ItemIndex,
|
||||||
|
new LegacyNamedKrxThemeRestoreIdentity(
|
||||||
|
profile.ActionId,
|
||||||
|
profile.BuilderKey,
|
||||||
|
profile.CutCode,
|
||||||
|
profile.PageSize,
|
||||||
|
classification.Sort!,
|
||||||
|
title,
|
||||||
|
row.DataCode,
|
||||||
|
verification.CurrentThemeCode!,
|
||||||
|
verification.PreviewItemCount,
|
||||||
|
verification.PreviewIsTruncated),
|
||||||
|
Failure: null,
|
||||||
|
ObservedCurrentThemeCode: verification.CurrentThemeCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RowClassification Classify(LegacyNamedKrxThemeRestoreRow row)
|
||||||
|
{
|
||||||
|
if (!string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) ||
|
||||||
|
!IsSafeCanonicalText(row.Subject, MaximumTitleLength) ||
|
||||||
|
row.Subject.Contains("(NXT)", StringComparison.Ordinal) ||
|
||||||
|
!StoredThemeCodePattern().IsMatch(row.DataCode ?? string.Empty) ||
|
||||||
|
!IsValidStoredPage(row.PageText))
|
||||||
|
{
|
||||||
|
return new RowClassification(Failure: LegacyNamedKrxThemeRestoreFailure.InvalidRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Subtypes.TryGetValue(row.Subtype ?? string.Empty, out var subtype))
|
||||||
|
{
|
||||||
|
return new RowClassification(
|
||||||
|
Failure: LegacyNamedKrxThemeRestoreFailure.UnsupportedAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Actions.TryGetValue(
|
||||||
|
ActionKey(row.GraphicType ?? string.Empty, subtype.ValueKind),
|
||||||
|
out var action))
|
||||||
|
{
|
||||||
|
return new RowClassification(
|
||||||
|
Failure: LegacyNamedKrxThemeRestoreFailure.UnsupportedAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RowClassification(row.Subject, action, subtype.Sort, Failure: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ThemeVerification> VerifyThemeAsync(
|
||||||
|
string title,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var spec = new DataQuerySpec(
|
||||||
|
IdentitySql,
|
||||||
|
[new DataQueryParameter("theme_title", title, DbType.String)]);
|
||||||
|
spec.ValidateFor(DataSourceKind.Oracle);
|
||||||
|
var table = await _executor.ExecuteAsync(
|
||||||
|
DataSourceKind.Oracle,
|
||||||
|
IdentityQueryName,
|
||||||
|
spec,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var identity = ValidateIdentityTable(table, title);
|
||||||
|
if (identity.Failure.HasValue)
|
||||||
|
{
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedIdentity = new ThemeSelectionIdentity(
|
||||||
|
ThemeMarket.Krx,
|
||||||
|
ThemeSession.NotApplicable,
|
||||||
|
identity.CurrentThemeCode!,
|
||||||
|
title);
|
||||||
|
ThemePreviewResult preview;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
preview = await _themeSelectionService.PreviewAsync(
|
||||||
|
selectedIdentity,
|
||||||
|
LegacyThemeSelectionService.MaximumPreviewItems,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (ThemeSelectionDataException)
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(identity.CurrentThemeCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Equals(preview.Identity, selectedIdentity) ||
|
||||||
|
preview.Items.Count > LegacyThemeSelectionService.MaximumPreviewItems ||
|
||||||
|
preview.IsTruncated &&
|
||||||
|
preview.Items.Count != LegacyThemeSelectionService.MaximumPreviewItems)
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData(identity.CurrentThemeCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return identity with
|
||||||
|
{
|
||||||
|
Failure = LegacyNamedKrxThemeRestoreFailure.PreviewEmpty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity with
|
||||||
|
{
|
||||||
|
PreviewItemCount = preview.Items.Count,
|
||||||
|
PreviewIsTruncated = preview.IsTruncated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ThemeVerification ValidateIdentityTable(DataTable? table, string title)
|
||||||
|
{
|
||||||
|
if (table is null || table.Columns.Count != IdentityColumns.Length ||
|
||||||
|
table.Rows.Count > 2)
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < IdentityColumns.Length; index++)
|
||||||
|
{
|
||||||
|
if (!string.Equals(
|
||||||
|
table.Columns[index].ColumnName,
|
||||||
|
IdentityColumns[index],
|
||||||
|
StringComparison.Ordinal) ||
|
||||||
|
table.Columns[index].DataType != typeof(string))
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table.Rows.Count == 0)
|
||||||
|
{
|
||||||
|
return new ThemeVerification(
|
||||||
|
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table.Rows.Count > 1)
|
||||||
|
{
|
||||||
|
return new ThemeVerification(
|
||||||
|
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous);
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = table.Rows[0];
|
||||||
|
if (row[0] is not string actualTitle ||
|
||||||
|
row[1] is not string code ||
|
||||||
|
row[2] is not string market ||
|
||||||
|
!string.Equals(actualTitle, title, StringComparison.Ordinal) ||
|
||||||
|
!string.Equals(market, "KRX", StringComparison.Ordinal) ||
|
||||||
|
!StoredThemeCodePattern().IsMatch(code) ||
|
||||||
|
!TryReadCount(row[3], out var codeIdentityCount))
|
||||||
|
{
|
||||||
|
return InvalidDatabaseData();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codeIdentityCount != 1)
|
||||||
|
{
|
||||||
|
return new ThemeVerification(
|
||||||
|
CurrentThemeCode: code,
|
||||||
|
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ThemeVerification(CurrentThemeCode: code, Failure: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadCount(object value, out int result)
|
||||||
|
{
|
||||||
|
result = 0;
|
||||||
|
return value is string text &&
|
||||||
|
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out result) &&
|
||||||
|
result is >= 0 and <= 1_000_000 &&
|
||||||
|
string.Equals(
|
||||||
|
text,
|
||||||
|
result.ToString(CultureInfo.InvariantCulture),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidStoredPage(string value)
|
||||||
|
{
|
||||||
|
var match = StoredPagePattern().Match(value ?? string.Empty);
|
||||||
|
if (!match.Success ||
|
||||||
|
!int.TryParse(
|
||||||
|
match.Groups[1].Value,
|
||||||
|
NumberStyles.None,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out var current) ||
|
||||||
|
!int.TryParse(
|
||||||
|
match.Groups[2].Value,
|
||||||
|
NumberStyles.None,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out var total))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current is >= 1 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount &&
|
||||||
|
total is >= 0 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount &&
|
||||||
|
(total == 0 ? current == 1 : current <= total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSafeCanonicalText(string? value, int maximumLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value) || value.Length > maximumLength ||
|
||||||
|
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
|
||||||
|
value.Contains('^'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rune in value.EnumerateRunes())
|
||||||
|
{
|
||||||
|
var category = Rune.GetUnicodeCategory(rune);
|
||||||
|
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||||
|
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||||
|
UnicodeCategory.LineSeparator or
|
||||||
|
UnicodeCategory.ParagraphSeparator)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ActionKey(string graphicType, string valueKind) =>
|
||||||
|
string.Concat(graphicType, "\u001F", valueKind);
|
||||||
|
|
||||||
|
private static LegacyNamedKrxThemeRestoreOutcome Failed(
|
||||||
|
int itemIndex,
|
||||||
|
LegacyNamedKrxThemeRestoreFailure failure,
|
||||||
|
string? observedCurrentThemeCode = null) =>
|
||||||
|
new(itemIndex, Identity: null, failure, observedCurrentThemeCode);
|
||||||
|
|
||||||
|
private static ThemeVerification InvalidDatabaseData(string? currentThemeCode = null) =>
|
||||||
|
new(
|
||||||
|
CurrentThemeCode: currentThemeCode,
|
||||||
|
Failure: LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid);
|
||||||
|
|
||||||
|
private sealed record ActionProfile(
|
||||||
|
string ActionId,
|
||||||
|
string BuilderKey,
|
||||||
|
string CutCode,
|
||||||
|
int PageSize);
|
||||||
|
|
||||||
|
private sealed record SubtypeProfile(string ValueKind, string Sort);
|
||||||
|
|
||||||
|
private sealed record RowClassification(
|
||||||
|
string? Title = null,
|
||||||
|
ActionProfile? Action = null,
|
||||||
|
string? Sort = null,
|
||||||
|
LegacyNamedKrxThemeRestoreFailure? Failure = null);
|
||||||
|
|
||||||
|
private sealed record ThemeVerification(
|
||||||
|
string? CurrentThemeCode = null,
|
||||||
|
int PreviewItemCount = 0,
|
||||||
|
bool PreviewIsTruncated = false,
|
||||||
|
LegacyNamedKrxThemeRestoreFailure? Failure = null);
|
||||||
|
|
||||||
|
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex StoredThemeCodePattern();
|
||||||
|
|
||||||
|
[GeneratedRegex("^([1-9][0-9]{0,3})/([0-9]{1,4})$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex StoredPagePattern();
|
||||||
|
}
|
||||||
511
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs
Normal file
511
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||||
|
|
||||||
|
namespace MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closed kinds understood by the named-playlist manual restore workflow. The
|
||||||
|
/// candidate deliberately has a non-data-bearing ToString implementation so an
|
||||||
|
/// audit failure cannot accidentally print an operator stock name or VI list.
|
||||||
|
/// </summary>
|
||||||
|
public enum LegacyNamedManualRestoreKind
|
||||||
|
{
|
||||||
|
Financial,
|
||||||
|
NetSell,
|
||||||
|
Vi
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LegacyNamedManualFreshProofFailure
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
FinancialSnapshotChanged,
|
||||||
|
FinancialStockSearchTruncated,
|
||||||
|
FinancialStockIdentityNotUnique,
|
||||||
|
FinancialSelectionMismatch,
|
||||||
|
NetSellSnapshotInvalid,
|
||||||
|
ViSnapshotInvalid,
|
||||||
|
ViSnapshotVersionChanged,
|
||||||
|
ViHistoricalNamesChanged,
|
||||||
|
ViPageCountChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LegacyNamedManualRestoreCandidate
|
||||||
|
{
|
||||||
|
internal LegacyNamedManualRestoreCandidate(
|
||||||
|
int itemIndex,
|
||||||
|
LegacyNamedManualRestoreKind kind,
|
||||||
|
bool isHistorical,
|
||||||
|
ManualFinancialScreenKind? financialScreen = null,
|
||||||
|
StockMarket? market = null,
|
||||||
|
string? stockName = null,
|
||||||
|
S5025ManualAudience? audience = null,
|
||||||
|
IReadOnlyList<string>? historicalViNames = null,
|
||||||
|
string? expectedViVersion = null,
|
||||||
|
int expectedPageCount = 1)
|
||||||
|
{
|
||||||
|
ItemIndex = itemIndex;
|
||||||
|
Kind = kind;
|
||||||
|
IsHistorical = isHistorical;
|
||||||
|
FinancialScreen = financialScreen;
|
||||||
|
Market = market;
|
||||||
|
StockName = stockName;
|
||||||
|
Audience = audience;
|
||||||
|
HistoricalViNames = historicalViNames;
|
||||||
|
ExpectedViVersion = expectedViVersion;
|
||||||
|
ExpectedPageCount = expectedPageCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ItemIndex { get; }
|
||||||
|
|
||||||
|
public LegacyNamedManualRestoreKind Kind { get; }
|
||||||
|
|
||||||
|
public bool IsHistorical { get; }
|
||||||
|
|
||||||
|
public ManualFinancialScreenKind? FinancialScreen { get; }
|
||||||
|
|
||||||
|
public StockMarket? Market { get; }
|
||||||
|
|
||||||
|
public string? StockName { get; }
|
||||||
|
|
||||||
|
public S5025ManualAudience? Audience { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string>? HistoricalViNames { get; }
|
||||||
|
|
||||||
|
public string? ExpectedViVersion { get; }
|
||||||
|
|
||||||
|
public int ExpectedPageCount { get; }
|
||||||
|
|
||||||
|
public override string ToString() => $"Named manual restore candidate ({Kind})";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C# equivalent of Web/named-manual-restore-workflow.js classify(). It accepts
|
||||||
|
/// only the historical seven-field PList grammar and the canonical grammar
|
||||||
|
/// emitted after a fresh materialization. It performs no I/O.
|
||||||
|
/// </summary>
|
||||||
|
public static class LegacyNamedManualRestoreClassifier
|
||||||
|
{
|
||||||
|
private const int ViPageSize = 5;
|
||||||
|
private const int MaximumViItems = 100;
|
||||||
|
|
||||||
|
public static LegacyNamedManualRestoreCandidate? Classify(LegacyPListAuditRow row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
return ClassifyNetSell(row) ?? ClassifyVi(row) ?? ClassifyFinancial(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyNamedManualRestoreCandidate? ClassifyNetSell(
|
||||||
|
LegacyPListAuditRow row)
|
||||||
|
{
|
||||||
|
if (row.PageTextForDisplay != "1/1" || row.DataCode.Length != 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryCanonicalAudience(row.GroupCode, out var canonical) &&
|
||||||
|
row.Subject == "INDEX" &&
|
||||||
|
row.GraphicType == "MANUAL_NET_SELL" &&
|
||||||
|
row.Subtype == "MANUAL_NET_SELL")
|
||||||
|
{
|
||||||
|
return new LegacyNamedManualRestoreCandidate(
|
||||||
|
row.ItemIndex,
|
||||||
|
LegacyNamedManualRestoreKind.NetSell,
|
||||||
|
isHistorical: false,
|
||||||
|
audience: canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryHistoricalAudience(row.GroupCode, out var historical) &&
|
||||||
|
row.Subject == "지수" &&
|
||||||
|
row.GraphicType == "순매도 상위" &&
|
||||||
|
row.Subtype == "순매도 상위")
|
||||||
|
{
|
||||||
|
return new LegacyNamedManualRestoreCandidate(
|
||||||
|
row.ItemIndex,
|
||||||
|
LegacyNamedManualRestoreKind.NetSell,
|
||||||
|
isHistorical: true,
|
||||||
|
audience: historical);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyNamedManualRestoreCandidate? ClassifyVi(
|
||||||
|
LegacyPListAuditRow row)
|
||||||
|
{
|
||||||
|
if (!TryPageCount(row.PageTextForDisplay, out var pageCount))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.GroupCode == "PAGED_VI" &&
|
||||||
|
row.Subject == TrustedViManualReference.SubjectSentinel &&
|
||||||
|
row.GraphicType == "INPUT_ORDER" &&
|
||||||
|
row.Subtype == "CURRENT" &&
|
||||||
|
TrustedViManualReference.IsRowVersion(row.DataCode))
|
||||||
|
{
|
||||||
|
return new LegacyNamedManualRestoreCandidate(
|
||||||
|
row.ItemIndex,
|
||||||
|
LegacyNamedManualRestoreKind.Vi,
|
||||||
|
isHistorical: false,
|
||||||
|
expectedViVersion: row.DataCode,
|
||||||
|
expectedPageCount: pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.GroupCode.Length != 0 ||
|
||||||
|
row.GraphicType != "5단 표그래프" ||
|
||||||
|
row.Subtype != "VI 발동(수동)" ||
|
||||||
|
row.DataCode.Length != 0 ||
|
||||||
|
!TryHistoricalViNames(row.Subject, out var names) ||
|
||||||
|
pageCount != DivideRoundUp(names.Count, ViPageSize))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyNamedManualRestoreCandidate(
|
||||||
|
row.ItemIndex,
|
||||||
|
LegacyNamedManualRestoreKind.Vi,
|
||||||
|
isHistorical: true,
|
||||||
|
historicalViNames: names,
|
||||||
|
expectedPageCount: pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyNamedManualRestoreCandidate? ClassifyFinancial(
|
||||||
|
LegacyPListAuditRow row)
|
||||||
|
{
|
||||||
|
if (row.PageTextForDisplay != "1/1" ||
|
||||||
|
row.Subtype.Length != 0 ||
|
||||||
|
row.DataCode.Length != 0 ||
|
||||||
|
!IsTrimmedVisible(row.Subject, 200))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonicalMarket = TryCanonicalMarket(row.GroupCode, out var canonical);
|
||||||
|
var historicalMarket = TryHistoricalMarket(row.GroupCode, out var historical);
|
||||||
|
foreach (var contract in ManualFinancialCutContracts.All)
|
||||||
|
{
|
||||||
|
if (canonicalMarket && row.GraphicType == contract.CanonicalGraphicType)
|
||||||
|
{
|
||||||
|
return Financial(row, contract.Screen, canonical, isHistorical: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historicalMarket && row.GraphicType == contract.OriginalGraphicType)
|
||||||
|
{
|
||||||
|
return Financial(row, contract.Screen, historical, isHistorical: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyNamedManualRestoreCandidate Financial(
|
||||||
|
LegacyPListAuditRow row,
|
||||||
|
ManualFinancialScreenKind screen,
|
||||||
|
StockMarket market,
|
||||||
|
bool isHistorical) =>
|
||||||
|
new(
|
||||||
|
row.ItemIndex,
|
||||||
|
LegacyNamedManualRestoreKind.Financial,
|
||||||
|
isHistorical,
|
||||||
|
financialScreen: screen,
|
||||||
|
market: market,
|
||||||
|
stockName: row.Subject);
|
||||||
|
|
||||||
|
private static bool TryCanonicalAudience(
|
||||||
|
string value,
|
||||||
|
out S5025ManualAudience audience)
|
||||||
|
{
|
||||||
|
audience = value switch
|
||||||
|
{
|
||||||
|
"INDIVIDUAL" => S5025ManualAudience.Individual,
|
||||||
|
"FOREIGN" => S5025ManualAudience.Foreign,
|
||||||
|
"INSTITUTION" => S5025ManualAudience.Institution,
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
return value is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryHistoricalAudience(
|
||||||
|
string value,
|
||||||
|
out S5025ManualAudience audience)
|
||||||
|
{
|
||||||
|
audience = value switch
|
||||||
|
{
|
||||||
|
"개인 순매도 상위(수동)" => S5025ManualAudience.Individual,
|
||||||
|
"외국인 순매도 상위(수동)" => S5025ManualAudience.Foreign,
|
||||||
|
"기관 순매도 상위(수동)" => S5025ManualAudience.Institution,
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
return value is
|
||||||
|
"개인 순매도 상위(수동)" or
|
||||||
|
"외국인 순매도 상위(수동)" or
|
||||||
|
"기관 순매도 상위(수동)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCanonicalMarket(string value, out StockMarket market)
|
||||||
|
{
|
||||||
|
market = value switch
|
||||||
|
{
|
||||||
|
"KOSPI" => StockMarket.Kospi,
|
||||||
|
"KOSDAQ" => StockMarket.Kosdaq,
|
||||||
|
"NXT_KOSPI" => StockMarket.NxtKospi,
|
||||||
|
"NXT_KOSDAQ" => StockMarket.NxtKosdaq,
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
return value is "KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryHistoricalMarket(string value, out StockMarket market)
|
||||||
|
{
|
||||||
|
market = value switch
|
||||||
|
{
|
||||||
|
"코스피" => StockMarket.Kospi,
|
||||||
|
"코스닥" => StockMarket.Kosdaq,
|
||||||
|
"코스피_NXT" => StockMarket.NxtKospi,
|
||||||
|
"코스닥_NXT" => StockMarket.NxtKosdaq,
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
return value is "코스피" or "코스닥" or "코스피_NXT" or "코스닥_NXT";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryPageCount(string value, out int pageCount)
|
||||||
|
{
|
||||||
|
pageCount = 0;
|
||||||
|
if (!value.StartsWith("1/", StringComparison.Ordinal) ||
|
||||||
|
!int.TryParse(value.AsSpan(2), out var parsed) ||
|
||||||
|
parsed is < 1 or > 20 ||
|
||||||
|
value != $"1/{parsed}")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageCount = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryHistoricalViNames(
|
||||||
|
string value,
|
||||||
|
out IReadOnlyList<string> names)
|
||||||
|
{
|
||||||
|
names = Array.Empty<string>();
|
||||||
|
if (!IsTrimmedVisible(value, LegacyPListReadAuditParser.MaximumListTextLength))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = value.Split(',', StringSplitOptions.None);
|
||||||
|
if (values.Length is < 1 or > MaximumViItems ||
|
||||||
|
values.Any(name => !IsTrimmedVisible(name, 200)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
names = Array.AsReadOnly(values);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTrimmedVisible(string value, int maximumLength) =>
|
||||||
|
value.Length is > 0 &&
|
||||||
|
value.Length <= maximumLength &&
|
||||||
|
value == value.Trim() &&
|
||||||
|
!value.Any(char.IsControl) &&
|
||||||
|
!value.Contains('\uFFFD');
|
||||||
|
|
||||||
|
private static int DivideRoundUp(int value, int divisor) =>
|
||||||
|
checked((value + divisor - 1) / divisor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure checks for the evidence gathered through production read boundaries.
|
||||||
|
/// No method opens a database or file and no result contains source text.
|
||||||
|
/// </summary>
|
||||||
|
public static class LegacyNamedManualFreshProof
|
||||||
|
{
|
||||||
|
private const int ViPageSize = 5;
|
||||||
|
|
||||||
|
public static string FinancialStockSearchQuery(
|
||||||
|
LegacyNamedManualRestoreCandidate candidate)
|
||||||
|
{
|
||||||
|
RequireKind(candidate, LegacyNamedManualRestoreKind.Financial);
|
||||||
|
var stockName = candidate.StockName!;
|
||||||
|
if (candidate.Market is not (StockMarket.NxtKospi or StockMarket.NxtKosdaq))
|
||||||
|
{
|
||||||
|
return stockName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string suffix = "(NXT)";
|
||||||
|
if (!stockName.EndsWith(suffix, StringComparison.Ordinal) ||
|
||||||
|
stockName.Length == suffix.Length)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"An NXT manual-financial identity requires its original display suffix.",
|
||||||
|
nameof(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = stockName[..^suffix.Length];
|
||||||
|
if (query.Length == 0 || query != query.Trim() || query.Contains(suffix))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"An NXT manual-financial identity has an invalid search identity.",
|
||||||
|
nameof(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LegacyNamedManualFreshProofFailure EvaluateFinancial(
|
||||||
|
LegacyNamedManualRestoreCandidate candidate,
|
||||||
|
ManualFinancialSnapshot firstRead,
|
||||||
|
ManualFinancialSnapshot secondRead,
|
||||||
|
StockSearchResult stockSearch)
|
||||||
|
{
|
||||||
|
RequireKind(candidate, LegacyNamedManualRestoreKind.Financial);
|
||||||
|
ArgumentNullException.ThrowIfNull(firstRead);
|
||||||
|
ArgumentNullException.ThrowIfNull(secondRead);
|
||||||
|
ArgumentNullException.ThrowIfNull(stockSearch);
|
||||||
|
|
||||||
|
var identity = new ManualFinancialIdentity(
|
||||||
|
candidate.FinancialScreen!.Value,
|
||||||
|
candidate.StockName!);
|
||||||
|
if (firstRead.Record.Identity != identity ||
|
||||||
|
secondRead.Record.Identity != identity ||
|
||||||
|
!string.Equals(firstRead.RowVersion, secondRead.RowVersion, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.FinancialSnapshotChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stockSearch.IsTruncated ||
|
||||||
|
!string.Equals(
|
||||||
|
stockSearch.Query,
|
||||||
|
FinancialStockSearchQuery(candidate),
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.FinancialStockSearchTruncated;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches = stockSearch.Items
|
||||||
|
.Where(item =>
|
||||||
|
item.Market == candidate.Market &&
|
||||||
|
string.Equals(item.Name, candidate.StockName, StringComparison.Ordinal))
|
||||||
|
.ToArray();
|
||||||
|
if (matches.Length != 1)
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var verified = ManualFinancialCutContracts.VerifyStock(
|
||||||
|
identity,
|
||||||
|
stockSearch.Items,
|
||||||
|
candidate.Market!.Value,
|
||||||
|
matches[0].Code);
|
||||||
|
var selection = ManualFinancialCutContracts.CreateSelection(secondRead, verified);
|
||||||
|
var contract = ManualFinancialCutContracts.Get(identity.Screen);
|
||||||
|
if (selection.BuilderKey != contract.BuilderKey ||
|
||||||
|
selection.CutCode != contract.CutCode ||
|
||||||
|
selection.CurrentPage != 1 ||
|
||||||
|
selection.TotalPages != 1 ||
|
||||||
|
selection.Selection.GroupCode != CanonicalMarket(candidate.Market.Value) ||
|
||||||
|
selection.Selection.Subject != candidate.StockName ||
|
||||||
|
selection.Selection.GraphicType != contract.CanonicalGraphicType ||
|
||||||
|
selection.Selection.Subtype.Length != 0 ||
|
||||||
|
selection.Selection.DataCode.Length != 0)
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.FinancialSelectionMismatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LegacyNamedManualFreshProofFailure.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LegacyNamedManualFreshProofFailure EvaluateNetSell(
|
||||||
|
LegacyNamedManualRestoreCandidate candidate,
|
||||||
|
IReadOnlyList<S5025TrustedManualRow> rows)
|
||||||
|
{
|
||||||
|
RequireKind(candidate, LegacyNamedManualRestoreKind.NetSell);
|
||||||
|
if (rows is null || rows.Count != 5 || rows.Any(row =>
|
||||||
|
row is null ||
|
||||||
|
!ValidManualCell(row.LeftName) ||
|
||||||
|
!ValidManualCell(row.LeftAmount) ||
|
||||||
|
!ValidManualCell(row.RightName) ||
|
||||||
|
!ValidManualCell(row.RightAmount)))
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.NetSellSnapshotInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LegacyNamedManualFreshProofFailure.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LegacyNamedManualFreshProofFailure EvaluateVi(
|
||||||
|
LegacyNamedManualRestoreCandidate candidate,
|
||||||
|
TrustedViStockNameSnapshot snapshot)
|
||||||
|
{
|
||||||
|
RequireKind(candidate, LegacyNamedManualRestoreKind.Vi);
|
||||||
|
if (snapshot is null ||
|
||||||
|
!TrustedViManualReference.IsRowVersion(snapshot.RowVersion) ||
|
||||||
|
snapshot.StockNames is null ||
|
||||||
|
snapshot.StockNames.Count is < 1 or > TrustedViManualReference.MaximumItemCount ||
|
||||||
|
snapshot.StockNames.Any(name =>
|
||||||
|
name is null ||
|
||||||
|
name.Length is < 1 or > 200 ||
|
||||||
|
name != name.Trim() ||
|
||||||
|
name.Contains(',') ||
|
||||||
|
name.Any(char.IsControl)))
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.ViSnapshotInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.ExpectedViVersion is not null &&
|
||||||
|
!string.Equals(
|
||||||
|
candidate.ExpectedViVersion,
|
||||||
|
snapshot.RowVersion,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.ViSnapshotVersionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.HistoricalViNames is not null &&
|
||||||
|
!candidate.HistoricalViNames.SequenceEqual(
|
||||||
|
snapshot.StockNames,
|
||||||
|
StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
return LegacyNamedManualFreshProofFailure.ViHistoricalNamesChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageCount = checked((snapshot.StockNames.Count + ViPageSize - 1) / ViPageSize);
|
||||||
|
return pageCount == candidate.ExpectedPageCount
|
||||||
|
? LegacyNamedManualFreshProofFailure.None
|
||||||
|
: LegacyNamedManualFreshProofFailure.ViPageCountChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireKind(
|
||||||
|
LegacyNamedManualRestoreCandidate candidate,
|
||||||
|
LegacyNamedManualRestoreKind expected)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(candidate);
|
||||||
|
if (candidate.Kind != expected)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The manual restore candidate kind is invalid.", nameof(candidate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValidManualCell(string value) =>
|
||||||
|
value is not null &&
|
||||||
|
value.Length <= 256 &&
|
||||||
|
!value.Any(char.IsControl);
|
||||||
|
|
||||||
|
private static string CanonicalMarket(StockMarket market) => market switch
|
||||||
|
{
|
||||||
|
StockMarket.Kospi => "KOSPI",
|
||||||
|
StockMarket.Kosdaq => "KOSDAQ",
|
||||||
|
StockMarket.NxtKospi => "NXT_KOSPI",
|
||||||
|
StockMarket.NxtKosdaq => "NXT_KOSDAQ",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(market))
|
||||||
|
};
|
||||||
|
}
|
||||||
341
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs
Normal file
341
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A closed pair of current and persisted NXT theme codes used only by the
|
||||||
|
/// read-only restore diagnostic. Titles and stock identities never cross this
|
||||||
|
/// boundary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LegacyNxtThemeJoinAuditIdentity(
|
||||||
|
string CurrentThemeCode,
|
||||||
|
string StoredThemeCode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate evidence for one current theme code. No theme or stock identifier
|
||||||
|
/// is returned, so callers can safely log only cardinalities.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LegacyNxtThemeJoinAuditEvidence(
|
||||||
|
int InputIndex,
|
||||||
|
int RawCurrentItemCount,
|
||||||
|
int RawStoredItemCount,
|
||||||
|
int CurrentOriginalJoinCount,
|
||||||
|
int CurrentNonStoppedJoinCount,
|
||||||
|
int CurrentLiveJoinCount,
|
||||||
|
int StoredOriginalJoinCount,
|
||||||
|
int StoredNonStoppedJoinCount,
|
||||||
|
int StoredLiveJoinCount,
|
||||||
|
int CurrentExactJoinCount,
|
||||||
|
int CurrentStripBothJoinCount,
|
||||||
|
int CurrentRightSixJoinCount,
|
||||||
|
int CurrentValidItemCodeCount,
|
||||||
|
int CurrentKospiMasterJoinCount,
|
||||||
|
int CurrentKosdaqMasterJoinCount,
|
||||||
|
int CurrentKospiOnlineJoinCount,
|
||||||
|
int CurrentKosdaqOnlineJoinCount);
|
||||||
|
|
||||||
|
public sealed record LegacyNxtThemeJoinAuditResult(
|
||||||
|
DateTimeOffset RetrievedAt,
|
||||||
|
IReadOnlyList<LegacyNxtThemeJoinAuditEvidence> Themes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes only bounded aggregate MariaDB reads. The original s5074, s5077,
|
||||||
|
/// and s5088 NXT loaders all use SUBSTRING(SB_I_CODE, 2, 12) = F_STOCK_CODE;
|
||||||
|
/// the other joins are diagnostic evidence only and are never used to resolve
|
||||||
|
/// or play a scene.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class LegacyNxtThemeJoinAuditService
|
||||||
|
{
|
||||||
|
public const int MaximumThemes = 100;
|
||||||
|
public const string QueryName = "LEGACY_NXT_THEME_JOIN_AUDIT";
|
||||||
|
|
||||||
|
private static readonly string[] Columns =
|
||||||
|
[
|
||||||
|
"RAW_CURRENT_ITEM_COUNT",
|
||||||
|
"RAW_STORED_ITEM_COUNT",
|
||||||
|
"CURRENT_ORIGINAL_JOIN_COUNT",
|
||||||
|
"CURRENT_NON_STOPPED_JOIN_COUNT",
|
||||||
|
"CURRENT_LIVE_JOIN_COUNT",
|
||||||
|
"STORED_ORIGINAL_JOIN_COUNT",
|
||||||
|
"STORED_NON_STOPPED_JOIN_COUNT",
|
||||||
|
"STORED_LIVE_JOIN_COUNT",
|
||||||
|
"CURRENT_EXACT_JOIN_COUNT",
|
||||||
|
"CURRENT_STRIP_BOTH_JOIN_COUNT",
|
||||||
|
"CURRENT_RIGHT_SIX_JOIN_COUNT",
|
||||||
|
"CURRENT_VALID_ITEM_CODE_COUNT",
|
||||||
|
"CURRENT_KOSPI_MASTER_JOIN_COUNT",
|
||||||
|
"CURRENT_KOSDAQ_MASTER_JOIN_COUNT",
|
||||||
|
"CURRENT_KOSPI_ONLINE_JOIN_COUNT",
|
||||||
|
"CURRENT_KOSDAQ_ONLINE_JOIN_COUNT"
|
||||||
|
];
|
||||||
|
|
||||||
|
private const string Sql = """
|
||||||
|
SELECT
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||||
|
RAW_CURRENT_ITEM_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE) AS CHAR)
|
||||||
|
RAW_STORED_ITEM_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||||
|
CURRENT_ORIGINAL_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||||
|
AND q.F_STOP_GUBUN = 'N') AS CHAR)
|
||||||
|
CURRENT_NON_STOPPED_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||||
|
AND q.F_STOP_GUBUN = 'N'
|
||||||
|
AND q.F_CURR_PRICE != 0) AS CHAR)
|
||||||
|
CURRENT_LIVE_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE) AS CHAR)
|
||||||
|
STORED_ORIGINAL_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE
|
||||||
|
AND q.F_STOP_GUBUN = 'N') AS CHAR)
|
||||||
|
STORED_NON_STOPPED_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE
|
||||||
|
AND q.F_STOP_GUBUN = 'N'
|
||||||
|
AND q.F_CURR_PRICE != 0) AS CHAR)
|
||||||
|
STORED_LIVE_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON i.SB_I_CODE = q.F_STOCK_CODE
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||||
|
CURRENT_EXACT_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON SUBSTRING(i.SB_I_CODE, 2, 12) =
|
||||||
|
SUBSTRING(q.F_STOCK_CODE, 2, 12)
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||||
|
CURRENT_STRIP_BOTH_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
JOIN v_all_stock q
|
||||||
|
ON RIGHT(i.SB_I_CODE, 6) = RIGHT(q.F_STOCK_CODE, 6)
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||||
|
CURRENT_RIGHT_SIX_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||||
|
AND i.SB_I_CODE REGEXP BINARY '^[XT][A-Za-z0-9]{1,12}$') AS CHAR)
|
||||||
|
CURRENT_VALID_ITEM_CODE_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM N_STOCK q
|
||||||
|
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
)) AS CHAR)
|
||||||
|
CURRENT_KOSPI_MASTER_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM N_KOSDAQ_STOCK q
|
||||||
|
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
)) AS CHAR)
|
||||||
|
CURRENT_KOSDAQ_MASTER_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM N_ONLINE q
|
||||||
|
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
)) AS CHAR)
|
||||||
|
CURRENT_KOSPI_ONLINE_JOIN_COUNT,
|
||||||
|
CAST((SELECT COUNT(*)
|
||||||
|
FROM SB_ITEM i
|
||||||
|
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM N_KOSDAQ_ONLINE q
|
||||||
|
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||||
|
)) AS CHAR)
|
||||||
|
CURRENT_KOSDAQ_ONLINE_JOIN_COUNT
|
||||||
|
FROM (
|
||||||
|
SELECT @current_theme_code CURRENT_THEME_CODE,
|
||||||
|
@stored_theme_code STORED_THEME_CODE
|
||||||
|
) p
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly IDataQueryExecutor _executor;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public LegacyNxtThemeJoinAuditService(IDataQueryExecutor executor)
|
||||||
|
: this(executor, TimeProvider.System)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal LegacyNxtThemeJoinAuditService(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||||
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LegacyNxtThemeJoinAuditResult> AuditAsync(
|
||||||
|
IReadOnlyList<LegacyNxtThemeJoinAuditIdentity> identities,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(identities);
|
||||||
|
if (identities.Count is < 1 or > MaximumThemes)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(identities),
|
||||||
|
$"A join audit must contain 1 through {MaximumThemes} themes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentCodes = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var identity in identities)
|
||||||
|
{
|
||||||
|
var current = identity?.CurrentThemeCode;
|
||||||
|
var stored = identity?.StoredThemeCode;
|
||||||
|
if (!ThemeCodePattern().IsMatch(current ?? string.Empty) ||
|
||||||
|
!ThemeCodePattern().IsMatch(stored ?? string.Empty) ||
|
||||||
|
!currentCodes.Add(current!))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"NXT join audit identities must have unique canonical current codes.",
|
||||||
|
nameof(identities));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var evidence = new List<LegacyNxtThemeJoinAuditEvidence>(identities.Count);
|
||||||
|
for (var index = 0; index < identities.Count; index++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var identity = identities[index];
|
||||||
|
var spec = new DataQuerySpec(
|
||||||
|
Sql,
|
||||||
|
[
|
||||||
|
new DataQueryParameter(
|
||||||
|
"current_theme_code",
|
||||||
|
identity.CurrentThemeCode,
|
||||||
|
DbType.String),
|
||||||
|
new DataQueryParameter(
|
||||||
|
"stored_theme_code",
|
||||||
|
identity.StoredThemeCode,
|
||||||
|
DbType.String)
|
||||||
|
]);
|
||||||
|
spec.ValidateFor(DataSourceKind.MariaDb);
|
||||||
|
var table = await _executor.ExecuteAsync(
|
||||||
|
DataSourceKind.MariaDb,
|
||||||
|
QueryName,
|
||||||
|
spec,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
evidence.Add(MapEvidence(table, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyNxtThemeJoinAuditResult(
|
||||||
|
_timeProvider.GetLocalNow(),
|
||||||
|
evidence.AsReadOnly());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyNxtThemeJoinAuditEvidence MapEvidence(
|
||||||
|
DataTable? table,
|
||||||
|
int inputIndex)
|
||||||
|
{
|
||||||
|
if (table is null || table.Columns.Count != Columns.Length || table.Rows.Count != 1)
|
||||||
|
{
|
||||||
|
throw InvalidData();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < Columns.Length; index++)
|
||||||
|
{
|
||||||
|
if (!string.Equals(
|
||||||
|
table.Columns[index].ColumnName,
|
||||||
|
Columns[index],
|
||||||
|
StringComparison.Ordinal) ||
|
||||||
|
table.Columns[index].DataType != typeof(string))
|
||||||
|
{
|
||||||
|
throw InvalidData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var counts = table.Rows[0].ItemArray.Select(ReadCount).ToArray();
|
||||||
|
if (counts[2] > counts[0] || counts[3] > counts[2] || counts[4] > counts[3] ||
|
||||||
|
counts[5] > counts[1] || counts[6] > counts[5] || counts[7] > counts[6] ||
|
||||||
|
counts[8] > counts[0] || counts[9] > counts[0] || counts[10] > counts[0] ||
|
||||||
|
counts[11] > counts[0] || counts[12] > counts[0] || counts[13] > counts[0] ||
|
||||||
|
counts[14] > counts[0] || counts[15] > counts[0] ||
|
||||||
|
counts[12] + counts[13] > counts[0] ||
|
||||||
|
counts[14] + counts[15] > counts[0])
|
||||||
|
{
|
||||||
|
throw InvalidData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyNxtThemeJoinAuditEvidence(
|
||||||
|
inputIndex,
|
||||||
|
counts[0],
|
||||||
|
counts[1],
|
||||||
|
counts[2],
|
||||||
|
counts[3],
|
||||||
|
counts[4],
|
||||||
|
counts[5],
|
||||||
|
counts[6],
|
||||||
|
counts[7],
|
||||||
|
counts[8],
|
||||||
|
counts[9],
|
||||||
|
counts[10],
|
||||||
|
counts[11],
|
||||||
|
counts[12],
|
||||||
|
counts[13],
|
||||||
|
counts[14],
|
||||||
|
counts[15]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadCount(object? value)
|
||||||
|
{
|
||||||
|
if (value is not string text ||
|
||||||
|
!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var count) ||
|
||||||
|
count is < 0 or > 1_000_000 ||
|
||||||
|
!string.Equals(text, count.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw InvalidData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InvalidDataException InvalidData() =>
|
||||||
|
new("The NXT theme join audit returned invalid aggregate data.");
|
||||||
|
|
||||||
|
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex ThemeCodePattern();
|
||||||
|
}
|
||||||
524
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs
Normal file
524
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The exact seven values written by the original PList.data_save method.
|
||||||
|
/// RawListText and Fields are retained so a read-only audit can prove that
|
||||||
|
/// parsing did not normalize any operator text. PageTextForDisplay is the one
|
||||||
|
/// original load-time transformation: ASCII spaces are removed from field 5.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LegacyPListAuditRow(
|
||||||
|
string ProgramCode,
|
||||||
|
string ProgramTitle,
|
||||||
|
int ItemIndex,
|
||||||
|
bool IsEnabled,
|
||||||
|
IReadOnlyList<string> Fields,
|
||||||
|
string RawListText,
|
||||||
|
string PageTextForDisplay)
|
||||||
|
{
|
||||||
|
public string GroupCode => Fields[1];
|
||||||
|
|
||||||
|
public string Subject => Fields[2];
|
||||||
|
|
||||||
|
public string GraphicType => Fields[3];
|
||||||
|
|
||||||
|
public string Subtype => Fields[4];
|
||||||
|
|
||||||
|
public string RawPageText => Fields[5];
|
||||||
|
|
||||||
|
public string DataCode => Fields[6];
|
||||||
|
|
||||||
|
public bool IsLossless =>
|
||||||
|
string.Equals(string.Join('^', Fields), RawListText, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LegacyPListAuditParseFailure
|
||||||
|
{
|
||||||
|
InvalidProgramCode,
|
||||||
|
InvalidProgramTitle,
|
||||||
|
InvalidItemIndex,
|
||||||
|
InvalidListText,
|
||||||
|
InvalidFieldCount,
|
||||||
|
InvalidEnabledField,
|
||||||
|
UnsafeText,
|
||||||
|
ReplacementCharacter,
|
||||||
|
MojibakeSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LegacyPListAuditRoute
|
||||||
|
{
|
||||||
|
ExistingClosedMapper,
|
||||||
|
IndustryFixedClosedMapper,
|
||||||
|
ComparisonFreshIdentity,
|
||||||
|
ForeignIndexCandleFreshIdentity,
|
||||||
|
NxtThemeFreshIdentity,
|
||||||
|
KrxThemeFreshIdentity,
|
||||||
|
ForeignStockFreshIdentity,
|
||||||
|
ManualFreshMaterialization,
|
||||||
|
Unmapped
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LegacyPListAuditParseResult(
|
||||||
|
LegacyPListAuditRow? Row,
|
||||||
|
LegacyPListAuditParseFailure? Failure)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => Row is not null && Failure is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser used only for the read-only production-data audit. It intentionally
|
||||||
|
/// accepts the original PList grammar before any current scene mapping is
|
||||||
|
/// attempted and never includes source text in exception or diagnostic output.
|
||||||
|
/// </summary>
|
||||||
|
public static class LegacyPListReadAuditParser
|
||||||
|
{
|
||||||
|
public const int MaximumProgramTitleLength = 128;
|
||||||
|
public const int MaximumListTextLength = 4_000;
|
||||||
|
public const int MaximumItems = LegacyNamedPlaylistPersistenceService.MaximumItems;
|
||||||
|
|
||||||
|
public static LegacyPListAuditParseResult Parse(
|
||||||
|
string? programCode,
|
||||||
|
string? programTitle,
|
||||||
|
string? itemIndex,
|
||||||
|
string? listText)
|
||||||
|
{
|
||||||
|
if (programCode is null || programCode.Length != 8 ||
|
||||||
|
programCode.Any(static value => !char.IsAsciiDigit(value)))
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.InvalidProgramCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCanonicalText(programTitle, MaximumProgramTitleLength, allowEmpty: false))
|
||||||
|
{
|
||||||
|
return Failed(programTitle?.Contains('\uFFFD') == true
|
||||||
|
? LegacyPListAuditParseFailure.ReplacementCharacter
|
||||||
|
: ContainsMojibakeSignal(programTitle)
|
||||||
|
? LegacyPListAuditParseFailure.MojibakeSignal
|
||||||
|
: LegacyPListAuditParseFailure.InvalidProgramTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemIndex is null ||
|
||||||
|
!int.TryParse(
|
||||||
|
itemIndex,
|
||||||
|
NumberStyles.None,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out var parsedIndex) ||
|
||||||
|
parsedIndex is < 0 or >= MaximumItems ||
|
||||||
|
!string.Equals(
|
||||||
|
itemIndex,
|
||||||
|
parsedIndex.ToString(CultureInfo.InvariantCulture),
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.InvalidItemIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listText is null || listText.Length is < 1 or > MaximumListTextLength)
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.InvalidListText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listText.Contains('\uFFFD'))
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.ReplacementCharacter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsMojibakeSignal(listText))
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.MojibakeSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsSafeText(listText))
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.UnsafeText);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields = listText.Split('^', StringSplitOptions.None);
|
||||||
|
if (fields.Length != 7)
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.InvalidFieldCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields[0] is not ("0" or "1"))
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.InvalidEnabledField);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A caret cannot occur inside a field after an exact seven-way split.
|
||||||
|
// Retain empty strings and every code unit exactly as the original row.
|
||||||
|
if (fields.Any(static field => !IsSafeText(field)))
|
||||||
|
{
|
||||||
|
return Failed(LegacyPListAuditParseFailure.UnsafeText);
|
||||||
|
}
|
||||||
|
|
||||||
|
var frozenFields = Array.AsReadOnly(fields);
|
||||||
|
var row = new LegacyPListAuditRow(
|
||||||
|
programCode,
|
||||||
|
programTitle!,
|
||||||
|
parsedIndex,
|
||||||
|
fields[0] == "1",
|
||||||
|
frozenFields,
|
||||||
|
listText,
|
||||||
|
fields[5].Replace(" ", string.Empty, StringComparison.Ordinal));
|
||||||
|
return row.IsLossless
|
||||||
|
? new LegacyPListAuditParseResult(row, Failure: null)
|
||||||
|
: Failed(LegacyPListAuditParseFailure.InvalidListText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ContainsMojibakeSignal(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// U+FFFD is reported separately. These code points and byte-order
|
||||||
|
// marker are reliable signs of UTF-8/legacy-codepage damage in the
|
||||||
|
// Korean operator data. Literal question marks are not rejected because
|
||||||
|
// they are valid operator text and are not proof of corruption alone.
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
if (character is '\u00C2' or '\u00C3' or '\u00EC' or '\u00ED' or
|
||||||
|
'\u00EA' or '\u00EB' or '\uFEFF')
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Contains("â€", StringComparison.Ordinal) ||
|
||||||
|
value.Contains("", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyPListAuditParseResult Failed(
|
||||||
|
LegacyPListAuditParseFailure failure) =>
|
||||||
|
new(Row: null, failure);
|
||||||
|
|
||||||
|
private static bool IsCanonicalText(
|
||||||
|
string? value,
|
||||||
|
int maximumLength,
|
||||||
|
bool allowEmpty)
|
||||||
|
{
|
||||||
|
if (value is null || value.Length > maximumLength ||
|
||||||
|
(!allowEmpty && value.Length == 0) ||
|
||||||
|
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
|
||||||
|
value.Contains('\uFFFD') || ContainsMojibakeSignal(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsSafeText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSafeText(string value)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < value.Length; index++)
|
||||||
|
{
|
||||||
|
if (char.IsHighSurrogate(value[index]))
|
||||||
|
{
|
||||||
|
if (index + 1 >= value.Length || !char.IsLowSurrogate(value[index + 1]))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
else if (char.IsLowSurrogate(value[index]))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rune in value.EnumerateRunes())
|
||||||
|
{
|
||||||
|
var category = Rune.GetUnicodeCategory(rune);
|
||||||
|
if (Rune.IsControl(rune) || category is
|
||||||
|
UnicodeCategory.Format or
|
||||||
|
UnicodeCategory.Surrogate or
|
||||||
|
UnicodeCategory.LineSeparator or
|
||||||
|
UnicodeCategory.ParagraphSeparator)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Side-effect-free evidence checks shared by the database smoke audit and its
|
||||||
|
/// fixtures. This is deliberately a route classifier, not a resolver: routes
|
||||||
|
/// that require current database or trusted-file evidence remain unplayable
|
||||||
|
/// until the corresponding service reports a unique materialization result.
|
||||||
|
/// </summary>
|
||||||
|
public static class LegacyPListReadAuditEvidence
|
||||||
|
{
|
||||||
|
public static bool HasContiguousIndexes(IReadOnlyList<LegacyPListAuditRow> rows)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
for (var index = 0; index < rows.Count; index++)
|
||||||
|
{
|
||||||
|
if (rows[index] is null || rows[index].ItemIndex != index)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MatchesPersistenceResult(
|
||||||
|
LegacyPListAuditRow raw,
|
||||||
|
NamedPlaylistStoredItem stored)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(raw);
|
||||||
|
ArgumentNullException.ThrowIfNull(stored);
|
||||||
|
return raw.ItemIndex == stored.ItemIndex &&
|
||||||
|
raw.IsEnabled == stored.IsEnabled &&
|
||||||
|
string.Equals(raw.GroupCode, stored.Selection.GroupCode, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(raw.Subject, stored.Selection.Subject, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(raw.GraphicType, stored.Selection.GraphicType, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(raw.Subtype, stored.Selection.Subtype, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(raw.DataCode, stored.Selection.DataCode, StringComparison.Ordinal) &&
|
||||||
|
string.Equals(
|
||||||
|
raw.PageTextForDisplay,
|
||||||
|
stored.Page?.ToString() ?? string.Empty,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LegacyPListAuditRoute ClassifyRoute(LegacyPListAuditRow row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
var comparison = ToComparisonRow(row);
|
||||||
|
if (LegacyNamedComparisonRestoreService.IsCandidate(comparison))
|
||||||
|
{
|
||||||
|
return LegacyPListAuditRoute.ComparisonFreshIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
var foreignIndex = ToForeignIndexCandleRow(row);
|
||||||
|
if (LegacyForeignIndexCandleRestoreService.IsCandidate(foreignIndex))
|
||||||
|
{
|
||||||
|
return LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nxt = ToNxtThemeRow(row);
|
||||||
|
if (LegacyNamedNxtThemeRestoreService.IsCandidate(nxt))
|
||||||
|
{
|
||||||
|
return LegacyPListAuditRoute.NxtThemeFreshIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsIndustryFixed(row))
|
||||||
|
{
|
||||||
|
return LegacyPListAuditRoute.IndustryFixedClosedMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(row.GroupCode, "테마", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return LegacyPListAuditRoute.KrxThemeFreshIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.GroupCode == "해외종목")
|
||||||
|
{
|
||||||
|
return LegacyPListAuditRoute.ForeignStockFreshIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The identity-bearing canonical format is already handled by the
|
||||||
|
// current overseas workflow. Only its one unambiguous current-price
|
||||||
|
// action is a closed seven-field mapping; the five candle periods share
|
||||||
|
// the same selection fields and therefore remain deliberately unmapped.
|
||||||
|
if (row.GroupCode == "FOREIGN_STOCK")
|
||||||
|
{
|
||||||
|
return IsCanonicalForeignStockCurrent(row)
|
||||||
|
? LegacyPListAuditRoute.ExistingClosedMapper
|
||||||
|
: LegacyPListAuditRoute.Unmapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsManualCandidate(row))
|
||||||
|
{
|
||||||
|
return LegacyPListAuditRoute.ManualFreshMaterialization;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsExistingClosedMapper(row)
|
||||||
|
? LegacyPListAuditRoute.ExistingClosedMapper
|
||||||
|
: LegacyPListAuditRoute.Unmapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LegacyNamedComparisonRestoreRow ToComparisonRow(LegacyPListAuditRow row) =>
|
||||||
|
new(
|
||||||
|
row.ItemIndex,
|
||||||
|
row.IsEnabled,
|
||||||
|
row.GroupCode,
|
||||||
|
row.Subject,
|
||||||
|
row.GraphicType,
|
||||||
|
row.Subtype,
|
||||||
|
row.PageTextForDisplay,
|
||||||
|
row.DataCode);
|
||||||
|
|
||||||
|
public static LegacyForeignIndexCandleRestoreRow ToForeignIndexCandleRow(
|
||||||
|
LegacyPListAuditRow row) =>
|
||||||
|
new(
|
||||||
|
row.ItemIndex,
|
||||||
|
row.IsEnabled,
|
||||||
|
row.GroupCode,
|
||||||
|
row.Subject,
|
||||||
|
row.GraphicType,
|
||||||
|
row.Subtype,
|
||||||
|
row.PageTextForDisplay,
|
||||||
|
row.DataCode);
|
||||||
|
|
||||||
|
public static LegacyNamedNxtThemeRestoreRow ToNxtThemeRow(LegacyPListAuditRow row) =>
|
||||||
|
new(
|
||||||
|
row.ItemIndex,
|
||||||
|
row.IsEnabled,
|
||||||
|
row.GroupCode,
|
||||||
|
row.Subject,
|
||||||
|
row.GraphicType,
|
||||||
|
row.Subtype,
|
||||||
|
row.PageTextForDisplay,
|
||||||
|
row.DataCode);
|
||||||
|
|
||||||
|
private static bool IsIndustryFixed(LegacyPListAuditRow row)
|
||||||
|
{
|
||||||
|
if (row.Subtype.Length != 0 || row.DataCode.Length != 0 ||
|
||||||
|
row.GraphicType is not ("5단 표그래프" or "네모그래프" or "섹터지수"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.GroupCode switch
|
||||||
|
{
|
||||||
|
"업종_코스피" => string.Equals(row.Subject, "코스피", StringComparison.Ordinal),
|
||||||
|
"업종_코스닥" => string.Equals(row.Subject, "코스닥", StringComparison.Ordinal),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsManualCandidate(LegacyPListAuditRow row) =>
|
||||||
|
LegacyNamedManualRestoreClassifier.Classify(row) is not null;
|
||||||
|
|
||||||
|
private static bool IsCanonicalForeignStockCurrent(LegacyPListAuditRow row)
|
||||||
|
{
|
||||||
|
if (row.PageTextForDisplay != "1/1" ||
|
||||||
|
row.Subject.Length is < 1 or > 200 ||
|
||||||
|
row.Subject != row.Subject.Trim() ||
|
||||||
|
row.Subject.Contains('|') ||
|
||||||
|
row.GraphicType != "1열판기본" ||
|
||||||
|
row.Subtype != "CURRENT")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = row.DataCode.Split('|', StringSplitOptions.None);
|
||||||
|
return parts.Length == 3 &&
|
||||||
|
LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(parts[0]) &&
|
||||||
|
parts[1] is "US" or "TW" &&
|
||||||
|
parts[2] == "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsExistingClosedMapper(LegacyPListAuditRow row) =>
|
||||||
|
IsLegacyStock(row) || IsLegacyExpert(row) || IsCanonicalDefault(row);
|
||||||
|
|
||||||
|
private static bool IsLegacyStock(LegacyPListAuditRow row)
|
||||||
|
{
|
||||||
|
if (row.PageTextForDisplay != "1/1" || row.Subject.Length is < 1 or > 128 ||
|
||||||
|
row.DataCode.Length is < 1 or > 32 ||
|
||||||
|
row.DataCode.Any(static value => !char.IsAsciiLetterOrDigit(value)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNxt = row.GroupCode is "코스피_NXT" or "코스닥_NXT";
|
||||||
|
if (row.GroupCode is not ("코스피" or "코스닥" or "코스피_NXT" or "코스닥_NXT"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = row.Subtype.Length == 0
|
||||||
|
? row.GraphicType
|
||||||
|
: string.Concat(row.GraphicType, "_", row.Subtype);
|
||||||
|
if (isNxt)
|
||||||
|
{
|
||||||
|
return label == "1열판기본_현재가";
|
||||||
|
}
|
||||||
|
|
||||||
|
return StockLegacyLabels.Contains(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLegacyExpert(LegacyPListAuditRow row) =>
|
||||||
|
row.PageTextForDisplay.StartsWith("1/", StringComparison.Ordinal) &&
|
||||||
|
row.GroupCode == "전문가 추천" && row.Subject.Length is > 0 and <= 128 &&
|
||||||
|
row.GraphicType == "5단 표그래프" && row.Subtype == "현재가" &&
|
||||||
|
row.DataCode.Length == 4 && row.DataCode.All(char.IsAsciiDigit);
|
||||||
|
|
||||||
|
private static bool IsCanonicalDefault(LegacyPListAuditRow row) =>
|
||||||
|
CanonicalDefaults.Contains(new SelectionKey(
|
||||||
|
row.GroupCode,
|
||||||
|
row.Subject,
|
||||||
|
row.GraphicType,
|
||||||
|
row.Subtype,
|
||||||
|
row.DataCode));
|
||||||
|
|
||||||
|
private static readonly HashSet<string> StockLegacyLabels = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"1열판기본_예상체결가",
|
||||||
|
"1열판기본_현재가",
|
||||||
|
"1열판기본_시간외단일가",
|
||||||
|
"1열판상세_시가",
|
||||||
|
"1열판상세_액면가",
|
||||||
|
"1열판상세_PBR",
|
||||||
|
"1열판상세_거래량",
|
||||||
|
"수익률그래프_5일",
|
||||||
|
"수익률그래프_20일",
|
||||||
|
"수익률그래프_60일",
|
||||||
|
"수익률그래프_120일",
|
||||||
|
"수익률그래프_240일",
|
||||||
|
"캔들그래프_일봉",
|
||||||
|
"캔들그래프_5일",
|
||||||
|
"캔들그래프_20일",
|
||||||
|
"캔들그래프_60일",
|
||||||
|
"캔들그래프_120일",
|
||||||
|
"캔들그래프_240일",
|
||||||
|
"캔들그래프(거래량)_일봉",
|
||||||
|
"캔들그래프(거래량)_5일",
|
||||||
|
"캔들그래프(거래량)_20일",
|
||||||
|
"캔들그래프(거래량)_60일",
|
||||||
|
"캔들그래프(거래량)_120일",
|
||||||
|
"캔들그래프(거래량)_240일",
|
||||||
|
"캔들그래프(예상체결가)_5일",
|
||||||
|
"캔들그래프(예상체결가)_20일",
|
||||||
|
"캔들그래프(예상체결가)_60일",
|
||||||
|
"캔들그래프(예상체결가)_120일",
|
||||||
|
"캔들그래프(예상체결가)_240일",
|
||||||
|
"호가창_표그래프",
|
||||||
|
"거래원_표그래프"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<SelectionKey> CanonicalDefaults = new()
|
||||||
|
{
|
||||||
|
new("ALL", "INDEX", string.Empty, "UnitedStatesIndices", string.Empty),
|
||||||
|
new("ALL", "INDEX", string.Empty, "Cotton", string.Empty),
|
||||||
|
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", string.Empty),
|
||||||
|
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", string.Empty),
|
||||||
|
new("INDEX", "FUTURES,Kospi", string.Empty, "CURRENT", string.Empty),
|
||||||
|
new("FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", string.Empty, string.Empty),
|
||||||
|
new("ALL", "INDEX", string.Empty, string.Empty, string.Empty),
|
||||||
|
new("ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", string.Empty),
|
||||||
|
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", string.Empty),
|
||||||
|
new("ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", string.Empty),
|
||||||
|
new("EXCHANGE_RATE", "원달러", string.Empty, "20일", string.Empty),
|
||||||
|
new("FOREIGN_INDEX", "DOW", string.Empty, "DowJones", string.Empty),
|
||||||
|
new("ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", string.Empty),
|
||||||
|
new("ALL", "KOSPI", string.Empty, string.Empty, string.Empty),
|
||||||
|
new("INDEX", "Kospi,Kosdaq", string.Empty, "CURRENT", string.Empty)
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly record struct SelectionKey(
|
||||||
|
string GroupCode,
|
||||||
|
string Subject,
|
||||||
|
string GraphicType,
|
||||||
|
string Subtype,
|
||||||
|
string DataCode);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace MMoneyCoderSharp.Data;
|
namespace MMoneyCoderSharp.Data;
|
||||||
@@ -459,13 +460,11 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
|
|||||||
|
|
||||||
private static bool IsSafeText(string value)
|
private static bool IsSafeText(string value)
|
||||||
{
|
{
|
||||||
for (var index = 0; index < value.Length; index++)
|
foreach (var rune in value.EnumerateRunes())
|
||||||
{
|
{
|
||||||
var character = value[index];
|
var category = Rune.GetUnicodeCategory(rune);
|
||||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||||
character == '\uFFFD' ||
|
|
||||||
category is UnicodeCategory.Format or
|
|
||||||
UnicodeCategory.LineSeparator or
|
UnicodeCategory.LineSeparator or
|
||||||
UnicodeCategory.ParagraphSeparator)
|
UnicodeCategory.ParagraphSeparator)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyNamedForeignStockRestoreServiceTests
|
||||||
|
{
|
||||||
|
public static TheoryData<string, string, string, string, int?> Actions =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
{ "1열판기본", "", "stock-current", "5001", null },
|
||||||
|
{ "캔들그래프", "5일", "stock-candle-5d", "8061", 5 },
|
||||||
|
{ "캔들그래프", "20일", "stock-candle-20d", "8040", 20 },
|
||||||
|
{ "캔들그래프", "60일", "stock-candle-60d", "8046", 60 },
|
||||||
|
{ "캔들그래프", "120일", "stock-candle-120d", "8051", 120 },
|
||||||
|
{ "캔들그래프", "240일", "stock-candle-240d", "8056", 240 }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Actions))]
|
||||||
|
public async Task ResolveAsync_MapsEveryClosedUc5ActionAfterExactBoundIdentity(
|
||||||
|
string graphicType,
|
||||||
|
string subtype,
|
||||||
|
string actionId,
|
||||||
|
string cutCode,
|
||||||
|
int? periodDays)
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(call =>
|
||||||
|
{
|
||||||
|
Assert.Equal(DataSourceKind.Oracle, call.Source);
|
||||||
|
Assert.Equal(LegacyNamedForeignStockRestoreService.QueryName, call.QueryName);
|
||||||
|
call.Spec.ValidateFor(DataSourceKind.Oracle);
|
||||||
|
Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("F_FDTC = '1'", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("SELECT DISTINCT", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("NVIDIA", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
var parameter = Assert.Single(call.Spec.Parameters);
|
||||||
|
Assert.Equal("input_name", parameter.Name);
|
||||||
|
Assert.Equal("NVIDIA", parameter.Value);
|
||||||
|
Assert.Equal(DbType.String, parameter.DbType);
|
||||||
|
return IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1"));
|
||||||
|
});
|
||||||
|
var row = Row(graphicType: graphicType, subtype: subtype);
|
||||||
|
|
||||||
|
Assert.True(LegacyNamedForeignStockRestoreService.IsCandidate(row));
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||||
|
.ResolveAsync([row])).Rows);
|
||||||
|
|
||||||
|
Assert.True(outcome.IsResolved);
|
||||||
|
Assert.Equal(actionId, outcome.Identity!.ActionId);
|
||||||
|
Assert.Equal("NVIDIA", outcome.Identity.InputName);
|
||||||
|
Assert.Equal("NAS@NVDA", outcome.Identity.Symbol);
|
||||||
|
Assert.Equal("US", outcome.Identity.NationCode);
|
||||||
|
Assert.Equal("1", outcome.Identity.ForeignDomesticCode);
|
||||||
|
Assert.Equal(periodDays is null ? "s5001" : "s8010", outcome.Identity.BuilderKey);
|
||||||
|
Assert.Equal(cutCode, outcome.Identity.CutCode);
|
||||||
|
Assert.Equal(periodDays is null ? "CURRENT" : "PRICE", outcome.Identity.ValueType);
|
||||||
|
Assert.Equal(periodDays, outcome.Identity.PeriodDays);
|
||||||
|
Assert.Single(executor.Calls);
|
||||||
|
Assert.Equal(0, executor.StringSqlCallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_PreservesOrderAndCachesRepeatedExactInputName()
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(call =>
|
||||||
|
{
|
||||||
|
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
|
||||||
|
return name == "TSMC"
|
||||||
|
? IdentityTable((name, "TPE@2330", "TW", "1"))
|
||||||
|
: IdentityTable((name, "NAS@NVDA", "US", "1"));
|
||||||
|
});
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
Row(itemIndex: 12, subject: "TSMC", graphicType: "캔들그래프", subtype: "240일"),
|
||||||
|
Row(itemIndex: 2, graphicType: "1열판기본", subtype: ""),
|
||||||
|
Row(itemIndex: 900, graphicType: "캔들그래프", subtype: "5일")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(rows);
|
||||||
|
|
||||||
|
Assert.Equal([12, 2, 900], result.Rows.Select(value => value.ItemIndex));
|
||||||
|
Assert.Equal(["TPE@2330", "NAS@NVDA", "NAS@NVDA"],
|
||||||
|
result.Rows.Select(value => value.Identity!.Symbol));
|
||||||
|
Assert.Equal(2, executor.Calls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_FailsClosedPerRowForMissingAmbiguousAndInvalidData()
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(call =>
|
||||||
|
{
|
||||||
|
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
|
||||||
|
return name switch
|
||||||
|
{
|
||||||
|
"MISSING" => IdentityTable(),
|
||||||
|
"AMBIGUOUS" => IdentityTable(
|
||||||
|
(name, "NAS@ONE", "US", "1"),
|
||||||
|
(name, "TPE@TWO", "TW", "1")),
|
||||||
|
"INVALID" => IdentityTable((name, "bad symbol!", "US", "1")),
|
||||||
|
_ => throw new InvalidOperationException(name)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(
|
||||||
|
[
|
||||||
|
Row(1, "MISSING"),
|
||||||
|
Row(2, "AMBIGUOUS"),
|
||||||
|
Row(3, "INVALID")
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new LegacyNamedForeignStockRestoreFailure?[]
|
||||||
|
{
|
||||||
|
LegacyNamedForeignStockRestoreFailure.IdentityNotFound,
|
||||||
|
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous,
|
||||||
|
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid
|
||||||
|
},
|
||||||
|
result.Rows.Select(value => value.Failure));
|
||||||
|
Assert.All(result.Rows, value => Assert.False(value.IsResolved));
|
||||||
|
Assert.Null(result.Rows[0].DatabaseIssue);
|
||||||
|
Assert.Null(result.Rows[1].DatabaseIssue);
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation,
|
||||||
|
result.Rows[2].DatabaseIssue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("FOREIGN_STOCK", "1열판기본", "", "1/1", "")]
|
||||||
|
[InlineData("해외종목", "CURRENT", "", "1/1", "")]
|
||||||
|
[InlineData("해외종목", "캔들그래프", "1년", "1/1", "")]
|
||||||
|
[InlineData("해외종목", "캔들그래프", "5일", "1/2", "")]
|
||||||
|
[InlineData("해외종목", "캔들그래프", "5일", "1/1", "NAS@NVDA|US|1")]
|
||||||
|
public async Task ResolveAsync_RejectsEverythingOutsideExactLegacyGrammarWithoutQuery(
|
||||||
|
string groupCode,
|
||||||
|
string graphicType,
|
||||||
|
string subtype,
|
||||||
|
string pageText,
|
||||||
|
string dataCode)
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||||
|
var row = Row(
|
||||||
|
groupCode: groupCode,
|
||||||
|
graphicType: graphicType,
|
||||||
|
subtype: subtype,
|
||||||
|
pageText: pageText,
|
||||||
|
dataCode: dataCode);
|
||||||
|
|
||||||
|
Assert.False(LegacyNamedForeignStockRestoreService.IsCandidate(row));
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||||
|
.ResolveAsync([row])).Rows);
|
||||||
|
|
||||||
|
Assert.Equal(LegacyNamedForeignStockRestoreFailure.InvalidRow, outcome.Failure);
|
||||||
|
Assert.Empty(executor.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_PreservesDisabledRowAndTaiwanNation()
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(_ =>
|
||||||
|
IdentityTable(("TSMC", "TPE@2330", "TW", "1")));
|
||||||
|
var row = Row(subject: "TSMC", isEnabled: false);
|
||||||
|
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||||
|
.ResolveAsync([row])).Rows);
|
||||||
|
|
||||||
|
Assert.True(outcome.IsResolved);
|
||||||
|
Assert.Equal("TW", outcome.Identity!.NationCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_AcceptsBoundProviderSymbolContainingHangulLetters()
|
||||||
|
{
|
||||||
|
const string symbol = "NAS@해외 종목1";
|
||||||
|
var executor = new RecordingExecutor(_ =>
|
||||||
|
IdentityTable(("NVIDIA", symbol, "US", "1")));
|
||||||
|
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||||
|
.ResolveAsync([Row()])).Rows);
|
||||||
|
|
||||||
|
Assert.True(outcome.IsResolved);
|
||||||
|
Assert.Equal(symbol, outcome.Identity!.Symbol);
|
||||||
|
Assert.Null(outcome.DatabaseIssue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_AcceptsSafeSupplementaryScalarsConsistentlyWithWebContract()
|
||||||
|
{
|
||||||
|
const string inputName = "회사🚀";
|
||||||
|
const string symbol = "NAS@𐐀1";
|
||||||
|
var executor = new RecordingExecutor(_ =>
|
||||||
|
IdentityTable((inputName, symbol, "US", "1")));
|
||||||
|
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||||
|
.ResolveAsync([Row(subject: inputName)])).Rows);
|
||||||
|
|
||||||
|
Assert.True(outcome.IsResolved);
|
||||||
|
Assert.Equal(inputName, outcome.Identity!.InputName);
|
||||||
|
Assert.Equal(symbol, outcome.Identity.Symbol);
|
||||||
|
Assert.Null(outcome.DatabaseIssue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("NAS|NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter)]
|
||||||
|
[InlineData("NAS^NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolCaret)]
|
||||||
|
[InlineData(" NAS@NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
|
||||||
|
[InlineData("NAS@NVDA ", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
|
||||||
|
[InlineData("NAS!NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation)]
|
||||||
|
[InlineData("NAS\nNVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
|
||||||
|
[InlineData("NAS😀NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther)]
|
||||||
|
public async Task ResolveAsync_DiagnosticReasonNeverPrintsOrRelaxesUnsafeSymbol(
|
||||||
|
string symbol,
|
||||||
|
LegacyNamedForeignStockDatabaseIssue expectedIssue)
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(_ =>
|
||||||
|
IdentityTable(("NVIDIA", symbol, "US", "1")));
|
||||||
|
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||||
|
.ResolveAsync([Row()])).Rows);
|
||||||
|
|
||||||
|
Assert.False(outcome.IsResolved);
|
||||||
|
Assert.Equal(LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid, outcome.Failure);
|
||||||
|
Assert.Equal(expectedIssue, outcome.DatabaseIssue);
|
||||||
|
Assert.DoesNotContain(symbol, outcome.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_UsesInjectedClockForCorrelatedBatchTimestamp()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero);
|
||||||
|
var executor = new RecordingExecutor(_ =>
|
||||||
|
IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1")));
|
||||||
|
var service = new LegacyNamedForeignStockRestoreService(
|
||||||
|
executor,
|
||||||
|
new FixedTimeProvider(now));
|
||||||
|
|
||||||
|
var result = await service.ResolveAsync([Row()]);
|
||||||
|
|
||||||
|
Assert.Equal(now, result.RetrievedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_RejectsMalformedBatchAndNeverRetriesFailure()
|
||||||
|
{
|
||||||
|
var failed = new RecordingExecutor(_ => throw new DataException("one read failure"));
|
||||||
|
var service = new LegacyNamedForeignStockRestoreService(failed);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<DataException>(() => service.ResolveAsync([Row()]));
|
||||||
|
Assert.Single(failed.Calls);
|
||||||
|
|
||||||
|
var unused = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||||
|
service = new LegacyNamedForeignStockRestoreService(unused);
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([Row(4), Row(4)]));
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([null!]));
|
||||||
|
Assert.Empty(unused.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_HonorsCancellationWithoutASecondQuery()
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var executor = new RecordingExecutor(call =>
|
||||||
|
{
|
||||||
|
cancellation.Cancel();
|
||||||
|
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
|
||||||
|
return IdentityTable((name, "NAS@NVDA", "US", "1"));
|
||||||
|
}, throwBeforeHandlerWhenCanceled: false);
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||||
|
new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(
|
||||||
|
[
|
||||||
|
Row(0),
|
||||||
|
Row(1, "TSMC")
|
||||||
|
],
|
||||||
|
cancellation.Token));
|
||||||
|
|
||||||
|
Assert.Single(executor.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyNamedForeignStockRestoreRow Row(
|
||||||
|
int itemIndex = 0,
|
||||||
|
string subject = "NVIDIA",
|
||||||
|
bool isEnabled = true,
|
||||||
|
string groupCode = "해외종목",
|
||||||
|
string graphicType = "1열판기본",
|
||||||
|
string subtype = "",
|
||||||
|
string pageText = "1/1",
|
||||||
|
string dataCode = "") =>
|
||||||
|
new(
|
||||||
|
itemIndex,
|
||||||
|
isEnabled,
|
||||||
|
groupCode,
|
||||||
|
subject,
|
||||||
|
graphicType,
|
||||||
|
subtype,
|
||||||
|
pageText,
|
||||||
|
dataCode);
|
||||||
|
|
||||||
|
private static DataTable IdentityTable(
|
||||||
|
params (string InputName, string Symbol, string Nation, string Fdtc)[] rows)
|
||||||
|
{
|
||||||
|
var table = new DataTable();
|
||||||
|
table.Columns.Add("F_INPUT_NAME", typeof(string));
|
||||||
|
table.Columns.Add("F_SYMB", typeof(string));
|
||||||
|
table.Columns.Add("F_NATC", typeof(string));
|
||||||
|
table.Columns.Add("F_FDTC", typeof(string));
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
table.Rows.Add(row.InputName, row.Symbol, row.Nation, row.Fdtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RecordedCall(
|
||||||
|
DataSourceKind Source,
|
||||||
|
string QueryName,
|
||||||
|
DataQuerySpec Spec,
|
||||||
|
CancellationToken CancellationToken);
|
||||||
|
|
||||||
|
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => value;
|
||||||
|
|
||||||
|
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingExecutor(
|
||||||
|
Func<RecordedCall, DataTable> handler,
|
||||||
|
bool throwBeforeHandlerWhenCanceled = true) : IDataQueryExecutor
|
||||||
|
{
|
||||||
|
private int _stringSqlCallCount;
|
||||||
|
|
||||||
|
public List<RecordedCall> Calls { get; } = [];
|
||||||
|
|
||||||
|
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
|
||||||
|
|
||||||
|
public DataTable Execute(DataSourceKind source, string tableName, string query)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _stringSqlCallCount);
|
||||||
|
throw new InvalidOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DataTable> ExecuteAsync(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
string query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _stringSqlCallCount);
|
||||||
|
throw new InvalidOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DataTable> ExecuteAsync(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
DataQuerySpec query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (throwBeforeHandlerWhenCanceled)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
query.ValidateFor(source);
|
||||||
|
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||||
|
Calls.Add(call);
|
||||||
|
return Task.FromResult(handler(call).Copy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyNamedKrxThemeRestoreServiceTests
|
||||||
|
{
|
||||||
|
public static TheoryData<string, string, string, string, int, string> SupportedRows =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
{ "5단 표그래프", "테마-현재가(입력순)", "five-row-current", "s5074", 5, "INPUT_ORDER" },
|
||||||
|
{ "5단 표그래프", "테마-현재가(상승률순)", "five-row-current", "s5074", 5, "GAIN_DESC" },
|
||||||
|
{ "5단 표그래프", "테마-현재가(하락률순)", "five-row-current", "s5074", 5, "GAIN_ASC" },
|
||||||
|
{ "5단 표그래프", "테마-현재가", "five-row-current", "s5074", 5, "GAIN_ASC" },
|
||||||
|
{ "5단 표그래프", "테마-예상체결가(입력순)", "five-row-expected", "s5074", 5, "INPUT_ORDER" },
|
||||||
|
{ "5단 표그래프", "테마-예상체결가(상승률순)", "five-row-expected", "s5074", 5, "GAIN_DESC" },
|
||||||
|
{ "5단 표그래프", "테마-예상체결가(하락률순)", "five-row-expected", "s5074", 5, "GAIN_ASC" },
|
||||||
|
{ "5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", 5, "GAIN_ASC" },
|
||||||
|
{ "6종목 현재가", "테마-현재가(입력순)", "six-row-current", "s5077", 6, "INPUT_ORDER" },
|
||||||
|
{ "6종목 현재가", "테마-현재가(상승률순)", "six-row-current", "s5077", 6, "GAIN_DESC" },
|
||||||
|
{ "6종목 현재가", "테마-현재가(하락률순)", "six-row-current", "s5077", 6, "GAIN_ASC" },
|
||||||
|
{ "6종목 현재가", "테마-현재가", "six-row-current", "s5077", 6, "GAIN_ASC" },
|
||||||
|
{ "12종목 현재가", "테마-현재가(입력순)", "twelve-row-current", "s5088", 12, "INPUT_ORDER" },
|
||||||
|
{ "12종목 현재가", "테마-현재가(상승률순)", "twelve-row-current", "s5088", 12, "GAIN_DESC" },
|
||||||
|
{ "12종목 현재가", "테마-현재가(하락률순)", "twelve-row-current", "s5088", 12, "GAIN_ASC" },
|
||||||
|
{ "12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", 12, "GAIN_ASC" }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(SupportedRows))]
|
||||||
|
public async Task ResolveAsync_PreservesOriginalActionsAndFinalSortBranchWhileRemappingCode(
|
||||||
|
string graphicType,
|
||||||
|
string subtype,
|
||||||
|
string actionId,
|
||||||
|
string builderKey,
|
||||||
|
int pageSize,
|
||||||
|
string sort)
|
||||||
|
{
|
||||||
|
var executor = GoodExecutor("반도체", "87654321", previewCount: 2);
|
||||||
|
var service = new LegacyNamedKrxThemeRestoreService(executor);
|
||||||
|
|
||||||
|
var outcome = Assert.Single((await service.ResolveAsync(
|
||||||
|
[Row(graphicType: graphicType, subtype: subtype)])).Rows);
|
||||||
|
|
||||||
|
Assert.True(outcome.IsResolved);
|
||||||
|
Assert.Null(outcome.Failure);
|
||||||
|
Assert.NotNull(outcome.Identity);
|
||||||
|
Assert.Equal(actionId, outcome.Identity.ActionId);
|
||||||
|
Assert.Equal(builderKey, outcome.Identity.BuilderKey);
|
||||||
|
Assert.Equal(builderKey[1..], outcome.Identity.CutCode);
|
||||||
|
Assert.Equal(pageSize, outcome.Identity.PageSize);
|
||||||
|
Assert.Equal(sort, outcome.Identity.Sort);
|
||||||
|
Assert.Equal("반도체", outcome.Identity.ThemeTitle);
|
||||||
|
Assert.Equal("0123", outcome.Identity.StoredThemeCode);
|
||||||
|
Assert.Equal("87654321", outcome.Identity.CurrentThemeCode);
|
||||||
|
Assert.True(outcome.Identity.CodeWasRemapped);
|
||||||
|
Assert.Equal(2, outcome.Identity.PreviewItemCount);
|
||||||
|
Assert.False(outcome.Identity.PreviewIsTruncated);
|
||||||
|
Assert.Equal(2, executor.Calls.Count);
|
||||||
|
|
||||||
|
var identityCall = executor.Calls[0];
|
||||||
|
Assert.Equal(DataSourceKind.Oracle, identityCall.Source);
|
||||||
|
Assert.Equal(LegacyNamedKrxThemeRestoreService.IdentityQueryName, identityCall.QueryName);
|
||||||
|
identityCall.Spec.ValidateFor(DataSourceKind.Oracle);
|
||||||
|
Assert.Contains("s.SB_TITLE = :theme_title", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("d.SB_CODE = s.SB_CODE", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ROWNUM <= 2", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("반도체", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Collection(identityCall.Spec.Parameters, parameter =>
|
||||||
|
{
|
||||||
|
Assert.Equal("theme_title", parameter.Name);
|
||||||
|
Assert.Equal("반도체", parameter.Value);
|
||||||
|
Assert.Equal(DbType.String, parameter.DbType);
|
||||||
|
});
|
||||||
|
|
||||||
|
var previewCall = executor.Calls[1];
|
||||||
|
Assert.Equal(DataSourceKind.Oracle, previewCall.Source);
|
||||||
|
Assert.Equal("THEME_PREVIEW_KRX", previewCall.QueryName);
|
||||||
|
Assert.Equal("87654321", previewCall.Spec.Parameters[0].Value);
|
||||||
|
Assert.Equal("반도체", previewCall.Spec.Parameters[1].Value);
|
||||||
|
Assert.Equal(241, previewCall.Spec.Parameters[2].Value);
|
||||||
|
Assert.Equal(0, executor.StringSqlCallCount);
|
||||||
|
Assert.All(executor.Calls, call =>
|
||||||
|
{
|
||||||
|
var sql = call.Spec.Sql.TrimStart().ToUpperInvariant();
|
||||||
|
Assert.StartsWith("SELECT", sql, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("INSERT ", sql, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("UPDATE ", sql, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("DELETE ", sql, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("MERGE ", sql, StringComparison.Ordinal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_CachesExactTitleButPreservesBatchOrderAndStoredCodes()
|
||||||
|
{
|
||||||
|
var executor = GoodExecutor("AI", "90000001", previewCount: 1);
|
||||||
|
var service = new LegacyNamedKrxThemeRestoreService(executor);
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
Row(itemIndex: 7, subject: "AI", dataCode: "111"),
|
||||||
|
Row(itemIndex: 2, subject: "AI", dataCode: "2222", graphicType: "6종목 현재가")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await service.ResolveAsync(rows);
|
||||||
|
|
||||||
|
Assert.Equal([7, 2], result.Rows.Select(row => row.ItemIndex));
|
||||||
|
Assert.Equal(["111", "2222"], result.Rows.Select(row => row.Identity!.StoredThemeCode));
|
||||||
|
Assert.All(result.Rows, row => Assert.Equal("90000001", row.Identity!.CurrentThemeCode));
|
||||||
|
Assert.Equal(2, executor.Calls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_AcceptsSafeSupplementaryThemeTitleConsistentlyWithWebContract()
|
||||||
|
{
|
||||||
|
const string title = "우주🚀";
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
|
||||||
|
GoodExecutor(title, "90000001", previewCount: 1))
|
||||||
|
.ResolveAsync([Row(subject: title)])).Rows);
|
||||||
|
|
||||||
|
Assert.True(outcome.IsResolved);
|
||||||
|
Assert.Equal(title, outcome.Identity!.ThemeTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("테마_NXT", "반도체", "1/1", "0123")]
|
||||||
|
[InlineData("테마", "반도체(NXT)", "1/1", "0123")]
|
||||||
|
[InlineData("테마", " 반도체", "1/1", "0123")]
|
||||||
|
[InlineData("테마", "반도체", "01/1", "0123")]
|
||||||
|
[InlineData("테마", "반도체", "1/1", "R0123")]
|
||||||
|
public async Task ResolveAsync_InvalidRowsFailWithoutDatabaseAccess(
|
||||||
|
string groupCode,
|
||||||
|
string subject,
|
||||||
|
string pageText,
|
||||||
|
string dataCode)
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
|
||||||
|
.ResolveAsync([Row(
|
||||||
|
groupCode: groupCode,
|
||||||
|
subject: subject,
|
||||||
|
pageText: pageText,
|
||||||
|
dataCode: dataCode)])).Rows);
|
||||||
|
|
||||||
|
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.InvalidRow, outcome.Failure);
|
||||||
|
Assert.Empty(executor.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("6종목 현재가", "테마-예상체결가(입력순)")]
|
||||||
|
[InlineData("12종목 현재가", "테마-예상체결가")]
|
||||||
|
[InlineData("네모그래프", "테마-현재가(입력순)")]
|
||||||
|
[InlineData("5단 표그래프", "테마-현재가(거래량순)")]
|
||||||
|
public async Task ResolveAsync_UnsupportedActionsFailClosedPerRow(
|
||||||
|
string graphicType,
|
||||||
|
string subtype)
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
|
||||||
|
.ResolveAsync([Row(graphicType: graphicType, subtype: subtype)])).Rows);
|
||||||
|
|
||||||
|
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.UnsupportedAction, outcome.Failure);
|
||||||
|
Assert.Empty(executor.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_ReportsNotFoundAndAmbiguousPerExactTitleWithoutPreview()
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(call =>
|
||||||
|
{
|
||||||
|
var title = Assert.IsType<string>(call.Spec.Parameters[0].Value);
|
||||||
|
return title == "없음"
|
||||||
|
? IdentityTable()
|
||||||
|
: IdentityTable(
|
||||||
|
(title, "111", "KRX", "1"),
|
||||||
|
(title, "222", "KRX", "1"));
|
||||||
|
});
|
||||||
|
var result = await new LegacyNamedKrxThemeRestoreService(executor).ResolveAsync(
|
||||||
|
[
|
||||||
|
Row(itemIndex: 0, subject: "없음"),
|
||||||
|
Row(itemIndex: 1, subject: "중복")
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.IdentityNotFound,
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous
|
||||||
|
],
|
||||||
|
result.Rows.Select(row => row.Failure));
|
||||||
|
Assert.Equal(2, executor.Calls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_RequiresNonemptyValidatedKrxPreview()
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(call => call.QueryName switch
|
||||||
|
{
|
||||||
|
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
|
||||||
|
IdentityTable(("반도체", "00000001", "KRX", "1")),
|
||||||
|
"THEME_PREVIEW_KRX" => EmptyPreviewTable("반도체", "00000001"),
|
||||||
|
_ => throw new InvalidOperationException(call.QueryName)
|
||||||
|
});
|
||||||
|
|
||||||
|
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
|
||||||
|
.ResolveAsync([Row()])).Rows);
|
||||||
|
|
||||||
|
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.PreviewEmpty, outcome.Failure);
|
||||||
|
Assert.False(outcome.IsResolved);
|
||||||
|
Assert.Equal("00000001", outcome.ObservedCurrentThemeCode);
|
||||||
|
Assert.Equal(2, executor.Calls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_PreservesValidatedPreviewTruncationEvidence()
|
||||||
|
{
|
||||||
|
var executor = GoodExecutor("반도체", "00000001", previewCount: 241);
|
||||||
|
|
||||||
|
var identity = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
|
||||||
|
.ResolveAsync([Row()])).Rows).Identity!;
|
||||||
|
|
||||||
|
Assert.Equal(LegacyThemeSelectionService.MaximumPreviewItems, identity.PreviewItemCount);
|
||||||
|
Assert.True(identity.PreviewIsTruncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_FailsClosedOnInvalidDatabaseSchemaCodeOrPreview()
|
||||||
|
{
|
||||||
|
var wrongSchema = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
|
||||||
|
new RecordingExecutor(_ => new DataTable())).ResolveAsync([Row()])).Rows);
|
||||||
|
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid, wrongSchema.Failure);
|
||||||
|
|
||||||
|
var duplicateCode = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
|
||||||
|
new RecordingExecutor(_ =>
|
||||||
|
IdentityTable(("반도체", "00000001", "KRX", "2"))))
|
||||||
|
.ResolveAsync([Row()])).Rows);
|
||||||
|
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous, duplicateCode.Failure);
|
||||||
|
|
||||||
|
var invalidPreviewExecutor = new RecordingExecutor(call => call.QueryName switch
|
||||||
|
{
|
||||||
|
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
|
||||||
|
IdentityTable(("반도체", "00000001", "KRX", "1")),
|
||||||
|
"THEME_PREVIEW_KRX" => new DataTable(),
|
||||||
|
_ => throw new InvalidOperationException(call.QueryName)
|
||||||
|
});
|
||||||
|
var invalidPreview = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
|
||||||
|
invalidPreviewExecutor).ResolveAsync([Row()])).Rows);
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid,
|
||||||
|
invalidPreview.Failure);
|
||||||
|
Assert.Equal("00000001", invalidPreview.ObservedCurrentThemeCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_PropagatesReadFailureOnceAndHonorsCancellation()
|
||||||
|
{
|
||||||
|
var failing = new RecordingExecutor(_ => throw new DataException("simulated"));
|
||||||
|
await Assert.ThrowsAsync<DataException>(() =>
|
||||||
|
new LegacyNamedKrxThemeRestoreService(failing).ResolveAsync([Row()]));
|
||||||
|
Assert.Single(failing.Calls);
|
||||||
|
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
cancellation.Cancel();
|
||||||
|
var canceled = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||||
|
new LegacyNamedKrxThemeRestoreService(canceled)
|
||||||
|
.ResolveAsync([Row()], cancellation.Token));
|
||||||
|
Assert.Empty(canceled.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_RejectsInvalidBatchAndAllowsDisabledRows()
|
||||||
|
{
|
||||||
|
var executor = GoodExecutor("반도체", "00000001", previewCount: 1);
|
||||||
|
var service = new LegacyNamedKrxThemeRestoreService(executor);
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
|
||||||
|
[Row(itemIndex: 3), Row(itemIndex: 3)]));
|
||||||
|
|
||||||
|
var outcome = Assert.Single((await service.ResolveAsync([Row(isEnabled: false)])).Rows);
|
||||||
|
Assert.True(outcome.IsResolved);
|
||||||
|
Assert.True(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(isEnabled: false)));
|
||||||
|
Assert.False(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(groupCode: "테마_NXT")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecordingExecutor GoodExecutor(
|
||||||
|
string title,
|
||||||
|
string code,
|
||||||
|
int previewCount) =>
|
||||||
|
new(call => call.QueryName switch
|
||||||
|
{
|
||||||
|
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
|
||||||
|
IdentityTable((title, code, "KRX", "1")),
|
||||||
|
"THEME_PREVIEW_KRX" => PreviewTable(title, code, previewCount),
|
||||||
|
_ => throw new InvalidOperationException(call.QueryName)
|
||||||
|
});
|
||||||
|
|
||||||
|
private static LegacyNamedKrxThemeRestoreRow Row(
|
||||||
|
int itemIndex = 0,
|
||||||
|
bool isEnabled = true,
|
||||||
|
string groupCode = "테마",
|
||||||
|
string subject = "반도체",
|
||||||
|
string graphicType = "5단 표그래프",
|
||||||
|
string subtype = "테마-현재가(입력순)",
|
||||||
|
string pageText = "1/1",
|
||||||
|
string dataCode = "0123") =>
|
||||||
|
new(itemIndex, isEnabled, groupCode, subject, graphicType, subtype, pageText, dataCode);
|
||||||
|
|
||||||
|
private static DataTable IdentityTable(
|
||||||
|
params (string Title, string Code, string Market, string CodeCount)[] rows)
|
||||||
|
{
|
||||||
|
var table = new DataTable();
|
||||||
|
table.Columns.Add("THEME_TITLE", typeof(string));
|
||||||
|
table.Columns.Add("THEME_CODE", typeof(string));
|
||||||
|
table.Columns.Add("THEME_MARKET", typeof(string));
|
||||||
|
table.Columns.Add("CODE_IDENTITY_COUNT", typeof(string));
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
table.Rows.Add(row.Title, row.Code, row.Market, row.CodeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataTable PreviewTable(string title, string code, int count)
|
||||||
|
{
|
||||||
|
var table = CreatePreviewTable();
|
||||||
|
for (var index = 0; index < count; index++)
|
||||||
|
{
|
||||||
|
table.Rows.Add(
|
||||||
|
title,
|
||||||
|
code,
|
||||||
|
"KRX",
|
||||||
|
$"종목{index + 1}",
|
||||||
|
$"P{index + 1:D6}",
|
||||||
|
index.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataTable EmptyPreviewTable(string title, string code)
|
||||||
|
{
|
||||||
|
var table = CreatePreviewTable();
|
||||||
|
table.Rows.Add(title, code, "KRX", DBNull.Value, DBNull.Value, DBNull.Value);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataTable CreatePreviewTable()
|
||||||
|
{
|
||||||
|
var table = new DataTable();
|
||||||
|
table.Columns.Add("THEME_TITLE", typeof(string));
|
||||||
|
table.Columns.Add("THEME_CODE", typeof(string));
|
||||||
|
table.Columns.Add("THEME_MARKET", typeof(string));
|
||||||
|
table.Columns.Add("ITEM_NAME", typeof(string));
|
||||||
|
table.Columns.Add("ITEM_CODE", typeof(string));
|
||||||
|
table.Columns.Add("ITEM_INDEX", typeof(string));
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RecordedCall(
|
||||||
|
DataSourceKind Source,
|
||||||
|
string QueryName,
|
||||||
|
DataQuerySpec Spec,
|
||||||
|
CancellationToken CancellationToken);
|
||||||
|
|
||||||
|
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
|
||||||
|
: IDataQueryExecutor
|
||||||
|
{
|
||||||
|
public List<RecordedCall> Calls { get; } = [];
|
||||||
|
|
||||||
|
public int StringSqlCallCount { get; private set; }
|
||||||
|
|
||||||
|
public DataTable Execute(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
string query)
|
||||||
|
{
|
||||||
|
StringSqlCallCount++;
|
||||||
|
throw new InvalidOperationException("String SQL must not be used.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DataTable> ExecuteAsync(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
string sql,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
StringSqlCallCount++;
|
||||||
|
throw new InvalidOperationException("String SQL must not be used.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DataTable> ExecuteAsync(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
DataQuerySpec query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||||
|
Calls.Add(call);
|
||||||
|
return Task.FromResult(handler(call));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyNamedManualRestoreAuditTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("코스피", "주요매출 구성", ManualFinancialScreenKind.RevenueComposition, StockMarket.Kospi)]
|
||||||
|
[InlineData("코스닥", "성장성 지표", ManualFinancialScreenKind.GrowthMetrics, StockMarket.Kosdaq)]
|
||||||
|
[InlineData("코스피_NXT", "매출액", ManualFinancialScreenKind.Sales, StockMarket.NxtKospi)]
|
||||||
|
[InlineData("코스닥_NXT", "영업이익", ManualFinancialScreenKind.OperatingProfit, StockMarket.NxtKosdaq)]
|
||||||
|
public void ClassifyHistoricalFinancialGrammar(
|
||||||
|
string group,
|
||||||
|
string graphic,
|
||||||
|
ManualFinancialScreenKind expectedScreen,
|
||||||
|
StockMarket expectedMarket)
|
||||||
|
{
|
||||||
|
var candidate = LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row(group, expectedMarket is StockMarket.NxtKospi or StockMarket.NxtKosdaq
|
||||||
|
? "테스트(NXT)"
|
||||||
|
: "테스트", graphic, string.Empty, "1/1", string.Empty));
|
||||||
|
|
||||||
|
Assert.NotNull(candidate);
|
||||||
|
Assert.Equal(LegacyNamedManualRestoreKind.Financial, candidate.Kind);
|
||||||
|
Assert.True(candidate.IsHistorical);
|
||||||
|
Assert.Equal(expectedScreen, candidate.FinancialScreen);
|
||||||
|
Assert.Equal(expectedMarket, candidate.Market);
|
||||||
|
Assert.Equal("테스트" + (expectedMarket is StockMarket.NxtKospi or StockMarket.NxtKosdaq
|
||||||
|
? "(NXT)"
|
||||||
|
: string.Empty), candidate.StockName);
|
||||||
|
Assert.Equal("Named manual restore candidate (Financial)", candidate.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("KOSPI", "REVENUE_COMPOSITION", ManualFinancialScreenKind.RevenueComposition)]
|
||||||
|
[InlineData("KOSDAQ", "GROWTH_METRICS", ManualFinancialScreenKind.GrowthMetrics)]
|
||||||
|
[InlineData("NXT_KOSPI", "SALES", ManualFinancialScreenKind.Sales)]
|
||||||
|
[InlineData("NXT_KOSDAQ", "OPERATING_PROFIT", ManualFinancialScreenKind.OperatingProfit)]
|
||||||
|
public void ClassifyCanonicalFinancialGrammar(
|
||||||
|
string group,
|
||||||
|
string graphic,
|
||||||
|
ManualFinancialScreenKind expectedScreen)
|
||||||
|
{
|
||||||
|
var candidate = LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row(group, group.StartsWith("NXT_", StringComparison.Ordinal)
|
||||||
|
? "테스트(NXT)"
|
||||||
|
: "테스트", graphic, string.Empty, "1/1", string.Empty));
|
||||||
|
|
||||||
|
Assert.NotNull(candidate);
|
||||||
|
Assert.False(candidate.IsHistorical);
|
||||||
|
Assert.Equal(expectedScreen, candidate.FinancialScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("개인 순매도 상위(수동)", S5025ManualAudience.Individual)]
|
||||||
|
[InlineData("외국인 순매도 상위(수동)", S5025ManualAudience.Foreign)]
|
||||||
|
[InlineData("기관 순매도 상위(수동)", S5025ManualAudience.Institution)]
|
||||||
|
public void ClassifyHistoricalNetSellGrammar(
|
||||||
|
string group,
|
||||||
|
S5025ManualAudience expected)
|
||||||
|
{
|
||||||
|
var candidate = LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row(group, "지수", "순매도 상위", "순매도 상위", "1/1", string.Empty));
|
||||||
|
|
||||||
|
Assert.NotNull(candidate);
|
||||||
|
Assert.Equal(LegacyNamedManualRestoreKind.NetSell, candidate.Kind);
|
||||||
|
Assert.True(candidate.IsHistorical);
|
||||||
|
Assert.Equal(expected, candidate.Audience);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("INDIVIDUAL", S5025ManualAudience.Individual)]
|
||||||
|
[InlineData("FOREIGN", S5025ManualAudience.Foreign)]
|
||||||
|
[InlineData("INSTITUTION", S5025ManualAudience.Institution)]
|
||||||
|
public void ClassifyCanonicalNetSellGrammar(
|
||||||
|
string group,
|
||||||
|
S5025ManualAudience expected)
|
||||||
|
{
|
||||||
|
var candidate = LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row(group, "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", "1/1", string.Empty));
|
||||||
|
|
||||||
|
Assert.NotNull(candidate);
|
||||||
|
Assert.False(candidate.IsHistorical);
|
||||||
|
Assert.Equal(expected, candidate.Audience);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyHistoricalAndVersionedViGrammar()
|
||||||
|
{
|
||||||
|
var historical = LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row(string.Empty, "가,나,다,라,마,바", "5단 표그래프", "VI 발동(수동)", "1/2", string.Empty));
|
||||||
|
var version = new string('a', 64);
|
||||||
|
var canonical = LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row("PAGED_VI", TrustedViManualReference.SubjectSentinel, "INPUT_ORDER", "CURRENT", "1/2", version));
|
||||||
|
|
||||||
|
Assert.NotNull(historical);
|
||||||
|
Assert.True(historical.IsHistorical);
|
||||||
|
Assert.Equal(6, historical.HistoricalViNames!.Count);
|
||||||
|
Assert.Equal(2, historical.ExpectedPageCount);
|
||||||
|
Assert.NotNull(canonical);
|
||||||
|
Assert.False(canonical.IsHistorical);
|
||||||
|
Assert.Equal(version, canonical.ExpectedViVersion);
|
||||||
|
Assert.Equal(2, canonical.ExpectedPageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1/1", "가,나,다,라,마,바")]
|
||||||
|
[InlineData("1/21", "가")]
|
||||||
|
[InlineData("01/1", "가")]
|
||||||
|
public void RejectViPageGrammarThatCannotMatchMaterialization(
|
||||||
|
string page,
|
||||||
|
string subject)
|
||||||
|
{
|
||||||
|
Assert.Null(LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row(string.Empty, subject, "5단 표그래프", "VI 발동(수동)", page, string.Empty)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FinancialFreshProofRequiresTwoStableReadsAndOneMasterIdentity()
|
||||||
|
{
|
||||||
|
var candidate = Assert.IsType<LegacyNamedManualRestoreCandidate>(
|
||||||
|
LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row("코스피", "테스트", "매출액", string.Empty, "1/1", string.Empty)));
|
||||||
|
var first = SalesSnapshot("테스트", 'A');
|
||||||
|
var second = SalesSnapshot("테스트", 'A');
|
||||||
|
var search = Search(
|
||||||
|
"테스트",
|
||||||
|
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "테스트", "A0001"));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedManualFreshProofFailure.None,
|
||||||
|
LegacyNamedManualFreshProof.EvaluateFinancial(candidate, first, second, search));
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedManualFreshProofFailure.FinancialSnapshotChanged,
|
||||||
|
LegacyNamedManualFreshProof.EvaluateFinancial(
|
||||||
|
candidate,
|
||||||
|
first,
|
||||||
|
SalesSnapshot("테스트", 'B'),
|
||||||
|
search));
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique,
|
||||||
|
LegacyNamedManualFreshProof.EvaluateFinancial(
|
||||||
|
candidate,
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
Search(
|
||||||
|
"테스트",
|
||||||
|
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "테스트", "A0001"),
|
||||||
|
new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "테스트", "A0002"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FinancialFreshProofUsesOriginalNxtDisplayIdentityForSearch()
|
||||||
|
{
|
||||||
|
var candidate = Assert.IsType<LegacyNamedManualRestoreCandidate>(
|
||||||
|
LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row("코스피_NXT", "테스트(NXT)", "매출액", string.Empty, "1/1", string.Empty)));
|
||||||
|
|
||||||
|
Assert.Equal("테스트", LegacyNamedManualFreshProof.FinancialStockSearchQuery(candidate));
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedManualFreshProofFailure.None,
|
||||||
|
LegacyNamedManualFreshProof.EvaluateFinancial(
|
||||||
|
candidate,
|
||||||
|
SalesSnapshot("테스트(NXT)", 'A'),
|
||||||
|
SalesSnapshot("테스트(NXT)", 'A'),
|
||||||
|
Search(
|
||||||
|
"테스트",
|
||||||
|
new StockSearchItem(
|
||||||
|
StockMarket.NxtKospi,
|
||||||
|
DataSourceKind.MariaDb,
|
||||||
|
"테스트(NXT)",
|
||||||
|
"A0001"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManualFileFreshProofRejectsShapeOrIdentityDrift()
|
||||||
|
{
|
||||||
|
var netSell = Assert.IsType<LegacyNamedManualRestoreCandidate>(
|
||||||
|
LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row("INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", "1/1", string.Empty)));
|
||||||
|
var rows = Enumerable.Range(0, 5)
|
||||||
|
.Select(_ => new S5025TrustedManualRow("", "", "", ""))
|
||||||
|
.ToArray();
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedManualFreshProofFailure.None,
|
||||||
|
LegacyNamedManualFreshProof.EvaluateNetSell(netSell, rows));
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedManualFreshProofFailure.NetSellSnapshotInvalid,
|
||||||
|
LegacyNamedManualFreshProof.EvaluateNetSell(netSell, rows.Take(4).ToArray()));
|
||||||
|
|
||||||
|
var vi = Assert.IsType<LegacyNamedManualRestoreCandidate>(
|
||||||
|
LegacyNamedManualRestoreClassifier.Classify(
|
||||||
|
Row(string.Empty, "가,나,다,라,마,바", "5단 표그래프", "VI 발동(수동)", "1/2", string.Empty)));
|
||||||
|
var snapshot = new TrustedViStockNameSnapshot(
|
||||||
|
["가", "나", "다", "라", "마", "바"],
|
||||||
|
new string('a', 64));
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedManualFreshProofFailure.None,
|
||||||
|
LegacyNamedManualFreshProof.EvaluateVi(vi, snapshot));
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyNamedManualFreshProofFailure.ViHistoricalNamesChanged,
|
||||||
|
LegacyNamedManualFreshProof.EvaluateVi(
|
||||||
|
vi,
|
||||||
|
snapshot with { StockNames = ["가", "나", "다", "라", "마", "변경"] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyPListAuditRow Row(
|
||||||
|
string group,
|
||||||
|
string subject,
|
||||||
|
string graphic,
|
||||||
|
string subtype,
|
||||||
|
string page,
|
||||||
|
string dataCode)
|
||||||
|
{
|
||||||
|
var fields = new[] { "1", group, subject, graphic, subtype, page, dataCode };
|
||||||
|
return new LegacyPListAuditRow(
|
||||||
|
"20260712",
|
||||||
|
"감사",
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
Array.AsReadOnly(fields),
|
||||||
|
string.Join('^', fields),
|
||||||
|
page.Replace(" ", string.Empty, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ManualFinancialSnapshot SalesSnapshot(string stockName, char version) =>
|
||||||
|
new(
|
||||||
|
new ManualSalesRecord(
|
||||||
|
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName),
|
||||||
|
Enumerable.Range(1, 6)
|
||||||
|
.Select(index => new ManualQuarterValue($"Q{index}", index))
|
||||||
|
.ToArray()),
|
||||||
|
new string(version, 64));
|
||||||
|
|
||||||
|
private static StockSearchResult Search(string query, params StockSearchItem[] items) =>
|
||||||
|
new(query, DateTimeOffset.UnixEpoch, Array.AsReadOnly(items), IsTruncated: false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyNxtThemeJoinAuditServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AuditAsync_UsesBoundAggregateReadsAndReturnsNoIdentifiers()
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(call =>
|
||||||
|
{
|
||||||
|
var current = Assert.IsType<string>(call.Spec.Parameters[0].Value);
|
||||||
|
return current == "90000001"
|
||||||
|
? Table("3", "0", "2", "2", "1", "0", "0", "0", "0", "0", "0", "3",
|
||||||
|
"2", "0", "1", "0")
|
||||||
|
: Table("4", "1", "0", "0", "0", "1", "1", "1", "0", "0", "0", "4",
|
||||||
|
"0", "0", "0", "0");
|
||||||
|
});
|
||||||
|
var service = new LegacyNxtThemeJoinAuditService(
|
||||||
|
executor,
|
||||||
|
new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero)));
|
||||||
|
|
||||||
|
var result = await service.AuditAsync(
|
||||||
|
[
|
||||||
|
new LegacyNxtThemeJoinAuditIdentity("90000001", "111"),
|
||||||
|
new LegacyNxtThemeJoinAuditIdentity("90000002", "222")
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero), result.RetrievedAt);
|
||||||
|
Assert.Equal(2, result.Themes.Count);
|
||||||
|
Assert.Equal(0, result.Themes[0].InputIndex);
|
||||||
|
Assert.Equal(3, result.Themes[0].RawCurrentItemCount);
|
||||||
|
Assert.Equal(2, result.Themes[0].CurrentOriginalJoinCount);
|
||||||
|
Assert.Equal(1, result.Themes[0].CurrentLiveJoinCount);
|
||||||
|
Assert.Equal(2, result.Themes[0].CurrentKospiMasterJoinCount);
|
||||||
|
Assert.Equal(1, result.Themes[0].CurrentKospiOnlineJoinCount);
|
||||||
|
Assert.Equal(1, result.Themes[1].InputIndex);
|
||||||
|
Assert.Equal(4, result.Themes[1].RawCurrentItemCount);
|
||||||
|
Assert.Equal(1, result.Themes[1].RawStoredItemCount);
|
||||||
|
Assert.Equal(1, result.Themes[1].StoredLiveJoinCount);
|
||||||
|
Assert.Equal(2, executor.Calls.Count);
|
||||||
|
Assert.Equal(0, executor.StringSqlCallCount);
|
||||||
|
|
||||||
|
Assert.All(executor.Calls, call =>
|
||||||
|
{
|
||||||
|
Assert.Equal(DataSourceKind.MariaDb, call.Source);
|
||||||
|
Assert.Equal(LegacyNxtThemeJoinAuditService.QueryName, call.QueryName);
|
||||||
|
call.Spec.ValidateFor(DataSourceKind.MariaDb);
|
||||||
|
Assert.Contains(
|
||||||
|
"SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE",
|
||||||
|
call.Spec.Sql,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains("RIGHT(i.SB_I_CODE, 6)", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("FROM N_STOCK q", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("FROM N_KOSDAQ_ONLINE q", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("AND EXISTS (", call.Spec.Sql, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||||
|
call.Spec.Sql,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Collection(
|
||||||
|
call.Spec.Parameters,
|
||||||
|
current =>
|
||||||
|
{
|
||||||
|
Assert.Equal("current_theme_code", current.Name);
|
||||||
|
Assert.Equal(DbType.String, current.DbType);
|
||||||
|
},
|
||||||
|
stored =>
|
||||||
|
{
|
||||||
|
Assert.Equal("stored_theme_code", stored.Name);
|
||||||
|
Assert.Equal(DbType.String, stored.DbType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(InvalidIdentities))]
|
||||||
|
public async Task AuditAsync_RejectsInvalidOrDuplicateIdentitiesBeforeDatabaseAccess(
|
||||||
|
IReadOnlyList<LegacyNxtThemeJoinAuditIdentity> identities)
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||||
|
var service = new LegacyNxtThemeJoinAuditService(executor);
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<ArgumentException>(() => service.AuditAsync(identities));
|
||||||
|
|
||||||
|
Assert.Empty(executor.Calls);
|
||||||
|
Assert.Equal(0, executor.StringSqlCallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<IReadOnlyList<LegacyNxtThemeJoinAuditIdentity>> InvalidIdentities =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Array.Empty<LegacyNxtThemeJoinAuditIdentity>(),
|
||||||
|
new LegacyNxtThemeJoinAuditIdentity[] { new("12", "123") },
|
||||||
|
new LegacyNxtThemeJoinAuditIdentity[] { new("123", "A123") },
|
||||||
|
new LegacyNxtThemeJoinAuditIdentity[]
|
||||||
|
{
|
||||||
|
new("123", "111"),
|
||||||
|
new("123", "222")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuditAsync_FailsClosedOnWrongSchemaNonCanonicalCountOrInconsistentSubset()
|
||||||
|
{
|
||||||
|
var tables = new Queue<DataTable>(
|
||||||
|
[
|
||||||
|
new DataTable(),
|
||||||
|
Table("01", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
|
||||||
|
"0", "0", "0", "0"),
|
||||||
|
Table("1", "0", "1", "2", "0", "0", "0", "0", "0", "0", "0", "1",
|
||||||
|
"0", "0", "0", "0"),
|
||||||
|
Table("1", "0", "2", "0", "0", "0", "0", "0", "0", "0", "0", "1",
|
||||||
|
"0", "0", "0", "0"),
|
||||||
|
Table("1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "1",
|
||||||
|
"2", "0", "0", "0"),
|
||||||
|
Table("2", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "2",
|
||||||
|
"1", "2", "0", "0")
|
||||||
|
]);
|
||||||
|
var service = new LegacyNxtThemeJoinAuditService(
|
||||||
|
new RecordingExecutor(_ => tables.Dequeue()));
|
||||||
|
var identity = new[] { new LegacyNxtThemeJoinAuditIdentity("123", "456") };
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuditAsync_HonorsPreCanceledTokenWithoutDatabaseAccess()
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
cancellation.Cancel();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||||
|
new LegacyNxtThemeJoinAuditService(executor).AuditAsync(
|
||||||
|
[new("123", "456")],
|
||||||
|
cancellation.Token));
|
||||||
|
|
||||||
|
Assert.Empty(executor.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataTable Table(params string[] counts)
|
||||||
|
{
|
||||||
|
Assert.Equal(16, counts.Length);
|
||||||
|
var table = new DataTable();
|
||||||
|
table.Columns.Add("RAW_CURRENT_ITEM_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("RAW_STORED_ITEM_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_ORIGINAL_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_NON_STOPPED_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_LIVE_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("STORED_ORIGINAL_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("STORED_NON_STOPPED_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("STORED_LIVE_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_EXACT_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_STRIP_BOTH_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_RIGHT_SIX_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_VALID_ITEM_CODE_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_KOSPI_MASTER_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_KOSDAQ_MASTER_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_KOSPI_ONLINE_JOIN_COUNT", typeof(string));
|
||||||
|
table.Columns.Add("CURRENT_KOSDAQ_ONLINE_JOIN_COUNT", typeof(string));
|
||||||
|
table.Rows.Add(counts.Cast<object>().ToArray());
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RecordedCall(
|
||||||
|
DataSourceKind Source,
|
||||||
|
string QueryName,
|
||||||
|
DataQuerySpec Spec,
|
||||||
|
CancellationToken CancellationToken);
|
||||||
|
|
||||||
|
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
|
||||||
|
: IDataQueryExecutor
|
||||||
|
{
|
||||||
|
public List<RecordedCall> Calls { get; } = [];
|
||||||
|
|
||||||
|
public int StringSqlCallCount { get; private set; }
|
||||||
|
|
||||||
|
public DataTable Execute(DataSourceKind source, string tableName, string query)
|
||||||
|
{
|
||||||
|
StringSqlCallCount++;
|
||||||
|
throw new InvalidOperationException("String SQL must not be used.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DataTable> ExecuteAsync(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
string sql,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
StringSqlCallCount++;
|
||||||
|
throw new InvalidOperationException("String SQL must not be used.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DataTable> ExecuteAsync(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
DataQuerySpec query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||||
|
Calls.Add(call);
|
||||||
|
return Task.FromResult(handler(call));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => value.ToUniversalTime();
|
||||||
|
|
||||||
|
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyPListReadAuditParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Parse_PreservesEveryOriginalFieldAndOnlyNormalizesDisplayPageSpaces()
|
||||||
|
{
|
||||||
|
const string text = "1^테마^반도체 장비^5단 표그래프^테마-현재가(입력순)^ 1 / 12 ^00123";
|
||||||
|
|
||||||
|
var result = LegacyPListReadAuditParser.Parse(
|
||||||
|
"00000001",
|
||||||
|
"아침 방송",
|
||||||
|
"0",
|
||||||
|
text);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var row = Assert.IsType<LegacyPListAuditRow>(result.Row);
|
||||||
|
Assert.Equal(text, row.RawListText);
|
||||||
|
Assert.Equal(text, string.Join('^', row.Fields));
|
||||||
|
Assert.True(row.IsLossless);
|
||||||
|
Assert.True(row.IsEnabled);
|
||||||
|
Assert.Equal("테마", row.GroupCode);
|
||||||
|
Assert.Equal("반도체 장비", row.Subject);
|
||||||
|
Assert.Equal(" 1 / 12 ", row.RawPageText);
|
||||||
|
Assert.Equal("1/12", row.PageTextForDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1^테마^반도체^5단^현재가^1/1", LegacyPListAuditParseFailure.InvalidFieldCount)]
|
||||||
|
[InlineData("2^테마^반도체^5단^현재가^1/1^1", LegacyPListAuditParseFailure.InvalidEnabledField)]
|
||||||
|
[InlineData("1^테마^반도체^5단^현재가^1/1^1^extra", LegacyPListAuditParseFailure.InvalidFieldCount)]
|
||||||
|
public void Parse_RejectsAnythingOutsideTheOriginalSevenFieldGrammar(
|
||||||
|
string text,
|
||||||
|
LegacyPListAuditParseFailure expected)
|
||||||
|
{
|
||||||
|
var result = LegacyPListReadAuditParser.Parse("00000001", "방송", "0", text);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(expected, result.Failure);
|
||||||
|
Assert.Null(result.Row);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_RejectsReplacementAndKnownCodepageDamageWithoutEchoingText()
|
||||||
|
{
|
||||||
|
var replacement = LegacyPListReadAuditParser.Parse(
|
||||||
|
"00000001", "방송", "0", "1^테마^반도체<EB8F84>^5단^현재가^1/1^1");
|
||||||
|
var mojibake = LegacyPListReadAuditParser.Parse(
|
||||||
|
"00000001", "방송", "0", "1^테마^ì Â^5단^현재가^1/1^1");
|
||||||
|
|
||||||
|
Assert.Equal(LegacyPListAuditParseFailure.ReplacementCharacter, replacement.Failure);
|
||||||
|
Assert.Equal(LegacyPListAuditParseFailure.MojibakeSignal, mojibake.Failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_RejectsUnpairedUtf16SurrogatesButPreservesValidScalarPairs()
|
||||||
|
{
|
||||||
|
var unpaired = LegacyPListReadAuditParser.Parse(
|
||||||
|
"00000001", "방송", "0", "1^테마^반도체\uD800^5단^현재가^1/1^1");
|
||||||
|
var validPair = LegacyPListReadAuditParser.Parse(
|
||||||
|
"00000001", "방송 📈", "0", "1^테마^반도체 📈^5단^현재가^1/1^1");
|
||||||
|
|
||||||
|
Assert.Equal(LegacyPListAuditParseFailure.UnsafeText, unpaired.Failure);
|
||||||
|
Assert.True(validPair.IsSuccess);
|
||||||
|
Assert.True(validPair.Row!.IsLossless);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("0000001")]
|
||||||
|
[InlineData("0000000A")]
|
||||||
|
[InlineData(" 00000001")]
|
||||||
|
public void Parse_RequiresTheOriginalEightDigitProgramIdentity(string code)
|
||||||
|
{
|
||||||
|
var result = LegacyPListReadAuditParser.Parse(
|
||||||
|
code, "방송", "0", "1^테마^반도체^5단^현재가^1/1^1");
|
||||||
|
|
||||||
|
Assert.Equal(LegacyPListAuditParseFailure.InvalidProgramCode, result.Failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("00")]
|
||||||
|
[InlineData("-1")]
|
||||||
|
[InlineData("1000")]
|
||||||
|
[InlineData(" 0")]
|
||||||
|
public void Parse_RequiresCanonicalBoundedItemIndex(string itemIndex)
|
||||||
|
{
|
||||||
|
var result = LegacyPListReadAuditParser.Parse(
|
||||||
|
"00000001", "방송", itemIndex, "1^테마^반도체^5단^현재가^1/1^1");
|
||||||
|
|
||||||
|
Assert.Equal(LegacyPListAuditParseFailure.InvalidItemIndex, result.Failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_AllowsEmptyLegacySelectionFieldsAndLiteralQuestionMarks()
|
||||||
|
{
|
||||||
|
const string text = "0^^확인?^^^1/1^";
|
||||||
|
|
||||||
|
var result = LegacyPListReadAuditParser.Parse("00000001", "질문?", "0", text);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Row!.IsEnabled);
|
||||||
|
Assert.Equal(string.Empty, result.Row.GroupCode);
|
||||||
|
Assert.Equal(string.Empty, result.Row.DataCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evidence_RequiresContiguousIndexesAndExactPersistenceFields()
|
||||||
|
{
|
||||||
|
var first = Parse("0", "1^업종_코스피^코스피^네모그래프^^1/1^");
|
||||||
|
var second = Parse("1", "0^업종_코스피^코스피^섹터지수^^^");
|
||||||
|
var stored = new NamedPlaylistStoredItem(
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
||||||
|
"업종_코스피", "코스피", "네모그래프", "", ""),
|
||||||
|
new NamedPlaylistPageState(1, 1));
|
||||||
|
|
||||||
|
Assert.True(LegacyPListReadAuditEvidence.HasContiguousIndexes([first, second]));
|
||||||
|
Assert.False(LegacyPListReadAuditEvidence.HasContiguousIndexes([
|
||||||
|
first,
|
||||||
|
second with { ItemIndex = 2 }
|
||||||
|
]));
|
||||||
|
Assert.True(LegacyPListReadAuditEvidence.MatchesPersistenceResult(first, stored));
|
||||||
|
Assert.False(LegacyPListReadAuditEvidence.MatchesPersistenceResult(
|
||||||
|
first with { RawListText = first.RawListText.Replace("코스피", "코스닥") },
|
||||||
|
stored with { IsEnabled = false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1^업종_코스피^코스피^네모그래프^^1/1^", LegacyPListAuditRoute.IndustryFixedClosedMapper)]
|
||||||
|
[InlineData("1^해외지수^다우존스^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity)]
|
||||||
|
[InlineData("1^테마_NXT^반도체(NXT)^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.NxtThemeFreshIdentity)]
|
||||||
|
[InlineData("1^테마^반도체^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.KrxThemeFreshIdentity)]
|
||||||
|
[InlineData("1^해외종목^APPLE^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignStockFreshIdentity)]
|
||||||
|
[InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.ExistingClosedMapper)]
|
||||||
|
[InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@𐐀|US|1", LegacyPListAuditRoute.ExistingClosedMapper)]
|
||||||
|
[InlineData("1^FOREIGN_STOCK^NVIDIA^MA5^PRICE^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.Unmapped)]
|
||||||
|
[InlineData("1^코스피^삼성전자^성장성 지표^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
|
||||||
|
[InlineData("1^KOSPI^삼성전자^GROWTH_METRICS^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
|
||||||
|
[InlineData("1^INDIVIDUAL^INDEX^MANUAL_NET_SELL^MANUAL_NET_SELL^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
|
||||||
|
[InlineData("1^PAGED_VI^@MBN_VI_SNAPSHOT_V1^INPUT_ORDER^CURRENT^1/1^aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", LegacyPListAuditRoute.ManualFreshMaterialization)]
|
||||||
|
[InlineData("1^코스피^삼성전자^캔들그래프^20일^1/1^005930", LegacyPListAuditRoute.ExistingClosedMapper)]
|
||||||
|
[InlineData("1^ALL^INDEX^PROGRAM_TRADING^TABLE_GRAPH^1/1^", LegacyPListAuditRoute.ExistingClosedMapper)]
|
||||||
|
[InlineData("1^알수없는그룹^알수없는대상^비슷한그래프^^1/1^", LegacyPListAuditRoute.Unmapped)]
|
||||||
|
public void Evidence_RoutesSpecialRowsWithoutGuessing(
|
||||||
|
string text,
|
||||||
|
LegacyPListAuditRoute expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, LegacyPListReadAuditEvidence.ClassifyRoute(Parse("0", text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evidence_DoesNotTreatNearestStockLabelAsAClosedMapperMatch()
|
||||||
|
{
|
||||||
|
var exact = Parse("0", "1^코스피^삼성전자^수익률그래프^20일^1/1^005930");
|
||||||
|
var nearest = Parse("0", "1^코스피^삼성전자^수익률 그래프^20일^1/1^005930");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyPListAuditRoute.ExistingClosedMapper,
|
||||||
|
LegacyPListReadAuditEvidence.ClassifyRoute(exact));
|
||||||
|
Assert.Equal(
|
||||||
|
LegacyPListAuditRoute.Unmapped,
|
||||||
|
LegacyPListReadAuditEvidence.ClassifyRoute(nearest));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyPListAuditRow Parse(string itemIndex, string text) =>
|
||||||
|
Assert.IsType<LegacyPListAuditRow>(LegacyPListReadAuditParser.Parse(
|
||||||
|
"00000001", "방송", itemIndex, text).Row);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const stock = require("../../Web/operator-workflow.js");
|
|||||||
const fixed = require("../../Web/legacy-fixed-workflow.js");
|
const fixed = require("../../Web/legacy-fixed-workflow.js");
|
||||||
const industry = require("../../Web/industry-workflow.js");
|
const industry = require("../../Web/industry-workflow.js");
|
||||||
const overseas = require("../../Web/overseas-workflow.js");
|
const overseas = require("../../Web/overseas-workflow.js");
|
||||||
|
const namedForeignStock = require("../../Web/named-foreign-stock-restore-workflow.js");
|
||||||
const tradingHalt = require("../../Web/trading-halt-workflow.js");
|
const tradingHalt = require("../../Web/trading-halt-workflow.js");
|
||||||
|
|
||||||
const adapters = { stock, fixed, industry, overseas, tradingHalt };
|
const adapters = { stock, fixed, industry, overseas, tradingHalt };
|
||||||
@@ -35,6 +36,37 @@ const haltedStock = Object.freeze({
|
|||||||
function entries() {
|
function entries() {
|
||||||
const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available);
|
const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available);
|
||||||
const industryCut = industry.cuts.find(value => value.kind === "candle");
|
const industryCut = industry.cuts.find(value => value.kind === "candle");
|
||||||
|
const pendingNamedForeign = namedForeignStock.classify({
|
||||||
|
itemIndex: 0,
|
||||||
|
enabled: false,
|
||||||
|
groupCode: "해외종목",
|
||||||
|
subject: "NVIDIA",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "120일",
|
||||||
|
pageText: "1/1",
|
||||||
|
dataCode: ""
|
||||||
|
}, options("named-overseas-candle"));
|
||||||
|
const namedResponse = namedForeignStock.normalizeResponse({
|
||||||
|
requestId: "named-overseas-load",
|
||||||
|
retrievedAt: "2026-07-12T12:34:56+09:00",
|
||||||
|
totalRowCount: 1,
|
||||||
|
rows: [{
|
||||||
|
itemIndex: 0,
|
||||||
|
status: "resolved",
|
||||||
|
actionId: "stock-candle-120d",
|
||||||
|
inputName: "NVIDIA",
|
||||||
|
symbol: "NAS@NVDA",
|
||||||
|
nationCode: "US",
|
||||||
|
foreignDomesticCode: "1",
|
||||||
|
builderKey: "s8010",
|
||||||
|
cutCode: "8051",
|
||||||
|
valueType: "PRICE",
|
||||||
|
periodDays: 120
|
||||||
|
}]
|
||||||
|
}, "named-overseas-load", [pendingNamedForeign]);
|
||||||
|
const namedForeignEntry = namedForeignStock.materialize(
|
||||||
|
pendingNamedForeign,
|
||||||
|
namedResponse.rows[0]);
|
||||||
return [
|
return [
|
||||||
stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")),
|
stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")),
|
||||||
fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")),
|
fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")),
|
||||||
@@ -46,6 +78,7 @@ function entries() {
|
|||||||
overseasStock,
|
overseasStock,
|
||||||
"stock-candle-120d",
|
"stock-candle-120d",
|
||||||
options("overseas-candle")),
|
options("overseas-candle")),
|
||||||
|
namedForeignEntry,
|
||||||
tradingHalt.createTradingHaltPlaylistEntry(
|
tradingHalt.createTradingHaltPlaylistEntry(
|
||||||
haltedStock,
|
haltedStock,
|
||||||
"candle-120d",
|
"candle-120d",
|
||||||
@@ -61,13 +94,35 @@ test("global flags map to the exact closed candle graphic type", () => {
|
|||||||
assert.throws(() => workflow.expectedGraphicType("yes", true));
|
assert.throws(() => workflow.expectedGraphicType("yes", true));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all five original candle entry sources can be recreated with global flags", () => {
|
test("all original and DB-restored candle entry sources can be recreated with global flags", () => {
|
||||||
for (const entry of entries()) {
|
for (const entry of entries()) {
|
||||||
const recreated = workflow.recreateCandleEntry(entry, true, true, adapters);
|
const recreated = workflow.recreateCandleEntry(entry, true, true, adapters);
|
||||||
assert.ok(recreated, entry.operator.source);
|
assert.ok(recreated, entry.operator.source);
|
||||||
assert.equal(recreated.selection.graphicType, "MA5,MA20");
|
assert.equal(recreated.selection.graphicType, "MA5,MA20");
|
||||||
assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true });
|
assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true });
|
||||||
assert.equal(recreated.enabled, entry.enabled);
|
assert.equal(recreated.enabled, entry.enabled);
|
||||||
|
if (entry.operator.source === namedForeignStock.source) {
|
||||||
|
assert.equal(recreated.operator.source, namedForeignStock.source);
|
||||||
|
assert.deepEqual(recreated.operator.rawSelection, entry.operator.rawSelection);
|
||||||
|
assert.ok(namedForeignStock.restorePlaylistEntry(recreated));
|
||||||
|
assert.deepEqual(namedForeignStock.legacySelectionForEntry(recreated), {
|
||||||
|
groupCode: "해외종목",
|
||||||
|
subject: "NVIDIA",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "120일",
|
||||||
|
dataCode: ""
|
||||||
|
});
|
||||||
|
assert.equal(namedForeignStock.matchesRaw(recreated, {
|
||||||
|
itemIndex: 0,
|
||||||
|
enabled: false,
|
||||||
|
groupCode: "해외종목",
|
||||||
|
subject: "NVIDIA",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "120일",
|
||||||
|
pageText: "1/1",
|
||||||
|
dataCode: ""
|
||||||
|
}), true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,25 @@ test("legacy signature round-trips only the exact verified entry", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("enabled replacement preserves the branded foreign-index proof immutably", () => {
|
||||||
|
const storedRaw = raw("S&P500", "20일", 6, true);
|
||||||
|
const pending = workflow.classify(storedRaw, {
|
||||||
|
id: "foreign_toggle", fadeDuration: 6
|
||||||
|
});
|
||||||
|
const normalized = workflow.normalizeResponse(response("load_toggle", [pending]),
|
||||||
|
"load_toggle", [pending]);
|
||||||
|
const entry = workflow.materialize(pending, normalized.rows[0]);
|
||||||
|
const replacement = workflow.withEnabled(entry, false);
|
||||||
|
|
||||||
|
assert.notEqual(replacement, entry);
|
||||||
|
assert.equal(Object.isFrozen(replacement), true);
|
||||||
|
assert.equal(entry.enabled, true);
|
||||||
|
assert.equal(replacement.enabled, false);
|
||||||
|
assert.equal(workflow.isMaterializedEntry(replacement), true);
|
||||||
|
assert.equal(workflow.matchesRaw(replacement, { ...storedRaw, enabled: false }), true);
|
||||||
|
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("bridge errors are request-bound and never retryable", () => {
|
test("bridge errors are request-bound and never retryable", () => {
|
||||||
const error = {
|
const error = {
|
||||||
requestId: "load_error",
|
requestId: "load_error",
|
||||||
|
|||||||
@@ -605,6 +605,24 @@ test("playlist entry has the complete standard operator metadata and native trus
|
|||||||
}, { id: "forged" }), /native-confirmed/);
|
}, { id: "forged" }), /native-confirmed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("enabled replacement keeps native financial trust without mutating the frozen entry", () => {
|
||||||
|
const snapshot = trustedSnapshot("sales");
|
||||||
|
const stock = verifiedStock(snapshot.record);
|
||||||
|
const selection = trustedSelection(snapshot, stock);
|
||||||
|
const entry = workflow.createPlaylistEntry(snapshot, stock, selection, {
|
||||||
|
id: "sales-toggle",
|
||||||
|
fadeDuration: 6
|
||||||
|
});
|
||||||
|
const replacement = workflow.withEnabled(entry, false);
|
||||||
|
|
||||||
|
assert.notEqual(replacement, entry);
|
||||||
|
assert.equal(Object.isFrozen(replacement), true);
|
||||||
|
assert.equal(entry.enabled, true);
|
||||||
|
assert.equal(replacement.enabled, false);
|
||||||
|
assert.equal(workflow.isPlaylistEntryPlayable(replacement), true);
|
||||||
|
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("stored entries restore only as non-playable refresh-required descriptors", () => {
|
test("stored entries restore only as non-playable refresh-required descriptors", () => {
|
||||||
const snapshot = trustedSnapshot("sales");
|
const snapshot = trustedSnapshot("sales");
|
||||||
const stock = verifiedStock(snapshot.record);
|
const stock = verifiedStock(snapshot.record);
|
||||||
|
|||||||
@@ -361,6 +361,29 @@ test("only entries bound to a correlated fresh read are playable", () => {
|
|||||||
viRead)), false);
|
viRead)), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("enabled replacement transfers only the correlated manual-list read capability", () => {
|
||||||
|
const netRead = workflow.normalizeNetSellDataResponse({
|
||||||
|
requestId,
|
||||||
|
audience: "FOREIGN",
|
||||||
|
rows: rows()
|
||||||
|
}, { requestId, audience: "FOREIGN" });
|
||||||
|
const entry = workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||||
|
id: "entry_net_toggle",
|
||||||
|
fadeDuration: 6
|
||||||
|
}, netRead);
|
||||||
|
const replacement = workflow.withEnabled(entry, false);
|
||||||
|
|
||||||
|
assert.notEqual(replacement, entry);
|
||||||
|
assert.equal(entry.enabled, true);
|
||||||
|
assert.equal(replacement.enabled, false);
|
||||||
|
assert.equal(workflow.isPlaylistEntryPlayable(replacement), true);
|
||||||
|
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||||
|
assert.equal(workflow.withEnabled(workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||||
|
id: "entry_net_toggle_untrusted",
|
||||||
|
fadeDuration: 6
|
||||||
|
}), false), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("request identifiers and option bounds are strict", () => {
|
test("request identifiers and option bounds are strict", () => {
|
||||||
assert.throws(() => workflow.createStatusRequest("bad id"));
|
assert.throws(() => workflow.createStatusRequest("bad id"));
|
||||||
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {
|
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||||
|
|||||||
136
tests/Web/named-foreign-stock-restore-app-integration.test.cjs
Normal file
136
tests/Web/named-foreign-stock-restore-app-integration.test.cjs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const foreignStock = require("../../Web/named-foreign-stock-restore-workflow.js");
|
||||||
|
const legacyRows = require("../../Web/legacy-named-row-workflow.js");
|
||||||
|
const namedPlaylists = require("../../Web/named-playlist-workflow.js");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "../..");
|
||||||
|
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
|
||||||
|
const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8");
|
||||||
|
const bridge = fs.readFileSync(
|
||||||
|
path.join(root, "MainWindow.NamedForeignStockRestore.cs"), "utf8");
|
||||||
|
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
|
||||||
|
const core = fs.readFileSync(
|
||||||
|
path.join(root, "src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs"),
|
||||||
|
"utf8");
|
||||||
|
|
||||||
|
function rawRow(enabled = false) {
|
||||||
|
return {
|
||||||
|
itemIndex: 0,
|
||||||
|
enabled,
|
||||||
|
groupCode: "해외종목",
|
||||||
|
subject: "NVIDIA",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "240일",
|
||||||
|
pageText: "1/1",
|
||||||
|
dataCode: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function materializedEntry() {
|
||||||
|
const raw = rawRow();
|
||||||
|
const pending = foreignStock.classify(raw, {
|
||||||
|
id: "db-foreign-stock-240",
|
||||||
|
fadeDuration: 6,
|
||||||
|
ma5: true,
|
||||||
|
ma20: false
|
||||||
|
});
|
||||||
|
const response = foreignStock.normalizeResponse({
|
||||||
|
requestId: "foreign-stock-load",
|
||||||
|
retrievedAt: "2026-07-12T12:34:56+09:00",
|
||||||
|
totalRowCount: 1,
|
||||||
|
rows: [{
|
||||||
|
itemIndex: 0,
|
||||||
|
status: "resolved",
|
||||||
|
actionId: "stock-candle-240d",
|
||||||
|
inputName: "NVIDIA",
|
||||||
|
symbol: "NAS@NVDA",
|
||||||
|
nationCode: "US",
|
||||||
|
foreignDomesticCode: "1",
|
||||||
|
builderKey: "s8010",
|
||||||
|
cutCode: "8056",
|
||||||
|
valueType: "PRICE",
|
||||||
|
periodDays: 240
|
||||||
|
}]
|
||||||
|
}, "foreign-stock-load", [pending]);
|
||||||
|
return foreignStock.materialize(pending, response.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("named load connects one correlated native foreign-stock restore batch end to end", () => {
|
||||||
|
assert.match(index, /<script src="named-foreign-stock-restore-workflow\.js"><\/script>[\s\S]*<script src="legacy-named-row-workflow\.js"><\/script>/u);
|
||||||
|
assert.match(app, /MbnNamedForeignStockRestoreWorkflow/u);
|
||||||
|
assert.match(app, /namedForeignStockRestoreWorkflow\.classify/u);
|
||||||
|
assert.match(app, /namedForeignStockRestoreWorkflow\.normalizeResponse/u);
|
||||||
|
assert.match(app, /namedForeignStockRestoreWorkflow\.materialize/u);
|
||||||
|
assert.match(app, /case "named-foreign-stock-restore-results"/u);
|
||||||
|
assert.match(app, /case "named-foreign-stock-restore-error"/u);
|
||||||
|
assert.match(namedBridge, /await PostNamedForeignStockRestoreAsync\(/u);
|
||||||
|
assert.match(bridge, /PostMessage\("named-foreign-stock-restore-results"/u);
|
||||||
|
assert.match(bridge, /PostMessage\("named-foreign-stock-restore-error"/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Core uses one exact bound SELECT and native bridge never writes or calls playout", () => {
|
||||||
|
assert.match(core, /F_INPUT_NAME = :input_name/u);
|
||||||
|
assert.match(core, /F_FDTC = '1'/u);
|
||||||
|
assert.match(core, /F_NATC IN \('US', 'TW'\)/u);
|
||||||
|
assert.match(core, /WHERE ROWNUM <= 2/u);
|
||||||
|
assert.match(core, /new DataQueryParameter\("input_name"/u);
|
||||||
|
assert.doesNotMatch(core, /\bLIKE\b/iu);
|
||||||
|
assert.doesNotMatch(core, /\b(?:INSERT\s+INTO|UPDATE\s+[A-Z_]|DELETE\s+FROM|MERGE\s+INTO)\b/iu);
|
||||||
|
assert.doesNotMatch(bridge, /Tornado|TakeIn|PrepareAsync|ExecuteNonQuery/iu);
|
||||||
|
assert.match(bridge, /retryable = false/u);
|
||||||
|
assert.match(bridge, /자동 재시도하지 않습니다/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending state blocks edits and timeout is terminal without an automatic retry", () => {
|
||||||
|
assert.match(app, /state\.namedPlaylist\.foreignStockRestorePending/u);
|
||||||
|
assert.match(app, /armNamedForeignStockRestoreTimeout/u);
|
||||||
|
assert.match(app, /setTimeout\(\(\) => \{[\s\S]*자동 재시도하지 않습니다\.[\s\S]*\}, 30000\)/u);
|
||||||
|
assert.match(app, /comparisonRestorePending \|\|[\s\S]*foreignStockRestorePending \|\|[\s\S]*foreignIndexCandleRestorePending/u);
|
||||||
|
assert.match(app, /pendingForeignStocks\.length === 0/u);
|
||||||
|
assert.match(app, /clearNamedForeignStockRestoreTimeout\(record\)/u);
|
||||||
|
assert.doesNotMatch(app, /retryNamedForeignStock|requestNamedForeignStockRestore/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verified identity and moving averages preserve the exact original seven fields for save", () => {
|
||||||
|
const entry = materializedEntry();
|
||||||
|
const raw = rawRow();
|
||||||
|
assert.ok(entry);
|
||||||
|
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
|
||||||
|
const selection = legacyRows.legacySelectionForEntry(entry);
|
||||||
|
assert.deepEqual(selection, {
|
||||||
|
groupCode: "해외종목",
|
||||||
|
subject: "NVIDIA",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "240일",
|
||||||
|
dataCode: ""
|
||||||
|
});
|
||||||
|
assert.deepEqual(entry.operator.movingAverages, { ma5: true, ma20: false });
|
||||||
|
assert.deepEqual(namedPlaylists.toLegacySevenFields({ ...entry, selection }, "1/1"), [
|
||||||
|
"0", "해외종목", "NVIDIA", "캔들그래프", "240일", "1/1", ""
|
||||||
|
]);
|
||||||
|
assert.match(app, /해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다/u);
|
||||||
|
assert.match(app, /namedForeignStockRestoreWorkflow\.matchesRaw\(restoredForeignStock, rawRow\)/u);
|
||||||
|
assert.match(app, /item\.operator\?\.source === namedForeignStockRestoreWorkflow\.source\) return item/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonical FOREIGN_STOCK remains on the existing workflow and cannot collide", () => {
|
||||||
|
assert.equal(foreignStock.classify({
|
||||||
|
...rawRow(true),
|
||||||
|
groupCode: "FOREIGN_STOCK",
|
||||||
|
graphicType: "MA5",
|
||||||
|
subtype: "PRICE",
|
||||||
|
dataCode: "NAS@NVDA|US|1"
|
||||||
|
}, {
|
||||||
|
id: "canonical-does-not-route",
|
||||||
|
fadeDuration: 6,
|
||||||
|
ma5: true,
|
||||||
|
ma20: false
|
||||||
|
}), null);
|
||||||
|
assert.match(app, /addOverseasNamedCandidates/u);
|
||||||
|
assert.match(app, /rawRow\.groupCode !== "FOREIGN_STOCK"/u);
|
||||||
|
});
|
||||||
251
tests/Web/named-foreign-stock-restore-workflow.test.cjs
Normal file
251
tests/Web/named-foreign-stock-restore-workflow.test.cjs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const workflow = require("../../Web/named-foreign-stock-restore-workflow.js");
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
["1열판기본", "", "stock-current", "s5001", "5001", "CURRENT", null],
|
||||||
|
["캔들그래프", "5일", "stock-candle-5d", "s8010", "8061", "PRICE", 5],
|
||||||
|
["캔들그래프", "20일", "stock-candle-20d", "s8010", "8040", "PRICE", 20],
|
||||||
|
["캔들그래프", "60일", "stock-candle-60d", "s8010", "8046", "PRICE", 60],
|
||||||
|
["캔들그래프", "120일", "stock-candle-120d", "s8010", "8051", "PRICE", 120],
|
||||||
|
["캔들그래프", "240일", "stock-candle-240d", "s8010", "8056", "PRICE", 240]
|
||||||
|
];
|
||||||
|
|
||||||
|
function raw(graphicType = "1열판기본", subtype = "", itemIndex = 0, enabled = true) {
|
||||||
|
return {
|
||||||
|
itemIndex,
|
||||||
|
enabled,
|
||||||
|
groupCode: "해외종목",
|
||||||
|
subject: "NVIDIA",
|
||||||
|
graphicType,
|
||||||
|
subtype,
|
||||||
|
pageText: "1/1",
|
||||||
|
dataCode: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(id = "foreign-stock-entry") {
|
||||||
|
return { id, fadeDuration: 6, ma5: true, ma20: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcome(pending, overrides = {}) {
|
||||||
|
return {
|
||||||
|
itemIndex: pending.itemIndex,
|
||||||
|
status: "resolved",
|
||||||
|
actionId: pending.actionId,
|
||||||
|
inputName: pending.inputName,
|
||||||
|
symbol: "NAS@NVDA",
|
||||||
|
nationCode: "US",
|
||||||
|
foreignDomesticCode: "1",
|
||||||
|
builderKey: pending.builderKey,
|
||||||
|
cutCode: pending.cutCode,
|
||||||
|
valueType: pending.valueType,
|
||||||
|
periodDays: pending.periodDays,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(requestId, pendingRows, rows = pendingRows.map(value => outcome(value))) {
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
retrievedAt: "2026-07-12T12:34:56+09:00",
|
||||||
|
totalRowCount: rows.length,
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("only the exact legacy overseas-stock grammar classifies all six UC5 actions", () => {
|
||||||
|
actions.forEach(([graphicType, subtype, actionId, builderKey, cutCode, valueType, periodDays], index) => {
|
||||||
|
const pending = workflow.classify(raw(graphicType, subtype, index, index !== 5), options(`foreign-${index}`));
|
||||||
|
assert.ok(pending);
|
||||||
|
assert.equal(workflow.isPending(pending), true);
|
||||||
|
assert.equal(pending.actionId, actionId);
|
||||||
|
assert.equal(pending.builderKey, builderKey);
|
||||||
|
assert.equal(pending.cutCode, cutCode);
|
||||||
|
assert.equal(pending.valueType, valueType);
|
||||||
|
assert.equal(pending.periodDays, periodDays);
|
||||||
|
assert.equal(pending.enabled, index !== 5);
|
||||||
|
assert.deepEqual(pending.movingAverages,
|
||||||
|
builderKey === "s8010" ? { ma5: true, ma20: false } : { ma5: false, ma20: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const invalid of [
|
||||||
|
{ ...raw(), groupCode: "FOREIGN_STOCK", dataCode: "NAS@NVDA|US|1" },
|
||||||
|
{ ...raw(), groupCode: " 해외종목" },
|
||||||
|
{ ...raw(), subject: " NVIDIA" },
|
||||||
|
{ ...raw(), graphicType: "CURRENT" },
|
||||||
|
{ ...raw("캔들그래프", "5일"), subtype: "1년" },
|
||||||
|
{ ...raw(), pageText: "1/2" },
|
||||||
|
{ ...raw(), dataCode: "NAS@NVDA" },
|
||||||
|
{ ...raw(), itemIndex: 1000 }
|
||||||
|
]) {
|
||||||
|
assert.equal(workflow.classify(invalid, options("invalid-row")), null);
|
||||||
|
}
|
||||||
|
assert.equal(workflow.classify(raw(), { ...options(), extra: true }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a correlated unique Oracle identity materializes current and candle entries", () => {
|
||||||
|
for (const [graphicType, subtype, , builderKey, cutCode] of actions) {
|
||||||
|
const pending = workflow.classify(raw(graphicType, subtype, 7, false), options(`entry-${cutCode}`));
|
||||||
|
const normalized = workflow.normalizeResponse(
|
||||||
|
response("load-one", [pending]),
|
||||||
|
"load-one",
|
||||||
|
[pending]);
|
||||||
|
assert.ok(normalized);
|
||||||
|
const entry = workflow.materialize(pending, normalized.rows[0]);
|
||||||
|
assert.ok(entry);
|
||||||
|
assert.equal(workflow.isMaterializedEntry(entry), true);
|
||||||
|
assert.equal(entry.builderKey, builderKey);
|
||||||
|
assert.equal(entry.code, cutCode);
|
||||||
|
assert.equal(entry.enabled, false);
|
||||||
|
assert.equal(entry.symbol, "NAS@NVDA");
|
||||||
|
assert.equal(entry.nationCode, "US");
|
||||||
|
assert.equal(entry.fdtc, "1");
|
||||||
|
assert.equal(entry.operator.source, workflow.source);
|
||||||
|
assert.equal(entry.selection.groupCode, "FOREIGN_STOCK");
|
||||||
|
assert.equal(entry.selection.dataCode, "NAS@NVDA|US|1");
|
||||||
|
assert.equal(entry.selection.graphicType, builderKey === "s8010" ? "MA5" : "1열판기본");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a correlated Hangul-letter provider symbol remains a bound closed identity", () => {
|
||||||
|
const pending = workflow.classify(
|
||||||
|
raw("캔들그래프", "5일", 8),
|
||||||
|
options("hangul-symbol"));
|
||||||
|
const normalized = workflow.normalizeResponse(response(
|
||||||
|
"hangul-symbol-load",
|
||||||
|
[pending],
|
||||||
|
[outcome(pending, { symbol: "NAS@해외 종목1" })]),
|
||||||
|
"hangul-symbol-load",
|
||||||
|
[pending]);
|
||||||
|
assert.ok(normalized);
|
||||||
|
const entry = workflow.materialize(pending, normalized.rows[0]);
|
||||||
|
assert.ok(entry);
|
||||||
|
assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");
|
||||||
|
assert.ok(workflow.restorePlaylistEntry(entry));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safe supplementary input-name and letter symbols match the native scalar contract", () => {
|
||||||
|
const pending = workflow.classify(
|
||||||
|
{ ...raw("1열판기본", "", 10), subject: "회사🚀" },
|
||||||
|
options("supplementary-symbol"));
|
||||||
|
const normalized = workflow.normalizeResponse(response(
|
||||||
|
"supplementary-symbol-load",
|
||||||
|
[pending],
|
||||||
|
[outcome(pending, { inputName: "회사🚀", symbol: "NAS@𐐀1" })]),
|
||||||
|
"supplementary-symbol-load",
|
||||||
|
[pending]);
|
||||||
|
|
||||||
|
assert.ok(normalized);
|
||||||
|
const entry = workflow.materialize(pending, normalized.rows[0]);
|
||||||
|
assert.ok(entry);
|
||||||
|
assert.equal(entry.selection.dataCode, "NAS@𐐀1|US|1");
|
||||||
|
assert.ok(workflow.restorePlaylistEntry(entry));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch count, order, request, action and identity correlation are strict", () => {
|
||||||
|
const first = workflow.classify(raw("캔들그래프", "5일", 4), options("first"));
|
||||||
|
const secondRaw = { ...raw("1열판기본", "", 9), subject: "TSMC" };
|
||||||
|
const second = workflow.classify(secondRaw, options("second"));
|
||||||
|
const rows = [outcome(first), outcome(second, { inputName: "TSMC", symbol: "TPE@2330", nationCode: "TW" })];
|
||||||
|
const exact = response("load-batch", [first, second], rows);
|
||||||
|
assert.ok(workflow.normalizeResponse(exact, "load-batch", [first, second]));
|
||||||
|
assert.equal(workflow.normalizeResponse(exact, "stale", [first, second]), null);
|
||||||
|
assert.equal(workflow.normalizeResponse({ ...exact, totalRowCount: 1 }, "load-batch", [first, second]), null);
|
||||||
|
assert.equal(workflow.normalizeResponse({ ...exact, rows: [...rows].reverse() }, "load-batch", [first, second]), null);
|
||||||
|
assert.equal(workflow.normalizeResponse({ ...exact, extra: true }, "load-batch", [first, second]), null);
|
||||||
|
|
||||||
|
for (const [key, value] of [
|
||||||
|
["actionId", "stock-current"],
|
||||||
|
["inputName", "TSMC"],
|
||||||
|
["nationCode", "KR"],
|
||||||
|
["foreignDomesticCode", "0"],
|
||||||
|
["builderKey", "s5001"],
|
||||||
|
["cutCode", "5001"],
|
||||||
|
["valueType", "VOLUME"],
|
||||||
|
["periodDays", 20]
|
||||||
|
]) {
|
||||||
|
const tampered = [{ ...rows[0], [key]: value }, rows[1]];
|
||||||
|
assert.equal(workflow.normalizeResponse({ ...exact, rows: tampered }, "load-batch", [first, second]), null, key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal per-row failures, forged capability objects and retryable errors fail closed", () => {
|
||||||
|
const pending = workflow.classify(raw("캔들그래프", "120일"), options("failed"));
|
||||||
|
for (const failure of [
|
||||||
|
"INVALID_ROW", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
|
||||||
|
"DATABASE_DATA_INVALID", "RESTORE_FAILED"
|
||||||
|
]) {
|
||||||
|
const normalized = workflow.normalizeResponse(response("failed-load", [pending], [{
|
||||||
|
itemIndex: pending.itemIndex,
|
||||||
|
status: "failed",
|
||||||
|
failure
|
||||||
|
}]), "failed-load", [pending]);
|
||||||
|
assert.ok(normalized);
|
||||||
|
assert.equal(workflow.materialize(pending, normalized.rows[0]), null);
|
||||||
|
}
|
||||||
|
const normalized = workflow.normalizeResponse(response("resolved-load", [pending]), "resolved-load", [pending]);
|
||||||
|
assert.equal(workflow.materialize({ ...pending }, normalized.rows[0]), null);
|
||||||
|
assert.equal(workflow.materialize(pending, { ...normalized.rows[0] }), null);
|
||||||
|
|
||||||
|
const error = {
|
||||||
|
requestId: "error-load",
|
||||||
|
code: "PLAYOUT_PRIORITY",
|
||||||
|
message: "송출 우선 처리로 취소했습니다.",
|
||||||
|
retryable: false
|
||||||
|
};
|
||||||
|
assert.deepEqual(workflow.normalizeError(error, "error-load"), error);
|
||||||
|
assert.equal(workflow.normalizeError({ ...error, retryable: true }, "error-load"), null);
|
||||||
|
assert.equal(workflow.normalizeError(error, "stale"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verified identity, moving averages and original seven-field signature round-trip together", () => {
|
||||||
|
const storedRaw = raw("캔들그래프", "240일", 3, false);
|
||||||
|
const pending = workflow.classify(storedRaw, options("round-trip"));
|
||||||
|
const normalized = workflow.normalizeResponse(response("round-trip-load", [pending]), "round-trip-load", [pending]);
|
||||||
|
const entry = workflow.materialize(pending, normalized.rows[0]);
|
||||||
|
const restored = workflow.restorePlaylistEntry(entry);
|
||||||
|
assert.ok(restored);
|
||||||
|
assert.deepEqual(restored.operator.movingAverages, { ma5: true, ma20: false });
|
||||||
|
assert.deepEqual(workflow.legacySelectionForEntry(restored), {
|
||||||
|
groupCode: "해외종목",
|
||||||
|
subject: "NVIDIA",
|
||||||
|
graphicType: "캔들그래프",
|
||||||
|
subtype: "240일",
|
||||||
|
dataCode: ""
|
||||||
|
});
|
||||||
|
assert.equal(workflow.matchesRaw(restored, storedRaw), true);
|
||||||
|
assert.equal(workflow.matchesRaw(restored, { ...storedRaw, subtype: "120일" }), false);
|
||||||
|
|
||||||
|
for (const tampered of [
|
||||||
|
{ ...entry, code: "8040" },
|
||||||
|
{ ...entry, selection: { ...entry.selection, dataCode: "TPE@2330|TW|1" } },
|
||||||
|
{ ...entry, operator: { ...entry.operator, identity: { ...entry.operator.identity, symbol: "TPE@2330" } } },
|
||||||
|
{ ...entry, operator: { ...entry.operator, rawSelection: { ...entry.operator.rawSelection, subtype: "120일" } } },
|
||||||
|
{ ...entry, operator: { ...entry.operator, movingAverages: { ma5: false, ma20: false } } },
|
||||||
|
{ ...entry, extra: true }
|
||||||
|
]) {
|
||||||
|
assert.equal(workflow.restorePlaylistEntry(tampered), null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enabled replacement preserves foreign-stock identity proof without mutating the frozen entry", () => {
|
||||||
|
const storedRaw = raw("1열판기본", "", 2, true);
|
||||||
|
const pending = workflow.classify(storedRaw, options("foreign-toggle"));
|
||||||
|
const normalized = workflow.normalizeResponse(response("foreign-toggle-load", [pending]),
|
||||||
|
"foreign-toggle-load", [pending]);
|
||||||
|
const entry = workflow.materialize(pending, normalized.rows[0]);
|
||||||
|
const replacement = workflow.withEnabled(entry, false);
|
||||||
|
|
||||||
|
assert.notEqual(replacement, entry);
|
||||||
|
assert.equal(Object.isFrozen(replacement), true);
|
||||||
|
assert.equal(entry.enabled, true);
|
||||||
|
assert.equal(replacement.enabled, false);
|
||||||
|
assert.equal(workflow.isMaterializedEntry(replacement), true);
|
||||||
|
assert.equal(workflow.matchesRaw(replacement, { ...storedRaw, enabled: false }), true);
|
||||||
|
assert.deepEqual(workflow.legacySelectionForEntry(replacement),
|
||||||
|
workflow.legacySelectionForEntry(entry));
|
||||||
|
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||||
|
});
|
||||||
83
tests/Web/named-krx-theme-restore-app-integration.test.cjs
Normal file
83
tests/Web/named-krx-theme-restore-app-integration.test.cjs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..", "..");
|
||||||
|
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
|
||||||
|
const index = fs.readFileSync(path.join(root, "Web", "index.html"), "utf8");
|
||||||
|
const native = fs.readFileSync(path.join(root, "MainWindow.NamedKrxThemeRestore.cs"), "utf8");
|
||||||
|
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
|
||||||
|
|
||||||
|
function functionBody(name, nextName) {
|
||||||
|
const start = app.indexOf(`function ${name}(`);
|
||||||
|
const end = app.indexOf(`function ${nextName}(`, start + 1);
|
||||||
|
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
|
||||||
|
return app.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("named KRX restore is correlated from one native load through page preflight", () => {
|
||||||
|
assert.match(index,
|
||||||
|
/src="named-krx-theme-restore-workflow\.js"[\s\S]*src="named-nxt-theme-restore-workflow\.js"/u);
|
||||||
|
assert.match(namedBridge,
|
||||||
|
/await PostNamedKrxThemeRestoreAsync\([\s\S]*await PostNamedNxtThemeRestoreAsync\(/u);
|
||||||
|
assert.match(native, /PostMessage\("named-krx-theme-restore-results"/u);
|
||||||
|
assert.match(app, /krxThemeRestorePending/u);
|
||||||
|
assert.match(app, /case "named-krx-theme-restore-results"/u);
|
||||||
|
assert.match(app, /case "named-krx-theme-restore-error"/u);
|
||||||
|
const finalize = functionBody("finalizeNamedManualRestores", "handleNamedManualListResults");
|
||||||
|
assert.match(finalize, /state\.namedPlaylist\.krxThemeRestorePending/u);
|
||||||
|
assert.match(finalize, /prepareNamedPagePreflight/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generic theme restore cannot trust a historical KRX code before native verification", () => {
|
||||||
|
const addTheme = functionBody("addThemeNamedCandidates", "addOverseasNamedCandidates");
|
||||||
|
assert.match(addTheme,
|
||||||
|
/rawRow\.groupCode === "테마" \|\| rawRow\.groupCode === "테마_NXT"/u);
|
||||||
|
const load = functionBody("handleNamedPlaylistLoadResults", "failNamedComparisonRestore");
|
||||||
|
assert.match(load, /namedKrxThemeRestoreWorkflow\.classify/u);
|
||||||
|
assert.match(load, /pendingKrxThemes\.length === 0/u);
|
||||||
|
assert.match(load, /krxThemeRestorePending = pendingKrxThemes\.length/u);
|
||||||
|
const resolve = functionBody("resolveNamedPlaylistRawRow", "prepareNamedPagePreflight");
|
||||||
|
assert.match(resolve,
|
||||||
|
/rawRow\?\.groupCode === "테마" \|\| rawRow\?\.groupCode === "테마_NXT"[\s\S]*return null/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("current KRX identity stays branded in memory and old code is retained only for save", () => {
|
||||||
|
const materialize = functionBody("materializeNamedRestoration", "namedPlaylistPrepareBlockers");
|
||||||
|
assert.match(materialize,
|
||||||
|
/namedKrxThemeRestoreWorkflow\.isMaterializedEntry\(row\.entry\)/u);
|
||||||
|
const synchronize = functionBody("synchronizeThemeEntries", "clearOverseasTimeout");
|
||||||
|
assert.match(synchronize,
|
||||||
|
/namedKrxThemeRestoreWorkflow\.isMaterializedEntry\(item\)[\s\S]*\? item/u);
|
||||||
|
const fields = functionBody("namedLegacyFieldsForEntry", "isTrustedNamedPlaylistEntry");
|
||||||
|
assert.match(fields, /namedKrxThemeRestoreWorkflow\.legacySelectionForEntry\(entry\)/u);
|
||||||
|
assert.match(fields, /namedKrxThemeRestoreWorkflow\.matchesRaw\(entry, rawRow\)/u);
|
||||||
|
assert.match(fields, /KRX 테마의 과거 code와 현재 송출 code/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("KRX failures are terminal and never trigger an automatic retry or playout", () => {
|
||||||
|
const failed = functionBody("failNamedKrxThemeRestore", "handleNamedKrxThemeRestoreResults");
|
||||||
|
assert.match(failed, /krxThemeRestorePending = null/u);
|
||||||
|
assert.doesNotMatch(app, /retryNamedKrxTheme|requestNamedKrxThemeRestore/u);
|
||||||
|
assert.doesNotMatch(native, /IPlayoutEngine|PrepareAsync|TakeInAsync|NextAsync|TakeOutAsync/u);
|
||||||
|
assert.match(native, /retryable = false/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("KRX success accounting and post-load cancellation close every correlated busy record", () => {
|
||||||
|
const results = functionBody("handleNamedKrxThemeRestoreResults", "handleNamedKrxThemeRestoreError");
|
||||||
|
assert.match(results,
|
||||||
|
/state\.playlist\[pending\.itemIndex\] = entry;\s*restoredCount \+= 1;/u);
|
||||||
|
|
||||||
|
const clear = functionBody("clearNamedPlaylistLoadState", "saveNamedPlaylistBackup");
|
||||||
|
assert.match(clear, /krxThemeRestorePending = null/u);
|
||||||
|
const busy = functionBody("namedPlaylistBusy", "renderNamedPlaylist");
|
||||||
|
assert.match(busy, /state\.namedPlaylist\.krxThemeRestorePending/u);
|
||||||
|
const loadError = functionBody("handleNamedPlaylistError", "normalizePlayoutState");
|
||||||
|
assert.match(loadError, /error\.operation !== "load"/u);
|
||||||
|
assert.match(loadError,
|
||||||
|
/state\.namedPlaylist\.krxThemeRestorePending\?\.requestId === error\.requestId[\s\S]*krxThemeRestorePending = null/u);
|
||||||
|
assert.match(loadError, /복원 배치는 자동 재시도하지 않습니다/u);
|
||||||
|
});
|
||||||
249
tests/Web/named-krx-theme-restore-workflow.test.cjs
Normal file
249
tests/Web/named-krx-theme-restore-workflow.test.cjs
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"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/named-krx-theme-restore-workflow.js");
|
||||||
|
|
||||||
|
function raw(overrides = {}) {
|
||||||
|
return {
|
||||||
|
itemIndex: 4,
|
||||||
|
enabled: true,
|
||||||
|
groupCode: "테마",
|
||||||
|
subject: "반도체",
|
||||||
|
graphicType: "5단 표그래프",
|
||||||
|
subtype: "테마-현재가(입력순)",
|
||||||
|
pageText: "1/9",
|
||||||
|
dataCode: "0123",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolved(overrides = {}) {
|
||||||
|
return {
|
||||||
|
itemIndex: 4,
|
||||||
|
status: "resolved",
|
||||||
|
actionId: "five-row-current",
|
||||||
|
builderKey: "s5074",
|
||||||
|
cutCode: "5074",
|
||||||
|
pageSize: 5,
|
||||||
|
sort: "INPUT_ORDER",
|
||||||
|
themeTitle: "반도체",
|
||||||
|
storedThemeCode: "0123",
|
||||||
|
currentThemeCode: "87654321",
|
||||||
|
previewItemCount: 7,
|
||||||
|
previewIsTruncated: false,
|
||||||
|
codeWasRemapped: true,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(row) {
|
||||||
|
return {
|
||||||
|
requestId: "load-1",
|
||||||
|
retrievedAt: "2026-07-12T12:59:00+09:00",
|
||||||
|
totalRowCount: 1,
|
||||||
|
rows: [row]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(sourceRaw = raw(), outcome = resolved()) {
|
||||||
|
const pending = workflow.classify(sourceRaw, { id: "db-4", fadeDuration: 6 });
|
||||||
|
const normalized = workflow.normalizeResponse(response(outcome), "load-1", [pending]);
|
||||||
|
return { pending, normalized, outcome: normalized?.rows[0] ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("classify routes every exact KRX theme group row for native fail-closed verification", () => {
|
||||||
|
const pending = workflow.classify(raw({
|
||||||
|
subject: "",
|
||||||
|
graphicType: "unsupported",
|
||||||
|
subtype: "",
|
||||||
|
pageText: "",
|
||||||
|
dataCode: ""
|
||||||
|
}), { id: "db-4", fadeDuration: 6 });
|
||||||
|
assert.equal(workflow.isPending(pending), true);
|
||||||
|
assert.equal(pending.rawSelection.groupCode, "테마");
|
||||||
|
assert.equal(workflow.classify(raw({ groupCode: "테마_NXT" }), {
|
||||||
|
id: "db-4",
|
||||||
|
fadeDuration: 6
|
||||||
|
}), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeResponse accepts one exact correlated current identity", () => {
|
||||||
|
const { normalized } = normalize();
|
||||||
|
assert.equal(normalized.rows[0].currentThemeCode, "87654321");
|
||||||
|
assert.equal(normalized.rows[0].previewItemCount, 7);
|
||||||
|
assert.deepEqual(workflow.bridgeContract, {
|
||||||
|
resultType: "named-krx-theme-restore-results",
|
||||||
|
errorType: "named-krx-theme-restore-error"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeResponse enforces exact wire schema, raw action correlation and preview bounds", () => {
|
||||||
|
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||||
|
for (const invalid of [
|
||||||
|
{ ...resolved(), extra: true },
|
||||||
|
resolved({ storedThemeCode: "999" }),
|
||||||
|
resolved({ themeTitle: "다른제목" }),
|
||||||
|
resolved({ actionId: "six-row-current", builderKey: "s5077", cutCode: "5077", pageSize: 6 }),
|
||||||
|
resolved({ sort: "GAIN_ASC" }),
|
||||||
|
resolved({ previewItemCount: 0 }),
|
||||||
|
resolved({ previewItemCount: 239, previewIsTruncated: true }),
|
||||||
|
resolved({ previewItemCount: 241, previewIsTruncated: true }),
|
||||||
|
resolved({ codeWasRemapped: false })
|
||||||
|
]) {
|
||||||
|
assert.equal(workflow.normalizeResponse(response(invalid), "load-1", [pending]), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(workflow.normalizeResponse({
|
||||||
|
requestId: "load-1",
|
||||||
|
retrievedAt: "2026-07-12T12:59:00+09:00",
|
||||||
|
totalRowCount: 2,
|
||||||
|
rows: [resolved({ itemIndex: 5 }), resolved({ itemIndex: 4 })]
|
||||||
|
}, "load-1", [
|
||||||
|
workflow.classify(raw({ itemIndex: 4 }), { id: "db-4", fadeDuration: 6 }),
|
||||||
|
workflow.classify(raw({ itemIndex: 5 }), { id: "db-5", fadeDuration: 6 })
|
||||||
|
]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed rows preserve a closed no-retry reason", () => {
|
||||||
|
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||||
|
for (const failure of [
|
||||||
|
"INVALID_ROW", "UNSUPPORTED_ACTION", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
|
||||||
|
"PREVIEW_EMPTY", "DATABASE_DATA_INVALID", "RESTORE_FAILED"
|
||||||
|
]) {
|
||||||
|
const result = workflow.normalizeResponse(response({
|
||||||
|
itemIndex: 4,
|
||||||
|
status: "failed",
|
||||||
|
failure
|
||||||
|
}), "load-1", [pending]);
|
||||||
|
assert.equal(result.rows[0].failure, failure);
|
||||||
|
}
|
||||||
|
assert.equal(workflow.normalizeError({
|
||||||
|
requestId: "load-1",
|
||||||
|
code: "DATABASE_UNAVAILABLE",
|
||||||
|
message: "조회 실패",
|
||||||
|
retryable: false
|
||||||
|
}, "load-1").retryable, false);
|
||||||
|
assert.equal(workflow.normalizeError({
|
||||||
|
requestId: "load-1",
|
||||||
|
code: "DATABASE_UNAVAILABLE",
|
||||||
|
message: "조회 실패",
|
||||||
|
retryable: true
|
||||||
|
}, "load-1"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("materialize uses only current code and retains the old raw identity for lossless save", () => {
|
||||||
|
const sourceRaw = raw({ enabled: false });
|
||||||
|
const { pending, outcome } = normalize(sourceRaw);
|
||||||
|
const entry = workflow.materialize(pending, outcome);
|
||||||
|
|
||||||
|
assert.equal(workflow.isMaterializedEntry(entry), true);
|
||||||
|
assert.equal(entry.enabled, false);
|
||||||
|
assert.equal(entry.selection.groupCode, "PAGED_THEME");
|
||||||
|
assert.equal(entry.selection.dataCode, "87654321");
|
||||||
|
assert.equal(entry.operator.theme.themeCode, "87654321");
|
||||||
|
assert.equal(entry.pagePreview.itemCount, 7);
|
||||||
|
assert.deepEqual(workflow.legacySelectionForEntry(entry), {
|
||||||
|
groupCode: "테마",
|
||||||
|
subject: "반도체",
|
||||||
|
graphicType: "5단 표그래프",
|
||||||
|
subtype: "테마-현재가(입력순)",
|
||||||
|
dataCode: "0123"
|
||||||
|
});
|
||||||
|
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
|
||||||
|
assert.equal(workflow.matchesRaw(entry, { ...sourceRaw, dataCode: "87654321" }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expected price and the original no-token loss-rate branch remain closed", () => {
|
||||||
|
const sourceRaw = raw({
|
||||||
|
subtype: "테마-예상체결가",
|
||||||
|
pageText: "1/2"
|
||||||
|
});
|
||||||
|
const native = resolved({
|
||||||
|
actionId: "five-row-expected",
|
||||||
|
sort: "GAIN_ASC"
|
||||||
|
});
|
||||||
|
const { pending, outcome } = normalize(sourceRaw, native);
|
||||||
|
const entry = workflow.materialize(pending, outcome);
|
||||||
|
|
||||||
|
assert.equal(entry.operator.actionId, "five-row-expected");
|
||||||
|
assert.equal(entry.operator.sort, "GAIN_ASC");
|
||||||
|
assert.equal(entry.selection.subtype, "EXPECTED");
|
||||||
|
assert.equal(entry.selection.graphicType, "GAIN_ASC");
|
||||||
|
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5-current, 5-expected, 6 and 12 row wire profiles retain the final sort fallback", () => {
|
||||||
|
const cases = [
|
||||||
|
["5단 표그래프", "테마-현재가", "five-row-current", "s5074", "5074", 5],
|
||||||
|
["5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", "5074", 5],
|
||||||
|
["6종목 현재가", "테마-현재가", "six-row-current", "s5077", "5077", 6],
|
||||||
|
["12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", "5088", 12]
|
||||||
|
];
|
||||||
|
for (const [graphicType, subtype, actionId, builderKey, cutCode, pageSize] of cases) {
|
||||||
|
const sourceRaw = raw({ graphicType, subtype });
|
||||||
|
const native = resolved({ actionId, builderKey, cutCode, pageSize, sort: "GAIN_ASC" });
|
||||||
|
const { pending, outcome } = normalize(sourceRaw, native);
|
||||||
|
const entry = workflow.materialize(pending, outcome);
|
||||||
|
assert.equal(entry.builderKey, builderKey);
|
||||||
|
assert.equal(entry.pageSize, pageSize);
|
||||||
|
assert.equal(entry.operator.sort, "GAIN_ASC");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("240 visible rows with truncation is the only accepted truncated preview boundary", () => {
|
||||||
|
const { pending, outcome } = normalize(raw(), resolved({
|
||||||
|
previewItemCount: 240,
|
||||||
|
previewIsTruncated: true
|
||||||
|
}));
|
||||||
|
const entry = workflow.materialize(pending, outcome);
|
||||||
|
assert.equal(entry.operator.namedKrxRestore.previewItemCount, 240);
|
||||||
|
assert.equal(entry.operator.namedKrxRestore.previewIsTruncated, true);
|
||||||
|
assert.equal(entry.pagePreview.pageCount, 20);
|
||||||
|
assert.equal(entry.pagePreview.isTruncated, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("materialization accepts only branded normalized responses", () => {
|
||||||
|
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||||
|
assert.equal(workflow.materialize(pending, resolved()), null);
|
||||||
|
const failed = workflow.normalizeResponse(response({
|
||||||
|
itemIndex: 4,
|
||||||
|
status: "failed",
|
||||||
|
failure: "PREVIEW_EMPTY"
|
||||||
|
}), "load-1", [pending]).rows[0];
|
||||||
|
assert.equal(workflow.materialize(pending, failed), null);
|
||||||
|
assert.equal(workflow.materialize({}, resolved()), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enabled replacement is immutable and preserves only the trusted KRX brand", () => {
|
||||||
|
const sourceRaw = raw({ enabled: true });
|
||||||
|
const { pending, outcome } = normalize(sourceRaw);
|
||||||
|
const entry = workflow.materialize(pending, outcome);
|
||||||
|
const replacement = workflow.withEnabled(entry, false);
|
||||||
|
|
||||||
|
assert.notEqual(replacement, entry);
|
||||||
|
assert.equal(Object.isFrozen(replacement), true);
|
||||||
|
assert.equal(entry.enabled, true);
|
||||||
|
assert.equal(replacement.enabled, false);
|
||||||
|
assert.equal(workflow.isMaterializedEntry(replacement), true);
|
||||||
|
assert.deepEqual(workflow.legacySelectionForEntry(replacement),
|
||||||
|
workflow.legacySelectionForEntry(entry));
|
||||||
|
assert.equal(workflow.matchesRaw(replacement, { ...sourceRaw, enabled: false }), true);
|
||||||
|
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||||
|
assert.equal(workflow.withEnabled(entry, "false"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native partial emits the exact result and non-retry error contract", () => {
|
||||||
|
const root = path.resolve(__dirname, "..", "..");
|
||||||
|
const native = fs.readFileSync(path.join(root, "MainWindow.NamedKrxThemeRestore.cs"), "utf8");
|
||||||
|
assert.match(native, /PostMessage\("named-krx-theme-restore-results"/u);
|
||||||
|
assert.match(native, /PostMessage\("named-krx-theme-restore-error"/u);
|
||||||
|
assert.match(native, /identity\.StoredThemeCode/u);
|
||||||
|
assert.match(native, /identity\.CurrentThemeCode/u);
|
||||||
|
assert.match(native, /identity\.PreviewIsTruncated/u);
|
||||||
|
assert.match(native, /retryable = false/u);
|
||||||
|
assert.doesNotMatch(native, /retryable = true/u);
|
||||||
|
assert.doesNotMatch(native, /IPlayoutEngine|PrepareAsync|TakeInAsync|NextAsync|TakeOutAsync/u);
|
||||||
|
});
|
||||||
@@ -132,6 +132,24 @@ test("dynamic NXT session changes exactly at local 13:00 without losing native p
|
|||||||
assert.equal(workflow.matchesRaw(atThirteen, sourceRaw), true);
|
assert.equal(workflow.matchesRaw(atThirteen, sourceRaw), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("enabled replacement is immutable and retains NXT proof across session refresh", () => {
|
||||||
|
const sourceRaw = raw({ enabled: true });
|
||||||
|
const pending = workflow.classify(sourceRaw, { id: "db-toggle", fadeDuration: 6 });
|
||||||
|
const outcome = workflow.normalizeResponse(response(resolved({ itemIndex: 4 })),
|
||||||
|
"load-1", [pending]).rows[0];
|
||||||
|
const entry = workflow.materialize(pending, outcome);
|
||||||
|
const replacement = workflow.withEnabled(entry, false);
|
||||||
|
const refreshed = workflow.refreshDynamicSession(replacement, new Date(2026, 6, 12, 13, 0));
|
||||||
|
|
||||||
|
assert.notEqual(replacement, entry);
|
||||||
|
assert.equal(Object.isFrozen(replacement), true);
|
||||||
|
assert.equal(entry.enabled, true);
|
||||||
|
assert.equal(replacement.enabled, false);
|
||||||
|
assert.equal(workflow.isMaterializedEntry(replacement), true);
|
||||||
|
assert.equal(workflow.matchesRaw(refreshed, { ...sourceRaw, enabled: false }), true);
|
||||||
|
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("materialization refuses a failed outcome and non-pending objects", () => {
|
test("materialization refuses a failed outcome and non-pending objects", () => {
|
||||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||||
assert.equal(workflow.materialize(pending, {
|
assert.equal(workflow.materialize(pending, {
|
||||||
|
|||||||
@@ -209,6 +209,25 @@ test("stock responses preserve US/TW identity and reject duplicate input-name lo
|
|||||||
})), null);
|
})), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("provider symbols accept Unicode letters but keep serialized delimiters closed", () => {
|
||||||
|
const unicode = workflow.normalizeStock({
|
||||||
|
inputName: "Provider Unicode Symbol",
|
||||||
|
symbol: "NAS@해외 종목1",
|
||||||
|
nationCode: "US",
|
||||||
|
fdtc: "1"
|
||||||
|
});
|
||||||
|
assert.ok(unicode);
|
||||||
|
const entry = workflow.createOverseasStockPlaylistEntry(
|
||||||
|
unicode,
|
||||||
|
"stock-candle-5d",
|
||||||
|
{ id: "unicode-symbol", fadeDuration: 6, ma5: true, ma20: false });
|
||||||
|
assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");
|
||||||
|
|
||||||
|
for (const symbol of ["NAS|NVDA", "NAS^NVDA", " NAS@NVDA", "NAS@NVDA ", "NAS!NVDA", "NAS\nNVDA", "NAS😀NVDA"]) {
|
||||||
|
assert.equal(workflow.normalizeStock({ ...unicode, symbol }), null, symbol);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("bridge errors use exact payloads and reject stale request correlation", () => {
|
test("bridge errors use exact payloads and reject stale request correlation", () => {
|
||||||
const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
|
const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
|
||||||
const industryError = {
|
const industryError = {
|
||||||
|
|||||||
122
tests/Web/playlist-enabled-toggle-app-integration.test.cjs
Normal file
122
tests/Web/playlist-enabled-toggle-app-integration.test.cjs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..", "..");
|
||||||
|
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
|
||||||
|
|
||||||
|
function functionSource(name, nextName) {
|
||||||
|
const start = app.indexOf(`function ${name}(`);
|
||||||
|
const end = app.indexOf(`function ${nextName}(`, start + 1);
|
||||||
|
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
|
||||||
|
return app.slice(start, end).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recreateWith(stubs) {
|
||||||
|
const source = functionSource(
|
||||||
|
"recreatePlaylistEntryWithEnabled",
|
||||||
|
"playlistEntryManualEditBlockReason");
|
||||||
|
return new Function(
|
||||||
|
"namedKrxThemeRestoreWorkflow",
|
||||||
|
"namedNxtThemeRestoreWorkflow",
|
||||||
|
"namedForeignStockRestoreWorkflow",
|
||||||
|
"foreignIndexCandleRestoreWorkflow",
|
||||||
|
"manualFinancialWorkflow",
|
||||||
|
"manualListsWorkflow",
|
||||||
|
`return (${source});`)(
|
||||||
|
stubs.krx,
|
||||||
|
stubs.nxt,
|
||||||
|
stubs.foreignStock,
|
||||||
|
stubs.foreignIndex,
|
||||||
|
stubs.manualFinancial,
|
||||||
|
stubs.manualLists);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubSet() {
|
||||||
|
const recreate = label => (entry, enabled) => entry.trusted === label
|
||||||
|
? Object.freeze({ ...entry, enabled, retainedBrand: label })
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
krx: {
|
||||||
|
isMaterializedEntry: entry => entry.trusted === "krx",
|
||||||
|
withEnabled: recreate("krx")
|
||||||
|
},
|
||||||
|
nxt: {
|
||||||
|
isMaterializedEntry: entry => entry.trusted === "nxt",
|
||||||
|
withEnabled: recreate("nxt")
|
||||||
|
},
|
||||||
|
foreignStock: {
|
||||||
|
source: "legacy-named-foreign-stock-workflow",
|
||||||
|
withEnabled: recreate("foreign-stock")
|
||||||
|
},
|
||||||
|
foreignIndex: { withEnabled: recreate("foreign-index") },
|
||||||
|
manualFinancial: { withEnabled: recreate("manual-financial") },
|
||||||
|
manualLists: { withEnabled: recreate("manual-lists") }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a frozen ordinary entry toggles through an immutable replacement", () => {
|
||||||
|
const recreate = recreateWith(stubSet());
|
||||||
|
const entry = Object.freeze({
|
||||||
|
id: "ordinary",
|
||||||
|
code: "5001",
|
||||||
|
enabled: true,
|
||||||
|
operator: Object.freeze({ source: "legacy-stock-workflow" })
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacement = recreate(entry, false);
|
||||||
|
assert.notEqual(replacement, entry);
|
||||||
|
assert.equal(entry.enabled, true);
|
||||||
|
assert.equal(replacement.enabled, false);
|
||||||
|
assert.equal(replacement.id, entry.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every capability-branded entry delegates to its trust-preserving workflow", () => {
|
||||||
|
const recreate = recreateWith(stubSet());
|
||||||
|
const cases = [
|
||||||
|
["krx", { source: "legacy-theme-workflow", namedKrxRestore: {} }],
|
||||||
|
["nxt", { source: "legacy-theme-workflow", namedNxtRestore: {} }],
|
||||||
|
["foreign-stock", { source: "legacy-named-foreign-stock-workflow" }],
|
||||||
|
["foreign-index", { source: "legacy-foreign-index-candle-workflow" }],
|
||||||
|
["manual-financial", { source: "manual-financial-workflow" }],
|
||||||
|
["manual-lists", { source: "legacy-manual-lists-workflow" }]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [trusted, operator] of cases) {
|
||||||
|
const entry = Object.freeze({ id: trusted, enabled: true, trusted, operator });
|
||||||
|
const replacement = recreate(entry, false);
|
||||||
|
assert.equal(replacement.retainedBrand, trusted);
|
||||||
|
assert.equal(replacement.enabled, false);
|
||||||
|
assert.equal(entry.enabled, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a proof-shaped but unbranded named entry fails closed instead of becoming a plain clone", () => {
|
||||||
|
const recreate = recreateWith(stubSet());
|
||||||
|
const forgedKrx = Object.freeze({
|
||||||
|
id: "forged-krx",
|
||||||
|
enabled: true,
|
||||||
|
operator: Object.freeze({
|
||||||
|
source: "legacy-theme-workflow",
|
||||||
|
namedKrxRestore: Object.freeze({ schemaVersion: 1 })
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(recreate(forgedKrx, false), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkbox, enabled-all and current-row paths never assign into playlist entries", () => {
|
||||||
|
const render = functionSource("renderPlaylist", "selectPlaylistRow");
|
||||||
|
const all = functionSource("toggleAllEntriesEnabled", "moveSelected");
|
||||||
|
const current = functionSource("toggleCurrentEntryEnabled", "savePlaylist");
|
||||||
|
|
||||||
|
assert.match(render, /replacePlaylistEntryEnabledAt\(index, checkbox\.checked\)/u);
|
||||||
|
assert.match(render, /renderPlaylist\(\)/u);
|
||||||
|
assert.match(all, /state\.playlist\.map\(item => recreatePlaylistEntryWithEnabled/u);
|
||||||
|
assert.match(all, /replacements\.some\(item => !item\)/u);
|
||||||
|
assert.match(current,
|
||||||
|
/replacePlaylistEntryEnabledAt\(state\.currentIndex, item\.enabled === false\)/u);
|
||||||
|
assert.doesNotMatch(`${render}\n${all}\n${current}`, /item\.enabled\s*=(?!=)/u);
|
||||||
|
});
|
||||||
180
tests/Web/playlist-entry-edit-safety-app-integration.test.cjs
Normal file
180
tests/Web/playlist-entry-edit-safety-app-integration.test.cjs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..", "..");
|
||||||
|
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
|
||||||
|
|
||||||
|
function functionSource(name, nextName) {
|
||||||
|
const start = app.indexOf(`function ${name}(`);
|
||||||
|
const end = app.indexOf(`function ${nextName}(`, start + 1);
|
||||||
|
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
|
||||||
|
return app.slice(start, end).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateFunction(name, nextName, dependencies = {}) {
|
||||||
|
const names = Object.keys(dependencies);
|
||||||
|
return new Function(...names,
|
||||||
|
`return (${functionSource(name, nextName)});`)(...names.map(key => dependencies[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateStub() {
|
||||||
|
return {
|
||||||
|
namedPlaylist: { guardByEntryId: new Map() },
|
||||||
|
manualFinancialRestore: { entries: new Map() }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every restored workflow boundary blocks direct scene and DB-row edits", () => {
|
||||||
|
const state = stateStub();
|
||||||
|
const blockReason = evaluateFunction(
|
||||||
|
"playlistEntryManualEditBlockReason",
|
||||||
|
"normalizeManualSceneSelection",
|
||||||
|
{ state });
|
||||||
|
const protectedEntries = [
|
||||||
|
["krx", "legacy-theme-workflow", { namedKrxRestore: {} }],
|
||||||
|
["nxt", "legacy-theme-workflow", { namedNxtRestore: {} }],
|
||||||
|
["foreign-stock", "legacy-named-foreign-stock-workflow", {}],
|
||||||
|
["foreign-index", "legacy-foreign-index-candle-workflow", {}],
|
||||||
|
["manual-financial", "manual-financial-workflow", {}],
|
||||||
|
["manual-lists", "legacy-manual-lists-workflow", {}]
|
||||||
|
];
|
||||||
|
for (const [id, source, proof] of protectedEntries) {
|
||||||
|
const entry = Object.freeze({
|
||||||
|
id,
|
||||||
|
enabled: true,
|
||||||
|
operator: Object.freeze({ source, ...proof })
|
||||||
|
});
|
||||||
|
assert.equal(blockReason(entry), "trusted-workflow-entry", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guarded = Object.freeze({ id: "guarded", enabled: true });
|
||||||
|
state.namedPlaylist.guardByEntryId.set(guarded.id, Object.freeze({ trusted: false }));
|
||||||
|
assert.equal(blockReason(guarded), "named-playlist-entry");
|
||||||
|
const pending = Object.freeze({ id: "pending", enabled: true });
|
||||||
|
state.manualFinancialRestore.entries.set(pending.id, Object.freeze({ status: "load" }));
|
||||||
|
assert.equal(blockReason(pending), "manual-restore-entry");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a frozen ordinary entry receives scene values through an immutable replacement", () => {
|
||||||
|
const state = stateStub();
|
||||||
|
const blockReason = evaluateFunction(
|
||||||
|
"playlistEntryManualEditBlockReason",
|
||||||
|
"normalizeManualSceneSelection",
|
||||||
|
{ state });
|
||||||
|
const normalize = evaluateFunction(
|
||||||
|
"normalizeManualSceneSelection",
|
||||||
|
"recreatePlaylistEntryWithSceneSelection");
|
||||||
|
const sceneAliases = item => item.aliases;
|
||||||
|
const recreate = evaluateFunction(
|
||||||
|
"recreatePlaylistEntryWithSceneSelection",
|
||||||
|
"recreatePlaylistEntryWithLiveSelection",
|
||||||
|
{
|
||||||
|
playlistEntryManualEditBlockReason: blockReason,
|
||||||
|
normalizeManualSceneSelection: normalize,
|
||||||
|
sceneAliases
|
||||||
|
});
|
||||||
|
const entry = Object.freeze({
|
||||||
|
id: "ordinary",
|
||||||
|
code: "5001",
|
||||||
|
aliases: Object.freeze(["5001", "N5001"]),
|
||||||
|
enabled: true,
|
||||||
|
fadeDuration: 6,
|
||||||
|
selection: Object.freeze({
|
||||||
|
groupCode: "KOSPI",
|
||||||
|
subject: "OLD",
|
||||||
|
graphicType: "",
|
||||||
|
subtype: "",
|
||||||
|
dataCode: ""
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const replacement = recreate(entry, {
|
||||||
|
groupCode: " NXT_KOSPI ",
|
||||||
|
subject: " 삼성전자 ",
|
||||||
|
graphicType: "CURRENT",
|
||||||
|
subtype: "",
|
||||||
|
dataCode: "005930"
|
||||||
|
}, "N5001", 4);
|
||||||
|
|
||||||
|
assert.notEqual(replacement, entry);
|
||||||
|
assert.equal(entry.code, "5001");
|
||||||
|
assert.equal(entry.fadeDuration, 6);
|
||||||
|
assert.equal(entry.selection.subject, "OLD");
|
||||||
|
assert.equal(replacement.code, "N5001");
|
||||||
|
assert.equal(replacement.fadeDuration, 4);
|
||||||
|
assert.equal(replacement.selection.groupCode, "NXT_KOSPI");
|
||||||
|
assert.equal(replacement.selection.subject, "삼성전자");
|
||||||
|
assert.equal(Object.isFrozen(replacement.selection), true);
|
||||||
|
assert.equal(recreate(entry, replacement.selection, "invalid", 4), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("live DB selection also replaces only an unprotected ordinary entry", () => {
|
||||||
|
const state = stateStub();
|
||||||
|
const blockReason = evaluateFunction(
|
||||||
|
"playlistEntryManualEditBlockReason",
|
||||||
|
"normalizeManualSceneSelection",
|
||||||
|
{ state });
|
||||||
|
const normalize = evaluateFunction(
|
||||||
|
"normalizeManualSceneSelection",
|
||||||
|
"recreatePlaylistEntryWithSceneSelection");
|
||||||
|
const recreate = evaluateFunction(
|
||||||
|
"recreatePlaylistEntryWithLiveSelection",
|
||||||
|
"replacePlaylistEntryAt",
|
||||||
|
{
|
||||||
|
playlistEntryManualEditBlockReason: blockReason,
|
||||||
|
normalizeManualSceneSelection: normalize
|
||||||
|
});
|
||||||
|
const ordinary = Object.freeze({
|
||||||
|
id: "ordinary-live",
|
||||||
|
code: "5001",
|
||||||
|
enabled: true,
|
||||||
|
selection: Object.freeze({
|
||||||
|
groupCode: "KOSPI_STOCK",
|
||||||
|
subject: "OLD",
|
||||||
|
graphicType: "CURRENT",
|
||||||
|
subtype: "",
|
||||||
|
dataCode: "000001"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const selection = {
|
||||||
|
groupCode: "KOSPI_STOCK",
|
||||||
|
subject: "삼성전자",
|
||||||
|
graphicType: "CURRENT",
|
||||||
|
subtype: "",
|
||||||
|
dataCode: "005930"
|
||||||
|
};
|
||||||
|
const replacement = recreate(ordinary, selection);
|
||||||
|
assert.notEqual(replacement, ordinary);
|
||||||
|
assert.equal(ordinary.selection.subject, "OLD");
|
||||||
|
assert.equal(replacement.selection.subject, "삼성전자");
|
||||||
|
assert.equal(replacement.subject, "삼성전자");
|
||||||
|
assert.equal(replacement.dataCode, "005930");
|
||||||
|
|
||||||
|
const protectedEntry = Object.freeze({
|
||||||
|
...ordinary,
|
||||||
|
id: "protected-live",
|
||||||
|
operator: Object.freeze({ source: "legacy-stock-workflow" })
|
||||||
|
});
|
||||||
|
assert.equal(recreate(protectedEntry, selection), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both edit handlers keep snapshot locking and contain no playlist-entry assignment", () => {
|
||||||
|
const render = functionSource("renderSceneSelectionForm", "applySceneSelection");
|
||||||
|
const scene = functionSource("applySceneSelection", "applyCutAliasPreset");
|
||||||
|
const live = functionSource("applyLiveRowToCurrentCut", "renderLiveTables");
|
||||||
|
|
||||||
|
assert.match(render, /playlistEntryManualEditBlockReason\(item\)/u);
|
||||||
|
assert.match(scene, /if \(!requirePlaylistEditable\(\)\) return;/u);
|
||||||
|
assert.match(scene, /playlistEntryManualEditBlockReason\(item\)/u);
|
||||||
|
assert.match(scene, /recreatePlaylistEntryWithSceneSelection/u);
|
||||||
|
assert.match(scene, /replacePlaylistEntryAt\(state\.currentIndex, replacement\)/u);
|
||||||
|
assert.match(live, /if \(!requirePlaylistEditable\(\)\) return;/u);
|
||||||
|
assert.match(live, /playlistEntryManualEditBlockReason\(item\)/u);
|
||||||
|
assert.match(live, /recreatePlaylistEntryWithLiveSelection/u);
|
||||||
|
assert.match(live, /replacePlaylistEntryAt\(state\.currentIndex, replacement\)/u);
|
||||||
|
assert.doesNotMatch(app,
|
||||||
|
/\bitem\.[A-Za-z_$][A-Za-z0-9_$]*\s*=(?!=)/u);
|
||||||
|
});
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||||
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
public sealed record NamedManualRestoreReadOnlyAuditSummary(
|
||||||
|
int CandidateCount,
|
||||||
|
int FreshMaterializedCount,
|
||||||
|
int BlockedCount,
|
||||||
|
LegacyManualOperatorDataRuntimeState RuntimeState,
|
||||||
|
bool OperatorFilesStable,
|
||||||
|
IReadOnlyDictionary<LegacyNamedManualRestoreKind, int> KindCounts,
|
||||||
|
IReadOnlyDictionary<string, int> BlockReasons);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production-data audit for the manual subset of PLAY_LIST. Every database
|
||||||
|
/// command is a SELECT through an IDataQueryExecutor. Operator files are opened
|
||||||
|
/// only through their production read paths; the before/after snapshot proves
|
||||||
|
/// that no file, marker, or intent entry was created or changed.
|
||||||
|
/// </summary>
|
||||||
|
public static class NamedManualRestoreReadOnlyAudit
|
||||||
|
{
|
||||||
|
private const string QueryName = "AUDIT_NAMED_MANUAL_RESTORE_ROWS";
|
||||||
|
private const int MaximumRows = 20_000;
|
||||||
|
private const int BrowserStockSearchLimit = 200;
|
||||||
|
|
||||||
|
private const string Sql = """
|
||||||
|
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
|
||||||
|
FROM (
|
||||||
|
SELECT TRIM(d.DC_CODE) DC_CODE,
|
||||||
|
d.DC_TITLE DC_TITLE,
|
||||||
|
p.LIST_TEXT LIST_TEXT,
|
||||||
|
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
|
||||||
|
FROM DC_LIST d
|
||||||
|
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
|
||||||
|
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
|
||||||
|
)
|
||||||
|
WHERE ROWNUM <= :row_limit
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static async Task<NamedManualRestoreReadOnlyAuditSummary> RunAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
string trustedDirectory,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(executor);
|
||||||
|
if (string.IsNullOrWhiteSpace(trustedDirectory) ||
|
||||||
|
!Path.IsPathFullyQualified(trustedDirectory))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("trusted directory composition");
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var candidates = await LoadCandidatesAsync(executor, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var runtime = LegacyManualOperatorDataRuntimeGuard.Inspect(trustedDirectory);
|
||||||
|
if (!runtime.IsReady)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("trusted runtime state");
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = SnapshotDirectory(trustedDirectory);
|
||||||
|
var results = new List<RestoreResult>(candidates.Count);
|
||||||
|
var financialService = new LegacyManualFinancialScreenService(executor);
|
||||||
|
var stockSearchService = new LegacyStockSearchService(executor);
|
||||||
|
var netSellSource = new S5025TrustedManualFileDataSource(trustedDirectory);
|
||||||
|
TrustedSourceEvidence trustedSources;
|
||||||
|
using (var viSource = new ViTrustedManualFileStore(trustedDirectory))
|
||||||
|
{
|
||||||
|
trustedSources = await ReadTrustedSourcesAsync(
|
||||||
|
netSellSource,
|
||||||
|
viSource,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
results.Add(candidate.Kind switch
|
||||||
|
{
|
||||||
|
LegacyNamedManualRestoreKind.Financial =>
|
||||||
|
await ResolveFinancialAsync(
|
||||||
|
candidate,
|
||||||
|
financialService,
|
||||||
|
stockSearchService,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
LegacyNamedManualRestoreKind.NetSell =>
|
||||||
|
await ResolveNetSellAsync(candidate, netSellSource, cancellationToken)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
LegacyNamedManualRestoreKind.Vi =>
|
||||||
|
await ResolveViAsync(candidate, viSource, cancellationToken)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
_ => throw InvalidBoundary("manual candidate kind")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var after = SnapshotDirectory(trustedDirectory);
|
||||||
|
var filesStable = before.Matches(after);
|
||||||
|
if (!filesStable)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("operator file immutability");
|
||||||
|
}
|
||||||
|
|
||||||
|
var kindCounts = candidates
|
||||||
|
.GroupBy(static candidate => candidate.Kind)
|
||||||
|
.ToDictionary(static group => group.Key, static group => group.Count());
|
||||||
|
var screenCounts = candidates
|
||||||
|
.Where(static candidate =>
|
||||||
|
candidate.Kind == LegacyNamedManualRestoreKind.Financial)
|
||||||
|
.GroupBy(static candidate => candidate.FinancialScreen!.Value)
|
||||||
|
.OrderBy(static group => group.Key)
|
||||||
|
.Select(group =>
|
||||||
|
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
var audienceCounts = candidates
|
||||||
|
.Where(static candidate => candidate.Kind == LegacyNamedManualRestoreKind.NetSell)
|
||||||
|
.GroupBy(static candidate => candidate.Audience!.Value)
|
||||||
|
.OrderBy(static group => group.Key)
|
||||||
|
.Select(group =>
|
||||||
|
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
var viRows = candidates
|
||||||
|
.Where(static candidate => candidate.Kind == LegacyNamedManualRestoreKind.Vi)
|
||||||
|
.ToArray();
|
||||||
|
var financialOutcomes = candidates
|
||||||
|
.Select((candidate, index) => (Candidate: candidate, Result: results[index]))
|
||||||
|
.Where(static pair =>
|
||||||
|
pair.Candidate.Kind == LegacyNamedManualRestoreKind.Financial)
|
||||||
|
.GroupBy(static pair => pair.Candidate.FinancialScreen!.Value)
|
||||||
|
.OrderBy(static group => group.Key)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var freshCount = group.Count(static pair => pair.Result.IsFresh);
|
||||||
|
var blocked = group
|
||||||
|
.Where(static pair => !pair.Result.IsFresh)
|
||||||
|
.GroupBy(static pair => pair.Result.Reason, StringComparer.Ordinal)
|
||||||
|
.OrderBy(static reason => reason.Key, StringComparer.Ordinal)
|
||||||
|
.Select(reason =>
|
||||||
|
$"{reason.Key}:{reason.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
return $"{group.Key}=fresh:{freshCount.ToString(CultureInfo.InvariantCulture)}" +
|
||||||
|
(freshCount == group.Count()
|
||||||
|
? string.Empty
|
||||||
|
: $"/blocked:{string.Join('+', blocked)}");
|
||||||
|
});
|
||||||
|
var blockReasons = results
|
||||||
|
.Where(static result => !result.IsFresh)
|
||||||
|
.GroupBy(static result => result.Reason, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
|
||||||
|
var fresh = results.Count(static result => result.IsFresh);
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_MANUAL_GRAMMAR_AUDIT: " +
|
||||||
|
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"financial={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.Financial).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"netSell={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.NetSell).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"vi={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.Vi).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"historical={candidates.Count(static candidate => candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"canonical={candidates.Count(static candidate => !candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
Console.WriteLine("PLIST_MANUAL_FINANCIAL_SCREENS: " + string.Join(',', screenCounts));
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_MANUAL_FINANCIAL_OUTCOMES: " + string.Join(',', financialOutcomes));
|
||||||
|
Console.WriteLine("PLIST_MANUAL_NET_SELL_AUDIENCES: " + string.Join(',', audienceCounts));
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_MANUAL_VI_GRAMMAR: " +
|
||||||
|
$"historical={viRows.Count(static candidate => candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"versioned={viRows.Count(static candidate => !candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_MANUAL_TRUSTED_SOURCES: " +
|
||||||
|
$"netSellAudiencesReady={trustedSources.NetSellAudiencesReady.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"viSnapshotReady={trustedSources.ViSnapshotReady.ToString().ToLowerInvariant()} " +
|
||||||
|
$"viItems={trustedSources.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"viPages={trustedSources.ViPageCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
"openedHandle=true");
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_MANUAL_FRESH_PROOF: " +
|
||||||
|
$"freshMaterialized={fresh.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"restoreBlocked={(results.Count - fresh).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
"graphE=double-read-row-version+fresh-stock-identity " +
|
||||||
|
"netSell=opened-handle-production-parser " +
|
||||||
|
"vi=opened-handle-version+identity+page-check " +
|
||||||
|
"sceneDataAndAssetsNotAsserted=true");
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_MANUAL_BLOCK_REASONS: " +
|
||||||
|
(blockReasons.Count == 0
|
||||||
|
? "none"
|
||||||
|
: string.Join(
|
||||||
|
',',
|
||||||
|
blockReasons.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||||
|
.Select(pair =>
|
||||||
|
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_MANUAL_LOCAL_READ_AUDIT: " +
|
||||||
|
$"runtimeState={runtime.State} " +
|
||||||
|
$"entries={before.Entries.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
"filesStable=true writesAttempted=false markerOrIntentCreated=false");
|
||||||
|
|
||||||
|
return new NamedManualRestoreReadOnlyAuditSummary(
|
||||||
|
candidates.Count,
|
||||||
|
fresh,
|
||||||
|
results.Count - fresh,
|
||||||
|
runtime.State,
|
||||||
|
filesStable,
|
||||||
|
kindCounts,
|
||||||
|
blockReasons);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DefaultTrustedDirectory()
|
||||||
|
{
|
||||||
|
var localAppData = Environment.GetFolderPath(
|
||||||
|
Environment.SpecialFolder.LocalApplicationData,
|
||||||
|
Environment.SpecialFolderOption.DoNotVerify);
|
||||||
|
if (string.IsNullOrWhiteSpace(localAppData))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("local application-data root");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(
|
||||||
|
localAppData,
|
||||||
|
"MBN_STOCK_WEBVIEW",
|
||||||
|
"OperatorData"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<LegacyNamedManualRestoreCandidate>> LoadCandidatesAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var spec = new DataQuerySpec(
|
||||||
|
Sql,
|
||||||
|
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
|
||||||
|
spec.ValidateFor(DataSourceKind.Oracle);
|
||||||
|
var table = await executor.ExecuteAsync(
|
||||||
|
DataSourceKind.Oracle,
|
||||||
|
QueryName,
|
||||||
|
spec,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
|
||||||
|
if (table is null ||
|
||||||
|
table.Columns.Count != columns.Length ||
|
||||||
|
table.Rows.Count is < 1 or > MaximumRows)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("PList query schema or row bound");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < columns.Length; index++)
|
||||||
|
{
|
||||||
|
if (table.Columns[index].ColumnName != columns[index] ||
|
||||||
|
table.Columns[index].DataType != typeof(string))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("PList query schema");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = new List<LegacyNamedManualRestoreCandidate>();
|
||||||
|
var routedManualCount = 0;
|
||||||
|
foreach (DataRow dataRow in table.Rows)
|
||||||
|
{
|
||||||
|
var parsed = LegacyPListReadAuditParser.Parse(
|
||||||
|
dataRow[0] as string,
|
||||||
|
dataRow[1] as string,
|
||||||
|
dataRow[3] as string,
|
||||||
|
dataRow[2] as string);
|
||||||
|
if (!parsed.IsSuccess)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("PList row parse");
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = parsed.Row!;
|
||||||
|
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
|
||||||
|
if (route == LegacyPListAuditRoute.ManualFreshMaterialization)
|
||||||
|
{
|
||||||
|
routedManualCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = LegacyNamedManualRestoreClassifier.Classify(row);
|
||||||
|
if (candidate is not null)
|
||||||
|
{
|
||||||
|
candidates.Add(candidate);
|
||||||
|
}
|
||||||
|
else if (route == LegacyPListAuditRoute.ManualFreshMaterialization)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("manual route closed grammar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.Count != routedManualCount)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("manual route/classifier agreement");
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<RestoreResult> ResolveFinancialAsync(
|
||||||
|
LegacyNamedManualRestoreCandidate candidate,
|
||||||
|
LegacyManualFinancialScreenService financialService,
|
||||||
|
LegacyStockSearchService stockSearchService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var identity = new ManualFinancialIdentity(
|
||||||
|
candidate.FinancialScreen!.Value,
|
||||||
|
candidate.StockName!);
|
||||||
|
var firstRead = await financialService.GetAsync(identity, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var query = LegacyNamedManualFreshProof.FinancialStockSearchQuery(candidate);
|
||||||
|
var stockSearch = await stockSearchService.SearchAsync(
|
||||||
|
query,
|
||||||
|
BrowserStockSearchLimit,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var secondRead = await financialService.GetAsync(identity, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var failure = LegacyNamedManualFreshProof.EvaluateFinancial(
|
||||||
|
candidate,
|
||||||
|
firstRead,
|
||||||
|
secondRead,
|
||||||
|
stockSearch);
|
||||||
|
return RestoreResult.From(failure);
|
||||||
|
}
|
||||||
|
catch (ManualFinancialNotFoundException)
|
||||||
|
{
|
||||||
|
return RestoreResult.Blocked("GRAPH_E_NOT_FOUND");
|
||||||
|
}
|
||||||
|
catch (ManualFinancialAmbiguousIdentityException)
|
||||||
|
{
|
||||||
|
return RestoreResult.Blocked("GRAPH_E_AMBIGUOUS");
|
||||||
|
}
|
||||||
|
catch (ManualFinancialDataException)
|
||||||
|
{
|
||||||
|
return RestoreResult.Blocked("GRAPH_E_DATA_INVALID");
|
||||||
|
}
|
||||||
|
catch (StockSearchDataException)
|
||||||
|
{
|
||||||
|
return RestoreResult.Blocked("STOCK_MASTER_DATA_INVALID");
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return RestoreResult.Blocked("GRAPH_E_IDENTITY_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<RestoreResult> ResolveNetSellAsync(
|
||||||
|
LegacyNamedManualRestoreCandidate candidate,
|
||||||
|
S5025TrustedManualFileDataSource source,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rows = await source.ReadAsync(candidate.Audience!.Value, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return RestoreResult.From(
|
||||||
|
LegacyNamedManualFreshProof.EvaluateNetSell(candidate, rows));
|
||||||
|
}
|
||||||
|
catch (S5025TrustedManualDataException)
|
||||||
|
{
|
||||||
|
return RestoreResult.Blocked("NET_SELL_TRUSTED_READ_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TrustedSourceEvidence> ReadTrustedSourcesAsync(
|
||||||
|
S5025TrustedManualFileDataSource netSellSource,
|
||||||
|
ViTrustedManualFileStore viSource,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var audiencesReady = 0;
|
||||||
|
foreach (var audience in Enum.GetValues<S5025ManualAudience>())
|
||||||
|
{
|
||||||
|
var rows = await netSellSource.ReadAsync(audience, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (rows.Count != 5)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("net-sell production parser");
|
||||||
|
}
|
||||||
|
|
||||||
|
audiencesReady++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var vi = await viSource.ReadStockNameSnapshotAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!TrustedViManualReference.IsRowVersion(vi.RowVersion) ||
|
||||||
|
vi.StockNames.Count is < 1 or > TrustedViManualReference.MaximumItemCount)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("VI production parser");
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonical = new LegacySceneSelection(
|
||||||
|
"PAGED_VI",
|
||||||
|
TrustedViManualReference.SubjectSentinel,
|
||||||
|
"INPUT_ORDER",
|
||||||
|
"CURRENT",
|
||||||
|
vi.RowVersion);
|
||||||
|
var secondRead = await TrustedViManualReference.ResolveAsync(
|
||||||
|
canonical,
|
||||||
|
pageIndexZeroBased: 0,
|
||||||
|
viSource,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!secondRead.StockNames.SequenceEqual(vi.StockNames, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("VI stable second read");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TrustedSourceEvidence(
|
||||||
|
audiencesReady,
|
||||||
|
ViSnapshotReady: true,
|
||||||
|
vi.StockNames.Count,
|
||||||
|
checked((vi.StockNames.Count + 4) / 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<RestoreResult> ResolveViAsync(
|
||||||
|
LegacyNamedManualRestoreCandidate candidate,
|
||||||
|
ViTrustedManualFileStore source,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = await source.ReadStockNameSnapshotAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var failure = LegacyNamedManualFreshProof.EvaluateVi(candidate, snapshot);
|
||||||
|
if (failure != LegacyNamedManualFreshProofFailure.None)
|
||||||
|
{
|
||||||
|
return RestoreResult.From(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonical = new LegacySceneSelection(
|
||||||
|
"PAGED_VI",
|
||||||
|
TrustedViManualReference.SubjectSentinel,
|
||||||
|
"INPUT_ORDER",
|
||||||
|
"CURRENT",
|
||||||
|
snapshot.RowVersion);
|
||||||
|
var resolved = await TrustedViManualReference.ResolveAsync(
|
||||||
|
canonical,
|
||||||
|
pageIndexZeroBased: 0,
|
||||||
|
source,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return resolved.PageNumberOneBased == 1 &&
|
||||||
|
resolved.StockNames.SequenceEqual(
|
||||||
|
snapshot.StockNames,
|
||||||
|
StringComparer.Ordinal)
|
||||||
|
? RestoreResult.Fresh()
|
||||||
|
: RestoreResult.Blocked("VI_SECOND_READ_CHANGED");
|
||||||
|
}
|
||||||
|
catch (ViTrustedManualDataException)
|
||||||
|
{
|
||||||
|
return RestoreResult.Blocked("VI_TRUSTED_READ_INVALID");
|
||||||
|
}
|
||||||
|
catch (LegacySceneDataException)
|
||||||
|
{
|
||||||
|
return RestoreResult.Blocked("VI_SECOND_READ_CHANGED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DirectorySnapshot SnapshotDirectory(string trustedDirectory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(trustedDirectory);
|
||||||
|
if (!directory.Exists ||
|
||||||
|
(directory.Attributes & (FileAttributes.ReparsePoint | FileAttributes.Directory)) !=
|
||||||
|
FileAttributes.Directory)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("operator directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = new List<FileSnapshot>();
|
||||||
|
foreach (var path in Directory.EnumerateFileSystemEntries(trustedDirectory)
|
||||||
|
.OrderBy(static value => Path.GetFileName(value), StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var attributes = File.GetAttributes(path);
|
||||||
|
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("operator directory entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = new FileInfo(path);
|
||||||
|
byte[] digest;
|
||||||
|
long length;
|
||||||
|
using (var stream = new FileStream(
|
||||||
|
path,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.Read,
|
||||||
|
FileShare.Read,
|
||||||
|
bufferSize: 4_096,
|
||||||
|
FileOptions.SequentialScan))
|
||||||
|
{
|
||||||
|
length = stream.Length;
|
||||||
|
digest = SHA256.HashData(stream);
|
||||||
|
if (stream.Length != length)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("operator file stable read");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var after = new FileInfo(path);
|
||||||
|
if (after.Length != length ||
|
||||||
|
before.LastWriteTimeUtc != after.LastWriteTimeUtc ||
|
||||||
|
before.CreationTimeUtc != after.CreationTimeUtc)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("operator file stable read");
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.Add(new FileSnapshot(
|
||||||
|
Path.GetFileName(path),
|
||||||
|
length,
|
||||||
|
after.CreationTimeUtc,
|
||||||
|
after.LastWriteTimeUtc,
|
||||||
|
Convert.ToHexString(digest)));
|
||||||
|
}
|
||||||
|
|
||||||
|
directory.Refresh();
|
||||||
|
return new DirectorySnapshot(directory.LastWriteTimeUtc, entries.AsReadOnly());
|
||||||
|
}
|
||||||
|
catch (InvalidDataException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is
|
||||||
|
IOException or
|
||||||
|
UnauthorizedAccessException or
|
||||||
|
ArgumentException or
|
||||||
|
NotSupportedException)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("operator file snapshot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InvalidDataException InvalidBoundary(string detail) =>
|
||||||
|
new($"The read-only named manual restore audit failed its {detail} boundary. " +
|
||||||
|
"No source values were printed.");
|
||||||
|
|
||||||
|
private sealed record RestoreResult(bool IsFresh, string Reason)
|
||||||
|
{
|
||||||
|
public static RestoreResult Fresh() => new(true, "NONE");
|
||||||
|
|
||||||
|
public static RestoreResult Blocked(string reason) => new(false, reason);
|
||||||
|
|
||||||
|
public static RestoreResult From(LegacyNamedManualFreshProofFailure failure) =>
|
||||||
|
failure == LegacyNamedManualFreshProofFailure.None
|
||||||
|
? Fresh()
|
||||||
|
: Blocked(failure.ToString().ToUpperInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record TrustedSourceEvidence(
|
||||||
|
int NetSellAudiencesReady,
|
||||||
|
bool ViSnapshotReady,
|
||||||
|
int ViItemCount,
|
||||||
|
int ViPageCount);
|
||||||
|
|
||||||
|
private sealed record FileSnapshot(
|
||||||
|
string Name,
|
||||||
|
long Length,
|
||||||
|
DateTime CreationTimeUtc,
|
||||||
|
DateTime LastWriteTimeUtc,
|
||||||
|
string Sha256);
|
||||||
|
|
||||||
|
private sealed record DirectorySnapshot(
|
||||||
|
DateTime LastWriteTimeUtc,
|
||||||
|
IReadOnlyList<FileSnapshot> Entries)
|
||||||
|
{
|
||||||
|
public bool Matches(DirectorySnapshot other) =>
|
||||||
|
other is not null &&
|
||||||
|
LastWriteTimeUtc == other.LastWriteTimeUtc &&
|
||||||
|
Entries.SequenceEqual(other.Entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
802
tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs
Normal file
802
tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
internal static class NamedPlaylistReadOnlyDbAudit
|
||||||
|
{
|
||||||
|
private const string QueryName = "AUDIT_ACTIVE_NAMED_PLAYLIST_ROWS";
|
||||||
|
private const int MaximumRows = 20_000;
|
||||||
|
|
||||||
|
private const string Sql = """
|
||||||
|
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
|
||||||
|
FROM (
|
||||||
|
SELECT TRIM(d.DC_CODE) DC_CODE,
|
||||||
|
d.DC_TITLE DC_TITLE,
|
||||||
|
p.LIST_TEXT LIST_TEXT,
|
||||||
|
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
|
||||||
|
FROM DC_LIST d
|
||||||
|
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
|
||||||
|
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
|
||||||
|
)
|
||||||
|
WHERE ROWNUM <= :row_limit
|
||||||
|
""";
|
||||||
|
|
||||||
|
internal static async Task RunAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(executor);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
Console.WriteLine("PLIST_READ_AUDIT: BEGIN");
|
||||||
|
var rawRows = await LoadRawRowsAsync(executor, cancellationToken).ConfigureAwait(false);
|
||||||
|
Console.WriteLine(
|
||||||
|
$"PLIST_READ_AUDIT_RAW: rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
var persistence = new LegacyNamedPlaylistPersistenceService(executor);
|
||||||
|
var definitions = await persistence
|
||||||
|
.ListAsync(LegacyNamedPlaylistPersistenceService.MaximumDefinitions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (definitions.IsTruncated || definitions.Items.Count == 0)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("definition cardinality");
|
||||||
|
}
|
||||||
|
Console.WriteLine(
|
||||||
|
$"PLIST_READ_AUDIT_DEFINITIONS: count={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
|
||||||
|
var rawByProgram = rawRows
|
||||||
|
.GroupBy(static row => row.ProgramCode, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(
|
||||||
|
static group => group.Key,
|
||||||
|
static group => (IReadOnlyList<LegacyPListAuditRow>)group.ToArray(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
if (rawByProgram.Count != definitions.Items.Count ||
|
||||||
|
rawRows.Count != rawByProgram.Values.Sum(static rows => rows.Count))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("active definition join");
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadedRows = 0;
|
||||||
|
foreach (var definition in definitions.Items)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!rawByProgram.TryGetValue(definition.ProgramCode, out var programRows) ||
|
||||||
|
!LegacyPListReadAuditEvidence.HasContiguousIndexes(programRows) ||
|
||||||
|
programRows.Any(row =>
|
||||||
|
!string.Equals(row.ProgramTitle, definition.Title, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("program identity or item order");
|
||||||
|
}
|
||||||
|
|
||||||
|
var loaded = await persistence
|
||||||
|
.LoadAsync(definition.ProgramCode, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (loaded.Definition != definition || loaded.Items.Count != programRows.Count)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("typed load cardinality");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < programRows.Count; index++)
|
||||||
|
{
|
||||||
|
if (!LegacyPListReadAuditEvidence.MatchesPersistenceResult(
|
||||||
|
programRows[index],
|
||||||
|
loaded.Items[index]))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("lossless typed load comparison");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedRows += loaded.Items.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedRows != rawRows.Count)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("typed load total");
|
||||||
|
}
|
||||||
|
Console.WriteLine(
|
||||||
|
$"PLIST_READ_AUDIT_TYPED: rows={loadedRows.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
|
||||||
|
IReadOnlySet<SelectionKey> fixedSignatures;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fixedSignatures = LoadFixedSignatures();
|
||||||
|
}
|
||||||
|
catch (InvalidDataException exception) when (
|
||||||
|
exception.Message.StartsWith(
|
||||||
|
"The read-only PList audit failed its ",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Console.WriteLine("PLIST_FIXED_MAPPER_BOUNDARY: " + exception.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_FIXED_MAPPER_AUDIT: " +
|
||||||
|
$"sourceActions=328 unavailable=6 " +
|
||||||
|
$"uniqueSignatures={fixedSignatures.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
"sourceHashes=verified");
|
||||||
|
var routedRows = rawRows
|
||||||
|
.Select(row => new RoutedRow(row, ClassifyRoute(row, fixedSignatures)))
|
||||||
|
.ToArray();
|
||||||
|
var routeCounts = routedRows
|
||||||
|
.GroupBy(static routed => routed.Route)
|
||||||
|
.ToDictionary(static group => group.Key, static group => group.Count());
|
||||||
|
MaterializationSummary outcomes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
outcomes = await ResolveMaterializationAsync(
|
||||||
|
executor,
|
||||||
|
routedRows,
|
||||||
|
routeCounts,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException exception) when (
|
||||||
|
exception.Message.StartsWith(
|
||||||
|
"The read-only PList audit failed its ",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Console.WriteLine("PLIST_MATERIALIZATION_BOUNDARY: " + exception.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
if (outcomes.Playable + outcomes.Blocked != rawRows.Count)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("materialization total");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_READ_AUDIT: " +
|
||||||
|
$"definitions={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"parsed={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"lossless={rawRows.Count(static row => row.IsLossless).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
"replacement=0 mojibake=0 nonContiguousPrograms=0 typedMismatch=0");
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_ROUTE_AUDIT: " + string.Join(
|
||||||
|
",",
|
||||||
|
Enum.GetValues<LegacyPListAuditRoute>().Select(route =>
|
||||||
|
$"{route}={routeCounts.GetValueOrDefault(route).ToString(CultureInfo.InvariantCulture)}")));
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_MATERIALIZATION_AUDIT: " +
|
||||||
|
$"unique={outcomes.Unique.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"selectionMapped={outcomes.Playable.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"restoreBlocked={outcomes.Blocked.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
"sceneDataAndAssetsNotAsserted=true");
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_BLOCK_REASONS: " +
|
||||||
|
(outcomes.BlockReasons.Count == 0
|
||||||
|
? "none"
|
||||||
|
: string.Join(
|
||||||
|
",",
|
||||||
|
outcomes.BlockReasons
|
||||||
|
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||||
|
.Select(pair =>
|
||||||
|
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<LegacyPListAuditRow>> LoadRawRowsAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var spec = new DataQuerySpec(
|
||||||
|
Sql,
|
||||||
|
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
|
||||||
|
spec.ValidateFor(DataSourceKind.Oracle);
|
||||||
|
var table = await executor
|
||||||
|
.ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
|
||||||
|
if (table is null || table.Columns.Count != columns.Length ||
|
||||||
|
table.Rows.Count is < 1 or > MaximumRows)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("query schema or row bound");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < columns.Length; index++)
|
||||||
|
{
|
||||||
|
if (!string.Equals(
|
||||||
|
table.Columns[index].ColumnName,
|
||||||
|
columns[index],
|
||||||
|
StringComparison.Ordinal) ||
|
||||||
|
table.Columns[index].DataType != typeof(string))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("query schema");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<LegacyPListAuditRow>(table.Rows.Count);
|
||||||
|
foreach (DataRow dataRow in table.Rows)
|
||||||
|
{
|
||||||
|
var result = LegacyPListReadAuditParser.Parse(
|
||||||
|
dataRow[0] as string,
|
||||||
|
dataRow[1] as string,
|
||||||
|
dataRow[3] as string,
|
||||||
|
dataRow[2] as string);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
// The failure enum contains no source text. Never echo raw
|
||||||
|
// operator data or identifiers from this audit boundary.
|
||||||
|
throw InvalidBoundary($"row parse ({result.Failure})");
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(result.Row!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<MaterializationSummary> ResolveMaterializationAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
IReadOnlyList<RoutedRow> routedRows,
|
||||||
|
IReadOnlyDictionary<LegacyPListAuditRoute, int> routeCounts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var summary = new MaterializationSummary();
|
||||||
|
|
||||||
|
// These closed classifiers create one immutable selection candidate.
|
||||||
|
// This audit does not call the scene data loaders or assert asset and
|
||||||
|
// PREPARE readiness. Industry fixed rows are independently constrained
|
||||||
|
// to two markets and three actions by LegacyPListReadAuditEvidence.
|
||||||
|
summary.AddPlayable(routeCounts.GetValueOrDefault(
|
||||||
|
LegacyPListAuditRoute.ExistingClosedMapper));
|
||||||
|
summary.AddPlayable(routeCounts.GetValueOrDefault(
|
||||||
|
LegacyPListAuditRoute.IndustryFixedClosedMapper));
|
||||||
|
|
||||||
|
var comparisonRows = RowsFor(
|
||||||
|
routedRows,
|
||||||
|
LegacyPListAuditRoute.ComparisonFreshIdentity,
|
||||||
|
static (row, index) => LegacyPListReadAuditEvidence
|
||||||
|
.ToComparisonRow(row) with { ItemIndex = index });
|
||||||
|
if (comparisonRows.Count > 0)
|
||||||
|
{
|
||||||
|
var resolved = await new LegacyNamedComparisonRestoreService(executor)
|
||||||
|
.ResolveAsync(comparisonRows, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
foreach (var row in resolved.Rows)
|
||||||
|
{
|
||||||
|
if (row.IsResolved)
|
||||||
|
{
|
||||||
|
summary.AddPlayable(1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.AddBlocked("COMPARISON_" + row.Failure!.Value.ToString().ToUpperInvariant());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var foreignIndexRows = RowsFor(
|
||||||
|
routedRows,
|
||||||
|
LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity,
|
||||||
|
static (row, index) => LegacyPListReadAuditEvidence
|
||||||
|
.ToForeignIndexCandleRow(row) with { ItemIndex = index });
|
||||||
|
if (foreignIndexRows.Count > 0)
|
||||||
|
{
|
||||||
|
var resolved = await new LegacyForeignIndexCandleRestoreService(executor)
|
||||||
|
.ResolveAsync(foreignIndexRows, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
foreach (var row in resolved.Rows)
|
||||||
|
{
|
||||||
|
if (row.IsResolved)
|
||||||
|
{
|
||||||
|
summary.AddPlayable(1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.AddBlocked("FOREIGN_INDEX_" + row.Failure!.Value.ToString().ToUpperInvariant());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nxtRows = RowsFor(
|
||||||
|
routedRows,
|
||||||
|
LegacyPListAuditRoute.NxtThemeFreshIdentity,
|
||||||
|
static (row, index) => LegacyPListReadAuditEvidence
|
||||||
|
.ToNxtThemeRow(row) with { ItemIndex = index });
|
||||||
|
if (nxtRows.Count > 0)
|
||||||
|
{
|
||||||
|
var resolved = await new LegacyNamedNxtThemeRestoreService(executor)
|
||||||
|
.ResolveAsync(nxtRows, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
foreach (var row in resolved.Rows)
|
||||||
|
{
|
||||||
|
if (row.IsResolved)
|
||||||
|
{
|
||||||
|
summary.AddPlayable(1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.AddBlocked("NXT_THEME_" + row.Failure!.Value.ToString().ToUpperInvariant());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ResolveKrxThemesAsync(executor, routedRows, summary, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await ResolveForeignStocksAsync(executor, routedRows, summary, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// GraphE/FSell/VI require a fresh row-version or opened-handle trusted
|
||||||
|
// file read. The DB-only audit intentionally does not claim those
|
||||||
|
// external proofs and keeps every such row blocked with one reason.
|
||||||
|
summary.AddBlocked(
|
||||||
|
"MANUAL_TRUSTED_SOURCE_FRESH_READ_REQUIRED",
|
||||||
|
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.ManualFreshMaterialization));
|
||||||
|
summary.AddBlocked(
|
||||||
|
"NO_UNIQUE_CLOSED_MAPPER",
|
||||||
|
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.Unmapped));
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ResolveKrxThemesAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
IReadOnlyList<RoutedRow> rows,
|
||||||
|
MaterializationSummary summary,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = RowsFor(
|
||||||
|
rows,
|
||||||
|
LegacyPListAuditRoute.KrxThemeFreshIdentity,
|
||||||
|
static (row, index) => new LegacyNamedKrxThemeRestoreRow(
|
||||||
|
index,
|
||||||
|
row.IsEnabled,
|
||||||
|
row.GroupCode,
|
||||||
|
row.Subject,
|
||||||
|
row.GraphicType,
|
||||||
|
row.Subtype,
|
||||||
|
row.PageTextForDisplay,
|
||||||
|
row.DataCode));
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = await new LegacyNamedKrxThemeRestoreService(executor)
|
||||||
|
.ResolveAsync(candidates, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var mapped = 0;
|
||||||
|
var remapped = 0;
|
||||||
|
foreach (var row in resolved.Rows)
|
||||||
|
{
|
||||||
|
if (row.IsResolved)
|
||||||
|
{
|
||||||
|
summary.AddPlayable(1);
|
||||||
|
mapped++;
|
||||||
|
if (row.Identity!.CodeWasRemapped)
|
||||||
|
{
|
||||||
|
remapped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.AddBlocked("KRX_THEME_" + KrxThemeFailureToken(row.Failure!.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_KRX_THEME_RESTORE: " +
|
||||||
|
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"codeRemapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ResolveForeignStocksAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
IReadOnlyList<RoutedRow> rows,
|
||||||
|
MaterializationSummary summary,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var candidates = RowsFor(
|
||||||
|
rows,
|
||||||
|
LegacyPListAuditRoute.ForeignStockFreshIdentity,
|
||||||
|
static (row, index) => new LegacyNamedForeignStockRestoreRow(
|
||||||
|
index,
|
||||||
|
row.IsEnabled,
|
||||||
|
row.GroupCode,
|
||||||
|
row.Subject,
|
||||||
|
row.GraphicType,
|
||||||
|
row.Subtype,
|
||||||
|
row.PageTextForDisplay,
|
||||||
|
row.DataCode));
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = await new LegacyNamedForeignStockRestoreService(executor)
|
||||||
|
.ResolveAsync(candidates, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var mapped = 0;
|
||||||
|
foreach (var row in resolved.Rows)
|
||||||
|
{
|
||||||
|
if (row.IsResolved)
|
||||||
|
{
|
||||||
|
summary.AddPlayable(1);
|
||||||
|
mapped++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary.AddBlocked(
|
||||||
|
"FOREIGN_STOCK_" + ForeignStockFailureToken(row.Failure!.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var actions = resolved.Rows
|
||||||
|
.Where(static row => row.IsResolved)
|
||||||
|
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
|
||||||
|
.OrderBy(static group => group.Key, StringComparer.Ordinal)
|
||||||
|
.Select(group =>
|
||||||
|
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
var databaseIssues = resolved.Rows
|
||||||
|
.Where(static row =>
|
||||||
|
row.Failure == LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid)
|
||||||
|
.GroupBy(static row => row.DatabaseIssue?.ToString() ?? "UNCLASSIFIED", StringComparer.Ordinal)
|
||||||
|
.OrderBy(static group => group.Key, StringComparer.Ordinal)
|
||||||
|
.Select(group =>
|
||||||
|
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
var slashSymbols = resolved.Rows.Count(static row =>
|
||||||
|
row.IsResolved && row.Identity!.Symbol.Contains('/'));
|
||||||
|
var nonAsciiSymbols = resolved.Rows.Count(static row =>
|
||||||
|
row.IsResolved && row.Identity!.Symbol.Any(static character => !char.IsAscii(character)));
|
||||||
|
var hangulSymbols = resolved.Rows.Count(static row =>
|
||||||
|
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit));
|
||||||
|
var internalSpaceSymbols = resolved.Rows.Count(static row =>
|
||||||
|
row.IsResolved && row.Identity!.Symbol.Contains(' '));
|
||||||
|
var hangulActions = resolved.Rows
|
||||||
|
.Where(static row =>
|
||||||
|
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit))
|
||||||
|
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
|
||||||
|
.OrderBy(static group => group.Key, StringComparer.Ordinal)
|
||||||
|
.Select(group =>
|
||||||
|
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
Console.WriteLine(
|
||||||
|
"PLIST_FOREIGN_STOCK_RESTORE: " +
|
||||||
|
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"slashSymbols={slashSymbols.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"nonAsciiSymbols={nonAsciiSymbols.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"hangulSymbols={hangulSymbols.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"internalSpaceSymbols={internalSpaceSymbols.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"actions={string.Join(',', actions)} " +
|
||||||
|
$"hangulActions={string.Join(',', hangulActions)} " +
|
||||||
|
$"databaseIssues={(databaseIssues.Any() ? string.Join(',', databaseIssues) : "none")} " +
|
||||||
|
"rawValuesPrinted=false");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string KrxThemeFailureToken(LegacyNamedKrxThemeRestoreFailure failure) =>
|
||||||
|
failure switch
|
||||||
|
{
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.InvalidRow => "INVALID_ROW",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY",
|
||||||
|
LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(failure))
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ForeignStockFailureToken(
|
||||||
|
LegacyNamedForeignStockRestoreFailure failure) =>
|
||||||
|
failure switch
|
||||||
|
{
|
||||||
|
LegacyNamedForeignStockRestoreFailure.InvalidRow => "INVALID_ROW",
|
||||||
|
LegacyNamedForeignStockRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
|
||||||
|
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
|
||||||
|
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(failure))
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IReadOnlyList<T> RowsFor<T>(
|
||||||
|
IReadOnlyList<RoutedRow> rows,
|
||||||
|
LegacyPListAuditRoute route,
|
||||||
|
Func<LegacyPListAuditRow, int, T> convert) =>
|
||||||
|
rows
|
||||||
|
.Where(row => row.Route == route)
|
||||||
|
.Select(static row => row.Row)
|
||||||
|
.Select(convert)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static bool HasExactStringColumn(DataColumn column, string name) =>
|
||||||
|
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
|
||||||
|
column.DataType == typeof(string);
|
||||||
|
|
||||||
|
private static bool IsHangulCodeUnit(char value) =>
|
||||||
|
value is >= '\uAC00' and <= '\uD7AF' or
|
||||||
|
>= '\u1100' and <= '\u11FF' or
|
||||||
|
>= '\u3130' and <= '\u318F';
|
||||||
|
|
||||||
|
private static InvalidDataException InvalidBoundary(string detail) =>
|
||||||
|
new($"The read-only PList audit failed its {detail} boundary. No source values were printed.");
|
||||||
|
|
||||||
|
private static LegacyPListAuditRoute ClassifyRoute(
|
||||||
|
LegacyPListAuditRow row,
|
||||||
|
IReadOnlySet<SelectionKey> fixedSignatures)
|
||||||
|
{
|
||||||
|
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
|
||||||
|
if (route != LegacyPListAuditRoute.Unmapped)
|
||||||
|
{
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = new SelectionKey(
|
||||||
|
row.GroupCode,
|
||||||
|
row.Subject,
|
||||||
|
row.GraphicType,
|
||||||
|
row.Subtype,
|
||||||
|
row.DataCode);
|
||||||
|
return fixedSignatures.Contains(key)
|
||||||
|
? LegacyPListAuditRoute.ExistingClosedMapper
|
||||||
|
: LegacyPListAuditRoute.Unmapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlySet<SelectionKey> LoadFixedSignatures()
|
||||||
|
{
|
||||||
|
var profile = Environment.GetFolderPath(
|
||||||
|
Environment.SpecialFolder.UserProfile,
|
||||||
|
Environment.SpecialFolderOption.DoNotVerify);
|
||||||
|
if (string.IsNullOrWhiteSpace(profile))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed-profile root");
|
||||||
|
}
|
||||||
|
|
||||||
|
var directory = Path.GetFullPath(Path.Combine(
|
||||||
|
profile,
|
||||||
|
"source",
|
||||||
|
"repos",
|
||||||
|
"MBN_STOCK_N",
|
||||||
|
"MBN_STOCK_N",
|
||||||
|
"bin",
|
||||||
|
"Debug",
|
||||||
|
"Res"));
|
||||||
|
if (!Directory.Exists(directory) ||
|
||||||
|
(File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed source directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
var encoding = Encoding.GetEncoding(
|
||||||
|
949,
|
||||||
|
EncoderFallback.ExceptionFallback,
|
||||||
|
DecoderFallback.ExceptionFallback);
|
||||||
|
var signatureCounts = new Dictionary<SelectionKey, int>();
|
||||||
|
var actionCount = 0;
|
||||||
|
foreach (var source in FixedSources)
|
||||||
|
{
|
||||||
|
var path = Path.GetFullPath(Path.Combine(directory, source.FileName));
|
||||||
|
if (!string.Equals(
|
||||||
|
Path.GetDirectoryName(path),
|
||||||
|
directory,
|
||||||
|
StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!File.Exists(path) ||
|
||||||
|
(File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed source file");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
using (var stream = new FileStream(
|
||||||
|
path,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.Read,
|
||||||
|
FileShare.Read))
|
||||||
|
{
|
||||||
|
if (stream.Length is < 1 or > 32_768)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed source size");
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = new byte[stream.Length];
|
||||||
|
stream.ReadExactly(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(
|
||||||
|
Convert.ToHexString(SHA256.HashData(bytes)),
|
||||||
|
source.ExpectedSha256,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed source hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
string text;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
text = encoding.GetString(bytes);
|
||||||
|
}
|
||||||
|
catch (DecoderFallbackException)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed source encoding");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppendFixedSignatures(signatureCounts, source.Market, text, ref actionCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three audited runtime INI files contain 328 source actions. Six
|
||||||
|
// unavailable manual placeholders are deliberately excluded below.
|
||||||
|
if (actionCount != 322)
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed signature cardinality");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw row must identify exactly one current action. If two source
|
||||||
|
// actions collapse to one historical five-field signature, retain no
|
||||||
|
// signature for that key and let the row fail closed as unmapped.
|
||||||
|
return signatureCounts
|
||||||
|
.Where(static pair => pair.Value == 1)
|
||||||
|
.Select(static pair => pair.Key)
|
||||||
|
.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendFixedSignatures(
|
||||||
|
IDictionary<SelectionKey, int> signatureCounts,
|
||||||
|
string market,
|
||||||
|
string text,
|
||||||
|
ref int actionCount)
|
||||||
|
{
|
||||||
|
string? section = null;
|
||||||
|
foreach (var rawLine in text.Split(["\r\n"], StringSplitOptions.None))
|
||||||
|
{
|
||||||
|
// MainForm's runtime INI loader calls Trim() on every line before
|
||||||
|
// it builds the tree and playlist signature.
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (line.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Length >= 3 && line[0] == '[' && line[^1] == ']')
|
||||||
|
{
|
||||||
|
section = line[1..^1];
|
||||||
|
if (section.Length == 0 || LegacyPListReadAuditParser.ContainsMojibakeSignal(section))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed section");
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section is null || line.Contains('^') ||
|
||||||
|
LegacyPListReadAuditParser.ContainsMojibakeSignal(line))
|
||||||
|
{
|
||||||
|
throw InvalidBoundary("legacy fixed action");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line == "VI 발동(수동)" ||
|
||||||
|
section == "매매동향" && line is
|
||||||
|
("개인 순매도 상위(수동)" or
|
||||||
|
"외국인 순매도 상위(수동)" or
|
||||||
|
"기관 순매도 상위(수동)"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionCount = checked(actionCount + 1);
|
||||||
|
|
||||||
|
var separator = line.IndexOf('_');
|
||||||
|
var first = separator < 0 ? line : line[..separator];
|
||||||
|
var remainder = separator < 0 ? string.Empty : line[(separator + 1)..];
|
||||||
|
SelectionKey signature;
|
||||||
|
if (market is "overseas" or "exchange")
|
||||||
|
{
|
||||||
|
signature = new SelectionKey(
|
||||||
|
market == "overseas" ? "해외지수" : "환율",
|
||||||
|
first,
|
||||||
|
section,
|
||||||
|
remainder,
|
||||||
|
string.Empty);
|
||||||
|
}
|
||||||
|
else if (section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
|
||||||
|
{
|
||||||
|
signature = new SelectionKey(
|
||||||
|
FixedIndexGroup(line),
|
||||||
|
"지수",
|
||||||
|
section,
|
||||||
|
line,
|
||||||
|
string.Empty);
|
||||||
|
}
|
||||||
|
else if (section == "매매동향")
|
||||||
|
{
|
||||||
|
signature = new SelectionKey(
|
||||||
|
FixedIndexGroup(line),
|
||||||
|
"지수",
|
||||||
|
first,
|
||||||
|
remainder,
|
||||||
|
string.Empty);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
signature = new SelectionKey(
|
||||||
|
section,
|
||||||
|
"지수",
|
||||||
|
first,
|
||||||
|
remainder,
|
||||||
|
string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureCounts.TryGetValue(signature, out var existingCount);
|
||||||
|
signatureCounts[signature] = checked(existingCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FixedIndexGroup(string label)
|
||||||
|
{
|
||||||
|
if (label.Contains("코스피_NXT", StringComparison.Ordinal)) return "코스피_NXT";
|
||||||
|
if (label.Contains("코스닥_NXT", StringComparison.Ordinal)) return "코스닥_NXT";
|
||||||
|
if (label.Contains("코스피", StringComparison.Ordinal)) return "코스피";
|
||||||
|
if (label.Contains("코스닥", StringComparison.Ordinal)) return "코스닥";
|
||||||
|
return "전체";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MaterializationSummary
|
||||||
|
{
|
||||||
|
public int Unique { get; private set; }
|
||||||
|
|
||||||
|
public int Playable { get; private set; }
|
||||||
|
|
||||||
|
public int Blocked { get; private set; }
|
||||||
|
|
||||||
|
public Dictionary<string, int> BlockReasons { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public void AddPlayable(int count)
|
||||||
|
{
|
||||||
|
if (count < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
Unique = checked(Unique + count);
|
||||||
|
Playable = checked(Playable + count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddBlocked(string reason, int count = 1)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reason) || count < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
Unique = checked(Unique + count);
|
||||||
|
Blocked = checked(Blocked + count);
|
||||||
|
BlockReasons[reason] = checked(BlockReasons.GetValueOrDefault(reason) + count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct RoutedRow(
|
||||||
|
LegacyPListAuditRow Row,
|
||||||
|
LegacyPListAuditRoute Route);
|
||||||
|
|
||||||
|
private readonly record struct SelectionKey(
|
||||||
|
string GroupCode,
|
||||||
|
string Subject,
|
||||||
|
string GraphicType,
|
||||||
|
string Subtype,
|
||||||
|
string DataCode);
|
||||||
|
|
||||||
|
private sealed record FixedSource(
|
||||||
|
string FileName,
|
||||||
|
string Market,
|
||||||
|
string ExpectedSha256);
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<FixedSource> FixedSources =
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
"해외.ini",
|
||||||
|
"overseas",
|
||||||
|
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
|
||||||
|
new(
|
||||||
|
"환율.ini",
|
||||||
|
"exchange",
|
||||||
|
"7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
|
||||||
|
new(
|
||||||
|
"지수.ini",
|
||||||
|
"index",
|
||||||
|
"E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||||
using MMoneyCoderSharp.Data;
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
internal static class NxtThemeRestoreDbAudit
|
internal static class NxtThemeRestoreDbAudit
|
||||||
{
|
{
|
||||||
|
private const string OriginalConfigurationPath =
|
||||||
|
@"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\RES\MmoneyCoder.ini";
|
||||||
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
|
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
|
||||||
|
|
||||||
private const string ActiveRowsSql = """
|
private const string ActiveRowsSql = """
|
||||||
@@ -20,8 +25,12 @@ internal static class NxtThemeRestoreDbAudit
|
|||||||
|
|
||||||
internal static async Task RunAsync(
|
internal static async Task RunAsync(
|
||||||
IDataQueryExecutor executor,
|
IDataQueryExecutor executor,
|
||||||
|
MariaDbDatabaseOptions currentOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
await RunEndpointAuditAsync(executor, currentOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
const int maximumRows = 1_000;
|
const int maximumRows = 1_000;
|
||||||
var spec = new DataQuerySpec(
|
var spec = new DataQuerySpec(
|
||||||
ActiveRowsSql,
|
ActiveRowsSql,
|
||||||
@@ -116,9 +125,11 @@ internal static class NxtThemeRestoreDbAudit
|
|||||||
$"remapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
|
$"remapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"blocked={(rows.Count - resolved).ToString(CultureInfo.InvariantCulture)} " +
|
$"blocked={(rows.Count - resolved).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"session={LegacyNamedNxtThemeRestoreService.SessionForLocalHour(DateTime.Now.Hour)}");
|
$"session={LegacyNamedNxtThemeRestoreService.SessionForLocalHour(DateTime.Now.Hour)}");
|
||||||
Console.WriteLine("NXT_THEME_RESTORE_GRAMMAR: " + string.Join(",", grammar
|
Console.WriteLine(
|
||||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
"NXT_THEME_RESTORE_GRAMMAR: " +
|
||||||
.Select(pair => $"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}")));
|
$"variants={grammar.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"rows={grammar.Values.Sum().ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
"rawValuesPrinted=false");
|
||||||
Console.WriteLine("NXT_THEME_RESTORE_ACTIONS: " +
|
Console.WriteLine("NXT_THEME_RESTORE_ACTIONS: " +
|
||||||
(actions.Length == 0 ? "none" : string.Join(",", actions)));
|
(actions.Length == 0 ? "none" : string.Join(",", actions)));
|
||||||
Console.WriteLine("NXT_THEME_RESTORE_FAILURES: " +
|
Console.WriteLine("NXT_THEME_RESTORE_FAILURES: " +
|
||||||
@@ -129,5 +140,323 @@ internal static class NxtThemeRestoreDbAudit
|
|||||||
: $"min={liveCounts.Min().ToString(CultureInfo.InvariantCulture)}," +
|
: $"min={liveCounts.Min().ToString(CultureInfo.InvariantCulture)}," +
|
||||||
$"max={liveCounts.Max().ToString(CultureInfo.InvariantCulture)}," +
|
$"max={liveCounts.Max().ToString(CultureInfo.InvariantCulture)}," +
|
||||||
$"sum={liveCounts.Sum().ToString(CultureInfo.InvariantCulture)}"));
|
$"sum={liveCounts.Sum().ToString(CultureInfo.InvariantCulture)}"));
|
||||||
|
|
||||||
|
await RunJoinDiagnosticAsync(executor, rows, result, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task RunJoinDiagnosticAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
IReadOnlyList<LegacyNamedNxtThemeRestoreRow> sourceRows,
|
||||||
|
LegacyNamedNxtThemeRestoreResult restore,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var codes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
for (var index = 0; index < restore.Rows.Count; index++)
|
||||||
|
{
|
||||||
|
var current = restore.Rows[index].ObservedCurrentThemeCode;
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored = sourceRows[index].DataCode;
|
||||||
|
if (codes.TryGetValue(current, out var previous) &&
|
||||||
|
!string.Equals(previous, stored, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"The NXT join audit found conflicting stored codes for one current identity.");
|
||||||
|
}
|
||||||
|
|
||||||
|
codes[current] = stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codes.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("NXT_THEME_JOIN_DIAGNOSTIC: currentThemes=0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort only to make repeated aggregate runs deterministic. Neither key
|
||||||
|
// is printed or included in an exception.
|
||||||
|
var identities = codes
|
||||||
|
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||||
|
.Select(static pair => new LegacyNxtThemeJoinAuditIdentity(pair.Key, pair.Value))
|
||||||
|
.ToArray();
|
||||||
|
var audit = await new LegacyNxtThemeJoinAuditService(executor)
|
||||||
|
.AuditAsync(identities, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var evidence = audit.Themes;
|
||||||
|
var rawCurrent = evidence.Sum(static row => row.RawCurrentItemCount);
|
||||||
|
var rawStored = evidence.Sum(static row => row.RawStoredItemCount);
|
||||||
|
var currentOriginal = evidence.Sum(static row => row.CurrentOriginalJoinCount);
|
||||||
|
var currentNonStopped = evidence.Sum(static row => row.CurrentNonStoppedJoinCount);
|
||||||
|
var currentLive = evidence.Sum(static row => row.CurrentLiveJoinCount);
|
||||||
|
var storedOriginal = evidence.Sum(static row => row.StoredOriginalJoinCount);
|
||||||
|
var storedNonStopped = evidence.Sum(static row => row.StoredNonStoppedJoinCount);
|
||||||
|
var storedLive = evidence.Sum(static row => row.StoredLiveJoinCount);
|
||||||
|
var exactCandidate = evidence.Sum(static row => row.CurrentExactJoinCount);
|
||||||
|
var stripBothCandidate = evidence.Sum(static row => row.CurrentStripBothJoinCount);
|
||||||
|
var rightSixCandidate = evidence.Sum(static row => row.CurrentRightSixJoinCount);
|
||||||
|
var validCodes = evidence.Sum(static row => row.CurrentValidItemCodeCount);
|
||||||
|
var kospiMaster = evidence.Sum(static row => row.CurrentKospiMasterJoinCount);
|
||||||
|
var kosdaqMaster = evidence.Sum(static row => row.CurrentKosdaqMasterJoinCount);
|
||||||
|
var kospiOnline = evidence.Sum(static row => row.CurrentKospiOnlineJoinCount);
|
||||||
|
var kosdaqOnline = evidence.Sum(static row => row.CurrentKosdaqOnlineJoinCount);
|
||||||
|
var rawCurrentZero = evidence.Count(static row => row.RawCurrentItemCount == 0);
|
||||||
|
var rawStoredPositive = evidence.Count(static row => row.RawStoredItemCount > 0);
|
||||||
|
var originalCurrentPositive = evidence.Count(static row => row.CurrentOriginalJoinCount > 0);
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
"NXT_THEME_JOIN_DIAGNOSTIC: " +
|
||||||
|
$"currentThemes={evidence.Count.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"remappedThemes={identities.Count(identity => !string.Equals(identity.CurrentThemeCode, identity.StoredThemeCode, StringComparison.Ordinal)).ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"rawCurrent={rawCurrent.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"rawStored={rawStored.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"currentOriginalJoin={currentOriginal.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"currentNonStopped={currentNonStopped.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"currentLive={currentLive.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"storedOriginalJoin={storedOriginal.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"storedNonStopped={storedNonStopped.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"storedLive={storedLive.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"currentValidItemCodes={validCodes.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
Console.WriteLine(
|
||||||
|
"NXT_THEME_MASTER_JOIN_DIAGNOSTIC: " +
|
||||||
|
$"kospiMaster={kospiMaster.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"kosdaqMaster={kosdaqMaster.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"kospiOnline={kospiOnline.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"kosdaqOnline={kosdaqOnline.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
Console.WriteLine(
|
||||||
|
"NXT_THEME_JOIN_CANDIDATES_DIAGNOSTIC_ONLY: " +
|
||||||
|
$"exact={exactCandidate.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"stripBoth={stripBothCandidate.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"rightSix={rightSixCandidate.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
Console.WriteLine(
|
||||||
|
"NXT_THEME_JOIN_CLASSIFICATION: " +
|
||||||
|
$"currentRawZeroThemes={rawCurrentZero.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"storedRawPositiveThemes={rawStoredPositive.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"currentOriginalPositiveThemes={originalCurrentPositive.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"root={ClassifyRoot(evidence)} " +
|
||||||
|
"action=read-only-review-no-fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ClassifyRoot(
|
||||||
|
IReadOnlyList<LegacyNxtThemeJoinAuditEvidence> evidence)
|
||||||
|
{
|
||||||
|
if (evidence.All(static row => row.RawCurrentItemCount == 0) &&
|
||||||
|
evidence.All(static row => row.RawStoredItemCount > 0))
|
||||||
|
{
|
||||||
|
return "CurrentThemeCodeHasNoSbItemRowsStoredCodeHasRows";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
|
||||||
|
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
|
||||||
|
evidence.All(static row =>
|
||||||
|
row.CurrentKospiMasterJoinCount == 0 &&
|
||||||
|
row.CurrentKosdaqMasterJoinCount == 0))
|
||||||
|
{
|
||||||
|
return "CurrentItemsMissingFromNxtStockMasters";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
|
||||||
|
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
|
||||||
|
evidence.Any(static row =>
|
||||||
|
row.CurrentKospiMasterJoinCount > 0 ||
|
||||||
|
row.CurrentKosdaqMasterJoinCount > 0))
|
||||||
|
{
|
||||||
|
return "VAllStockExcludesCurrentNxtMasterRows";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
|
||||||
|
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
|
||||||
|
evidence.Any(static row =>
|
||||||
|
row.CurrentExactJoinCount > 0 ||
|
||||||
|
row.CurrentStripBothJoinCount > 0 ||
|
||||||
|
row.CurrentRightSixJoinCount > 0))
|
||||||
|
{
|
||||||
|
return "ItemCodeJoinShapeMismatch";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
|
||||||
|
evidence.All(static row => row.CurrentOriginalJoinCount == 0))
|
||||||
|
{
|
||||||
|
return "CurrentItemsHaveNoStockIdentityJoin";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "MixedOrUnclassifiedReadOnlyEvidence";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunEndpointAuditAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
MariaDbDatabaseOptions currentOptions,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(executor);
|
||||||
|
ArgumentNullException.ThrowIfNull(currentOptions);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var original = ReadOriginalEndpoint(OriginalConfigurationPath);
|
||||||
|
var current = NormalizeEndpoint(
|
||||||
|
currentOptions.Host,
|
||||||
|
currentOptions.Port,
|
||||||
|
currentOptions.Database);
|
||||||
|
var spec = new DataQuerySpec(
|
||||||
|
"SELECT DATABASE() CURRENT_DATABASE, VERSION() SERVER_VERSION");
|
||||||
|
spec.ValidateFor(DataSourceKind.MariaDb);
|
||||||
|
var table = await executor.ExecuteAsync(
|
||||||
|
DataSourceKind.MariaDb,
|
||||||
|
"AUDIT_NXT_ENDPOINT_METADATA",
|
||||||
|
spec,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (table is null || table.Columns.Count != 2 || table.Rows.Count != 1 ||
|
||||||
|
!HasStringColumn(table.Columns[0], "CURRENT_DATABASE") ||
|
||||||
|
!HasStringColumn(table.Columns[1], "SERVER_VERSION") ||
|
||||||
|
table.Rows[0][0] is not string actualDatabase ||
|
||||||
|
table.Rows[0][1] is not string serverVersion ||
|
||||||
|
string.IsNullOrWhiteSpace(actualDatabase) ||
|
||||||
|
string.IsNullOrWhiteSpace(serverVersion))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"The NXT endpoint metadata audit returned an invalid closed result.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalHash = HashEndpoint(original);
|
||||||
|
var currentHash = HashEndpoint(current);
|
||||||
|
var configuredEqual = string.Equals(original, current, StringComparison.Ordinal);
|
||||||
|
var actualDatabaseMatches = string.Equals(
|
||||||
|
NormalizeDatabase(actualDatabase),
|
||||||
|
NormalizeDatabase(currentOptions.Database),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
var serverFamily = serverVersion.Contains(
|
||||||
|
"MariaDB",
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "MariaDB"
|
||||||
|
: "MySqlCompatible";
|
||||||
|
var versionParts = serverVersion.Split('.', '-', '+');
|
||||||
|
var versionMajorMinor = versionParts.Length >= 2 &&
|
||||||
|
int.TryParse(versionParts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) &&
|
||||||
|
int.TryParse(versionParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
|
||||||
|
? $"{major.ToString(CultureInfo.InvariantCulture)}.{minor.ToString(CultureInfo.InvariantCulture)}"
|
||||||
|
: "unclassified";
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
"NXT_ENDPOINT_AUDIT: " +
|
||||||
|
$"originalSha256={originalHash} " +
|
||||||
|
$"currentSha256={currentHash} " +
|
||||||
|
$"configuredEqual={configuredEqual.ToString().ToLowerInvariant()} " +
|
||||||
|
$"actualDatabaseMatchesCurrent={actualDatabaseMatches.ToString().ToLowerInvariant()} " +
|
||||||
|
$"serverFamily={serverFamily} " +
|
||||||
|
$"serverMajorMinor={versionMajorMinor}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadOriginalEndpoint(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"The fixed read-only original MariaDB configuration is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = new FileInfo(path);
|
||||||
|
if ((source.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
|
||||||
|
source.Length is < 1 or > 64 * 1024)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"The fixed read-only original MariaDB configuration failed its file boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var inMariaSection = false;
|
||||||
|
string? connectionName = null;
|
||||||
|
foreach (var rawLine in File.ReadLines(path))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (line.StartsWith("[", StringComparison.Ordinal) &&
|
||||||
|
line.EndsWith("]", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
inMariaSection = string.Equals(line, "[Maria]", StringComparison.Ordinal);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inMariaSection || line.Length == 0 ||
|
||||||
|
line.StartsWith("//", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string prefix = "ConnectionName=";
|
||||||
|
if (line.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (connectionName is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"The original MariaDB endpoint is ambiguous.");
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionName = line[prefix.Length..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionName is null)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"The original MariaDB endpoint is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var slash = connectionName.LastIndexOf('/');
|
||||||
|
var colon = connectionName.LastIndexOf(':', slash - 1);
|
||||||
|
if (slash <= 0 || colon <= 0 || colon >= slash - 1 || slash == connectionName.Length - 1 ||
|
||||||
|
!int.TryParse(
|
||||||
|
connectionName.AsSpan(colon + 1, slash - colon - 1),
|
||||||
|
NumberStyles.None,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out var port))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"The original MariaDB endpoint has an invalid closed shape.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NormalizeEndpoint(
|
||||||
|
connectionName[..colon],
|
||||||
|
port,
|
||||||
|
connectionName[(slash + 1)..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeEndpoint(string? host, int port, string? database)
|
||||||
|
{
|
||||||
|
var normalizedHost = (host ?? string.Empty)
|
||||||
|
.Trim()
|
||||||
|
.TrimEnd('.')
|
||||||
|
.ToLowerInvariant();
|
||||||
|
var normalizedDatabase = NormalizeDatabase(database);
|
||||||
|
if (normalizedHost.Length == 0 || normalizedHost.Length > 253 ||
|
||||||
|
normalizedDatabase.Length == 0 || normalizedDatabase.Length > 128 ||
|
||||||
|
port is < 1 or > 65_535 ||
|
||||||
|
normalizedHost.Any(static value => char.IsControl(value)) ||
|
||||||
|
normalizedDatabase.Any(static value => char.IsControl(value)))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"A MariaDB endpoint is invalid at the closed audit boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(
|
||||||
|
'\u001f',
|
||||||
|
normalizedHost,
|
||||||
|
port.ToString(CultureInfo.InvariantCulture),
|
||||||
|
normalizedDatabase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeDatabase(string? database) =>
|
||||||
|
(database ?? string.Empty).Trim().Normalize(NormalizationForm.FormC);
|
||||||
|
|
||||||
|
private static string HashEndpoint(string endpoint) =>
|
||||||
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(endpoint)));
|
||||||
|
|
||||||
|
private static bool HasStringColumn(DataColumn column, string name) =>
|
||||||
|
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
|
||||||
|
column.DataType == typeof(string);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||||
using MMoneyCoderSharp.Data;
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
Console.OutputEncoding = Encoding.UTF8;
|
Console.OutputEncoding = Encoding.UTF8;
|
||||||
|
if (args.Length == 1 &&
|
||||||
|
args[0].Equals("--manual-import-audit", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return RunManualImportAudit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.Length == 1 &&
|
||||||
|
args[0].Equals("--manual-import-fixture", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return await RunManualImportFixtureAsync();
|
||||||
|
}
|
||||||
|
|
||||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10));
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10));
|
||||||
Console.CancelKeyPress += (_, eventArgs) =>
|
Console.CancelKeyPress += (_, eventArgs) =>
|
||||||
{
|
{
|
||||||
@@ -38,7 +51,16 @@ try
|
|||||||
await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token);
|
await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token);
|
||||||
await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token);
|
await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token);
|
||||||
await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token);
|
await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token);
|
||||||
await NxtThemeRestoreDbAudit.RunAsync(runtime.Executor, cancellation.Token);
|
await NamedPlaylistReadOnlyDbAudit.RunAsync(runtime.Executor, cancellation.Token);
|
||||||
|
await NamedManualRestoreReadOnlyAudit.RunAsync(
|
||||||
|
runtime.Executor,
|
||||||
|
NamedManualRestoreReadOnlyAudit.DefaultTrustedDirectory(),
|
||||||
|
cancellation.Token);
|
||||||
|
await NxtThemeRestoreDbAudit.RunAsync(
|
||||||
|
runtime.Executor,
|
||||||
|
runtime.Options.MariaDb,
|
||||||
|
cancellation.Token);
|
||||||
|
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
|
||||||
|
|
||||||
var service = new LegacyMarketDataService(runtime.Executor);
|
var service = new LegacyMarketDataService(runtime.Executor);
|
||||||
await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token);
|
await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token);
|
||||||
@@ -123,7 +145,362 @@ static string? GetConfigurationPath(string[] arguments)
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new DatabaseConfigurationException(
|
throw new DatabaseConfigurationException(
|
||||||
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>]");
|
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--manual-import-audit|--manual-import-fixture]");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task VerifyLegacyComparisonImportAsync(
|
||||||
|
IDataQueryExecutor executor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string expectedSourceSha256 =
|
||||||
|
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
|
||||||
|
var result = await new LegacyComparisonPairImportService(
|
||||||
|
executor,
|
||||||
|
TrustedLegacyComparisonFileSource.CreateDefault())
|
||||||
|
.ImportAsync(cancellationToken);
|
||||||
|
if (!string.Equals(
|
||||||
|
result.SourceSha256,
|
||||||
|
expectedSourceSha256,
|
||||||
|
StringComparison.Ordinal) ||
|
||||||
|
result.SourceRowCount != 8 ||
|
||||||
|
result.Pairs.Count != 8 ||
|
||||||
|
result.Pairs.Where((pair, index) => pair.RowNumber != index + 1).Any())
|
||||||
|
{
|
||||||
|
throw new DatabaseConfigurationException(
|
||||||
|
"The fixed legacy comparison import did not reproduce its audited 8-row identity set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$"LegacyComparisonImport: rows={result.SourceRowCount.ToString(CultureInfo.InvariantCulture)}, " +
|
||||||
|
$"sha256={result.SourceSha256}, identities=fresh-read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<int> RunManualImportFixtureAsync()
|
||||||
|
{
|
||||||
|
const string expectedSourceSha256 =
|
||||||
|
"fd39a3639e574a19f174225e8e65234d6bdd4c58700bb5d10230a779a03a2114";
|
||||||
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
|
||||||
|
var profile = Environment.GetFolderPath(
|
||||||
|
Environment.SpecialFolder.UserProfile,
|
||||||
|
Environment.SpecialFolderOption.DoNotVerify);
|
||||||
|
var localAppData = Environment.GetFolderPath(
|
||||||
|
Environment.SpecialFolder.LocalApplicationData,
|
||||||
|
Environment.SpecialFolderOption.DoNotVerify);
|
||||||
|
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: FAIL - required profile roots are unavailable.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceDirectory = Path.Combine(
|
||||||
|
profile,
|
||||||
|
"source",
|
||||||
|
"repos",
|
||||||
|
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
|
||||||
|
var operatingDirectory = Path.Combine(
|
||||||
|
localAppData,
|
||||||
|
"MBN_STOCK_WEBVIEW",
|
||||||
|
"OperatorData");
|
||||||
|
var temporaryDirectory = Path.GetFullPath(Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"MBN_STOCK_WEBVIEW-manual-import-fixture-" + Guid.NewGuid().ToString("N")));
|
||||||
|
var expectedDataFiles = new[]
|
||||||
|
{
|
||||||
|
"개인.dat",
|
||||||
|
"외국인.dat",
|
||||||
|
"기관.dat",
|
||||||
|
"VI발동.dat"
|
||||||
|
};
|
||||||
|
var trackedOperatingFiles = expectedDataFiles.Concat(new[]
|
||||||
|
{
|
||||||
|
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||||
|
LegacyManualOperatorDataImporter.IntentFileName
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
var sourceBefore = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||||
|
var operatingBefore = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var destination = TrustedManualDirectory.PreparePrivateWritableDirectory(
|
||||||
|
temporaryDirectory);
|
||||||
|
LegacyManualOperatorImportResult result;
|
||||||
|
LegacyManualOperatorImportException? secondFailure = null;
|
||||||
|
using (var importer = new LegacyManualOperatorDataImporter(
|
||||||
|
destination,
|
||||||
|
new FixedLegacyManualOperatorDataSource()))
|
||||||
|
{
|
||||||
|
result = await importer.ImportAsync(cancellation.Token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = await importer.ImportAsync(cancellation.Token);
|
||||||
|
}
|
||||||
|
catch (LegacyManualOperatorImportException exception)
|
||||||
|
{
|
||||||
|
secondFailure = exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceAfter = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||||
|
var operatingAfter = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
|
||||||
|
var fixtureFiles = SnapshotFiles(destination, trackedOperatingFiles);
|
||||||
|
var dataHashesMatch = expectedDataFiles.All(name =>
|
||||||
|
sourceBefore.TryGetValue(name, out var sourceValue) &&
|
||||||
|
fixtureFiles.TryGetValue(name, out var fixtureValue) &&
|
||||||
|
string.Equals(sourceValue, fixtureValue, StringComparison.Ordinal));
|
||||||
|
var markerPresent = fixtureFiles.TryGetValue(
|
||||||
|
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||||
|
out var markerValue) && markerValue != "missing";
|
||||||
|
var intentAbsent = fixtureFiles.TryGetValue(
|
||||||
|
LegacyManualOperatorDataImporter.IntentFileName,
|
||||||
|
out var intentValue) && intentValue == "missing";
|
||||||
|
var runtimeState = LegacyManualOperatorDataRuntimeGuard.Inspect(destination).State;
|
||||||
|
|
||||||
|
if (!SnapshotsEqual(sourceBefore, sourceAfter) ||
|
||||||
|
!SnapshotsEqual(operatingBefore, operatingAfter) ||
|
||||||
|
!dataHashesMatch || !markerPresent || !intentAbsent ||
|
||||||
|
result.MarkerVersion != LegacyManualOperatorDataImporter.MarkerVersion ||
|
||||||
|
result.NetSellRowCount != 15 ||
|
||||||
|
result.ViItemCount != 9 ||
|
||||||
|
!string.Equals(
|
||||||
|
result.SourceSha256,
|
||||||
|
expectedSourceSha256,
|
||||||
|
StringComparison.Ordinal) ||
|
||||||
|
runtimeState != LegacyManualOperatorDataRuntimeState.ReadyImportedData ||
|
||||||
|
secondFailure is null ||
|
||||||
|
secondFailure.Failure != LegacyManualOperatorImportFailure.AlreadyImported ||
|
||||||
|
secondFailure.OutcomeUnknown)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"MANUAL_IMPORT_FIXTURE: FAIL - an isolated import invariant was not reproduced.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$"MANUAL_IMPORT_FIXTURE: PASS rows={result.NetSellRowCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"vi={result.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
|
$"sourceSha256={result.SourceSha256} state={runtimeState} " +
|
||||||
|
"secondAttempt=AlreadyImported outcomeUnknown=false " +
|
||||||
|
"sourceUnchanged=true operatingImportSetUnchanged=true");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: CANCELED_OR_TIMED_OUT");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
catch (LegacyManualOperatorImportException exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"MANUAL_IMPORT_FIXTURE: FAIL - failure={exception.Failure} " +
|
||||||
|
$"outcomeUnknown={exception.OutcomeUnknown.ToString().ToLowerInvariant()}");
|
||||||
|
return exception.OutcomeUnknown ? 5 : 1;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"MANUAL_IMPORT_FIXTURE: FAIL - {exception.GetType().Name}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RemoveOwnedFixtureDirectory(temporaryDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int RunManualImportAudit()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profile = Environment.GetFolderPath(
|
||||||
|
Environment.SpecialFolder.UserProfile,
|
||||||
|
Environment.SpecialFolderOption.DoNotVerify);
|
||||||
|
var localAppData = Environment.GetFolderPath(
|
||||||
|
Environment.SpecialFolder.LocalApplicationData,
|
||||||
|
Environment.SpecialFolderOption.DoNotVerify);
|
||||||
|
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("MANUAL_IMPORT_AUDIT: FAIL - required profile roots are unavailable.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceDirectory = Path.Combine(
|
||||||
|
profile,
|
||||||
|
"source",
|
||||||
|
"repos",
|
||||||
|
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
|
||||||
|
var operatingDirectory = Path.Combine(
|
||||||
|
localAppData,
|
||||||
|
"MBN_STOCK_WEBVIEW",
|
||||||
|
"OperatorData");
|
||||||
|
var expectedDataFiles = new[]
|
||||||
|
{
|
||||||
|
"개인.dat",
|
||||||
|
"외국인.dat",
|
||||||
|
"기관.dat",
|
||||||
|
"VI발동.dat"
|
||||||
|
};
|
||||||
|
var source = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||||
|
var operating = SnapshotFiles(
|
||||||
|
operatingDirectory,
|
||||||
|
expectedDataFiles.Concat(new[]
|
||||||
|
{
|
||||||
|
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||||
|
LegacyManualOperatorDataImporter.IntentFileName
|
||||||
|
}));
|
||||||
|
var markerPresent = operating[LegacyManualOperatorDataImporter.MarkerFileName] != "missing";
|
||||||
|
var intentPresent = operating[LegacyManualOperatorDataImporter.IntentFileName] != "missing";
|
||||||
|
var dataPresent = expectedDataFiles.Count(name => operating[name] != "missing");
|
||||||
|
var sourcePresent = expectedDataFiles.Count(name => source[name] != "missing");
|
||||||
|
var byteIdentical = expectedDataFiles.All(name =>
|
||||||
|
source[name] != "missing" &&
|
||||||
|
string.Equals(source[name], operating[name], StringComparison.Ordinal));
|
||||||
|
var stagedPresent = Directory.Exists(operatingDirectory) &&
|
||||||
|
Directory.EnumerateFileSystemEntries(
|
||||||
|
operatingDirectory,
|
||||||
|
".legacy-manual-import-v1-*",
|
||||||
|
SearchOption.TopDirectoryOnly).Any();
|
||||||
|
var runtimeState = LegacyManualOperatorDataRuntimeGuard
|
||||||
|
.Inspect(operatingDirectory)
|
||||||
|
.State;
|
||||||
|
|
||||||
|
var (decision, outcomeUnknown) = runtimeState switch
|
||||||
|
{
|
||||||
|
LegacyManualOperatorDataRuntimeState.InterruptedImport =>
|
||||||
|
("InterruptedImport", true),
|
||||||
|
LegacyManualOperatorDataRuntimeState.InvalidImportedState =>
|
||||||
|
("InvalidImportedState", false),
|
||||||
|
LegacyManualOperatorDataRuntimeState.ReadyImportedData =>
|
||||||
|
("AlreadyImported", false),
|
||||||
|
_ when dataPresent > 0 || stagedPresent =>
|
||||||
|
("DestinationNotEmpty", false),
|
||||||
|
_ when sourcePresent == expectedDataFiles.Length =>
|
||||||
|
("Eligible", false),
|
||||||
|
_ => ("SourceUnavailable", false)
|
||||||
|
};
|
||||||
|
Console.WriteLine(
|
||||||
|
$"MANUAL_IMPORT_AUDIT: PASS sourceFiles={sourcePresent.ToString(CultureInfo.InvariantCulture)}/4 " +
|
||||||
|
$"operatingFiles={dataPresent.ToString(CultureInfo.InvariantCulture)}/4 " +
|
||||||
|
$"sourceByteIdentical={byteIdentical.ToString().ToLowerInvariant()} " +
|
||||||
|
$"marker={markerPresent.ToString().ToLowerInvariant()} " +
|
||||||
|
$"intent={intentPresent.ToString().ToLowerInvariant()} " +
|
||||||
|
$"staged={stagedPresent.ToString().ToLowerInvariant()} " +
|
||||||
|
$"runtime={runtimeState} " +
|
||||||
|
$"decision={decision} outcomeUnknown={outcomeUnknown.ToString().ToLowerInvariant()} " +
|
||||||
|
"writesAttempted=false");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"MANUAL_IMPORT_AUDIT: FAIL - {exception.GetType().Name}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static IReadOnlyDictionary<string, string> SnapshotFiles(
|
||||||
|
string directory,
|
||||||
|
IEnumerable<string> fileNames)
|
||||||
|
{
|
||||||
|
const long maximumAuditFileBytes = 32 * 1024;
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var fileName in fileNames)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(directory, fileName);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
result.Add(fileName, "missing");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new FileStream(
|
||||||
|
path,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.Read,
|
||||||
|
FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
if (stream.Length is < 0 or > maximumAuditFileBytes)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"A manual-import audit file exceeded its closed size boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = new byte[checked((int)stream.Length)];
|
||||||
|
stream.ReadExactly(bytes);
|
||||||
|
if (stream.ReadByte() != -1)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"A manual-import audit file changed beyond its closed size boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
||||||
|
result.Add(
|
||||||
|
fileName,
|
||||||
|
bytes.Length.ToString(CultureInfo.InvariantCulture) + ":" + hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool SnapshotsEqual(
|
||||||
|
IReadOnlyDictionary<string, string> first,
|
||||||
|
IReadOnlyDictionary<string, string> second) =>
|
||||||
|
first.Count == second.Count && first.All(pair =>
|
||||||
|
second.TryGetValue(pair.Key, out var value) &&
|
||||||
|
string.Equals(pair.Value, value, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
static void RemoveOwnedFixtureDirectory(string directory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var temporaryRoot = Path.TrimEndingDirectorySeparator(
|
||||||
|
Path.GetFullPath(Path.GetTempPath()));
|
||||||
|
var resolved = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||||
|
var prefix = temporaryRoot + Path.DirectorySeparatorChar;
|
||||||
|
const string ownedNamePrefix = "MBN_STOCK_WEBVIEW-manual-import-fixture-";
|
||||||
|
var ownedName = Path.GetFileName(resolved);
|
||||||
|
var ownedSuffix = ownedName.StartsWith(ownedNamePrefix, StringComparison.Ordinal)
|
||||||
|
? ownedName[ownedNamePrefix.Length..]
|
||||||
|
: string.Empty;
|
||||||
|
if (!resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
ownedSuffix.Length != 32 ||
|
||||||
|
ownedSuffix.Any(static value => !char.IsAsciiHexDigit(value)) ||
|
||||||
|
(File.GetAttributes(resolved) & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Refusing to remove a directory outside the owned fixture boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingDirectories = new Stack<string>();
|
||||||
|
pendingDirectories.Push(resolved);
|
||||||
|
while (pendingDirectories.TryPop(out var current))
|
||||||
|
{
|
||||||
|
foreach (var entry in Directory.EnumerateFileSystemEntries(
|
||||||
|
current,
|
||||||
|
"*",
|
||||||
|
new EnumerationOptions
|
||||||
|
{
|
||||||
|
RecurseSubdirectories = false,
|
||||||
|
ReturnSpecialDirectories = false,
|
||||||
|
AttributesToSkip = 0
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
var attributes = File.GetAttributes(entry);
|
||||||
|
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Refusing to recursively remove a fixture containing a reparse point.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attributes & FileAttributes.Directory) != 0)
|
||||||
|
{
|
||||||
|
pendingDirectories.Push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.Delete(resolved, recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task VerifyOracleServerVersionAsync(
|
static async Task VerifyOracleServerVersionAsync(
|
||||||
|
|||||||
Reference in New Issue
Block a user