diff --git a/app/client/lib/ACIndex.ts b/app/client/lib/ACIndex.ts index 44eb83f1..d5e2293d 100644 --- a/app/client/lib/ACIndex.ts +++ b/app/client/lib/ACIndex.ts @@ -11,6 +11,7 @@ import {localeCompare, nativeCompare, sortedIndex} from 'app/common/gutil'; import {DomContents} from 'grainjs'; import escapeRegExp = require("lodash/escapeRegExp"); import deburr = require("lodash/deburr"); +import split = require("lodash/split"); export interface ACItem { // This should be a trimmed lowercase version of the item's text. It may be an accessor. @@ -243,11 +244,17 @@ function highlightMatches(searchWords: string[], text: string): string[] { for (let i = 0; i < textParts.length; i += 2) { const word = textParts[i]; const separator = textParts[i + 1] || ''; - const prefixLen = findLongestPrefixLen(word.toLowerCase(), searchWords); + // deburr (remove diacritics) was used to produce searchWords, so `word` needs to match that. + const prefixLen = findLongestPrefixLen(deburr(word).toLowerCase(), searchWords); if (prefixLen === 0) { outputs[outputs.length - 1] += word + separator; } else { - outputs.push(word.slice(0, prefixLen), word.slice(prefixLen) + separator); + // Split into unicode 'characters' that keep diacritics combined + const chars = split(word, ''); + outputs.push( + chars.slice(0, prefixLen).join(''), + chars.slice(prefixLen).join('') + separator + ); } } return outputs; diff --git a/test/client/lib/ACIndex.ts b/test/client/lib/ACIndex.ts index 0221cf57..49a931ab 100644 --- a/test/client/lib/ACIndex.ts +++ b/test/client/lib/ACIndex.ts @@ -281,9 +281,18 @@ describe('ACIndex', function() { it('should highlight multi-byte unicode', function() { const acIndex = new ACIndexImpl(['Lorem ipsum 𝌆 dolor sit ameͨ͆t.', "mañana", "Москва"].map(makeItem), 3); - const acResult: ACResults = acIndex.search("mañ моск am"); + let acResult: ACResults = acIndex.search("mañ моск am"); assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)), [["", "Моск", "ва"], ["", "mañ", "ana"], ["Lorem ipsum 𝌆 dolor sit ", "am", "eͨ͆t."]]); + + const original = "ameͨ͆"; + assert.equal(original.length, 5); + for (let end = 3; end <= original.length; end++) { + const text = original.slice(0, end); // i.e. test: ame, ameͨ, ameͨ͆ (hard to see the difference in some editors) + acResult = acIndex.search(text); + assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text))[0], + ["Lorem ipsum 𝌆 dolor sit ", original, "t."]); + } }); it('should match a brute-force scoring implementation', function() { diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index d8e8b5cd..82d1e619 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -456,32 +456,34 @@ export async function rightClick(cell: WebElement) { * grid view or field name for detail view. */ export async function getCursorPosition() { - const section = await driver.findWait('.active_section', 4000); - const cursor = await section.findWait('.active_cursor', 1000); - // Query assuming the cursor is in a GridView and a DetailView, then use whichever query data - // works out. - const [colIndex, rowIndex, rowNum, colName] = await Promise.all([ - catchNoSuchElem(() => cursor.findClosest('.field').index()), - catchNoSuchElem(() => cursor.findClosest('.gridview_row').index()), - catchNoSuchElem(() => cursor.findClosest('.g_record_detail').find('.detail_row_num').getText()), - catchNoSuchElem(() => cursor.findClosest('.g_record_detail_el') - .find('.g_record_detail_label').getText()) - ]); - if (rowNum && colName) { - // This must be a detail view, and we just got the info we need. - return {rowNum: parseInt(rowNum, 10), col: colName}; - } else { - // We might be on a single card record - const counter = await section.findAll(".grist-single-record__menu__count"); - if (counter.length) { - const cardRow = (await counter[0].getText()).split(' OF ')[0]; - return { rowNum : parseInt(cardRow), col: colName }; + return await retryOnStale(async () => { + const section = await driver.findWait('.active_section', 4000); + const cursor = await section.findWait('.active_cursor', 1000); + // Query assuming the cursor is in a GridView and a DetailView, then use whichever query data + // works out. + const [colIndex, rowIndex, rowNum, colName] = await Promise.all([ + catchNoSuchElem(() => cursor.findClosest('.field').index()), + catchNoSuchElem(() => cursor.findClosest('.gridview_row').index()), + catchNoSuchElem(() => cursor.findClosest('.g_record_detail').find('.detail_row_num').getText()), + catchNoSuchElem(() => cursor.findClosest('.g_record_detail_el') + .find('.g_record_detail_label').getText()) + ]); + if (rowNum && colName) { + // This must be a detail view, and we just got the info we need. + return {rowNum: parseInt(rowNum, 10), col: colName}; + } else { + // We might be on a single card record + const counter = await section.findAll(".grist-single-record__menu__count"); + if (counter.length) { + const cardRow = (await counter[0].getText()).split(' OF ')[0]; + return { rowNum : parseInt(cardRow), col: colName }; + } + // Otherwise, it's a grid view, and we need to use indices to look up the info. + const gridRows = await section.findAll('.gridview_data_row_num'); + const gridRowNum = await gridRows[rowIndex].getText(); + return { rowNum: parseInt(gridRowNum, 10), col: colIndex }; } - // Otherwise, it's a grid view, and we need to use indices to look up the info. - const gridRows = await section.findAll('.gridview_data_row_num'); - const gridRowNum = await gridRows[rowIndex].getText(); - return { rowNum: parseInt(gridRowNum, 10), col: colIndex }; - } + }); } /** @@ -496,6 +498,15 @@ async function catchNoSuchElem(query: () => any) { } } +async function retryOnStale(query: () => Promise): Promise { + try { + return await query(); + } catch (err) { + if (err instanceof error.StaleElementReferenceError) { return await query(); } + throw err; + } +} + /** * Type keys in the currently active cell, then hit Enter to save, and wait for the server.