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

@@ -720,17 +720,24 @@ public sealed class LegacyManualFinancialScreenService :
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var rowLimit = checked(maximumResults + 1); var isInitialSearch =
HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery);
var candidateLimit = isInitialSearch
? MaximumResults
: maximumResults;
var rowLimit = checked(candidateLimit + 1);
var spec = new DataQuerySpec( var spec = new DataQuerySpec(
profile.SearchSql, profile.SearchSql,
[ [
new DataQueryParameter( new DataQueryParameter(
"search_empty", "search_empty",
normalizedQuery.Length == 0 ? 1 : 0, normalizedQuery.Length == 0 || isInitialSearch ? 1 : 0,
DbType.Int32), DbType.Int32),
new DataQueryParameter( new DataQueryParameter(
"search_term", "search_term",
EscapeLikePattern(normalizedQuery.ToUpperInvariant()), isInitialSearch
? string.Empty
: EscapeLikePattern(normalizedQuery.ToUpperInvariant()),
DbType.String), DbType.String),
new DataQueryParameter("row_limit", rowLimit, DbType.Int32) new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
]); ]);
@@ -742,11 +749,70 @@ public sealed class LegacyManualFinancialScreenService :
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (isInitialSearch)
{
// Validate every row in the finite candidate window before filtering.
// Then parse the matching rows again with the caller's visible limit
// so Items, RawItems and rejected-row diagnostics stay correlated.
_ = ParseRows(
table,
profile,
rowLimit,
candidateLimit,
tolerateInvalidRows: true,
out _,
out _,
out _);
var matchingTable = table.Clone();
foreach (var row in table.Rows
.Cast<DataRow>()
.Take(candidateLimit))
{
try
{
var stockName =
ManualFinancialValidation.SearchStockName(row);
if (HangulSearchMatcher.IsMatch(stockName, normalizedQuery))
{
matchingTable.ImportRow(row);
}
}
catch (ArgumentException)
{
// An unsafe or absent name cannot match a safe initial query.
}
}
var matchingSnapshots = ParseRows(
matchingTable,
profile,
candidateLimit,
maximumResults,
tolerateInvalidRows: true,
out var matchingRejectedItemCount,
out var matchingUnreadableStockNames,
out var matchingRawItems);
return new ManualFinancialSearchResult(
screen,
normalizedQuery,
DateTimeOffset.Now,
matchingSnapshots,
table.Rows.Count == rowLimit ||
matchingTable.Rows.Count > maximumResults)
{
RejectedItemCount = matchingRejectedItemCount,
UnreadableStockNames = matchingUnreadableStockNames,
RawItems = matchingRawItems,
RawItemsComplete = true
};
}
var snapshots = ParseRows( var snapshots = ParseRows(
table, table,
profile, profile,
rowLimit, rowLimit,
maximumResults, candidateLimit,
tolerateInvalidRows: true, tolerateInvalidRows: true,
out var rejectedItemCount, out var rejectedItemCount,
out var unreadableStockNames, out var unreadableStockNames,

View File

@@ -91,9 +91,18 @@ public sealed class LegacyOverseasIndustryIndexSearchService
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var rowLimit = checked(maximumResults + 1); var isInitialSearch =
HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery);
// Oracle LIKE cannot compare compatibility-jamo initials with composed
// Hangul names. Keep the same US index search scope, but read its finite
// service-wide candidate window and filter only after strict validation.
var rowLimit = isInitialSearch
? checked(MaximumResults + 1)
: checked(maximumResults + 1);
var spec = CreateQuerySpec( var spec = CreateQuerySpec(
EscapeLikePattern(normalizedQuery.ToUpperInvariant()), isInitialSearch
? string.Empty
: EscapeLikePattern(normalizedQuery.ToUpperInvariant()),
rowLimit); rowLimit);
var table = await _executor.ExecuteAsync( var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle, DataSourceKind.Oracle,
@@ -103,11 +112,17 @@ public sealed class LegacyOverseasIndustryIndexSearchService
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var matches = ValidateRows(table, rowLimit); var matches = ValidateRows(table, rowLimit);
var filtered = isInitialSearch
? matches.Where(item => HangulSearchMatcher.IsMatch(
item.KoreanName,
normalizedQuery)).ToArray()
: matches;
return new OverseasIndustryIndexSearchResult( return new OverseasIndustryIndexSearchResult(
normalizedQuery, normalizedQuery,
DateTimeOffset.Now, DateTimeOffset.Now,
matches.Take(maximumResults).ToArray(), filtered.Take(maximumResults).ToArray(),
matches.Count > maximumResults); filtered.Count > maximumResults ||
(isInitialSearch && matches.Count > MaximumResults));
} }
private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit) private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit)

View File

@@ -80,11 +80,16 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var isInitialSearch =
HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery);
// Read enough additional rows to keep malformed display-only entries // Read enough additional rows to keep malformed display-only entries
// from consuming the operator-visible result budget. The query remains // from consuming the operator-visible result budget. The query remains
// finite and one extra valid row is retained for truncation detection. // finite and one extra valid row is retained for truncation detection.
var rowLimit = checked(maximumResults + MaximumIsolatedRows + 1); // An initial-only query keeps the ordinary non-empty all-nation scope;
var spec = CreateQuerySpec(normalizedQuery, rowLimit); // its empty bind reads the full finite candidate window for local match.
var candidateLimit = isInitialSearch ? MaximumResults : maximumResults;
var rowLimit = checked(candidateLimit + MaximumIsolatedRows + 1);
var spec = CreateQuerySpec(normalizedQuery, rowLimit, isInitialSearch);
var table = await _executor.ExecuteAsync( var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle, DataSourceKind.Oracle,
QueryName, QueryName,
@@ -93,18 +98,26 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var validation = ValidateRows(table, rowLimit); var validation = ValidateRows(table, rowLimit);
var matches = validation.Items; var matches = isInitialSearch
? validation.Items.Where(item => HangulSearchMatcher.IsMatch(
item.InputName,
normalizedQuery)).ToArray()
: validation.Items;
return new WorldStockSearchResult( return new WorldStockSearchResult(
normalizedQuery, normalizedQuery,
DateTimeOffset.Now, DateTimeOffset.Now,
matches.Take(maximumResults).ToArray(), matches.Take(maximumResults).ToArray(),
matches.Count > maximumResults || matches.Count > maximumResults ||
(isInitialSearch && validation.Items.Count > MaximumResults) ||
table.Rows.Count == rowLimit || table.Rows.Count == rowLimit ||
validation.IsolatedInvalidRowCount > 0, validation.IsolatedInvalidRowCount > 0,
validation.IsolatedInvalidRowCount); validation.IsolatedInvalidRowCount);
} }
private static DataQuerySpec CreateQuerySpec(string query, int rowLimit) private static DataQuerySpec CreateQuerySpec(
string query,
int rowLimit,
bool isInitialSearch)
{ {
var parameters = new List<DataQueryParameter> var parameters = new List<DataQueryParameter>
{ {
@@ -116,7 +129,9 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
sql = SearchSql; sql = SearchSql;
parameters.Insert(0, new DataQueryParameter( parameters.Insert(0, new DataQueryParameter(
"search_term", "search_term",
EscapeLikePattern(query.ToUpperInvariant()), isInitialSearch
? string.Empty
: EscapeLikePattern(query.ToUpperInvariant()),
DbType.String)); DbType.String));
} }

View File

@@ -107,9 +107,16 @@ public sealed class LegacyStockSearchService : IStockSearchService
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); var isInitialSearch =
var perMarketLimit = checked(maximumResults + 1); HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery);
var providerQuery = isInitialSearch
? string.Empty
: normalizedQuery;
var escapedQuery = EscapeLikePattern(providerQuery.ToUpperInvariant());
var perMarketLimit = checked(
(isInitialSearch ? MaximumResults : maximumResults) + 1);
var matches = new List<StockSearchItem>(perMarketLimit * StockSearchQueries.All.Count); var matches = new List<StockSearchItem>(perMarketLimit * StockSearchQueries.All.Count);
var providerCandidateWindowSaturated = false;
// Keep provider load predictable and cancellation responsive. The shared // Keep provider load predictable and cancellation responsive. The shared
// application database gate grants this whole operation one read slot. // application database gate grants this whole operation one read slot.
@@ -122,9 +129,20 @@ public sealed class LegacyStockSearchService : IStockSearchService
.ConfigureAwait(false); .ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
AppendValidatedRows(table, profile, matches); AppendValidatedRows(table, profile, matches);
providerCandidateWindowSaturated |=
isInitialSearch && table.Rows.Count >= perMarketLimit;
} }
var ordered = matches // Oracle and MariaDB cannot compare compatibility-jamo initials with
// composed Hangul syllables through LIKE. Validate the complete finite
// provider candidate windows first, then apply the shared matcher only
// to the display name. Exact market/code identities remain unchanged.
var filteredMatches = isInitialSearch
? matches.Where(item => HangulSearchMatcher.IsMatch(
item.Name,
normalizedQuery)).ToList()
: matches;
var ordered = filteredMatches
.OrderBy(static item => item.Market) .OrderBy(static item => item.Market)
.ThenBy(static item => item.Name, StringComparer.Ordinal) .ThenBy(static item => item.Name, StringComparer.Ordinal)
.ThenBy(static item => item.Code, StringComparer.Ordinal) .ThenBy(static item => item.Code, StringComparer.Ordinal)
@@ -135,7 +153,8 @@ public sealed class LegacyStockSearchService : IStockSearchService
normalizedQuery, normalizedQuery,
DateTimeOffset.Now, DateTimeOffset.Now,
ordered, ordered,
matches.Count > maximumResults); filteredMatches.Count > maximumResults ||
providerCandidateWindowSaturated);
} }
private static string ValidateAndNormalizeQuery(string query) private static string ValidateAndNormalizeQuery(string query)

View File

@@ -125,9 +125,15 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
ValidateLimit(maximumResults, MaximumResults, nameof(maximumResults)); ValidateLimit(maximumResults, MaximumResults, nameof(maximumResults));
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); var isInitialSearch =
var perMarketLimit = checked(maximumResults + 1); HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery);
var escapedQuery = isInitialSearch
? string.Empty
: EscapeLikePattern(normalizedQuery.ToUpperInvariant());
var perMarketLimit = checked(
(isInitialSearch ? MaximumResults : maximumResults) + 1);
var matches = new List<ThemeSelectorItem>(perMarketLimit * ThemeQueries.All.Count); var matches = new List<ThemeSelectorItem>(perMarketLimit * ThemeQueries.All.Count);
var candidateSetIsTruncated = false;
foreach (var profile in ThemeQueries.All) foreach (var profile in ThemeQueries.All)
{ {
@@ -139,9 +145,16 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
AppendSearchRows(table, profile, nxtSession, perMarketLimit, matches); AppendSearchRows(table, profile, nxtSession, perMarketLimit, matches);
candidateSetIsTruncated |=
isInitialSearch && table.Rows.Count == perMarketLimit;
} }
var ordered = matches IReadOnlyList<ThemeSelectorItem> filteredMatches = isInitialSearch
? matches.Where(item => HangulSearchMatcher.IsMatch(
item.Identity.ThemeTitle,
normalizedQuery)).ToArray()
: matches;
var ordered = filteredMatches
.Take(maximumResults) .Take(maximumResults)
.ToArray(); .ToArray();
@@ -150,7 +163,8 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
nxtSession, nxtSession,
DateTimeOffset.Now, DateTimeOffset.Now,
ordered, ordered,
matches.Count > maximumResults); filteredMatches.Count > maximumResults ||
candidateSetIsTruncated);
} }
public async Task<ThemePreviewResult> PreviewAsync( public async Task<ThemePreviewResult> PreviewAsync(

View File

@@ -78,11 +78,17 @@ public sealed class LegacyTradingHaltSelectionService : ITradingHaltSelectionSer
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var rowLimit = checked(maximumResults + 1); var isInitialSearch =
var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery);
var rowLimit = checked(
(isInitialSearch ? MaximumResults : maximumResults) + 1);
var escapedQuery = isInitialSearch
? string.Empty
: EscapeLikePattern(normalizedQuery.ToUpperInvariant());
var matches = new List<TradingHaltSelection>(rowLimit * TradingHaltQueries.All.Count); var matches = new List<TradingHaltSelection>(rowLimit * TradingHaltQueries.All.Count);
var identities = new HashSet<(TradingHaltMarket Market, string StockCode)>(); var identities = new HashSet<(TradingHaltMarket Market, string StockCode)>();
var lookupKeys = new HashSet<(TradingHaltMarket Market, string DisplayName)>(); var lookupKeys = new HashSet<(TradingHaltMarket Market, string DisplayName)>();
var candidateSetIsTruncated = false;
// UC7 appends KOSPI followed by KOSDAQ. Keep that provider sequence, // UC7 appends KOSPI followed by KOSDAQ. Keep that provider sequence,
// then apply a deterministic final order independent of provider row order. // then apply a deterministic final order independent of provider row order.
@@ -104,9 +110,17 @@ public sealed class LegacyTradingHaltSelectionService : ITradingHaltSelectionSer
matches, matches,
identities, identities,
lookupKeys); lookupKeys);
candidateSetIsTruncated |=
isInitialSearch && table.Rows.Count == rowLimit;
} }
var ordered = matches IEnumerable<TradingHaltSelection> filteredMatches = isInitialSearch
? matches.Where(item => HangulSearchMatcher.IsMatch(
item.DisplayName,
normalizedQuery))
: matches;
var filtered = filteredMatches.ToArray();
var ordered = filtered
.OrderBy(static item => item.Market) .OrderBy(static item => item.Market)
.ThenBy(static item => item.DisplayName, StringComparer.Ordinal) .ThenBy(static item => item.DisplayName, StringComparer.Ordinal)
.ThenBy(static item => item.StockCode, StringComparer.Ordinal) .ThenBy(static item => item.StockCode, StringComparer.Ordinal)
@@ -117,7 +131,8 @@ public sealed class LegacyTradingHaltSelectionService : ITradingHaltSelectionSer
normalizedQuery, normalizedQuery,
DateTimeOffset.Now, DateTimeOffset.Now,
ordered, ordered,
matches.Count > maximumResults); filtered.Length > maximumResults ||
candidateSetIsTruncated);
} }
private static string ValidateAndNormalizeQuery(string query) private static string ValidateAndNormalizeQuery(string query)

View File

@@ -100,28 +100,41 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var isInitialSearch =
HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery);
// Keep blank legacy display rows from consuming the visible result budget while // Keep blank legacy display rows from consuming the visible result budget while
// retaining a finite provider bound and one extra valid row for truncation proof. // retaining a finite provider bound and one extra valid row for truncation proof.
var rowLimit = checked(maximumResults + MaximumIsolatedRows + 1); // Initial-only input uses the ordinary non-empty US/TW search scope with
var spec = CreateQuerySpec(normalizedQuery, rowLimit); // an empty bind, then applies the shared matcher to validated identities.
var candidateLimit = isInitialSearch ? MaximumResults : maximumResults;
var rowLimit = checked(candidateLimit + MaximumIsolatedRows + 1);
var spec = CreateQuerySpec(normalizedQuery, rowLimit, isInitialSearch);
var table = await _executor var table = await _executor
.ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken) .ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var validation = ValidateRows(table, rowLimit); var validation = ValidateRows(table, rowLimit);
var matches = validation.Items; var matches = isInitialSearch
? validation.Items.Where(item => HangulSearchMatcher.IsMatch(
item.InputName,
normalizedQuery)).ToArray()
: validation.Items;
return new WorldStockSearchResult( return new WorldStockSearchResult(
normalizedQuery, normalizedQuery,
DateTimeOffset.Now, DateTimeOffset.Now,
matches.Take(maximumResults).ToArray(), matches.Take(maximumResults).ToArray(),
matches.Count > maximumResults || matches.Count > maximumResults ||
(isInitialSearch && validation.Items.Count > MaximumResults) ||
table.Rows.Count == rowLimit || table.Rows.Count == rowLimit ||
validation.IsolatedInvalidRowCount > 0, validation.IsolatedInvalidRowCount > 0,
validation.IsolatedInvalidRowCount); validation.IsolatedInvalidRowCount);
} }
private static DataQuerySpec CreateQuerySpec(string query, int rowLimit) private static DataQuerySpec CreateQuerySpec(
string query,
int rowLimit,
bool isInitialSearch)
{ {
var parameters = new List<DataQueryParameter> var parameters = new List<DataQueryParameter>
{ {
@@ -133,7 +146,9 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
sql = SearchSql; sql = SearchSql;
parameters.Insert(0, new DataQueryParameter( parameters.Insert(0, new DataQueryParameter(
"search_term", "search_term",
EscapeLikePattern(query.ToUpperInvariant()), isInitialSearch
? string.Empty
: EscapeLikePattern(query.ToUpperInvariant()),
DbType.String)); DbType.String));
} }

View File

@@ -418,8 +418,9 @@ public sealed class LegacyExpertWorkflowController
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
result.Items is null || result.Items is null ||
result.Items.Count > LegacyExpertSelectionService.MaximumResults || result.Items.Count > LegacyExpertSelectionService.MaximumResults ||
result.IsTruncated && (result.IsTruncated &&
result.Items.Count != LegacyExpertSelectionService.MaximumResults) result.Items.Count != LegacyExpertSelectionService.MaximumResults &&
!HangulSearchMatcher.IsInitialConsonantQuery(expectedQuery)))
{ {
throw new ExpertSelectionDataException("The expert search response correlation is invalid."); throw new ExpertSelectionDataException("The expert search response correlation is invalid.");
} }

View File

@@ -580,7 +580,9 @@ public sealed class LegacyThemeWorkflow
var serviceItems = result.Items.ToArray(); var serviceItems = result.Items.ToArray();
if (serviceItems.Length > MaximumSearchResults || if (serviceItems.Length > MaximumSearchResults ||
result.IsTruncated && serviceItems.Length != MaximumSearchResults) (result.IsTruncated &&
serviceItems.Length != MaximumSearchResults &&
!HangulSearchMatcher.IsInitialConsonantQuery(query)))
{ {
throw InvalidData("search envelope"); throw InvalidData("search envelope");
} }

View File

@@ -871,9 +871,9 @@ public sealed class LegacyManualFinancialWorkflow
{ {
for (var index = selectedIndex + 1; index < snapshot.Rows.Count; index++) for (var index = selectedIndex + 1; index < snapshot.Rows.Count; index++)
{ {
if (snapshot.Rows[index].StockName.Contains( if (HangulSearchMatcher.IsMatch(
normalizedQuery, snapshot.Rows[index].StockName,
StringComparison.OrdinalIgnoreCase)) normalizedQuery))
{ {
matchIndex = index; matchIndex = index;
break; break;
@@ -884,9 +884,9 @@ public sealed class LegacyManualFinancialWorkflow
{ {
for (var index = selectedIndex - 1; index >= 0; index--) for (var index = selectedIndex - 1; index >= 0; index--)
{ {
if (snapshot.Rows[index].StockName.Contains( if (HangulSearchMatcher.IsMatch(
normalizedQuery, snapshot.Rows[index].StockName,
StringComparison.OrdinalIgnoreCase)) normalizedQuery))
{ {
matchIndex = index; matchIndex = index;
break; break;
@@ -1801,10 +1801,9 @@ public sealed class LegacyManualFinancialWorkflow
detail.Record.Identity.StockName, detail.Record.Identity.StockName,
StringComparison.Ordinal)) StringComparison.Ordinal))
.ToList(); .ToList();
if (snapshot.Query.Length == 0 || if (HangulSearchMatcher.IsMatch(
detail.Record.Identity.StockName.Contains( detail.Record.Identity.StockName,
snapshot.Query, snapshot.Query))
StringComparison.OrdinalIgnoreCase))
{ {
rows.Add(new LegacyManualFinancialListRow(resultId, detail)); rows.Add(new LegacyManualFinancialListRow(resultId, detail));
} }

View File

@@ -382,8 +382,9 @@ public sealed class LegacyTradingHaltWorkflowController
if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
result.Items is null || result.Items is null ||
result.Items.Count > LegacyTradingHaltSelectionService.MaximumResults || result.Items.Count > LegacyTradingHaltSelectionService.MaximumResults ||
result.IsTruncated && (result.IsTruncated &&
result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults) result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults &&
!HangulSearchMatcher.IsInitialConsonantQuery(expectedQuery)))
{ {
throw new TradingHaltSelectionDataException( throw new TradingHaltSelectionDataException(
"The trading-halt search response correlation is invalid."); "The trading-halt search response correlation is invalid.");

View File

@@ -92,6 +92,36 @@ public sealed class LegacyManualFinancialScreenServiceTests
Assert.Contains("ESCAPE '!'", escapedExecutor.Spec.Sql, StringComparison.Ordinal); 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] [Theory]
[MemberData(nameof(Screens))] [MemberData(nameof(Screens))]
public void Row_version_SQL_is_one_shared_null_tagged_length_prefixed_contract( 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); 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] [Fact]
public async Task SearchAsync_EscapesLikeMetacharactersOnlyInsideBoundValue() public async Task SearchAsync_EscapesLikeMetacharactersOnlyInsideBoundValue()
{ {

View File

@@ -37,6 +37,35 @@ public sealed class LegacyOverseasStockSearchServiceTests
Assert.Equal("row_limit", call.Spec.Parameters[1].Name); 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] [Fact]
public async Task AllowsDuplicateInputNamesWhenTheExactIdentityDiffersButRejectsDuplicateIdentity() public async Task AllowsDuplicateInputNamesWhenTheExactIdentityDiffersButRejectsDuplicateIdentity()
{ {

View File

@@ -85,6 +85,87 @@ public sealed class LegacyStockSearchServiceTests
call.Spec.Parameters[1].Value)); 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] [Fact]
public async Task OperatorCatalogSearchAllAsync_UsesBoundEmptyTermAndFiniteProviderLimits() 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)); 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] [Fact]
public async Task SearchAsync_PreservesLegacyThreeAndFourDigitReadIdentities() public async Task SearchAsync_PreservesLegacyThreeAndFourDigitReadIdentities()
{ {

View File

@@ -96,6 +96,79 @@ public sealed class LegacyTradingHaltSelectionServiceTests
Assert.Equal(0, executor.StringSqlCallCount); 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] [Fact]
public async Task SearchAsync_ReturnsDeterministicGlobalLimitAndTruncation() 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); 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] [Fact]
public async Task SearchAsync_ReturnsBoundedRowsAndReportsTruncation() public async Task SearchAsync_ReturnsBoundedRowsAndReportsTruncation()
{ {

View File

@@ -54,6 +54,36 @@ public sealed class LegacyExpertWorkflowTests
() => controller.MaterializeSelectedAction("5074-caller-override")); () => controller.MaterializeSelectedAction("5074-caller-override"));
} }
[Fact]
public async Task Search_AcceptsConservativeTruncationAfterInitialFiltering()
{
var service = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(
SearchResult(query, Alpha) with { IsTruncated = true })
};
var controller = new LegacyExpertWorkflowController(service);
var searched = await controller.SearchAsync("ㄱㅈㅁ");
Assert.Equal(Alpha.ExpertName, Assert.Single(searched.Search.Results).ExpertName);
Assert.True(searched.Search.IsTruncated);
}
[Fact]
public async Task Search_StillRejectsImpossiblePartialTruncationForOrdinaryText()
{
var service = new DelegateExpertService
{
Search = (query, _, _) => Task.FromResult(
SearchResult(query, Alpha) with { IsTruncated = true })
};
var controller = new LegacyExpertWorkflowController(service);
await Assert.ThrowsAsync<ExpertSelectionDataException>(
() => controller.SearchAsync("Expert"));
}
[Theory] [Theory]
[InlineData(1, false, 1)] [InlineData(1, false, 1)]
[InlineData(5, false, 1)] [InlineData(5, false, 1)]

View File

@@ -366,6 +366,62 @@ public sealed class LegacyThemeWorkflowTests
Assert.Equal("theme-result-5000", snapshot.SearchResults[^1].ResultId); Assert.Equal("theme-result-5000", snapshot.SearchResults[^1].ResultId);
} }
[Fact]
public async Task SearchAsync_AcceptsConservativeTruncationAfterInitialFiltering()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => new ThemeSearchResult(
query,
session,
DateTimeOffset.UtcNow,
[
Selector(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"삼성 테마",
"00000001")
],
IsTruncated: true)
};
var workflow = new LegacyThemeWorkflow(service);
var snapshot = await workflow.SearchAsync(
workflow.CreateInitial(),
"ㅅㅅ",
ThemeSession.PreMarket);
Assert.Equal("삼성 테마", Assert.Single(snapshot.SearchResults).DisplayTitle);
Assert.True(snapshot.SearchIsTruncated);
}
[Fact]
public async Task SearchAsync_StillRejectsImpossiblePartialTruncationForOrdinaryText()
{
var service = new FakeThemeSelectionService
{
SearchHandler = (query, session) => new ThemeSearchResult(
query,
session,
DateTimeOffset.UtcNow,
[
Selector(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
"AI",
"00000001")
],
IsTruncated: true)
};
var workflow = new LegacyThemeWorkflow(service);
await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
workflow.SearchAsync(
workflow.CreateInitial(),
"AI",
ThemeSession.PreMarket));
}
[Fact] [Fact]
public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview() public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview()
{ {

View File

@@ -286,6 +286,78 @@ public sealed class LegacyManualFinancialWorkflowTests
workflow.ToggleNameSort(state, staleGeneration)); workflow.ToggleNameSort(state, staleGeneration));
} }
[Fact]
public async Task Directional_find_matches_Hangul_initial_consonants()
{
var service = new FakeManualFinancialService(
Snapshot(Record(ManualFinancialScreenKind.Sales, "가나다"), 1),
Snapshot(Record(ManualFinancialScreenKind.Sales, "삼성전자"), 2),
Snapshot(Record(ManualFinancialScreenKind.Sales, "현대차"), 3));
var workflow = Workflow(service);
var state = await workflow.SearchAsync(
workflow.CreateInitial(ManualFinancialScreenKind.Sales));
state = workflow.FindByName(
state,
" ㅅㅅㅈ ",
LegacyManualFinancialFindDirection.Down,
state.Revision);
Assert.Equal("ㅅㅅㅈ", state.FindQuery);
Assert.Equal(
"삼성전자",
state.Rows.Single(row => row.ResultId == state.SelectedRowId).StockName);
state = workflow.FindByName(
state,
"ㄱㄴㄷ",
LegacyManualFinancialFindDirection.Up,
state.Revision);
Assert.Equal(
"가나다",
state.Rows.Single(row => row.ResultId == state.SelectedRowId).StockName);
}
[Fact]
public async Task Create_keeps_a_matching_row_inside_an_initial_consonant_filter()
{
const string stockName = "삼성전자";
var service = new FakeManualFinancialService
{
SearchOverride = (screen, query) => new ManualFinancialSearchResult(
screen,
query,
DateTimeOffset.UtcNow,
Array.Empty<ManualFinancialSnapshot>(),
IsTruncated: false)
};
var workflow = new LegacyManualFinancialWorkflow(
service,
new FakeStockSearchService(new StockSearchItem(
StockMarket.Kospi,
DataSourceKind.Oracle,
stockName,
"005930")),
new LegacyManualFinancialWriteSafetyState());
var state = await workflow.SearchAsync(
workflow.CreateInitial(ManualFinancialScreenKind.Sales),
"ㅅㅅㅈ");
state = await workflow.SearchStocksAsync(state, stockName);
state = workflow.SelectStock(
state,
Assert.Single(state.StockCandidates).ResultId);
state = await workflow.ExecuteAsync(
state,
workflow.CreateCreateRequest(
state,
Record(ManualFinancialScreenKind.Sales, stockName)));
Assert.Equal("ㅅㅅㅈ", state.Query);
Assert.Equal(stockName, Assert.Single(state.Rows).StockName);
}
[Theory] [Theory]
[MemberData(nameof(Screens))] [MemberData(nameof(Screens))]
public async Task Create_save_readback_and_action_id_only_draft_work_for_all_modes( public async Task Create_save_readback_and_action_id_only_draft_work_for_all_modes(

View File

@@ -34,6 +34,36 @@ public sealed class LegacyTradingHaltWorkflowTests
Assert.Equal("가온전자", current.DisplayName); Assert.Equal("가온전자", current.DisplayName);
} }
[Fact]
public async Task Search_AcceptsConservativeTruncationAfterInitialFiltering()
{
var service = new DelegateTradingHaltService
{
Search = (query, _, _) => Task.FromResult(
SearchResult(query, KospiAlpha) with { IsTruncated = true })
};
var controller = new LegacyTradingHaltWorkflowController(service);
var searched = await controller.SearchAsync("ㄱㅈ");
Assert.Equal(KospiAlpha.DisplayName, Assert.Single(searched.Results).DisplayName);
Assert.True(searched.IsTruncated);
}
[Fact]
public async Task Search_StillRejectsImpossiblePartialTruncationForOrdinaryText()
{
var service = new DelegateTradingHaltService
{
Search = (query, _, _) => Task.FromResult(
SearchResult(query, KospiAlpha) with { IsTruncated = true })
};
var controller = new LegacyTradingHaltWorkflowController(service);
await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
() => controller.SearchAsync("Alpha"));
}
[Fact] [Fact]
public async Task SearchSelect_MaterializesClosedCurrentAndCandleDraftsFromActionIdOnly() public async Task SearchSelect_MaterializesClosedCurrentAndCandleDraftsFromActionIdOnly()
{ {