feat: enable initial-consonant search everywhere
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user