803 lines
31 KiB
C#
803 lines
31 KiB
C#
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")
|
|
];
|
|
}
|