fix: preserve search input and add choseong lookup
This commit is contained in:
@@ -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<ExpertPreviewResult> PreviewAsync(
|
||||
|
||||
112
src/MBN_STOCK_WEBVIEW.Core/Data/HangulSearchMatcher.cs
Normal file
112
src/MBN_STOCK_WEBVIEW.Core/Data/HangulSearchMatcher.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace MMoneyCoderSharp.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Adds Korean initial-consonant matching without weakening the exact identities
|
||||
/// used after a search result is selected.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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<IReadOnlyList<LegacyStockIdentity>> GetMasterCatalogAsync(
|
||||
|
||||
@@ -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]")) {
|
||||
|
||||
@@ -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(",
|
||||
|
||||
283
tests/LegacyParityWeb/text-input-render-stability.test.cjs
Normal file
283
tests/LegacyParityWeb/text-input-render-stability.test.cjs
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<string, DataTable>
|
||||
{
|
||||
["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();
|
||||
|
||||
@@ -457,6 +457,17 @@ public sealed class LegacyBridgeProtocolTests
|
||||
Assert.IsType<LegacyActivateTradingHaltActionIntent>(haltAction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseExpertInitialConsonantQuery_PreservesCompatibilityJamo()
|
||||
{
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"search-experts\",\"payload\":{\"query\":\"ㄱㅈㅁ\"}}",
|
||||
out var intent,
|
||||
out _));
|
||||
|
||||
Assert.Equal("ㄱㅈㅁ", Assert.IsType<LegacySearchExpertsIntent>(intent).Query);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("up", LegacyPlaylistMoveDirection.Up)]
|
||||
[InlineData("down", LegacyPlaylistMoveDirection.Down)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user