fix: keyboard layout map, picker type tabs as filters, add unit tests

1. Fix RU_TO_EN keyboard map in keyboardLayout.ts:
   - ч was mapped to 'c' (wrong), now correctly 'x'
   - с was mapped to 'v' (wrong), now correctly 'c'
   - ю was mapped to '.' (wrong), now correctly ','
   - Full rewrite with standard QWERTY/ЙЦУКЕН positional mapping
   - Examples: dthcnfr→верстак, руддщ→hello, цщкдв→world all correct now

2. Root cause of search breaking after 3-4 chars:
   Old map had ч:'c', с:'v' swapped. So dthcnfr → 'верчиак' (wrong).
   Each character was mapped to wrong Cyrillic equivalent.

3. Add unit tests: keyboardLayout.test.js (39 tests, node-runner):
   - EN→RU: dthcnfr, ghbdtn, ntcn
   - RU→EN: руддщ, цщкдв, ышеш
   - Unicode safety: Latin c (U+0063) ≠ Cyrillic с (U+0441)
   - expandKeyboardVariants for mixed inputs
   - Edge cases: empty, single char, mixed case, numbers

4. InternalLinkPicker type tabs → filters (not search modes):
   - Store rawResults (all) + filtered results by activeType
   - Switching type tab no longer clears query or triggers new search
   - Just filters existing rawResults by selected type
   - Shows 'Нет результатов для этого типа' when filtered empty

5. Both GlobalSearch and InternalLinkPicker use same expandKeyboardVariants()

All tests PASS, full build OK.
This commit is contained in:
mirivlad 2026-06-15 11:37:35 +08:00
parent 700e4dae5b
commit 077d25a269
3 changed files with 170 additions and 23 deletions

View File

@ -17,7 +17,8 @@
let activeType = 'note'; let activeType = 'note';
let query = ''; let query = '';
let results = []; let rawResults = []; // all search results (unfiltered)
let results = []; // filtered by activeType
let selectedIndex = 0; let selectedIndex = 0;
let loading = false; let loading = false;
let error = ''; let error = '';
@ -31,8 +32,15 @@
return 'case'; return 'case';
} }
// Filter rawResults by activeType into displayed results
function applyTypeFilter() {
results = rawResults.filter(n => nodeTypeToFilter(n.type) === activeType);
selectedIndex = 0;
}
async function search() { async function search() {
if (!query.trim() || query.trim().length < 2) { if (!query.trim() || query.trim().length < 2) {
rawResults = [];
results = []; results = [];
return; return;
} }
@ -41,7 +49,6 @@
try { try {
// Expand query with keyboard layout variants for tolerant search // Expand query with keyboard layout variants for tolerant search
const variants = expandKeyboardVariants(query.trim()); const variants = expandKeyboardVariants(query.trim());
// Deduplicate: skip variants identical to the original query's lowercase
const seen = new Set(); const seen = new Set();
const queries = []; const queries = [];
for (const v of variants) { for (const v of variants) {
@ -63,11 +70,11 @@
} }
} }
} }
// Filter by active type rawResults = Array.from(merged.values());
results = Array.from(merged.values()).filter(n => nodeTypeToFilter(n.type) === activeType); applyTypeFilter();
selectedIndex = 0;
} catch (e) { } catch (e) {
error = String(e); error = String(e);
rawResults = [];
results = []; results = [];
} finally { } finally {
loading = false; loading = false;
@ -104,9 +111,7 @@
function handleTypeChange(typeId) { function handleTypeChange(typeId) {
activeType = typeId; activeType = typeId;
query = ''; applyTypeFilter();
results = [];
error = '';
if (inputRef) inputRef.focus(); if (inputRef) inputRef.focus();
} }
@ -159,8 +164,10 @@
<div class="picker-loading">Загрузка...</div> <div class="picker-loading">Загрузка...</div>
{:else if error} {:else if error}
<div class="picker-error">{error}</div> <div class="picker-error">{error}</div>
{:else if query.trim().length >= 2 && results.length === 0} {:else if query.trim().length >= 2 && results.length === 0 && !loading}
<div class="picker-empty">Ничего не найдено</div> <div class="picker-empty">
{rawResults.length > 0 ? 'Нет результатов для этого типа' : 'Ничего не найдено'}
</div>
{:else} {:else}
{#each results as item, i} {#each results as item, i}
<button <button

View File

@ -0,0 +1,126 @@
/**
* Unit tests for keyboardLayout.ts
* Run: node frontend/src/lib/util/keyboardLayout.test.js
*
* Tests exact Unicode character mappings for RU/EN QWERTY keyboard layout swap.
* Critical: Latin 'c' Cyrillic 'с' (different Unicode codepoints).
*/
// Inline the module logic to avoid Svelte/TS build dependencies
const RU_TO_EN = {
й:'q', ц:'w', у:'e', к:'r', е:'t', н:'y', г:'u', ш:'i', щ:'o', з:'p', х:'[', ъ:']',
ф:'a', ы:'s', в:'d', а:'f', п:'g', р:'h', о:'j', л:'k', д:'l', ж:';', э:"'",
я:'z', ч:'x', с:'c', м:'v', и:'b', т:'n', ь:'m', ю:',', ё:'`',
Й:'Q', Ц:'W', У:'E', К:'R', Е:'T', Н:'Y', Г:'U', Ш:'I', Щ:'O', З:'P', Х:'{', Ъ:'}',
Ф:'A', Ы:'S', В:'D', А:'F', П:'G', Р:'H', О:'J', Л:'K', Д:'L', Ж:':', Э:'"',
Я:'Z', Ч:'X', С:'C', М:'V', И:'B', Т:'N', Ь:'M', Ю:'<', Ё:'~',
};
const EN_TO_RU = {};
for (const [ru, en] of Object.entries(RU_TO_EN)) EN_TO_RU[en] = ru;
function swap(s, map) { let o=''; for(const c of s) o+=map[c]??c; return o; }
function ruToEn(s) { return swap(s, RU_TO_EN); }
function enToRu(s) { return swap(s, EN_TO_RU); }
function expandKeyboardVariants(query) {
const v = new Set([query]);
const en = ruToEn(query); if(en!==query) v.add(en);
const ru = enToRu(query); if(ru!==query) v.add(ru);
for(const x of [...v]) v.add(x.toLowerCase());
return [...v];
}
// ─── Test runner ─────────────────────────────────────────────
let passed = 0, failed = 0;
function assert(label, actual, expected) {
if (actual === expected) {
passed++;
} else {
failed++;
console.error(`FAIL: ${label}`);
console.error(` actual: ${JSON.stringify(actual)}`);
console.error(` expected: ${JSON.stringify(expected)}`);
}
}
function assertArray(label, actual, expected) {
const a = JSON.stringify(actual.sort());
const e = JSON.stringify(expected.sort());
if (a === e) {
passed++;
} else {
failed++;
console.error(`FAIL: ${label}`);
console.error(` actual: ${a}`);
console.error(` expected: ${e}`);
}
}
// ─── EN → RU: user typed Latin chars on Russian layout ──────
console.log('=== EN → RU (latin on RU layout) ===');
assert('dthcnfr → верстак', enToRu('dthcnfr'), 'верстак');
assert('ghbdtn → привет', enToRu('ghbdtn'), 'привет');
assert('ntcn → тест', enToRu('ntcn'), 'тест');
// Verify each character mapping individually
assert('d → в', enToRu('d'), 'в');
assert('t → е', enToRu('t'), 'е');
assert('h → р', enToRu('h'), 'р');
assert('c → с', enToRu('c'), 'с'); // Latin c → Cyrillic с (NOT ч!)
assert('n → т', enToRu('n'), 'т');
assert('f → а', enToRu('f'), 'а');
assert('r → к', enToRu('r'), 'к');
// ─── RU → EN: user typed Cyrillic chars on English layout ───
console.log('=== RU → EN (cyrillic on EN layout) ===');
assert('руддщ → hello', ruToEn('руддщ'), 'hello');
assert('цщкдв → world', ruToEn('цщкдв'), 'world');
assert('ышеш → siti', ruToEn('ышеш'), 'siti');
// Verify each character mapping individually
assert('р → h', ruToEn('р'), 'h');
assert('у → e', ruToEn('у'), 'e');
assert('д → l', ruToEn('д'), 'l');
assert('щ → o', ruToEn('щ'), 'o');
assert('ц → w', ruToEn('ц'), 'w');
assert('к → r', ruToEn('к'), 'r');
assert('в → d', ruToEn('в'), 'd');
// ─── Critical: Latin c vs Cyrillic с ────────────────────────
console.log('=== Unicode safety: Latin c ≠ Cyrillic с ===');
const latinC = 'c';
const cyrillicC = 'с'; // U+0441
assert('Latin c !== Cyrillic с', latinC === cyrillicC, false);
assert('Latin c charCode', latinC.charCodeAt(0), 99);
assert('Cyrillic с charCode', cyrillicC.charCodeAt(0), 1089); // 0x0441
assert('enToRu(c) → с (Cyrillic)', enToRu('c'), 'с');
assert('enToRu(c) is Cyrillic с', enToRu('c').charCodeAt(0), 1089);
assert('ruToEn(с) → c (Latin)', ruToEn('с'), 'c');
assert('ruToEn(с) is Latin c', ruToEn('с').charCodeAt(0), 99);
// ─── expandKeyboardVariants ─────────────────────────────────
console.log('=== expandKeyboardVariants ===');
assertArray('dthcnfr variants', expandKeyboardVariants('dthcnfr'), ['dthcnfr', 'верстак']);
assertArray('руддщ variants', expandKeyboardVariants('руддщ'), ['руддщ', 'hello']);
assertArray('верстак variants', expandKeyboardVariants('верстак'), ['верстак', 'dthcnfr']);
assertArray('hello variants', expandKeyboardVariants('hello'), ['hello', 'руддщ']);
// Screen contains chars that map to RU: S→Ы, c→с, r→к, e→у, n→т
assertArray('Screen variants (EN+RU swap)', expandKeyboardVariants('Screen'), ['Screen', 'screen', 'Ыскуут', 'ыскуут']);
// ─── Edge cases ─────────────────────────────────────────────
console.log('=== Edge cases ===');
assert('empty string', enToRu(''), '');
assert('single char d', enToRu('d'), 'в');
assert('already correct RU', ruToEn('верстак'), 'dthcnfr');
assert('already correct EN', enToRu('hello'), 'руддщ');
assert('mixed case EN: DthCnFr → ВерСтАк', enToRu('DthCnFr'), 'ВерСтАк');
assert('numbers pass through', enToRu('123'), '123');
assert('spaces pass through', enToRu('dth cnfr'), 'вер стак');
// ─── Summary ─────────────────────────────────────────────────
console.log();
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failed > 0) {
process.exit(1);
} else {
console.log('ALL TESTS PASSED');
}

View File

@ -5,25 +5,39 @@
* are silently mapped to a different letter. This module converts * are silently mapped to a different letter. This module converts
* such "garbled" text back to what the user intended. * such "garbled" text back to what the user intended.
* *
* RU layout mapping (what you get when you type RU keys with EN layout active): * Standard QWERTY / ЙЦУКЕН positional mapping:
* йq, цw, уe, кr, еt, нy, гu, шi, щo, зp, х[, ъ] *
* фa, ыs, вd, аf, пg, рh, оj, лk, дl, ж;, э' * EN: q w e r t y u i o p [ ]
* яz, чc, сv, мb, иn, тm, ж,, ю. * РУ: й ц у к е н г ш щ з х ъ
* Uppercase variants behave identically (just swapped case). *
* EN: a s d f g h j k l ; '
* РУ: ф ы в а п р о л д ж э
*
* EN: z x c v b n m , . /
* РУ: я ч с м и т ь ю .
*
* Uppercase mirrors the same positions.
*/ */
// Maps: wrong-layout char → intended char (both directions) // RU → EN: what you get when you press RU keys with EN layout active
const RU_TO_EN: Record<string, string> = { const RU_TO_EN: Record<string, string> = {
// Row 1 (top)
й: 'q', ц: 'w', у: 'e', к: 'r', е: 't', н: 'y', г: 'u', ш: 'i', щ: 'o', з: 'p', й: 'q', ц: 'w', у: 'e', к: 'r', е: 't', н: 'y', г: 'u', ш: 'i', щ: 'o', з: 'p',
х: '[', ъ: ']', ф: 'a', ы: 's', в: 'd', а: 'f', п: 'g', р: 'h', о: 'j', л: 'k', х: '[', ъ: ']',
д: 'l', ж: ';', э: "'", я: 'z', ч: 'c', с: 'v', м: 'b', и: 'n', т: 'm', ь: ',', // Row 2 (home)
ю: '.', ё: '`', ф: 'a', ы: 's', в: 'd', а: 'f', п: 'g', р: 'h', о: 'j', л: 'k', д: 'l', ж: ';',
э: "'",
// Row 3 (bottom)
я: 'z', ч: 'x', с: 'c', м: 'v', и: 'b', т: 'n', ь: 'm', ю: ',', ё: '`',
// Uppercase
Й: 'Q', Ц: 'W', У: 'E', К: 'R', Е: 'T', Н: 'Y', Г: 'U', Ш: 'I', Щ: 'O', З: 'P', Й: 'Q', Ц: 'W', У: 'E', К: 'R', Е: 'T', Н: 'Y', Г: 'U', Ш: 'I', Щ: 'O', З: 'P',
Х: '{', Ъ: '}', Ф: 'A', Ы: 'S', В: 'D', А: 'F', П: 'G', Р: 'H', О: 'J', Л: 'K', Х: '{', Ъ: '}',
Д: 'L', Ж: ':', Э: '"', Я: 'Z', Ч: 'C', С: 'V', М: 'B', И: 'N', Т: 'M', Ь: '<', Ф: 'A', Ы: 'S', В: 'D', А: 'F', П: 'G', Р: 'H', О: 'J', Л: 'K', Д: 'L', Ж: ':',
Ю: '>', Ё: '~', Э: '"',
Я: 'Z', Ч: 'X', С: 'C', М: 'V', И: 'B', Т: 'N', Ь: 'M', Ю: '<', Ё: '~',
}; };
// EN → RU: reverse mapping, built automatically
const EN_TO_RU: Record<string, string> = {}; const EN_TO_RU: Record<string, string> = {};
for (const [ru, en] of Object.entries(RU_TO_EN)) { for (const [ru, en] of Object.entries(RU_TO_EN)) {
EN_TO_RU[en] = ru; EN_TO_RU[en] = ru;
@ -52,7 +66,7 @@ export function enToRu(s: string): string {
* Produce all search-query variants for keyboard-layout-tolerant search. * Produce all search-query variants for keyboard-layout-tolerant search.
* *
* Returns a deduplicated array that always contains the original query, * Returns a deduplicated array that always contains the original query,
* and up to two layout-swapped variants (ENRU). * and up to two layout-swapped variants (ENRU), plus lowercased versions.
*/ */
export function expandKeyboardVariants(query: string): string[] { export function expandKeyboardVariants(query: string): string[] {
const variants = new Set<string>(); const variants = new Set<string>();