diff --git a/test/utils/search.spec.ts b/test/utils/search.spec.ts index 209c6a0c..7f6e3d05 100644 --- a/test/utils/search.spec.ts +++ b/test/utils/search.spec.ts @@ -419,6 +419,33 @@ describe('highlight', () => { expect(highlight({ label: value, matches: [{ key: 'label', value, indices: [[2, 2]] }] }, 'a', 'label')).toBe(value) }) + it('does not treat a malformed region as degenerate just because its first two bounds match', () => { + // The skip is guarded on `region.length === 2` as well as on the bounds + // being equal, and that half had no fixture: every region the suite builds + // is a real 2-tuple, so dropping the length check changed nothing (#411). + // + // Reachable without any cast from application code: `postFilter` is typed + // `(term, items: T[]) => T[]`, and `CommandPaletteItem` carries a + // `[key: string]: any` index signature, so `items[i].matches` is `any` + // inside that callback. `unknown` appears here only because this test + // calls `highlight()` directly, against its stricter signature. + // + // Worth being exact about what the guard is and is not. It does not + // sanitize: nothing downstream reads past `region[1]`, so a three-element + // region slices identically either way. It decides one thing only — + // whether a malformed region gets the one-character skip. It should not, + // because a longer array is out of contract rather than degenerate, and + // silently treating it as a single character drops a highlight the caller + // asked for. That is a smaller claim than the integer filter above, which + // removes a region whose `NaN` would otherwise reach the cursor and repeat + // the whole value. + const value = 'alpha beta' + const malformed = [[2, 2, 999]] as unknown as [number, number][] + + expect(highlight({ label: value, matches: [{ key: 'label', value, indices: malformed }] }, 'a', 'label')) + .toBe('alpha beta') + }) + it.each(ASTRAL)('wraps %s wholly, wherever the region boundary falls', (astral) => { const value = `${astral.repeat(3)}tail` @@ -542,6 +569,40 @@ describe('highlight', () => { expect(result).toBe('a\r\nb') }) + it('joins CR and LF in that order only, never either one alone', () => { + // Every pair here is one the segmenter breaks, so a correct + // implementation snaps none of them. That is the point: the case above + // holds the only pair the carve-out *does* join, and on a real CR+LF + // `previous === CR && current === LF` and `previous === CR || current + // === LF` agree. So the conjunction went unpinned while both its + // constants were guarded (#410). + // + // Two shapes of over-joining, and the second is why one fixture is not + // enough: `||` fires when either side matches, `(CR|LF) && (CR|LF)` fires + // when both sides are line breaks in any order. The first is caught by a + // lone CR or LF beside a letter; the second survives that and needs a + // reversed or doubled pair. + // + // Under-joining — failing to keep a real CR+LF together — is what the + // case above pins. These add nothing there, deliberately; do not read + // them as covering it. + // + // Every fixture sits entirely below the fast-path floor, which is what + // routes it into the carve-out rather than to the segmenter. + const pairs: [string, string][] = [ + ['a\rXb', 'a\rXb'], + ['aX\nb', 'aX\nb'], + ['a\n\rb', 'a\n\rb'], + ['a\r\rb', 'a\r\rb'], + ['a\n\nb', 'a\n\nb'] + ] + + for (const [value, expected] of pairs) { + expect(highlight({ label: value, matches: [{ key: 'label', value, indices: [[0, 1]] }] }, 'x', 'label')) + .toBe(expected) + } + }) + it('leaves a bare run of emoji modifiers whole — UAX #29 makes it one cluster', () => { // Not a realistic label, but it pins the behaviour that made the earlier // fixture wrong: 20 bare U+1F3FF are one character, not twenty, so the