feat: enable initial-consonant search everywhere

This commit is contained in:
2026-07-27 01:48:32 +09:00
parent 8cad072cf0
commit e3b174eec7
22 changed files with 738 additions and 45 deletions

View File

@@ -92,6 +92,36 @@ public sealed class LegacyManualFinancialScreenServiceTests
Assert.Contains("ESCAPE '!'", escapedExecutor.Spec.Sql, StringComparison.Ordinal);
}
[Fact]
public async Task Initial_consonant_search_filters_the_bounded_full_candidate_list()
{
var table = EmptyTable(ManualFinancialScreenKind.Sales);
AddQuarterRow(table, "가나다");
AddQuarterRow(table, "삼성전자");
AddQuarterRow(table, "현대차");
var executor = new RecordingQueryExecutor(table);
var service = new LegacyManualFinancialScreenService(executor);
var result = await service.SearchAsync(
ManualFinancialScreenKind.Sales,
" ㅅㅅㅈ ",
maximumResults: 1);
Assert.Equal("ㅅㅅㅈ", result.Query);
Assert.Equal(
"삼성전자",
Assert.Single(result.Items).Record.Identity.StockName);
Assert.Equal(
"삼성전자",
Assert.Single(result.RawItems).Record.Identity.StockName);
Assert.False(result.IsTruncated);
Assert.Equal(1, executor.Spec!.Parameters[0].Value);
Assert.Equal(string.Empty, executor.Spec.Parameters[1].Value);
Assert.Equal(
LegacyManualFinancialScreenService.MaximumResults + 1,
executor.Spec.Parameters[2].Value);
}
[Theory]
[MemberData(nameof(Screens))]
public void Row_version_SQL_is_one_shared_null_tagged_length_prefixed_contract(

View File

@@ -84,6 +84,30 @@ public sealed class LegacyOverseasIndustryIndexSearchServiceTests
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
}
[Fact]
public async Task SearchAsync_InitialConsonantsFilterTheFullBoundedUsCandidateWindow()
{
var executor = new RecordingExecutor(_ => ResultTable(
("필라델피아 반도체", "US_SEMICONDUCTOR", "SOX"),
("다우 운송", "US_DOW_TRANSPORT", "DJT"),
("필라델피아 은행", "US_BANK", "BKX")));
var service = new LegacyOverseasIndustryIndexSearchService(executor);
var result = await service.SearchAsync(" ㅍㄹㄷㅍㅇ ", maximumResults: 1);
Assert.Equal("ㅍㄹㄷㅍㅇ", result.Query);
Assert.True(result.IsTruncated);
Assert.Equal(
"필라델피아 반도체",
Assert.Single(result.Items).KoreanName);
var call = Assert.Single(executor.Calls);
Assert.Contains("F_NATC = 'US'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
Assert.Equal(
LegacyOverseasIndustryIndexSearchService.MaximumResults + 1,
call.Spec.Parameters[1].Value);
}
[Fact]
public async Task SearchAsync_EscapesLikeMetacharactersOnlyInsideBoundValue()
{

View File

@@ -37,6 +37,35 @@ public sealed class LegacyOverseasStockSearchServiceTests
Assert.Equal("row_limit", call.Spec.Parameters[1].Name);
}
[Fact]
public async Task InitialConsonantQuery_UsesAllNationSearchAndFiltersTheFullCandidateWindow()
{
var executor = new RecordingExecutor(WorldStockTable(
("삼성전자", "LNS@SAMSUNG", "GB"),
("Apple", "NAS@AAPL", "US"),
("신성전자", "TSE@SHINSEONG", "JP")));
var service = new LegacyOverseasStockSearchService(executor);
var result = await service.SearchAsync(" ㅅㅅㅈㅈ ", maximumResults: 1);
Assert.Equal("ㅅㅅㅈㅈ", result.Query);
Assert.True(result.IsTruncated);
Assert.Equal(
new WorldStockSearchItem("삼성전자", "LNS@SAMSUNG", "GB"),
Assert.Single(result.Items));
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(string.Empty, call.Spec.Parameters[0].Value);
Assert.Equal(
LegacyOverseasStockSearchService.MaximumResults +
LegacyOverseasStockSearchService.MaximumIsolatedRows + 1,
call.Spec.Parameters[1].Value);
}
[Fact]
public async Task AllowsDuplicateInputNamesWhenTheExactIdentityDiffersButRejectsDuplicateIdentity()
{

View File

@@ -85,6 +85,87 @@ public sealed class LegacyStockSearchServiceTests
call.Spec.Parameters[1].Value));
}
[Fact]
public async Task InitialConsonantSearch_UsesFullEmptyBoundCandidateWindowsThenFiltersNames()
{
var tables = new Queue<DataTable>(
[
StockTable(
("카카오", "035720"),
("삼성전자", "005930")),
StockTable(("신성델타테크", "065350")),
StockTable(("삼성전자(NXT)", "005930")),
StockTable(("알테오젠(NXT)", "196170"))
]);
var executor = new RecordingExecutor(_ => tables.Dequeue());
var service = new LegacyStockSearchService(executor);
var result = await service.SearchAsync(" ㅅㅅ ", maximumResults: 2);
Assert.Equal("ㅅㅅ", result.Query);
Assert.True(result.IsTruncated);
Assert.Collection(
result.Items,
item => Assert.Equal(
new StockSearchItem(
StockMarket.Kospi,
DataSourceKind.Oracle,
"삼성전자",
"005930"),
item),
item => Assert.Equal(
new StockSearchItem(
StockMarket.Kosdaq,
DataSourceKind.Oracle,
"신성델타테크",
"065350"),
item));
Assert.All(executor.Calls, call =>
{
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
Assert.Equal(
LegacyStockSearchService.MaximumResults + 1,
call.Spec.Parameters[1].Value);
});
}
[Fact]
public async Task InitialConsonantSearch_ValidatesNonmatchingCandidatesBeforeFiltering()
{
var malformed = StockTable(("카\n카오", "035720"));
var executor = new RecordingExecutor(_ => malformed);
var service = new LegacyStockSearchService(executor);
await Assert.ThrowsAsync<StockSearchDataException>(
() => service.SearchAsync("ㅅㅅ"));
Assert.Single(executor.Calls);
Assert.Equal(string.Empty, executor.Calls[0].Spec.Parameters[0].Value);
}
[Fact]
public async Task InitialConsonantSearch_ReportsSaturatedCandidateWindowConservatively()
{
var callIndex = 0;
var executor = new RecordingExecutor(_ =>
callIndex++ == 0
? StockTable(LegacyStockSearchService.MaximumResults + 1)
: StockTable());
var service = new LegacyStockSearchService(executor);
var result = await service.SearchAsync("ㅅㅅ", maximumResults: 25);
Assert.Empty(result.Items);
Assert.True(result.IsTruncated);
Assert.All(executor.Calls, call =>
{
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
Assert.Equal(
LegacyStockSearchService.MaximumResults + 1,
call.Spec.Parameters[1].Value);
});
}
[Fact]
public async Task OperatorCatalogSearchAllAsync_UsesBoundEmptyTermAndFiniteProviderLimits()
{

View File

@@ -109,6 +109,83 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.All(executor.Calls, call => Assert.Equal("A!!!_!%", call.Spec.Parameters[0].Value));
}
[Fact]
public async Task SearchAsync_InitialConsonantsReadFullProvidersThenFilterInProviderOrder()
{
var executor = new RecordingExecutor(call => call.Source switch
{
DataSourceKind.Oracle => SearchTable(
("AI", "00000001", "KRX"),
("반도체", "00000002", "KRX")),
DataSourceKind.MariaDb => SearchTable(
("바다차", "00000003", "NXT")),
_ => throw new InvalidOperationException()
});
var service = new LegacyThemeSelectionService(executor);
var result = await service.SearchAsync(
" ㅂㄷㅊ ",
ThemeSession.PreMarket,
maximumResults: 1);
Assert.Equal("ㅂㄷㅊ", result.Query);
Assert.True(result.IsTruncated);
var item = Assert.Single(result.Items);
Assert.Equal(ThemeMarket.Krx, item.Identity.Market);
Assert.Equal("반도체", item.Identity.ThemeTitle);
Assert.All(executor.Calls, call =>
{
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
Assert.Equal(
LegacyThemeSelectionService.MaximumResults + 1,
call.Spec.Parameters[1].Value);
});
}
[Fact]
public async Task SearchAsync_InitialConsonantsValidateNonMatchingCandidatesBeforeFiltering()
{
var executor = new RecordingExecutor(call => call.Source switch
{
DataSourceKind.Oracle => SearchTable(("AI", "invalid", "KRX")),
DataSourceKind.MariaDb => SearchTable(),
_ => throw new InvalidOperationException()
});
var service = new LegacyThemeSelectionService(executor);
await Assert.ThrowsAsync<ThemeSelectionDataException>(
() => service.SearchAsync("ㅂㄷㅊ", ThemeSession.PreMarket));
Assert.Single(executor.Calls);
}
[Fact]
public async Task SearchAsync_InitialConsonantsConservativelyReportProviderCandidateCap()
{
var cappedKrx = SearchTable(
Enumerable.Range(0, LegacyThemeSelectionService.MaximumResults + 1)
.Select(index => (
Title: "알파",
Code: index.ToString("D8", System.Globalization.CultureInfo.InvariantCulture),
Market: "KRX"))
.ToArray());
var executor = new RecordingExecutor(call => call.Source switch
{
DataSourceKind.Oracle => cappedKrx,
DataSourceKind.MariaDb => SearchTable(),
_ => throw new InvalidOperationException()
});
var service = new LegacyThemeSelectionService(executor);
var result = await service.SearchAsync(
"ㅅㅅ",
ThemeSession.AfterMarket,
maximumResults: 1);
Assert.Empty(result.Items);
Assert.True(result.IsTruncated);
}
[Fact]
public async Task SearchAsync_PreservesLegacyThreeAndFourDigitReadIdentities()
{

View File

@@ -96,6 +96,79 @@ public sealed class LegacyTradingHaltSelectionServiceTests
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task SearchAsync_InitialConsonantsReadFullMarketsThenFilterBeforeExistingSort()
{
var results = new Queue<DataTable>(
[
TradingHaltTable(
("000001", "Alpha"),
("000002", "삼성전자")),
TradingHaltTable(("100001", "삼성전기"))
]);
var executor = new RecordingExecutor(_ => results.Dequeue());
var service = new LegacyTradingHaltSelectionService(executor);
var result = await service.SearchAsync(" ㅅㅅㅈ ", maximumResults: 1);
Assert.Equal("ㅅㅅㅈ", result.Query);
Assert.True(result.IsTruncated);
Assert.Equal(
new TradingHaltSelection(
TradingHaltMarket.Kospi,
"000002",
"삼성전자"),
Assert.Single(result.Items));
Assert.All(executor.Calls, call =>
{
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("row_limit", parameter.Name);
Assert.Equal(
LegacyTradingHaltSelectionService.MaximumResults + 1,
parameter.Value);
});
}
[Fact]
public async Task SearchAsync_InitialConsonantsValidateNonMatchingCandidatesBeforeFiltering()
{
var executor = new RecordingExecutor(_ => TradingHaltTable(
("000001", "Alpha"),
("000001", "Other")));
var service = new LegacyTradingHaltSelectionService(executor);
var exception = await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
() => service.SearchAsync("ㅅㅅ"));
Assert.Contains("duplicate market/code", exception.Message, StringComparison.Ordinal);
Assert.Single(executor.Calls);
}
[Fact]
public async Task SearchAsync_InitialConsonantsConservativelyReportProviderCandidateCap()
{
var cappedKospi = TradingHaltTable(
Enumerable.Range(0, LegacyTradingHaltSelectionService.MaximumResults + 1)
.Select(index => (
StockCode: index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture),
DisplayName: "알파" + index.ToString(
System.Globalization.CultureInfo.InvariantCulture)))
.ToArray());
var results = new Queue<DataTable>(
[
cappedKospi,
TradingHaltTable()
]);
var executor = new RecordingExecutor(_ => results.Dequeue());
var service = new LegacyTradingHaltSelectionService(executor);
var result = await service.SearchAsync("ㅅㅅ", maximumResults: 1);
Assert.Empty(result.Items);
Assert.True(result.IsTruncated);
}
[Fact]
public async Task SearchAsync_ReturnsDeterministicGlobalLimitAndTruncation()
{

View File

@@ -102,6 +102,35 @@ public sealed class LegacyWorldStockSearchServiceTests
Assert.Equal("row_limit", Assert.Single(call.Spec.Parameters).Name);
}
[Fact]
public async Task SearchAsync_InitialConsonantsUseTheBoundedUsTwSearchCandidateWindow()
{
var executor = new RecordingExecutor(_ => WorldStockTable(
("삼성전자", "SAMSUNG", "US"),
("Apple", "AAPL", "US"),
("신성전자", "SHINSEONG", "TW")));
var service = new LegacyWorldStockSearchService(executor);
var result = await service.SearchAsync(" ㅅㅅㅈㅈ ", maximumResults: 1);
Assert.Equal("ㅅㅅㅈㅈ", result.Query);
Assert.True(result.IsTruncated);
Assert.Equal(
new WorldStockSearchItem("삼성전자", "SAMSUNG", "US"),
Assert.Single(result.Items));
var call = Assert.Single(executor.Calls);
Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(
"LIKE '%' || :search_term || '%' ESCAPE '!'",
call.Spec.Sql,
StringComparison.Ordinal);
Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
Assert.Equal(
LegacyWorldStockSearchService.MaximumResults +
LegacyWorldStockSearchService.MaximumIsolatedRows + 1,
call.Spec.Parameters[1].Value);
}
[Fact]
public async Task SearchAsync_ReturnsBoundedRowsAndReportsTruncation()
{