feat: restore legacy named playlist workflows
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyNamedForeignStockRestoreServiceTests
|
||||
{
|
||||
public static TheoryData<string, string, string, string, int?> Actions =>
|
||||
new()
|
||||
{
|
||||
{ "1열판기본", "", "stock-current", "5001", null },
|
||||
{ "캔들그래프", "5일", "stock-candle-5d", "8061", 5 },
|
||||
{ "캔들그래프", "20일", "stock-candle-20d", "8040", 20 },
|
||||
{ "캔들그래프", "60일", "stock-candle-60d", "8046", 60 },
|
||||
{ "캔들그래프", "120일", "stock-candle-120d", "8051", 120 },
|
||||
{ "캔들그래프", "240일", "stock-candle-240d", "8056", 240 }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Actions))]
|
||||
public async Task ResolveAsync_MapsEveryClosedUc5ActionAfterExactBoundIdentity(
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string actionId,
|
||||
string cutCode,
|
||||
int? periodDays)
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
Assert.Equal(DataSourceKind.Oracle, call.Source);
|
||||
Assert.Equal(LegacyNamedForeignStockRestoreService.QueryName, call.QueryName);
|
||||
call.Spec.ValidateFor(DataSourceKind.Oracle);
|
||||
Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("F_FDTC = '1'", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("SELECT DISTINCT", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("NVIDIA", call.Spec.Sql, StringComparison.Ordinal);
|
||||
var parameter = Assert.Single(call.Spec.Parameters);
|
||||
Assert.Equal("input_name", parameter.Name);
|
||||
Assert.Equal("NVIDIA", parameter.Value);
|
||||
Assert.Equal(DbType.String, parameter.DbType);
|
||||
return IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1"));
|
||||
});
|
||||
var row = Row(graphicType: graphicType, subtype: subtype);
|
||||
|
||||
Assert.True(LegacyNamedForeignStockRestoreService.IsCandidate(row));
|
||||
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||
.ResolveAsync([row])).Rows);
|
||||
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Equal(actionId, outcome.Identity!.ActionId);
|
||||
Assert.Equal("NVIDIA", outcome.Identity.InputName);
|
||||
Assert.Equal("NAS@NVDA", outcome.Identity.Symbol);
|
||||
Assert.Equal("US", outcome.Identity.NationCode);
|
||||
Assert.Equal("1", outcome.Identity.ForeignDomesticCode);
|
||||
Assert.Equal(periodDays is null ? "s5001" : "s8010", outcome.Identity.BuilderKey);
|
||||
Assert.Equal(cutCode, outcome.Identity.CutCode);
|
||||
Assert.Equal(periodDays is null ? "CURRENT" : "PRICE", outcome.Identity.ValueType);
|
||||
Assert.Equal(periodDays, outcome.Identity.PeriodDays);
|
||||
Assert.Single(executor.Calls);
|
||||
Assert.Equal(0, executor.StringSqlCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PreservesOrderAndCachesRepeatedExactInputName()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
|
||||
return name == "TSMC"
|
||||
? IdentityTable((name, "TPE@2330", "TW", "1"))
|
||||
: IdentityTable((name, "NAS@NVDA", "US", "1"));
|
||||
});
|
||||
var rows = new[]
|
||||
{
|
||||
Row(itemIndex: 12, subject: "TSMC", graphicType: "캔들그래프", subtype: "240일"),
|
||||
Row(itemIndex: 2, graphicType: "1열판기본", subtype: ""),
|
||||
Row(itemIndex: 900, graphicType: "캔들그래프", subtype: "5일")
|
||||
};
|
||||
|
||||
var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(rows);
|
||||
|
||||
Assert.Equal([12, 2, 900], result.Rows.Select(value => value.ItemIndex));
|
||||
Assert.Equal(["TPE@2330", "NAS@NVDA", "NAS@NVDA"],
|
||||
result.Rows.Select(value => value.Identity!.Symbol));
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FailsClosedPerRowForMissingAmbiguousAndInvalidData()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
|
||||
return name switch
|
||||
{
|
||||
"MISSING" => IdentityTable(),
|
||||
"AMBIGUOUS" => IdentityTable(
|
||||
(name, "NAS@ONE", "US", "1"),
|
||||
(name, "TPE@TWO", "TW", "1")),
|
||||
"INVALID" => IdentityTable((name, "bad symbol!", "US", "1")),
|
||||
_ => throw new InvalidOperationException(name)
|
||||
};
|
||||
});
|
||||
|
||||
var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(
|
||||
[
|
||||
Row(1, "MISSING"),
|
||||
Row(2, "AMBIGUOUS"),
|
||||
Row(3, "INVALID")
|
||||
]);
|
||||
|
||||
Assert.Equal(
|
||||
new LegacyNamedForeignStockRestoreFailure?[]
|
||||
{
|
||||
LegacyNamedForeignStockRestoreFailure.IdentityNotFound,
|
||||
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous,
|
||||
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid
|
||||
},
|
||||
result.Rows.Select(value => value.Failure));
|
||||
Assert.All(result.Rows, value => Assert.False(value.IsResolved));
|
||||
Assert.Null(result.Rows[0].DatabaseIssue);
|
||||
Assert.Null(result.Rows[1].DatabaseIssue);
|
||||
Assert.Equal(
|
||||
LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation,
|
||||
result.Rows[2].DatabaseIssue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("FOREIGN_STOCK", "1열판기본", "", "1/1", "")]
|
||||
[InlineData("해외종목", "CURRENT", "", "1/1", "")]
|
||||
[InlineData("해외종목", "캔들그래프", "1년", "1/1", "")]
|
||||
[InlineData("해외종목", "캔들그래프", "5일", "1/2", "")]
|
||||
[InlineData("해외종목", "캔들그래프", "5일", "1/1", "NAS@NVDA|US|1")]
|
||||
public async Task ResolveAsync_RejectsEverythingOutsideExactLegacyGrammarWithoutQuery(
|
||||
string groupCode,
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string pageText,
|
||||
string dataCode)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var row = Row(
|
||||
groupCode: groupCode,
|
||||
graphicType: graphicType,
|
||||
subtype: subtype,
|
||||
pageText: pageText,
|
||||
dataCode: dataCode);
|
||||
|
||||
Assert.False(LegacyNamedForeignStockRestoreService.IsCandidate(row));
|
||||
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||
.ResolveAsync([row])).Rows);
|
||||
|
||||
Assert.Equal(LegacyNamedForeignStockRestoreFailure.InvalidRow, outcome.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PreservesDisabledRowAndTaiwanNation()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ =>
|
||||
IdentityTable(("TSMC", "TPE@2330", "TW", "1")));
|
||||
var row = Row(subject: "TSMC", isEnabled: false);
|
||||
|
||||
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||
.ResolveAsync([row])).Rows);
|
||||
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Equal("TW", outcome.Identity!.NationCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_AcceptsBoundProviderSymbolContainingHangulLetters()
|
||||
{
|
||||
const string symbol = "NAS@해외 종목1";
|
||||
var executor = new RecordingExecutor(_ =>
|
||||
IdentityTable(("NVIDIA", symbol, "US", "1")));
|
||||
|
||||
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||
.ResolveAsync([Row()])).Rows);
|
||||
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Equal(symbol, outcome.Identity!.Symbol);
|
||||
Assert.Null(outcome.DatabaseIssue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_AcceptsSafeSupplementaryScalarsConsistentlyWithWebContract()
|
||||
{
|
||||
const string inputName = "회사🚀";
|
||||
const string symbol = "NAS@𐐀1";
|
||||
var executor = new RecordingExecutor(_ =>
|
||||
IdentityTable((inputName, symbol, "US", "1")));
|
||||
|
||||
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||
.ResolveAsync([Row(subject: inputName)])).Rows);
|
||||
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Equal(inputName, outcome.Identity!.InputName);
|
||||
Assert.Equal(symbol, outcome.Identity.Symbol);
|
||||
Assert.Null(outcome.DatabaseIssue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("NAS|NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter)]
|
||||
[InlineData("NAS^NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolCaret)]
|
||||
[InlineData(" NAS@NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
|
||||
[InlineData("NAS@NVDA ", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
|
||||
[InlineData("NAS!NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation)]
|
||||
[InlineData("NAS\nNVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
|
||||
[InlineData("NAS😀NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther)]
|
||||
public async Task ResolveAsync_DiagnosticReasonNeverPrintsOrRelaxesUnsafeSymbol(
|
||||
string symbol,
|
||||
LegacyNamedForeignStockDatabaseIssue expectedIssue)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ =>
|
||||
IdentityTable(("NVIDIA", symbol, "US", "1")));
|
||||
|
||||
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
|
||||
.ResolveAsync([Row()])).Rows);
|
||||
|
||||
Assert.False(outcome.IsResolved);
|
||||
Assert.Equal(LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid, outcome.Failure);
|
||||
Assert.Equal(expectedIssue, outcome.DatabaseIssue);
|
||||
Assert.DoesNotContain(symbol, outcome.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UsesInjectedClockForCorrelatedBatchTimestamp()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero);
|
||||
var executor = new RecordingExecutor(_ =>
|
||||
IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1")));
|
||||
var service = new LegacyNamedForeignStockRestoreService(
|
||||
executor,
|
||||
new FixedTimeProvider(now));
|
||||
|
||||
var result = await service.ResolveAsync([Row()]);
|
||||
|
||||
Assert.Equal(now, result.RetrievedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RejectsMalformedBatchAndNeverRetriesFailure()
|
||||
{
|
||||
var failed = new RecordingExecutor(_ => throw new DataException("one read failure"));
|
||||
var service = new LegacyNamedForeignStockRestoreService(failed);
|
||||
|
||||
await Assert.ThrowsAsync<DataException>(() => service.ResolveAsync([Row()]));
|
||||
Assert.Single(failed.Calls);
|
||||
|
||||
var unused = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
service = new LegacyNamedForeignStockRestoreService(unused);
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([Row(4), Row(4)]));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([null!]));
|
||||
Assert.Empty(unused.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_HonorsCancellationWithoutASecondQuery()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
cancellation.Cancel();
|
||||
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
|
||||
return IdentityTable((name, "NAS@NVDA", "US", "1"));
|
||||
}, throwBeforeHandlerWhenCanceled: false);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(
|
||||
[
|
||||
Row(0),
|
||||
Row(1, "TSMC")
|
||||
],
|
||||
cancellation.Token));
|
||||
|
||||
Assert.Single(executor.Calls);
|
||||
}
|
||||
|
||||
private static LegacyNamedForeignStockRestoreRow Row(
|
||||
int itemIndex = 0,
|
||||
string subject = "NVIDIA",
|
||||
bool isEnabled = true,
|
||||
string groupCode = "해외종목",
|
||||
string graphicType = "1열판기본",
|
||||
string subtype = "",
|
||||
string pageText = "1/1",
|
||||
string dataCode = "") =>
|
||||
new(
|
||||
itemIndex,
|
||||
isEnabled,
|
||||
groupCode,
|
||||
subject,
|
||||
graphicType,
|
||||
subtype,
|
||||
pageText,
|
||||
dataCode);
|
||||
|
||||
private static DataTable IdentityTable(
|
||||
params (string InputName, string Symbol, string Nation, string Fdtc)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("F_INPUT_NAME", typeof(string));
|
||||
table.Columns.Add("F_SYMB", typeof(string));
|
||||
table.Columns.Add("F_NATC", typeof(string));
|
||||
table.Columns.Add("F_FDTC", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.InputName, row.Symbol, row.Nation, row.Fdtc);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private sealed record RecordedCall(
|
||||
DataSourceKind Source,
|
||||
string QueryName,
|
||||
DataQuerySpec Spec,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => value;
|
||||
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
}
|
||||
|
||||
private sealed class RecordingExecutor(
|
||||
Func<RecordedCall, DataTable> handler,
|
||||
bool throwBeforeHandlerWhenCanceled = true) : IDataQueryExecutor
|
||||
{
|
||||
private int _stringSqlCallCount;
|
||||
|
||||
public List<RecordedCall> Calls { get; } = [];
|
||||
|
||||
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
|
||||
|
||||
public DataTable Execute(DataSourceKind source, string tableName, string query)
|
||||
{
|
||||
Interlocked.Increment(ref _stringSqlCallCount);
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _stringSqlCallCount);
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
DataQuerySpec query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (throwBeforeHandlerWhenCanceled)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
query.ValidateFor(source);
|
||||
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||
Calls.Add(call);
|
||||
return Task.FromResult(handler(call).Copy());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyNamedKrxThemeRestoreServiceTests
|
||||
{
|
||||
public static TheoryData<string, string, string, string, int, string> SupportedRows =>
|
||||
new()
|
||||
{
|
||||
{ "5단 표그래프", "테마-현재가(입력순)", "five-row-current", "s5074", 5, "INPUT_ORDER" },
|
||||
{ "5단 표그래프", "테마-현재가(상승률순)", "five-row-current", "s5074", 5, "GAIN_DESC" },
|
||||
{ "5단 표그래프", "테마-현재가(하락률순)", "five-row-current", "s5074", 5, "GAIN_ASC" },
|
||||
{ "5단 표그래프", "테마-현재가", "five-row-current", "s5074", 5, "GAIN_ASC" },
|
||||
{ "5단 표그래프", "테마-예상체결가(입력순)", "five-row-expected", "s5074", 5, "INPUT_ORDER" },
|
||||
{ "5단 표그래프", "테마-예상체결가(상승률순)", "five-row-expected", "s5074", 5, "GAIN_DESC" },
|
||||
{ "5단 표그래프", "테마-예상체결가(하락률순)", "five-row-expected", "s5074", 5, "GAIN_ASC" },
|
||||
{ "5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", 5, "GAIN_ASC" },
|
||||
{ "6종목 현재가", "테마-현재가(입력순)", "six-row-current", "s5077", 6, "INPUT_ORDER" },
|
||||
{ "6종목 현재가", "테마-현재가(상승률순)", "six-row-current", "s5077", 6, "GAIN_DESC" },
|
||||
{ "6종목 현재가", "테마-현재가(하락률순)", "six-row-current", "s5077", 6, "GAIN_ASC" },
|
||||
{ "6종목 현재가", "테마-현재가", "six-row-current", "s5077", 6, "GAIN_ASC" },
|
||||
{ "12종목 현재가", "테마-현재가(입력순)", "twelve-row-current", "s5088", 12, "INPUT_ORDER" },
|
||||
{ "12종목 현재가", "테마-현재가(상승률순)", "twelve-row-current", "s5088", 12, "GAIN_DESC" },
|
||||
{ "12종목 현재가", "테마-현재가(하락률순)", "twelve-row-current", "s5088", 12, "GAIN_ASC" },
|
||||
{ "12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", 12, "GAIN_ASC" }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SupportedRows))]
|
||||
public async Task ResolveAsync_PreservesOriginalActionsAndFinalSortBranchWhileRemappingCode(
|
||||
string graphicType,
|
||||
string subtype,
|
||||
string actionId,
|
||||
string builderKey,
|
||||
int pageSize,
|
||||
string sort)
|
||||
{
|
||||
var executor = GoodExecutor("반도체", "87654321", previewCount: 2);
|
||||
var service = new LegacyNamedKrxThemeRestoreService(executor);
|
||||
|
||||
var outcome = Assert.Single((await service.ResolveAsync(
|
||||
[Row(graphicType: graphicType, subtype: subtype)])).Rows);
|
||||
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Null(outcome.Failure);
|
||||
Assert.NotNull(outcome.Identity);
|
||||
Assert.Equal(actionId, outcome.Identity.ActionId);
|
||||
Assert.Equal(builderKey, outcome.Identity.BuilderKey);
|
||||
Assert.Equal(builderKey[1..], outcome.Identity.CutCode);
|
||||
Assert.Equal(pageSize, outcome.Identity.PageSize);
|
||||
Assert.Equal(sort, outcome.Identity.Sort);
|
||||
Assert.Equal("반도체", outcome.Identity.ThemeTitle);
|
||||
Assert.Equal("0123", outcome.Identity.StoredThemeCode);
|
||||
Assert.Equal("87654321", outcome.Identity.CurrentThemeCode);
|
||||
Assert.True(outcome.Identity.CodeWasRemapped);
|
||||
Assert.Equal(2, outcome.Identity.PreviewItemCount);
|
||||
Assert.False(outcome.Identity.PreviewIsTruncated);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
|
||||
var identityCall = executor.Calls[0];
|
||||
Assert.Equal(DataSourceKind.Oracle, identityCall.Source);
|
||||
Assert.Equal(LegacyNamedKrxThemeRestoreService.IdentityQueryName, identityCall.QueryName);
|
||||
identityCall.Spec.ValidateFor(DataSourceKind.Oracle);
|
||||
Assert.Contains("s.SB_TITLE = :theme_title", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("d.SB_CODE = s.SB_CODE", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ROWNUM <= 2", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("반도체", identityCall.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Collection(identityCall.Spec.Parameters, parameter =>
|
||||
{
|
||||
Assert.Equal("theme_title", parameter.Name);
|
||||
Assert.Equal("반도체", parameter.Value);
|
||||
Assert.Equal(DbType.String, parameter.DbType);
|
||||
});
|
||||
|
||||
var previewCall = executor.Calls[1];
|
||||
Assert.Equal(DataSourceKind.Oracle, previewCall.Source);
|
||||
Assert.Equal("THEME_PREVIEW_KRX", previewCall.QueryName);
|
||||
Assert.Equal("87654321", previewCall.Spec.Parameters[0].Value);
|
||||
Assert.Equal("반도체", previewCall.Spec.Parameters[1].Value);
|
||||
Assert.Equal(241, previewCall.Spec.Parameters[2].Value);
|
||||
Assert.Equal(0, executor.StringSqlCallCount);
|
||||
Assert.All(executor.Calls, call =>
|
||||
{
|
||||
var sql = call.Spec.Sql.TrimStart().ToUpperInvariant();
|
||||
Assert.StartsWith("SELECT", sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("INSERT ", sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("UPDATE ", sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DELETE ", sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("MERGE ", sql, StringComparison.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_CachesExactTitleButPreservesBatchOrderAndStoredCodes()
|
||||
{
|
||||
var executor = GoodExecutor("AI", "90000001", previewCount: 1);
|
||||
var service = new LegacyNamedKrxThemeRestoreService(executor);
|
||||
var rows = new[]
|
||||
{
|
||||
Row(itemIndex: 7, subject: "AI", dataCode: "111"),
|
||||
Row(itemIndex: 2, subject: "AI", dataCode: "2222", graphicType: "6종목 현재가")
|
||||
};
|
||||
|
||||
var result = await service.ResolveAsync(rows);
|
||||
|
||||
Assert.Equal([7, 2], result.Rows.Select(row => row.ItemIndex));
|
||||
Assert.Equal(["111", "2222"], result.Rows.Select(row => row.Identity!.StoredThemeCode));
|
||||
Assert.All(result.Rows, row => Assert.Equal("90000001", row.Identity!.CurrentThemeCode));
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_AcceptsSafeSupplementaryThemeTitleConsistentlyWithWebContract()
|
||||
{
|
||||
const string title = "우주🚀";
|
||||
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
|
||||
GoodExecutor(title, "90000001", previewCount: 1))
|
||||
.ResolveAsync([Row(subject: title)])).Rows);
|
||||
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.Equal(title, outcome.Identity!.ThemeTitle);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("테마_NXT", "반도체", "1/1", "0123")]
|
||||
[InlineData("테마", "반도체(NXT)", "1/1", "0123")]
|
||||
[InlineData("테마", " 반도체", "1/1", "0123")]
|
||||
[InlineData("테마", "반도체", "01/1", "0123")]
|
||||
[InlineData("테마", "반도체", "1/1", "R0123")]
|
||||
public async Task ResolveAsync_InvalidRowsFailWithoutDatabaseAccess(
|
||||
string groupCode,
|
||||
string subject,
|
||||
string pageText,
|
||||
string dataCode)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
|
||||
.ResolveAsync([Row(
|
||||
groupCode: groupCode,
|
||||
subject: subject,
|
||||
pageText: pageText,
|
||||
dataCode: dataCode)])).Rows);
|
||||
|
||||
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.InvalidRow, outcome.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("6종목 현재가", "테마-예상체결가(입력순)")]
|
||||
[InlineData("12종목 현재가", "테마-예상체결가")]
|
||||
[InlineData("네모그래프", "테마-현재가(입력순)")]
|
||||
[InlineData("5단 표그래프", "테마-현재가(거래량순)")]
|
||||
public async Task ResolveAsync_UnsupportedActionsFailClosedPerRow(
|
||||
string graphicType,
|
||||
string subtype)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
|
||||
.ResolveAsync([Row(graphicType: graphicType, subtype: subtype)])).Rows);
|
||||
|
||||
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.UnsupportedAction, outcome.Failure);
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ReportsNotFoundAndAmbiguousPerExactTitleWithoutPreview()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var title = Assert.IsType<string>(call.Spec.Parameters[0].Value);
|
||||
return title == "없음"
|
||||
? IdentityTable()
|
||||
: IdentityTable(
|
||||
(title, "111", "KRX", "1"),
|
||||
(title, "222", "KRX", "1"));
|
||||
});
|
||||
var result = await new LegacyNamedKrxThemeRestoreService(executor).ResolveAsync(
|
||||
[
|
||||
Row(itemIndex: 0, subject: "없음"),
|
||||
Row(itemIndex: 1, subject: "중복")
|
||||
]);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
LegacyNamedKrxThemeRestoreFailure.IdentityNotFound,
|
||||
LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous
|
||||
],
|
||||
result.Rows.Select(row => row.Failure));
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RequiresNonemptyValidatedKrxPreview()
|
||||
{
|
||||
var executor = new RecordingExecutor(call => call.QueryName switch
|
||||
{
|
||||
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
|
||||
IdentityTable(("반도체", "00000001", "KRX", "1")),
|
||||
"THEME_PREVIEW_KRX" => EmptyPreviewTable("반도체", "00000001"),
|
||||
_ => throw new InvalidOperationException(call.QueryName)
|
||||
});
|
||||
|
||||
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
|
||||
.ResolveAsync([Row()])).Rows);
|
||||
|
||||
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.PreviewEmpty, outcome.Failure);
|
||||
Assert.False(outcome.IsResolved);
|
||||
Assert.Equal("00000001", outcome.ObservedCurrentThemeCode);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PreservesValidatedPreviewTruncationEvidence()
|
||||
{
|
||||
var executor = GoodExecutor("반도체", "00000001", previewCount: 241);
|
||||
|
||||
var identity = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
|
||||
.ResolveAsync([Row()])).Rows).Identity!;
|
||||
|
||||
Assert.Equal(LegacyThemeSelectionService.MaximumPreviewItems, identity.PreviewItemCount);
|
||||
Assert.True(identity.PreviewIsTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FailsClosedOnInvalidDatabaseSchemaCodeOrPreview()
|
||||
{
|
||||
var wrongSchema = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
|
||||
new RecordingExecutor(_ => new DataTable())).ResolveAsync([Row()])).Rows);
|
||||
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid, wrongSchema.Failure);
|
||||
|
||||
var duplicateCode = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
|
||||
new RecordingExecutor(_ =>
|
||||
IdentityTable(("반도체", "00000001", "KRX", "2"))))
|
||||
.ResolveAsync([Row()])).Rows);
|
||||
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous, duplicateCode.Failure);
|
||||
|
||||
var invalidPreviewExecutor = new RecordingExecutor(call => call.QueryName switch
|
||||
{
|
||||
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
|
||||
IdentityTable(("반도체", "00000001", "KRX", "1")),
|
||||
"THEME_PREVIEW_KRX" => new DataTable(),
|
||||
_ => throw new InvalidOperationException(call.QueryName)
|
||||
});
|
||||
var invalidPreview = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
|
||||
invalidPreviewExecutor).ResolveAsync([Row()])).Rows);
|
||||
Assert.Equal(
|
||||
LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid,
|
||||
invalidPreview.Failure);
|
||||
Assert.Equal("00000001", invalidPreview.ObservedCurrentThemeCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PropagatesReadFailureOnceAndHonorsCancellation()
|
||||
{
|
||||
var failing = new RecordingExecutor(_ => throw new DataException("simulated"));
|
||||
await Assert.ThrowsAsync<DataException>(() =>
|
||||
new LegacyNamedKrxThemeRestoreService(failing).ResolveAsync([Row()]));
|
||||
Assert.Single(failing.Calls);
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var canceled = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
new LegacyNamedKrxThemeRestoreService(canceled)
|
||||
.ResolveAsync([Row()], cancellation.Token));
|
||||
Assert.Empty(canceled.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RejectsInvalidBatchAndAllowsDisabledRows()
|
||||
{
|
||||
var executor = GoodExecutor("반도체", "00000001", previewCount: 1);
|
||||
var service = new LegacyNamedKrxThemeRestoreService(executor);
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
|
||||
[Row(itemIndex: 3), Row(itemIndex: 3)]));
|
||||
|
||||
var outcome = Assert.Single((await service.ResolveAsync([Row(isEnabled: false)])).Rows);
|
||||
Assert.True(outcome.IsResolved);
|
||||
Assert.True(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(isEnabled: false)));
|
||||
Assert.False(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(groupCode: "테마_NXT")));
|
||||
}
|
||||
|
||||
private static RecordingExecutor GoodExecutor(
|
||||
string title,
|
||||
string code,
|
||||
int previewCount) =>
|
||||
new(call => call.QueryName switch
|
||||
{
|
||||
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
|
||||
IdentityTable((title, code, "KRX", "1")),
|
||||
"THEME_PREVIEW_KRX" => PreviewTable(title, code, previewCount),
|
||||
_ => throw new InvalidOperationException(call.QueryName)
|
||||
});
|
||||
|
||||
private static LegacyNamedKrxThemeRestoreRow Row(
|
||||
int itemIndex = 0,
|
||||
bool isEnabled = true,
|
||||
string groupCode = "테마",
|
||||
string subject = "반도체",
|
||||
string graphicType = "5단 표그래프",
|
||||
string subtype = "테마-현재가(입력순)",
|
||||
string pageText = "1/1",
|
||||
string dataCode = "0123") =>
|
||||
new(itemIndex, isEnabled, groupCode, subject, graphicType, subtype, pageText, dataCode);
|
||||
|
||||
private static DataTable IdentityTable(
|
||||
params (string Title, string Code, string Market, string CodeCount)[] rows)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("THEME_TITLE", typeof(string));
|
||||
table.Columns.Add("THEME_CODE", typeof(string));
|
||||
table.Columns.Add("THEME_MARKET", typeof(string));
|
||||
table.Columns.Add("CODE_IDENTITY_COUNT", typeof(string));
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.Title, row.Code, row.Market, row.CodeCount);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable PreviewTable(string title, string code, int count)
|
||||
{
|
||||
var table = CreatePreviewTable();
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
table.Rows.Add(
|
||||
title,
|
||||
code,
|
||||
"KRX",
|
||||
$"종목{index + 1}",
|
||||
$"P{index + 1:D6}",
|
||||
index.ToString());
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable EmptyPreviewTable(string title, string code)
|
||||
{
|
||||
var table = CreatePreviewTable();
|
||||
table.Rows.Add(title, code, "KRX", DBNull.Value, DBNull.Value, DBNull.Value);
|
||||
return table;
|
||||
}
|
||||
|
||||
private static DataTable CreatePreviewTable()
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("THEME_TITLE", typeof(string));
|
||||
table.Columns.Add("THEME_CODE", typeof(string));
|
||||
table.Columns.Add("THEME_MARKET", typeof(string));
|
||||
table.Columns.Add("ITEM_NAME", typeof(string));
|
||||
table.Columns.Add("ITEM_CODE", typeof(string));
|
||||
table.Columns.Add("ITEM_INDEX", typeof(string));
|
||||
return table;
|
||||
}
|
||||
|
||||
private sealed record RecordedCall(
|
||||
DataSourceKind Source,
|
||||
string QueryName,
|
||||
DataQuerySpec Spec,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
|
||||
: IDataQueryExecutor
|
||||
{
|
||||
public List<RecordedCall> Calls { get; } = [];
|
||||
|
||||
public int StringSqlCallCount { get; private set; }
|
||||
|
||||
public DataTable Execute(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string query)
|
||||
{
|
||||
StringSqlCallCount++;
|
||||
throw new InvalidOperationException("String SQL must not be used.");
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string sql,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
StringSqlCallCount++;
|
||||
throw new InvalidOperationException("String SQL must not be used.");
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
DataQuerySpec query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||
Calls.Add(call);
|
||||
return Task.FromResult(handler(call));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyNamedManualRestoreAuditTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("코스피", "주요매출 구성", ManualFinancialScreenKind.RevenueComposition, StockMarket.Kospi)]
|
||||
[InlineData("코스닥", "성장성 지표", ManualFinancialScreenKind.GrowthMetrics, StockMarket.Kosdaq)]
|
||||
[InlineData("코스피_NXT", "매출액", ManualFinancialScreenKind.Sales, StockMarket.NxtKospi)]
|
||||
[InlineData("코스닥_NXT", "영업이익", ManualFinancialScreenKind.OperatingProfit, StockMarket.NxtKosdaq)]
|
||||
public void ClassifyHistoricalFinancialGrammar(
|
||||
string group,
|
||||
string graphic,
|
||||
ManualFinancialScreenKind expectedScreen,
|
||||
StockMarket expectedMarket)
|
||||
{
|
||||
var candidate = LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row(group, expectedMarket is StockMarket.NxtKospi or StockMarket.NxtKosdaq
|
||||
? "테스트(NXT)"
|
||||
: "테스트", graphic, string.Empty, "1/1", string.Empty));
|
||||
|
||||
Assert.NotNull(candidate);
|
||||
Assert.Equal(LegacyNamedManualRestoreKind.Financial, candidate.Kind);
|
||||
Assert.True(candidate.IsHistorical);
|
||||
Assert.Equal(expectedScreen, candidate.FinancialScreen);
|
||||
Assert.Equal(expectedMarket, candidate.Market);
|
||||
Assert.Equal("테스트" + (expectedMarket is StockMarket.NxtKospi or StockMarket.NxtKosdaq
|
||||
? "(NXT)"
|
||||
: string.Empty), candidate.StockName);
|
||||
Assert.Equal("Named manual restore candidate (Financial)", candidate.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("KOSPI", "REVENUE_COMPOSITION", ManualFinancialScreenKind.RevenueComposition)]
|
||||
[InlineData("KOSDAQ", "GROWTH_METRICS", ManualFinancialScreenKind.GrowthMetrics)]
|
||||
[InlineData("NXT_KOSPI", "SALES", ManualFinancialScreenKind.Sales)]
|
||||
[InlineData("NXT_KOSDAQ", "OPERATING_PROFIT", ManualFinancialScreenKind.OperatingProfit)]
|
||||
public void ClassifyCanonicalFinancialGrammar(
|
||||
string group,
|
||||
string graphic,
|
||||
ManualFinancialScreenKind expectedScreen)
|
||||
{
|
||||
var candidate = LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row(group, group.StartsWith("NXT_", StringComparison.Ordinal)
|
||||
? "테스트(NXT)"
|
||||
: "테스트", graphic, string.Empty, "1/1", string.Empty));
|
||||
|
||||
Assert.NotNull(candidate);
|
||||
Assert.False(candidate.IsHistorical);
|
||||
Assert.Equal(expectedScreen, candidate.FinancialScreen);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("개인 순매도 상위(수동)", S5025ManualAudience.Individual)]
|
||||
[InlineData("외국인 순매도 상위(수동)", S5025ManualAudience.Foreign)]
|
||||
[InlineData("기관 순매도 상위(수동)", S5025ManualAudience.Institution)]
|
||||
public void ClassifyHistoricalNetSellGrammar(
|
||||
string group,
|
||||
S5025ManualAudience expected)
|
||||
{
|
||||
var candidate = LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row(group, "지수", "순매도 상위", "순매도 상위", "1/1", string.Empty));
|
||||
|
||||
Assert.NotNull(candidate);
|
||||
Assert.Equal(LegacyNamedManualRestoreKind.NetSell, candidate.Kind);
|
||||
Assert.True(candidate.IsHistorical);
|
||||
Assert.Equal(expected, candidate.Audience);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("INDIVIDUAL", S5025ManualAudience.Individual)]
|
||||
[InlineData("FOREIGN", S5025ManualAudience.Foreign)]
|
||||
[InlineData("INSTITUTION", S5025ManualAudience.Institution)]
|
||||
public void ClassifyCanonicalNetSellGrammar(
|
||||
string group,
|
||||
S5025ManualAudience expected)
|
||||
{
|
||||
var candidate = LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row(group, "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", "1/1", string.Empty));
|
||||
|
||||
Assert.NotNull(candidate);
|
||||
Assert.False(candidate.IsHistorical);
|
||||
Assert.Equal(expected, candidate.Audience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyHistoricalAndVersionedViGrammar()
|
||||
{
|
||||
var historical = LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row(string.Empty, "가,나,다,라,마,바", "5단 표그래프", "VI 발동(수동)", "1/2", string.Empty));
|
||||
var version = new string('a', 64);
|
||||
var canonical = LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row("PAGED_VI", TrustedViManualReference.SubjectSentinel, "INPUT_ORDER", "CURRENT", "1/2", version));
|
||||
|
||||
Assert.NotNull(historical);
|
||||
Assert.True(historical.IsHistorical);
|
||||
Assert.Equal(6, historical.HistoricalViNames!.Count);
|
||||
Assert.Equal(2, historical.ExpectedPageCount);
|
||||
Assert.NotNull(canonical);
|
||||
Assert.False(canonical.IsHistorical);
|
||||
Assert.Equal(version, canonical.ExpectedViVersion);
|
||||
Assert.Equal(2, canonical.ExpectedPageCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1/1", "가,나,다,라,마,바")]
|
||||
[InlineData("1/21", "가")]
|
||||
[InlineData("01/1", "가")]
|
||||
public void RejectViPageGrammarThatCannotMatchMaterialization(
|
||||
string page,
|
||||
string subject)
|
||||
{
|
||||
Assert.Null(LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row(string.Empty, subject, "5단 표그래프", "VI 발동(수동)", page, string.Empty)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinancialFreshProofRequiresTwoStableReadsAndOneMasterIdentity()
|
||||
{
|
||||
var candidate = Assert.IsType<LegacyNamedManualRestoreCandidate>(
|
||||
LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row("코스피", "테스트", "매출액", string.Empty, "1/1", string.Empty)));
|
||||
var first = SalesSnapshot("테스트", 'A');
|
||||
var second = SalesSnapshot("테스트", 'A');
|
||||
var search = Search(
|
||||
"테스트",
|
||||
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "테스트", "A0001"));
|
||||
|
||||
Assert.Equal(
|
||||
LegacyNamedManualFreshProofFailure.None,
|
||||
LegacyNamedManualFreshProof.EvaluateFinancial(candidate, first, second, search));
|
||||
Assert.Equal(
|
||||
LegacyNamedManualFreshProofFailure.FinancialSnapshotChanged,
|
||||
LegacyNamedManualFreshProof.EvaluateFinancial(
|
||||
candidate,
|
||||
first,
|
||||
SalesSnapshot("테스트", 'B'),
|
||||
search));
|
||||
Assert.Equal(
|
||||
LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique,
|
||||
LegacyNamedManualFreshProof.EvaluateFinancial(
|
||||
candidate,
|
||||
first,
|
||||
second,
|
||||
Search(
|
||||
"테스트",
|
||||
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "테스트", "A0001"),
|
||||
new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "테스트", "A0002"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinancialFreshProofUsesOriginalNxtDisplayIdentityForSearch()
|
||||
{
|
||||
var candidate = Assert.IsType<LegacyNamedManualRestoreCandidate>(
|
||||
LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row("코스피_NXT", "테스트(NXT)", "매출액", string.Empty, "1/1", string.Empty)));
|
||||
|
||||
Assert.Equal("테스트", LegacyNamedManualFreshProof.FinancialStockSearchQuery(candidate));
|
||||
Assert.Equal(
|
||||
LegacyNamedManualFreshProofFailure.None,
|
||||
LegacyNamedManualFreshProof.EvaluateFinancial(
|
||||
candidate,
|
||||
SalesSnapshot("테스트(NXT)", 'A'),
|
||||
SalesSnapshot("테스트(NXT)", 'A'),
|
||||
Search(
|
||||
"테스트",
|
||||
new StockSearchItem(
|
||||
StockMarket.NxtKospi,
|
||||
DataSourceKind.MariaDb,
|
||||
"테스트(NXT)",
|
||||
"A0001"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualFileFreshProofRejectsShapeOrIdentityDrift()
|
||||
{
|
||||
var netSell = Assert.IsType<LegacyNamedManualRestoreCandidate>(
|
||||
LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row("INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", "1/1", string.Empty)));
|
||||
var rows = Enumerable.Range(0, 5)
|
||||
.Select(_ => new S5025TrustedManualRow("", "", "", ""))
|
||||
.ToArray();
|
||||
Assert.Equal(
|
||||
LegacyNamedManualFreshProofFailure.None,
|
||||
LegacyNamedManualFreshProof.EvaluateNetSell(netSell, rows));
|
||||
Assert.Equal(
|
||||
LegacyNamedManualFreshProofFailure.NetSellSnapshotInvalid,
|
||||
LegacyNamedManualFreshProof.EvaluateNetSell(netSell, rows.Take(4).ToArray()));
|
||||
|
||||
var vi = Assert.IsType<LegacyNamedManualRestoreCandidate>(
|
||||
LegacyNamedManualRestoreClassifier.Classify(
|
||||
Row(string.Empty, "가,나,다,라,마,바", "5단 표그래프", "VI 발동(수동)", "1/2", string.Empty)));
|
||||
var snapshot = new TrustedViStockNameSnapshot(
|
||||
["가", "나", "다", "라", "마", "바"],
|
||||
new string('a', 64));
|
||||
Assert.Equal(
|
||||
LegacyNamedManualFreshProofFailure.None,
|
||||
LegacyNamedManualFreshProof.EvaluateVi(vi, snapshot));
|
||||
Assert.Equal(
|
||||
LegacyNamedManualFreshProofFailure.ViHistoricalNamesChanged,
|
||||
LegacyNamedManualFreshProof.EvaluateVi(
|
||||
vi,
|
||||
snapshot with { StockNames = ["가", "나", "다", "라", "마", "변경"] }));
|
||||
}
|
||||
|
||||
private static LegacyPListAuditRow Row(
|
||||
string group,
|
||||
string subject,
|
||||
string graphic,
|
||||
string subtype,
|
||||
string page,
|
||||
string dataCode)
|
||||
{
|
||||
var fields = new[] { "1", group, subject, graphic, subtype, page, dataCode };
|
||||
return new LegacyPListAuditRow(
|
||||
"20260712",
|
||||
"감사",
|
||||
0,
|
||||
true,
|
||||
Array.AsReadOnly(fields),
|
||||
string.Join('^', fields),
|
||||
page.Replace(" ", string.Empty, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static ManualFinancialSnapshot SalesSnapshot(string stockName, char version) =>
|
||||
new(
|
||||
new ManualSalesRecord(
|
||||
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName),
|
||||
Enumerable.Range(1, 6)
|
||||
.Select(index => new ManualQuarterValue($"Q{index}", index))
|
||||
.ToArray()),
|
||||
new string(version, 64));
|
||||
|
||||
private static StockSearchResult Search(string query, params StockSearchItem[] items) =>
|
||||
new(query, DateTimeOffset.UnixEpoch, Array.AsReadOnly(items), IsTruncated: false);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyNxtThemeJoinAuditServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AuditAsync_UsesBoundAggregateReadsAndReturnsNoIdentifiers()
|
||||
{
|
||||
var executor = new RecordingExecutor(call =>
|
||||
{
|
||||
var current = Assert.IsType<string>(call.Spec.Parameters[0].Value);
|
||||
return current == "90000001"
|
||||
? Table("3", "0", "2", "2", "1", "0", "0", "0", "0", "0", "0", "3",
|
||||
"2", "0", "1", "0")
|
||||
: Table("4", "1", "0", "0", "0", "1", "1", "1", "0", "0", "0", "4",
|
||||
"0", "0", "0", "0");
|
||||
});
|
||||
var service = new LegacyNxtThemeJoinAuditService(
|
||||
executor,
|
||||
new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero)));
|
||||
|
||||
var result = await service.AuditAsync(
|
||||
[
|
||||
new LegacyNxtThemeJoinAuditIdentity("90000001", "111"),
|
||||
new LegacyNxtThemeJoinAuditIdentity("90000002", "222")
|
||||
]);
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero), result.RetrievedAt);
|
||||
Assert.Equal(2, result.Themes.Count);
|
||||
Assert.Equal(0, result.Themes[0].InputIndex);
|
||||
Assert.Equal(3, result.Themes[0].RawCurrentItemCount);
|
||||
Assert.Equal(2, result.Themes[0].CurrentOriginalJoinCount);
|
||||
Assert.Equal(1, result.Themes[0].CurrentLiveJoinCount);
|
||||
Assert.Equal(2, result.Themes[0].CurrentKospiMasterJoinCount);
|
||||
Assert.Equal(1, result.Themes[0].CurrentKospiOnlineJoinCount);
|
||||
Assert.Equal(1, result.Themes[1].InputIndex);
|
||||
Assert.Equal(4, result.Themes[1].RawCurrentItemCount);
|
||||
Assert.Equal(1, result.Themes[1].RawStoredItemCount);
|
||||
Assert.Equal(1, result.Themes[1].StoredLiveJoinCount);
|
||||
Assert.Equal(2, executor.Calls.Count);
|
||||
Assert.Equal(0, executor.StringSqlCallCount);
|
||||
|
||||
Assert.All(executor.Calls, call =>
|
||||
{
|
||||
Assert.Equal(DataSourceKind.MariaDb, call.Source);
|
||||
Assert.Equal(LegacyNxtThemeJoinAuditService.QueryName, call.QueryName);
|
||||
call.Spec.ValidateFor(DataSourceKind.MariaDb);
|
||||
Assert.Contains(
|
||||
"SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE",
|
||||
call.Spec.Sql,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("RIGHT(i.SB_I_CODE, 6)", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("FROM N_STOCK q", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("FROM N_KOSDAQ_ONLINE q", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.Contains("AND EXISTS (", call.Spec.Sql, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
Assert.IsType<string>(call.Spec.Parameters[0].Value),
|
||||
call.Spec.Sql,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Collection(
|
||||
call.Spec.Parameters,
|
||||
current =>
|
||||
{
|
||||
Assert.Equal("current_theme_code", current.Name);
|
||||
Assert.Equal(DbType.String, current.DbType);
|
||||
},
|
||||
stored =>
|
||||
{
|
||||
Assert.Equal("stored_theme_code", stored.Name);
|
||||
Assert.Equal(DbType.String, stored.DbType);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidIdentities))]
|
||||
public async Task AuditAsync_RejectsInvalidOrDuplicateIdentitiesBeforeDatabaseAccess(
|
||||
IReadOnlyList<LegacyNxtThemeJoinAuditIdentity> identities)
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
var service = new LegacyNxtThemeJoinAuditService(executor);
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => service.AuditAsync(identities));
|
||||
|
||||
Assert.Empty(executor.Calls);
|
||||
Assert.Equal(0, executor.StringSqlCallCount);
|
||||
}
|
||||
|
||||
public static TheoryData<IReadOnlyList<LegacyNxtThemeJoinAuditIdentity>> InvalidIdentities =>
|
||||
new()
|
||||
{
|
||||
Array.Empty<LegacyNxtThemeJoinAuditIdentity>(),
|
||||
new LegacyNxtThemeJoinAuditIdentity[] { new("12", "123") },
|
||||
new LegacyNxtThemeJoinAuditIdentity[] { new("123", "A123") },
|
||||
new LegacyNxtThemeJoinAuditIdentity[]
|
||||
{
|
||||
new("123", "111"),
|
||||
new("123", "222")
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task AuditAsync_FailsClosedOnWrongSchemaNonCanonicalCountOrInconsistentSubset()
|
||||
{
|
||||
var tables = new Queue<DataTable>(
|
||||
[
|
||||
new DataTable(),
|
||||
Table("01", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
|
||||
"0", "0", "0", "0"),
|
||||
Table("1", "0", "1", "2", "0", "0", "0", "0", "0", "0", "0", "1",
|
||||
"0", "0", "0", "0"),
|
||||
Table("1", "0", "2", "0", "0", "0", "0", "0", "0", "0", "0", "1",
|
||||
"0", "0", "0", "0"),
|
||||
Table("1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "1",
|
||||
"2", "0", "0", "0"),
|
||||
Table("2", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "2",
|
||||
"1", "2", "0", "0")
|
||||
]);
|
||||
var service = new LegacyNxtThemeJoinAuditService(
|
||||
new RecordingExecutor(_ => tables.Dequeue()));
|
||||
var identity = new[] { new LegacyNxtThemeJoinAuditIdentity("123", "456") };
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditAsync_HonorsPreCanceledTokenWithoutDatabaseAccess()
|
||||
{
|
||||
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
new LegacyNxtThemeJoinAuditService(executor).AuditAsync(
|
||||
[new("123", "456")],
|
||||
cancellation.Token));
|
||||
|
||||
Assert.Empty(executor.Calls);
|
||||
}
|
||||
|
||||
private static DataTable Table(params string[] counts)
|
||||
{
|
||||
Assert.Equal(16, counts.Length);
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("RAW_CURRENT_ITEM_COUNT", typeof(string));
|
||||
table.Columns.Add("RAW_STORED_ITEM_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_ORIGINAL_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_NON_STOPPED_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_LIVE_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("STORED_ORIGINAL_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("STORED_NON_STOPPED_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("STORED_LIVE_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_EXACT_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_STRIP_BOTH_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_RIGHT_SIX_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_VALID_ITEM_CODE_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_KOSPI_MASTER_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_KOSDAQ_MASTER_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_KOSPI_ONLINE_JOIN_COUNT", typeof(string));
|
||||
table.Columns.Add("CURRENT_KOSDAQ_ONLINE_JOIN_COUNT", typeof(string));
|
||||
table.Rows.Add(counts.Cast<object>().ToArray());
|
||||
return table;
|
||||
}
|
||||
|
||||
private sealed record RecordedCall(
|
||||
DataSourceKind Source,
|
||||
string QueryName,
|
||||
DataQuerySpec Spec,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
|
||||
: IDataQueryExecutor
|
||||
{
|
||||
public List<RecordedCall> Calls { get; } = [];
|
||||
|
||||
public int StringSqlCallCount { get; private set; }
|
||||
|
||||
public DataTable Execute(DataSourceKind source, string tableName, string query)
|
||||
{
|
||||
StringSqlCallCount++;
|
||||
throw new InvalidOperationException("String SQL must not be used.");
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
string sql,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
StringSqlCallCount++;
|
||||
throw new InvalidOperationException("String SQL must not be used.");
|
||||
}
|
||||
|
||||
public Task<DataTable> ExecuteAsync(
|
||||
DataSourceKind source,
|
||||
string tableName,
|
||||
DataQuerySpec query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var call = new RecordedCall(source, tableName, query, cancellationToken);
|
||||
Calls.Add(call);
|
||||
return Task.FromResult(handler(call));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => value.ToUniversalTime();
|
||||
|
||||
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyPListReadAuditParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_PreservesEveryOriginalFieldAndOnlyNormalizesDisplayPageSpaces()
|
||||
{
|
||||
const string text = "1^테마^반도체 장비^5단 표그래프^테마-현재가(입력순)^ 1 / 12 ^00123";
|
||||
|
||||
var result = LegacyPListReadAuditParser.Parse(
|
||||
"00000001",
|
||||
"아침 방송",
|
||||
"0",
|
||||
text);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var row = Assert.IsType<LegacyPListAuditRow>(result.Row);
|
||||
Assert.Equal(text, row.RawListText);
|
||||
Assert.Equal(text, string.Join('^', row.Fields));
|
||||
Assert.True(row.IsLossless);
|
||||
Assert.True(row.IsEnabled);
|
||||
Assert.Equal("테마", row.GroupCode);
|
||||
Assert.Equal("반도체 장비", row.Subject);
|
||||
Assert.Equal(" 1 / 12 ", row.RawPageText);
|
||||
Assert.Equal("1/12", row.PageTextForDisplay);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1^테마^반도체^5단^현재가^1/1", LegacyPListAuditParseFailure.InvalidFieldCount)]
|
||||
[InlineData("2^테마^반도체^5단^현재가^1/1^1", LegacyPListAuditParseFailure.InvalidEnabledField)]
|
||||
[InlineData("1^테마^반도체^5단^현재가^1/1^1^extra", LegacyPListAuditParseFailure.InvalidFieldCount)]
|
||||
public void Parse_RejectsAnythingOutsideTheOriginalSevenFieldGrammar(
|
||||
string text,
|
||||
LegacyPListAuditParseFailure expected)
|
||||
{
|
||||
var result = LegacyPListReadAuditParser.Parse("00000001", "방송", "0", text);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(expected, result.Failure);
|
||||
Assert.Null(result.Row);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RejectsReplacementAndKnownCodepageDamageWithoutEchoingText()
|
||||
{
|
||||
var replacement = LegacyPListReadAuditParser.Parse(
|
||||
"00000001", "방송", "0", "1^테마^반도체<EB8F84>^5단^현재가^1/1^1");
|
||||
var mojibake = LegacyPListReadAuditParser.Parse(
|
||||
"00000001", "방송", "0", "1^테마^ì Â^5단^현재가^1/1^1");
|
||||
|
||||
Assert.Equal(LegacyPListAuditParseFailure.ReplacementCharacter, replacement.Failure);
|
||||
Assert.Equal(LegacyPListAuditParseFailure.MojibakeSignal, mojibake.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RejectsUnpairedUtf16SurrogatesButPreservesValidScalarPairs()
|
||||
{
|
||||
var unpaired = LegacyPListReadAuditParser.Parse(
|
||||
"00000001", "방송", "0", "1^테마^반도체\uD800^5단^현재가^1/1^1");
|
||||
var validPair = LegacyPListReadAuditParser.Parse(
|
||||
"00000001", "방송 📈", "0", "1^테마^반도체 📈^5단^현재가^1/1^1");
|
||||
|
||||
Assert.Equal(LegacyPListAuditParseFailure.UnsafeText, unpaired.Failure);
|
||||
Assert.True(validPair.IsSuccess);
|
||||
Assert.True(validPair.Row!.IsLossless);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0000001")]
|
||||
[InlineData("0000000A")]
|
||||
[InlineData(" 00000001")]
|
||||
public void Parse_RequiresTheOriginalEightDigitProgramIdentity(string code)
|
||||
{
|
||||
var result = LegacyPListReadAuditParser.Parse(
|
||||
code, "방송", "0", "1^테마^반도체^5단^현재가^1/1^1");
|
||||
|
||||
Assert.Equal(LegacyPListAuditParseFailure.InvalidProgramCode, result.Failure);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("00")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("1000")]
|
||||
[InlineData(" 0")]
|
||||
public void Parse_RequiresCanonicalBoundedItemIndex(string itemIndex)
|
||||
{
|
||||
var result = LegacyPListReadAuditParser.Parse(
|
||||
"00000001", "방송", itemIndex, "1^테마^반도체^5단^현재가^1/1^1");
|
||||
|
||||
Assert.Equal(LegacyPListAuditParseFailure.InvalidItemIndex, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_AllowsEmptyLegacySelectionFieldsAndLiteralQuestionMarks()
|
||||
{
|
||||
const string text = "0^^확인?^^^1/1^";
|
||||
|
||||
var result = LegacyPListReadAuditParser.Parse("00000001", "질문?", "0", text);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Row!.IsEnabled);
|
||||
Assert.Equal(string.Empty, result.Row.GroupCode);
|
||||
Assert.Equal(string.Empty, result.Row.DataCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evidence_RequiresContiguousIndexesAndExactPersistenceFields()
|
||||
{
|
||||
var first = Parse("0", "1^업종_코스피^코스피^네모그래프^^1/1^");
|
||||
var second = Parse("1", "0^업종_코스피^코스피^섹터지수^^^");
|
||||
var stored = new NamedPlaylistStoredItem(
|
||||
0,
|
||||
true,
|
||||
new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
||||
"업종_코스피", "코스피", "네모그래프", "", ""),
|
||||
new NamedPlaylistPageState(1, 1));
|
||||
|
||||
Assert.True(LegacyPListReadAuditEvidence.HasContiguousIndexes([first, second]));
|
||||
Assert.False(LegacyPListReadAuditEvidence.HasContiguousIndexes([
|
||||
first,
|
||||
second with { ItemIndex = 2 }
|
||||
]));
|
||||
Assert.True(LegacyPListReadAuditEvidence.MatchesPersistenceResult(first, stored));
|
||||
Assert.False(LegacyPListReadAuditEvidence.MatchesPersistenceResult(
|
||||
first with { RawListText = first.RawListText.Replace("코스피", "코스닥") },
|
||||
stored with { IsEnabled = false }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1^업종_코스피^코스피^네모그래프^^1/1^", LegacyPListAuditRoute.IndustryFixedClosedMapper)]
|
||||
[InlineData("1^해외지수^다우존스^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity)]
|
||||
[InlineData("1^테마_NXT^반도체(NXT)^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.NxtThemeFreshIdentity)]
|
||||
[InlineData("1^테마^반도체^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.KrxThemeFreshIdentity)]
|
||||
[InlineData("1^해외종목^APPLE^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignStockFreshIdentity)]
|
||||
[InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.ExistingClosedMapper)]
|
||||
[InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@𐐀|US|1", LegacyPListAuditRoute.ExistingClosedMapper)]
|
||||
[InlineData("1^FOREIGN_STOCK^NVIDIA^MA5^PRICE^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.Unmapped)]
|
||||
[InlineData("1^코스피^삼성전자^성장성 지표^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
|
||||
[InlineData("1^KOSPI^삼성전자^GROWTH_METRICS^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
|
||||
[InlineData("1^INDIVIDUAL^INDEX^MANUAL_NET_SELL^MANUAL_NET_SELL^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
|
||||
[InlineData("1^PAGED_VI^@MBN_VI_SNAPSHOT_V1^INPUT_ORDER^CURRENT^1/1^aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", LegacyPListAuditRoute.ManualFreshMaterialization)]
|
||||
[InlineData("1^코스피^삼성전자^캔들그래프^20일^1/1^005930", LegacyPListAuditRoute.ExistingClosedMapper)]
|
||||
[InlineData("1^ALL^INDEX^PROGRAM_TRADING^TABLE_GRAPH^1/1^", LegacyPListAuditRoute.ExistingClosedMapper)]
|
||||
[InlineData("1^알수없는그룹^알수없는대상^비슷한그래프^^1/1^", LegacyPListAuditRoute.Unmapped)]
|
||||
public void Evidence_RoutesSpecialRowsWithoutGuessing(
|
||||
string text,
|
||||
LegacyPListAuditRoute expected)
|
||||
{
|
||||
Assert.Equal(expected, LegacyPListReadAuditEvidence.ClassifyRoute(Parse("0", text)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evidence_DoesNotTreatNearestStockLabelAsAClosedMapperMatch()
|
||||
{
|
||||
var exact = Parse("0", "1^코스피^삼성전자^수익률그래프^20일^1/1^005930");
|
||||
var nearest = Parse("0", "1^코스피^삼성전자^수익률 그래프^20일^1/1^005930");
|
||||
|
||||
Assert.Equal(
|
||||
LegacyPListAuditRoute.ExistingClosedMapper,
|
||||
LegacyPListReadAuditEvidence.ClassifyRoute(exact));
|
||||
Assert.Equal(
|
||||
LegacyPListAuditRoute.Unmapped,
|
||||
LegacyPListReadAuditEvidence.ClassifyRoute(nearest));
|
||||
}
|
||||
|
||||
private static LegacyPListAuditRow Parse(string itemIndex, string text) =>
|
||||
Assert.IsType<LegacyPListAuditRow>(LegacyPListReadAuditParser.Parse(
|
||||
"00000001", "방송", itemIndex, text).Row);
|
||||
}
|
||||
Reference in New Issue
Block a user