diff --git a/README.md b/README.md index 8dc7786..6958296 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ It adds a native experience for Simulink file types — a **Simulink Data Explor - **Live two-way sync (textual `.sldd`)** — because a textual (JSON) `.sldd` is backed by its JSON text document, edits in the table and edits in the JSON text editor update each other instantly, and there is a single shared undo history across both views. - **Properties panel** — a selection-following webview that shows the full properties of the entry selected in the table. It lives in its own view container and can be docked in the secondary sidebar. - **Variable Editor for matrix values** — a value with two or more dimensions stays a short descriptor in its cell (`<2x3x2 double>`) with a grid glyph beside it; clicking the glyph opens the whole array in a floating spreadsheet-style grid, laid out the way MATLAB displays it. Anything above rank 2 gets a `(:,:,k)` page selector to step through its trailing dimensions. Available from both the table and the Properties panel; view-only. -- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`. Scope a condition to one column by naming that column's header — `Name:gain`, `"Data Type"=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`; quote anything containing a space. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. +- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`, and the `×` at the right end of the box clears the whole search. Scope a condition to one column by naming that column's header exactly as the header spells it — `Name:gain`, `Data Type=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`, and spaces around one are ignored, so `Data Type: double` and `Value > 5` each read as a single condition. Quote a *value* that contains a space (`Name:"my var"`); a header's own space needs no quoting. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. > Quoting now only groups words: `value:"5"` matches any value *containing* 5. To ask for exactly 5, use `Value=5`. - **Usage column, both directions** — a dictionary entry, MAT variable, or model-workspace variable lists the blocks that read it, qualified by the model they are in; a block's row shows which of its parameters resolved where (`Gain=Kp (params.sldd)`). Either link navigates to the other side. Resolution follows MATLAB: the mask parameters of the masked subsystems a block sits inside come first, then the model workspace, then the linked data dictionary and any dictionary it references, then linked MAT-files — so a `Gain = g1` inside a mask reads as the mask's own `g1` (`Gain=g1 (MulAdd)`), and the value that mask parameter was given is credited to the masked block. - **Block paths in the table** — where a model's blocks share a name, each row's Name shows the subsystem it lives in (`Gain (Controller)`), and hovering a block in the Usage column shows that block's full path. diff --git a/src/webview/components/dex-filter-bar.ts b/src/webview/components/dex-filter-bar.ts index c8393de..087e356 100644 --- a/src/webview/components/dex-filter-bar.ts +++ b/src/webview/components/dex-filter-bar.ts @@ -36,7 +36,7 @@ export class DexFilterBar extends LitElement { as conditions accumulate, and the row under the caret is the one that matters. */ max-height: 52px; overflow-y: auto; - padding: 2px 6px; + padding: 1px 4px; box-sizing: border-box; border: 1px solid var(--dex-border-color, #d0d0d0); border-radius: 3px; @@ -56,8 +56,8 @@ export class DexFilterBar extends LitElement { align-items: baseline; gap: 3px; max-width: 100%; - padding: 1px 2px 1px 6px; - border-radius: 9px; + padding: 1px 2px 1px 5px; + border-radius: 3px; background: var(--dex-bg-badge, rgba(128, 128, 128, 0.18)); white-space: nowrap; } @@ -98,7 +98,7 @@ export class DexFilterBar extends LitElement { height: 14px; padding: 0; border: none; - border-radius: 7px; + border-radius: 2px; background: none; color: var(--dex-color-text-secondary, #666); font: inherit; @@ -124,6 +124,31 @@ export class DexFilterBar extends LitElement { font: inherit; outline: none; } + /* Clear everything. Sized and shaped like a chip's own × because it does the + same kind of thing, one row up: this removes the whole query, that one + condition. Sits after the input, which flexes, so it lands at the right end. */ + .clear-all { + flex: 0 0 auto; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 2px; + background: none; + color: var(--dex-color-text-secondary, #666); + font: inherit; + font-size: 13px; + line-height: 1; + cursor: pointer; + outline: none; + } + .clear-all:hover { + background: var(--dex-bg-hover, #e8e8e8); + color: var(--dex-color-text, inherit); + } + .clear-all:focus-visible { + outline: 1px solid var(--dex-color-accent, #0078d4); + } /* Without this, Enter-to-filter reads as a search box that stopped working. */ .pending-hint { flex: 0 0 auto; @@ -146,7 +171,8 @@ export class DexFilterBar extends LitElement { .chip.warning { border: 2px solid Highlight !important; } - .chip-remove:focus-visible { + .chip-remove:focus-visible, + .clear-all:focus-visible { outline: 2px solid Highlight !important; } } @@ -182,6 +208,19 @@ export class DexFilterBar extends LitElement { this._propose(this.text ? `${this.text} ${tail}` : tail); } + // Everything at once, which is the one thing Escape cannot do in a single press: + // it clears the tail first and the filter second, deliberately, so a half-typed + // word can be abandoned without losing an applied search. A × is aimed, not + // typed, so it means all of it. Proposing only when something IS applied keeps + // "abandon what I was typing" a local edit that no consumer hears about. + private _clearAll(): void { + const hadFilter = this.text !== ''; + this._tail = ''; + if (hadFilter) this._propose(''); + // The next thing the user does is type, so leave the caret where they left it. + this._input?.focus(); + } + private _removeAt(index: number): void { const token = this.tokens[index]; if (token) this._propose(removeToken(this.text, token)); @@ -272,6 +311,20 @@ export class DexFilterBar extends LitElement { @blur=${() => this.classList.remove('focused')} /> ${this._tail.trim() ? html`⏎ to filter` : nothing} + ${this.text || this._tail + ? html`` + : nothing} `; } } diff --git a/src/webview/rowFilter.ts b/src/webview/rowFilter.ts index fdd49c6..1e6d634 100644 --- a/src/webview/rowFilter.ts +++ b/src/webview/rowFilter.ts @@ -130,6 +130,127 @@ function unquote(s: string): string { const OP_CHARS = new Set([':', '=', '<', '>', '!', '~']); +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Whitespace splits tokens, which made a condition unwritable the way a person +// writes one: `Data Type: double` split into three pieces, of which `Data` and +// `double` became stray words ANDed onto the query. So whitespace inside a +// condition — around the operator, and between the words of a multi-word header +// label — is insignificant. The one thing holding that open: it applies ONLY once +// the prefix has resolved to a real column of THIS table. `a > b` names no column, +// so it stays three ordinary words to search for. +// +// One sticky alternation of every prefix this table understands: each header label +// (its spaces relaxed to `\s+`, since the label came off a header and a double +// space there is a typo rather than a different question) and each legacy alias. +// Longest first, or `Last Modified By` matches as `Last Modified` and strands `By`. +function buildPrefixRe(labelMap: Map): RegExp { + const names = [...labelMap.keys(), ...SUBSTRING_FILTER_COLUMNS.keys(), 'value']; + const alts = [...new Set(names)] + .sort((a, b) => b.length - a.length) + .map((l) => l.trim().split(/\s+/).map(escapeRe).join('\\s+')); + return new RegExp(`(?:${alts.join('|')})`, 'iy'); +} + +function skipWs(text: string, pos: number): number { + let i = pos; + while (i < text.length && /\s/.test(text[i])) i++; + return i; +} + +// Where a value ends: the next whitespace outside quotes, so `Data Type: "fixed +// point"` keeps its value whole. +function valueEnd(text: string, pos: number): number { + let i = pos; + while (i < text.length) { + const ch = text[i]; + if (/\s/.test(ch)) break; + if (ch === '"') { + // Only a BALANCED pair groups. An unclosed quote ends the value here, which + // is what the chunk tokenizer does with one too — the two have to agree, or + // `value:"5` means one thing when this path reads it and another when the + // chunk path does. + const close = text.indexOf('"', i + 1); + if (close === -1) break; + i = close + 1; + continue; + } + i++; + } + return i; +} + +// Reads an operator at `pos`, or null when there is none. A lone `!` or `~` is not +// one — `Name!abc` is text — and that is what stops this from claiming every +// punctuation mark as syntax. +function readOperator(text: string, pos: number): { op: FilterOp; end: number } | null { + const two = text.slice(pos, pos + 2); + if (two === '!=' || two === '~=') return { op: '!=', end: pos + 2 }; + if (two === '>=' || two === '<=') return { op: two as FilterOp, end: pos + 2 }; + const ch = text[pos]; + if (ch === '=') return { op: '=', end: pos + 1 }; + if (ch === '>' || ch === '<') return { op: ch as FilterOp, end: pos + 1 }; + if (ch !== ':') return null; + // `:` is contains — unless an operator follows it, which is the legacy + // `value:>10` spelling, now accepted on every column and across a space. To + // search for the literal text `>10`, quote it: `Description:">10"`. + const after = skipWs(text, pos + 1); + const legacy = /^(>=|<=|!=|~=|=|>|<)/.exec(text.slice(after)); + if (legacy) { + const g = legacy[1]; + return { op: g === '~=' ? '!=' : (g as FilterOp), end: after + g.length }; + } + return { op: 'contains', end: pos + 1 }; +} + +interface ConditionHit { + column: string; + op: FilterOp; + value: string; + /** End of the whole condition in the source text — the chip's span ends here. */ + end: number; +} + +// Reads ` ` at `pos`, with optional whitespace at each seam. +// Null unless the prefix resolves to a column of this table AND an operator +// follows, which is what keeps ordinary text out. +function readCondition( + text: string, + pos: number, + prefixRe: RegExp, + labelMap: Map, + vocab: ColumnVocabulary | undefined, + crossWhitespaceForValue: boolean, +): ConditionHit | null { + prefixRe.lastIndex = pos; + const prefix = prefixRe.exec(text); + if (!prefix) return null; + const column = resolveColumn(prefix[0].toLowerCase().replace(/\s+/g, ' '), labelMap, vocab); + if (!column) return null; + + const opHit = readOperator(text, skipWs(text, pos + prefix[0].length)); + if (!opHit) return null; + + let start = opHit.end; + let end = valueEnd(text, start); + if (end === start) { + // Nothing flush against the operator. The value is the next word — unless that + // word is itself a condition, in which case this one has an empty value and + // means it: `Unit= Value>5` asks for entries with no Unit whose Value is over 5, + // and must not read as `Unit=Value>5`. + const next = skipWs(text, start); + const nextIsCondition = + next < text.length && readCondition(text, next, prefixRe, labelMap, vocab, false) !== null; + if (crossWhitespaceForValue && next > start && !nextIsCondition) { + start = next; + end = valueEnd(text, next); + } + } + return { column, op: opHit.op, value: unquote(text.slice(start, end)), end }; +} + interface OpHit { /** Where the prefix ends, i.e. the operator's first character. */ prefixEnd: number; @@ -219,12 +340,66 @@ export function parseFilterExpression( if (term) terms.push({ column, text: term }); }; - for (const m of text.matchAll(/(?:[^\s"]+|"[^"]*")+/g)) { - const raw = m[0]; - const start = m.index; - const end = start + raw.length; + // One column-scoped condition, however it was spelled. Both paths below end here, + // so what `Data Type: double` and `"Data Type":double` mean cannot drift apart. + const emitColumn = (raw: string, start: number, end: number, column: string, op: FilterOp, value: string): void => { + const label = vocabulary?.labels?.[column] ?? column; + const token: FilterToken = { raw, start, end, column, columnLabel: label, op, value }; + tokens.push(token); + + if (op === 'contains') { + const lower = value.toLowerCase(); + addTerm(column, lower); + predicates.push((row) => getCellText(row, column).toLowerCase().includes(lower)); + } else if (op === '=' || op === '!=') { + // `=` highlights (its value IS in the cell); `!=` cannot — nothing matched. + if (op === '=') addTerm(column, value.toLowerCase()); + const want = op === '='; + predicates.push((row) => valuesEqual(getCellText(row, column), value) === want); + } else { + // A bound that is not a number contributes NO predicate — a half-typed + // `Value>` must not blank the table. Surfaced on the chip instead. + const bound = parseFloat(value); + if (!Number.isFinite(bound)) { + token.warning = 'non-numeric-bound'; + return; + } + predicates.push((row) => { + const n = parseFloat(getCellText(row, column)); + if (!Number.isFinite(n)) return false; + return op === '>' ? n > bound : op === '<' ? n < bound : op === '>=' ? n >= bound : n <= bound; + }); + } + }; + + const prefixRe = buildPrefixRe(labelMap); + // Chunks up front rather than a streaming matchAll: a condition may span several + // of them, so this loop sometimes has to swallow the ones that follow. + const chunks = [...text.matchAll(/(?:[^\s"]+|"[^"]*")+/g)].map((m) => ({ + raw: m[0], + start: m.index, + end: m.index + m[0].length, + })); + + for (let ci = 0; ci < chunks.length; ci++) { + const { start } = chunks[ci]; + + // A condition first, reading across whitespace. Its span ends where the + // condition ends, so the chip's `×` removes every piece of it and nothing else. + const cond = readCondition(text, start, prefixRe, labelMap, vocabulary, true); + if (cond) { + emitColumn(text.slice(start, cond.end), start, cond.end, cond.column, cond.op, cond.value); + while (ci + 1 < chunks.length && chunks[ci + 1].start < cond.end) ci++; + continue; + } + + // Otherwise the chunk stands alone. Still needed for the quoted prefix form + // (`"Data Type":double`, which no bare label matches) and for a prefix that + // looks like a column but names none — the `unknown-column` warning. + const { raw, end } = chunks[ci]; const hit = findOperator(raw); - const column = hit ? resolveColumn(unquote(raw.slice(0, hit.prefixEnd)).toLowerCase(), labelMap, vocabulary) : null; + const prefix = hit ? unquote(raw.slice(0, hit.prefixEnd)).toLowerCase().replace(/\s+/g, ' ') : ''; + const column = hit ? resolveColumn(prefix, labelMap, vocabulary) : null; // No operator, or a prefix that names no column: the whole token is text, // colon included. `constructor:` is ordinary text a user may well look for. @@ -246,35 +421,7 @@ export function parseFilterExpression( continue; } - const value = unquote(raw.slice(hit.valueStart)); - const label = vocabulary?.labels?.[column] ?? column; - const token: FilterToken = { raw, start, end, column, columnLabel: label, op: hit.op, value }; - tokens.push(token); - - if (hit.op === 'contains') { - const lower = value.toLowerCase(); - addTerm(column, lower); - predicates.push((row) => getCellText(row, column).toLowerCase().includes(lower)); - } else if (hit.op === '=' || hit.op === '!=') { - // `=` highlights (its value IS in the cell); `!=` cannot — nothing matched. - if (hit.op === '=') addTerm(column, value.toLowerCase()); - const want = hit.op === '='; - predicates.push((row) => valuesEqual(getCellText(row, column), value) === want); - } else { - // A bound that is not a number contributes NO predicate — a half-typed - // `Value>` must not blank the table. Surfaced on the chip instead. - const bound = parseFloat(value); - if (!Number.isFinite(bound)) { - token.warning = 'non-numeric-bound'; - continue; - } - const op = hit.op; - predicates.push((row) => { - const n = parseFloat(getCellText(row, column)); - if (!Number.isFinite(n)) return false; - return op === '>' ? n > bound : op === '<' ? n < bound : op === '>=' ? n >= bound : n <= bound; - }); - } + emitColumn(raw, start, end, column, hit.op, unquote(raw.slice(hit.valueStart))); } return { tokens, predicates, terms }; diff --git a/test/filterBar.test.ts b/test/filterBar.test.ts index 6a3e5b3..2636ad1 100644 --- a/test/filterBar.test.ts +++ b/test/filterBar.test.ts @@ -107,6 +107,64 @@ describe('dex-filter-bar', () => { }); }); +describe('dex-filter-bar clear-all', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + const clearAll = (el: DexFilterBar) => el.shadowRoot!.querySelector('.clear-all') as HTMLButtonElement | null; + + it('offers nothing to clear when there is nothing to clear', async () => { + // A × on an empty box is a control that does nothing, sitting where the eye + // looks for one that does. + expect(clearAll(await bar())).toBeNull(); + }); + + it('appears once anything is in the box, applied or still being typed', async () => { + expect(clearAll(await bar('abc'))).not.toBeNull(); + const el = await bar(); + await type(el, 'ga'); + expect(clearAll(el)).not.toBeNull(); + }); + + it('clears the applied filter and the pending tail in one click', async () => { + const el = await bar('abc Name:gain'); + const seen = applied(el); + await type(el, 'Value>1'); + clearAll(el)!.click(); + await el.updateComplete; + expect(seen).toEqual(['']); + expect(input(el).value).toBe(''); + }); + + it('leaves the caret in the box, ready for the next search', async () => { + const el = await bar('abc'); + clearAll(el)!.click(); + await el.updateComplete; + expect(el.shadowRoot!.activeElement).toBe(input(el)); + }); + + it('is a labelled button, so it is reachable without a mouse', async () => { + const el = await bar('abc'); + const button = clearAll(el)!; + expect(button.tagName).toBe('BUTTON'); + expect(button.getAttribute('aria-label')).toBe('Clear search'); + }); + + it('proposes nothing when only a tail was pending — there is no filter to replace', async () => { + // Clearing an uncommitted tail is a local edit. Proposing '' would look the + // same here and would clear an applied filter that a later Escape should have + // kept, so the two cases stay distinct. + const el = await bar(); + const seen = applied(el); + await type(el, 'ga'); + clearAll(el)!.click(); + await el.updateComplete; + expect(seen).toEqual([]); + expect(input(el).value).toBe(''); + }); +}); + describe('dex-filter-bar keyboard', () => { beforeEach(() => { document.body.innerHTML = ''; diff --git a/test/rowFilter.test.ts b/test/rowFilter.test.ts index da5a702..e245f5d 100644 --- a/test/rowFilter.test.ts +++ b/test/rowFilter.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest'; import { parseFilterExpression, filterRows, formatToken, removeToken, SUBSTRING_FILTER_COLUMNS, + type ColumnVocabulary, } from '../src/webview/rowFilter.js'; interface Row { @@ -33,8 +34,13 @@ function row(id: string, parent: string | null, extra: Partial = {}): Row { // Runs the whole pipeline `filterRows` exercises: parse + filter, returning just // the surviving ids in original order — the shape most of these tests care about. -function ids(rows: Row[], text: string, stickyRowIds: Set = new Set()): string[] { - return filterRows(rows, text, COLUMNS, getCellText, stickyRowIds).map((r) => r.ID); +function ids( + rows: Row[], + text: string, + stickyRowIds: Set = new Set(), + vocabulary?: ColumnVocabulary, +): string[] { + return filterRows(rows, text, COLUMNS, getCellText, stickyRowIds, vocabulary).map((r) => r.ID); } describe('parseFilterExpression', () => { @@ -286,6 +292,167 @@ describe('the operator scanner', () => { }); }); +// A user types the prefix off the header they are looking at, and that header says +// `Data Type`, not `"Data Type"`. Without this the space split the label in two and +// `Data Type:double` silently became `Data` AND `Type:double` — two conditions, one +// of them a stray word, which reads as the filter being broken. +describe('a header label with a space, unquoted', () => { + const VOCAB = { + labels: { Name: 'Name', Value: 'Value', DataType: 'Data Type', lastModified: 'Last Modified', lastModifiedBy: 'Last Modified By' }, + keys: [...COLUMNS, 'lastModified', 'lastModifiedBy'], + }; + + it('reads the whole label as the prefix', () => { + const { tokens } = parseFilterExpression('Data Type:double', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'DataType', op: 'contains', value: 'double', raw: 'Data Type:double' }); + }); + + it('works for every operator, and case-insensitively', () => { + for (const [text, op] of [['Data Type=double', '='], ['data type!=double', '!='], ['DATA TYPE:double', 'contains']] as const) { + const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'DataType', op }); + } + }); + + it('prefers the longest label, so Last Modified By is not Last Modified + By', () => { + const { tokens } = parseFilterExpression('Last Modified By:ww', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'lastModifiedBy', value: 'ww' }); + }); + + it('keeps a quoted value together after an unquoted label', () => { + const { tokens } = parseFilterExpression('Data Type="fixed point"', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'DataType', op: '=', value: 'fixed point' }); + }); + + it('leaves the same words alone when no operator follows them', () => { + const { tokens } = parseFilterExpression('Data Type', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(2); + expect(tokens.map((t) => t.column)).toEqual([null, null]); + expect(tokens.map((t) => t.value)).toEqual(['Data', 'Type']); + }); + + it('records one span covering the label, so its chip removes in one go', () => { + const text = 'abc Data Type:double Value>1'; + const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB); + expect(tokens.map((t) => text.slice(t.start, t.end))).toEqual(['abc', 'Data Type:double', 'Value>1']); + expect(removeToken(text, tokens[1])).toBe('abc Value>1'); + }); + + it('tolerates a double space inside the label', () => { + const { tokens } = parseFilterExpression('Data Type:double', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'DataType', value: 'double' }); + }); + + it('is not fooled by a quoted phrase that happens to start with a label', () => { + const { tokens } = parseFilterExpression('"Data Type: double"', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: null, value: 'Data Type: double' }); + }); +}); + +// `Value > 5` and `Name: abc` are how a person writes a condition. Whitespace around +// the operator used to split one condition into two or three junk terms that matched +// nothing. It is insignificant now — but ONLY once the prefix has resolved to a real +// column, which is what keeps `a > b` ordinary text. +describe('whitespace around the operator', () => { + const VOCAB = { + labels: { Name: 'Name', Value: 'Value', DataType: 'Data Type', Unit: 'Unit' }, + keys: [...COLUMNS, 'Unit'], + }; + const one = (text: string) => { + const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + return tokens[0]; + }; + + it('accepts a space after the colon, multi-word label included', () => { + expect(one('data type: double')).toMatchObject({ column: 'DataType', op: 'contains', value: 'double' }); + expect(one('Name: abc')).toMatchObject({ column: 'Name', op: 'contains', value: 'abc' }); + }); + + it('accepts spaces on both sides of any operator', () => { + expect(one('Value > 5')).toMatchObject({ column: 'Value', op: '>', value: '5' }); + expect(one('Value >= 5')).toMatchObject({ column: 'Value', op: '>=', value: '5' }); + expect(one('Name != abc')).toMatchObject({ column: 'Name', op: '!=', value: 'abc' }); + expect(one('Data Type = double')).toMatchObject({ column: 'DataType', op: '=', value: 'double' }); + expect(one('Value< 5')).toMatchObject({ column: 'Value', op: '<', value: '5' }); + expect(one('Value :5')).toMatchObject({ column: 'Value', op: 'contains', value: '5' }); + }); + + it('reads the legacy colon-then-operator form across a space too', () => { + expect(one('Value: >10')).toMatchObject({ op: '>', value: '10' }); + expect(one('Value: > 10')).toMatchObject({ op: '>', value: '10' }); + }); + + it('takes a quoted value from after the space', () => { + expect(one('Data Type: "fixed point"')).toMatchObject({ column: 'DataType', value: 'fixed point' }); + }); + + it('does not swallow the NEXT condition as a value', () => { + const { tokens } = parseFilterExpression('Unit= Value>5', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(2); + expect(tokens[0]).toMatchObject({ column: 'Unit', op: '=', value: '' }); + expect(tokens[1]).toMatchObject({ column: 'Value', op: '>', value: '5' }); + }); + + it('still reads a trailing operator as an empty value, which asks for empty cells', () => { + expect(one('Unit=')).toMatchObject({ column: 'Unit', op: '=', value: '' }); + expect(one('Unit= ')).toMatchObject({ column: 'Unit', op: '=', value: '' }); + }); + + it('leaves an operator between two non-columns as ordinary text', () => { + const { tokens } = parseFilterExpression('a > b', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(3); + expect(tokens.map((t) => t.column)).toEqual([null, null, null]); + }); + + it('leaves a column name followed by an ordinary word alone', () => { + const { tokens } = parseFilterExpression('Name gain', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(2); + expect(tokens.map((t) => t.value)).toEqual(['Name', 'gain']); + }); + + it('spans the whole condition, so the chip removes every part of it', () => { + const text = 'abc Data Type: double Value>1'; + const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB); + expect(tokens.map((t) => text.slice(t.start, t.end))).toEqual(['abc', 'Data Type: double', 'Value>1']); + expect(removeToken(text, tokens[1])).toBe('abc Value>1'); + }); + + it('filters the rows it says it does', () => { + const rows = [ + row('a', null, { Name: 'gain', Value: '10', DataType: 'double' }), + row('b', null, { Name: 'other', Value: '2', DataType: 'single' }), + ]; + expect(ids(rows, 'Value > 5', new Set(), VOCAB)).toEqual(['a']); + expect(ids(rows, 'data type: single', new Set(), VOCAB)).toEqual(['b']); + }); + + it('reads every spelling of one condition the same way', () => { + // Two code paths now: one that scans a bare label across whitespace, and the + // per-chunk one that still handles a QUOTED prefix (which no bare label can + // match). Pin the invariant BETWEEN them — a user who quotes, spaces, or does + // neither is asking the same question and must get the same answer. + const want = { column: 'DataType', op: '=' as const, value: 'double' }; + for (const text of ['Data Type=double', 'Data Type = double', 'Data Type =double', '"Data Type"=double']) { + expect(one(text), text).toMatchObject(want); + } + }); + + it('takes the word after the operator as the value, so Unit= abc is not two conditions', () => { + // The cost of crossing whitespace: `Unit= abc` used to mean "no Unit, and abc + // somewhere". It now means Unit equals abc, which is what the spacing looks + // like. Asking for empty cells still works — leave nothing after the operator. + expect(one('Unit= abc')).toMatchObject({ column: 'Unit', op: '=', value: 'abc' }); + expect(one('Unit=')).toMatchObject({ column: 'Unit', op: '=', value: '' }); + }); +}); + describe('the = rule', () => { const VOCAB = { labels: { Name: 'Name', Value: 'Value' }, keys: COLUMNS }; const match = (text: string, r: Row) =>