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:
parent
700e4dae5b
commit
077d25a269
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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 (EN↔RU).
|
* and up to two layout-swapped variants (EN↔RU), 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>();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue