feat: advance legacy UI behavior parity

This commit is contained in:
2026-07-18 10:18:29 +09:00
parent d7ec571343
commit 1302b1b92f
71 changed files with 11244 additions and 1153 deletions

View File

@@ -125,7 +125,7 @@ public sealed class CandleSceneDataLoaderTests
CandleChartMode.Price,
"World",
"NAS@WORLD",
"DE"),
"DEU"),
Request(
CandleCutAlias.Cut8061,
CandleMarketTarget.OverseasIndex,
@@ -246,7 +246,7 @@ public sealed class CandleSceneDataLoaderTests
Assert.All(stockExecutor.Calls, call =>
{
Assert.Contains("a.f_fdtc = '1'", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
Assert.DoesNotContain(
"a.f_natc IN ('US', 'TW')",
call.Spec.Sql,
StringComparison.OrdinalIgnoreCase);
@@ -268,7 +268,7 @@ public sealed class CandleSceneDataLoaderTests
}
[Fact]
public async Task Overseas_stock_candle_preserves_the_original_taiwan_scope()
public async Task Overseas_stock_candle_accepts_an_exact_all_nation_identity()
{
var executor = new RecordingExecutor(QuoteTable(), RowsTable(false));
@@ -277,18 +277,18 @@ public sealed class CandleSceneDataLoaderTests
CandleCutAlias.Cut8061,
CandleMarketTarget.OverseasStock,
CandleChartMode.Price,
"Taiwan Stock",
"TWS@2330",
"TW"));
"London Stock",
"LNS@LON",
"GB"));
Assert.All(executor.Calls, call =>
{
Assert.Contains(
Assert.DoesNotContain(
"a.f_natc IN ('US', 'TW')",
call.Spec.Sql,
StringComparison.OrdinalIgnoreCase);
Assert.Equal(
"TW",
"GB",
Assert.Single(
call.Spec.Parameters,
parameter => parameter.Name == "nationCode").Value);

View File

@@ -84,16 +84,25 @@ public sealed class LegacyComparisonPairImportServiceTests
}
[Fact]
public void Parser_RejectsDuplicatePairsAndInvalidCp949()
public void Parser_AllowsDuplicatePairsAsDistinctOrderedRows()
{
var duplicate = Encode(
"1^삼성전자,기아^코스피^코스피^^^^^\r\n" +
"2^삼성전자,기아^코스피^코스피^^^^^\r\n");
Assert.Equal(
LegacyComparisonImportFailure.SourceInvalid,
Assert.Throws<LegacyComparisonImportException>(
() => LegacyComparisonFileParser.Parse(duplicate)).Failure);
var rows = LegacyComparisonFileParser.Parse(duplicate);
Assert.Collection(
rows,
row => Assert.Equal(1, row.RowNumber),
row => Assert.Equal(2, row.RowNumber));
Assert.Equal(rows[0].First, rows[1].First);
Assert.Equal(rows[0].Second, rows[1].Second);
}
[Fact]
public void Parser_RejectsInvalidCp949()
{
Assert.Equal(
LegacyComparisonImportFailure.SourceInvalid,
Assert.Throws<LegacyComparisonImportException>(
@@ -228,6 +237,32 @@ public sealed class LegacyComparisonPairImportServiceTests
Assert.Equal("엔비디아", executor.Calls[3].Spec.Parameters[0].Value);
}
[Fact]
public async Task ImportAsync_PreservesDuplicatePairsAsDistinctStableRows()
{
var bytes = Encode(
"1^삼성전자,기아^코스피^코스피^^^^^\r\n" +
"2^삼성전자,기아^코스피^코스피^^^^^\r\n");
var executor = new RecordingExecutor(call =>
string.Equals(
call.Spec.Parameters[0].Value as string,
"삼성전자",
StringComparison.Ordinal)
? StockTable("삼성전자", "005930")
: StockTable("기아", "000270"));
var service = new LegacyComparisonPairImportService(
executor,
new MemorySource(bytes));
var result = await service.ImportAsync();
Assert.Equal(2, result.SourceRowCount);
Assert.Equal([1, 2], result.Pairs.Select(static pair => pair.RowNumber));
Assert.Equal(result.Pairs[0].First, result.Pairs[1].First);
Assert.Equal(result.Pairs[0].Second, result.Pairs[1].Second);
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ImportAsync_MapsAllTwentyFourOriginalFixedTargetsExactly()
{

View File

@@ -69,7 +69,7 @@ public sealed class LegacyExpertCatalogPersistenceServiceTests
var parent = transaction.Commands[0];
Assert.Contains("NOT EXISTS", parent.Sql, StringComparison.Ordinal);
Assert.Equal("홍'길동", Parameter(parent, "expert_name").Value);
Assert.Equal("홍'길동", Parameter(parent, "conflict_name").Value);
Assert.DoesNotContain(parent.Parameters, parameter => parameter.Name == "conflict_name");
var first = transaction.Commands[1];
Assert.Equal("005930", Parameter(first, "stock_code").Value);
Assert.Equal(72000m, Parameter(first, "buy_amount").Value);
@@ -100,8 +100,8 @@ public sealed class LegacyExpertCatalogPersistenceServiceTests
var update = transaction.Commands[0];
Assert.Equal("기존 전문가", Parameter(update, "expected_name").Value);
Assert.Equal("새 전문가", Parameter(update, "new_name").Value);
Assert.Equal("새 전문가", Parameter(update, "duplicate_name").Value);
Assert.Contains("NOT EXISTS", update.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(update.Parameters, parameter => parameter.Name == "duplicate_name");
Assert.DoesNotContain("NOT EXISTS", update.Sql, StringComparison.Ordinal);
Assert.Equal(
OperatorCatalogAffectedRowsRule.ExactlyOne,
update.AffectedRowsRule);
@@ -132,26 +132,21 @@ public sealed class LegacyExpertCatalogPersistenceServiceTests
}
[Fact]
public async Task Mutations_RejectDuplicateNamesCodesOrderAndUnsafeAmountsBeforeExecutor()
public async Task Mutations_AllowRepeatedRecommendationsButRejectOrderAndUnsafeAmountsBeforeExecutor()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyExpertCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ExpertSelectionIdentity("0001", "전문가");
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
await service.CreateAsync(
new ExpertCatalogDefinition(
identity,
[
new ExpertCatalogRecommendation(0, "005930", "삼성전자", 1m),
new ExpertCatalogRecommendation(1, "005930", "다른이름", 2m)
])));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
new ExpertCatalogDefinition(
identity,
[
new ExpertCatalogRecommendation(0, "005930", "삼성전자", 1m),
new ExpertCatalogRecommendation(1, "035720", "삼성전자", 2m)
])));
new ExpertCatalogRecommendation(1, "005930", "삼성전자", 2m)
]));
Assert.Equal(3, Assert.Single(mutation.Calls).Transaction.Commands.Count);
mutation.Calls.Clear();
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
new ExpertCatalogDefinition(
identity,

View File

@@ -133,9 +133,8 @@ public sealed class LegacyExpertSelectionServiceTests
}
[Theory]
[InlineData("Analyst", "0001", "Analyst", "0002")]
[InlineData("Analyst", "0001", "Other", "0001")]
public async Task SearchAsync_RejectsDuplicateExpertNameOrCode(
public async Task SearchAsync_RejectsDuplicateExpertCode(
string name1,
string code1,
string name2,
@@ -149,7 +148,19 @@ public sealed class LegacyExpertSelectionServiceTests
var exception = await Assert.ThrowsAsync<ExpertSelectionDataException>(
() => service.SearchAsync());
Assert.Contains("duplicate expert name or code", exception.Message, StringComparison.Ordinal);
Assert.Contains("duplicate expert code", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_AllowsDuplicateExpertNamesWithDistinctCodes()
{
var executor = new RecordingExecutor(_ => SearchTable(
("Analyst", "0001"),
("Analyst", "0002")));
var result = await new LegacyExpertSelectionService(executor).SearchAsync();
Assert.Equal(2, result.Items.Count);
}
[Theory]
@@ -351,11 +362,9 @@ public sealed class LegacyExpertSelectionServiceTests
}
[Theory]
[InlineData("005930", "Samsung", "0", "005930", "Other", "1")]
[InlineData("005930", "Samsung", "0", "035720", "Samsung", "1")]
[InlineData("005930", "Samsung", "0", "035720", "Kakao", "0")]
[InlineData("005930", "Samsung", "2", "035720", "Kakao", "1")]
public async Task PreviewAsync_RejectsDuplicateOrNonIncreasingRecommendationIdentity(
public async Task PreviewAsync_RejectsDuplicateOrNonIncreasingPlayIndex(
string code1,
string name1,
string index1,
@@ -371,6 +380,18 @@ public sealed class LegacyExpertSelectionServiceTests
new LegacyExpertSelectionService(executor).PreviewAsync(Identity()));
}
[Fact]
public async Task PreviewAsync_AllowsRepeatedStockCodeAndNameAtDistinctPlayIndexes()
{
var executor = new RecordingExecutor(_ => PreviewTable(
("Analyst A", "0001", "005930", "Samsung", "100", "0"),
("Analyst A", "0001", "005930", "Samsung", "200", "1")));
var result = await new LegacyExpertSelectionService(executor).PreviewAsync(Identity());
Assert.Equal(2, result.Items.Count);
}
[Fact]
public async Task PreviewAsync_StopsAfterProviderCancellation()
{

View File

@@ -32,7 +32,7 @@ public sealed class LegacyIndustrySelectionServiceTests
}
[Fact]
public async Task GetAsync_rejects_wrong_schema_duplicate_unsafe_code_and_control_text()
public async Task GetAsync_rejects_wrong_schema_unsafe_code_and_control_text()
{
var wrongSchema = Table(("반도체", "013"));
wrongSchema.Columns[0].ColumnName = "NAME";
@@ -40,11 +40,6 @@ public sealed class LegacyIndustrySelectionServiceTests
new LegacyIndustrySelectionService(new RecordingExecutor(wrongSchema))
.GetAsync(IndustryMarket.Kospi));
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(
Table(("반도체", "013"), ("반도체", "013"))))
.GetAsync(IndustryMarket.Kospi));
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(Table(("반도체", "01-3"))))
.GetAsync(IndustryMarket.Kospi));
@@ -55,17 +50,24 @@ public sealed class LegacyIndustrySelectionServiceTests
}
[Fact]
public async Task GetAsync_rejects_ambiguous_duplicate_name_or_code()
public async Task GetAsync_preserves_duplicate_name_and_code_rows_in_original_order()
{
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(
Table(("반도체", "013"), ("반도체", "014"))))
.GetAsync(IndustryMarket.Kospi));
var result = await new LegacyIndustrySelectionService(new RecordingExecutor(
Table(
("반도체", "013"),
("반도체", "014"),
("운송", "013"),
("반도체", "013"))))
.GetAsync(IndustryMarket.Kospi);
await Assert.ThrowsAsync<IndustrySelectionDataException>(() =>
new LegacyIndustrySelectionService(new RecordingExecutor(
Table(("반도체", "013"), ("운송", "013"))))
.GetAsync(IndustryMarket.Kosdaq));
Assert.Equal(
[
new IndustrySelection(IndustryMarket.Kospi, "반도체", "013"),
new IndustrySelection(IndustryMarket.Kospi, "반도체", "014"),
new IndustrySelection(IndustryMarket.Kospi, "운송", "013"),
new IndustrySelection(IndustryMarket.Kospi, "반도체", "013")
],
result);
}
[Fact]

View File

@@ -0,0 +1,112 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyOverseasStockSearchServiceTests
{
[Fact]
public async Task BlankQuery_UsesOriginalUsTwInitialScopeAndOrder()
{
var executor = new RecordingExecutor(WorldStockTable(("Apple", "NAS@AAPL", "US")));
var result = await new LegacyOverseasStockSearchService(executor).SearchAsync(" ");
Assert.Equal(string.Empty, result.Query);
Assert.Equal(new WorldStockSearchItem("Apple", "NAS@AAPL", "US"), Assert.Single(result.Items));
var call = Assert.Single(executor.Calls);
Assert.Equal("OVERSEAS_STOCK_SEARCH", call.Name);
Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY F_INPUT_NAME", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("ORDER BY F_INPUT_NAME, F_SYMB", call.Spec.Sql, StringComparison.Ordinal);
Assert.Single(call.Spec.Parameters);
Assert.Equal("row_limit", call.Spec.Parameters[0].Name);
}
[Fact]
public async Task NonblankQuery_UsesOriginalAllNationScopeWithBoundEscapedTerm()
{
var executor = new RecordingExecutor(WorldStockTable(("London Stock", "LNS@LON", "GB")));
var result = await new LegacyOverseasStockSearchService(executor).SearchAsync(" lo_% ");
Assert.Equal("lo_%", result.Query);
Assert.Equal("GB", Assert.Single(result.Items).NationCode);
var call = Assert.Single(executor.Calls);
Assert.DoesNotContain("F_NATC IN", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LIKE '%' || :search_term || '%' ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("LO!_!%", call.Spec.Parameters[0].Value);
Assert.Equal("row_limit", call.Spec.Parameters[1].Name);
}
[Fact]
public async Task AllowsDuplicateInputNamesWhenTheExactIdentityDiffersButRejectsDuplicateIdentity()
{
var ambiguous = new RecordingExecutor(WorldStockTable(
("Alpha", "AAA", "US"),
("Alpha", "AAA", "GB")));
var result = await new LegacyOverseasStockSearchService(ambiguous).SearchAsync("alpha");
Assert.Equal(2, result.Items.Count);
Assert.Equal(["US", "GB"], result.Items.Select(item => item.NationCode));
var duplicate = new RecordingExecutor(WorldStockTable(
("Alpha", "AAA", "US"),
("Alpha", "AAA", "US")));
await Assert.ThrowsAsync<WorldStockSearchDataException>(
() => new LegacyOverseasStockSearchService(duplicate).SearchAsync("alpha"));
}
[Fact]
public async Task DefaultQueryUsesTheFullBoundAndReportsWhenOriginalUnboundedResultsAreTruncated()
{
var rows = Enumerable.Range(0, LegacyOverseasStockSearchService.MaximumResults + 1)
.Select(index => ($"Stock {index:D3}", $"LNS@{index:D4}", "GB"))
.ToArray();
var executor = new RecordingExecutor(WorldStockTable(rows));
var result = await new LegacyOverseasStockSearchService(executor).SearchAsync("stock");
Assert.Equal(LegacyOverseasStockSearchService.MaximumResults, result.Items.Count);
Assert.True(result.IsTruncated);
var rowLimit = Assert.Single(Assert.Single(executor.Calls).Spec.Parameters,
parameter => parameter.Name == "row_limit");
Assert.Equal(LegacyOverseasStockSearchService.MaximumResults + 1, rowLimit.Value);
}
private static DataTable WorldStockTable(params (string Name, 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.Name, row.Symbol, row.Nation);
return table;
}
private sealed record Call(string Name, DataQuerySpec Spec);
private sealed class RecordingExecutor(DataTable result) : IDataQueryExecutor
{
public List<Call> Calls { get; } = [];
public DataTable Execute(DataSourceKind source, string queryName, string query) =>
throw new InvalidOperationException("String SQL fallback is forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string queryName,
string query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("String SQL fallback is forbidden.");
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string queryName,
DataQuerySpec spec,
CancellationToken cancellationToken = default)
{
Assert.Equal(DataSourceKind.Oracle, source);
cancellationToken.ThrowIfCancellationRequested();
Calls.Add(new Call(queryName, spec));
return Task.FromResult(result.Copy());
}
}
}

View File

@@ -33,6 +33,36 @@ public sealed class LegacyParameterizedSceneRequestResolverTests
Assert.Equal(S5001NxtBadge.PreMarket, stock.Badge);
}
[Fact]
public void S5001_foreign_stock_uses_exact_hidden_identity_when_present_and_keeps_legacy_rows_compatible()
{
var resolver = new LegacyParameterizedSceneRequestResolver(new RecordingExecutor());
var exact = resolver.CreateS5001Request(new LegacyPlaylistEntry(
"exact",
"5001",
Selection: new LegacySceneSelection(
"FOREIGN_STOCK",
"Shared Name",
"1열판기본",
"CURRENT",
"LNS@LON|GB|1")));
var legacy = resolver.CreateS5001Request(new LegacyPlaylistEntry(
"legacy",
"5001",
Selection: new LegacySceneSelection(
"FOREIGN_STOCK",
"Legacy Name",
"1열판기본",
"CURRENT",
string.Empty)));
var exactRequest = Assert.IsType<S5001ExactForeignStockLoadRequest>(exact);
Assert.Equal("Shared Name", exactRequest.StockName);
Assert.Equal("LNS@LON", exactRequest.ForeignSymbol);
Assert.Equal("GB", exactRequest.ForeignNationCode);
Assert.Equal("Legacy Name", Assert.IsType<S5001ForeignStockLoadRequest>(legacy).StockName);
}
[Fact]
public async Task Two_column_alias_routes_futures_to_s5032_and_normalizes_side_order()
{

View File

@@ -260,14 +260,47 @@ public sealed class LegacyRefreshSchedulerTests
}
[Fact]
public void ZeroMaximum_DisablesAutomaticRefreshBeforeTheFirstDelay()
public async Task ZeroMaximum_DisablesAutomaticRefreshBeforeCallbacksOrTheFirstDelay()
{
var scheduler = CreateScheduler(new ControlledDelay(), maximumRefreshCount: 0);
var delays = new ControlledDelay();
var scheduler = CreateScheduler(delays, maximumRefreshCount: 0);
var callbackCalls = 0;
var refreshCalls = 0;
Assert.Equal(0, scheduler.CompletedRefreshCount);
Assert.Equal(0, scheduler.MaximumRefreshCount);
Assert.True(scheduler.HasReachedMaximum);
Assert.True(scheduler.IsMaximumCallbackDrained);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
scheduler.WaitForNextRefreshAsync(
_ =>
{
callbackCalls++;
return Task.FromResult(true);
},
null,
CancellationToken.None));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
scheduler.ExecuteRefreshAndTrackSuccessAsync(
_ =>
{
refreshCalls++;
return Task.FromResult(true);
},
static result => result,
CancellationToken.None));
Assert.True(await scheduler.DrainMaximumCallbackAsync(
_ =>
{
callbackCalls++;
return Task.FromResult(true);
},
CancellationToken.None));
Assert.Equal(0, callbackCalls);
Assert.Equal(0, refreshCalls);
Assert.Equal(0, delays.CallCount);
Assert.Throws<InvalidOperationException>(scheduler.MarkRefreshSucceeded);
}

View File

@@ -192,8 +192,8 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
var update = transaction.Commands[0];
Assert.Equal("기존 테마", Parameter(update, "expected_title").Value);
Assert.Equal("새 테마", Parameter(update, "new_title").Value);
Assert.Equal("새 테마", Parameter(update, "duplicate_title").Value);
Assert.Contains("NOT EXISTS", update.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(update.Parameters, parameter => parameter.Name == "duplicate_title");
Assert.DoesNotContain("NOT EXISTS", update.Sql, StringComparison.Ordinal);
Assert.Equal(
OperatorCatalogAffectedRowsRule.ExactlyOne,
update.AffectedRowsRule);
@@ -284,7 +284,7 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
}
[Fact]
public async Task Mutations_RejectDuplicateOrAmbiguousItemsBeforeExecutor()
public async Task Mutations_AllowRepeatedItemsButRejectInvalidOrderOrMarketBeforeExecutor()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
@@ -294,24 +294,17 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
"테마",
[]);
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
await service.CreateAsync(
baseDefinition with
{
Items =
[
new ThemeCatalogItem(0, "P005930", "삼성전자"),
new ThemeCatalogItem(1, "P005930", "다른이름")
new ThemeCatalogItem(1, "P005930", "삼성전자")
]
}));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
baseDefinition with
{
Items =
[
new ThemeCatalogItem(0, "P005930", "삼성전자"),
new ThemeCatalogItem(1, "D035720", "삼성전자")
]
}));
});
Assert.Equal(3, Assert.Single(mutation.Calls).Transaction.Commands.Count);
mutation.Calls.Clear();
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
baseDefinition with
{

View File

@@ -83,6 +83,7 @@ public sealed class LegacyThemeSelectionServiceTests
{
call.Spec.ValidateFor(call.Source);
Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("ORDER BY", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal(DbType.String, call.Spec.Parameters[0].DbType);
Assert.Equal(DbType.Int32, call.Spec.Parameters[1].DbType);
});
@@ -122,7 +123,7 @@ public sealed class LegacyThemeSelectionServiceTests
}
[Fact]
public async Task SearchAsync_AppliesCombinedBoundAndDeterministicMarketOrder()
public async Task SearchAsync_AppliesCombinedBoundAndPreservesKrxProviderOrderBeforeNxt()
{
var executor = new RecordingExecutor(call => call.Source switch
{
@@ -138,7 +139,7 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.True(result.IsTruncated);
Assert.Equal(
["Alpha", "Zulu"],
["Zulu", "Alpha"],
result.Items.Select(static item => item.Identity.ThemeTitle));
Assert.All(executor.Calls, call => Assert.Equal(3, call.Spec.Parameters[1].Value));
}
@@ -229,9 +230,8 @@ public sealed class LegacyThemeSelectionServiceTests
}
[Theory]
[InlineData("AI", "00000001", "AI", "00000002")]
[InlineData("AI", "00000001", "Robotics", "00000001")]
public async Task SearchAsync_RejectsDuplicateTitleOrCodeWithinMarket(
public async Task SearchAsync_RejectsDuplicateCodeWithinMarket(
string title1,
string code1,
string title2,
@@ -245,7 +245,22 @@ public sealed class LegacyThemeSelectionServiceTests
var exception = await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.SearchAsync("", ThemeSession.PreMarket));
Assert.Contains("duplicate title or code", exception.Message, StringComparison.Ordinal);
Assert.Contains("duplicate theme code", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task SearchAsync_AllowsDuplicateTitlesWithDistinctCodes()
{
var executor = new RecordingExecutor(call => call.Source == DataSourceKind.Oracle
? SearchTable(
("AI", "00000001", "KRX"),
("AI", "00000002", "KRX"))
: SearchTable());
var result = await new LegacyThemeSelectionService(executor)
.SearchAsync("", ThemeSession.PreMarket);
Assert.Equal(2, result.Items.Count);
}
[Fact]
@@ -431,10 +446,9 @@ public sealed class LegacyThemeSelectionServiceTests
}
[Theory]
[InlineData("P005930", "0", "P005930", "1")]
[InlineData("P005930", "0", "D035720", "0")]
[InlineData("P005930", "2", "D035720", "1")]
public async Task PreviewAsync_RejectsDuplicateOrNonIncreasingItemIdentity(
public async Task PreviewAsync_RejectsDuplicateOrNonIncreasingItemIndex(
string code1,
string index1,
string code2,
@@ -449,6 +463,19 @@ public sealed class LegacyThemeSelectionServiceTests
() => service.PreviewAsync(KrxIdentity()));
}
[Fact]
public async Task PreviewAsync_AllowsRepeatedItemAtDistinctIndexes()
{
var executor = new RecordingExecutor(_ => PreviewTable(
("AI", "00000001", "KRX", "Samsung", "P005930", "0"),
("AI", "00000001", "KRX", "Samsung", "P005930", "1")));
var result = await new LegacyThemeSelectionService(executor)
.PreviewAsync(KrxIdentity());
Assert.Equal(2, result.Items.Count);
}
[Theory]
[InlineData("01")]
[InlineData("-1")]

View File

@@ -130,6 +130,28 @@ public sealed class ParameterizedMarketSceneDataLoadersTests
Assert.Equal(stockName, Assert.Single(stock.Spec.Parameters).Value);
}
[Fact]
public async Task Exact_foreign_stock_identity_allows_any_two_letter_nation_without_name_collision()
{
const string name = "Shared Name";
const string symbol = "LNS@LON";
const string nation = "GB";
var executor = new RecordingExecutor(QuoteTable(name, 100m, "+", 1m, 1m));
var data = await new S5001SceneDataLoader(executor).LoadAsync(
new S5001ExactForeignStockLoadRequest(name, symbol, nation));
Assert.IsType<S5001ForeignStockSceneData>(data);
var call = Assert.Single(executor.Calls);
Assert.Equal("SCENE_FOREIGN_STOCK", call.TableName);
Assert.Contains("b.f_symb = :symbol", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("b.f_natc = :nationCode", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("b.f_natc IN ('US', 'TW')", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal(
[name, symbol, nation],
call.Spec.Parameters.Select(parameter => parameter.Value));
}
[Fact]
public async Task Closed_foreign_index_symbols_keep_global_markets_but_reject_stock_master_rows()
{