Migrate remaining legacy operator workflows

This commit is contained in:
2026-07-12 05:39:27 +09:00
parent ffb8f43c19
commit a01836a2d7
132 changed files with 20566 additions and 720 deletions

View File

@@ -55,8 +55,8 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
string builderKey)
{
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
StockIdentity("첫번째", "111111"),
StockIdentity("두번째", "222222"));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = await resolver.ResolveAsync(Entry(
@@ -65,7 +65,7 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
subject: " 첫번째 , 두번째 ",
subtype: "ignored-sub",
graphicType: "ignored-forCutInfo",
dataCode: "ignored-dataCode"));
dataCode: "KOSPI:111111|KOSDAQ:222222"));
Assert.Equal(builderKey, result.BuilderKey);
var request = result switch
@@ -76,35 +76,90 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
};
Assert.Equal(ComparisonGraphPeriod.FiveDays, request.Period);
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째"),
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째", "111111"),
request.First);
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째"),
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째", "222222"),
request.Second);
AssertStockLookupOrder(executor, "첫번째", "두번째");
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task Exact_pair_identity_preserves_market_and_code_without_cross_market_name_fallback()
{
var executor = new RecordingExecutor(
StockIdentity("Duplicate", "111111"),
StockIdentity("Second", "222222"));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5026SceneLoadRequest>(await resolver.ResolveAsync(Entry(
"5026",
"STOCK",
"Duplicate,Second",
"",
dataCode: "KOSPI:111111|KOSDAQ:222222")));
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "Duplicate", "111111"),
result.Request.First);
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "222222"),
result.Request.Second);
Assert.Equal(2, executor.Calls.Count);
Assert.Equal("SCENE_COMPARISON_RESOLVE_EXACT_KOSPI_STOCK", executor.Calls[0].TableName);
Assert.Equal("SCENE_COMPARISON_RESOLVE_EXACT_KOSDAQ_STOCK", executor.Calls[1].TableName);
Assert.Equal(
new object?[] { "Duplicate", "111111" },
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
Assert.DoesNotContain(
executor.Calls,
call => call.TableName == "SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK");
}
[Theory]
[InlineData("일봉", ComparisonGraphPeriod.Daily)]
[InlineData("5일", ComparisonGraphPeriod.FiveDays)]
[InlineData("1개월", ComparisonGraphPeriod.OneMonth)]
[InlineData("3개월", ComparisonGraphPeriod.ThreeMonths)]
[InlineData("6개월", ComparisonGraphPeriod.SixMonths)]
[InlineData("12개월", ComparisonGraphPeriod.TwelveMonths)]
[InlineData("")]
[InlineData("KOSPI:111111")]
[InlineData("KOSPI:111111|KOSDAQ:")]
[InlineData("KOSPI:111111|KOSDAQ:222222|KOSPI:333333")]
[InlineData("KOSPI:111111|KOSDAQ:bad/code")]
public async Task Malformed_exact_pair_identity_fails_before_database_access(string dataCode)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("5026", "STOCK", "First,Second", "", dataCode: dataCode)));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("\uC77C\uBD09", ComparisonGraphPeriod.Daily)]
[InlineData("5\uC77C", ComparisonGraphPeriod.FiveDays)]
[InlineData("1\uAC1C\uC6D4", ComparisonGraphPeriod.OneMonth)]
[InlineData("3\uAC1C\uC6D4", ComparisonGraphPeriod.ThreeMonths)]
[InlineData("6\uAC1C\uC6D4", ComparisonGraphPeriod.SixMonths)]
[InlineData("12\uAC1C\uC6D4", ComparisonGraphPeriod.TwelveMonths)]
public async Task S5029_maps_every_original_period(
string subtype,
ComparisonGraphPeriod expected)
{
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
StockIdentity("A", "111111"),
StockIdentity("B", "222222"));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5029SceneLoadRequest>(
await resolver.ResolveAsync(Entry("5029", "", "A,B", subtype)));
await resolver.ResolveAsync(Entry(
"5029",
"STOCK",
"A,B",
subtype,
dataCode: "KOSPI:111111|KOSDAQ:222222")));
Assert.Equal(expected, result.Request.Period);
Assert.Equal("A", result.Request.First.StockName);
Assert.Equal("111111", result.Request.First.StockCode);
Assert.Equal("B", result.Request.Second.StockName);
}
@@ -251,34 +306,33 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
const string firstName = "A' OR '1'='1";
const string secondName = "B; DELETE FROM T_STOCK";
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
StockIdentity(firstName, "111111"),
StockIdentity(secondName, "222222"));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
_ = await resolver.ResolveAsync(Entry(
"5026",
"",
"STOCK",
firstName + "," + secondName,
""));
"",
dataCode: "KOSPI:111111|KOSDAQ:222222"));
AssertStockLookupOrder(executor, firstName, secondName);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Contains("SELECT COUNT(*)", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("SELECT STOCK_NAME, STOCK_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("INSERT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("UPDATE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DELETE FROM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("stockName", parameter.Name);
Assert.Equal(DbType.String, parameter.DbType);
Assert.DoesNotContain(
Assert.IsType<string>(parameter.Value),
call.Spec.Sql,
StringComparison.Ordinal);
Assert.Equal(new[] { "stockName", "stockCode" },
call.Spec.Parameters.Select(parameter => parameter.Name));
Assert.All(call.Spec.Parameters, parameter => Assert.Equal(DbType.String, parameter.DbType));
Assert.All(call.Spec.Parameters, parameter => Assert.DoesNotContain(
Assert.IsType<string>(parameter.Value), call.Spec.Sql, StringComparison.Ordinal));
});
Assert.Contains("FROM T_STOCK ", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM T_KOSDAQ_STOCK ", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM T_STOCK", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM T_KOSDAQ_STOCK", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
}
[Theory]
@@ -355,11 +409,35 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveAsync(Entry("5026", "", "A,B", "")));
resolver.ResolveAsync(Entry(
"5026",
"STOCK",
"A,B",
"",
dataCode: "KOSPI:111111|KOSDAQ:222222")));
Assert.Single(executor.Calls);
}
[Fact]
public async Task Exact_identity_duplicate_rows_are_ambiguous_even_when_name_and_code_match()
{
var duplicate = StockIdentity("A", "111111");
duplicate.ImportRow(duplicate.Rows[0]);
var executor = new RecordingExecutor(duplicate);
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(Entry(
"5026",
"STOCK",
"A,B",
"",
dataCode: "KOSPI:111111|KOSDAQ:222222")));
Assert.Single(executor.Calls);
Assert.DoesNotContain("DISTINCT", executor.Calls[0].Spec.Sql, StringComparison.OrdinalIgnoreCase);
}
private static LegacyPlaylistEntry Entry(
string cutCode,
string groupCode,
@@ -384,6 +462,15 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
return table;
}
private static DataTable StockIdentity(string name, string code)
{
var table = new DataTable();
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
table.Rows.Add(name, code);
return table;
}
private static void AssertStockLookupOrder(
RecordingExecutor executor,
params string[] stockNames)

View File

@@ -52,10 +52,11 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
Assert.Contains("FROM t_kosdaq_online1", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(firstName, executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(secondName, executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { "stockName", "rowLimit" },
Assert.Equal(new[] { "stockName", "stockCode", "rowLimit" },
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal(new object?[] { firstName, 5 },
Assert.Equal(new object?[] { firstName, "FIRST001", 5 },
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
Assert.Contains("b.f_stock_code = s.STOCK_CODE", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second },
data.Series.Select(series => series.Quote.Series));
Assert.Equal(new[] { firstName, secondName },
@@ -92,6 +93,7 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(rowLimit, call.Spec.Parameters.Single(p => p.Name == "rowLimit").Value);
Assert.Contains(":stockName", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(":stockCode", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal);
});
Assert.Contains("t_candle_history", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
@@ -502,7 +504,15 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
new ComparisonPairSceneLoadRequest(
ComparisonGraphPeriod.FiveDays,
null!,
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second"))
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002")),
new ComparisonPairSceneLoadRequest(
ComparisonGraphPeriod.FiveDays,
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "First", "bad/code"),
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002")),
new ComparisonPairSceneLoadRequest(
ComparisonGraphPeriod.FiveDays,
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "First", null!),
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002"))
};
foreach (var request in invalidPairRequests)
{
@@ -543,8 +553,8 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
string secondName,
ComparisonEquityMarket secondMarket) => new(
period,
new ComparisonStockSelection(firstMarket, firstName),
new ComparisonStockSelection(secondMarket, secondName));
new ComparisonStockSelection(firstMarket, firstName, "FIRST001"),
new ComparisonStockSelection(secondMarket, secondName, "SECOND002"));
private static DataTable CandleTable(
string name,

View File

@@ -0,0 +1,531 @@
#nullable enable
using System.Data;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyComparisonPairImportServiceTests
{
private static readonly Encoding Cp949 = CreateCp949();
[Fact]
public void Parser_AcceptsTheExactUc3NineFieldCp949Shape()
{
var rows = LegacyComparisonFileParser.Parse(Encode(
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
"2^코스닥 지수,코스피 지수^^^^^^^\r\n" +
"3^엔비디아,삼성전자^^코스피^^^^^\r\n"));
Assert.Collection(
rows,
row =>
{
Assert.Equal(1, row.RowNumber);
Assert.Equal(new LegacyComparisonFileTarget("삼성전자", "코스피"), row.First);
Assert.Equal(
new LegacyComparisonFileTarget("삼성전자(NXT)", "코스피_NXT"),
row.Second);
},
row =>
{
Assert.Equal(2, row.RowNumber);
Assert.Equal(string.Empty, row.First.Market);
Assert.Equal(string.Empty, row.Second.Market);
},
row =>
{
Assert.Equal(3, row.RowNumber);
Assert.Equal("엔비디아", row.First.Name);
Assert.Equal("코스피", row.Second.Market);
});
}
[Fact]
public void Parser_AcceptsTheAuditedEightRowOriginalFixture()
{
var bytes = Encode(
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
"2^기아(NXT),기아^코스피_NXT^코스피^^^^^\r\n" +
"3^기아(NXT),삼성전자(NXT)^코스피_NXT^코스피_NXT^^^^^\r\n" +
"4^SK하이닉스,삼성전자^코스피^코스피^^^^^\r\n" +
"5^에이비엘바이오(NXT),에이비엘바이오^코스닥_NXT^코스닥^^^^^\r\n" +
"6^코스닥 지수,코스피 지수^^^^^^^\r\n" +
"7^엔비디아,삼성전자^^코스피^^^^^\r\n" +
"8^삼성전자(NXT),엔비디아^코스피_NXT^^^^^^\r\n");
Assert.Equal(
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2",
Convert.ToHexString(SHA256.HashData(bytes)));
var rows = LegacyComparisonFileParser.Parse(bytes);
Assert.Equal(8, rows.Count);
Assert.Equal("엔비디아", rows[7].Second.Name);
Assert.Equal(string.Empty, rows[7].Second.Market);
}
[Theory]
[InlineData("1^삼성전자,기아^코스피^코스피^^^^\r\n")]
[InlineData("01^삼성전자,기아^코스피^코스피^^^^^\r\n")]
[InlineData("2^삼성전자,기아^코스피^코스피^^^^^\r\n")]
[InlineData("1^삼성전자^코스피^코스피^^^^^\r\n")]
[InlineData("1^삼성전자,기아^코스피^코스피^unexpected^^^^\r\n")]
[InlineData("1^삼성전자(NXT),기아^코스피^코스피^^^^^\r\n")]
[InlineData("1^삼성전자,기아^코스피_NXT^코스피^^^^^\r\n")]
[InlineData("1^ 삼성전자,기아^코스피^코스피^^^^^\r\n")]
public void Parser_RejectsMalformedRows(string text)
{
var exception = Assert.Throws<LegacyComparisonImportException>(
() => LegacyComparisonFileParser.Parse(Encode(text)));
Assert.Equal(LegacyComparisonImportFailure.SourceInvalid, exception.Failure);
}
[Fact]
public void Parser_RejectsDuplicatePairsAndInvalidCp949()
{
var duplicate = Encode(
"1^삼성전자,기아^코스피^코스피^^^^^\r\n" +
"2^삼성전자,기아^코스피^코스피^^^^^\r\n");
Assert.Equal(
LegacyComparisonImportFailure.SourceInvalid,
Assert.Throws<LegacyComparisonImportException>(
() => LegacyComparisonFileParser.Parse(duplicate)).Failure);
Assert.Equal(
LegacyComparisonImportFailure.SourceInvalid,
Assert.Throws<LegacyComparisonImportException>(
() => LegacyComparisonFileParser.Parse([0x81])).Failure);
}
[Fact]
public async Task TrustedSource_UsesOnlyTheFixedReadOnlyPathAndReturnsItsDigest()
{
using var directory = new TemporaryDirectory();
var source = new TrustedLegacyComparisonFileSource(directory.Path);
var expected = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
await File.WriteAllBytesAsync(source.SourcePath, expected);
var result = await source.ReadAsync();
Assert.Equal(expected, result.Bytes);
Assert.Equal(Convert.ToHexString(SHA256.HashData(expected)), result.Sha256);
Assert.Equal(
Path.GetFullPath(Path.Combine(directory.Path, TrustedLegacyComparisonFileSource.FixedRelativePath)),
source.SourcePath);
}
[Fact]
public async Task TrustedSource_RejectsMissingAndOversizedFiles()
{
using var directory = new TemporaryDirectory();
var source = new TrustedLegacyComparisonFileSource(directory.Path);
var missing = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => source.ReadAsync());
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, missing.Failure);
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
await File.WriteAllBytesAsync(
source.SourcePath,
new byte[TrustedLegacyComparisonFileSource.MaximumFileBytes + 1]);
var oversized = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => source.ReadAsync());
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, oversized.Failure);
}
[Fact]
public async Task TrustedSource_RejectsAHardLinkedFileEvenWhenItsBytesAreValid()
{
if (!OperatingSystem.IsWindows())
{
return;
}
using var directory = new TemporaryDirectory();
var source = new TrustedLegacyComparisonFileSource(directory.Path);
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
var outside = Path.Combine(directory.Path, "outside-valid-comparison.dat");
await File.WriteAllBytesAsync(
outside,
Encode("1^코스피 지수,코스닥 지수^^^^^^\r\n"));
Assert.True(CreateHardLinkW(source.SourcePath, outside, IntPtr.Zero));
var error = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => source.ReadAsync());
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, error.Failure);
}
[Fact]
public async Task ImportAsync_ResolvesEveryTargetKindWithBoundExactReadsAndCachesIdentity()
{
var bytes = Encode(
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
"2^코스닥 지수,삼성전자^^코스피^^^^^\r\n" +
"3^엔비디아,삼성전자^^코스피^^^^^\r\n");
var source = new MemorySource(bytes);
var executor = new RecordingExecutor(call => call.TableName switch
{
"LEGACY_COMPARISON_IMPORT_KOSPI" => StockTable("삼성전자", "005930"),
"LEGACY_COMPARISON_IMPORT_NXT_KOSPI" => StockTable("삼성전자", "005930"),
"LEGACY_COMPARISON_IMPORT_WORLD_STOCK" =>
string.Equals(
call.Spec.Parameters[0].Value as string,
"엔비디아",
StringComparison.Ordinal)
? WorldTable("엔비디아", "NAS@NVDA", "US")
: WorldTable(),
_ => throw new InvalidOperationException("Unexpected identity query.")
});
var service = new LegacyComparisonPairImportService(executor, source);
var result = await service.ImportAsync();
Assert.Equal(3, result.SourceRowCount);
Assert.Equal(source.Digest, result.SourceSha256);
Assert.Collection(
result.Pairs,
row =>
{
Assert.Equal(LegacyComparisonImportTargetKind.KrxStock, row.First.Kind);
Assert.Equal("kospi", row.First.Market);
Assert.Equal("005930", row.First.StockCode);
Assert.Equal(LegacyComparisonImportTargetKind.NxtStock, row.Second.Kind);
Assert.Equal("삼성전자", row.Second.StockName);
},
row =>
{
Assert.Equal("Kosdaq", row.First.MarketTarget);
Assert.Equal(LegacyComparisonImportTargetKind.MarketTarget, row.First.Kind);
Assert.Equal("005930", row.Second.StockCode);
},
row =>
{
Assert.Equal(LegacyComparisonImportTargetKind.WorldStock, row.First.Kind);
Assert.Equal("NAS@NVDA", row.First.Symbol);
Assert.Equal("US", row.First.Nation);
Assert.Equal("005930", row.Second.StockCode);
});
Assert.Equal(4, executor.Calls.Count);
Assert.Equal(0, executor.StringSqlCallCount);
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.Single(call.Spec.Parameters);
Assert.DoesNotContain(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
call.Spec.Sql,
StringComparison.Ordinal);
});
Assert.Equal("삼성전자", executor.Calls[0].Spec.Parameters[0].Value);
Assert.Equal("삼성전자", executor.Calls[1].Spec.Parameters[0].Value);
Assert.Equal("코스닥 지수", executor.Calls[2].Spec.Parameters[0].Value);
Assert.Equal("엔비디아", executor.Calls[3].Spec.Parameters[0].Value);
}
[Fact]
public async Task ImportAsync_MapsAllTwentyFourOriginalFixedTargetsExactly()
{
(string Name, string Target)[] expected =
[
("코스피 지수", "Kospi"),
("코스닥 지수", "Kosdaq"),
("코스피200 지수", "Kospi200"),
("선물 지수", "Futures"),
("KRX100지수", "Krx100"),
("환율-원달러", "WonDollar"),
("환율-원엔", "WonYen"),
("환율-원위엔", "WonYuan"),
("환율-원유로", "WonEuro"),
("해외지수-다우", "Dow"),
("해외지수-나스닥", "Nasdaq"),
("해외지수-S&P", "Sp500"),
("해외지수-독일", "GermanyDax"),
("해외지수-영국", "UnitedKingdomFtse"),
("해외지수-프랑스", "FranceCac"),
("해외지수-일본", "Nikkei"),
("해외지수-홍콩", "HangSeng"),
("해외지수-대만", "TaiwanWeighted"),
("해외지수-싱가포르", "SingaporeStraitsTimes"),
("해외지수-태국", "ThailandSet"),
("해외지수-필리핀", "PhilippinesComposite"),
("해외지수-말레이시아", "MalaysiaKlse"),
("해외지수-인도네시아", "IndonesiaComposite"),
("해외지수-중국", "ShanghaiComposite")
];
var text = new StringBuilder();
for (var index = 0; index < expected.Length; index += 2)
{
text.Append(index / 2 + 1)
.Append('^')
.Append(expected[index].Name)
.Append(',')
.Append(expected[index + 1].Name)
.Append("^^^^^^^\r\n");
}
var executor = new RecordingExecutor(call =>
{
Assert.Equal("LEGACY_COMPARISON_IMPORT_WORLD_STOCK", call.TableName);
return WorldTable();
});
var service = new LegacyComparisonPairImportService(
executor,
new MemorySource(Encode(text.ToString())));
var result = await service.ImportAsync();
var actual = result.Pairs
.SelectMany(row => new[] { row.First, row.Second })
.Select(target => target.MarketTarget)
.ToArray();
Assert.Equal(expected.Select(value => value.Target), actual);
Assert.Equal(24, executor.Calls.Count);
Assert.Equal(
expected.Select(value => value.Name),
executor.Calls.Select(call => Assert.IsType<string>(call.Spec.Parameters[0].Value)));
}
[Fact]
public async Task ImportAsync_FailsClosedForMissingAmbiguousAndMalformedMasterRows()
{
var bytes = Encode("1^삼성전자,기아^코스피^코스피^^^^^\r\n");
await AssertFailureAsync(
bytes,
_ => StockTable(),
LegacyComparisonImportFailure.IdentityNotFound);
await AssertFailureAsync(
bytes,
_ => StockTable(("삼성전자", "005930"), ("삼성전자", "999999")),
LegacyComparisonImportFailure.IdentityAmbiguous);
var wrongSchema = new DataTable();
wrongSchema.Columns.Add("stock_name", typeof(string));
wrongSchema.Columns.Add("STOCK_CODE", typeof(string));
await AssertFailureAsync(
bytes,
_ => wrongSchema,
LegacyComparisonImportFailure.DatabaseDataInvalid);
}
[Fact]
public async Task ImportAsync_RejectsAWorldNameThatCollidesWithAFixedMarketLabel()
{
var bytes = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
var service = new LegacyComparisonPairImportService(
new RecordingExecutor(call =>
WorldTable(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
"COLLISION",
"US")),
new MemorySource(bytes));
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => service.ImportAsync());
Assert.Equal(LegacyComparisonImportFailure.IdentityAmbiguous, exception.Failure);
}
[Fact]
public async Task ImportAsync_RejectsAChangedDigestBeforeDatabaseAccess()
{
var bytes = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var source = new CorruptDigestSource(bytes);
var service = new LegacyComparisonPairImportService(executor, source);
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => service.ImportAsync());
Assert.Equal(LegacyComparisonImportFailure.SourceInvalid, exception.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ImportAsync_HonorsPreCanceledRequestsWithoutReadingOrQuerying()
{
var source = new CountingSource();
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyComparisonPairImportService(executor, source);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.ImportAsync(cancellation.Token));
Assert.Equal(0, source.ReadCount);
Assert.Empty(executor.Calls);
}
private static async Task AssertFailureAsync(
byte[] bytes,
Func<RecordedCall, DataTable> handler,
LegacyComparisonImportFailure expected)
{
var service = new LegacyComparisonPairImportService(
new RecordingExecutor(handler),
new MemorySource(bytes));
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => service.ImportAsync());
Assert.Equal(expected, exception.Failure);
}
private static DataTable StockTable(params (string Name, string Code)[] rows)
{
var table = new DataTable();
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Name, row.Code);
}
return table;
}
private static DataTable StockTable(string name, string code) =>
StockTable((name, code));
private static DataTable WorldTable(
params (string InputName, string Symbol, string Nation)[] 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));
foreach (var row in rows)
{
table.Rows.Add(row.InputName, row.Symbol, row.Nation);
}
return table;
}
private static DataTable WorldTable(string inputName, string symbol, string nation) =>
WorldTable((inputName, symbol, nation));
private static byte[] Encode(string value) => Cp949.GetBytes(value);
private static Encoding CreateCp949()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private sealed class MemorySource(byte[] bytes) : ILegacyComparisonFileSource
{
public string Digest { get; } = Convert.ToHexString(SHA256.HashData(bytes));
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new LegacyComparisonSourceSnapshot(bytes, Digest));
}
}
private sealed class CorruptDigestSource(byte[] bytes) : ILegacyComparisonFileSource
{
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyComparisonSourceSnapshot(bytes, new string('0', 64)));
}
private sealed class CountingSource : ILegacyComparisonFileSource
{
public int ReadCount { get; private set; }
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default)
{
ReadCount++;
throw new InvalidOperationException();
}
}
private sealed record RecordedCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
: 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("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call).Copy());
}
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateHardLinkW(
string fileName,
string existingFileName,
IntPtr securityAttributes);
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"mbn-comparison-import-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch
{
// Best-effort test cleanup only.
}
}
}
}

View File

@@ -0,0 +1,135 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyCutRequirementManifestTests
{
private static readonly StringComparer PathComparer = StringComparer.OrdinalIgnoreCase;
[Fact]
public void Critical_builder_assets_match_the_closed_preflight_manifest()
{
var manifest = ReadManifest();
var actual = new HashSet<string>(PathComparer);
var directions = new[]
{
ScenePriceDirection.Up,
ScenePriceDirection.Flat,
ScenePriceDirection.Down
};
foreach (var direction in directions)
{
AddAssets(actual, new S5006SceneMutationBuilder().Build(new S5006SceneData(
LegacyDomesticEquityMarket.Kospi,
"fixture",
direction,
1,
1,
1m,
1,
1m,
1,
1m,
1,
1m)));
foreach (var target in Enum.GetValues<S6001QuoteTarget>())
{
AddAssets(actual, new S6001SceneMutationBuilder().Build(new S6001SceneData(
target,
"fixture",
1m,
1m,
1m,
direction)));
}
}
var expected = manifest.Assets
.Where(asset => asset.Builder is "s5006" or "s6001" or "shared-price-direction")
.Select(asset => asset.Path)
.ToHashSet(PathComparer);
Assert.Equal(expected.Count, actual.Count);
Assert.Empty(expected.Except(actual, PathComparer));
Assert.Empty(actual.Except(expected, PathComparer));
}
[Fact]
public void Manifest_is_relative_closed_and_includes_all_s6001_videos()
{
var manifest = ReadManifest();
Assert.Equal(1, manifest.SchemaVersion);
Assert.Equal(34, manifest.ReachableBuilders);
Assert.Equal(45, manifest.ActiveAliases.Count);
Assert.Equal(23, manifest.Assets.Count);
Assert.Equal(
13,
manifest.Assets
.Where(asset => asset.Builder == "s6001" && asset.Kind == "Video")
.Select(asset => asset.Path)
.Distinct(PathComparer)
.Count());
Assert.Contains(
manifest.Assets,
asset => asset.Builder == "s5006" &&
PathComparer.Equals(asset.Path, @"Video\큐브배경.vrv"));
Assert.All(manifest.Assets, asset =>
{
Assert.Contains(asset.Kind, new[] { "Image", "Texture", "Video" });
Assert.False(Path.IsPathRooted(asset.Path));
Assert.DoesNotContain(
"..",
asset.Path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
});
}
private static void AddAssets(
ISet<string> paths,
IEnumerable<PlayoutMutation> mutations)
{
foreach (var mutation in mutations)
{
switch (mutation)
{
case PlayoutSetAssetValue asset:
paths.Add(asset.AssetPath);
break;
case PlayoutSetBackgroundTexture texture:
paths.Add(texture.AssetPath);
break;
case PlayoutSetBackgroundVideo video:
paths.Add(video.AssetPath);
break;
}
}
}
private static LegacyCutRequirementManifest ReadManifest()
{
var path = Path.Combine(
AppContext.BaseDirectory,
"Fixtures",
"LegacyCutRequirements.json");
var manifest = JsonSerializer.Deserialize<LegacyCutRequirementManifest>(
File.ReadAllText(path),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Assert.IsType<LegacyCutRequirementManifest>(manifest);
}
private sealed record LegacyCutRequirementManifest(
int SchemaVersion,
int ReachableBuilders,
IReadOnlyList<string> ActiveAliases,
IReadOnlyList<LegacyBuilderAssetRequirement> Assets);
private sealed record LegacyBuilderAssetRequirement(
string Builder,
string Kind,
string Path);
}

View File

@@ -0,0 +1,458 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyForeignIndexCandleRestoreServiceTests
{
public static TheoryData<string, string, string, string, int, string> SupportedProfiles =>
new()
{
{ "다우존스", "5일", "Dow", "DJI@DJI", 5, "8061" },
{ "다우존스", "20일", "Dow", "DJI@DJI", 20, "8040" },
{ "다우존스", "60일", "Dow", "DJI@DJI", 60, "8046" },
{ "다우존스", "120일", "Dow", "DJI@DJI", 120, "8051" },
{ "다우존스", "240일", "Dow", "DJI@DJI", 240, "8056" },
{ "나스닥", "5일", "Nasdaq", "NAS@IXIC", 5, "8061" },
{ "나스닥", "20일", "Nasdaq", "NAS@IXIC", 20, "8040" },
{ "나스닥", "60일", "Nasdaq", "NAS@IXIC", 60, "8046" },
{ "나스닥", "120일", "Nasdaq", "NAS@IXIC", 120, "8051" },
{ "나스닥", "240일", "Nasdaq", "NAS@IXIC", 240, "8056" },
{ "S&P500", "5일", "Sp500", "SPI@SPX", 5, "8061" },
{ "S&P500", "20일", "Sp500", "SPI@SPX", 20, "8040" },
{ "S&P500", "60일", "Sp500", "SPI@SPX", 60, "8046" },
{ "S&P500", "120일", "Sp500", "SPI@SPX", 120, "8051" },
{ "S&P500", "240일", "Sp500", "SPI@SPX", 240, "8056" }
};
[Theory]
[MemberData(nameof(SupportedProfiles))]
public async Task ResolveAsync_VerifiesEveryClosedTargetAndPeriodExactly(
string subject,
string period,
string targetKey,
string symbol,
int periodDays,
string cutCode)
{
var executor = new RecordingExecutor(call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(LegacyForeignIndexCandleRestoreService.QueryName, call.QueryName);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_SYMB = :symbol", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_NATC = 'US'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_FDTC = '0'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(subject, call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(symbol, call.Spec.Sql, StringComparison.Ordinal);
Assert.Collection(
call.Spec.Parameters,
parameter =>
{
Assert.Equal("input_name", parameter.Name);
Assert.Equal(subject, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
},
parameter =>
{
Assert.Equal("symbol", parameter.Name);
Assert.Equal(symbol, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
});
return IdentityTable((subject, symbol, "US", "0"));
});
var service = new LegacyForeignIndexCandleRestoreService(executor);
var row = Row(17, subject, period);
Assert.True(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
var result = await service.ResolveAsync([row]);
var outcome = Assert.Single(result.Rows);
Assert.Equal(17, outcome.ItemIndex);
Assert.True(outcome.IsResolved);
Assert.Null(outcome.Failure);
Assert.Equal(
new LegacyForeignIndexCandleRestoreIdentity(
targetKey,
subject,
symbol,
"US",
"0",
"s8010",
"PRICE",
periodDays,
cutCode),
outcome.Identity);
Assert.Single(executor.Calls);
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task ResolveAsync_PreservesBatchOrderAndCachesEachTargetIdentity()
{
var executor = new RecordingExecutor(call =>
{
var inputName = Assert.IsType<string>(call.Spec.Parameters[0].Value);
var symbol = Assert.IsType<string>(call.Spec.Parameters[1].Value);
return IdentityTable((inputName, symbol, "US", "0"));
});
var service = new LegacyForeignIndexCandleRestoreService(executor);
var rows = new[]
{
Row(900, "S&P500", "240일"),
Row(2, "다우존스", "20일"),
Row(701, "나스닥", "5일"),
Row(3, "다우존스", "5일")
};
var result = await service.ResolveAsync(rows);
Assert.Equal([900, 2, 701, 3], result.Rows.Select(row => row.ItemIndex));
Assert.Equal(
["Sp500", "Dow", "Nasdaq", "Dow"],
result.Rows.Select(row => row.Identity!.TargetKey));
Assert.Equal([240, 20, 5, 5], result.Rows.Select(row => row.Identity!.PeriodDays));
Assert.Equal(3, executor.Calls.Count);
Assert.Equal(
["S&P500", "다우존스", "나스닥"],
executor.Calls.Select(call => call.Spec.Parameters[0].Value));
}
[Fact]
public async Task ResolveAsync_ReturnsNotFoundAmbiguousAndInvalidPerRow()
{
var executor = new RecordingExecutor(call =>
{
var inputName = Assert.IsType<string>(call.Spec.Parameters[0].Value);
var symbol = Assert.IsType<string>(call.Spec.Parameters[1].Value);
return inputName switch
{
"다우존스" => IdentityTable(),
"나스닥" => IdentityTable(
(inputName, symbol, "US", "0"),
(inputName, symbol, "US", "0")),
"S&P500" => WrongSchemaTable(),
_ => throw new InvalidOperationException(inputName)
};
});
var service = new LegacyForeignIndexCandleRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(10, "다우존스", "5일"),
Row(11, "나스닥", "20일"),
Row(12, "S&P500", "60일")
]);
Assert.Equal(
new LegacyForeignIndexCandleRestoreFailure?[]
{
LegacyForeignIndexCandleRestoreFailure.IdentityNotFound,
LegacyForeignIndexCandleRestoreFailure.IdentityAmbiguous,
LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid
},
result.Rows.Select(row => row.Failure));
Assert.All(result.Rows, row =>
{
Assert.False(row.IsResolved);
Assert.Null(row.Identity);
});
}
[Theory]
[InlineData("다우", "5일")]
[InlineData("DOW", "5일")]
[InlineData(" 다우존스", "5일")]
[InlineData("다우존스", "5")]
[InlineData("다우존스", "240")]
[InlineData("다우존스", " 240일")]
public async Task ResolveAsync_RejectsNonExactRawRowsWithoutDatabaseAccess(
string subject,
string period)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyForeignIndexCandleRestoreService(executor);
var row = Row(0, subject, period);
Assert.False(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
Assert.Equal(LegacyForeignIndexCandleRestoreFailure.InvalidRow, outcome.Failure);
Assert.Null(outcome.Identity);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("FOREIGN_INDEX", "캔들그래프", "1/1", "")]
[InlineData(" 해외지수", "캔들그래프", "1/1", "")]
[InlineData("해외지수", "캔들 그래프", "1/1", "")]
[InlineData("해외지수", "CANDLE", "1/1", "")]
[InlineData("해외지수", "캔들그래프", "01/01", "")]
[InlineData("해외지수", "캔들그래프", "1/2", "")]
[InlineData("해외지수", "캔들그래프", "1/1", "DJI@DJI")]
public async Task ResolveAsync_RevalidatesFullRawPlaylistBoundary(
string groupCode,
string graphicType,
string pageText,
string dataCode)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyForeignIndexCandleRestoreService(executor);
var row = Row(
groupCode: groupCode,
graphicType: graphicType,
pageText: pageText,
dataCode: dataCode);
Assert.False(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
Assert.Equal(LegacyForeignIndexCandleRestoreFailure.InvalidRow, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_AllowsDisabledRowSoCallerCanPreserveEnabledState()
{
var executor = new RecordingExecutor(call => IdentityTable((
Assert.IsType<string>(call.Spec.Parameters[0].Value),
Assert.IsType<string>(call.Spec.Parameters[1].Value),
"US",
"0")));
var service = new LegacyForeignIndexCandleRestoreService(executor);
var row = Row(isEnabled: false);
Assert.True(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
Assert.True(outcome.IsResolved);
Assert.Equal("Dow", outcome.Identity!.TargetKey);
Assert.Single(executor.Calls);
}
[Fact]
public async Task ResolveAsync_RejectsMalformedBatchSchemaAndDuplicateIndexes()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyForeignIndexCandleRestoreService(executor);
var tooMany = Enumerable.Range(
0,
LegacyForeignIndexCandleRestoreService.MaximumRows + 1)
.Select(index => Row(index))
.ToArray();
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync(tooMany));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[
Row(4, "다우존스", "5일"),
Row(4, "나스닥", "20일")
]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[
null!
]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[
Row(-1, "다우존스", "5일")
]));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_PropagatesDatabaseFailureWithoutRetry()
{
var executor = new RecordingExecutor(_ =>
throw new DataException("simulated read failure"));
var service = new LegacyForeignIndexCandleRestoreService(executor);
await Assert.ThrowsAsync<DataException>(() => service.ResolveAsync(
[Row(0, "다우존스", "5일")]));
Assert.Single(executor.Calls);
}
[Theory]
[InlineData("extra-column")]
[InlineData("wrong-case")]
[InlineData("wrong-type")]
[InlineData("unexpected-value")]
[InlineData("too-many")]
public async Task ResolveAsync_FailsClosedOnExpandedOrTamperedDatabaseData(string mode)
{
var executor = new RecordingExecutor(_ => mode switch
{
"extra-column" => TableWithExtraColumn(),
"wrong-case" => WrongSchemaTable(),
"wrong-type" => WrongTypeTable(),
"unexpected-value" => IdentityTable(("다우존스", "WRONG", "US", "0")),
"too-many" => IdentityTable(
("다우존스", "DJI@DJI", "US", "0"),
("다우존스", "DJI@DJI", "US", "0"),
("다우존스", "DJI@DJI", "US", "0")),
_ => throw new InvalidOperationException(mode)
});
var service = new LegacyForeignIndexCandleRestoreService(executor);
var outcome = Assert.Single((await service.ResolveAsync(
[Row(0, "다우존스", "5일")])).Rows);
Assert.Equal(
LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid,
outcome.Failure);
Assert.False(outcome.IsResolved);
}
[Fact]
public async Task ResolveAsync_HonorsPreCancellationWithoutQueryOrRetry()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyForeignIndexCandleRestoreService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ResolveAsync(
[Row(0, "다우존스", "5일")],
cancellation.Token));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_StopsAfterCancellationObservedByFirstQuery()
{
using var cancellation = new CancellationTokenSource();
var executor = new RecordingExecutor(call =>
{
cancellation.Cancel();
return IdentityTable((
Assert.IsType<string>(call.Spec.Parameters[0].Value),
Assert.IsType<string>(call.Spec.Parameters[1].Value),
"US",
"0"));
}, throwBeforeHandlerWhenCanceled: false);
var service = new LegacyForeignIndexCandleRestoreService(executor);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ResolveAsync(
[
Row(0, "다우존스", "5일"),
Row(1, "나스닥", "5일")
], cancellation.Token));
Assert.Single(executor.Calls);
}
private static DataTable IdentityTable(
params (string InputName, string Symbol, string Nation, string ForeignDomestic)[] 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.ForeignDomestic);
}
return table;
}
private static LegacyForeignIndexCandleRestoreRow Row(
int itemIndex = 0,
string subject = "다우존스",
string subtype = "5일",
bool isEnabled = true,
string groupCode = "해외지수",
string graphicType = "캔들그래프",
string pageText = "1/1",
string dataCode = "") =>
new(
itemIndex,
isEnabled,
groupCode,
subject,
graphicType,
subtype,
pageText,
dataCode);
private static DataTable WrongSchemaTable()
{
var table = IdentityTable();
table.Columns[0].ColumnName = "f_input_name";
return table;
}
private static DataTable WrongTypeTable()
{
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(int));
return table;
}
private static DataTable TableWithExtraColumn()
{
var table = IdentityTable(("다우존스", "DJI@DJI", "US", "0"));
table.Columns.Add("UNEXPECTED", typeof(string));
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
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,102 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyManualOperatorDataCodecTests
{
[Fact]
public void NetSell_requires_five_rows_and_the_original_empty_terminal_record()
{
var expected = Rows();
var canonical = LegacyManualOperatorDataCodec.SerializeNetSell(expected);
Assert.Equal(expected, LegacyManualOperatorDataCodec.ParseNetSell(canonical));
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.ParseNetSell(canonical.AsSpan(0, canonical.Length - 2)));
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeNetSell(expected.Take(4).ToArray()));
}
[Fact]
public void NetSell_rejects_unicode_bom_delimiters_and_non_cp949_text()
{
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.ParseNetSell(
Encoding.UTF8.GetBytes("a^b^c^d\r\n")));
var delimiter = Rows().ToArray();
delimiter[0] = delimiter[0] with { LeftName = "bad^name" };
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeNetSell(delimiter));
var emoji = Rows().ToArray();
emoji[0] = emoji[0] with { LeftName = "😀" };
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeNetSell(emoji));
}
[Fact]
public void Vi_preserves_original_order_and_duplicates_in_nine_columns()
{
LegacyManualViItem[] expected =
[
new("PKR7005930003", "삼성전자"),
new("PKR7016360000", "삼성증권"),
new("PKR7005930003", "삼성전자")
];
var bytes = LegacyManualOperatorDataCodec.SerializeVi(expected);
var actual = LegacyManualOperatorDataCodec.ParseVi(bytes);
Assert.Equal(expected, actual);
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Assert.All(
Encoding.GetEncoding(949).GetString(bytes)
.Split("\r\n", StringSplitOptions.RemoveEmptyEntries),
row => Assert.Equal(9, row.Split('^').Length));
}
[Fact]
public void Vi_enforces_twenty_pages_and_the_4000_byte_serialized_bound()
{
var tooMany = Enumerable.Range(1, 101)
.Select(index => new LegacyManualViItem(index.ToString("D6"), $"종목{index}"))
.ToArray();
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeVi(tooMany));
var tooLarge = Enumerable.Range(1, 100)
.Select(index => new LegacyManualViItem(
$"CODE{index:D3}" + new string('X', 30),
$"종목{index:D3}" + new string('가', 30)))
.ToArray();
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeVi(tooLarge));
}
[Fact]
public void Vi_accepts_an_existing_empty_file_but_rejects_nonempty_extra_columns()
{
Assert.Empty(LegacyManualOperatorDataCodec.ParseVi(Array.Empty<byte>()));
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.ParseVi(
Cp949("005930^삼성전자^unexpected^^^^^^\r\n")));
}
private static IReadOnlyList<S5025TrustedManualRow> Rows() =>
[
new("삼성전자", "1,000", "SK하이닉스", "-2,000"),
new("현대차", "2", "기아", "-2"),
new("NAVER", "3", "카카오", "-3"),
new("LG화학", "4", "포스코", "-4"),
new("한화", "5", "셀트리온", "-5")
];
private static byte[] Cp949(string value)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(949).GetBytes(value);
}
}

View File

@@ -0,0 +1,398 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNamedComparisonRestoreServiceTests
{
[Fact]
public async Task ResolveAsync_RestoresFixedPairsWithoutGuessingOrDatabaseAccess()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판"),
Row(
itemIndex: 1,
groupCode: "지수",
subject: "코스피200 지수,KRX100지수",
graphicType: "2열판",
subtype: "예상체결")
]);
Assert.Collection(
result.Rows,
row =>
{
Assert.True(row.IsResolved);
Assert.Equal("two-column-current", row.ActionId);
Assert.Equal("Kospi", row.First!.MarketTarget);
Assert.Equal("Kosdaq", row.Second!.MarketTarget);
},
row =>
{
Assert.True(row.IsResolved);
Assert.Equal("two-column-expected", row.ActionId);
Assert.Equal("Kospi200", row.First!.MarketTarget);
Assert.Equal("Krx100", row.Second!.MarketTarget);
});
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("삼성전자,SK하이닉스")]
[InlineData("삼성전자(NXT),기아(NXT)")]
[InlineData("엔비디아,애플")]
public async Task ResolveAsync_RejectsTwoColumnStocksBecauseLegacyRowsLostEachMarket(
string subject)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(groupCode: "종목", subject: subject, graphicType: "2열판")
]);
var outcome = Assert.Single(result.Rows);
Assert.False(outcome.IsResolved);
Assert.Equal(
LegacyNamedComparisonRestoreFailure.MissingMarketIdentity,
outcome.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_ReportsUnsupportedFixedExpectedPairAsOneFailedRow()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "지수",
subject: "해외지수-다우,해외지수-나스닥",
graphicType: "2열판",
subtype: "예상체결")
]);
Assert.Equal(
LegacyNamedComparisonRestoreFailure.UnsupportedAction,
Assert.Single(result.Rows).Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_BlocksAllSixteenStaleMarketLostRowsWithoutSearchingByName()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var rows = Enumerable.Range(0, 16)
.Select(index => Row(
itemIndex: index,
groupCode: "종목",
subject: $"과거종목{index * 2},과거종목{index * 2 + 1}",
graphicType: "2열판"))
.ToArray();
var result = await service.ResolveAsync(rows);
Assert.Equal(16, result.Rows.Count);
Assert.All(result.Rows, outcome => Assert.Equal(
LegacyNamedComparisonRestoreFailure.MissingMarketIdentity,
outcome.Failure));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("종목별 비교분석_캔들 그래프", "", "comparison-candle")]
[InlineData("종목별 비교분석_라인 그래프", "", "comparison-line")]
[InlineData("종목별 수익률 비교", "5일", "return-5d")]
[InlineData("종목별 수익률 비교", "1개월", "return-1m")]
[InlineData("종목별 수익률 비교", "3개월", "return-3m")]
[InlineData("종목별 수익률 비교", "6개월", "return-6m")]
[InlineData("종목별 수익률 비교", "12개월", "return-12m")]
public async Task ResolveAsync_RestoresEveryGraphSubtypeWithExactPerTargetMarkets(
string graphicType,
string subtype,
string expectedAction)
{
var executor = new RecordingExecutor(call =>
{
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
return name switch
{
"삼성전자" => StockTable("삼성전자", "005930"),
"에이비엘바이오" => StockTable("에이비엘바이오", "298380"),
_ => throw new InvalidOperationException()
};
});
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "코스피,코스닥",
subject: "삼성전자,에이비엘바이오",
graphicType: graphicType,
subtype: subtype)
]);
var outcome = Assert.Single(result.Rows);
Assert.True(outcome.IsResolved);
Assert.Equal(expectedAction, outcome.ActionId);
Assert.Equal("kospi", outcome.First!.Market);
Assert.Equal("005930", outcome.First.StockCode);
Assert.Equal("kosdaq", outcome.Second!.Market);
Assert.Equal("298380", outcome.Second.StockCode);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.DoesNotContain(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
call.Spec.Sql,
StringComparison.Ordinal);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task ResolveAsync_CachesRepeatedIdentityAcrossRows()
{
var executor = new RecordingExecutor(call =>
StockTable(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
call.TableName.EndsWith("KOSPI", StringComparison.Ordinal)
? "005930"
: "298380"));
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "코스피,코스닥",
subject: "삼성전자,에이비엘바이오",
graphicType: "종목별 비교분석_캔들 그래프"),
Row(
itemIndex: 1,
groupCode: "코스피,코스닥",
subject: "삼성전자,에이비엘바이오",
graphicType: "종목별 수익률 비교",
subtype: "5일")
]);
Assert.All(result.Rows, row => Assert.True(row.IsResolved));
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ResolveAsync_FailsEachMissingAmbiguousAndMalformedIdentityClosed()
{
var executor = new RecordingExecutor(call =>
{
var name = Assert.IsType<string>(call.Spec.Parameters[0].Value);
return name switch
{
"없음" => StockTable(),
"중복" => StockTable(("중복", "111111"), ("중복", "222222")),
"오염" => WrongSchemaTable(),
"정상" => StockTable("정상", "333333"),
_ => throw new InvalidOperationException()
};
});
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "코스피,코스닥",
subject: "없음,정상",
graphicType: "종목별 비교분석_캔들 그래프"),
Row(
itemIndex: 1,
groupCode: "코스피,코스닥",
subject: "중복,정상",
graphicType: "종목별 비교분석_라인 그래프"),
Row(
itemIndex: 2,
groupCode: "코스피,코스닥",
subject: "오염,정상",
graphicType: "종목별 수익률 비교",
subtype: "1개월")
]);
Assert.Equal(
new LegacyNamedComparisonRestoreFailure?[]
{
LegacyNamedComparisonRestoreFailure.IdentityNotFound,
LegacyNamedComparisonRestoreFailure.IdentityAmbiguous,
LegacyNamedComparisonRestoreFailure.DatabaseDataInvalid
},
result.Rows.Select(row => row.Failure));
}
[Fact]
public async Task ResolveAsync_RejectsNxtGraphsBeforeTheyCanBecomePlayable()
{
var executor = new RecordingExecutor(call =>
StockTable(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
"005930"));
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "코스피_NXT,코스피_NXT",
subject: "삼성전자(NXT),기아(NXT)",
graphicType: "종목별 비교분석_캔들 그래프")
]);
var outcome = Assert.Single(result.Rows);
Assert.Equal(
LegacyNamedComparisonRestoreFailure.UnsupportedAction,
outcome.Failure);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("종목별 수익률 비교", "20일")]
[InlineData("종목별 비교분석_캔들 그래프", "5일")]
[InlineData("2열판", "CURRENT")]
public async Task ResolveAsync_RejectsUnknownOrCanonicalizedSubtypeGrammar(
string graphicType,
string subtype)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: graphicType == "2열판" ? "지수" : "코스피,코스피",
subject: graphicType == "2열판"
? "코스피 지수,코스닥 지수"
: "삼성전자,기아",
graphicType: graphicType,
subtype: subtype)
]);
Assert.Equal(
LegacyNamedComparisonRestoreFailure.InvalidRow,
Assert.Single(result.Rows).Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_HonorsCancellationAndRejectsDuplicateIndexes()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.ResolveAsync(
[Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판")],
cancellation.Token));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판"),
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판")
]));
Assert.Empty(executor.Calls);
}
private static LegacyNamedComparisonRestoreRow Row(
int itemIndex = 0,
string groupCode = "코스피,코스피",
string subject = "삼성전자,기아",
string graphicType = "종목별 비교분석_캔들 그래프",
string subtype = "",
string pageText = "1/1",
string dataCode = "") =>
new(
itemIndex,
true,
groupCode,
subject,
graphicType,
subtype,
pageText,
dataCode);
private static DataTable StockTable(params (string Name, string Code)[] rows)
{
var table = new DataTable();
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Name, row.Code);
}
return table;
}
private static DataTable StockTable(string name, string code) =>
StockTable((name, code));
private static DataTable WrongSchemaTable()
{
var table = new DataTable();
table.Columns.Add("stock_name", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
: 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)
{
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,401 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNamedNxtThemeRestoreServiceTests
{
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" },
{ "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_MapsOnlyOriginalCurrentPriceActionsAndRemapsStaleCode(
string graphicType,
string subtype,
string actionId,
string builderKey,
int pageSize,
string sort)
{
var executor = GoodExecutor("반도체", "87654321", previewCount: 2, liveCount: 2);
var service = new LegacyNamedNxtThemeRestoreService(
executor,
new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 12, 59, 0, TimeSpan.Zero)));
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(ThemeSession.PreMarket, outcome.Identity.Session);
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.Equal(2, outcome.Identity.LiveItemCount);
Assert.Equal(2, executor.Calls.Count);
var identityCall = executor.Calls[0];
Assert.Equal(DataSourceKind.MariaDb, identityCall.Source);
Assert.Equal(LegacyNamedNxtThemeRestoreService.IdentityQueryName, identityCall.QueryName);
identityCall.Spec.ValidateFor(DataSourceKind.MariaDb);
Assert.Contains("BINARY s.SB_TITLE = BINARY @theme_title", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("BINARY d.SB_CODE = BINARY s.SB_CODE", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_CURR_PRICE != 0", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LIMIT 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("THEME_PREVIEW_NXT", 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);
}
[Theory]
[InlineData(0, ThemeSession.PreMarket)]
[InlineData(12, ThemeSession.PreMarket)]
[InlineData(13, ThemeSession.AfterMarket)]
[InlineData(23, ThemeSession.AfterMarket)]
public void SessionForLocalHour_PreservesOriginalBoundary(
int hour,
ThemeSession expected) =>
Assert.Equal(expected, LegacyNamedNxtThemeRestoreService.SessionForLocalHour(hour));
[Fact]
public async Task ResolveAsync_CachesExactTitleButPreservesBatchOrderAndStoredCodes()
{
var executor = GoodExecutor("AI", "90000001", previewCount: 1, liveCount: 1);
var service = new LegacyNamedNxtThemeRestoreService(executor);
var rows = new[]
{
Row(itemIndex: 7, subject: "AI(NXT)", dataCode: "111"),
Row(itemIndex: 2, subject: "AI(NXT)", 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);
}
[Theory]
[InlineData("테마", "반도체(NXT)", "5단 표그래프", "테마-현재가", "1/1", "0123")]
[InlineData("테마_NXT", "반도체", "5단 표그래프", "테마-현재가", "1/1", "0123")]
[InlineData("테마_NXT", "반도체(NXT)", "5단 표그래프", "테마-현재가", "01/1", "0123")]
[InlineData("테마_NXT", "반도체(NXT)", "5단 표그래프", "테마-현재가", "1/1", "R0123")]
public async Task ResolveAsync_InvalidRowsFailWithoutDatabaseAccess(
string groupCode,
string subject,
string graphicType,
string subtype,
string pageText,
string dataCode)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedNxtThemeRestoreService(executor);
var row = Row(
groupCode: groupCode,
subject: subject,
graphicType: graphicType,
subtype: subtype,
pageText: pageText,
dataCode: dataCode);
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.InvalidRow, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("5단 표그래프", "테마-예상체결가(입력순)")]
[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 LegacyNamedNxtThemeRestoreService(executor)
.ResolveAsync([Row(graphicType: graphicType, subtype: subtype)])).Rows);
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.UnsupportedAction, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_ReportsNotFoundAndAmbiguousPerTitleWithoutPreview()
{
var executor = new RecordingExecutor(call =>
{
var title = Assert.IsType<string>(call.Spec.Parameters[0].Value);
return title == "없음"
? IdentityTable()
: IdentityTable(
(title, "111", "NXT", "1", "2", "2"),
(title, "222", "NXT", "1", "2", "2"));
});
var result = await new LegacyNamedNxtThemeRestoreService(executor).ResolveAsync(
[
Row(itemIndex: 0, subject: "없음(NXT)"),
Row(itemIndex: 1, subject: "중복(NXT)")
]);
Assert.Equal(
[
LegacyNamedNxtThemeRestoreFailure.IdentityNotFound,
LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous
],
result.Rows.Select(row => row.Failure));
Assert.Equal(2, executor.Calls.Count);
}
[Theory]
[InlineData(0, 0, LegacyNamedNxtThemeRestoreFailure.PreviewEmpty)]
[InlineData(2, 0, LegacyNamedNxtThemeRestoreFailure.LiveItemsEmpty)]
public async Task ResolveAsync_RequiresValidatedPreviewAndAtLeastOneLiveItem(
int previewCount,
int liveCount,
LegacyNamedNxtThemeRestoreFailure expected)
{
var executor = new RecordingExecutor(call => call.QueryName switch
{
LegacyNamedNxtThemeRestoreService.IdentityQueryName =>
IdentityTable(("반도체", "00000001", "NXT", "1",
previewCount.ToString(), liveCount.ToString())),
"THEME_PREVIEW_NXT" => previewCount == 0
? EmptyPreviewTable("반도체", "00000001")
: PreviewTable("반도체", "00000001", previewCount),
_ => throw new InvalidOperationException(call.QueryName)
});
var outcome = Assert.Single((await new LegacyNamedNxtThemeRestoreService(executor)
.ResolveAsync([Row()])).Rows);
Assert.Equal(expected, outcome.Failure);
Assert.False(outcome.IsResolved);
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ResolveAsync_FailsClosedOnInvalidDatabaseSchemaOrCodeCardinality()
{
var wrongSchemaExecutor = new RecordingExecutor(_ => new DataTable());
var wrongSchema = Assert.Single((await new LegacyNamedNxtThemeRestoreService(
wrongSchemaExecutor).ResolveAsync([Row()])).Rows);
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.DatabaseDataInvalid, wrongSchema.Failure);
var duplicateCodeExecutor = new RecordingExecutor(_ =>
IdentityTable(("반도체", "00000001", "NXT", "2", "1", "1")));
var duplicateCode = Assert.Single((await new LegacyNamedNxtThemeRestoreService(
duplicateCodeExecutor).ResolveAsync([Row()])).Rows);
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous, duplicateCode.Failure);
}
[Fact]
public async Task ResolveAsync_PropagatesReadFailureOnceAndHonorsCancellation()
{
var failing = new RecordingExecutor(_ => throw new DataException("simulated"));
await Assert.ThrowsAsync<DataException>(() =>
new LegacyNamedNxtThemeRestoreService(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 LegacyNamedNxtThemeRestoreService(canceled)
.ResolveAsync([Row()], cancellation.Token));
Assert.Empty(canceled.Calls);
}
[Fact]
public async Task ResolveAsync_RejectsInvalidBatchAndAllowsDisabledRows()
{
var executor = GoodExecutor("반도체", "00000001", 1, 1);
var service = new LegacyNamedNxtThemeRestoreService(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(LegacyNamedNxtThemeRestoreService.IsCandidate(Row(isEnabled: false)));
}
private static RecordingExecutor GoodExecutor(
string title,
string code,
int previewCount,
int liveCount) =>
new(call => call.QueryName switch
{
LegacyNamedNxtThemeRestoreService.IdentityQueryName => IdentityTable((
title,
code,
"NXT",
"1",
previewCount.ToString(),
liveCount.ToString())),
"THEME_PREVIEW_NXT" => PreviewTable(title, code, previewCount),
_ => throw new InvalidOperationException(call.QueryName)
});
private static LegacyNamedNxtThemeRestoreRow Row(
int itemIndex = 0,
bool isEnabled = true,
string groupCode = "테마_NXT",
string subject = "반도체(NXT)",
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,
string PreviewCount, string LiveCount)[] 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));
table.Columns.Add("PREVIEW_ITEM_COUNT", typeof(string));
table.Columns.Add("LIVE_ITEM_COUNT", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(
row.Title,
row.Code,
row.Market,
row.CodeCount,
row.PreviewCount,
row.LiveCount);
}
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,
"NXT",
$"종목{index + 1}",
$"X{index + 1:D6}",
index.ToString());
}
return table;
}
private static DataTable EmptyPreviewTable(string title, string code)
{
var table = CreatePreviewTable();
table.Rows.Add(title, code, "NXT", 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));
}
}
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => value.ToUniversalTime();
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
}

View File

@@ -150,6 +150,21 @@ public sealed class LegacyNamedPlaylistPersistenceServiceTests
});
}
[Fact]
public async Task LoadAsync_AllowsManualViSubjectBeyondTheGeneralFieldLimit()
{
var names = string.Join(",", Enumerable.Range(0, 100).Select(index => $"종목{index:D3}"));
Assert.True(names.Length > 256);
Assert.True($"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^".Length <= 4_000);
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => LoadTable(
("00000001", "VI", $"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^", "0"))));
var document = await service.LoadAsync("00000001");
Assert.Equal(names, Assert.Single(document.Items).Selection.Subject);
}
[Fact]
public async Task LoadAsync_ReturnsEmptyDocumentFromLeftJoinSentinel()
{
@@ -294,6 +309,28 @@ public sealed class LegacyNamedPlaylistPersistenceServiceTests
Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind);
}
[Fact]
public async Task ReplaceItemsAsync_AllowsManualViSubjectBeyondTheGeneralFieldLimit()
{
var names = string.Join(",", Enumerable.Range(0, 100).Select(index => $"종목{index:D3}"));
var mutations = new RecordingMutationExecutor(call => Result(call, [0, 1]));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
mutations);
await service.ReplaceItemsAsync(
"00000001",
[Item(0, true, "", names, "5단 표그래프", "VI 발동(수동)", "", 1, 20)]);
var insert = Assert.Single(
Assert.Single(mutations.Calls).Transaction.Commands,
command => command.Kind == NamedPlaylistDbCommandKind.InsertItem);
var listText = Assert.Single(
insert.Parameters,
parameter => parameter.Name == "list_text");
Assert.Equal($"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^", listText.Value);
}
[Fact]
public async Task ReplaceItemsAsync_RejectsGapsAndCaretInjectionBeforeMutation()
{

View File

@@ -0,0 +1,162 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyStockMasterIdentityValidationServiceTests
{
[Fact]
public async Task ThemeItems_ResolveExactPrefixMarketWithBoundFreshQueries()
{
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
{
"STOCK_MASTER_VERIFY_KOSPI" => StockTable(("삼성전자", "005930")),
"STOCK_MASTER_VERIFY_KOSDAQ" => StockTable(("카카오", "035720")),
_ => throw new InvalidOperationException(call.TableName)
});
var service = new LegacyStockMasterIdentityValidationService(executor);
var result = await service.ValidateThemeItemsAsync(
[
new ThemeCatalogItem(0, "P005930", "삼성전자"),
new ThemeCatalogItem(1, "D035720", "카카오")
]);
Assert.Equal(
[
new StockMasterIdentity(
StockMarket.Kospi,
DataSourceKind.Oracle,
"삼성전자",
"005930"),
new StockMasterIdentity(
StockMarket.Kosdaq,
DataSourceKind.Oracle,
"카카오",
"035720")
], result);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.Equal(2, call.Spec.Parameters.Count);
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
});
}
[Fact]
public async Task NxtThemeItem_ValidatesStoredNameWithoutDisplaySuffix()
{
var executor = new CatalogRecordingQueryExecutor(call =>
{
Assert.Equal("STOCK_MASTER_VERIFY_NXT_KOSPI", call.TableName);
Assert.DoesNotContain("CONCAT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal("삼성전자", call.Spec.Parameters[1].Value);
return StockTable(("삼성전자", "005930"));
});
var service = new LegacyStockMasterIdentityValidationService(executor);
var result = await service.ValidateThemeItemsAsync(
[new ThemeCatalogItem(0, "X005930", "삼성전자")]);
var identity = Assert.Single(result);
Assert.Equal(StockMarket.NxtKospi, identity.Market);
Assert.Equal(DataSourceKind.MariaDb, identity.Source);
Assert.Equal("삼성전자", identity.Name);
}
[Fact]
public async Task ExpertRecommendation_UsesNxtDisplaySuffixAndRequiresOneMarket()
{
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
{
"STOCK_MASTER_VERIFY_NXT_KOSPI" => StockTable(("삼성전자(NXT)", "005930")),
_ => StockTable()
});
var service = new LegacyStockMasterIdentityValidationService(executor);
var result = await service.ValidateExpertRecommendationsAsync(
[new ExpertCatalogRecommendation(0, "005930", "삼성전자(NXT)", 70_000)]);
var identity = Assert.Single(result);
Assert.Equal(StockMarket.NxtKospi, identity.Market);
Assert.Equal("삼성전자(NXT)", identity.Name);
Assert.Equal(4, executor.Calls.Count);
var nxt = Assert.Single(
executor.Calls,
call => call.TableName == "STOCK_MASTER_VERIFY_NXT_KOSPI");
Assert.Contains("CONCAT", nxt.Spec.Sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ExpertRecommendation_RejectsSameExactIdentityAcrossMarkets()
{
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
{
"STOCK_MASTER_VERIFY_KOSPI" or "STOCK_MASTER_VERIFY_KOSDAQ" =>
StockTable(("동명이인", "000001")),
_ => StockTable()
});
var service = new LegacyStockMasterIdentityValidationService(executor);
await Assert.ThrowsAsync<StockMasterIdentityDataException>(() =>
service.ValidateExpertRecommendationsAsync(
[new ExpertCatalogRecommendation(0, "000001", "동명이인", 1)]));
}
[Theory]
[InlineData("missing")]
[InlineData("duplicate")]
[InlineData("extra-column")]
public async Task ThemeItem_RejectsMissingDuplicateOrExpandedResult(string mode)
{
var executor = new CatalogRecordingQueryExecutor(_ => mode switch
{
"missing" => StockTable(),
"duplicate" => StockTable(("삼성전자", "005930"), ("삼성전자", "005930")),
"extra-column" => StockTableWithExtraColumn(("삼성전자", "005930")),
_ => throw new InvalidOperationException(mode)
});
var service = new LegacyStockMasterIdentityValidationService(executor);
await Assert.ThrowsAsync<StockMasterIdentityDataException>(() =>
service.ValidateThemeItemsAsync(
[new ThemeCatalogItem(0, "P005930", "삼성전자")]));
}
[Fact]
public async Task PreCanceledValidation_DoesNotTouchTheDatabase()
{
var executor = new CatalogRecordingQueryExecutor(_ => StockTable());
var service = new LegacyStockMasterIdentityValidationService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.ValidateThemeItemsAsync(
[new ThemeCatalogItem(0, "P005930", "삼성전자")],
cancellation.Token));
Assert.Empty(executor.Calls);
}
private static DataTable StockTable(params (string Name, string Code)[] rows)
{
var table = new DataTable();
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Name, row.Code);
}
return table;
}
private static DataTable StockTableWithExtraColumn(params (string Name, string Code)[] rows)
{
var table = StockTable(rows);
table.Columns.Add("UNEXPECTED", typeof(string));
return table;
}
}

View File

@@ -28,6 +28,7 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Contains("TO_NUMBER(TRIM(SB_CODE))", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("'^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM SB_LIST", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
},
@@ -35,7 +36,8 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
{
Assert.Equal(DataSourceKind.MariaDb, call.Source);
Assert.Contains("CAST(TRIM(SB_CODE) AS UNSIGNED)", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("REGEXP '^[0-9]{8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("REGEXP BINARY '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("INVALID", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
});
}
@@ -166,6 +168,60 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
transaction.Commands[1].AffectedRowsRule);
}
[Theory]
[InlineData("123")]
[InlineData("0123")]
[InlineData("000123")]
[InlineData("00000123")]
public async Task ExistingIdentity_PreservesLegacyThreeThroughEightDigitCode(string code)
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
code,
"기존 테마");
await service.ReplaceAsync(identity, "수정 테마", []);
var transaction = Assert.Single(mutation.Calls).Transaction;
Assert.Equal(code, transaction.IdentityCode);
Assert.Equal(code, Parameter(transaction.Commands[0], "theme_code").Value);
}
[Theory]
[InlineData("12")]
[InlineData("123456789")]
[InlineData("12A")]
public async Task ExistingIdentity_RejectsCodeOutsideClosedLegacyRange(string code)
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
code,
"기존 테마");
await Assert.ThrowsAsync<ArgumentException>(() =>
service.DeleteAsync(identity));
Assert.Empty(mutation.Calls);
}
[Fact]
public async Task CreateStillRequiresNewEightDigitCode()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
new ThemeCatalogDefinition(ThemeMarket.Krx, "123", "신규 테마", [])));
Assert.Empty(mutation.Calls);
}
[Fact]
public async Task DeleteAsync_DeletesChildrenBeforeIdentityBoundParent()
{

View File

@@ -58,6 +58,9 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("THEME_SEARCH_KRX", call.TableName);
Assert.Contains("SB_MARKET = 'KRX'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("REGEXP_LIKE(s.SB_CODE, '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("d.SB_TITLE = s.SB_TITLE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("d.SB_CODE = s.SB_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("AI", call.Spec.Parameters[0].Value);
Assert.Equal(26, call.Spec.Parameters[1].Value);
@@ -68,6 +71,9 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.Equal(DataSourceKind.MariaDb, call.Source);
Assert.Equal("THEME_SEARCH_NXT", call.TableName);
Assert.Contains("SB_MARKET = 'NXT'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("s.SB_CODE REGEXP BINARY '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("BINARY d.SB_TITLE = BINARY s.SB_TITLE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("BINARY d.SB_CODE = BINARY s.SB_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LIMIT @row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("AI", call.Spec.Parameters[0].Value);
Assert.Equal(26, call.Spec.Parameters[1].Value);
@@ -97,6 +103,24 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.All(executor.Calls, call => Assert.Equal("A!!!_!%", call.Spec.Parameters[0].Value));
}
[Fact]
public async Task SearchAsync_PreservesLegacyThreeAndFourDigitReadIdentities()
{
var executor = new RecordingExecutor(call => call.Source switch
{
DataSourceKind.Oracle => SearchTable(("Legacy KRX", "123", "KRX")),
DataSourceKind.MariaDb => SearchTable(("Legacy NXT", "0123", "NXT")),
_ => throw new InvalidOperationException()
});
var service = new LegacyThemeSelectionService(executor);
var result = await service.SearchAsync("", ThemeSession.PreMarket);
Assert.Equal(
["123", "0123"],
result.Items.Select(static item => item.Identity.ThemeCode));
}
[Fact]
public async Task SearchAsync_AppliesCombinedBoundAndDeterministicMarketOrder()
{
@@ -340,7 +364,7 @@ public sealed class LegacyThemeSelectionServiceTests
new(ThemeMarket.Nxt, ThemeSession.NotApplicable, "00000001", "AI"),
new(ThemeMarket.Nxt, (ThemeSession)99, "00000001", "AI"),
new((ThemeMarket)99, ThemeSession.NotApplicable, "00000001", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "1", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "12", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "0000000A", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", " AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", "bad\u0000title")

View File

@@ -22,4 +22,10 @@
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\scripts\LegacyCutRequirements.json"
Link="Fixtures\LegacyCutRequirements.json"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -90,6 +90,7 @@ public sealed class PagedQuoteSceneDataLoadersTests
var data = await new S5088SceneDataLoader(executor).LoadAsync(
new ThemePagedQuoteLoadRequest(
"0001",
"AI",
PagedQuoteThemeSort.GainRateDescending,
PagedQuoteThemeValueKind.ExpectedExecution));
@@ -97,13 +98,15 @@ public sealed class PagedQuoteSceneDataLoadersTests
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("l.SB_CODE = :themeCode", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("l.SB_TITLE = :themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("t_online1_call", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY q.RATE DESC", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { "themeTitle", "maxRows" },
Assert.Equal(new[] { "themeCode", "themeTitle", "maxRows" },
executor.Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal("AI", executor.Spec.Parameters[0].Value);
Assert.Equal(240, executor.Spec.Parameters[1].Value);
Assert.Equal("0001", executor.Spec.Parameters[0].Value);
Assert.Equal("AI", executor.Spec.Parameters[1].Value);
Assert.Equal(240, executor.Spec.Parameters[2].Value);
Assert.Equal(PagedQuoteBranch.Theme, data.Branch);
Assert.Equal(PagedQuoteTitleStyle.ExpectedExecution, data.TitleStyle);
Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession);
@@ -120,16 +123,19 @@ public sealed class PagedQuoteSceneDataLoadersTests
var data = await new S5074SceneDataLoader(executor).LoadAsync(
new NxtThemePagedQuoteLoadRequest(
"0002",
"Robotics(NXT)",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket));
Assert.Equal(DataSourceKind.MariaDb, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("l.SB_CODE = @themeCode", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("l.SB_TITLE = @themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("l.SB_MARKET = 'NXT'", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY i.SB_I_INDEX", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("Robotics", executor.Spec.Parameters[0].Value);
Assert.Equal("0002", executor.Spec.Parameters[0].Value);
Assert.Equal("Robotics", executor.Spec.Parameters[1].Value);
Assert.Equal(PagedQuoteBranch.NxtTheme, data.Branch);
Assert.Equal(NxtMarketSession.PreMarket, data.NxtSession);
Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle);
@@ -351,6 +357,7 @@ public sealed class PagedQuoteSceneDataLoadersTests
await new S5074SceneDataLoader(executor).LoadAsync(
new ThemePagedQuoteLoadRequest(
"123",
"Theme",
sort,
PagedQuoteThemeValueKind.Current));
@@ -456,17 +463,40 @@ public sealed class PagedQuoteSceneDataLoadersTests
NxtPagedQuoteList.GainRate,
NxtMarketSession.NotApplicable),
new ThemePagedQuoteLoadRequest(
"123",
"Theme",
(PagedQuoteThemeSort)99,
PagedQuoteThemeValueKind.Current),
new ThemePagedQuoteLoadRequest(
"123",
"Theme",
PagedQuoteThemeSort.InputOrder,
(PagedQuoteThemeValueKind)99),
new NxtThemePagedQuoteLoadRequest(
"123",
"Theme",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.NotApplicable),
new ThemePagedQuoteLoadRequest(
"12",
"Theme",
PagedQuoteThemeSort.InputOrder,
PagedQuoteThemeValueKind.Current),
new NxtThemePagedQuoteLoadRequest(
"12A4",
"Theme",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket),
new NxtThemePagedQuoteLoadRequest(
"1234",
"Theme",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket),
new NxtThemePagedQuoteLoadRequest(
"1234",
"Theme(NXT)Copy(NXT)",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket),
new ExpertPagedQuoteLoadRequest("001", "Analyst A"),
new ViPagedQuoteLoadRequest([])
];