From 9e62c6f8f0f60d35438c17f32e04af00bac5fd0d Mon Sep 17 00:00:00 2001 From: Prajwol Shrestha Date: Thu, 1 Oct 2026 17:19:46 +0545 Subject: [PATCH 1/6] fix: rules the inline builder writes for "has an answer" / "is not selected" reopen as clauses (#15) Two defects met at ruleToClause in conditionReducer.ts: (a) clauseToRule emitted `${f}` and `not(${f})` as kind 'raw', so on reopen the parser handed back a raw rule, hydrateColumn fell to rawFallback and every control was disabled. They now have a real parser kind, `truthy` { field, negated }, that serializes to exactly the two spellings the builder has always written. Spacing-divergent forms (`${ f }`, `not( ${f} )`) stay raw via the self-check. (b) `${f} != ''` / `${f} = ''` parsed cleanly as kind 'answered' and were dropped at the same spot. They now map to the ref / not clause and carry the author's spelling as Clause.source, which clauseToRule re-emits while it still describes the clause. An unedited reopen writes the same bytes; `${f} != ''` is never rewritten to `${f}`. Pinned separately: a bare `${f}` fixture exercises only the truthy path, `${f} != ''` only the answered path. All four reducer tests and the three positive parser round-trip tests fail on bff69cb and pass here. Three UI consumers that switch exhaustively on Rule['kind'] gained a branch for the new kind (modal row, decisions prose, calc prose). Verified: shared tests 792 pass / 0 fail; typecheck clean; corpus sweep output byte-identical to master; a cell-level serialize(parse(x)) check over 4034 relevant/constraint/choice_filter cells in seven configs shows the same 37 pre-existing drifts as master and none new; 21 relevant cells now open as clauses that were raw before. Co-Authored-By: Claude Fable 5.1 --- client/src/ui/CalculationBuilder.tsx | 3 + client/src/ui/DecisionsView.tsx | 2 + client/src/ui/RelevantRuleBuilder.tsx | 22 ++++ .../conditionBuilder/conditionReducer.test.ts | 105 ++++++++++++++++++ .../src/conditionBuilder/conditionReducer.ts | 52 ++++++++- .../relevantParser.truthy.roundtrip.test.ts | 66 +++++++++++ shared/src/xlsform/relevantParser.ts | 31 ++++++ 7 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 shared/src/xlsform/relevantParser.truthy.roundtrip.test.ts diff --git a/client/src/ui/CalculationBuilder.tsx b/client/src/ui/CalculationBuilder.tsx index 7958055..3a8e6df 100644 --- a/client/src/ui/CalculationBuilder.tsx +++ b/client/src/ui/CalculationBuilder.tsx @@ -1342,6 +1342,9 @@ function conditionProse(cond: ParsedExpression): string { if (r.kind === 'answered') { return `\${${r.field}} ${r.negated ? 'is empty' : 'is answered'}`; } + if (r.kind === 'truthy') { + return `\${${r.field}} ${r.negated ? 'is not selected' : 'has an answer'}`; + } if (r.kind === 'age') { return `age of \${${r.field}} ${r.op} ${r.value} years`; } diff --git a/client/src/ui/DecisionsView.tsx b/client/src/ui/DecisionsView.tsx index c0396f7..e558b27 100644 --- a/client/src/ui/DecisionsView.tsx +++ b/client/src/ui/DecisionsView.tsx @@ -487,6 +487,8 @@ function relevantToHumanLines(cond: import('@cht-ui/shared').ParsedExpression): return `${r.negated ? 'NOT ' : ''}${r.field} includes "${r.value}"`; case 'answered': return r.negated ? `${r.field} is empty` : `${r.field} is answered`; + case 'truthy': + return r.negated ? `${r.field} is not selected` : `${r.field} has an answer`; case 'age': return `age of ${r.field} ${r.op} ${r.value} years`; case 'date_offset': diff --git a/client/src/ui/RelevantRuleBuilder.tsx b/client/src/ui/RelevantRuleBuilder.tsx index dc42aa4..27c9b75 100644 --- a/client/src/ui/RelevantRuleBuilder.tsx +++ b/client/src/ui/RelevantRuleBuilder.tsx @@ -483,6 +483,28 @@ function RuleRow(props: { ); } + if (rule.kind === 'truthy') { + // `${f}` / `not(${f})` — what the inline builder writes for "has an + // answer" / "is not selected" (T9b, #15). Same labels here so a rule + // reads the same in both builders. + return ( +
+ props.onChange({ ...rule, field: v })} + /> + + +
+ ); + } if (rule.kind === 'contact-input-comparison') { // Mirrors the ComparisonRule shape but with a datalist on the LHS // backed by the project's contact-input field list ∪ the CHT-canonical diff --git a/shared/src/conditionBuilder/conditionReducer.test.ts b/shared/src/conditionBuilder/conditionReducer.test.ts index 2fa719c..75c9d12 100644 --- a/shared/src/conditionBuilder/conditionReducer.test.ts +++ b/shared/src/conditionBuilder/conditionReducer.test.ts @@ -820,3 +820,108 @@ test('relabel guard: every ClauseOp value is one of the canonical 11 (no label l test('FieldKind co-domain mirror stays in sync with classifier', () => { assert.equal(ALL_FIELD_KINDS_INC_UNKNOWN.length, 6); }); + +/* ------------------- T9b (#15): reopen "has an answer" / "is not selected" ------------------- */ +/* + * Two distinct defects met at `ruleToClause`, so they are pinned separately. + * A bare `${f}` fixture exercises the (formerly raw) truthy path and never + * the `answered` path; `${f} != ''` is the reverse. One test cannot cover + * both — the assertions on the parser kind below make that explicit. + */ + +test('T9b case (a): `${field}` written by the inline builder reopens as op=ref with chaining enabled', () => { + // The builder itself emits this exact text for "has an answer". + let written = withColumn('relevant'); + written = setDraft(written, { field: 'lmp_approx', op: 'ref', value: '' }); + const bytes = serializeBuilderState(written); + assert.equal(bytes, '${lmp_approx}'); + // Document which parser path this fixture exercises (NOT `answered`). + assert.equal(parseRelevant(bytes).rules[0]?.kind, 'truthy'); + + const reopened = conditionBuilderReducer(initialConditionBuilderState, { + kind: 'set-column', + column: 'relevant', + existingValue: bytes, + }); + assert.equal(reopened.rawFallback, null); + assert.deepEqual(reopened.clauses, [{ field: 'lmp_approx', op: 'ref', value: '' }]); + assert.equal(serializeBuilderState(reopened), bytes); +}); + +test('T9b case (a): `not(${field})` written by the inline builder reopens as op=not, and chains', () => { + let written = withColumn('relevant'); + written = setDraft(written, { field: 'danger_signs', op: 'not', value: '' }); + written = commit(written, 'and'); + written = setDraft(written, { field: 'age', op: '>', value: '18' }); + const bytes = serializeBuilderState(written); + assert.equal(bytes, 'not(${danger_signs}) and ${age} > 18'); + + const reopened = conditionBuilderReducer(initialConditionBuilderState, { + kind: 'set-column', + column: 'relevant', + existingValue: bytes, + }); + assert.equal(reopened.rawFallback, null); + assert.deepEqual(reopened.clauses, [ + { field: 'danger_signs', op: 'not', value: '' }, + { field: 'age', op: '>', value: '18' }, + ]); + assert.equal(reopened.lockedConnector, 'and'); + assert.equal(serializeBuilderState(reopened), bytes); +}); + +test('T9b case (a): a spacing-divergent `${ field }` is still raw (self-check stays authoritative)', () => { + const existing = '${ lmp_approx }'; + const s = conditionBuilderReducer(initialConditionBuilderState, { + kind: 'set-column', + column: 'relevant', + existingValue: existing, + }); + assert.equal(s.rawFallback, existing); + assert.equal(s.clauses.length, 0); +}); + +test("T9b case (b): `${field} != ''` (parser kind `answered`) reopens as \"has an answer\" and is re-emitted unchanged", () => { + const existing = "${lmp_date} != ''"; + // Document which parser path this fixture exercises (NOT `truthy`). + assert.equal(parseRelevant(existing).rules[0]?.kind, 'answered'); + + const s = conditionBuilderReducer(initialConditionBuilderState, { + kind: 'set-column', + column: 'relevant', + existingValue: existing, + }); + assert.equal(s.rawFallback, null); + assert.equal(s.clauses.length, 1); + assert.equal(s.clauses[0]?.field, 'lmp_date'); + assert.equal(s.clauses[0]?.op, 'ref'); + assert.equal(s.clauses[0]?.value, ''); + // Invariant 1 — never normalised to `${lmp_date}`. + assert.equal(serializeBuilderState(s), existing); +}); + +test("T9b case (b): `${field} = ''` reopens as \"is not selected\" and chains without rewriting the spelling", () => { + const existing = "${lmp_date} = '' and ${sex} = 'female'"; + const s = conditionBuilderReducer(initialConditionBuilderState, { + kind: 'set-column', + column: 'relevant', + existingValue: existing, + }); + assert.equal(s.rawFallback, null); + assert.equal(s.clauses.length, 2); + assert.equal(s.clauses[0]?.op, 'not'); + assert.deepEqual(s.clauses[1], { field: 'sex', op: '=', value: 'female' }); + assert.equal(serializeBuilderState(s), existing); + + // Appending a clause keeps the hydrated spelling and emits the new one canonically. + const extended = commit(setDraft(s, { field: 'age', op: '>', value: '18' }), 'and'); + assert.equal(serializeBuilderState(extended), `${existing} and \${age} > 18`); +}); + +test('T9b case (b): a stale `source` that no longer describes the clause is ignored, not emitted', () => { + // Only reachable by a caller constructing state by hand — the reducer never + // edits a committed clause — but the guard is what makes `source` safe. + let s = withColumn('relevant'); + s = setDraft(s, { field: 'other', op: 'ref', value: '', source: "${lmp_date} != ''" }); + assert.equal(serializeBuilderState(s), '${other}'); +}); diff --git a/shared/src/conditionBuilder/conditionReducer.ts b/shared/src/conditionBuilder/conditionReducer.ts index debed51..4f909c3 100644 --- a/shared/src/conditionBuilder/conditionReducer.ts +++ b/shared/src/conditionBuilder/conditionReducer.ts @@ -31,8 +31,10 @@ * legacy fragment-append path was removed in commit B (§3.7). */ import { + parseRelevant, parseRelevantGrouped, serializeAnyParsed, + serializeRelevant, type AnyParsed, type GroupedExpression, type ParsedExpression, @@ -62,6 +64,17 @@ export interface Clause { op: ClauseOp; /** Empty for ops in {'ref','today','not','selected'-without-value}. */ value: string; + /** + * The clause EXACTLY as the author wrote it, when this clause was + * hydrated from a cell whose spelling differs from what `clauseToRule` + * would emit for the same projection. Today that is `${f} != ''` / + * `${f} = ''` (parser kind `answered`), which the strip shows as + * "has an answer" / "is not selected" — the same labels as `${f}` / + * `not(${f})` — but must never be rewritten into them (T9 invariant 1: + * the preset is a projection; the stored text wins on save unless the + * projection changed). Absent on every clause the user builds. + */ + source?: string; } export type Connector = 'and' | 'or'; @@ -485,9 +498,28 @@ export function isDraftComplete(c: Clause): boolean { } function clauseToRule(c: Clause): Rule { + // A hydrated clause carries the author's own spelling. Re-emit it while + // it still projects to the same clause (field / op / value); the user + // cannot edit a committed clause in place, but a stale `source` on a + // clause that no longer matches would otherwise write the OLD text. + if (c.source !== undefined) { + const parsed = parseRelevant(c.source); + const sole = parsed.rules.length === 1 ? parsed.rules[0] : undefined; + if (sole && !parsed.isRawFallback) { + const projected = ruleToClause(sole); + if ( + projected && + projected.field === c.field && + projected.op === c.op && + projected.value === c.value + ) { + return sole; + } + } + } if (c.op === 'today') return { kind: 'raw', text: 'today()' }; - if (c.op === 'ref') return { kind: 'raw', text: `\${${c.field}}` }; - if (c.op === 'not') return { kind: 'raw', text: `not(\${${c.field}})` }; + if (c.op === 'ref') return { kind: 'truthy', field: c.field, negated: false }; + if (c.op === 'not') return { kind: 'truthy', field: c.field, negated: true }; if (c.op === 'selected') { return { kind: 'selected', field: c.field, value: c.value, negated: false }; } @@ -515,7 +547,21 @@ function ruleToClause(r: Rule): Clause | null { value: r.value, }; } - // answered / date_offset / age / raw don't have a 1:1 in the Clause shape. + if (r.kind === 'truthy') { + return { field: r.field, op: r.negated ? 'not' : 'ref', value: '' }; + } + if (r.kind === 'answered') { + // `${f} != ''` reads as "has an answer" and `${f} = ''` as "is not + // selected" — the same projection as `truthy` — but the spelling is + // kept on the clause so an unedited reopen writes back the same bytes. + return { + field: r.field, + op: r.negated ? 'not' : 'ref', + value: '', + source: serializeRelevant({ combinator: 'and', rules: [r], isRawFallback: false }), + }; + } + // date_offset / age / contact-* / raw don't have a 1:1 in the Clause shape. return null; } diff --git a/shared/src/xlsform/relevantParser.truthy.roundtrip.test.ts b/shared/src/xlsform/relevantParser.truthy.roundtrip.test.ts new file mode 100644 index 0000000..47833fd --- /dev/null +++ b/shared/src/xlsform/relevantParser.truthy.roundtrip.test.ts @@ -0,0 +1,66 @@ +/** + * T9b (#15) — `${field}` / `not(${field})` as a structured rule. + * + * Every positive case calls the serializer and asserts byte identity; the + * negative cases pin that spacing-divergent spellings stay raw (and still + * byte-identical), and that the new kind does not steal `${f} != ''` from + * `answered`. + */ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + parseRelevant, + parseRelevantGrouped, + serializeAnyParsed, + serializeRelevant, +} from './relevantParser.js'; + +test('truthy: `${f}` parses as truthy and serializes byte-identical', () => { + const src = '${lmp_approx}'; + const parsed = parseRelevant(src); + assert.equal(parsed.isRawFallback, false); + assert.deepEqual(parsed.rules, [{ kind: 'truthy', field: 'lmp_approx', negated: false }]); + assert.equal(serializeRelevant(parsed), src); +}); + +test('truthy: `not(${f})` parses as negated truthy and serializes byte-identical', () => { + const src = 'not(${danger_signs})'; + const parsed = parseRelevant(src); + assert.equal(parsed.isRawFallback, false); + assert.deepEqual(parsed.rules, [{ kind: 'truthy', field: 'danger_signs', negated: true }]); + assert.equal(serializeRelevant(parsed), src); +}); + +test('truthy: chains with other kinds and inside a grouped expression', () => { + const flat = "${a} and not(${b}) and ${c} = 'x'"; + const p = parseRelevant(flat); + assert.equal(p.isRawFallback, false); + assert.deepEqual( + p.rules.map((r) => r.kind), + ['truthy', 'truthy', 'comparison'], + ); + assert.equal(serializeRelevant(p), flat); + + const grouped = "(${a} and not(${b})) or ${c} = 'x'"; + const g = parseRelevantGrouped(grouped); + assert.equal(g.isRawFallback, false); + assert.ok('subgroups' in g); + assert.equal(serializeAnyParsed(g), grouped); +}); + +test('truthy: spacing-divergent spellings stay raw and byte-identical', () => { + for (const src of ['${ f }', 'not( ${f} )', 'not(${f} )', 'not (${f})', 'NOT(${f})']) { + const parsed = parseRelevant(src); + assert.equal(parsed.isRawFallback, true, `expected raw for: ${src}`); + assert.equal(serializeRelevant(parsed), src); + } +}); + +test("truthy: does not steal `${f} != ''` / `${f} = ''` from `answered`", () => { + const answered = parseRelevant("${f} != ''"); + assert.equal(answered.rules[0]?.kind, 'answered'); + assert.equal(serializeRelevant(answered), "${f} != ''"); + const notAnswered = parseRelevant("${f} = ''"); + assert.equal(notAnswered.rules[0]?.kind, 'answered'); + assert.equal(serializeRelevant(notAnswered), "${f} = ''"); +}); diff --git a/shared/src/xlsform/relevantParser.ts b/shared/src/xlsform/relevantParser.ts index 35da434..df20126 100644 --- a/shared/src/xlsform/relevantParser.ts +++ b/shared/src/xlsform/relevantParser.ts @@ -50,6 +50,26 @@ export interface AnsweredRule { negated: boolean; } +/** + * A field reference used directly as the boolean — `${field}` ("has an + * answer" in the inline builder) or `not(${field})` ("is not selected"). + * + * T9b (#15): the inline builder has always WRITTEN these two spellings but + * emitted them as `raw`, so on reopen the parser handed back a raw rule and + * the builder disabled every control. A rule the tool itself produced must + * open in the tool, so they get a real kind. Spacing-divergent spellings + * (`${ f }`, `not( ${f} )`) are demoted to raw by the self-check as usual. + * + * This is NOT the same rule as `answered` (`${f} != ''` / `${f} = ''`): the + * two spellings are never rewritten into each other. + */ +export interface TruthyRule { + kind: 'truthy'; + field: string; + /** True for `not(${field})`. */ + negated: boolean; +} + export interface RawRule { kind: 'raw'; text: string; @@ -156,6 +176,7 @@ export type Rule = | ComparisonRule | SelectedRule | AnsweredRule + | TruthyRule | DateOffsetRule | AgeRule | ContactInputComparisonRule @@ -360,6 +381,13 @@ function parseSinglePart(part: string): Rule { if (sel && sel[1] && sel[2] !== undefined) { return { kind: 'selected', field: sel[1], value: sel[2], negated: false }; } + // ${field} or not(${field}) — the reference itself as the boolean. + // Deliberately tight (no inner whitespace): the serializer emits exactly + // this shape, and the §3.1 self-check would demote anything looser anyway. + const truthy = /^(not\()?\$\{([^}\s]+)\}(\))?$/.exec(t); + if (truthy && truthy[2] && Boolean(truthy[1]) === Boolean(truthy[3])) { + return { kind: 'truthy', field: truthy[2], negated: truthy[1] !== undefined }; + } // ${field} = '' or ${field} != '' const ans = /^\$\{\s*([^}\s]+)\s*\}\s*(=|!=)\s*''$/.exec(t); if (ans && ans[1]) { @@ -468,6 +496,9 @@ function ruleToString(rule: Rule): string { // Answered = `${f} != ''`; not answered = `${f} = ''`. return rule.negated ? `\${${rule.field}} = ''` : `\${${rule.field}} != ''`; } + case 'truthy': { + return rule.negated ? `not(\${${rule.field}})` : `\${${rule.field}}`; + } case 'date_offset': { const op = rule.comparator === 'more_than' ? '>' : '<'; const days = UNIT_DAYS[rule.unit]; From f74b77f1df8885af7a45838c619e8fda4954a71e Mon Sep 17 00:00:00 2001 From: Prajwol Shrestha Date: Thu, 1 Oct 2026 17:41:19 +0545 Subject: [PATCH 2/6] test(e2e): "has an answer" / "is not selected" reopen in the inline builder (#15) Two Playwright specs on a throwaway copy of the mini-config fixture: - write `${lmp_date}` and `not(${danger_signs})` through the strip, save, assert the bytes via the API, reload, and reopen both rows as clauses (no "hand-written" status, undo-last-clause present, insert enabled); - the fixture's existing `${lmp_date} != ''` relevant opens as a clause, re-inserts with zero edits as the same bytes, and survives a save. Co-Authored-By: Claude Fable 5.1 --- client/tests/reopen-truthy-answered.spec.ts | 174 ++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 client/tests/reopen-truthy-answered.spec.ts diff --git a/client/tests/reopen-truthy-answered.spec.ts b/client/tests/reopen-truthy-answered.spec.ts new file mode 100644 index 0000000..e5c24ba --- /dev/null +++ b/client/tests/reopen-truthy-answered.spec.ts @@ -0,0 +1,174 @@ +/** + * T9b (#15) — rules the inline builder writes for "has an answer" (`${f}`) + * and "is not selected" (`not(${f})`) must reopen in the inline builder, + * and `${f} != ''` (already in the fixture as `lmp_note`'s relevant) must + * open as a clause and save back byte-identical. + * + * "Reopens as a clause" is observable in the strip as: no "hand-written" + * status, the `↶ undo last clause` control present (it only renders when a + * clause is committed), and `+ insert` enabled. A hand-written rule shows + * the status line and disables both. + * + * Runs on a throwaway copy of `fixtures/mini-config` so the committed + * fixture never changes. Bytes are asserted through the API (`GET + * /api/forms/app:pregnancy`), i.e. what the server re-parsed from the + * sheet on disk after the UI saved it. + */ +import { test, expect } from './setup.js'; +import type { Locator, Page } from '@playwright/test'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.resolve(here, 'fixtures', 'mini-config'); +const API = 'http://127.0.0.1:5174'; + +type SurveyRow = { name?: string; type: string; extras?: Record }; + +function rowByType(page: Page, rawType: RegExp): Locator { + return page + .locator('.survey-row') + .filter({ has: page.locator('code.type-chip-raw', { hasText: rawType }) }); +} + +async function openPregnancy(page: Page): Promise { + await page.goto('/'); + await page.locator('.nav-item', { hasText: 'Forms' }).click(); + await page.getByRole('button', { name: 'pregnancy.xlsx' }).click(); + await expect(page.locator('.survey-row').first()).toBeVisible(); +} + +async function saveForm(page: Page): Promise { + await page.locator('.page-header').getByRole('button', { name: 'Save', exact: true }).click(); + await page.locator('.rule-builder-card').getByRole('button', { name: 'Save', exact: true }).click(); + await expect( + page.locator('.page-header').getByRole('button', { name: 'Saved', exact: true }), + ).toBeVisible(); +} + +/** Open the row's advanced panel and point the unified strip at `column`. */ +async function openStrip(row: Locator, column: 'relevant' | 'constraint'): Promise { + await row.getByRole('button', { name: /show advanced/ }).click(); + const strip = row.locator('.cond-strip-unified'); + await expect(strip).toBeVisible(); + await strip.locator('.ref-chip-select').nth(0).selectOption(column); + return strip; +} + +/** The raw column textbox for `column` inside the row's advanced panel. */ +function rawColumnInput(row: Locator, column: string): Locator { + return row + .locator('label.expr-field') + .filter({ has: row.page().locator('code.raw-col-tag', { hasText: new RegExp(`^${column}$`) }) }) + .locator('input') + .first(); +} + +async function expectOpenAsClause(strip: Locator): Promise { + await expect(strip.getByText(/This rule was hand-written/)).toHaveCount(0); + await expect(strip.getByRole('button', { name: '↶ undo last clause' })).toBeVisible(); + await expect(strip.getByRole('button', { name: '+ insert' })).toBeEnabled(); + await expect(strip.locator('.ref-chip-select').nth(1)).toBeEnabled(); +} + +test('T9b — "has an answer" and "is not selected" written by the strip reopen as clauses with controls enabled', async ({ + page, + request, +}) => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cht-ui-t9b-')); + try { + await fs.cp(FIXTURE_DIR, tmp, { recursive: true }); + expect((await request.post(`${API}/api/project/open`, { data: { path: tmp } })).ok()).toBeTruthy(); + await openPregnancy(page); + + // gravidity (integer): relevant = "lmp_date has an answer" → `${lmp_date}`. + const gravidity = rowByType(page, /^integer$/); + let strip = await openStrip(gravidity, 'relevant'); + await strip.locator('.ref-chip-select').nth(1).selectOption('lmp_date'); + await strip.locator('.ref-chip-select').nth(2).selectOption('ref'); + await strip.getByRole('button', { name: '+ insert' }).click(); + await expect(rawColumnInput(gravidity, 'relevant')).toHaveValue('${lmp_date}'); + // The strip re-hydrates from what it just wrote: a clause, not hand-written text. + await expectOpenAsClause(strip); + + // chair_rise (select_one): relevant = "danger_signs is not selected" → `not(${danger_signs})`. + const chairRise = rowByType(page, /^select_one pass_fail$/); + strip = await openStrip(chairRise, 'relevant'); + await strip.locator('.ref-chip-select').nth(1).selectOption('danger_signs'); + await strip.locator('.ref-chip-select').nth(2).selectOption('not'); + await strip.getByRole('button', { name: '+ insert' }).click(); + await expect(rawColumnInput(chairRise, 'relevant')).toHaveValue('not(${danger_signs})'); + await expectOpenAsClause(strip); + + await saveForm(page); + + // Bytes on disk are exactly what the builder has always written. + const saved = (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as { + form: { survey: SurveyRow[] }; + }; + const byName = new Map(saved.form.survey.map((r) => [r.name, r])); + expect(byName.get('gravidity')?.extras?.relevant).toBe('${lmp_date}'); + expect(byName.get('chair_rise')?.extras?.relevant).toBe('not(${danger_signs})'); + + // Cold reopen after a reload: both rows come back as clauses, not text. + await openPregnancy(page); + strip = await openStrip(rowByType(page, /^integer$/), 'relevant'); + await expectOpenAsClause(strip); + strip = await openStrip(rowByType(page, /^select_one pass_fail$/), 'relevant'); + await expectOpenAsClause(strip); + } finally { + await request.post(`${API}/api/project/open`, { data: { path: FIXTURE_DIR } }); + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test("T9b — an existing `${f} != ''` relevant opens as a clause and saves back byte-identical", async ({ + page, + request, +}) => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cht-ui-t9b-')); + try { + await fs.cp(FIXTURE_DIR, tmp, { recursive: true }); + expect((await request.post(`${API}/api/project/open`, { data: { path: tmp } })).ok()).toBeTruthy(); + + const before = (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as { + form: { survey: SurveyRow[] }; + }; + expect(before.form.survey.find((r) => r.name === 'lmp_note')?.extras?.relevant).toBe( + "${lmp_date} != ''", + ); + + await openPregnancy(page); + const lmpNote = rowByType(page, /^note$/); + const strip = await openStrip(lmpNote, 'relevant'); + await expectOpenAsClause(strip); + + // Re-insert with zero edits: the clause is written back as it was + // spelled, never normalised to `${lmp_date}`. + await strip.getByRole('button', { name: '+ insert' }).click(); + await expect(rawColumnInput(lmpNote, 'relevant')).toHaveValue("${lmp_date} != ''"); + + // Dirty the form elsewhere so Save is reachable, then save. + const gravidity = rowByType(page, /^integer$/); + const gStrip = await openStrip(gravidity, 'relevant'); + await gStrip.locator('.ref-chip-select').nth(1).selectOption('lmp_date'); + await gStrip.locator('.ref-chip-select').nth(2).selectOption('ref'); + await gStrip.getByRole('button', { name: '+ insert' }).click(); + await saveForm(page); + + const after = (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as { + form: { survey: SurveyRow[] }; + }; + expect(after.form.survey.find((r) => r.name === 'lmp_note')?.extras?.relevant).toBe( + "${lmp_date} != ''", + ); + expect(after.form.survey.find((r) => r.name === 'gravidity')?.extras?.relevant).toBe( + '${lmp_date}', + ); + } finally { + await request.post(`${API}/api/project/open`, { data: { path: FIXTURE_DIR } }); + await fs.rm(tmp, { recursive: true, force: true }); + } +}); From c2399e3f326816d66d6c9d3f17999d4345946259 Mon Sep 17 00:00:00 2001 From: Prajwol Shrestha Date: Thu, 1 Oct 2026 17:43:08 +0545 Subject: [PATCH 3/6] feat(parser): `.` as a subject, `../field` as an alias of `${field}`, function forms on `.` (#14) Real validation rules are written against the answer itself and real relevants use relative paths; the shared parser only knew `${field}` subjects, so 774 of the 777 constraints in seven real configs opened as plain text. This slice is shared-only and additive. New in shared/src/xlsform/operand.ts: a closed operand grammar (`.`, `${f}` / `../f` with the spelling kept, literals, whitelisted ODK + CHT calls, `+ - * div mod`, parens). On top of it, four additive rule kinds: - expr-comparison `. >= 0`, `string-length(.) <= 100`, `int(format-date(today(),'%Y')) + 57 >= int(.)`, `. > max(coalesce(${a}, 0), ...)`, `. <= today() - 30` - predicate `regex(., '...')`, `selected(., 'none')`, `not(...)` of those - not-group `not(selected(., 'none') and count-selected(.) > 1)` - always-true `true`, `true()`, `1` (text carried, never rewritten) Each carries the clause verbatim as `source`; the serializer re-emits it while it still parses to the same rule, so `.<=100` and `. <= 100` both open AND save back byte-identical, and only a rule the author changed gets canonical spacing. `../field` on the existing comparison / selected / answered / truthy kinds is a `refSpelling: 'relative'` flag, re-emitted exactly as written in either direction. The reducer attaches `source` to any hydrated clause whose canonical emission would differ, so a `../field` rule opens in the inline strip and saves back unchanged. Also: the self-check now runs on all-raw chains too. Splitting on the combinator rejoined `a and b` with one space (six distinct real FCHV / LMP constraints); such a chain is now one raw rule, byte-identical. Consumers that switch exhaustively on Rule['kind'] show the new kinds as the text the author wrote (modal row, decisions / calc prose); a change in the modal turns the rule into a raw fragment, as before. Measured on the seven analysis configs (777 constraint cells): constraint 682 / 777 open fully structured (3 before; 51 are placeholders) relevant 1839 / 3050 drift 0 / 3919 cells (serialize(parse(x)) === x on every cell) `../` share: 410 relative refs vs 4433 ${} refs; 329 cells use `../` Tests: operand grammar; serializer-exercising round trips from non-canonical fixtures for every form in the ticket; `../` hostile fixtures live for both halves (byte identity and opens-as-clause); the all-raw self-check; an "additive" guard that every pre-T9a fixture still yields its old kind; Playwright: seeded `.` / `../` cells survive open-and-save byte-identical, the modal opens a `.` constraint as rows, a `../` relevant opens in the strip. Co-Authored-By: Claude Fable 5.1 --- client/src/ui/CalculationBuilder.tsx | 7 + client/src/ui/DecisionsView.tsx | 9 + client/src/ui/RelevantRuleBuilder.tsx | 24 ++ client/tests/parser-dot-subject.spec.ts | 149 ++++++++ .../conditionBuilder/conditionReducer.test.ts | 68 ++++ .../src/conditionBuilder/conditionReducer.ts | 38 +- shared/src/xlsform/operand.test.ts | 71 ++++ shared/src/xlsform/operand.ts | 338 ++++++++++++++++++ .../src/xlsform/relevantParser.dates.test.ts | 11 +- ...levantParser.selfSubject.roundtrip.test.ts | 242 +++++++++++++ .../relevantParser.truthy.roundtrip.test.ts | 12 +- shared/src/xlsform/relevantParser.ts | 324 ++++++++++++++++- 12 files changed, 1272 insertions(+), 21 deletions(-) create mode 100644 client/tests/parser-dot-subject.spec.ts create mode 100644 shared/src/xlsform/operand.test.ts create mode 100644 shared/src/xlsform/operand.ts create mode 100644 shared/src/xlsform/relevantParser.selfSubject.roundtrip.test.ts diff --git a/client/src/ui/CalculationBuilder.tsx b/client/src/ui/CalculationBuilder.tsx index 3a8e6df..60c7a76 100644 --- a/client/src/ui/CalculationBuilder.tsx +++ b/client/src/ui/CalculationBuilder.tsx @@ -38,6 +38,7 @@ import { type ReportFieldChoice, type ParsedCalculation, type ParsedExpression, + serializeRule, } from '@cht-ui/shared'; import { useApp } from '../state/store.js'; import { RelevantRuleBuilder } from './RelevantRuleBuilder.js'; @@ -1345,6 +1346,12 @@ function conditionProse(cond: ParsedExpression): string { if (r.kind === 'truthy') { return `\${${r.field}} ${r.negated ? 'is not selected' : 'has an answer'}`; } + if (r.kind === 'expr-comparison' || r.kind === 'predicate' || r.kind === 'not-group') { + return serializeRule(r); // T9a — shown as written until 9e gives presets + } + if (r.kind === 'always-true') { + return 'always passes'; + } if (r.kind === 'age') { return `age of \${${r.field}} ${r.op} ${r.value} years`; } diff --git a/client/src/ui/DecisionsView.tsx b/client/src/ui/DecisionsView.tsx index e558b27..4fa06b5 100644 --- a/client/src/ui/DecisionsView.tsx +++ b/client/src/ui/DecisionsView.tsx @@ -25,6 +25,7 @@ import { type AppliesIfRule, type ContextRule, type ParsedCalculation, + serializeRule, } from '@cht-ui/shared'; import { api } from '../api.js'; import { useApp } from '../state/store.js'; @@ -489,6 +490,14 @@ function relevantToHumanLines(cond: import('@cht-ui/shared').ParsedExpression): return r.negated ? `${r.field} is empty` : `${r.field} is answered`; case 'truthy': return r.negated ? `${r.field} is not selected` : `${r.field} has an answer`; + case 'expr-comparison': + case 'predicate': + case 'not-group': + // T9a — rules about the answer itself; shown as written until 9e + // gives them presets. + return serializeRule(r); + case 'always-true': + return 'always passes (no rule)'; case 'age': return `age of ${r.field} ${r.op} ${r.value} years`; case 'date_offset': diff --git a/client/src/ui/RelevantRuleBuilder.tsx b/client/src/ui/RelevantRuleBuilder.tsx index 27c9b75..328e559 100644 --- a/client/src/ui/RelevantRuleBuilder.tsx +++ b/client/src/ui/RelevantRuleBuilder.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'; import { parseRelevant, serializeRelevant, + serializeRule, type ContextWrapper, type DateOffsetComparator, type DateOffsetDirection, @@ -605,6 +606,29 @@ function RuleRow(props: { ); } + if ( + rule.kind === 'expr-comparison' || + rule.kind === 'predicate' || + rule.kind === 'not-group' || + rule.kind === 'always-true' + ) { + // T9a (#14): rules about the answer itself (`. >= 0`, `regex(., '…')`, + // `not(selected(., 'none') and …)`, `true`) parse structurally now but + // have no visual row until the Validation panel (9e). Show the text the + // author wrote; an edit turns it into a raw fragment, exactly as before. + return ( +
+ props.onChange({ kind: 'raw', text: e.target.value })} + placeholder="expression" + className="raw-rule-input" + aria-label="Rule about this answer" + /> + +
+ ); + } // raw return (
diff --git a/client/tests/parser-dot-subject.spec.ts b/client/tests/parser-dot-subject.spec.ts new file mode 100644 index 0000000..58dd741 --- /dev/null +++ b/client/tests/parser-dot-subject.spec.ts @@ -0,0 +1,149 @@ +/** + * T9a (#14) — rules about the answer itself (`.`) and relative-path + * relevants (`../field`) through the running app. + * + * Shared-only slice, so the UI assertion is deliberately narrow: + * 1. open-and-save with zero edits to these cells leaves every one of + * them byte-identical on disk, including the tight-spaced `.<=100` + * that now parses structurally (the no-normalise invariant); + * 2. the "✎ build" modal opens a `.` constraint in Visual mode as rows, + * not as the "couldn't be parsed" raw warning; + * 3. a `../field` relevant opens in the inline strip as a clause. + * + * The cells are seeded through the API on a throwaway copy of the fixture. + */ +import { test, expect } from './setup.js'; +import type { Locator, Page } from '@playwright/test'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.resolve(here, 'fixtures', 'mini-config'); +const API = 'http://127.0.0.1:5174'; + +type SurveyRow = { name?: string; type: string; labels?: unknown; extras: Record }; +type FormBody = { form: { survey: SurveyRow[] }; properties?: unknown }; + +const SEED: Record> = { + gravidity: { constraint: '. >= 0 and . <= 20', 'constraint_message::en': 'Must be 0 to 20' }, + chair_rise: { constraint: '.<=100' }, + danger_signs: { constraint: "not(selected(., 'none') and count-selected(.) > 1)" }, + lmp_note: { relevant: "../lmp_date != ''" }, + lmp_date: { constraint: '. <= today()', relevant: "selected(../danger_signs, 'none')" }, +}; + +function rowByType(page: Page, rawType: RegExp): Locator { + return page + .locator('.survey-row') + .filter({ has: page.locator('code.type-chip-raw', { hasText: rawType }) }); +} + +async function getForm(request: Parameters[1]>[0]['request']): Promise { + return (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as FormBody; +} + +test('T9a — `.` constraints and `../field` relevants survive open-and-save byte-identical and open structurally', async ({ + page, + request, +}) => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cht-ui-t9a-')); + try { + await fs.cp(FIXTURE_DIR, tmp, { recursive: true }); + expect((await request.post(`${API}/api/project/open`, { data: { path: tmp } })).ok()).toBeTruthy(); + + // Seed the cells through the API. + const body = await getForm(request); + for (const row of body.form.survey) { + const seed = row.name ? SEED[row.name] : undefined; + if (seed) row.extras = { ...row.extras, ...seed }; + } + const put = await request.put(`${API}/api/forms/app:pregnancy`, { + data: { form: body.form, properties: body.properties ?? null }, + }); + expect(put.ok()).toBeTruthy(); + const seeded = await getForm(request); + for (const [name, cells] of Object.entries(SEED)) { + const row = seeded.form.survey.find((r) => r.name === name); + for (const [col, val] of Object.entries(cells)) { + expect(row?.extras[col], `${name}.${col} seeded`).toBe(val); + } + } + + // Open the form in the UI. + await page.goto('/'); + await page.locator('.nav-item', { hasText: 'Forms' }).click(); + await page.getByRole('button', { name: 'pregnancy.xlsx' }).click(); + await expect(page.locator('.survey-row').first()).toBeVisible(); + + // (2) The modal shows the `.` constraint as rows, not as a raw warning. + const gravidity = rowByType(page, /^integer$/); + await gravidity.getByRole('button', { name: /show advanced/ }).click(); + const constraintField = gravidity + .locator('label.expr-field') + .filter({ has: page.locator('code.raw-col-tag', { hasText: /^constraint$/ }) }); + await expect(constraintField.locator('input').first()).toHaveValue('. >= 0 and . <= 20'); + // The button sits inside the
); diff --git a/client/tests/add-question-configure.spec.ts b/client/tests/add-question-configure.spec.ts new file mode 100644 index 0000000..d66c508 --- /dev/null +++ b/client/tests/add-question-configure.spec.ts @@ -0,0 +1,282 @@ +/** + * T9d (#17) — the add-question configure step, insert-after-current-row, + * and scroll + highlight of the new row. + * + * Acceptance under test: + * 1. add **age** (integer) with required and a hint set in the configure + * step; the new row appears directly after the row the author was + * on, scrolled into view and highlighted; + * 2. the sheet on disk has the row in that position with `required` and + * `hint::` set (and the Validation slot's constraint + message); + * every other row is byte-identical; + * 3. adding inside a group keeps `begin group` / `end group` paired; + * 4. the step is skippable: "add without details" for one question, and + * the remembered "always skip this step" preference for one-click + * tiles (which is what the rest of the suite runs with). + * + * The suite's fixture seeds the one-click preference ON; this spec clears + * it so the step itself is exercised. + */ +import { test, expect } from './setup.js'; +import type { Locator, Page } from '@playwright/test'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.resolve(here, 'fixtures', 'mini-config'); +const API = 'http://127.0.0.1:5174'; + +type SurveyRow = { + name?: string; + type: string; + required?: string; + labels?: Record; + extras: Record; +}; +type FormBody = { form: { survey: SurveyRow[] } }; + +function rowByType(page: Page, rawType: RegExp): Locator { + return page + .locator('.survey-row') + .filter({ has: page.locator('code.type-chip-raw', { hasText: rawType }) }); +} + +async function openPregnancy(page: Page): Promise { + await page.goto('/'); + await page.locator('.nav-item', { hasText: 'Forms' }).click(); + await page.getByRole('button', { name: 'pregnancy.xlsx' }).click(); + await expect(page.locator('.survey-row').first()).toBeVisible(); +} + +async function saveForm(page: Page): Promise { + await page.locator('.page-header').getByRole('button', { name: 'Save', exact: true }).click(); + await page.locator('.rule-builder-card').getByRole('button', { name: 'Save', exact: true }).click(); + await expect( + page.locator('.page-header').getByRole('button', { name: 'Saved', exact: true }), + ).toBeVisible(); +} + +/** The row card whose name textbox reads `name` (React-controlled inputs carry no value attribute). */ +async function rowByName(page: Page, name: string): Promise { + const rows = page.locator('.survey-row').filter({ + has: page.getByRole('textbox', { name: 'name', exact: true }), + }); + const n = await rows.count(); + for (let i = 0; i < n; i++) { + const row = rows.nth(i); + if ((await row.getByRole('textbox', { name: 'name', exact: true }).inputValue()) === name) { + return row; + } + } + throw new Error(`no row named ${name}`); +} + +/** Names of the rendered row cards, in DOM order. */ +async function renderedNames(page: Page): Promise { + const inputs = page.locator('.survey-row').getByRole('textbox', { name: 'name', exact: true }); + const n = await inputs.count(); + const out: string[] = []; + for (let i = 0; i < n; i++) out.push(await inputs.nth(i).inputValue()); + return out; +} + +async function withScratchProject( + request: Parameters[1]>[0]['request'], + body: (tmp: string) => Promise, +): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cht-ui-t9d-')); + try { + await fs.cp(FIXTURE_DIR, tmp, { recursive: true }); + expect((await request.post(`${API}/api/project/open`, { data: { path: tmp } })).ok()).toBeTruthy(); + await body(tmp); + } finally { + await request.post(`${API}/api/project/open`, { data: { path: FIXTURE_DIR } }); + await fs.rm(tmp, { recursive: true, force: true }); + } +} + +test.describe('configure step on (the default for a new author)', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + try { + // eslint-disable-next-line no-undef + window.localStorage.setItem('cht-ui-builder.oneClickTiles', 'false'); + } catch { + /* storage unavailable */ + } + }); + }); + + test('T9d — age (integer) with required, hint and a rule lands right after the current row, scrolled and highlighted; bytes on disk', async ({ + page, + request, + }) => { + await withScratchProject(request, async () => { + const before = (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as FormBody; + + await openPregnancy(page); + // The author is on the LMP date row: focus a control inside its card. + const lmp = rowByType(page, /^date$/); + await lmp.getByRole('textbox', { name: 'name', exact: true }).click(); + + await page.getByRole('button', { name: '+ Question', exact: true }).click(); + const picker = page.locator('.qtype-modal'); + await picker.getByPlaceholder(/has_fever/).fill('age'); + await picker.locator('.qtype-locale-label input').first().fill('Age'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Number$/ }) }) + .click(); + + // The configure step, not an immediate commit. + const configure = picker.locator('.qtype-configure'); + await expect(configure).toBeVisible(); + await configure.getByRole('checkbox', { name: /^Required/ }).check(); + // The fixture is bilingual (en, ne): one hint and one message per language. + await configure.getByPlaceholder(/Help text/).first().fill('Years'); + await configure.getByPlaceholder(/\. >= 0 and \. <= 20/).fill('. >= 0 and . <= 20'); + await configure.getByPlaceholder(/Message when the answer is rejected/).first().fill('Must be 0 to 20'); + await configure.getByRole('button', { name: 'Add question', exact: true }).click(); + await expect(picker).not.toBeVisible(); + + // Directly after lmp_date, highlighted, in view. + const names = await renderedNames(page); + expect(names[names.indexOf('lmp_date') + 1]).toBe('age'); + const flashed = page.locator('.survey-row.row-flash'); + await expect(flashed).toHaveCount(1); + await expect(flashed.getByRole('textbox', { name: 'name', exact: true })).toHaveValue('age'); + await expect(flashed).toBeInViewport(); + + await saveForm(page); + const after = (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as FormBody; + const idx = after.form.survey.findIndex((r) => r.name === 'age'); + expect(after.form.survey[idx - 1]?.name).toBe('lmp_date'); + const age = after.form.survey[idx]!; + expect(age.type).toBe('integer'); + expect(age.required).toBe('yes'); + expect(age.extras['hint::en']).toBe('Years'); + expect(age.extras['constraint']).toBe('. >= 0 and . <= 20'); + expect(age.extras['constraint_message::en']).toBe('Must be 0 to 20'); + expect(age.labels?.['en']).toBe('Age'); + // Every other row is unchanged (row ids are positional and shift by one + // after the insert, so compare the cells, not the ids). + const cells = (r: SurveyRow) => ({ + type: r.type, + name: r.name, + required: r.required, + labels: r.labels, + extras: r.extras, + }); + const others = after.form.survey.filter((r) => r.name !== 'age').map(cells); + expect(others).toEqual(before.form.survey.map(cells)); + }); + }); + + test('T9d — "add without details" is the one-click behaviour for a single question', async ({ + page, + request, + }) => { + await withScratchProject(request, async () => { + await openPregnancy(page); + await page.getByRole('button', { name: '+ Question', exact: true }).click(); + const picker = page.locator('.qtype-modal'); + await picker.getByPlaceholder(/has_fever/).fill('quick'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Text$/ }) }) + .click(); + await expect(picker.locator('.qtype-configure')).toBeVisible(); + await picker.getByRole('button', { name: 'add without details' }).click(); + await expect(picker).not.toBeVisible(); + const names = await renderedNames(page); + expect(names).toContain('quick'); + // Not required, no hint, no constraint: exactly the one-click row. + const row = page.locator('.survey-row.row-flash'); + await expect(row.getByRole('checkbox', { name: 'required' })).not.toBeChecked(); + }); + }); + + test('T9d — adding while on a row inside a group lands inside it, right after that row, and keeps the pair balanced', async ({ + page, + request, + }) => { + await withScratchProject(request, async () => { + await openPregnancy(page); + await page.getByRole('button', { name: 'Full', exact: true }).click(); + const picker = page.locator('.qtype-modal'); + + // A fresh group (structural tiles never get the configure step). + await page.getByRole('button', { name: '+ Question', exact: true }).click(); + await picker.getByPlaceholder(/has_fever/).fill('blk'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Group$/ }) }) + .click(); + await expect(picker).not.toBeVisible(); + + // The group header carries the begin row's id, so focusing it makes the + // group the current row: "+ Question" lands as its first child. + const blk = page + .locator('.survey-group-accordion') + .filter({ has: page.locator('.survey-group-header code', { hasText: 'blk' }) }); + await expect(blk).toHaveCount(1); + const header = blk.locator('button.survey-group-header').first(); + await header.focus(); + await page.getByRole('button', { name: '+ Question', exact: true }).click(); + await picker.getByPlaceholder(/has_fever/).fill('first'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Number$/ }) }) + .click(); + await picker.getByRole('button', { name: 'add without details' }).click(); + await expect(picker).not.toBeVisible(); + + // Now the author is ON `first`; "+ Question" lands right after it, inside. + // (Expand the accordion first if the new child is folded away.) + if ((await header.getAttribute('aria-expanded')) === 'false') await header.click(); + await (await rowByName(page, 'first')).getByRole('textbox', { name: 'name', exact: true }).click(); + await page.getByRole('button', { name: '+ Question', exact: true }).click(); + await picker.getByPlaceholder(/has_fever/).fill('second'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Number$/ }) }) + .click(); + await picker.getByRole('button', { name: 'add without details' }).click(); + await expect(picker).not.toBeVisible(); + + await expect(page.locator('.page-header .badge.danger')).toHaveCount(0); + await saveForm(page); + const after = (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as FormBody; + const seq = after.form.survey.map((r) => `${r.type.trim().toLowerCase()}:${r.name ?? ''}`); + const begin = seq.indexOf('begin group:blk'); + expect(begin).toBeGreaterThanOrEqual(0); + expect(seq.slice(begin, begin + 4)).toEqual([ + 'begin group:blk', + 'integer:first', + 'integer:second', + 'end group:blk', + ]); + }); + }); +}); + +test('T9d — with "always skip this step" remembered, a question tile commits on the click', async ({ + page, + request, +}) => { + // The suite fixture seeds the preference ON (see setup.ts). + await withScratchProject(request, async () => { + await openPregnancy(page); + await page.getByRole('button', { name: '+ Question', exact: true }).click(); + const picker = page.locator('.qtype-modal'); + await picker.getByPlaceholder(/has_fever/).fill('oneclick'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Number$/ }) }) + .click(); + await expect(picker).not.toBeVisible(); + expect(await renderedNames(page)).toContain('oneclick'); + }); +}); diff --git a/client/tests/demo.spec.ts b/client/tests/demo.spec.ts index d0ef6c0..d556632 100644 --- a/client/tests/demo.spec.ts +++ b/client/tests/demo.spec.ts @@ -149,9 +149,11 @@ test('demo 1 — author a survey: edit, choices, add, move, group + nest, transl 'lmp_date', 'lmp_note', 'danger_signs', + // T9d (#17): "+ Question" inserts after the row the author is on — + // the danger_signs card they just edited — not at the end of the sheet. + 'temperature', 'chair_rise', 'gravida', - 'temperature', ]); // `.first()` = the ROW header's move button (the open choices panel adds // per-option move buttons that would otherwise make this ambiguous). @@ -161,18 +163,20 @@ test('demo 1 — author a survey: edit, choices, add, move, group + nest, transl 'lmp_date', 'danger_signs', 'lmp_note', + 'temperature', 'chair_rise', 'gravida', - 'temperature', ]); await danger().getByRole('button', { name: 'move down' }).first().click(); expect(await visibleRowNames(page)).toEqual([ 'lmp_date', 'lmp_note', 'danger_signs', + // T9d (#17): "+ Question" inserts after the row the author is on — + // the danger_signs card they just edited — not at the end of the sheet. + 'temperature', 'chair_rise', 'gravida', - 'temperature', ]); }); diff --git a/shared/src/xlsform/surveyEdits.insertAfter.test.ts b/shared/src/xlsform/surveyEdits.insertAfter.test.ts new file mode 100644 index 0000000..611d766 --- /dev/null +++ b/shared/src/xlsform/surveyEdits.insertAfter.test.ts @@ -0,0 +1,64 @@ +/** + * T9d (#17) — "+ Question" inserts after the row the author is on, and an + * insert inside a group keeps its begin / end pair balanced. + */ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { defaultInsertIndex, insertIndexAfterRow } from './surveyEdits.js'; +import { isStructurallyBalanced } from './structuralBalance.js'; +import type { SurveyRow } from './types.js'; + +function row(rowId: string, type: string, name = rowId): SurveyRow { + return { rowId, type, name, labels: {}, extras: {} } as SurveyRow; +} + +const survey: SurveyRow[] = [ + row('lmp', 'date'), + row('g_begin', 'begin group', 'danger'), + row('ds', 'select_multiple ds'), + row('note', 'note'), + row('g_end', 'end group', 'danger'), + row('grav', 'integer'), + row('calc1', 'calculate'), + row('calc2', 'calculate'), +]; + +function spliced(at: number): SurveyRow[] { + return [...survey.slice(0, at), row('age', 'integer'), ...survey.slice(at)]; +} + +test('insertIndexAfterRow: directly after a top-level row', () => { + const at = insertIndexAfterRow(survey, 'lmp'); + assert.equal(at, 1); + assert.equal(spliced(at)[1]?.rowId, 'age'); + assert.ok(isStructurallyBalanced(spliced(at))); +}); + +test('insertIndexAfterRow: after a row inside a group stays inside and balanced', () => { + const at = insertIndexAfterRow(survey, 'ds'); + const next = spliced(at); + assert.deepEqual( + next.map((r) => r.rowId).slice(1, 6), + ['g_begin', 'ds', 'age', 'note', 'g_end'], + ); + assert.ok(isStructurallyBalanced(next)); +}); + +test('insertIndexAfterRow: after a begin-group row becomes its first child', () => { + const at = insertIndexAfterRow(survey, 'g_begin'); + const next = spliced(at); + assert.deepEqual(next.map((r) => r.rowId).slice(1, 4), ['g_begin', 'age', 'ds']); + assert.ok(isStructurallyBalanced(next)); +}); + +test('insertIndexAfterRow: after the last question lands before the trailing plumbing calculates', () => { + // Explicit "after grav" is index 6 — the author asked for that spot. + assert.equal(insertIndexAfterRow(survey, 'grav'), 6); + // No current row → the default, which is also before the plumbing run. + assert.equal(insertIndexAfterRow(survey, null), defaultInsertIndex(survey)); + assert.equal(defaultInsertIndex(survey), 6); +}); + +test('insertIndexAfterRow: unknown row id falls back to the default position', () => { + assert.equal(insertIndexAfterRow(survey, 'nope'), defaultInsertIndex(survey)); +}); diff --git a/shared/src/xlsform/surveyEdits.ts b/shared/src/xlsform/surveyEdits.ts index 2b92d4e..d2ea765 100644 --- a/shared/src/xlsform/surveyEdits.ts +++ b/shared/src/xlsform/surveyEdits.ts @@ -276,3 +276,18 @@ export function defaultInsertIndex(survey: SurveyRow[]): number { } return trailingStart === -1 ? survey.length : trailingStart; } + +/** + * T9d (#17) — the index where "+ Question" lands when the author is ON a + * row: directly after it. A `begin group` / `begin repeat` row counts as + * "inside the group" (its first child), so the pair stays balanced; any + * other row is followed by its new sibling, which keeps every enclosing + * pair balanced too. An unknown or absent `rowId` falls back to + * {@link defaultInsertIndex}, the before-the-trailing-plumbing position. + */ +export function insertIndexAfterRow(survey: SurveyRow[], rowId: string | null): number { + if (!rowId) return defaultInsertIndex(survey); + const idx = survey.findIndex((r) => r.rowId === rowId); + if (idx < 0) return defaultInsertIndex(survey); + return idx + 1; +} From 7121f5d2856dce5b2b4928ceb85a5f114b594ae2 Mon Sep 17 00:00:00 2001 From: Prajwol Shrestha Date: Thu, 1 Oct 2026 18:59:27 +0545 Subject: [PATCH 5/6] feat(validation): Validation panel with presets per question type, messages beside the rule; never normalises (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation is where a program says what a correct answer is, and the tool could show 3 of 777 real rules. With `.` readable (9a) most real rules are a dozen shapes a non-developer recognises by name. This slice gives them a panel and replaces both constraint builders. shared/src/validation/presets.ts (pure, node-tested): the preset model (between, compare-value, compare-field, compare-today, days-from-today, months-from-today, after-all-of, text-length, allowed-chars, pattern, int-value, bs-year, choice-alone, count-selected, always-true, code), `parseValidation` (one parsed rule → one preset; two adjacent numeric bounds → "between"; an `or` chain, a grouped or unparseable cell → one code item), `emitPreset` (canonical XPath), `presetsFor` (the menu per question kind; today() for dates, now() for date-times), `suggestMessage` and `presetComplete`. THE RECOGNISER NEVER NORMALISES. Every item keeps `source`, the rule text as written; `serializeValidation` re-emits it while it still reads as the same preset and writes the canonical spelling only for an item the author changed. `. <= 100 and . >= 70` is displayed as "Between 70 and 100" and saved as `. <= 100 and . >= 70`; edit the maximum and that item becomes `. >= 70 and . <= 99` while its siblings keep their spelling. relevantParser.ts: ParsedExpression gains an optional `separators` (the text between rules exactly as written — ` and\n`, ` and `), set only when a join is not canonical and honoured by serializeRelevant while it still fits the rule count and spells the combinator. Real configs break ~90 chains across a newline or a double space; they were raw, now they open. Additive: canonical cells and consumer-built expressions carry no separators. client/src/ui/ValidationPanel.tsx: a list of sentence-shaped preset rows with inputs, "+ Add rule" per question kind (plus a plain expression), a code toggle, constraint_message per visible language with a suggested text that stops as soon as the author writes their own, and (row editor) the required checkbox with required_message per language beside it. `true` / `true()` / `1` show "This rule always passes: no validation". Incomplete presets stay on screen without writing a broken expression; only complete items reach the cell. The rule and a suggested message are written in ONE row update (two updates in a tick each started from the same stale row and the second won). Mounted in the row editor in place of the constraint expression field (constraint_message inputs leave the hints block; required_message leaves the raw overrides), and in the add-question configure step's Validation slot (9d) in place of the expression box + modal. The inline strip no longer offers the constraint column. Measured on the seven analysis configs (777 constraint cells): 575 open entirely as presets, 61 as presets plus a plain-text item, 51 are placeholders now labelled, 90 stay plain text (mixed and/or inside not(), curly quotes, decimal-date-time arithmetic). Parser-level: 730 of 777 structured. Drift 0 of 777 through parseValidation → serializeValidation, and 0 of 3919 cells through the parser. A form holding every preset's canonical output (40 constraints) compiles with pyxform 4.5 xls2xform, the step cht-conf runs (Docker is not running on this machine; cht-conf itself is installed). Tests: presets.test.ts (every round trip from a non-canonical fixture through the serializer; edited-item canonicalisation; stale source ignored; every catalogue entry emits and parses back to itself; separators), relevantParser separators tests, Playwright validation-panel.spec.ts (age with Between 0 and 20 in the picker → sheet → reopened preset; reverse-order between, tight `.<=today()` and `true()` displayed as presets and saved byte-identical, then one edited item canonical; text length, date not in the future, select-many choice alone, required message, cells asserted on disk). Two earlier specs updated for the panel replacing the constraint box and modal. Co-Authored-By: Claude Fable 5.1 --- client/src/ui/FormEditor.tsx | 60 +- client/src/ui/QuestionTypePicker.tsx | 86 +- client/src/ui/ValidationPanel.css | 69 ++ client/src/ui/ValidationPanel.tsx | 802 ++++++++++++++++++ client/tests/add-question-configure.spec.ts | 8 +- client/tests/parser-dot-subject.spec.ts | 29 +- client/tests/validation-panel.spec.ts | 247 ++++++ shared/src/index.ts | 1 + shared/src/validation/presets.test.ts | 240 ++++++ shared/src/validation/presets.ts | 641 ++++++++++++++ ...levantParser.selfSubject.roundtrip.test.ts | 48 +- shared/src/xlsform/relevantParser.ts | 79 +- 12 files changed, 2192 insertions(+), 118 deletions(-) create mode 100644 client/src/ui/ValidationPanel.css create mode 100644 client/src/ui/ValidationPanel.tsx create mode 100644 client/tests/validation-panel.spec.ts create mode 100644 shared/src/validation/presets.test.ts create mode 100644 shared/src/validation/presets.ts diff --git a/client/src/ui/FormEditor.tsx b/client/src/ui/FormEditor.tsx index 2ccca33..71ac202 100644 --- a/client/src/ui/FormEditor.tsx +++ b/client/src/ui/FormEditor.tsx @@ -93,6 +93,7 @@ import { LineageBuilder } from './LineageBuilder.js'; import { InlineChoicesEditor } from './InlineChoicesEditor.js'; import { ChoiceNameInput } from './ChoiceNameInput.js'; import { InsertLabelRefButton } from './InsertLabelRefButton.js'; +import { ValidationPanel } from './ValidationPanel.js'; import { useHistory } from '../state/useHistory.js'; import { showUndoToast } from './UndoToast.js'; @@ -2352,17 +2353,39 @@ function SurveyRowCard(props: { inputContactFields={props.inputContactFields} contextKeys={props.contextKeys} /> - setExtra('constraint', v)} + locales={props.locales.filter((loc) => !props.hiddenLocales.has(loc))} + messages={Object.fromEntries( + props.locales.map((loc) => [loc, row.extras[`constraint_message::${loc}`] ?? '']), + )} + onMessageChange={(loc, v) => setExtra(`constraint_message::${loc}`, v)} + onBatch={(changes) => + props.update((r) => { + const nextExtras = { ...r.extras }; + for (const [k, v] of Object.entries(changes)) { + if (v === '') delete nextExtras[k]; + else nextExtras[k] = v; + } + return { ...r, extras: nextExtras }; + }) + } + required={Boolean(row.required && row.required !== 'no' && row.required !== 'false')} + onRequiredChange={(next) => + props.update((r) => ({ ...r, required: next ? 'yes' : '' })) + } + requiredMessages={Object.fromEntries( + props.locales.map((loc) => [loc, row.extras[`required_message::${loc}`] ?? '']), + )} + onRequiredMessageChange={(loc, v) => setExtra(`required_message::${loc}`, v)} fieldOptions={props.fieldOptions} - fieldChoiceOptions={props.fieldChoiceOptions} - inputContactFields={props.inputContactFields} - contextKeys={props.contextKeys} + choices={props.fieldChoiceOptions[row.name] ?? []} /> {isSelectRow(row) && ( setExtra(`hint::${loc}`, v)} /> ))} - {row.extras['constraint'] && - props.locales - .filter((loc) => !props.hiddenLocales.has(loc)) - .map((loc) => ( - setExtra(`constraint_message::${loc}`, v)} - /> - ))} + {/* constraint_message inputs moved into the Validation panel (T9e). */}
@@ -2453,7 +2463,8 @@ function SurveyRowCard(props: { 'repeat_count', ].includes(k) && !k.startsWith('hint::') && - !k.startsWith('constraint_message::'), + !k.startsWith('constraint_message::') && + !k.startsWith('required_message::'), ) .map(([k, v]) => ( ', '<', '>=', '<=', 'selecte // not a boolean, and is edited via the dedicated CalculationBuilder // (mounted by ExpressionField when `supportsCalculation` holds). See // docs/plans/calculation-builder.md v0.2 §3.6 — "double-door" fix. +// T9e (#18): `constraint` is no longer offered here — the Validation panel +// owns that column (presets per question type, messages beside the rule). const COLUMN_OPTIONS = [ { value: 'relevant', label: 'Show when… (relevant)' }, - { value: 'constraint', label: 'Accept only if… (constraint)' }, { value: 'choice_filter', label: 'Filter choices when… (choice_filter)' }, ] as const; diff --git a/client/src/ui/QuestionTypePicker.tsx b/client/src/ui/QuestionTypePicker.tsx index cf23a14..407bba1 100644 --- a/client/src/ui/QuestionTypePicker.tsx +++ b/client/src/ui/QuestionTypePicker.tsx @@ -12,7 +12,7 @@ */ import { useEffect, useMemo, useRef, useState } from 'react'; import { slugifyHierarchyId, type ReportFieldChoice } from '@cht-ui/shared'; -import { RelevantRuleBuilder } from './RelevantRuleBuilder.js'; +import { ValidationPanel } from './ValidationPanel.js'; import { CATEGORY_LABELS, CATEGORY_ORDER, @@ -196,7 +196,6 @@ export function QuestionTypePicker(props: Props) { const [hints, setHints] = useState>({}); const [constraint, setConstraint] = useState(''); const [constraintMessages, setConstraintMessages] = useState>({}); - const [showConstraintBuilder, setShowConstraintBuilder] = useState(false); const [sectionLabel, setSectionLabel] = useState(''); const [sectionAppearance, setSectionAppearance] = useState<'default' | 'field-list'>('default'); const [sectionKind, setSectionKind] = useState<'group' | 'repeat'>('group'); @@ -869,62 +868,33 @@ export function QuestionTypePicker(props: Props) { ))} -
- - Validation{' '} - — accept the answer only if… - - - {activeLocales.map((loc) => ( - - ))} -
- - {showConstraintBuilder && props.fieldOptions && ( - setShowConstraintBuilder(false)} - onSave={(v) => { - setConstraint(v); - setShowConstraintBuilder(false); - }} - /> - )} + {/* T9e (#18) — the Validation panel: presets per question type, + a message per language. The choices typed in the previous + step feed "[choice] must be chosen alone". */} + setConstraintMessages((prev) => ({ ...prev, [loc]: v }))} + fieldOptions={props.fieldOptions ?? []} + choices={ + listChoice === 'new' + ? draftChoices + .filter((c) => c.name.trim()) + .map((c) => ({ + name: c.name.trim(), + label: (c.labels[activeLocales[0] ?? 'en'] ?? '').trim() || c.name.trim(), + })) + : [] + } + />
+ )} +
+ + {onlyPlaceholder && ( +

+ This rule always passes: no validation. Remove it to add a real rule. +

+ )} + +
    + {items.map((item, idx) => ( +
  1. + updateItem(idx, p)} + /> + {!presetComplete(item.preset) && ( + fill in to apply + )} + +
  2. + ))} +
+ +
+ + {showCode && hasRule && ( + + {code || '(incomplete)'} + + )} +
+ + {hasRule && !onlyPlaceholder && ( +
+ {props.locales.map((loc) => ( + + ))} +
+ )} + + {props.onRequiredChange && ( +
+ + {props.required && + props.locales.map((loc) => ( + + ))} +
+ )} + + ); +} + +/* ------------------------------------------------------------------------ */ +/* One preset row */ +/* ------------------------------------------------------------------------ */ + +function PresetRow(props: { + preset: Preset; + kind: QuestionKind; + locales: string[]; + fieldOptions: string[]; + fieldLabel: (name: string) => string; + choices: ReportFieldChoice[]; + onChange: (p: Preset) => void; +}) { + const p = props.preset; + const numeric = props.kind === 'integer' || props.kind === 'decimal'; + const step = props.kind === 'integer' ? 1 : 'any'; + const isDate = props.kind === 'date' || props.kind === 'datetime'; + + const num = (value: string, onChange: (v: string) => void, label: string) => ( + onChange(e.target.value)} + aria-label={label} + className="validation-value" + /> + ); + + const fieldSelect = (name: string, onChange: (n: string) => void, label: string) => ( + + ); + + switch (p.kind) { + case 'between': + return ( + + Between {num(p.min.value, (v) => props.onChange({ ...p, min: { ...p.min, value: v } }), 'Minimum')} + + and {num(p.max.value, (v) => props.onChange({ ...p, max: { ...p.max, value: v } }), 'Maximum')} + + + ); + case 'compare-value': + return ( + + Is + + {p.isString ? ( + props.onChange({ ...p, value: e.target.value })} + aria-label="Text value" + className="validation-value" + /> + ) : ( + num(p.value, (v) => props.onChange({ ...p, value: v }), 'Value') + )} + + ); + case 'compare-field': + return ( + + Is + + {fieldSelect(p.field.name, (n) => props.onChange({ ...p, field: { ...p.field, name: n } }), 'Other question')} + + ); + case 'compare-today': + return ( + + + ({p.clock}()) + + ); + case 'days-from-today': + return ( + + + {num(p.days, (v) => props.onChange({ ...p, days: v }), 'Days')} days + + ); + case 'months-from-today': + return ( + + Months from today + + {num(p.months, (v) => props.onChange({ ...p, months: v }), 'Months')} + + ); + case 'after-all-of': + return ( + + + {p.fields.map((f, i) => ( + + {fieldSelect( + f.name, + (n) => props.onChange({ ...p, fields: p.fields.map((g, j) => (j === i ? { ...g, name: n } : g)) }), + `Date ${i + 1}`, + )} + + + ))} + + + ); + case 'text-length': + return ( + + + props.onChange({ ...p, n: e.target.value })} + aria-label="Characters" + className="validation-value" + />{' '} + characters + + ); + case 'allowed-chars': + return ( + + Allowed characters: + + {p.mode === 'letters' && + (['latin', 'devanagari'] as Script[]).map((s) => ( + + ))} + + ); + case 'pattern': + return props.onChange({ ...p, pattern })} />; + case 'int-value': + return ( + + As a number, is + + props.onChange({ ...p, value: e.target.value })} + aria-label="Value" + className="validation-value" + /> + + ); + case 'bs-year': + return ( + + Year (B.S.) + + {p.bound !== 'max-this-year' && ( + <> + props.onChange({ ...p, years: e.target.value })} + aria-label="Years" + className="validation-value" + />{' '} + years + + )} + + ); + case 'choice-alone': + return ( + + + {!props.choices.some((c) => c.name === p.choice) && ( + props.onChange({ ...p, choice: e.target.value })} + aria-label="Choice name" + className="validation-value" + placeholder="choice name" + /> + )} + must be chosen alone + + ); + case 'count-selected': + return ( + + Choose + + props.onChange({ ...p, value: e.target.value })} + aria-label="Count" + className="validation-value" + placeholder="N or ${question}" + /> + + ); + case 'always-true': + return ( + + Always passes {p.text} + + ); + case 'code': + return ( + + props.onChange({ ...p, text: e.target.value })} + aria-label="Expression" + className="raw-rule-input" + spellCheck={false} + placeholder="XPath expression, e.g. . >= 0" + /> + + ); + } +} + +function PatternRow(props: { pattern: string; onChange: (pattern: string) => void }) { + const [sample, setSample] = useState(''); + let verdict: 'match' | 'no-match' | 'invalid' | null = null; + if (props.pattern && sample !== '') { + try { + verdict = new RegExp(props.pattern).test(sample) ? 'match' : 'no-match'; + } catch { + verdict = 'invalid'; + } + } + return ( + + Matches the pattern + props.onChange(e.target.value)} + aria-label="Pattern" + className="validation-value" + spellCheck={false} + placeholder="^9[78][0-9]{8}$" + /> + setSample(e.target.value)} + aria-label="Try a value" + className="validation-value" + placeholder="try a value…" + /> + {verdict && ( + + {verdict === 'match' ? '✓ accepted' : verdict === 'no-match' ? '✗ rejected' : 'invalid pattern'} + + )} + + ); +} diff --git a/client/tests/add-question-configure.spec.ts b/client/tests/add-question-configure.spec.ts index d66c508..628d3b9 100644 --- a/client/tests/add-question-configure.spec.ts +++ b/client/tests/add-question-configure.spec.ts @@ -136,8 +136,12 @@ test.describe('configure step on (the default for a new author)', () => { await configure.getByRole('checkbox', { name: /^Required/ }).check(); // The fixture is bilingual (en, ne): one hint and one message per language. await configure.getByPlaceholder(/Help text/).first().fill('Years'); - await configure.getByPlaceholder(/\. >= 0 and \. <= 20/).fill('. >= 0 and . <= 20'); - await configure.getByPlaceholder(/Message when the answer is rejected/).first().fill('Must be 0 to 20'); + // The Validation slot is the 9e panel: a preset, and a message per language. + const panel = configure.getByTestId('validation-panel'); + await panel.getByRole('combobox', { name: 'Add a validation rule' }).selectOption({ label: 'Between two values' }); + await panel.getByRole('spinbutton', { name: 'Minimum' }).fill('0'); + await panel.getByRole('spinbutton', { name: 'Maximum' }).fill('20'); + await panel.getByPlaceholder('Message shown when the answer is rejected').fill('Must be 0 to 20'); await configure.getByRole('button', { name: 'Add question', exact: true }).click(); await expect(picker).not.toBeVisible(); diff --git a/client/tests/parser-dot-subject.spec.ts b/client/tests/parser-dot-subject.spec.ts index 58dd741..891b8fc 100644 --- a/client/tests/parser-dot-subject.spec.ts +++ b/client/tests/parser-dot-subject.spec.ts @@ -77,25 +77,20 @@ test('T9a — `.` constraints and `../field` relevants survive open-and-save byt await page.getByRole('button', { name: 'pregnancy.xlsx' }).click(); await expect(page.locator('.survey-row').first()).toBeVisible(); - // (2) The modal shows the `.` constraint as rows, not as a raw warning. + // (2) The `.` constraint opens structured: since 9e the Validation panel + // shows `. >= 0 and . <= 20` as "Between 0 and 20", and `.<=100` (tight) + // as a single bound — neither as plain text. const gravidity = rowByType(page, /^integer$/); await gravidity.getByRole('button', { name: /show advanced/ }).click(); - const constraintField = gravidity - .locator('label.expr-field') - .filter({ has: page.locator('code.raw-col-tag', { hasText: /^constraint$/ }) }); - await expect(constraintField.locator('input').first()).toHaveValue('. >= 0 and . <= 20'); - // The button sits inside the