feat: restore legacy named playlist workflows
This commit is contained in:
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")
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user