fix: preserve search input and add choseong lookup

This commit is contained in:
2026-07-21 11:23:42 +09:00
parent 5ccf8b8ffd
commit b6208c0971
11 changed files with 801 additions and 64 deletions

View File

@@ -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(",

View 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);
});

View File

@@ -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(" "));
}
}

View File

@@ -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)]

View File

@@ -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();

View File

@@ -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)]

View File

@@ -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);