From b6208c0971f7a2f6ccdb53404a409d8a17b610de Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 21 Jul 2026 11:23:42 +0900 Subject: [PATCH] fix: preserve search input and add choseong lookup --- .../Data/ExpertSelection.cs | 23 +- .../Data/HangulSearchMatcher.cs | 112 +++++++ .../LegacyParityStockSearchService.cs | 14 +- .../Web/app.js | 269 +++++++++++++---- .../legacy-modal-workflow-parity.test.cjs | 11 +- .../text-input-render-stability.test.cjs | 283 ++++++++++++++++++ .../HangulSearchMatcherTests.cs | 56 ++++ .../LegacyExpertSelectionServiceTests.cs | 40 +++ .../LegacyParityStockSearchServiceTests.cs | 42 +++ .../LegacyBridgeProtocolTests.cs | 11 + .../LegacyFixedSectionBatchWebTests.cs | 4 +- 11 files changed, 801 insertions(+), 64 deletions(-) create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Data/HangulSearchMatcher.cs create mode 100644 tests/LegacyParityWeb/text-input-render-stability.test.cjs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/HangulSearchMatcherTests.cs diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs index 64ba28b..e6b29f9 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs @@ -136,9 +136,18 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi ValidateLimit(maximumResults, MaximumResults, nameof(maximumResults)); cancellationToken.ThrowIfCancellationRequested(); - var rowLimit = checked(maximumResults + 1); + var isInitialSearch = HangulSearchMatcher.IsInitialConsonantQuery(normalizedQuery); + // Oracle cannot compare compatibility-jamo initials with composed Hangul + // syllables through LIKE. Expert lists are bounded to the service-wide + // maximum, so an initial-only query reads that closed candidate window + // and applies the shared matcher after strict row/schema validation. + var rowLimit = isInitialSearch + ? checked(MaximumResults + 1) + : checked(maximumResults + 1); var spec = CreateSearchSpec( - EscapeLikePattern(normalizedQuery.ToUpperInvariant()), + isInitialSearch + ? string.Empty + : EscapeLikePattern(normalizedQuery.ToUpperInvariant()), rowLimit); var table = await _executor.ExecuteAsync( DataSourceKind.Oracle, @@ -148,7 +157,12 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi cancellationToken.ThrowIfCancellationRequested(); var matches = MapSearchRows(table, rowLimit); - var ordered = matches + var filtered = isInitialSearch + ? matches.Where(item => HangulSearchMatcher.IsMatch( + item.Identity.ExpertName, + normalizedQuery)).ToArray() + : matches; + var ordered = filtered .OrderBy(static item => item.Identity.ExpertName, StringComparer.Ordinal) .ThenBy(static item => item.Identity.ExpertCode, StringComparer.Ordinal) .Take(maximumResults) @@ -157,7 +171,8 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi normalizedQuery, DateTimeOffset.Now, ordered, - matches.Count > maximumResults); + filtered.Count > maximumResults || + (isInitialSearch && matches.Count > MaximumResults)); } public async Task PreviewAsync( diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/HangulSearchMatcher.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/HangulSearchMatcher.cs new file mode 100644 index 0000000..f285035 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/HangulSearchMatcher.cs @@ -0,0 +1,112 @@ +#nullable enable + +using System.Text; + +namespace MMoneyCoderSharp.Data; + +/// +/// Adds Korean initial-consonant matching without weakening the exact identities +/// used after a search result is selected. +/// +public static class HangulSearchMatcher +{ + private const int HangulSyllableStart = 0xAC00; + private const int HangulSyllableEnd = 0xD7A3; + private const int SyllablesPerInitial = 21 * 28; + private const int LeadingJamoStart = 0x1100; + private const int LeadingJamoEnd = 0x1112; + private const string CompatibilityInitials = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; + + public static bool IsMatch(string candidate, string query) + { + ArgumentNullException.ThrowIfNull(candidate); + ArgumentNullException.ThrowIfNull(query); + + var normalizedCandidate = candidate.Normalize(NormalizationForm.FormC); + var normalizedQuery = query.Trim().Normalize(NormalizationForm.FormC); + if (normalizedQuery.Length == 0) + { + return true; + } + + if (normalizedCandidate.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return TryCreateInitialQuery(normalizedQuery, out var initialQuery) && + CreateInitialKey(normalizedCandidate).Contains( + initialQuery, + StringComparison.Ordinal); + } + + public static bool IsInitialConsonantQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + return TryCreateInitialQuery( + query.Trim().Normalize(NormalizationForm.FormC), + out _); + } + + private static bool TryCreateInitialQuery(string query, out string initialQuery) + { + var builder = new StringBuilder(query.Length); + foreach (var character in query) + { + if (char.IsWhiteSpace(character)) + { + continue; + } + + if (TryNormalizeInitial(character, out var initial)) + { + builder.Append(initial); + continue; + } + + initialQuery = string.Empty; + return false; + } + + initialQuery = builder.ToString(); + return initialQuery.Length > 0; + } + + private static string CreateInitialKey(string candidate) + { + var builder = new StringBuilder(candidate.Length); + foreach (var character in candidate) + { + if (character is >= (char)HangulSyllableStart and <= (char)HangulSyllableEnd) + { + var initialIndex = (character - HangulSyllableStart) / SyllablesPerInitial; + builder.Append(CompatibilityInitials[initialIndex]); + } + else if (TryNormalizeInitial(character, out var initial)) + { + builder.Append(initial); + } + } + + return builder.ToString(); + } + + private static bool TryNormalizeInitial(char character, out char initial) + { + var compatibilityIndex = CompatibilityInitials.IndexOf(character); + if (compatibilityIndex >= 0) + { + initial = CompatibilityInitials[compatibilityIndex]; + return true; + } + + if (character is >= (char)LeadingJamoStart and <= (char)LeadingJamoEnd) + { + initial = CompatibilityInitials[character - LeadingJamoStart]; + return true; + } + + initial = default; + return false; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyParityStockSearchService.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyParityStockSearchService.cs index c2f1a56..b2d52b4 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyParityStockSearchService.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyParityStockSearchService.cs @@ -63,7 +63,14 @@ public sealed class LegacyParityStockSearchService : ILegacyStockLookup } var masterCatalog = await GetMasterCatalogAsync(cancellationToken).ConfigureAwait(false); - var searchTerm = query.ToUpper(CultureInfo.CurrentCulture); + var isInitialSearch = HangulSearchMatcher.IsInitialConsonantQuery(query); + // Keep the candidate set identical to the legacy provider searches. + // In particular, NXT search joins the online tables while the complete + // identity master intentionally does not. The master must therefore + // never be used as the visible result set for an initial-only query. + var searchTerm = isInitialSearch + ? string.Empty + : query.ToUpper(CultureInfo.CurrentCulture); var names = new List(); foreach (var profile in Profiles) @@ -77,7 +84,10 @@ public sealed class LegacyParityStockSearchService : ILegacyStockLookup AppendNames(table, profile.SearchTableName, names); } - return new LegacyStockSearchData(query, names, masterCatalog); + var displayNames = isInitialSearch + ? names.Where(name => HangulSearchMatcher.IsMatch(name, query)) + : names; + return new LegacyStockSearchData(query, displayNames, masterCatalog); } private async Task> GetMasterCatalogAsync( diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index 2191936..dcba775 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -119,6 +119,7 @@ let namedPlaylistConfirmation = null; let manualDraft = null; let manualDraftKey = ""; + let manualDraftResetPending = false; let operatorCatalogState = null; let operatorCatalogNameDraft = ""; let operatorCatalogDraftKey = ""; @@ -149,6 +150,12 @@ let industryRenderStructureKey = ""; let overseasRenderStructureKey = ""; let themeRenderStructureKey = ""; + let comparisonRenderStructureKey = ""; + let expertRenderStructureKey = ""; + let tradingHaltRenderStructureKey = ""; + let manualFinancialRenderStateKey = ""; + let manualListRenderStateKey = ""; + let operatorCatalogRenderStateKey = ""; let renderedTreeTabId = null; let treeInteractionLocked = false; let treeFocusOwnedBeforeRender = false; @@ -339,6 +346,7 @@ const controlFocus = activeElement && activeElement.id ? { id: activeElement.id, + value: typeof activeElement.value === "string" ? activeElement.value : null, selectionStart: Number.isInteger(activeElement.selectionStart) ? activeElement.selectionStart : null, selectionEnd: Number.isInteger(activeElement.selectionEnd) @@ -423,17 +431,28 @@ const canRestoreFocus = treeFocusOwnedBeforeRender && !modalFocusManager.getActiveModal(); const focused = findTreeNodeByKey(saved.focusKey); - if (canRestoreFocus && focused && typeof focused.focus === "function") { + const control = canRestoreFocus && saved.controlFocus && saved.controlFocus.id + ? document.getElementById(saved.controlFocus.id) + : null; + const canRestoreControl = control && categoryTree.contains(control) && + control.disabled !== true && typeof control.focus === "function"; + // A focused editor must never be blurred through the previously selected + // tree node. Apart from losing the local draft, that blur commits/cancels + // an in-progress Korean IME composition on every native status refresh. + if (canRestoreFocus && !canRestoreControl && focused && + typeof focused.focus === "function") { focused.focus({ preventScroll: true }); } - if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id) { - const control = document.getElementById(saved.controlFocus.id); - if (control && categoryTree.contains(control) && control.disabled !== true && - typeof control.focus === "function") { - control.focus({ preventScroll: true }); - if (Number.isInteger(saved.controlFocus.selectionStart) && - Number.isInteger(saved.controlFocus.selectionEnd) && - typeof control.setSelectionRange === "function") { + if (canRestoreControl) { + const controlWasRecreated = document.activeElement !== control; + if (controlWasRecreated && typeof saved.controlFocus.value === "string" && + typeof control.value === "string") { + control.value = saved.controlFocus.value; + } + if (controlWasRecreated) control.focus({ preventScroll: true }); + if (Number.isInteger(saved.controlFocus.selectionStart) && + Number.isInteger(saved.controlFocus.selectionEnd) && + typeof control.setSelectionRange === "function" && controlWasRecreated) { try { control.setSelectionRange( saved.controlFocus.selectionStart, @@ -441,7 +460,6 @@ } catch (_) { // Non-text controls can expose selection properties without accepting a range. } - } } } if (Number.isFinite(saved.scrollTop)) categoryTree.scrollTop = saved.scrollTop; @@ -457,6 +475,21 @@ } } + function setTransientControlsBusy(root, busy) { + if (!root) return; + root.querySelectorAll("button, input, select, textarea").forEach(function (control) { + if (busy) { + if (control.dataset.busyDisabled === undefined) { + control.dataset.busyDisabled = control.disabled === true ? "true" : "false"; + } + control.disabled = true; + } else if (control.dataset.busyDisabled !== undefined) { + control.disabled = control.dataset.busyDisabled === "true"; + delete control.dataset.busyDisabled; + } + }); + } + function isOperatorMutationLocked(state) { const playout = state && state.playout; return !state || state.isBusy === true || state.fixedSectionBatch != null || @@ -1399,7 +1432,6 @@ const catalog = state.fixedCatalog; const batch = state.fixedSectionBatch; if (!catalog || !Array.isArray(catalog.sections)) { - categoryTree.replaceChildren(); return; } @@ -1823,10 +1855,43 @@ overseasRenderStructureKey = structureKey; } + function updateComparisonWorkspace(workspace, comparison) { + ["domestic", "world"].forEach(function (kind) { + const input = workspace.querySelector("#comparison-" + kind + "-search"); + if (input && document.activeElement !== input) { + input.value = comparison[kind + "Search"].query || ""; + } + }); + } + function renderComparison(state) { const comparison = state.comparison; if (!comparison || !comparison.domesticSearch || !comparison.worldSearch) return; + const structureKey = JSON.stringify({ + domestic: { + results: comparison.domesticSearch.results, + selectedResultId: comparison.domesticSearch.selectedResultId, + isTruncated: comparison.domesticSearch.isTruncated + }, + world: { + results: comparison.worldSearch.results, + selectedResultId: comparison.worldSearch.selectedResultId, + isTruncated: comparison.worldSearch.isTruncated + }, + fixedTargets: comparison.fixedTargets, + selectedFixedTargetId: comparison.selectedFixedTargetId, + firstTarget: comparison.firstTarget, + secondTarget: comparison.secondTarget, + savedPairs: comparison.savedPairs, + actions: comparison.actions + }); + const existingWorkspace = categoryTree.querySelector(".comparison-workspace"); + if (existingWorkspace && structureKey === comparisonRenderStructureKey) { + updateComparisonWorkspace(existingWorkspace, comparison); + return; + } + const workspace = document.createElement("div"); workspace.className = "comparison-workspace"; @@ -1971,6 +2036,8 @@ workspace.append(searches, fixed, pairPanel, actions); categoryTree.replaceChildren(workspace); + comparisonRenderStructureKey = structureKey; + updateComparisonWorkspace(workspace, comparison); } function themeActionIsClickable(action) { @@ -2169,9 +2236,28 @@ updateThemeWorkspace(workspace, theme); } + function updateExpertWorkspace(workspace, expert) { + const input = workspace.querySelector("#expert-search"); + if (input && document.activeElement !== input) { + input.value = expert.search.query || ""; + } + } + function renderExpert(state) { const expert = state.expert; if (!expert || !expert.search || !expert.preview) return; + const structureKey = JSON.stringify({ + results: expert.search.results, + isTruncated: expert.search.isTruncated, + selectedExpert: expert.selectedExpert, + preview: expert.preview, + actions: expert.actions + }); + const existingWorkspace = categoryTree.querySelector(".expert-workspace"); + if (existingWorkspace && structureKey === expertRenderStructureKey) { + updateExpertWorkspace(existingWorkspace, expert); + return; + } const workspace = document.createElement("div"); workspace.className = "expert-workspace"; const search = document.createElement("div"); @@ -2247,11 +2333,28 @@ actions.appendChild(editRecommendations); workspace.append(search, body, actions); categoryTree.replaceChildren(workspace); + expertRenderStructureKey = structureKey; + updateExpertWorkspace(workspace, expert); + } + + function updateTradingHaltWorkspace(workspace, halt) { + const input = workspace.querySelector("#halt-search"); + if (input && document.activeElement !== input) input.value = halt.query || ""; } function renderTradingHalt(state) { const halt = state.tradingHalt; if (!halt || !Array.isArray(halt.results)) return; + const structureKey = JSON.stringify({ + results: halt.results, + nameSort: halt.nameSort, + actions: halt.actions + }); + const existingWorkspace = categoryTree.querySelector(".halt-workspace"); + if (existingWorkspace && structureKey === tradingHaltRenderStructureKey) { + updateTradingHaltWorkspace(existingWorkspace, halt); + return; + } const workspace = document.createElement("div"); workspace.className = "halt-workspace"; const search = document.createElement("div"); @@ -2292,6 +2395,8 @@ }); workspace.append(search, results, actions); categoryTree.replaceChildren(workspace); + tradingHaltRenderStructureKey = structureKey; + updateTradingHaltWorkspace(workspace, halt); } function blankManualRecord(screen) { @@ -2465,19 +2570,38 @@ if (record) manualDraft = record; } + function captureManualDraftForChangedModel() { + if (!manualModal.hidden && !manualDraftResetPending) rememberManualDraft(); + manualDraftResetPending = false; + } + function renderManualFinancial(state) { const manual = state.manualFinancial; manualButtons.forEach(function (button) { button.disabled = state.isBusy === true; }); if (!manual) { + setTransientControlsBusy(manualDialog, false); manualModal.hidden = true; manualWorkspace.replaceChildren(); manualDraft = null; manualDraftKey = ""; + manualDraftResetPending = false; + manualFinancialRenderStateKey = ""; return; } + const renderStateKey = JSON.stringify(manual); + if (!manualModal.hidden && renderStateKey === manualFinancialRenderStateKey) { + setTransientControlsBusy(manualDialog, state.isBusy === true); + return; + } + + captureManualDraftForChangedModel(); + // Clear the previous transient snapshot before applying a changed model. + // Otherwise a busy -> new-model transition can restore the old screen's + // semantic disabled states over the controls rendered below. + setTransientControlsBusy(manualDialog, false); if (manualModal.hidden) rememberModalOpener(manualDialog); manualModal.hidden = false; manualTitle.textContent = manual.label + " · GraphE 수동 입력"; @@ -2594,12 +2718,9 @@ body.append(list, editor); manualWorkspace.replaceChildren(toolbar, body); - manualClose.disabled = state.isBusy === true; - if (state.isBusy === true) { - manualWorkspace.querySelectorAll("button, input").forEach(function (control) { - control.disabled = true; - }); - } + manualClose.disabled = false; + setTransientControlsBusy(manualDialog, state.isBusy === true); + manualFinancialRenderStateKey = renderStateKey; } function isCanonicalNamedPlaylistTitle(value) { @@ -2614,13 +2735,25 @@ button.disabled = state.isBusy === true || fixedBatch != null; }); if (!manual || manual.isOpen !== true) { + setTransientControlsBusy(manualListDialog, false); clearDelayedClick(manualViResultClickTimer); manualViResultClickTimer = null; manualListModal.hidden = true; manualListWorkspace.replaceChildren(); + manualListRenderStateKey = ""; return; } + const renderStateKey = JSON.stringify({ + manual: manual, + fixedBatch: fixedBatch + }); + if (!manualListModal.hidden && renderStateKey === manualListRenderStateKey) { + setTransientControlsBusy(manualListDialog, state.isBusy === true); + return; + } + + setTransientControlsBusy(manualListDialog, false); if (manualListModal.hidden) rememberModalOpener(manualListDialog); manualListModal.hidden = false; const busy = state.isBusy === true; @@ -2669,7 +2802,7 @@ audience === manual.audience ? "active" : "", audienceLabels[audience]); button.type = "button"; - button.disabled = busy || fixedBatch != null; + button.disabled = fixedBatch != null; button.addEventListener("click", function () { send("open-manual-list", { screen: audience }); }); @@ -2697,16 +2830,15 @@ input.type = "text"; input.maxLength = 256; input.value = row[field] || ""; - input.disabled = busy || manual.isAvailable !== true || manual.writesQuarantined; + input.disabled = manual.isAvailable !== true || manual.writesQuarantined; input.addEventListener("change", function () { - manualListWorkspace.querySelectorAll("button, input").forEach(function (control) { - control.disabled = true; - }); - send("set-manual-net-sell-cell", { + setTransientControlsBusy(manualListDialog, true); + const sent = send("set-manual-net-sell-cell", { rowId: row.rowId, field: field, value: input.value }); + if (!sent) setTransientControlsBusy(manualListDialog, false); }); td.appendChild(input); tr.appendChild(td); @@ -2725,7 +2857,7 @@ input.value = manual.searchQuery || ""; const searchButton = manualElement("button", "", "검색"); searchButton.type = "button"; - searchButton.disabled = busy || manual.isAvailable !== true; + searchButton.disabled = manual.isAvailable !== true; function submitSearch() { if (input.value.trim()) send("search-manual-vi", { query: input.value }); } @@ -2746,7 +2878,7 @@ manual.selectedSearchResultId === row.resultId ? "selected" : "", row.displayName + " · " + row.marketText); button.type = "button"; - button.disabled = busy; + button.disabled = false; button.addEventListener("click", function () { clearDelayedClick(manualViResultClickTimer); manualViResultClickTimer = window.setTimeout(function () { @@ -2782,7 +2914,9 @@ [["↑", "up"], ["↓", "down"]].forEach(function (move) { const button = manualElement("button", "", move[0]); button.type = "button"; - button.disabled = busy || (move[1] === "up" ? index === 0 : index === manual.viItems.length - 1); + button.disabled = move[1] === "up" + ? index === 0 + : index === manual.viItems.length - 1; button.addEventListener("click", function () { send("move-manual-vi-item", { itemId: row.itemId, direction: move[1] }); }); @@ -2790,7 +2924,7 @@ }); const remove = manualElement("button", "", "삭제"); remove.type = "button"; - remove.disabled = busy; + remove.disabled = false; remove.addEventListener("click", function () { send("delete-manual-vi-item", { itemId: row.itemId }); }); @@ -2813,7 +2947,7 @@ const actions = manualElement("div", "manual-list-actions-row"); const importButton = manualElement("button", "", "원본 1회 가져오기"); importButton.type = "button"; - importButton.disabled = busy || manual.isAvailable !== true || manual.writesQuarantined; + importButton.disabled = manual.isAvailable !== true || manual.writesQuarantined; importButton.addEventListener("click", function () { if (window.confirm("원본 파일을 현재 빈 저장소로 한 번만 가져옵니다. 기존 파일은 덮어쓰지 않습니다.")) { send("import-manual-lists", {}); @@ -2821,12 +2955,12 @@ }); const refresh = manualElement("button", "", "재조회"); refresh.type = "button"; - refresh.disabled = busy || manual.isAvailable !== true; + refresh.disabled = manual.isAvailable !== true; refresh.addEventListener("click", function () { send("refresh-manual-list", {}); }); if (manual.screen === "vi") { const clear = manualElement("button", "", "전체 삭제"); clear.type = "button"; - clear.disabled = busy || manual.viItems.length === 0; + clear.disabled = manual.viItems.length === 0; clear.addEventListener("click", function () { if (window.confirm("VI 목록을 모두 지우시겠습니까?")) { send("delete-all-manual-vi-items", {}); @@ -2841,14 +2975,16 @@ ? "확인 후 다음" : "확인 후 섹션 추가"; } - confirm.disabled = busy || manual.canSave !== true; + confirm.disabled = manual.canSave !== true; confirm.addEventListener("click", function () { send("confirm-manual-list", {}); }); actions.append(importButton, refresh, confirm); fragment.appendChild(actions); manualListWorkspace.replaceChildren(fragment); - manualListClose.disabled = busy; + manualListClose.disabled = false; + setTransientControlsBusy(manualListDialog, busy); + manualListRenderStateKey = renderStateKey; } function openNamedPlaylist(mode) { @@ -3234,6 +3370,7 @@ operatorCatalogMutationLocked = isOperatorMutationLocked(state); operatorCatalogState = catalog || null; if (!catalog || catalog.isOpen !== true) { + setTransientControlsBusy(operatorCatalogDialog, false); draggedOperatorCatalogRowId = null; operatorCatalogDragBlockedRowId = null; clearOperatorCatalogDragVisuals(); @@ -3244,6 +3381,7 @@ operatorCatalogNameDraft = ""; operatorCatalogNameStatus.textContent = ""; operatorCatalogNameStatus.className = ""; + operatorCatalogRenderStateKey = ""; return; } @@ -3253,6 +3391,20 @@ clearOperatorCatalogDragVisuals(); } + const renderStateKey = JSON.stringify(catalog); + if (!operatorCatalogModal.hidden && renderStateKey === operatorCatalogRenderStateKey) { + setTransientControlsBusy(operatorCatalogDialog, busy); + operatorCatalogDraftRows.querySelectorAll("[data-operator-catalog-row-id]") + .forEach(function (row) { + row.draggable = !operatorCatalogMutationLocked; + row.setAttribute( + "aria-disabled", + operatorCatalogMutationLocked ? "true" : "false"); + }); + return; + } + + setTransientControlsBusy(operatorCatalogDialog, false); if (operatorCatalogModal.hidden) rememberModalOpener(operatorCatalogDialog); operatorCatalogModal.hidden = false; const isTheme = catalog.entity === "theme"; @@ -3262,18 +3414,18 @@ : catalog.mode === "create" ? "전문가 추가" : "전문가 추천 편집"; operatorCatalogThemeTab.classList.toggle("selected", isTheme); operatorCatalogExpertTab.classList.toggle("selected", !isTheme); - operatorCatalogThemeTab.disabled = busy || editing; - operatorCatalogExpertTab.disabled = busy || editing; + operatorCatalogThemeTab.disabled = editing; + operatorCatalogExpertTab.disabled = editing; operatorCatalogToolbar.hidden = editing; operatorCatalogListPanel.hidden = editing; operatorCatalogBody.classList.toggle("editor-only", editing); - operatorCatalogClose.disabled = busy; - operatorCatalogSearch.disabled = busy; - operatorCatalogSession.disabled = busy || !isTheme; + operatorCatalogClose.disabled = false; + operatorCatalogSearch.disabled = false; + operatorCatalogSession.disabled = !isTheme; operatorCatalogSession.hidden = !isTheme; - operatorCatalogNewMarket.disabled = busy || !isTheme; + operatorCatalogNewMarket.disabled = !isTheme; operatorCatalogNewMarket.hidden = !isTheme; - operatorCatalogNew.disabled = busy || catalog.canBeginCreate !== true; + operatorCatalogNew.disabled = catalog.canBeginCreate !== true; operatorCatalogNew.textContent = isTheme ? "새 ThemeA" : "새 EList"; if (document.activeElement !== operatorCatalogQuery) { operatorCatalogQuery.value = catalog.query || ""; @@ -3288,7 +3440,7 @@ button.type = "button"; button.dataset.operatorCatalogResultId = row.resultId; button.className = row.isSelected ? "selected" : ""; - button.disabled = busy; + button.disabled = false; button.setAttribute("role", "option"); button.setAttribute("aria-selected", row.isSelected ? "true" : "false"); const code = document.createElement("strong"); @@ -3316,7 +3468,7 @@ ? "코드 " + catalog.codeLabel : ""; operatorCatalogNameLabel.textContent = isTheme ? "테마명" : "전문가명"; operatorCatalogName.placeholder = isTheme ? "ThemeA 이름" : "전문가 이름"; - operatorCatalogName.disabled = busy || !editing || + operatorCatalogName.disabled = !editing || (!isTheme && catalog.mode === "edit"); const currentThemeName = operatorCatalogNameDraft.trim(); const checkedCurrentThemeName = isTheme && currentThemeName.length > 0 && @@ -3325,7 +3477,7 @@ ? catalog.themeNameAvailability : "notChecked"; operatorCatalogNameCheck.hidden = !isTheme; - operatorCatalogNameCheck.disabled = busy || !isTheme || !editing || + operatorCatalogNameCheck.disabled = !isTheme || !editing || catalog.canCheckThemeName !== true || currentThemeName.length === 0; operatorCatalogNameStatus.hidden = !isTheme; operatorCatalogNameStatus.className = themeNameAvailability === "available" || @@ -3342,8 +3494,8 @@ if (document.activeElement !== operatorCatalogStockQuery) { operatorCatalogStockQuery.value = catalog.stockQuery || ""; } - operatorCatalogStockQuery.disabled = busy || !editing; - operatorCatalogStockSearch.disabled = busy || !editing; + operatorCatalogStockQuery.disabled = !editing; + operatorCatalogStockSearch.disabled = !editing; const showCatalogRows = isTheme || catalog.mode === "edit"; operatorCatalogStockSearchLine.hidden = !showCatalogRows; operatorCatalogStockResults.hidden = !showCatalogRows; @@ -3355,7 +3507,7 @@ operatorCatalogAmountLabel.hidden = true; operatorCatalogAmount.hidden = true; operatorCatalogAmount.disabled = true; - operatorCatalogAddStock.disabled = busy || catalog.canAddStock !== true; + operatorCatalogAddStock.disabled = catalog.canAddStock !== true; const stockFragment = document.createDocumentFragment(); (catalog.stockResults || []).forEach(function (row) { @@ -3363,7 +3515,7 @@ button.type = "button"; button.dataset.operatorCatalogStockResultId = row.resultId; button.className = row.isSelected ? "selected" : ""; - button.disabled = busy || row.isCompatible !== true; + button.disabled = row.isCompatible !== true; button.title = "더블클릭하면 구성 종목에 추가합니다."; button.setAttribute("role", "option"); button.setAttribute("aria-selected", row.isSelected ? "true" : "false"); @@ -3430,25 +3582,25 @@ detail.value = row.buyAmount == null ? "" : String(row.buyAmount); detail.dataset.operatorCatalogBuyAmountRowId = row.rowId; detail.setAttribute("aria-label", row.displayName + " 매수가"); - detail.disabled = busy; + detail.disabled = false; } const up = document.createElement("button"); up.type = "button"; up.textContent = "Up"; up.dataset.operatorCatalogMoveRowId = row.rowId; up.dataset.operatorCatalogMoveDirection = "up"; - up.disabled = busy || index === 0; + up.disabled = index === 0; const down = document.createElement("button"); down.type = "button"; down.textContent = "Down"; down.dataset.operatorCatalogMoveRowId = row.rowId; down.dataset.operatorCatalogMoveDirection = "down"; - down.disabled = busy || index + 1 === catalog.draftRows.length; + down.disabled = index + 1 === catalog.draftRows.length; const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Delete"; remove.dataset.operatorCatalogRemoveRowId = row.rowId; - remove.disabled = busy; + remove.disabled = false; line.append(order, name, detail, up, down, remove); draftFragment.appendChild(line); }); @@ -3462,11 +3614,11 @@ if (activeRow) activeRow.focus({ preventScroll: true }); } - operatorCatalogSave.disabled = busy || catalog.canSave !== true; + operatorCatalogSave.disabled = catalog.canSave !== true; operatorCatalogDeleteAll.hidden = !isTheme || !editing; - operatorCatalogDeleteAll.disabled = busy || !isTheme || !editing || + operatorCatalogDeleteAll.disabled = !isTheme || !editing || (catalog.draftRows || []).length === 0; - operatorCatalogCancel.disabled = busy || !editing; + operatorCatalogCancel.disabled = !editing; const messages = []; if (catalog.statusMessage) messages.push(catalog.statusMessage); if (catalog.resultsAreTruncated) messages.push("목록 일부만 표시 중입니다."); @@ -3478,6 +3630,8 @@ operatorCatalogStatus.classList.toggle( "error", catalog.writesQuarantined === true || catalog.statusKind === "error"); + setTransientControlsBusy(operatorCatalogDialog, busy); + operatorCatalogRenderStateKey = renderStateKey; } function renderDialog(state) { @@ -3661,6 +3815,7 @@ manualResultClickTimer = null; manualDraft = null; manualDraftKey = ""; + manualDraftResetPending = false; send("open-manual-financial", { screen: button.dataset.manualScreen }); }); }); @@ -3689,6 +3844,7 @@ manualResultClickTimer = null; manualDraft = null; manualDraftKey = ""; + manualDraftResetPending = false; send("activate-manual-financial-result", { resultId: row.dataset.manualResultId }); @@ -3702,6 +3858,7 @@ manualResultClickTimer = null; manualDraft = null; manualDraftKey = ""; + manualDraftResetPending = false; send("load-manual-financial", { resultId: resultId }); }, 250); return; @@ -3736,7 +3893,11 @@ const screen = manualDraft ? manualDraft.screen : ""; manualDraft = blankManualRecord(screen); manualDraftKey = screen + "|new"; - send("begin-manual-financial-create", {}); + manualDraftResetPending = true; + if (!send("begin-manual-financial-create", {})) { + manualDraftResetPending = false; + rememberManualDraft(); + } return; } if (event.target.closest("button[data-manual-stock-search]")) { diff --git a/tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs b/tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs index 15c01f5..ca87aef 100644 --- a/tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs +++ b/tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs @@ -139,6 +139,7 @@ test("category-tree opener survives rerender by resolving its stable id or node assertOrdered(capture, [ "categoryTree.contains(document.activeElement)", "id: activeElement.id", + 'value: typeof activeElement.value === "string" ? activeElement.value : null', "selectionStart: Number.isInteger(activeElement.selectionStart)", "selectionEnd: Number.isInteger(activeElement.selectionEnd)", "focusKey: active ? active.dataset.treeNodeKey : previous.focusKey || null", @@ -148,10 +149,14 @@ test("category-tree opener survives rerender by resolving its stable id or node const finish = section("function finishTreeRender", "function send(type, payload)"); assertOrdered(finish, [ "const canRestoreFocus = treeFocusOwnedBeforeRender", - "if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id)", + "const control = canRestoreFocus && saved.controlFocus && saved.controlFocus.id", "document.getElementById(saved.controlFocus.id)", - "categoryTree.contains(control)", - "control.focus({ preventScroll: true })", + "const canRestoreControl = control && categoryTree.contains(control)", + "if (canRestoreFocus && !canRestoreControl && focused", + "const controlWasRecreated = document.activeElement !== control", + 'typeof saved.controlFocus.value === "string"', + "control.value = saved.controlFocus.value", + "if (controlWasRecreated) control.focus({ preventScroll: true })", "Number.isInteger(saved.controlFocus.selectionStart)", "Number.isInteger(saved.controlFocus.selectionEnd)", "control.setSelectionRange(", diff --git a/tests/LegacyParityWeb/text-input-render-stability.test.cjs b/tests/LegacyParityWeb/text-input-render-stability.test.cjs new file mode 100644 index 0000000..696c8cc --- /dev/null +++ b/tests/LegacyParityWeb/text-input-render-stability.test.cjs @@ -0,0 +1,283 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const app = fs.readFileSync(path.join(repositoryRoot, "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web", "app.js"), "utf8"); + +function section(startMarker, endMarker) { + const start = app.indexOf(startMarker); + const end = app.indexOf(endMarker, start + startMarker.length); + assert.ok(start >= 0 && end > start, + `Unable to extract ${startMarker} through ${endMarker}`); + return app.slice(start, end); +} + +function workspaceHarness(source, keyVariable, renderFunction, workspaceSelector, + inputs) { + let replacements = 0; + const workspace = { + querySelector(selector) { return inputs[selector] || null; } + }; + const categoryTree = { + querySelector(selector) { + return selector === workspaceSelector ? workspace : null; + }, + replaceChildren() { replacements += 1; } + }; + const documentObject = { activeElement: null }; + const harness = new Function("categoryTree", "document", ` + let ${keyVariable} = ""; + ${source} + return { + render: ${renderFunction}, + prime: function (value) { ${keyVariable} = value; } + }; + `)(categoryTree, documentObject); + return { + ...harness, + workspace, + document: documentObject, + replacements: () => replacements + }; +} + +test("expert search keeps the same focused input across equivalent native states", () => { + const input = { value: "ㄱㅈ" }; + const harness = workspaceHarness( + section("function updateExpertWorkspace", "function updateTradingHaltWorkspace"), + "expertRenderStructureKey", + "renderExpert", + ".expert-workspace", + { "#expert-search": input }); + const state = { + expert: { + search: { query: "", results: [], isTruncated: false }, + selectedExpert: null, + preview: { items: [] }, + actions: [] + } + }; + const key = JSON.stringify({ + results: state.expert.search.results, + isTruncated: state.expert.search.isTruncated, + selectedExpert: state.expert.selectedExpert, + preview: state.expert.preview, + actions: state.expert.actions + }); + harness.prime(key); + harness.document.activeElement = input; + + harness.render(state); + + assert.equal(input.value, "ㄱㅈ"); + assert.equal(harness.replacements(), 0); + + harness.document.activeElement = null; + state.expert.search.query = "김전문"; + harness.render(state); + assert.equal(input.value, "김전문"); + assert.equal(harness.replacements(), 0); +}); + +test("comparison searches keep both focused editor identities across status polls", () => { + const domestic = { value: "ㅅㅅ" }; + const world = { value: "apple" }; + const harness = workspaceHarness( + section("function updateComparisonWorkspace", "function themeActionIsClickable"), + "comparisonRenderStructureKey", + "renderComparison", + ".comparison-workspace", + { + "#comparison-domestic-search": domestic, + "#comparison-world-search": world + }); + const state = { + comparison: { + domesticSearch: { query: "", results: [], selectedResultId: null, + isTruncated: false }, + worldSearch: { query: "", results: [], selectedResultId: null, + isTruncated: false }, + fixedTargets: [], + selectedFixedTargetId: null, + firstTarget: null, + secondTarget: null, + savedPairs: [], + actions: [] + } + }; + const key = JSON.stringify({ + domestic: { results: [], selectedResultId: null, isTruncated: false }, + world: { results: [], selectedResultId: null, isTruncated: false }, + fixedTargets: [], + selectedFixedTargetId: null, + firstTarget: null, + secondTarget: null, + savedPairs: [], + actions: [] + }); + harness.prime(key); + harness.document.activeElement = domestic; + + harness.render(state); + + assert.equal(domestic.value, "ㅅㅅ"); + assert.equal(world.value, ""); + assert.equal(harness.replacements(), 0); +}); + +test("trading-halt search keeps its focused draft across equivalent status polls", () => { + const input = { value: "ㅎㄱ" }; + const harness = workspaceHarness( + section("function updateTradingHaltWorkspace", "function blankManualRecord"), + "tradingHaltRenderStructureKey", + "renderTradingHalt", + ".halt-workspace", + { "#halt-search": input }); + const state = { + tradingHalt: { query: "", results: [], nameSort: "ascending", actions: [] } + }; + harness.prime(JSON.stringify({ + results: [], nameSort: "ascending", actions: [] + })); + harness.document.activeElement = input; + + harness.render(state); + + assert.equal(input.value, "ㅎㄱ"); + assert.equal(harness.replacements(), 0); +}); + +test("all recreated editors use stable state keys and transient busy locking", () => { + const fixedCatalog = section("function renderFixedCatalog", "function markIndustryRowSelected"); + const manualFinancial = section("function renderManualFinancial", "function isCanonicalNamedPlaylistTitle"); + const manualLists = section("function renderManualLists", "function openNamedPlaylist"); + const operatorCatalog = section("function renderOperatorCatalog", "operatorCatalogClose.addEventListener"); + const treeFinish = section("function finishTreeRender", "function send("); + + assert.doesNotMatch(fixedCatalog, + /if \(!catalog[^]*?categoryTree\.replaceChildren\(\)/); + assert.match(manualFinancial, + /renderStateKey = JSON\.stringify\(manual\)/); + assert.match(manualFinancial, + /setTransientControlsBusy\(manualDialog, state\.isBusy === true\)/); + const manualStableReturn = manualFinancial.indexOf( + "if (!manualModal.hidden && renderStateKey === manualFinancialRenderStateKey)"); + const manualDraftCapture = manualFinancial.indexOf( + "captureManualDraftForChangedModel();", + manualStableReturn); + const manualWorkspaceReplace = manualFinancial.indexOf( + "manualWorkspace.replaceChildren(toolbar, body);", + manualStableReturn); + assert.ok(manualStableReturn >= 0 && manualDraftCapture > manualStableReturn && + manualWorkspaceReplace > manualDraftCapture, + "a changed GraphE model must capture the current editor before replacing it"); + assert.match(manualLists, + /setTransientControlsBusy\(manualListDialog, state\.isBusy === true\)/); + assert.match(operatorCatalog, + /renderStateKey = JSON\.stringify\(catalog\)/); + assert.match(operatorCatalog, + /setTransientControlsBusy\(operatorCatalogDialog, busy\)/); + assert.doesNotMatch(manualLists, /disabled\s*=\s*busy\s*\|\|/); + assert.doesNotMatch(operatorCatalog, /disabled\s*=\s*busy\s*\|\|/); + assert.doesNotMatch(manualLists, + /querySelectorAll\("button, input"\)[^]*?control\.disabled = true/); + assert.match(manualLists, + /setTransientControlsBusy\(manualListDialog, true\)[^]*?set-manual-net-sell-cell/); + const operatorStableReturn = operatorCatalog.indexOf( + "if (!operatorCatalogModal.hidden && renderStateKey === operatorCatalogRenderStateKey)"); + const operatorModelReset = operatorCatalog.indexOf( + "setTransientControlsBusy(operatorCatalogDialog, false);", + operatorStableReturn); + const operatorSemanticRender = operatorCatalog.indexOf( + "operatorCatalogThemeTab.disabled = editing;", + operatorStableReturn); + assert.ok(operatorStableReturn >= 0 && operatorModelReset > operatorStableReturn && + operatorSemanticRender > operatorModelReset, + "changed operator models must clear transient busy state before semantic rendering"); + assert.match(treeFinish, /!canRestoreControl && focused/); + assert.match(treeFinish, /document\.activeElement !== control/); +}); + +test("transient busy snapshots can be cleared before a changed model is rendered", () => { + const source = section("function setTransientControlsBusy", "function isOperatorMutationLocked"); + const setBusy = new Function(`${source}; return setTransientControlsBusy;`)(); + const control = { disabled: true, dataset: {} }; + const root = { querySelectorAll() { return [control]; } }; + + setBusy(root, true); + assert.equal(control.dataset.busyDisabled, "true"); + + setBusy(root, false); + assert.equal(control.disabled, true); + assert.equal(control.dataset.busyDisabled, undefined); + + control.disabled = false; + setBusy(root, true); + setBusy(root, false); + + assert.equal(control.disabled, false); + assert.equal(control.dataset.busyDisabled, undefined); +}); + +test("GraphE captures the current editor values before a changed model rebuild", () => { + const source = section("function blankManualRecord", "function renderManualFinancial"); + const inputs = new Map([ + ["#manual-financial-stock-name", { value: " 삼성전자 " }], + ["[data-manual-quarter-label=\"0\"]", { value: "2026Q1" }], + ["[data-manual-quarter-value=\"0\"]", { value: "123" }] + ]); + const workspace = { + querySelector(selector) { return inputs.get(selector) || null; } + }; + const harness = new Function("manualWorkspace", ` + let manualDraft = { + screen: "sales", + stockName: "기존값", + quarters: Array.from({ length: 6 }, function () { + return { quarter: "", value: 0 }; + }) + }; + ${source} + return { + capture: rememberManualDraft, + draft: function () { return manualDraft; } + }; + `)(workspace); + + harness.capture(); + + assert.equal(harness.draft().stockName, "삼성전자"); + assert.deepEqual(harness.draft().quarters[0], { quarter: "2026Q1", value: 123 }); +}); + +test("GraphE preserves edits on model refresh but protects an intentional new-form reset", () => { + const source = section( + "function captureManualDraftForChangedModel", + "function renderManualFinancial"); + let captures = 0; + const harness = new Function("manualModal", "rememberManualDraft", ` + let manualDraftResetPending = false; + ${source} + return { + capture: captureManualDraftForChangedModel, + reset: function () { manualDraftResetPending = true; }, + pending: function () { return manualDraftResetPending; } + }; + `)({ hidden: false }, function () { captures += 1; }); + + harness.capture(); + assert.equal(captures, 1); + + harness.reset(); + harness.capture(); + assert.equal(captures, 1); + assert.equal(harness.pending(), false); + + harness.capture(); + assert.equal(captures, 2); +}); diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/HangulSearchMatcherTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/HangulSearchMatcherTests.cs new file mode 100644 index 0000000..3070bd8 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/HangulSearchMatcherTests.cs @@ -0,0 +1,56 @@ +using System.Text; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class HangulSearchMatcherTests +{ + [Theory] + [InlineData("삼성전자", "ㅅㅅㅈㅈ")] + [InlineData("홍길동 위원", "ㅎㄱㄷ ㅇㅇ")] + [InlineData("까치산", "ㄲㅊㅅ")] + [InlineData("삼성전자(NXT)", "ㅅㅅㅈㅈ")] + [InlineData("삼성전자", "ᄉᄉᄌᄌ")] + public void IsMatch_MatchesCompatibilityOrLeadingInitialConsonants( + string candidate, + string query) + { + Assert.True(HangulSearchMatcher.IsMatch(candidate, query)); + Assert.True(HangulSearchMatcher.IsInitialConsonantQuery(query)); + } + + [Fact] + public void IsMatch_NormalizesDecomposedHangulAndKeepsOrdinarySubstringSearch() + { + var decomposed = "삼성전자".Normalize(NormalizationForm.FormD); + + Assert.True(HangulSearchMatcher.IsMatch(decomposed, "ㅅㅅㅈㅈ")); + Assert.True(HangulSearchMatcher.IsMatch("김전문 연구원", "전문")); + Assert.True(HangulSearchMatcher.IsMatch("Alpha Analyst", "analyst")); + } + + [Theory] + [InlineData("삼성전자", "ㅅ성")] + [InlineData("삼성전자", "ㅏ")] + public void IsMatch_DoesNotTreatMixedOrNonInitialJamoAsFuzzyInput( + string candidate, + string query) + { + Assert.False(HangulSearchMatcher.IsMatch(candidate, query)); + Assert.False(HangulSearchMatcher.IsInitialConsonantQuery(query)); + } + + [Fact] + public void IsMatch_RecognizesAValidDoubleInitialWithoutCreatingAFalseMatch() + { + Assert.True(HangulSearchMatcher.IsInitialConsonantQuery("ㄲㅅ")); + Assert.False(HangulSearchMatcher.IsMatch("삼성전자", "ㄲㅅ")); + } + + [Fact] + public void IsMatch_BlankQueryRetainsExistingInitialListBehavior() + { + Assert.True(HangulSearchMatcher.IsMatch("아무 이름", " ")); + Assert.False(HangulSearchMatcher.IsInitialConsonantQuery(" ")); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertSelectionServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertSelectionServiceTests.cs index 62ad17c..04f59b9 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertSelectionServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertSelectionServiceTests.cs @@ -72,6 +72,46 @@ public sealed class LegacyExpertSelectionServiceTests Assert.Equal(2, executor.Calls[0].Spec.Parameters[1].Value); } + [Fact] + public async Task SearchAsync_MatchesHangulInitialConsonantsAfterBoundedCandidateRead() + { + var executor = new RecordingExecutor(_ => SearchTable( + ("박전문", "0003"), + ("김투자", "0002"), + ("김전문", "0001"), + ("강지민", "0004"))); + var service = new LegacyExpertSelectionService(executor); + + var result = await service.SearchAsync(" ㄱㅈㅁ ", maximumResults: 10); + + Assert.Equal("ㄱㅈㅁ", result.Query); + Assert.False(result.IsTruncated); + Assert.Equal( + ["강지민", "김전문"], + result.Items.Select(item => item.Identity.ExpertName)); + var call = Assert.Single(executor.Calls); + Assert.Equal(string.Empty, call.Spec.Parameters[0].Value); + Assert.Equal(LegacyExpertSelectionService.MaximumResults + 1, + call.Spec.Parameters[1].Value); + Assert.DoesNotContain("ㄱㅈㅁ", call.Spec.Sql, StringComparison.Ordinal); + } + + [Fact] + public async Task SearchAsync_AppliesRequestedLimitAfterInitialConsonantFiltering() + { + var executor = new RecordingExecutor(_ => SearchTable( + ("김전문", "0001"), + ("강지민", "0002"), + ("고정민", "0003"))); + + var result = await new LegacyExpertSelectionService(executor) + .SearchAsync("ᄀᄌᄆ", maximumResults: 1); + + Assert.Single(result.Items); + Assert.Equal("강지민", result.Items[0].Identity.ExpertName); + Assert.True(result.IsTruncated); + } + [Theory] [InlineData(0)] [InlineData(-1)] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyParityStockSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyParityStockSearchServiceTests.cs index 84a70b9..fda726e 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyParityStockSearchServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyParityStockSearchServiceTests.cs @@ -66,6 +66,48 @@ public sealed class LegacyParityStockSearchServiceTests call => Assert.Equal(string.Empty, call.Spec.Parameters[0].Value)); } + [Fact] + public async Task InitialConsonantSearch_FiltersOnlyLegacySearchCandidatesInProviderOrder() + { + var executor = new RecordingExecutor(new Dictionary + { + ["LEGACY_PARITY_KOSPI_MASTER"] = Table( + ("삼성전자", "005930")), + ["LEGACY_PARITY_KOSDAQ_MASTER"] = Table( + ("삼성중공업", "010140")), + ["LEGACY_PARITY_NXT_KOSPI_MASTER"] = Table( + ("삼성전자(NXT)", "005930"), + ("삼성화재(NXT)", "000810")), + ["LEGACY_PARITY_NXT_KOSDAQ_MASTER"] = Table( + ("카카오게임즈(NXT)", "293490")), + ["LEGACY_PARITY_KOSPI"] = Table( + ("삼성전자", "005930"), + ("서울반도체", "046890")), + ["LEGACY_PARITY_KOSDAQ"] = Table( + ("삼성중공업", "010140")), + ["LEGACY_PARITY_NXT_KOSPI"] = Table( + ("삼성전자(NXT)", "005930")), + ["LEGACY_PARITY_NXT_KOSDAQ"] = Table() + }); + var service = new LegacyParityStockSearchService(executor); + + var result = await service.SearchAsync("ㅅㅅ"); + + Assert.Equal( + ["삼성전자", "삼성중공업", "삼성전자(NXT)"], + result.DisplayNames); + Assert.DoesNotContain("삼성화재(NXT)", result.DisplayNames); + Assert.NotNull(result.ResolveLastByDisplayName("삼성화재(NXT)")); + Assert.Equal(8, executor.Calls.Count); + Assert.All( + executor.Calls.Where(call => !call.TableName.EndsWith("_MASTER", StringComparison.Ordinal)), + call => Assert.Equal(string.Empty, call.Spec.Parameters[0].Value)); + Assert.Contains( + executor.Calls, + call => call.TableName == "LEGACY_PARITY_NXT_KOSPI" && + call.Spec.Sql.Contains("N_ONLINE", StringComparison.Ordinal)); + } + private static DataTable Table(params (string Name, string Code)[] rows) { var table = new DataTable(); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs index 8ca3e09..53ed639 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs @@ -457,6 +457,17 @@ public sealed class LegacyBridgeProtocolTests Assert.IsType(haltAction); } + [Fact] + public void ParseExpertInitialConsonantQuery_PreservesCompatibilityJamo() + { + Assert.True(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"search-experts\",\"payload\":{\"query\":\"ㄱㅈㅁ\"}}", + out var intent, + out _)); + + Assert.Equal("ㄱㅈㅁ", Assert.IsType(intent).Query); + } + [Theory] [InlineData("up", LegacyPlaylistMoveDirection.Up)] [InlineData("down", LegacyPlaylistMoveDirection.Down)] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyFixedSectionBatchWebTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyFixedSectionBatchWebTests.cs index 241f329..95d32be 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyFixedSectionBatchWebTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyFixedSectionBatchWebTests.cs @@ -44,7 +44,9 @@ public sealed class LegacyFixedSectionBatchWebTests Assert.Contains("const fixedBatch = state.fixedSectionBatch", manual, StringComparison.Ordinal); Assert.Contains("아직 편성표에 추가되지 않았습니다", manual, StringComparison.Ordinal); - Assert.Contains("button.disabled = busy || fixedBatch != null", manual, + Assert.Contains("button.disabled = fixedBatch != null", manual, + StringComparison.Ordinal); + Assert.Contains("setTransientControlsBusy(manualListDialog, state.isBusy === true)", manual, StringComparison.Ordinal); Assert.Contains("확인 후 다음", manual, StringComparison.Ordinal); Assert.Contains("확인 후 섹션 추가", manual, StringComparison.Ordinal);