Migrate remaining legacy operator workflows
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([])
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user