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) =>