|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#18113] The text-comparand door, driven against the table it answers. |
| 5 | + * |
| 6 | + * `filter-text-conformance.test.ts` proves `FILTER_TEXT_CASES` is internally |
| 7 | + * honest. This file proves the PREDICATE and the REASON published beside it |
| 8 | + * answer that same table — the two halves are only worth publishing together |
| 9 | + * if they cannot drift apart, and a predicate written from the rows by hand is |
| 10 | + * exactly how they drift. |
| 11 | + * |
| 12 | + * Both pins are driven from the table rather than from a transcribed list, so a |
| 13 | + * row added to `FILTER_TEXT_CASES` arrives here automatically. |
| 14 | + */ |
| 15 | + |
| 16 | +import { describe, it, expect } from 'vitest'; |
| 17 | +import { |
| 18 | + FILTER_TEXT_CASES, |
| 19 | + FILTER_TEXT_ROWS, |
| 20 | + type FilterTextCase, |
| 21 | + type FilterTextRejectionCase, |
| 22 | +} from './filter-text-conformance'; |
| 23 | +import { isRefusedTextComparand, textComparandRefusalReason } from './filter-text-comparand'; |
| 24 | +// Entry reachability, the ruling's actual unblock criterion: the objectui half |
| 25 | +// is gated on the INSTALLED `@objectstack/spec` exporting these two names, so |
| 26 | +// the barrel hop is pinned here and `check:api-surface` covers the built face. |
| 27 | +import * as dataEntry from './index'; |
| 28 | + |
| 29 | +/** The operator this door is scoped to, in the two spellings that can ARRIVE. */ |
| 30 | +const ARRIVING_SPELLINGS = ['$icontains', 'icontains'] as const; |
| 31 | + |
| 32 | +const isRejection = (c: FilterTextCase): c is FilterTextRejectionCase => |
| 33 | + 'expectRejection' in c && c.expectRejection === true; |
| 34 | + |
| 35 | +/** Every (field, operator, comparand) triple a case's filter carries. */ |
| 36 | +function comparands(c: FilterTextCase): Array<{ field: string; operator: string; target: unknown }> { |
| 37 | + const out: Array<{ field: string; operator: string; target: unknown }> = []; |
| 38 | + for (const [field, ops] of Object.entries(c.filter as Record<string, Record<string, unknown>>)) { |
| 39 | + for (const [operator, target] of Object.entries(ops)) out.push({ field, operator, target }); |
| 40 | + } |
| 41 | + return out; |
| 42 | +} |
| 43 | + |
| 44 | +/** The case-insensitive contains operator, in whichever dialect the row is written. */ |
| 45 | +const isTheOperator = (operator: string) => operator === '$icontains' || operator === 'icontains'; |
| 46 | + |
| 47 | +// --------------------------------------------------------------------------- |
| 48 | +// 1. The predicate answers EXACTLY the two declared REJECTION rows |
| 49 | +// --------------------------------------------------------------------------- |
| 50 | + |
| 51 | +describe('#18113 — isRefusedTextComparand, driven through every FILTER_TEXT_CASES case', () => { |
| 52 | + it('the table still carries both verdicts, so this suite is not vacuous', () => { |
| 53 | + expect(FILTER_TEXT_CASES.filter(isRejection).length).toBeGreaterThan(0); |
| 54 | + expect(FILTER_TEXT_CASES.filter((c) => !isRejection(c)).length).toBeGreaterThan(0); |
| 55 | + }); |
| 56 | + |
| 57 | + for (const c of FILTER_TEXT_CASES) { |
| 58 | + const rejection = isRejection(c); |
| 59 | + it(`${rejection ? 'REJECTION' : 'rows'}: ${c.name}`, () => { |
| 60 | + for (const { operator, target } of comparands(c)) { |
| 61 | + // The door is scoped to ONE operator (the module's Scope section). A |
| 62 | + // comparand on any other operator is not its question, and answering |
| 63 | + // one would be the widening-by-analogy the table reserves to itself. |
| 64 | + const expected = rejection && isTheOperator(operator); |
| 65 | + expect( |
| 66 | + isRefusedTextComparand(target), |
| 67 | + `${c.name} — comparand ${JSON.stringify(target) ?? String(target)} on '${operator}'`, |
| 68 | + ).toBe(expected); |
| 69 | + } |
| 70 | + }); |
| 71 | + } |
| 72 | + |
| 73 | + it('answers TRUE for exactly the two rows the table declares refused for this operator', () => { |
| 74 | + // The whole-table reading, stated as a set rather than per case: whatever |
| 75 | + // rows arrive later, the predicate's TRUE set must stay equal to the |
| 76 | + // REJECTION rows written in this operator's dialect. |
| 77 | + const trueFor = FILTER_TEXT_CASES |
| 78 | + .filter((c) => comparands(c).some(({ target }) => isRefusedTextComparand(target))) |
| 79 | + .map((c) => c.name); |
| 80 | + const declared = FILTER_TEXT_CASES |
| 81 | + .filter((c) => isRejection(c) && comparands(c).some(({ operator }) => isTheOperator(operator))) |
| 82 | + .map((c) => c.name); |
| 83 | + expect(trueFor).toEqual(declared); |
| 84 | + expect(trueFor).toHaveLength(2); |
| 85 | + }); |
| 86 | + |
| 87 | + it('answers FALSE for the RETIRED-operator rejections — they are a different door', () => { |
| 88 | + // Measured, and the reason the set above is not simply "every REJECTION |
| 89 | + // case": `$regex` / `$options` rows are refused because the OPERATOR is |
| 90 | + // retired (`RETIRED_FILTER_OPERATORS` carries their prescription), and |
| 91 | + // their comparands are perfectly ordinary non-empty strings. A predicate |
| 92 | + // that answered TRUE for them would be reporting the wrong repair. |
| 93 | + const retired = FILTER_TEXT_CASES.filter( |
| 94 | + (c) => isRejection(c) && !comparands(c).some(({ operator }) => isTheOperator(operator)), |
| 95 | + ); |
| 96 | + expect(retired.length, 'the table still carries retired-operator rejections').toBeGreaterThan(0); |
| 97 | + for (const c of retired) { |
| 98 | + for (const { target } of comparands(c)) expect(isRefusedTextComparand(target), c.name).toBe(false); |
| 99 | + } |
| 100 | + }); |
| 101 | + |
| 102 | + it('answers FALSE for every stored NAME in the fixture', () => { |
| 103 | + // The card's other half: nothing the fixture stores is a refused comparand, |
| 104 | + // so a face cannot pass this door by refusing its own test data. (`score` is |
| 105 | + // deliberately not asked — it is a STORED value, never a comparand; the |
| 106 | + // rows that aim a text operator at it are `expected: []`, not rejections.) |
| 107 | + for (const row of FILTER_TEXT_ROWS) expect(isRefusedTextComparand(row.name), row.name).toBe(false); |
| 108 | + }); |
| 109 | + |
| 110 | + it('answers TRUE for `undefined` — the carve-out a caller owns, not a third row', () => { |
| 111 | + // Pinned because a vocabulary with an "absent" (a view rule whose operator |
| 112 | + // takes no comparand) must test absence BEFORE this door, and the module's |
| 113 | + // docblock promises exactly this answer to callers that do. |
| 114 | + expect(isRefusedTextComparand(undefined)).toBe(true); |
| 115 | + expect(FILTER_TEXT_CASES.filter(isRejection)).toHaveLength(5); |
| 116 | + }); |
| 117 | +}); |
| 118 | + |
| 119 | +// --------------------------------------------------------------------------- |
| 120 | +// 2. The reason names each row's `mustMention` tokens, per ARRIVING spelling |
| 121 | +// --------------------------------------------------------------------------- |
| 122 | + |
| 123 | +describe('#18113 — textComparandRefusalReason names what the row requires', () => { |
| 124 | + const rows = FILTER_TEXT_CASES.filter( |
| 125 | + (c): c is FilterTextRejectionCase => |
| 126 | + isRejection(c) && comparands(c).some(({ operator }) => isTheOperator(operator)), |
| 127 | + ); |
| 128 | + |
| 129 | + for (const c of rows) { |
| 130 | + const { field, target } = comparands(c).find(({ operator }) => isTheOperator(operator))!; |
| 131 | + |
| 132 | + it(`${c.name} — the $-dialect spelling carries every mustMention token`, () => { |
| 133 | + const reason = textComparandRefusalReason(field, '$icontains', target); |
| 134 | + for (const token of c.mustMention) expect(reason, `${c.name} / ${token}`).toContain(token); |
| 135 | + }); |
| 136 | + |
| 137 | + it(`${c.name} — the INFIX spelling names what arrived; the $ twin is the envelope's job`, () => { |
| 138 | + // ⚠️ Measured, and deliberate. `mustMention` is spelled in the `$` dialect |
| 139 | + // because the published rows' filters are. A view rule spells the same |
| 140 | + // operator `icontains`, and this function names the spelling that |
| 141 | + // ARRIVED — so the `$` token is NOT in the contract half for that |
| 142 | + // dialect. The face serving that vocabulary names the `$` twin in its own |
| 143 | + // tail (objectui#9152 does exactly this); prescribing it here would send |
| 144 | + // a view author looking for a key their metadata cannot contain. |
| 145 | + const reason = textComparandRefusalReason(field, 'icontains', target); |
| 146 | + for (const token of c.mustMention) { |
| 147 | + expect(reason, `${c.name} / ${token} without its dialect sigil`).toContain(token.replace(/^\$/, '')); |
| 148 | + expect(reason, `${c.name} / the $ twin is NOT substituted for what arrived`).not.toContain(token); |
| 149 | + } |
| 150 | + }); |
| 151 | + |
| 152 | + for (const operator of ARRIVING_SPELLINGS) { |
| 153 | + it(`${c.name} — '${operator}' arrives verbatim, never a canonical substitute`, () => { |
| 154 | + const reason = textComparandRefusalReason(field, operator, target); |
| 155 | + expect(reason).toContain(`on operator '${operator}'`); |
| 156 | + expect(reason).toContain(`the declared comparand for '${operator}'`); |
| 157 | + expect(reason).toContain(`field '${field}'`); |
| 158 | + }); |
| 159 | + |
| 160 | + it(`${c.name} — '${operator}' is seatable: no leading capital, no trailing period`, () => { |
| 161 | + // The shape contract each face depends on to seat this in its own |
| 162 | + // sentence. Both shipped faces read it mid-sentence. |
| 163 | + const reason = textComparandRefusalReason(field, operator, target); |
| 164 | + expect(reason[0]).toBe(reason[0]!.toLowerCase()); |
| 165 | + expect(reason.endsWith('.')).toBe(false); |
| 166 | + expect(reason).toContain('INVALID_FILTER'); |
| 167 | + }); |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + it('the two rows get DIFFERENT reasons — the empty string is not "not a string"', () => { |
| 172 | + const empty = textComparandRefusalReason('name', '$icontains', ''); |
| 173 | + const nonString = textComparandRefusalReason('name', '$icontains', 42); |
| 174 | + expect(empty).not.toEqual(nonString); |
| 175 | + expect(empty).toContain('EMPTY STRING'); |
| 176 | + expect(nonString).toContain('not a string'); |
| 177 | + }); |
| 178 | + |
| 179 | + it('the BYTES, transcribed — a reword is a different failure to honour the same row', () => { |
| 180 | + // `mustMention` cannot catch a reword: it only requires `$icontains`. These |
| 181 | + // are the bytes two objectui faces have shipped since objectui#8748 / |
| 182 | + // objectui#9001, which is what makes them the contract rather than prose. |
| 183 | + // ⛔ Change them only by changing the rows they answer. |
| 184 | + expect(textComparandRefusalReason('name', '$icontains', '')).toBe( |
| 185 | + "filter comparand for field 'name' on operator '$icontains' is the EMPTY STRING. " |
| 186 | + + 'Every value contains the empty substring, so evaluating it is a predicate that ' |
| 187 | + + "constrains nothing. @objectstack/spec's FILTER_TEXT_CASES declares this shape " |
| 188 | + + "refused (INVALID_FILTER); the declared comparand for '$icontains' is a NON-EMPTY " |
| 189 | + + 'STRING. Drop the condition instead of sending an empty comparand', |
| 190 | + ); |
| 191 | + expect(textComparandRefusalReason('name', '$icontains', 42)).toBe( |
| 192 | + "filter comparand for field 'name' on operator '$icontains' is number (42), not a " |
| 193 | + + "string. Coercing it would answer a query nobody wrote. @objectstack/spec's " |
| 194 | + + 'FILTER_TEXT_CASES declares this shape refused (INVALID_FILTER); the declared ' |
| 195 | + + "comparand for '$icontains' is a NON-EMPTY STRING. Write the comparand as a string", |
| 196 | + ); |
| 197 | + }); |
| 198 | + |
| 199 | + it('describes a comparand JSON.stringify would THROW on, instead of throwing', () => { |
| 200 | + // The guard that travels with the text: on a throwing face a `TypeError` |
| 201 | + // raised while BUILDING the message escapes in the refusal's place. |
| 202 | + const cyclic: Record<string, unknown> = {}; |
| 203 | + cyclic.self = cyclic; |
| 204 | + expect(() => textComparandRefusalReason('name', '$icontains', cyclic)).not.toThrow(); |
| 205 | + expect(() => textComparandRefusalReason('name', '$icontains', 10n)).not.toThrow(); |
| 206 | + expect(textComparandRefusalReason('name', '$icontains', 10n)).toContain('bigint'); |
| 207 | + // `null` is reported as `null`, not as `object` — the typeof trap. |
| 208 | + expect(textComparandRefusalReason('name', '$icontains', null)).toContain('is null (null)'); |
| 209 | + // `undefined` and symbols stay readable, which `JSON.stringify` alone does not. |
| 210 | + expect(textComparandRefusalReason('name', '$icontains', undefined)).toContain('undefined'); |
| 211 | + }); |
| 212 | +}); |
| 213 | + |
| 214 | +// --------------------------------------------------------------------------- |
| 215 | +// 3. Reachability — the ruling's unblock criterion is an EXPORT, not a merge |
| 216 | +// --------------------------------------------------------------------------- |
| 217 | + |
| 218 | +describe('#18113 — both names reach the data entry', () => { |
| 219 | + it('are re-exported from the barrel a consumer imports', () => { |
| 220 | + // objectui#9048 is `pm:blocked` until the INSTALLED `@objectstack/spec` |
| 221 | + // exports these. A module nothing re-exports satisfies the card's letter |
| 222 | + // and none of its purpose, so the hop is pinned rather than assumed. |
| 223 | + expect(typeof (dataEntry as Record<string, unknown>).isRefusedTextComparand).toBe('function'); |
| 224 | + expect(typeof (dataEntry as Record<string, unknown>).textComparandRefusalReason).toBe('function'); |
| 225 | + }); |
| 226 | + |
| 227 | + it('and `describeComparand` deliberately does NOT — two new symbols, not three', () => { |
| 228 | + // ⚠️ Not a ban. #18113 declared TWO new exported symbols (Clause-② carrier), |
| 229 | + // so the helper stays internal. If a face needs to describe a comparand in |
| 230 | + // its OWN envelope text, export it in the PR that needs it and say so, so |
| 231 | + // the addition is a decision rather than a side effect of `export *`. |
| 232 | + expect(Object.prototype.hasOwnProperty.call(dataEntry, 'describeComparand')).toBe(false); |
| 233 | + }); |
| 234 | +}); |
0 commit comments