From e3b174eec7a498d4b504e9f6b674957f25440a30 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 27 Jul 2026 01:48:32 +0900 Subject: [PATCH] feat: enable initial-consonant search everywhere --- .../Data/ManualFinancialScreens.cs | 74 ++++++++++++++++- .../Data/OverseasIndustryIndexSearch.cs | 23 +++++- .../Data/OverseasStockSearch.cs | 25 ++++-- .../Data/StockSearch.cs | 27 ++++++- .../Data/ThemeSelection.cs | 22 ++++- .../Data/TradingHaltSelection.cs | 23 +++++- .../Data/WorldStockSearch.cs | 25 ++++-- .../Expert/LegacyExpertWorkflow.cs | 5 +- .../LegacyThemeWorkflow.cs | 4 +- .../LegacyManualFinancialWorkflow.cs | 19 +++-- .../TradingHalt/LegacyTradingHaltWorkflow.cs | 5 +- ...LegacyManualFinancialScreenServiceTests.cs | 30 +++++++ ...OverseasIndustryIndexSearchServiceTests.cs | 24 ++++++ .../LegacyOverseasStockSearchServiceTests.cs | 29 +++++++ .../LegacyStockSearchServiceTests.cs | 81 +++++++++++++++++++ .../LegacyThemeSelectionServiceTests.cs | 77 ++++++++++++++++++ .../LegacyTradingHaltSelectionServiceTests.cs | 73 +++++++++++++++++ .../LegacyWorldStockSearchServiceTests.cs | 29 +++++++ .../Expert/LegacyExpertWorkflowTests.cs | 30 +++++++ .../LegacyThemeWorkflowTests.cs | 56 +++++++++++++ .../LegacyManualFinancialWorkflowTests.cs | 72 +++++++++++++++++ .../LegacyTradingHaltWorkflowTests.cs | 30 +++++++ 22 files changed, 738 insertions(+), 45 deletions(-) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs index 971b643..dc4b9c1 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs @@ -720,17 +720,24 @@ public sealed class LegacyManualFinancialScreenService : } 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( profile.SearchSql, [ new DataQueryParameter( "search_empty", - normalizedQuery.Length == 0 ? 1 : 0, + normalizedQuery.Length == 0 || isInitialSearch ? 1 : 0, DbType.Int32), new DataQueryParameter( "search_term", - EscapeLikePattern(normalizedQuery.ToUpperInvariant()), + isInitialSearch + ? string.Empty + : EscapeLikePattern(normalizedQuery.ToUpperInvariant()), DbType.String), new DataQueryParameter("row_limit", rowLimit, DbType.Int32) ]); @@ -742,11 +749,70 @@ public sealed class LegacyManualFinancialScreenService : cancellationToken).ConfigureAwait(false); 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() + .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( table, profile, rowLimit, - maximumResults, + candidateLimit, tolerateInvalidRows: true, out var rejectedItemCount, out var unreadableStockNames, diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs index 18db508..afd02c7 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs @@ -91,9 +91,18 @@ public sealed class LegacyOverseasIndustryIndexSearchService } 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( - EscapeLikePattern(normalizedQuery.ToUpperInvariant()), + isInitialSearch + ? string.Empty + : EscapeLikePattern(normalizedQuery.ToUpperInvariant()), rowLimit); var table = await _executor.ExecuteAsync( DataSourceKind.Oracle, @@ -103,11 +112,17 @@ public sealed class LegacyOverseasIndustryIndexSearchService cancellationToken.ThrowIfCancellationRequested(); var matches = ValidateRows(table, rowLimit); + var filtered = isInitialSearch + ? matches.Where(item => HangulSearchMatcher.IsMatch( + item.KoreanName, + normalizedQuery)).ToArray() + : matches; return new OverseasIndustryIndexSearchResult( normalizedQuery, DateTimeOffset.Now, - matches.Take(maximumResults).ToArray(), - matches.Count > maximumResults); + filtered.Take(maximumResults).ToArray(), + filtered.Count > maximumResults || + (isInitialSearch && matches.Count > MaximumResults)); } private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs index 081f242..d8dcd6d 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs @@ -80,11 +80,16 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi } cancellationToken.ThrowIfCancellationRequested(); + var isInitialSearch = + HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery); // Read enough additional rows to keep malformed display-only entries // from consuming the operator-visible result budget. The query remains // finite and one extra valid row is retained for truncation detection. - var rowLimit = checked(maximumResults + MaximumIsolatedRows + 1); - var spec = CreateQuerySpec(normalizedQuery, rowLimit); + // An initial-only query keeps the ordinary non-empty all-nation scope; + // 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( DataSourceKind.Oracle, QueryName, @@ -93,18 +98,26 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi cancellationToken.ThrowIfCancellationRequested(); 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( normalizedQuery, DateTimeOffset.Now, matches.Take(maximumResults).ToArray(), matches.Count > maximumResults || + (isInitialSearch && validation.Items.Count > MaximumResults) || table.Rows.Count == rowLimit || validation.IsolatedInvalidRowCount > 0, validation.IsolatedInvalidRowCount); } - private static DataQuerySpec CreateQuerySpec(string query, int rowLimit) + private static DataQuerySpec CreateQuerySpec( + string query, + int rowLimit, + bool isInitialSearch) { var parameters = new List { @@ -116,7 +129,9 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi sql = SearchSql; parameters.Insert(0, new DataQueryParameter( "search_term", - EscapeLikePattern(query.ToUpperInvariant()), + isInitialSearch + ? string.Empty + : EscapeLikePattern(query.ToUpperInvariant()), DbType.String)); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs index e3b08b7..cef0a5d 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs @@ -107,9 +107,16 @@ public sealed class LegacyStockSearchService : IStockSearchService cancellationToken.ThrowIfCancellationRequested(); - var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); - var perMarketLimit = checked(maximumResults + 1); + var isInitialSearch = + 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(perMarketLimit * StockSearchQueries.All.Count); + var providerCandidateWindowSaturated = false; // Keep provider load predictable and cancellation responsive. The shared // application database gate grants this whole operation one read slot. @@ -122,9 +129,20 @@ public sealed class LegacyStockSearchService : IStockSearchService .ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); 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) .ThenBy(static item => item.Name, StringComparer.Ordinal) .ThenBy(static item => item.Code, StringComparer.Ordinal) @@ -135,7 +153,8 @@ public sealed class LegacyStockSearchService : IStockSearchService normalizedQuery, DateTimeOffset.Now, ordered, - matches.Count > maximumResults); + filteredMatches.Count > maximumResults || + providerCandidateWindowSaturated); } private static string ValidateAndNormalizeQuery(string query) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs index 95f8629..f36873d 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs @@ -125,9 +125,15 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService ValidateLimit(maximumResults, MaximumResults, nameof(maximumResults)); cancellationToken.ThrowIfCancellationRequested(); - var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); - var perMarketLimit = checked(maximumResults + 1); + var isInitialSearch = + HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery); + var escapedQuery = isInitialSearch + ? string.Empty + : EscapeLikePattern(normalizedQuery.ToUpperInvariant()); + var perMarketLimit = checked( + (isInitialSearch ? MaximumResults : maximumResults) + 1); var matches = new List(perMarketLimit * ThemeQueries.All.Count); + var candidateSetIsTruncated = false; foreach (var profile in ThemeQueries.All) { @@ -139,9 +145,16 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); AppendSearchRows(table, profile, nxtSession, perMarketLimit, matches); + candidateSetIsTruncated |= + isInitialSearch && table.Rows.Count == perMarketLimit; } - var ordered = matches + IReadOnlyList filteredMatches = isInitialSearch + ? matches.Where(item => HangulSearchMatcher.IsMatch( + item.Identity.ThemeTitle, + normalizedQuery)).ToArray() + : matches; + var ordered = filteredMatches .Take(maximumResults) .ToArray(); @@ -150,7 +163,8 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService nxtSession, DateTimeOffset.Now, ordered, - matches.Count > maximumResults); + filteredMatches.Count > maximumResults || + candidateSetIsTruncated); } public async Task PreviewAsync( diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs index 5b6360e..9641036 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs @@ -78,11 +78,17 @@ public sealed class LegacyTradingHaltSelectionService : ITradingHaltSelectionSer cancellationToken.ThrowIfCancellationRequested(); - var rowLimit = checked(maximumResults + 1); - var escapedQuery = EscapeLikePattern(normalizedQuery.ToUpperInvariant()); + var isInitialSearch = + HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery); + var rowLimit = checked( + (isInitialSearch ? MaximumResults : maximumResults) + 1); + var escapedQuery = isInitialSearch + ? string.Empty + : EscapeLikePattern(normalizedQuery.ToUpperInvariant()); var matches = new List(rowLimit * TradingHaltQueries.All.Count); var identities = new HashSet<(TradingHaltMarket Market, string StockCode)>(); var lookupKeys = new HashSet<(TradingHaltMarket Market, string DisplayName)>(); + var candidateSetIsTruncated = false; // UC7 appends KOSPI followed by KOSDAQ. Keep that provider sequence, // then apply a deterministic final order independent of provider row order. @@ -104,9 +110,17 @@ public sealed class LegacyTradingHaltSelectionService : ITradingHaltSelectionSer matches, identities, lookupKeys); + candidateSetIsTruncated |= + isInitialSearch && table.Rows.Count == rowLimit; } - var ordered = matches + IEnumerable filteredMatches = isInitialSearch + ? matches.Where(item => HangulSearchMatcher.IsMatch( + item.DisplayName, + normalizedQuery)) + : matches; + var filtered = filteredMatches.ToArray(); + var ordered = filtered .OrderBy(static item => item.Market) .ThenBy(static item => item.DisplayName, StringComparer.Ordinal) .ThenBy(static item => item.StockCode, StringComparer.Ordinal) @@ -117,7 +131,8 @@ public sealed class LegacyTradingHaltSelectionService : ITradingHaltSelectionSer normalizedQuery, DateTimeOffset.Now, ordered, - matches.Count > maximumResults); + filtered.Length > maximumResults || + candidateSetIsTruncated); } private static string ValidateAndNormalizeQuery(string query) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs index 0809a40..c8a931f 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs @@ -100,28 +100,41 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService cancellationToken.ThrowIfCancellationRequested(); + var isInitialSearch = + HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery); // 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. - var rowLimit = checked(maximumResults + MaximumIsolatedRows + 1); - var spec = CreateQuerySpec(normalizedQuery, rowLimit); + // Initial-only input uses the ordinary non-empty US/TW search scope with + // 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 .ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken) .ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); 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( normalizedQuery, DateTimeOffset.Now, matches.Take(maximumResults).ToArray(), matches.Count > maximumResults || + (isInitialSearch && validation.Items.Count > MaximumResults) || table.Rows.Count == rowLimit || validation.IsolatedInvalidRowCount > 0, validation.IsolatedInvalidRowCount); } - private static DataQuerySpec CreateQuerySpec(string query, int rowLimit) + private static DataQuerySpec CreateQuerySpec( + string query, + int rowLimit, + bool isInitialSearch) { var parameters = new List { @@ -133,7 +146,9 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService sql = SearchSql; parameters.Insert(0, new DataQueryParameter( "search_term", - EscapeLikePattern(query.ToUpperInvariant()), + isInitialSearch + ? string.Empty + : EscapeLikePattern(query.ToUpperInvariant()), DbType.String)); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs index 6185b7b..6deef6e 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs @@ -418,8 +418,9 @@ public sealed class LegacyExpertWorkflowController if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || result.Items is null || result.Items.Count > LegacyExpertSelectionService.MaximumResults || - result.IsTruncated && - result.Items.Count != LegacyExpertSelectionService.MaximumResults) + (result.IsTruncated && + result.Items.Count != LegacyExpertSelectionService.MaximumResults && + !HangulSearchMatcher.IsInitialConsonantQuery(expectedQuery))) { throw new ExpertSelectionDataException("The expert search response correlation is invalid."); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs index 0e243de..645c8cd 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs @@ -580,7 +580,9 @@ public sealed class LegacyThemeWorkflow var serviceItems = result.Items.ToArray(); if (serviceItems.Length > MaximumSearchResults || - result.IsTruncated && serviceItems.Length != MaximumSearchResults) + (result.IsTruncated && + serviceItems.Length != MaximumSearchResults && + !HangulSearchMatcher.IsInitialConsonantQuery(query))) { throw InvalidData("search envelope"); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs index 1a3c25a..910349b 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs @@ -871,9 +871,9 @@ public sealed class LegacyManualFinancialWorkflow { for (var index = selectedIndex + 1; index < snapshot.Rows.Count; index++) { - if (snapshot.Rows[index].StockName.Contains( - normalizedQuery, - StringComparison.OrdinalIgnoreCase)) + if (HangulSearchMatcher.IsMatch( + snapshot.Rows[index].StockName, + normalizedQuery)) { matchIndex = index; break; @@ -884,9 +884,9 @@ public sealed class LegacyManualFinancialWorkflow { for (var index = selectedIndex - 1; index >= 0; index--) { - if (snapshot.Rows[index].StockName.Contains( - normalizedQuery, - StringComparison.OrdinalIgnoreCase)) + if (HangulSearchMatcher.IsMatch( + snapshot.Rows[index].StockName, + normalizedQuery)) { matchIndex = index; break; @@ -1801,10 +1801,9 @@ public sealed class LegacyManualFinancialWorkflow detail.Record.Identity.StockName, StringComparison.Ordinal)) .ToList(); - if (snapshot.Query.Length == 0 || - detail.Record.Identity.StockName.Contains( - snapshot.Query, - StringComparison.OrdinalIgnoreCase)) + if (HangulSearchMatcher.IsMatch( + detail.Record.Identity.StockName, + snapshot.Query)) { rows.Add(new LegacyManualFinancialListRow(resultId, detail)); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs index 3d50572..e555cda 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs @@ -382,8 +382,9 @@ public sealed class LegacyTradingHaltWorkflowController if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || result.Items is null || result.Items.Count > LegacyTradingHaltSelectionService.MaximumResults || - result.IsTruncated && - result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults) + (result.IsTruncated && + result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults && + !HangulSearchMatcher.IsInitialConsonantQuery(expectedQuery))) { throw new TradingHaltSelectionDataException( "The trading-halt search response correlation is invalid."); diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs index 8be0da6..9dabd57 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs @@ -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( diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasIndustryIndexSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasIndustryIndexSearchServiceTests.cs index f718489..aeb140f 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasIndustryIndexSearchServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasIndustryIndexSearchServiceTests.cs @@ -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() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasStockSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasStockSearchServiceTests.cs index 9b0ae58..f4e7f6d 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasStockSearchServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasStockSearchServiceTests.cs @@ -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() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyStockSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyStockSearchServiceTests.cs index e0fd170..12d94e4 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyStockSearchServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyStockSearchServiceTests.cs @@ -85,6 +85,87 @@ public sealed class LegacyStockSearchServiceTests call.Spec.Parameters[1].Value)); } + [Fact] + public async Task InitialConsonantSearch_UsesFullEmptyBoundCandidateWindowsThenFiltersNames() + { + var tables = new Queue( + [ + 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( + () => 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() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs index b2bdf72..4dbde91 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs @@ -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( + () => 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() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyTradingHaltSelectionServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyTradingHaltSelectionServiceTests.cs index 4b0c677..4b8d938 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyTradingHaltSelectionServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyTradingHaltSelectionServiceTests.cs @@ -96,6 +96,79 @@ public sealed class LegacyTradingHaltSelectionServiceTests Assert.Equal(0, executor.StringSqlCallCount); } + [Fact] + public async Task SearchAsync_InitialConsonantsReadFullMarketsThenFilterBeforeExistingSort() + { + var results = new Queue( + [ + 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( + () => 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( + [ + 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() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWorldStockSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWorldStockSearchServiceTests.cs index fa1c45b..f034bef 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWorldStockSearchServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyWorldStockSearchServiceTests.cs @@ -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() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/Expert/LegacyExpertWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/Expert/LegacyExpertWorkflowTests.cs index c98354c..985cc15 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/Expert/LegacyExpertWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/Expert/LegacyExpertWorkflowTests.cs @@ -54,6 +54,36 @@ public sealed class LegacyExpertWorkflowTests () => 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( + () => controller.SearchAsync("Expert")); + } + [Theory] [InlineData(1, false, 1)] [InlineData(5, false, 1)] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyThemeWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyThemeWorkflowTests.cs index ee55718..2364356 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyThemeWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyThemeWorkflowTests.cs @@ -366,6 +366,62 @@ public sealed class LegacyThemeWorkflowTests 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(() => + workflow.SearchAsync( + workflow.CreateInitial(), + "AI", + ThemeSession.PreMarket)); + } + [Fact] public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualFinancial/LegacyManualFinancialWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualFinancial/LegacyManualFinancialWorkflowTests.cs index 06c4a38..e1744fa 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualFinancial/LegacyManualFinancialWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualFinancial/LegacyManualFinancialWorkflowTests.cs @@ -286,6 +286,78 @@ public sealed class LegacyManualFinancialWorkflowTests 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(), + 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] [MemberData(nameof(Screens))] public async Task Create_save_readback_and_action_id_only_draft_work_for_all_modes( diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/TradingHalt/LegacyTradingHaltWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/TradingHalt/LegacyTradingHaltWorkflowTests.cs index e276759..5a04db4 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/TradingHalt/LegacyTradingHaltWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/TradingHalt/LegacyTradingHaltWorkflowTests.cs @@ -34,6 +34,36 @@ public sealed class LegacyTradingHaltWorkflowTests 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( + () => controller.SearchAsync("Alpha")); + } + [Fact] public async Task SearchSelect_MaterializesClosedCurrentAndCandleDraftsFromActionIdOnly() {