feat: restore legacy named playlist workflows

This commit is contained in:
2026-07-12 11:59:18 +09:00
parent a481c96c39
commit af80c36cde
52 changed files with 9261 additions and 83 deletions

View File

@@ -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());
}
}
}

View File

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

View File

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

View File

@@ -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;
}
}

View File

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