From 6a040fefd95e28b496ee9a6770b3ff736fae74c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:14:15 +0000 Subject: [PATCH] fix(core): lower $icontains, the canonical operator the converter refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$icontains` is a member of `@objectstack/spec`'s `FILTER_OPERATORS`, `ValueDataSource` executes it, `FilterConditionField` emits it for its "contains (ignore case)" builder row, and `packages/core/src/adapters/README.md` prescribes it as the repair when `$like` / `$ilike` / `$regex` are refused. `convertOperatorToAST` had no row for it, so `convertFiltersToAST` answered the generic unknown-operator refusal (`INVALID_FILTER` / 400) — the one spelling this repo tells an author to write was the one spelling it rejected. Aligning the converter with the contract, per AGENTS.md #0. The lowered value is an identity: `icontains` is itself a member of `VALID_AST_OPERATORS`, so unlike `$startsWith` -> `startswith` there is no case to squash. Two consequences of the same gap ride along: the unknown-operator message now enumerates `$icontains`, and the `$regex` refusal prescribes it by name — the spec's own `FILTER_TEXT_CASES` requires that refusal to mention `$icontains`, and it could not while the converter did not accept it. `packages/data-objectstack/README.md` gains the row it never had, and the reconciliation pin's blind spot is closed in the same change: it held the tables against the code's own two populations (`operatorMap`'s keys and the operators the error calls supported), and `$icontains` was in NEITHER, so the omission was invisible to it by construction. The tables are now also held complete against the spec's `FILTER_OPERATORS`. This is the opposite leg of objectui#8568 and deliberately not folded with it: there the converter was more tolerant than the contract, here less tolerant. A single "make them agree" change would have widened the matcher instead. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .../8976-icontains-lowers-in-converter.md | 34 +++ .../filter-icontains-alignment-8976.test.ts | 229 ++++++++++++++++++ packages/core/src/utils/filter-converter.ts | 21 +- packages/data-objectstack/README.md | 13 +- .../src/readme-filter-operator-table.test.ts | 76 ++++++ 5 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 .changeset/8976-icontains-lowers-in-converter.md create mode 100644 packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.ts diff --git a/.changeset/8976-icontains-lowers-in-converter.md b/.changeset/8976-icontains-lowers-in-converter.md new file mode 100644 index 0000000000..2ea0548fd7 --- /dev/null +++ b/.changeset/8976-icontains-lowers-in-converter.md @@ -0,0 +1,34 @@ +--- +'@object-ui/core': minor +--- + +`convertFiltersToAST` accepts `$icontains`, the canonical case-insensitive `contains` +the rest of the stack already spoke (objectui#8976). + +`$icontains` is a member of `@objectstack/spec`'s `FILTER_OPERATORS`, `ValueDataSource` +executes it, `FilterConditionField` emits it for its "contains (ignore case)" builder +row, and `packages/core/src/adapters/README.md` prescribes it as the repair when +`$like` / `$ilike` / `$regex` are refused. `convertOperatorToAST` had no row for it, so +the ObjectStack lowering path answered a `FilterOperatorError` (`code: +'INVALID_FILTER'`, `httpStatus: 400`) with the generic unknown-operator paragraph — the +one spelling this repo tells an author to write was the one spelling it rejected. An +admin who picked "contains (ignore case)" in the filter builder authored criteria that +the in-memory matcher honoured and the ObjectStack data source refused. + +`{ name: { $icontains: 'john' } }` now lowers to `['name', 'icontains', 'john']`. The +value is an identity because `icontains` is itself a member of the spec's +`VALID_AST_OPERATORS`: unlike `$startsWith` → `startswith` there is no case to squash, +and the spelling the author writes is the spelling the AST carries. No existing filter +changes shape — this is a refusal becoming an acceptance, so nothing that lowered +before lowers differently now. + +Two smaller repairs ride along, both consequences of the same gap. The +unknown-operator message now enumerates `$icontains` among the supported operators, and +the `$regex` refusal now prescribes it by name for a case-insensitive substring — +`@objectstack/spec`'s own `FILTER_TEXT_CASES` requires that refusal to mention +`$icontains`, and it could not while the converter did not accept it. + +This is the opposite leg of objectui#8568, which retired four lowercase aliases the +converter accepted and the matcher refused. There the converter was more tolerant than +the contract; here it was less tolerant than it. The two needed different fixes: a +single "make the two sides agree" change would have widened the matcher instead. diff --git a/packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.ts b/packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.ts new file mode 100644 index 0000000000..5c0e6ae456 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.ts @@ -0,0 +1,229 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `$icontains` lowers, and the two faces of the `$` dialect agree about it + * (objectui#8976). + * + * ## The defect + * + * `$icontains` is a canonical member of `@objectstack/spec`'s + * `FILTER_OPERATORS`; `ValueDataSource` executes it; `FilterConditionField` + * emits it for the `containsCaseInsensitive` builder row; and + * `packages/core/src/adapters/README.md` PRESCRIBES it as the repair for + * `$like` / `$ilike` / `$regex`. `convertOperatorToAST` had no row for it, so + * `convertFiltersToAST` refused it with the generic unknown-operator paragraph + * — an `INVALID_FILTER` / 400 on a spelling this repo tells authors to write. + * + * That is the OPPOSITE leg of objectui#8568, which retired four lowercase + * aliases the converter accepted and the matcher refused. There the converter + * was more tolerant than the contract; here it was less tolerant than the + * contract. ⛔ The two are not one change: making the acceptance sets "agree" + * in a single sweep would have widened the matcher instead. + * + * ## What is asserted, and why in this shape + * + * The weak version of this file would be `expect(convertOperatorToAST('$icontains')) + * .not.toBeNull()` — true the moment a row is added, and blind to whether the + * row means anything. Three stronger claims are made instead. + * + * 1. **The spelling the adapters README PRESCRIBES must work.** The + * prescriptions are read out of that page's refusal table and intersected + * with the spec's `FILTER_OPERATORS`, so the population comes from prose a + * human wrote and from the contract — never from the function under test. + * This is the claim that fails if the converter is repaired but the page + * keeps prescribing something else, and the claim that would have failed + * before this card in the way an author actually meets the bug. + * 2. **Both faces answer the same rows.** `convertFiltersToAST` and + * `ValueDataSource` are probed over one fixture, with a case-SENSITIVE + * control (`$contains`) that must select a strictly smaller set. Without + * that control "both select rows" would pass on a matcher that ignored the + * case fold entirely. + * 3. **`icontains` survives to the AST unchanged.** The lowered operator must + * be a member of the spec's `VALID_AST_OPERATORS` — the set that gates + * `isFilterAST()`, and therefore the difference between a filter the wire + * carries and one `driver-sql` silently DROPS (objectstack#3948). + * + * ⚠️ Deliberately NOT claimed: end-to-end reach from a stored `criteria_json` + * into this converter. The criteria store is not in this tree — the + * consumer-local caveat objectui#6839 established. What is provable here, and + * all this file asserts, is the acceptance-set disagreement and the producer + * arm. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { FILTER_OPERATORS, VALID_AST_OPERATORS } from '@objectstack/spec/data'; +import { convertFiltersToAST, convertOperatorToAST } from '../filter-converter'; +import { ValueDataSource } from '../../adapters/ValueDataSource'; + +function repoRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i += 1) { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir; + dir = resolve(dir, '..'); + } + throw new Error('repo root (pnpm-workspace.yaml) not found from this test file'); +} + +const ADAPTERS_README_PATH = 'packages/core/src/adapters/README.md'; +const ADAPTERS_README = readFileSync(join(repoRoot(), ADAPTERS_README_PATH), 'utf8'); + +/** + * The `$`-spellings `packages/core/src/adapters/README.md` tells an author to + * write INSTEAD of something it refuses — the third column of its refusal + * table, whose header is "write instead". + * + * Narrowed to members of the spec's `FILTER_OPERATORS` on purpose. That column + * also carries `$field` (a comparand marker, not an operator) and prose cells + * with no code span at all, and asserting those "lower" would be a category + * error. The intersection is still two INDEPENDENT sources — hand-written prose + * and the contract — and neither is the function under test. + */ +function prescribedOperators(): string[] { + const heading = '| spelling | why | write instead |'; + const start = ADAPTERS_README.indexOf(heading); + if (start === -1) { + throw new Error( + `${ADAPTERS_README_PATH} no longer has the "write instead" refusal table (objectui#8976): ` + + 're-point this reader at wherever the prescriptions now live. Do not delete the case — ' + + 'a page that prescribes a spelling the converter refuses is exactly this card', + ); + } + const canonical = new Set(FILTER_OPERATORS as readonly string[]); + const found = new Set(); + for (const line of ADAPTERS_README.slice(start).split('\n')) { + if (!line.startsWith('|')) break; + const cells = line.replace(/^\|/, '').replace(/\|\s*$/, '').split('|'); + const writeInstead = cells[cells.length - 1] ?? ''; + for (const match of writeInstead.matchAll(/`(\$[A-Za-z]+)`/g)) { + if (canonical.has(match[1])) found.add(match[1]); + } + } + return [...found].sort(); +} + +const PRESCRIBED = prescribedOperators(); + +const ROWS = [ + { id: 'a', name: 'ACME Corporation' }, + { id: 'b', name: 'acme holdings' }, + { id: 'c', name: 'Globex' }, +]; + +async function selectedIds(filter: unknown): Promise { + const ds = new ValueDataSource({ items: ROWS }); + const result = await ds.find('rows', { $filter: filter as any }); + return result.data.map((r) => r.id as string); +} + +function spyWarn() { + return vi.spyOn(console, 'warn').mockImplementation(() => {}); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// 0. Controls — every population below was really read, and can really fail +// --------------------------------------------------------------------------- + +describe('objectui#8976 — controls', () => { + it('read the adapters README prescriptions, and $icontains is among them', () => { + // Guards the vacuous pass: a reader that matched nothing would make the + // prescription case below iterate zero spellings and report success. + expect( + PRESCRIBED.length, + `no canonical operator was read out of ${ADAPTERS_README_PATH}'s "write instead" column`, + ).toBeGreaterThanOrEqual(3); + expect( + PRESCRIBED, + 'this card exists because that page prescribes $icontains; if the prescription is gone, ' + + 'the premise changed and this file must be re-read, not re-pointed', + ).toContain('$icontains'); + }); + + it('$icontains is still canonical in the spec, in both vocabularies', () => { + // If either of these is ever false, objectui#8976 stops being an + // invariant restoration and becomes a ruling. Fail loudly rather than + // quietly keeping a row the contract no longer declares. + expect(FILTER_OPERATORS).toContain('$icontains'); + expect(VALID_AST_OPERATORS.has('icontains')).toBe(true); + }); + + it('the matcher discriminates, so "selects rows" below is not vacuous', () => { + // A case-SENSITIVE probe must select a strictly smaller set than the + // case-insensitive one. Without this, a matcher that folded case for + // everything (or for nothing) would satisfy the agreement case. + expect(ROWS.filter((r) => r.name.includes('ACME')).map((r) => r.id)).toEqual(['a']); + expect( + ROWS.filter((r) => r.name.toLowerCase().includes('acme')).map((r) => r.id), + ).toEqual(['a', 'b']); + }); +}); + +// --------------------------------------------------------------------------- +// 1. The prescription must work — the claim an author actually meets +// --------------------------------------------------------------------------- + +describe('objectui#8976 — every spelling the adapters README prescribes lowers', () => { + it.each(PRESCRIBED)('%s is accepted by convertFiltersToAST', (spelling) => { + let node: unknown; + expect(() => { + node = convertFiltersToAST({ name: { [spelling]: 'acme' } }); + }, `${ADAPTERS_README_PATH} prescribes ${spelling}, and this converter refuses it`).not.toThrow(); + expect(Array.isArray(node), `${spelling} did not lower to a comparison node`).toBe(true); + expect((node as unknown[])[0]).toBe('name'); + }); + + it.each(PRESCRIBED)('%s lowers to an operator the AST gate accepts', (spelling) => { + const node = convertFiltersToAST({ name: { [spelling]: 'acme' } }) as unknown[]; + // `isFilterAST()` is gated on this set; an operator outside it reaches the + // wire and is DROPPED rather than refused (objectstack#3948). + expect(VALID_AST_OPERATORS.has(node[1] as string)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 2. The two faces agree — the disagreement this card measured +// --------------------------------------------------------------------------- + +describe('objectui#8976 — convertFiltersToAST and ValueDataSource agree on $icontains', () => { + it('the converter lowers $icontains to `icontains`, unsquashed', () => { + // The identity is the point: `icontains` is itself an AST operator, so + // unlike `$startsWith` -> `startswith` there is no case to fold away. + expect(convertOperatorToAST('$icontains')).toBe('icontains'); + expect(convertFiltersToAST({ name: { $icontains: 'acme' } })).toEqual([ + 'name', + 'icontains', + 'acme', + ]); + }); + + it('the matcher selects the case-insensitive set, with no refusal logged', async () => { + const warn = spyWarn(); + expect(await selectedIds({ name: { $icontains: 'acme' } })).toEqual(['a', 'b']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('the case-SENSITIVE sibling still selects the smaller set on both faces', async () => { + // The discriminating control for the case above: this is what proves the + // agreement is about the case fold and not about the fixture. + expect(convertFiltersToAST({ name: { $contains: 'acme' } })).toEqual([ + 'name', + 'contains', + 'acme', + ]); + const warn = spyWarn(); + expect(await selectedIds({ name: { $contains: 'acme' } })).toEqual(['b']); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/utils/filter-converter.ts b/packages/core/src/utils/filter-converter.ts index 21e1707478..11f41b551b 100644 --- a/packages/core/src/utils/filter-converter.ts +++ b/packages/core/src/utils/filter-converter.ts @@ -112,6 +112,22 @@ export function convertOperatorToAST(operator: string): string | null { '$notContains': 'notcontains', '$startsWith': 'startswith', '$endsWith': 'endswith', + // Case-insensitive contains. A canonical `FILTER_OPERATORS` member that + // `ValueDataSource` executes and `FilterConditionField` emits (for its + // `containsCaseInsensitive` builder row), while this map refused it with the + // generic unknown-operator paragraph — so ONE authored filter selected rows + // through the in-memory matcher and 400'd on the ObjectStack lowering path + // (objectui#8976). The other direction of the same split objectui#8568 fixed: + // there the map was MORE tolerant than the matcher, here it was LESS tolerant + // than the contract. Restored by aligning the map with the spec, per AGENTS.md + // #0 — not by removing the operator from the builder, which the spec declares. + // + // The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a + // member of the spec's `VALID_AST_OPERATORS`, so unlike its camelCase siblings + // above there is no case to squash. Same identity row, for the same stated + // reason, that `FILTER_OPERATOR_ALIASES` carries in + // `packages/data-objectstack/src/index.ts`. + '$icontains': 'icontains', }; return operatorMap[operator] || null; @@ -526,7 +542,8 @@ export function convertFiltersToAST(filter: Record): FilterNode | R `converted to 'contains', which matches a literal substring rather than a ` + `pattern — a different result, not a degraded one. ` + `Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` + - `Use $contains, $startsWith or $endsWith.` + `Use $contains for a case-sensitive substring, $icontains for a ` + + `case-insensitive one, or $startsWith / $endsWith.` ); } @@ -565,7 +582,7 @@ export function convertFiltersToAST(filter: Record): FilterNode | R throw new FilterOperatorError( `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` + `Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, ` + - `$contains, $notContains, $startsWith, $endsWith, $null, $exists. ` + + `$contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists. ` + `If you need exact object matching, use the value directly without an operator.` ); } diff --git a/packages/data-objectstack/README.md b/packages/data-objectstack/README.md index 866f0a16db..890bd254c3 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -131,6 +131,16 @@ no aliases: the four lowercase spellings this table used to list beside the camelCase keys — `$notin`, `$notcontains`, `$startswith`, `$endswith` — were retired by objectui#8568 and moved to the refused table below. +`$icontains` is the case-insensitive member of the `$contains` family, and its +ObjectStack spelling is the SAME word: `icontains` is itself a member of the +spec's `VALID_AST_OPERATORS`, so nothing is squashed on the way down. It had no +row here at all until objectui#8976 — `convertFiltersToAST` refused it as an +unknown operator while `ValueDataSource` executed it and this repo's own filter +builder emitted it. The pin now holds these two tables complete against the +spec's `FILTER_OPERATORS` as well as against the code, so a canonical operator +that is documented NEITHER as supported NOR as refused fails the suite instead +of going unnoticed. + | MongoDB Operator | ObjectStack Operator | Example | |------------------|---------------------|---------| | plain value (no operator) | `=` | `{ status: 'active' }` → `['status', '=', 'active']` | @@ -147,6 +157,7 @@ retired by objectui#8568 and moved to the refused table below. | `$notContains` | `notcontains` | `{ name: { $notContains: 'test' } }` → `['name', 'notcontains', 'test']` | | `$startsWith` | `startswith` | `{ email: { $startsWith: 'admin' } }` → `['email', 'startswith', 'admin']` | | `$endsWith` | `endswith` | `{ email: { $endsWith: '@example.com' } }` → `['email', 'endswith', '@example.com']` | +| `$icontains` | `icontains` | `{ name: { $icontains: 'john' } }` → `['name', 'icontains', 'john']` | | `$null` | `is_null` / `is_not_null` | `{ email: { $null: true } }` → `['email', 'is_null', true]` | | `$exists` | `is_not_null` / `is_null` | `{ email: { $exists: true } }` → `['email', 'is_not_null', true]` | @@ -178,7 +189,7 @@ the call site rather than as a `400` from the server or as an empty list. | Shape | Why | Example | |-------|-----|---------| -| `$regex` | The spec has no `$regex`, and it is not downgraded to `contains`: a pattern match and a substring match are different questions, not stronger and weaker forms of one. Use `$contains`, `$startsWith` or `$endsWith`. | `{ name: { $regex: '^J' } }` → throws `INVALID_FILTER` | +| `$regex` | The spec has no `$regex`, and it is not downgraded to `contains`: a pattern match and a substring match are different questions, not stronger and weaker forms of one. Use `$contains` for a case-sensitive substring, `$icontains` for a case-insensitive one, or `$startsWith` / `$endsWith`. | `{ name: { $regex: '^J' } }` → throws `INVALID_FILTER` | | `$not` | The AST has no negation keyword, and rewriting the negation inward would be silently partial. Use a negated operator instead: `$ne`, `$nin`, `$notContains`. | `{ $not: { status: 'open' } }` → throws `INVALID_FILTER` | | a bare array as a field's value | The AST has no array-equality node, and the array is deliberately not read as `$in` (see below). | `{ tags: ['a', 'b'] }` → throws `INVALID_FILTER` | | `$notin` / `$notcontains` / `$startswith` / `$endswith` | Retired lowercase aliases (objectui#8568). The `$` dialect follows `@objectstack/spec`'s spellings, and this repo's in-memory matcher already refused these; accepting them here made one authored filter behave differently depending on the data source behind the view. The refusal names the canonical spelling for the alias you wrote — rename the key, the operator is unchanged. | `{ email: { $startswith: 'a' } }` → throws `INVALID_FILTER` | diff --git a/packages/data-objectstack/src/readme-filter-operator-table.test.ts b/packages/data-objectstack/src/readme-filter-operator-table.test.ts index f82bb0a6c8..674c16371a 100644 --- a/packages/data-objectstack/src/readme-filter-operator-table.test.ts +++ b/packages/data-objectstack/src/readme-filter-operator-table.test.ts @@ -58,6 +58,8 @@ * `convertOperatorToAST`'s `operatorMap` and for every operator the * unknown-operator error message calls supported (`$null` / `$exists` * live outside the map); + * - the two tables TOGETHER must account for every member of the spec's + * `FILTER_OPERATORS` — see "The blind spot" below; * - every `$`-spelling in the refused table must be refused, and every * combinator row's keyword must be the one its lowered group carries. * @@ -72,6 +74,37 @@ * lowering comes from calling the function. * A red here is always "fix the README (or the code)", never "update the test". * + * ## The blind spot this pin had, and what closes it (objectui#8976) + * + * Until objectui#8976 both completeness checks read the SAME two populations, + * and both were the CODE's: `operatorMap`'s keys, and the operators the + * unknown-operator error calls supported. That makes the pin exact about drift + * between the code and the page — and structurally unable to see an operator + * missing from BOTH sides at once. + * + * `$icontains` was exactly that operator. It is a canonical member of + * `FILTER_OPERATORS`, `ValueDataSource` executed it, `FilterConditionField` + * emitted it and `packages/core/src/adapters/README.md` PRESCRIBED it — while + * `convertOperatorToAST` had no row for it and the error message did not list + * it. Absent from both code-side populations, it was absent from everything + * this pin derives from them, so the README could carry no row for it in either + * table and the suite stayed green. A pin that reads only the code cannot + * report the code being wrong. + * + * So a THIRD population is read, and it is the one the code answers TO: the + * spec's `FILTER_OPERATORS`. Every canonical member must be accounted for by + * these tables — documented as supported, or documented as refused. Which of + * the two is not asserted here (that is the code's ruling, and the rows above + * already hold the tables to it); what is asserted is that no canonical + * operator is missing from BOTH, because that is the state nothing else can + * see. + * + * ⚠️ Deliberately ONE-DIRECTIONAL. The refused table legitimately carries + * spellings the spec does NOT declare (`$regex`, and the four lowercase aliases + * objectui#8568 retired) — documenting a refusal for something outside + * `FILTER_OPERATORS` is the point of that table, not drift. Asserting the + * converse would delete it. + * * ## Exhaustiveness IS asserted, deliberately * * `plugin-calendar`'s `readme-calendar-view-schema.test.ts` declines to assert @@ -88,6 +121,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { convertFiltersToAST, convertOperatorToAST } from '@object-ui/core'; +import { FILTER_OPERATORS } from '@objectstack/spec/data'; /** Walk up to the workspace root, so both files are found by repo layout. */ function repoRoot(): string { @@ -330,6 +364,30 @@ describe('README filter-operator tables are decided by convertFiltersToAST (obje expect(supportedByError).toContain('$exists'); }); + it('reads the spec population the code-side lists cannot report on', () => { + // The third population, and the only one that is not derived from the + // code under test — which is the whole point of adding it (objectui#8976). + expect(FILTER_OPERATORS.length).toBeGreaterThanOrEqual(12); + expect(FILTER_OPERATORS).toContain('$icontains'); + // Discriminating control: the spec population is not a restatement of the + // one the other completeness case reads. `operatorMap` structurally CANNOT + // carry `$null` / `$exists` — they pick their AST operator from the value, + // so they are handled before the map is consulted — which is why holding + // the page to the map alone always left canonical operators unaccounted + // for, and why the population below has to come from outside the code. + expect(sorted(FILTER_OPERATORS)).not.toEqual(sorted(operatorMap.keys())); + for (const outsideTheMap of ['$null', '$exists']) { + expect(FILTER_OPERATORS).toContain(outsideTheMap); + expect([...operatorMap.keys()]).not.toContain(outsideTheMap); + } + // ⚠️ NOT asserted: that the spec list differs from the unknown-operator + // message's enumeration. On a healthy tree those two agree — they did not + // before objectui#8976, and making them agree is part of what this card + // fixed. Pinning them equal would be wrong in the other direction: a spec + // operator this converter deliberately refuses must NOT be enumerated as + // supported, and the refused table is where it would be documented. + }); + it('found all three tables, each with rows', () => { expect(supported.length).toBeGreaterThanOrEqual(12); expect(combinators.length).toBeGreaterThanOrEqual(2); @@ -443,6 +501,24 @@ describe('README filter-operator tables are decided by convertFiltersToAST (obje }); }); + describe('every canonical operator is accounted for by the two tables (objectui#8976)', () => { + it('no member of the spec FILTER_OPERATORS is missing from BOTH tables', () => { + const documented = new Set( + [...supported, ...refused].flatMap((row) => operatorSpellings(row.cells[0] ?? '')), + ); + const undocumented = (FILTER_OPERATORS as readonly string[]) + .filter((spelling) => !documented.has(spelling)); + expect( + undocumented, + 'the spec declares these operators canonical and this page documents them neither as ' + + 'supported nor as refused. Until objectui#8976 `$icontains` was exactly that: absent ' + + 'from `operatorMap` AND from the unknown-operator message, so every other case here ' + + 'derived it away. Add a row to whichever table the code actually implements — and if ' + + 'the code implements neither, that is the defect, not this pin', + ).toEqual([]); + }); + }); + describe('the Logical combinators table', () => { it('the second column is the keyword the lowered group carries', () => { const bad: string[] = [];