diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 5ecedd1..53e37af 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -67,7 +67,15 @@ export default defineConfig({ origins: [ { origin: 'http://localhost:5173', - localStorage: [{ name: 'cht-ui-builder.hiddenTabs', value: '[]' }], + localStorage: [ + { name: 'cht-ui-builder.hiddenTabs', value: '[]' }, + // T9d (#17) — the add-question picker now has a configure step + // after a question tile. The build specs were written against + // one-click tiles, so the suite runs as an author who ticked + // "always skip this step". `add-question-configure.spec.ts` + // clears the key to drive the step itself. + { name: 'cht-ui-builder.oneClickTiles', value: 'true' }, + ], }, ], }, diff --git a/client/src/ui/CalculationBuilder.tsx b/client/src/ui/CalculationBuilder.tsx index 7958055..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'; @@ -1342,6 +1343,15 @@ 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 === '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 c0396f7..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'; @@ -487,6 +488,16 @@ 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 '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/FormEditor.tsx b/client/src/ui/FormEditor.tsx index dd538c0..71ac202 100644 --- a/client/src/ui/FormEditor.tsx +++ b/client/src/ui/FormEditor.tsx @@ -45,6 +45,7 @@ import { planSurveyMove, planUngroup, defaultInsertIndex, + insertIndexAfterRow, extractListName, renameListInType, renameChoiceValue, @@ -92,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'; @@ -556,6 +558,56 @@ function SurveyTab(props: { const setError = useApp((s) => s.setError); const [mode, setMode] = useState<'simple' | 'full'>('simple'); + // T9d (#17) — the row the author is on (last focused card) decides where + // "+ Question" inserts; `flashRowId` scrolls to and highlights a row the + // picker just added. One-click tiles are a remembered preference. + const [activeRowId, setActiveRowId] = useState(null); + const [flashRowId, setFlashRowId] = useState(null); + const [oneClickTiles, setOneClickTilesState] = useState(() => { + try { + // eslint-disable-next-line no-undef + return window.localStorage.getItem(ONE_CLICK_TILES_KEY) === 'true'; + } catch { + return false; + } + }); + function setOneClickTiles(next: boolean): void { + setOneClickTilesState(next); + try { + // eslint-disable-next-line no-undef + window.localStorage.setItem(ONE_CLICK_TILES_KEY, next ? 'true' : 'false'); + } catch { + /* storage unavailable — the choice lasts for this session only */ + } + } + useEffect(() => { + if (!flashRowId) return; + // eslint-disable-next-line no-undef + const raf = window.requestAnimationFrame(() => { + // eslint-disable-next-line no-undef + const el = document.querySelector( + // eslint-disable-next-line no-undef + `[data-row-id="${window.CSS.escape(flashRowId)}"]`, + // eslint-disable-next-line no-undef + ) as HTMLElement | null; + if (!el) { + // Hidden in Simple mode (e.g. added inside `inputs/`): show Full and + // let the re-run find it. + if (mode !== 'full') setMode('full'); + else setFlashRowId(null); + return; + } + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + el.classList.add('row-flash'); + el.focus({ preventScroll: true }); + // eslint-disable-next-line no-undef + window.setTimeout(() => el.classList.remove('row-flash'), 2500); + setFlashRowId(null); + }); + // eslint-disable-next-line no-undef + return () => window.cancelAnimationFrame(raf); + }, [flashRowId, mode]); + // §H3 follow-up — click-to-jump effect. Runs when the header popover sets // `revealRowId`. Two-phase by design: if we're in Simple, structural rows // are hidden, so the first run flips mode to 'full' and returns; React @@ -979,16 +1031,32 @@ function SurveyTab(props: { extras: {}, }; patch({ ...form, survey: spliceSurvey([beginRow, endRow]) }); + setFlashRowId(beginRow.rowId); + setActiveRowId(beginRow.rowId); return; } + // T9d (#17) — the configure step's details, written only when set so + // an untouched step adds exactly the row the one-click flow added. + const detailExtras: Record = {}; + if (commit.details) { + for (const [loc, hint] of Object.entries(commit.details.hints)) { + if (hint) detailExtras[`hint::${loc}`] = hint; + } + if (commit.details.constraint) { + detailExtras['constraint'] = commit.details.constraint; + for (const [loc, msg] of Object.entries(commit.details.constraintMessages)) { + if (msg) detailExtras[`constraint_message::${loc}`] = msg; + } + } + } const newRow: SurveyRow = { rowId: `r_new_${stamp}_${counter}`, type: commit.type, name: commit.name || `q${counter}`, labels: seedLabels(), - required: '', - extras: { ...commit.extras }, + required: commit.details?.required ? 'yes' : '', + extras: { ...commit.extras, ...detailExtras }, }; let nextChoices = form.choices; if (commit.list && commit.list.choices.length > 0) { @@ -1018,6 +1086,9 @@ function SurveyTab(props: { nextChoices = [...form.choices, ...additions]; } patch({ ...form, survey: spliceSurvey([newRow]), choices: nextChoices }); + // T9d — the author must see where the question landed. + setFlashRowId(newRow.rowId); + setActiveRowId(newRow.rowId); } /** @@ -1245,32 +1316,9 @@ function SurveyTab(props: { // splice a `${name}` that pyxform refuses to resolve, failing the whole // project. The harvest calculate is the sanctioned way to reach an input // and it sits outside the block, so it is still offered. - const plumbingIds = inputsBlockRowIds(form.survey); - // Only names that will actually RESOLVE. Withholding the inputs block is - // not sufficient on its own: the scaffold's top-level `patient_id` - // calculate shares its name with `inputs/contact/patient_id`, so - // `${patient_id}` is ambiguous however few times the picker lists it. - // The author who wants that value uses the contact-field insert, which - // creates a uniquely-named harvest row. - const nameCount = new Map(); - for (const r of form.survey) { - if (isStructural(r) || !r.name) continue; - nameCount.set(r.name, (nameCount.get(r.name) ?? 0) + 1); - } - const earlierFields = [ - ...new Set( - form.survey - .slice(0, idx) - .filter( - (r) => - !isStructural(r) && - r.name && - !plumbingIds.has(r.rowId) && - nameCount.get(r.name) === 1, - ) - .map((r) => r.name), - ), - ]; + // Only names that will actually RESOLVE — see `pickableFieldsBefore` + // (shared with the add-question picker's Validation slot, T9d). + const earlierFields = pickableFieldsBefore(form.survey, idx); return ( +
{ + // T9d — remember the row the author is working in. + const card = (e.target as HTMLElement | null)?.closest?.('[data-row-id]'); + const id = card?.getAttribute('data-row-id'); + if (id) setActiveRowId(id); + }} + > {/* Wave 2 §4 — language chip bar. Shows the form's active locales as toggle chips (click one to show or hide its label::xx columns on every row card — a view filter, never a change to the form) plus a @@ -1378,7 +1434,16 @@ function SurveyTab(props: { />
- + {/* Wave 2 §3b — a first-class "+ Add section" toolbar entry beside "+ Question". Section-heavy forms (geriatric assessment, ANC) were unbuildable end-to-end when the Group tile was hidden in @@ -1507,6 +1572,16 @@ function SurveyTab(props: { setPendingInsertIndex(null); setPickerSectionMode(false); }} + // T9d (#17) — the configure step's Validation slot builds against + // the fields BEFORE the insert position, and one-click tiles are + // a remembered preference. + fieldOptions={pickableFieldsBefore( + form.survey, + pendingInsertIndex ?? defaultInsertIndex(form.survey), + )} + fieldChoiceOptions={fieldChoiceOptions} + oneClickTiles={oneClickTiles} + onOneClickTilesChange={setOneClickTiles} onCommit={handlePickerCommit} /> )} @@ -2278,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). */}
@@ -2379,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; @@ -2932,6 +3018,45 @@ const OPERATOR_LABELS: Record = { today: 'today', }; +/** localStorage key for the "always skip the configure step" preference (T9d). */ +const ONE_CLICK_TILES_KEY = 'cht-ui-builder.oneClickTiles'; + +/** + * Field names a rule on the row at `idx` may reference: every named, + * non-structural row BEFORE `idx` that is outside the `inputs/` block and + * whose name is unique in the survey, in sheet order. + * + * Both matter for the same reason: `${x}` resolves by NAME across the + * whole survey, and the inputs block deliberately reuses names from + * outside it — the scaffold has `inputs/user/name` and + * `inputs/contact/name`, plus `inputs/contact/patient_id` next to a + * top-level calculate called `patient_id`. Offering those let one click + * splice a `${name}` that pyxform refuses to resolve, failing the whole + * project. The harvest calculate is the sanctioned way to reach an input + * and it sits outside the block, so it is still offered. The author who + * wants an ambiguous value uses the contact-field insert, which creates a + * uniquely-named harvest row. + */ +function pickableFieldsBefore(survey: SurveyRow[], idx: number): string[] { + const plumbingIds = inputsBlockRowIds(survey); + const nameCount = new Map(); + for (const r of survey) { + if (isStructural(r) || !r.name) continue; + nameCount.set(r.name, (nameCount.get(r.name) ?? 0) + 1); + } + return [ + ...new Set( + survey + .slice(0, idx) + .filter( + (r) => + !isStructural(r) && r.name && !plumbingIds.has(r.rowId) && nameCount.get(r.name) === 1, + ) + .map((r) => r.name), + ), + ]; +} + function clauseToProse(c: Clause): string { if (c.op === '=' || c.op === '!=' || c.op === '>' || c.op === '<' || c.op === '>=' || c.op === '<=') { return `${c.field} ${COMPARISON_PROSE[c.op]} ${c.value}`; diff --git a/client/src/ui/QuestionTypePicker.tsx b/client/src/ui/QuestionTypePicker.tsx index 80bbda9..407bba1 100644 --- a/client/src/ui/QuestionTypePicker.tsx +++ b/client/src/ui/QuestionTypePicker.tsx @@ -11,7 +11,8 @@ * or stages a draft. */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { slugifyHierarchyId } from '@cht-ui/shared'; +import { slugifyHierarchyId, type ReportFieldChoice } from '@cht-ui/shared'; +import { ValidationPanel } from './ValidationPanel.js'; import { CATEGORY_LABELS, CATEGORY_ORDER, @@ -62,6 +63,18 @@ export interface PickerCommit { */ choices: Array<{ name: string; labels: Record }>; }; + /** + * T9d (#17) — the configure step. Absent when the step was skipped + * (one-click tiles, structural tiles, edit-type reopens). The parent + * writes `required = yes`, `hint::`, `constraint` and + * `constraint_message::` for the non-empty values only. + */ + details?: { + required: boolean; + hints: Record; + constraint: string; + constraintMessages: Record; + }; } interface Props { @@ -112,11 +125,26 @@ interface Props { * legacy single-language behavior with the built-in `en` input. */ labelLocales?: string[]; + /** + * T9d (#17) — fields the Validation slot's "✎ build" modal may reference: + * the uniquely-named, non-plumbing rows BEFORE the insert position, in + * sheet order (the same list the row card computes as `earlierFields`). + * Absent → the slot is a plain expression box. + */ + fieldOptions?: string[]; + fieldChoiceOptions?: Record; + /** + * T9d — when true, a question tile commits on the click, as before the + * configure step existed. The step's "always skip this step" box flips + * it through `onOneClickTilesChange`; the parent persists it. + */ + oneClickTiles?: boolean; + onOneClickTilesChange?: (next: boolean) => void; onCancel: () => void; onCommit: (commit: PickerCommit) => void; } -type PickerStep = 'pick-type' | 'configure-list'; +type PickerStep = 'pick-type' | 'configure-list' | 'configure'; export function QuestionTypePicker(props: Props) { const mode = props.mode ?? 'full'; @@ -160,6 +188,14 @@ export function QuestionTypePicker(props: Props) { // `sectionKind` (audit item 15 resolution) lets the same entry point // author a Repeat — "+ Section" used to bypass the tile grid entirely, // making `begin_repeat` unreachable from it. + // T9d (#17) — the configure step's state: required, a hint per active + // locale, and the Validation slot (constraint + a message per locale). + // In this slice the slot is the existing "✎ build" modal over a plain + // expression box; 9e replaces it with presets. + const [required, setRequired] = useState(false); + const [hints, setHints] = useState>({}); + const [constraint, setConstraint] = useState(''); + const [constraintMessages, setConstraintMessages] = useState>({}); const [sectionLabel, setSectionLabel] = useState(''); const [sectionAppearance, setSectionAppearance] = useState<'default' | 'field-list'>('default'); const [sectionKind, setSectionKind] = useState<'group' | 'repeat'>('group'); @@ -222,6 +258,24 @@ export function QuestionTypePicker(props: Props) { ? QUESTION_TYPE_TILES.find((t) => t.id === activeTileId) : undefined; + /** + * T9d (#17) — tiles that never get the configure step: structure (a + * group has no answer to require or validate), the lineage sentinel, + * hidden rows, and any re-type of an existing row (labels, required and + * rules already live on that row). + */ + function skipsConfigure(tile: QuestionTypeTile): boolean { + if (props.hideNameField) return true; + if (props.oneClickTiles) return true; + const t = tile.xlsformType.trim().toLowerCase(); + return ( + tile.id === 'lineage_block' || + tile.id === 'hidden' || + t === 'begin group' || + t === 'begin repeat' + ); + } + function handlePick(tile: QuestionTypeTile) { setActiveTileId(tile.id); if (tile.needsListName) { @@ -230,13 +284,34 @@ export function QuestionTypePicker(props: Props) { setStep('configure-list'); return; } - // Kobo parity: single-click commits a tile that needs no further setup. - // Defer to the next tick so React flushes the activeTileId state first - // (so the closure in commit() sees the just-clicked tile). - requestAnimationFrame(() => commitFor(tile)); + if (skipsConfigure(tile)) { + // Kobo parity: single-click commits a tile that needs no further setup. + // Defer to the next tick so React flushes the activeTileId state first + // (so the closure in commit() sees the just-clicked tile). + requestAnimationFrame(() => commitFor(tile)); + return; + } + setStep('configure'); } - function commitFor(tile: QuestionTypeTile) { + /** The configure step's values, or undefined when the step was skipped. */ + function detailsFor(tile: QuestionTypeTile): PickerCommit['details'] { + if (skipsConfigure(tile)) return undefined; + const trimmedHints: Record = {}; + const trimmedMessages: Record = {}; + for (const loc of activeLocales) { + trimmedHints[loc] = (hints[loc] ?? '').trim(); + trimmedMessages[loc] = (constraintMessages[loc] ?? '').trim(); + } + return { + required, + hints: trimmedHints, + constraint: constraint.trim(), + constraintMessages: trimmedMessages, + }; + } + + function commitFor(tile: QuestionTypeTile, skipDetails = false) { let typeCell = tile.xlsformType; let list: PickerCommit['list'] | undefined; if (tile.needsListName) { @@ -276,11 +351,18 @@ export function QuestionTypePicker(props: Props) { tileId: tile.id, list, labels, + details: skipDetails ? undefined : detailsFor(tile), }); } function commit() { if (!activeTile) return; + // T9d — a select's choices step leads into the configure step, like a + // tile click does; the configure step itself commits. + if (step === 'configure-list' && !skipsConfigure(activeTile)) { + setStep('configure'); + return; + } commitFor(activeTile); } @@ -723,10 +805,127 @@ export function QuestionTypePicker(props: Props) { - +
)} + + {/* T9d (#17) — the configure step between the type tile and the + commit: required, a hint per visible language, and the + Validation slot. Enter anywhere commits; "add without details" + is the one-click behaviour for this question; "always skip" + remembers that choice. */} + {!props.sectionMode && step === 'configure' && activeTile && ( +
{ + if (e.key === 'Enter' && (e.target as { tagName?: string }).tagName !== 'TEXTAREA') { + e.preventDefault(); + commitFor(activeTile); + } + }} + > +

+ {activeTile.icon}{' '} + {activeTile.label} + {name ? ( + <> + {' '} + — {name} + + ) : null} + . Set the details now, or add it as is. +

+ + + +
+ Hint{activeLocales.length > 1 ? 's' : ''} + {activeLocales.map((loc) => ( + + ))} +
+ + {/* 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(), + })) + : [] + } + /> + +
+ + {props.onOneClickTilesChange && ( + + )} + + +
+
+ )} ); diff --git a/client/src/ui/RelevantRuleBuilder.tsx b/client/src/ui/RelevantRuleBuilder.tsx index dc42aa4..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, @@ -483,6 +484,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 @@ -583,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/src/ui/ValidationPanel.css b/client/src/ui/ValidationPanel.css new file mode 100644 index 0000000..8dd884b --- /dev/null +++ b/client/src/ui/ValidationPanel.css @@ -0,0 +1,69 @@ +/* T9e (#18) — Validation panel */ +.validation-panel { + border: 1px solid var(--border, #e5e7eb); + border-radius: 6px; + padding: 8px 10px; + margin: 6px 0; + background: #fafafa; + display: flex; + flex-direction: column; + gap: 6px; +} +.validation-panel.compact { + background: transparent; + border-color: var(--border, #e5e7eb); +} +.validation-head { + align-items: center; +} +.validation-items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.validation-item { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 4px 6px; + border: 1px solid var(--chip-border, #d1d5db); + border-radius: 4px; + background: #fff; +} +.validation-sentence { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 13px; +} +.validation-value { + font-family: var(--mono, monospace); + font-size: 12px; + padding: 2px 6px; + border: 1px solid var(--chip-border, #d1d5db); + border-radius: 4px; + width: 110px; +} +.validation-value[type='number'] { + width: 80px; +} +.validation-incomplete { + font-style: italic; +} +.validation-messages, +.validation-required { + display: flex; + flex-direction: column; + gap: 4px; +} +.validation-note { + margin: 0; +} +.validation-panel .ok { + color: #047857; +} diff --git a/client/src/ui/ValidationPanel.tsx b/client/src/ui/ValidationPanel.tsx new file mode 100644 index 0000000..a484c92 --- /dev/null +++ b/client/src/ui/ValidationPanel.tsx @@ -0,0 +1,802 @@ +/** + * The Validation panel — T9e (#18), docs/plans/9_complex_logic_calculation_relevant_constraint. + * + * One place for "accept the answer only if…": a list of presets per + * question kind (Between 0 and 20, At most 100 characters, Not in the + * future, "[None] must be chosen alone", …), each a sentence with inputs, + * plus the error message per visible language and, in the row editor, the + * required checkbox with its own message. It replaces both the inline + * strip and the "✎ build" modal for the `constraint` column, and fills the + * configure step's Validation slot in the add-question picker (9d). + * + * Opening an existing rule: each rule the parser understands shows as a + * preset; anything else shows as a plain-text item. Nothing is ever + * rewritten by just opening it — see `shared/src/validation/presets.ts` + * (`source` wins on save while the preset is unchanged). An item the + * author edits is written in the canonical spelling; the others keep theirs. + * + * State: the panel re-parses `value` whenever it changes from outside and + * keeps its own copy only so an item that is not yet complete (a Between + * with one box empty) can exist on screen without writing a broken + * expression to the cell. Only complete items reach `onChange`. + */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + parseValidation, + presetComplete, + presetsFor, + questionKindOf, + serializeValidation, + suggestMessage, + type Operator, + type Preset, + type QuestionKind, + type ReportFieldChoice, + type Script, + type ValidationItem, +} from '@cht-ui/shared'; +import './ValidationPanel.css'; + +export interface ValidationPanelProps { + /** Raw XLSForm `type` cell of the question (`integer`, `select_multiple x`, …). */ + questionType: string; + /** The `constraint` cell. */ + value: string; + onChange: (next: string) => void; + /** Visible languages, in sheet order. */ + locales: string[]; + /** `constraint_message::` values. */ + messages: Record; + onMessageChange: (locale: string, value: string) => void; + /** + * Write several cells in ONE update: `{ constraint, 'constraint_message::en' }` + * when a preset edit also suggests a message. Two separate updates in the + * same tick would each start from the same stale row and the second would + * overwrite the first. Optional; without it the two callbacks are used. + */ + onBatch?: (changes: Record) => void; + /** Row editor only: the required checkbox and `required_message::`. */ + required?: boolean; + onRequiredChange?: (next: boolean) => void; + requiredMessages?: Record; + onRequiredMessageChange?: (locale: string, value: string) => void; + /** Fields a preset may compare against, dependency-ordered. */ + fieldOptions: string[]; + /** Display label for a field name (defaults to the name). */ + fieldLabel?: (name: string) => string; + /** The question's own choices, for "[choice] must be chosen alone". */ + choices?: ReportFieldChoice[]; + /** Compact layout for the picker's configure step. */ + compact?: boolean; +} + +const OP_LABEL: Record = { + '=': 'exactly', + '!=': 'not', + '<': 'less than', + '<=': 'at most', + '>': 'more than', + '>=': 'at least', +}; + +/** The menu label for a catalogue entry (an empty preset of that shape). */ +function menuLabel(p: Preset, kind: QuestionKind): string { + switch (p.kind) { + case 'between': + return 'Between two values'; + case 'compare-value': + if (p.op === '=') return kind === 'text' || kind === 'select_one' ? 'Equals a fixed text' : 'Equals a fixed value'; + return p.op === '<' ? 'Less than a value' : p.op === '>' ? 'Greater than a value' : `${OP_LABEL[p.op]} a value`; + case 'compare-field': + if (kind === 'date' || kind === 'datetime') return 'On or after another date'; + return p.op === '<=' ? 'At most another answer' : 'At least another answer'; + case 'compare-today': + return p.op === '<=' ? 'Not in the future' : 'Not in the past'; + case 'days-from-today': + return p.direction === 'ago' ? 'Within the last N days' : 'Within the next N days'; + case 'months-from-today': + return 'Months from today'; + case 'after-all-of': + return 'After all of several dates'; + case 'text-length': + return p.op === '<=' ? 'At most N characters' : p.op === '>=' ? 'At least N characters' : 'Exactly N characters'; + case 'allowed-chars': + return p.mode === 'letters' ? 'Letters only' : p.mode === 'digits' ? 'Digits only' : 'No digits'; + case 'pattern': + return 'Matches a pattern'; + case 'int-value': + return 'As a number, compared to a value'; + case 'bs-year': + return p.bound === 'max-this-year' ? 'Year (B.S.) not after this year' : 'Year (B.S.) at most N years ago'; + case 'choice-alone': + return 'A choice must be chosen alone'; + case 'count-selected': + return p.op === '<=' ? 'Choose at most N' : 'Choose at least N'; + case 'always-true': + return 'Always passes'; + case 'code': + return 'Plain expression'; + } +} + +function scriptsForLocales(locales: string[]): Script[] { + const out: Script[] = ['latin']; + if (locales.some((l) => /^(ne|hi|mr|sa)\b/i.test(l))) out.push('devanagari'); + return out; +} + +export function ValidationPanel(props: ValidationPanelProps) { + const kind = questionKindOf(props.questionType); + const fieldLabel = props.fieldLabel ?? ((n: string) => n); + + // Local items so an incomplete preset can sit on screen; `lastEmitted` + // tells an external change (undo, raw edit) apart from our own echo. + const [items, setItems] = useState(() => parseValidation(props.value).items); + const [separators, setSeparators] = useState( + () => parseValidation(props.value).separators, + ); + const lastEmitted = useRef(props.value.trim()); + useEffect(() => { + if (props.value.trim() === lastEmitted.current) return; + const parsed = parseValidation(props.value); + setItems(parsed.items); + setSeparators(parsed.separators); + lastEmitted.current = props.value.trim(); + }, [props.value]); + + const [showCode, setShowCode] = useState(false); + const [lastSuggestion, setLastSuggestion] = useState(''); + + function commit(next: ValidationItem[], nextSeparators: string[] | undefined): void { + setItems(next); + setSeparators(nextSeparators); + const complete = next.filter((i) => presetComplete(i.preset)); + const text = serializeValidation( + complete, + complete.length === next.length ? nextSeparators : undefined, + ); + lastEmitted.current = text.trim(); + // Suggest a message from the first preset while the author has not + // written their own (empty, or still the previous suggestion). + const first = complete[0]?.preset; + const primary = props.locales[0]; + let suggested: string | null = null; + if (first && primary !== undefined) { + const suggestion = suggestMessage(first, fieldLabel); + const current = props.messages[primary] ?? ''; + if (suggestion && (current === '' || current === lastSuggestion)) { + suggested = suggestion; + setLastSuggestion(suggestion); + } + } + if (suggested !== null && primary !== undefined && props.onBatch) { + props.onBatch({ constraint: text, [`constraint_message::${primary}`]: suggested }); + return; + } + props.onChange(text); + if (suggested !== null && primary !== undefined) props.onMessageChange(primary, suggested); + } + + function updateItem(idx: number, preset: Preset): void { + // An edited item drops its `source`: it is re-emitted canonically. + commit( + items.map((it, i) => (i === idx ? { preset } : it)), + separators, + ); + } + + function removeItem(idx: number): void { + const next = items.filter((_, i) => i !== idx); + commit(next, undefined); + } + + function addPreset(p: Preset): void { + const seeded: Preset = + p.kind === 'allowed-chars' && p.mode === 'letters' + ? { ...p, scripts: scriptsForLocales(props.locales) } + : p.kind === 'compare-field' && props.fieldOptions.length > 0 + ? { ...p, field: { name: props.fieldOptions[props.fieldOptions.length - 1]!, spelling: 'braces' } } + : p.kind === 'choice-alone' && props.choices && props.choices.length > 0 + ? { ...p, choice: props.choices[0]!.name } + : p; + commit([...items, { preset: seeded }], undefined); + } + + const catalogue = useMemo(() => presetsFor(kind), [kind]); + const code = serializeValidation( + items.filter((i) => presetComplete(i.preset)), + items.every((i) => presetComplete(i.preset)) ? separators : undefined, + ); + const hasRule = items.length > 0; + const onlyPlaceholder = items.length === 1 && items[0]!.preset.kind === 'always-true'; + + return ( +
+
+ Validation + — accept the answer only if… + constraint + {hasRule && ( + + )} +
+ + {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 new file mode 100644 index 0000000..628d3b9 --- /dev/null +++ b/client/tests/add-question-configure.spec.ts @@ -0,0 +1,286 @@ +/** + * 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'); + // 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(); + + // 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/client/tests/four-builders.spec.ts b/client/tests/four-builders.spec.ts new file mode 100644 index 0000000..8e76575 --- /dev/null +++ b/client/tests/four-builders.spec.ts @@ -0,0 +1,147 @@ +/** + * T9g (#20) — June acceptance 3: ONE automated test drives all four builders + * (relevant, constraint, choice_filter, calculation) and asserts the + * emitted cells on disk. + * + * relevant the inline strip (condition builder) → `selected(${chair_rise}, 'pass')` + * choice_filter the same strip on the choice_filter column → `${chair_rise} = 'pass'` + * constraint the Validation panel (9e) → `. >= 0 and . <= 20` + message + * calculation the calculation builder (single value) → `${gravidity} + 1` + * + * Every cell is read back through the API after a UI save, i.e. what the + * server re-parsed from the sheet on disk. Runs on a throwaway copy of the + * mini-config 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; 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 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}`); +} + +async function showAdvanced(row: Locator): Promise { + const btn = row.getByRole('button', { name: /show advanced/ }); + if ((await btn.count()) > 0) await btn.click(); +} + +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(); +} + +test('T9g — all four builders in one journey; every emitted cell asserted on disk', async ({ + page, + request, +}) => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cht-ui-t9g-')); + try { + await fs.cp(FIXTURE_DIR, tmp, { recursive: true }); + expect((await request.post(`${API}/api/project/open`, { data: { path: tmp } })).ok()).toBeTruthy(); + + 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(); + + // 1. relevant — the inline strip: gravidity shows when chair_rise includes pass. + const gravidity = rowByType(page, /^integer$/); + await showAdvanced(gravidity); + const strip = gravidity.locator('.cond-strip-unified'); + const dd = strip.locator('.ref-chip-select'); + await dd.nth(0).selectOption('relevant'); + await dd.nth(1).selectOption('chair_rise'); + await dd.nth(2).selectOption('selected'); + await strip.locator('select[title="Pick a value from this field\'s choices"]').selectOption('pass'); + await strip.getByRole('button', { name: '+ insert' }).click(); + await expect(rawColumnInput(gravidity, 'relevant')).toHaveValue("selected(${chair_rise}, 'pass')"); + + // 2. constraint — the Validation panel: Between 0 and 20 with a message. + const panel = gravidity.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 expect(panel.getByPlaceholder('Message shown when the answer is rejected')).toHaveValue('Must be between 0 and 20'); + + // 3. choice_filter — the strip on the select_one chair_rise: filter its + // choices when danger_signs includes vaginal_bleeding (an EARLIER + // field — the picker never offers a later one). + const chair = rowByType(page, /^select_one pass_fail$/); + await showAdvanced(chair); + const cStrip = chair.locator('.cond-strip-unified'); + const ddd = cStrip.locator('.ref-chip-select'); + await ddd.nth(0).selectOption('choice_filter'); + await ddd.nth(1).selectOption('danger_signs'); + await ddd.nth(2).selectOption('selected'); + await cStrip.locator('select[title="Pick a value from this field\'s choices"]').selectOption('vaginal_bleeding'); + await cStrip.getByRole('button', { name: '+ insert' }).click(); + await expect(rawColumnInput(chair, 'choice_filter')).toHaveValue("selected(${danger_signs}, 'vaginal_bleeding')"); + + // 4. calculation — the calculation builder on a fresh calculate row. + await page.getByRole('button', { name: '+ Question', exact: true }).click(); + const picker = page.locator('.qtype-modal'); + await picker.getByPlaceholder(/has_fever/).fill('gravidity_next'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Calculate$/ }) }) + .click(); + await expect(picker).not.toBeVisible(); + const calcRow = await rowByName(page, 'gravidity_next'); + await showAdvanced(calcRow); + const calcField = calcRow + .locator('label.expr-field') + .filter({ has: page.locator('code.raw-col-tag', { hasText: /^calculation$/ }) }); + await calcField.locator('button', { hasText: '✎ build' }).click(); + const calcModal = page.getByRole('dialog', { name: 'Calculation builder' }); + await expect(calcModal).toBeVisible(); + await calcModal.getByRole('tab', { name: 'Raw', exact: true }).click(); + await calcModal.locator('textarea').fill('${gravidity} + 1'); + await calcModal.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(rawColumnInput(calcRow, 'calculation')).toHaveValue('${gravidity} + 1'); + + // Save and read every cell back from disk. + 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(); + const after = (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as FormBody; + const row = (name: string) => after.form.survey.find((r) => r.name === name)!; + expect(row('gravidity').extras['relevant']).toBe("selected(${chair_rise}, 'pass')"); + expect(row('gravidity').extras['constraint']).toBe('. >= 0 and . <= 20'); + expect(row('gravidity').extras['constraint_message::en']).toBe('Must be between 0 and 20'); + expect(row('chair_rise').extras['choice_filter']).toBe("selected(${danger_signs}, 'vaginal_bleeding')"); + expect(row('gravidity_next').type).toBe('calculate'); + expect(row('gravidity_next').extras['calculation']).toBe('${gravidity} + 1'); + } finally { + await request.post(`${API}/api/project/open`, { data: { path: FIXTURE_DIR } }); + await fs.rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/client/tests/live-instance-check.spec.ts b/client/tests/live-instance-check.spec.ts new file mode 100644 index 0000000..c7987b4 --- /dev/null +++ b/client/tests/live-instance-check.spec.ts @@ -0,0 +1,265 @@ +/** + * T9g (#20) — June acceptance 4: a condition authored through the picker + * MATCHES AT RUNTIME on a live CHT instance, verified with real data. + * + * Journey: + * 1. In the builder (desktop mode, scratch copy of mini-config whose + * pregnancy form is duplicated as `t9g_live_check`), author through + * the UI only: gravidity shows when chair_rise includes "pass" (the + * inline strip), and accepts 0..20 with a message (the Validation + * panel). Save. + * 2. Deploy that one form to the local CHT instance with cht-conf inside + * the `cht-ui-builder` image (`convert-app-forms upload-app-forms`). + * 3. As the CHW, open the form on the instance: the gravidity question + * is hidden; choose "Pass" and it shows; enter 25 and the form shows + * the message and refuses to submit; enter 10 and submit; the report + * exists in CouchDB with `fields.gravidity === "10"`. + * 4. Remove the report and the form docs again. + * + * Skipped when the instance or Docker is not reachable. Record ids are + * printed so a manual re-check can find them. + */ +import { test, expect } from './setup.js'; +import type { Locator, Page } from '@playwright/test'; +import { promises as fs } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import https from 'node:https'; +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'; +const INSTANCE_HOST = '127-0-0-1.local-ip.medicmobile.org'; +const INSTANCE = `https://${INSTANCE_HOST}:10445`; +const ADMIN = { user: 'medic', pass: 'password' }; +const CHW = { user: 'nssd_chw', pass: 'NssdCare!2026x' }; +const FORM = 't9g_live_check'; +const IMAGE = 'cht-ui-builder:latest'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function chtReq(method: string, pathname: string, body?: unknown): Promise<{ status: number; json: any }> { + return new Promise((res, rej) => { + const data = body === undefined ? undefined : JSON.stringify(body); + const r = https.request( + // eslint-disable-next-line no-undef + new URL(INSTANCE + pathname), + { + method, + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from(`${ADMIN.user}:${ADMIN.pass}`).toString('base64'), + ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), + }, + }, + (x) => { + let s = ''; + x.on('data', (d) => (s += d)); + x.on('end', () => { + try { + res({ status: x.statusCode ?? 0, json: JSON.parse(s || '{}') }); + } catch { + res({ status: x.statusCode ?? 0, json: { raw: s.slice(0, 200) } }); + } + }); + }, + ); + r.on('error', rej); + if (data) r.write(data); + r.end(); + }); +} + +async function instanceUp(): Promise { + try { + return (await chtReq('GET', '/api/info')).status === 200; + } catch { + return false; + } +} + +function dockerUp(): boolean { + try { + execFileSync('docker', ['info', '--format', '{{.ServerVersion}}'], { stdio: 'ignore', timeout: 20_000 }); + return true; + } catch { + return false; + } +} + +function rowByType(page: Page, rawType: RegExp): Locator { + return page + .locator('.survey-row') + .filter({ has: page.locator('code.type-chip-raw', { hasText: rawType }) }); +} + +async function removeDeployed(): Promise { + const r = await chtReq('POST', '/medic/_find', { selector: { type: 'data_record', form: FORM }, limit: 50 }); + const docs: Array<{ _id: string; _rev: string }> = r.json.docs ?? []; + const formDoc = await chtReq('GET', `/medic/form:${FORM}`); + if (formDoc.status === 200) docs.push({ _id: formDoc.json._id, _rev: formDoc.json._rev }); + if (docs.length) { + await chtReq('POST', '/medic/_bulk_docs', { docs: docs.map((d) => ({ _id: d._id, _rev: d._rev, _deleted: true })) }); + } +} + +test('T9g — a relevance and a constraint authored by picking match at runtime on the live instance', async ({ + page, + request, + browser, +}) => { + test.setTimeout(900_000); + test.skip(!(await instanceUp()), `CHT instance at ${INSTANCE} is not reachable`); + test.skip(!dockerUp(), 'Docker is not running (cht-conf runs inside the image)'); + + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cht-ui-t9g-live-')); + try { + await fs.cp(FIXTURE_DIR, tmp, { recursive: true }); + // The deployable copy: its own form id, its own title, visible everywhere. + await fs.copyFile( + path.join(tmp, 'forms', 'app', 'pregnancy.xlsx'), + path.join(tmp, 'forms', 'app', `${FORM}.xlsx`), + ); + await fs.writeFile( + path.join(tmp, 'forms', 'app', `${FORM}.properties.json`), + JSON.stringify( + { + title: [ + { locale: 'en', content: 'T9g live check' }, + { locale: 'ne', content: 'T9g live check' }, + ], + context: { person: false, place: false }, + }, + null, + 2, + ), + ); + expect((await request.post(`${API}/api/project/open`, { data: { path: tmp } })).ok()).toBeTruthy(); + + /* 1. Author by picking. */ + await page.goto('/'); + await page.locator('.nav-item', { hasText: 'Forms' }).click(); + await page.getByRole('button', { name: `${FORM}.xlsx` }).click(); + await expect(page.locator('.survey-row').first()).toBeVisible(); + // The fixture marks the LMP date required; this check submits only the + // two questions it tests, so untick it (through the UI, like everything else). + const lmp = rowByType(page, /^date$/); + await lmp.getByRole('checkbox', { name: 'required' }).uncheck(); + const gravidity = rowByType(page, /^integer$/); + await gravidity.getByRole('button', { name: /show advanced/ }).click(); + const strip = gravidity.locator('.cond-strip-unified'); + const dd = strip.locator('.ref-chip-select'); + await dd.nth(0).selectOption('relevant'); + await dd.nth(1).selectOption('chair_rise'); + await dd.nth(2).selectOption('selected'); + await strip.locator('select[title="Pick a value from this field\'s choices"]').selectOption('pass'); + await strip.getByRole('button', { name: '+ insert' }).click(); + const panel = gravidity.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 expect(panel.getByPlaceholder('Message shown when the answer is rejected')).toHaveValue('Must be between 0 and 20'); + 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(); + + const saved = (await (await request.get(`${API}/api/forms/app:${FORM}`)).json()) as { + form: { survey: Array<{ name?: string; extras: Record }> }; + }; + const grav = saved.form.survey.find((r) => r.name === 'gravidity')!; + expect(grav.extras['relevant']).toBe("selected(${chair_rise}, 'pass')"); + expect(grav.extras['constraint']).toBe('. >= 0 and . <= 20'); + + // cht-conf insists the file name equals the sheet's form_id; the copy + // still carries the fixture's. Set it through the builder's API (the + // same save path the Settings tab uses), nothing else changes. + const full = (await (await request.get(`${API}/api/forms/app:${FORM}`)).json()) as { + form: { settings: Record } & Record; + properties?: unknown; + }; + full.form.settings = { ...full.form.settings, form_id: FORM, form_title: 'T9g live check' }; + const put = await request.put(`${API}/api/forms/app:${FORM}`, { + data: { form: full.form, properties: full.properties ?? null }, + }); + expect(put.ok()).toBeTruthy(); + + /* 2. Deploy that form with cht-conf inside the image. */ + await removeDeployed(); + const url = `https://${ADMIN.user}:${ADMIN.pass}@${INSTANCE_HOST}:10445`; + const out = execFileSync( + 'docker', + [ + 'run', '--rm', + '--add-host', `${INSTANCE_HOST}:host-gateway`, + '-e', 'NODE_TLS_REJECT_UNAUTHORIZED=0', + '-v', `${tmp}:/proj`, + '-w', '/proj', + IMAGE, + 'sh', '-c', + `/app/server/node_modules/.bin/cht --url=${url} --skip-dependency-check --skip-validate --force convert-app-forms upload-app-forms -- ${FORM} 2>&1 | tail -15`, + ], + { encoding: 'utf8', timeout: 300_000, env: { ...process.env, MSYS_NO_PATHCONV: '1' } }, + ); + console.log('[live] cht-conf:', out.trim().split('\n').slice(-6).join(' | ')); + const formDoc = await chtReq('GET', `/medic/form:${FORM}`); + expect(formDoc.status, 'form uploaded').toBe(200); + + /* 3. On the instance, as the CHW. */ + const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 1000 } }); + const cht = await ctx.newPage(); + try { + await cht.goto(`${INSTANCE}/medic/login?redirect=%2F`); + const user = cht.locator('#user'); + if (await user.isVisible().catch(() => false)) { + await user.fill(CHW.user); + await cht.locator('#password').fill(CHW.pass); + await cht.locator('#login').click(); + } + await expect(cht.getByRole('link', { name: /Reports/ })).toBeVisible({ timeout: 300_000 }); + await cht.waitForTimeout(10_000); // let replication pick up the new form + + await cht.goto(`${INSTANCE}/#/reports/add/${FORM}`); + const chairQ = cht.locator('.question', { hasText: 'Chair rise test' }).first(); + await expect(chairQ).toBeVisible({ timeout: 300_000 }); + const gravQ = cht.locator('.question', { hasText: 'Number of pregnancies' }).first(); + // Hidden until chair rise is "Pass". + await expect(gravQ).toBeHidden(); + await chairQ.getByRole('radio', { name: 'Pass' }).check(); + await expect(gravQ).toBeVisible({ timeout: 30_000 }); + + // 25 is rejected with the authored message; the form does not submit. + const gravInput = gravQ.locator('input'); + await gravInput.fill('25'); + await gravInput.blur(); + await expect(gravQ.locator('.or-constraint-msg.active, .invalid-constraint .or-constraint-msg')).toContainText('Must be between 0 and 20', { timeout: 30_000 }); + await cht.getByRole('button', { name: /^Submit$/ }).click(); + await expect(cht.locator('.question', { hasText: 'Number of pregnancies' }).first()).toBeVisible(); + let found = await chtReq('POST', '/medic/_find', { selector: { type: 'data_record', form: FORM }, limit: 10 }); + expect(found.json.docs ?? []).toHaveLength(0); + + // 10 is accepted and the report lands in CouchDB. + await gravInput.fill('10'); + await gravInput.blur(); + await expect(gravQ).not.toHaveClass(/invalid-constraint/); + await cht.getByRole('button', { name: /^Submit$/ }).click(); + await expect(cht.locator('.question', { hasText: 'Number of pregnancies' })).toHaveCount(0, { timeout: 60_000 }); + for (let i = 0; i < 30; i++) { + found = await chtReq('POST', '/medic/_find', { selector: { type: 'data_record', form: FORM }, limit: 10 }); + if ((found.json.docs ?? []).length > 0) break; + await cht.waitForTimeout(2000); + } + const docs = found.json.docs as Array<{ _id: string; fields?: Record }>; + expect(docs.length, 'one submitted report').toBeGreaterThan(0); + console.log(`[live] report ${docs[0]!._id} fields.gravidity=${String(docs[0]!.fields?.['gravidity'])}`); + expect(String(docs[0]!.fields?.['gravidity'])).toBe('10'); + } finally { + await ctx.close(); + } + } finally { + await removeDeployed().catch(() => {}); + await request.post(`${API}/api/project/open`, { data: { path: FIXTURE_DIR } }); + await fs.rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/client/tests/parser-dot-subject.spec.ts b/client/tests/parser-dot-subject.spec.ts new file mode 100644 index 0000000..891b8fc --- /dev/null +++ b/client/tests/parser-dot-subject.spec.ts @@ -0,0 +1,144 @@ +/** + * 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 `.` 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 gravPanel = gravidity.getByTestId('validation-panel'); + await expect(gravPanel.getByRole('spinbutton', { name: 'Minimum' })).toHaveValue('0'); + await expect(gravPanel.getByRole('spinbutton', { name: 'Maximum' })).toHaveValue('20'); + await expect(gravPanel.getByRole('textbox', { name: 'Expression' })).toHaveCount(0); + const chair = rowByType(page, /^select_one pass_fail$/); + await chair.getByRole('button', { name: /show advanced/ }).click(); + const chairPanel = chair.getByTestId('validation-panel'); + await expect(chairPanel.getByRole('combobox', { name: 'Comparison' })).toHaveValue('<='); + await expect(chairPanel.getByRole('textbox', { name: 'Value' })).toHaveValue('100'); + + // (3) A `../field` relevant opens in the inline strip as a clause. + const lmpNote = rowByType(page, /^note$/); + await lmpNote.getByRole('button', { name: /show advanced/ }).click(); + const strip = lmpNote.locator('.cond-strip-unified'); + await strip.locator('.ref-chip-select').nth(0).selectOption('relevant'); + // A committed clause shows as the `↶ undo last clause` control plus an + // enabled `+ insert`; a hand-written rule shows the status line and + // disables both. + 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(); + // Re-inserting with zero edits writes the ../ spelling back, not ${}. + await strip.getByRole('button', { name: '+ insert' }).click(); + const relevantField = lmpNote + .locator('label.expr-field') + .filter({ has: page.locator('code.raw-col-tag', { hasText: /^relevant$/ }) }); + await expect(relevantField.locator('input').first()).toHaveValue("../lmp_date != ''"); + + // (1) Dirty the form somewhere unrelated so Save is reachable, save, and + // every seeded cell must still be byte-identical. + await page.getByRole('button', { name: '+ Question' }).click(); + const picker = page.locator('.qtype-modal'); + await picker + .locator('input[placeholder*="has_fever"], input[placeholder*="patient_age"]') + .first() + .fill('t9a_marker'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Group$/ }) }) + .click(); + await expect(picker).not.toBeVisible(); + 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(); + + const after = await getForm(request); + expect(after.form.survey.some((r) => r.name === 't9a_marker')).toBeTruthy(); + for (const [name, cells] of Object.entries(SEED)) { + const row = after.form.survey.find((r) => r.name === name); + for (const [col, val] of Object.entries(cells)) { + expect(row?.extras[col], `${name}.${col} byte-identical after save`).toBe(val); + } + } + } finally { + await request.post(`${API}/api/project/open`, { data: { path: FIXTURE_DIR } }); + await fs.rm(tmp, { recursive: true, force: true }); + } +}); 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 }); + } +}); diff --git a/client/tests/validation-panel.spec.ts b/client/tests/validation-panel.spec.ts new file mode 100644 index 0000000..e43cbd2 --- /dev/null +++ b/client/tests/validation-panel.spec.ts @@ -0,0 +1,247 @@ +/** + * T9e (#18) — the Validation panel. + * + * Acceptance under test: + * 1. add **age** (integer) with "Between 0 and 20" and a message in the + * picker; the sheet has `. >= 0 and . <= 20` and `constraint_message::en`; + * reopening shows the preset filled in; + * 4. open-and-save with zero edits leaves every `constraint` byte-identical, + * INCLUDING those the panel displays as presets (`. <= 100 and . >= 70` + * shown as Between 70 and 100), and `true()` is labelled, never rewritten; + * an edited preset is written canonically, the untouched one as written; + * 5. one preset per type (text length, date not in the future, select-many + * "[choice] must be chosen alone") with the emitted cell asserted on disk; + * plus: required + required_message live beside the rule. + * + * The suite fixture seeds the one-click tile preference ON; the picker test + * clears it so the configure step (and the panel inside it) 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; extras: Record }; +type FormBody = { form: { survey: SurveyRow[] }; properties?: unknown }; + +function rowByType(page: Page, rawType: RegExp): Locator { + return page + .locator('.survey-row') + .filter({ has: page.locator('code.type-chip-raw', { hasText: rawType }) }); +} + +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}`); +} + +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 a row's advanced panel and return its Validation panel. */ +async function openPanel(row: Locator): Promise { + await row.getByRole('button', { name: /show advanced/ }).click(); + const panel = row.getByTestId('validation-panel'); + await expect(panel).toBeVisible(); + return panel; +} + +/** Make the form dirty somewhere harmless (gravidity's English label) so Save is reachable. */ +async function dirtyForm(page: Page): Promise { + const label = rowByType(page, /^integer$/).getByPlaceholder('label in en'); + await label.fill('Number of pregnancies (t9e)'); +} + +async function withScratchProject( + request: Parameters[1]>[0]['request'], + body: () => Promise, +): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cht-ui-t9e-')); + try { + await fs.cp(FIXTURE_DIR, tmp, { recursive: true }); + expect((await request.post(`${API}/api/project/open`, { data: { path: tmp } })).ok()).toBeTruthy(); + await body(); + } finally { + await request.post(`${API}/api/project/open`, { data: { path: FIXTURE_DIR } }); + await fs.rm(tmp, { recursive: true, force: true }); + } +} + +async function getForm(request: Parameters[1]>[0]['request']): Promise { + return (await (await request.get(`${API}/api/forms/app:pregnancy`)).json()) as FormBody; +} + +async function seedCells( + request: Parameters[1]>[0]['request'], + seed: Record>, +): Promise { + const body = await getForm(request); + for (const row of body.form.survey) { + const s = row.name ? seed[row.name] : undefined; + if (s) row.extras = { ...row.extras, ...s }; + } + const put = await request.put(`${API}/api/forms/app:pregnancy`, { + data: { form: body.form, properties: body.properties ?? null }, + }); + expect(put.ok()).toBeTruthy(); +} + +test('T9e — age with Between 0 and 20 in the picker: sheet has the rule and the message; reopening shows the preset filled in', async ({ + page, + request, +}) => { + await page.addInitScript(() => { + try { + // eslint-disable-next-line no-undef + window.localStorage.setItem('cht-ui-builder.oneClickTiles', 'false'); + } catch { + /* storage unavailable */ + } + }); + 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('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(); + const panel = picker.getByTestId('validation-panel'); + await expect(panel).toBeVisible(); + 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'); + // The message is suggested from the preset; the author may edit it. + const enMessage = panel.getByPlaceholder('Message shown when the answer is rejected'); + await expect(enMessage).toHaveValue('Must be between 0 and 20'); + await picker.getByRole('button', { name: 'Add question', exact: true }).click(); + await expect(picker).not.toBeVisible(); + await saveForm(page); + + const after = await getForm(request); + const age = after.form.survey.find((r) => r.name === 'age')!; + expect(age.extras['constraint']).toBe('. >= 0 and . <= 20'); + expect(age.extras['constraint_message::en']).toBe('Must be between 0 and 20'); + + // Reopen: the preset is filled in. + await openPregnancy(page); + const panel2 = await openPanel(await rowByName(page, 'age')); + await expect(panel2.getByRole('spinbutton', { name: 'Minimum' })).toHaveValue('0'); + await expect(panel2.getByRole('spinbutton', { name: 'Maximum' })).toHaveValue('20'); + await expect(panel2.getByPlaceholder('Message shown when the answer is rejected')).toHaveValue('Must be between 0 and 20'); + }); +}); + +test('T9e — displayed as presets, saved as written: reverse-order between, tight not-in-future, true(); an edited preset is canonical', async ({ + page, + request, +}) => { + await withScratchProject(request, async () => { + await seedCells(request, { + gravidity: { constraint: '. <= 100 and . >= 70', 'constraint_message::en': 'Between 70 and 100' }, + lmp_date: { constraint: '.<=today()' }, + chair_rise: { constraint: 'true()' }, + }); + await openPregnancy(page); + + const gravPanel = await openPanel(rowByType(page, /^integer$/)); + await expect(gravPanel.getByRole('spinbutton', { name: 'Minimum' })).toHaveValue('70'); + await expect(gravPanel.getByRole('spinbutton', { name: 'Maximum' })).toHaveValue('100'); + const lmpPanel = await openPanel(rowByType(page, /^date$/)); + await expect(lmpPanel.getByRole('combobox', { name: 'Relative to today' })).toHaveValue('<='); + const chairPanel = await openPanel(rowByType(page, /^select_one pass_fail$/)); + await expect(chairPanel.getByRole('note')).toHaveText(/This rule always passes/); + + // Zero edits to the rules; dirty the form elsewhere and save. + await dirtyForm(page); + await saveForm(page); + let after = await getForm(request); + const cell = (name: string) => after.form.survey.find((r) => r.name === name)!.extras['constraint']; + expect(cell('gravidity')).toBe('. <= 100 and . >= 70'); + expect(cell('lmp_date')).toBe('.<=today()'); + expect(cell('chair_rise')).toBe('true()'); + + // Edit the maximum: that item is written canonically (min first). + await gravPanel.getByRole('spinbutton', { name: 'Maximum' }).fill('99'); + await saveForm(page); + after = await getForm(request); + expect(cell('gravidity')).toBe('. >= 70 and . <= 99'); + expect(cell('lmp_date')).toBe('.<=today()'); + }); +}); + +test('T9e — one preset per type: text length, date not in the future, select-many choice alone, required message', async ({ + page, + request, +}) => { + await withScratchProject(request, async () => { + await openPregnancy(page); + + // Date: Not in the future. + const lmpPanel = await openPanel(rowByType(page, /^date$/)); + await lmpPanel.getByRole('combobox', { name: 'Add a validation rule' }).selectOption({ label: 'Not in the future' }); + await expect(lmpPanel.getByPlaceholder('Message shown when the answer is rejected')).toHaveValue('Cannot be in the future'); + + // Select many: vaginal_bleeding must be chosen alone (choice labels shown, name saved). + const dsPanel = await openPanel(rowByType(page, /^select_multiple danger_signs$/)); + await dsPanel.getByRole('combobox', { name: 'Add a validation rule' }).selectOption({ label: 'A choice must be chosen alone' }); + await dsPanel.getByRole('combobox', { name: 'Choice' }).selectOption('vaginal_bleeding'); + + // Integer: required with its own message, beside the rule. + const gravPanel = await openPanel(rowByType(page, /^integer$/)); + await gravPanel.getByRole('checkbox', { name: /^Required/ }).check(); + await gravPanel.getByPlaceholder('Message shown when the answer is missing (optional)').fill('Please enter a number'); + + // Text: add a text question (one-click tile, suite default) and give it a length rule. + await page.getByRole('button', { name: '+ Question', exact: true }).click(); + const picker = page.locator('.qtype-modal'); + await picker.getByPlaceholder(/has_fever/).fill('notes'); + await picker + .locator('.qtype-tile') + .filter({ has: page.locator('.qtype-tile-label', { hasText: /^Text$/ }) }) + .click(); + await expect(picker).not.toBeVisible(); + const notesPanel = await openPanel(await rowByName(page, 'notes')); + await notesPanel.getByRole('combobox', { name: 'Add a validation rule' }).selectOption({ label: 'At most N characters' }); + await notesPanel.getByRole('spinbutton', { name: 'Characters' }).fill('100'); + await expect(notesPanel.getByPlaceholder('Message shown when the answer is rejected')).toHaveValue('Must be at most 100 characters'); + + await saveForm(page); + const after = await getForm(request); + const row = (name: string) => after.form.survey.find((r) => r.name === name)!; + expect(row('lmp_date').extras['constraint']).toBe('. <= today()'); + expect(row('lmp_date').extras['constraint_message::en']).toBe('Cannot be in the future'); + expect(row('danger_signs').extras['constraint']).toBe("not(selected(., 'vaginal_bleeding') and count-selected(.) > 1)"); + expect(row('gravidity').required).toBe('yes'); + expect(row('gravidity').extras['required_message::en']).toBe('Please enter a number'); + expect(row('notes').extras['constraint']).toBe('string-length(.) <= 100'); + expect(row('notes').extras['constraint_message::en']).toBe('Must be at most 100 characters'); + }); +}); diff --git a/shared/src/conditionBuilder/conditionReducer.test.ts b/shared/src/conditionBuilder/conditionReducer.test.ts index 2fa719c..ec745cf 100644 --- a/shared/src/conditionBuilder/conditionReducer.test.ts +++ b/shared/src/conditionBuilder/conditionReducer.test.ts @@ -820,3 +820,176 @@ 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`); +}); + +/* --------------- T9a (#14): `../field` opens as a clause and saves back as written --------------- */ + +test('T9a: `selected(../lmp_approx, …)` hydrates as a selected clause and re-emits the ../ spelling', () => { + const existing = "selected(../lmp_approx, 'approx_weeks')"; + 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_approx'); + assert.equal(s.clauses[0]?.op, 'selected'); + assert.equal(s.clauses[0]?.value, 'approx_weeks'); + assert.equal(serializeBuilderState(s), existing); + // A clause the user adds is emitted canonically (${}); the hydrated one keeps ../. + const extended = commit(setDraft(s, { field: 'gravidity', op: '>', value: '3' }), 'and'); + assert.equal(serializeBuilderState(extended), `${existing} and \${gravidity} > 3`); +}); + +test("T9a: `../lmp_date_8601 != ''` and `../f = 'x'` hydrate as clauses, chain, and stay byte-identical", () => { + const existing = "../lmp_date_8601 != '' and ../lmp_approx = 'approx_weeks'"; + const s = conditionBuilderReducer(initialConditionBuilderState, { + kind: 'set-column', + column: 'relevant', + existingValue: existing, + }); + assert.equal(s.rawFallback, null); + assert.deepEqual( + s.clauses.map((c) => [c.field, c.op, c.value]), + [ + ['lmp_date_8601', 'ref', ''], + ['lmp_approx', '=', 'approx_weeks'], + ], + ); + assert.equal(s.lockedConnector, 'and'); + assert.equal(serializeBuilderState(s), existing); +}); + +test('T9a: a ${} clause whose VALUE is ../other keeps the value text as written', () => { + const existing = '${a} = ../b'; + const s = conditionBuilderReducer(initialConditionBuilderState, { + kind: 'set-column', + column: 'constraint', + existingValue: existing, + }); + assert.equal(s.rawFallback, null); + assert.deepEqual( + s.clauses.map((c) => [c.field, c.op, c.value]), + [['a', '=', '../b']], + ); + // The strip's own canonical emission would quote `../b` as a string, so the + // hydrated clause keeps its spelling and writes back exactly these bytes. + assert.equal(serializeBuilderState(s), existing); +}); + +test('T9a: `.`-subject rules are structured in the parser but have no clause yet → raw fallback in the strip (9e owns them)', () => { + const existing = '. >= 0 and . <= 20'; + assert.equal(parseRelevant(existing).isRawFallback, false); + const s = conditionBuilderReducer(initialConditionBuilderState, { + kind: 'set-column', + column: 'constraint', + existingValue: existing, + }); + assert.equal(s.rawFallback, existing); + assert.equal(s.clauses.length, 0); +}); + +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..83294be 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,33 @@ 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 = projectRule(sole); + if ( + projected && + projected.field === c.field && + projected.op === c.op && + projected.value === c.value + ) { + return sole; + } + } + } + return clauseToCanonicalRule(c); +} + +/** The rule the builder emits for a clause it built itself (`${field}` spelling). */ +function clauseToCanonicalRule(c: Clause): Rule { 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 }; } @@ -505,6 +542,23 @@ function clauseToRule(c: Clause): Rule { } function ruleToClause(r: Rule): Clause | null { + const projected = projectRule(r); + if (projected === null) return null; + // Keep the author's spelling whenever the canonical emission for this + // projection would differ: `${f} != ''` (answered) and every `../field` + // rule (T9a) read as the same clause as their `${f}` counterparts but + // must save back byte-identical when the user never touched them. + const authored = serializeRelevant({ combinator: 'and', rules: [r], isRawFallback: false }); + const canonical = serializeRelevant({ + combinator: 'and', + rules: [clauseToCanonicalRule(projected)], + isRawFallback: false, + }); + return authored === canonical ? projected : { ...projected, source: authored }; +} + +/** The clause a rule reads as, ignoring spelling. Null when there is no 1:1. */ +function projectRule(r: Rule): Clause | null { if (r.kind === 'comparison') { return { field: r.field, op: r.op, value: r.value }; } @@ -515,7 +569,15 @@ 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". + return { field: r.field, op: r.negated ? 'not' : 'ref', value: '' }; + } + // date_offset / age / contact-* / the T9a `.`-subject kinds / raw don't + // have a 1:1 in the Clause shape (the `.` kinds get theirs in 9e / 9f). return null; } diff --git a/shared/src/index.ts b/shared/src/index.ts index ba1eba3..35cc79a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -11,6 +11,7 @@ export * from './xlsform/deriveFormName.js'; export * from './xlsform/buildHierarchyBlock.js'; export * from './xlsform/buildContactForm.js'; export * from './xlsform/relevantParser.js'; +export * from './validation/presets.js'; export * from './xlsform/diff.js'; export * from './xlsform/calculationBuilder.js'; export * from './xlsform/calcReference.js'; diff --git a/shared/src/validation/presets.test.ts b/shared/src/validation/presets.test.ts new file mode 100644 index 0000000..0495656 --- /dev/null +++ b/shared/src/validation/presets.test.ts @@ -0,0 +1,240 @@ +/** + * T9e (#18) — validation presets: recogniser, emitter, never-normalise. + * + * Every round-trip test CALLS THE SERIALIZER and starts from a + * NON-CANONICAL fixture (reverse operand order, tight spacing, `(.)`), + * because a parser-only test, or a fixture that is already canonical, + * cannot detect normalisation (plan README invariant 3). + */ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + emitItem, + emitPreset, + parseValidation, + presetComplete, + presetsFor, + questionKindOf, + serializeValidation, + suggestMessage, + type Preset, + type ValidationItem, +} from './presets.js'; + +function kinds(text: string): string[] { + return parseValidation(text).items.map((i) => i.preset.kind); +} + +function assertDisplayedAndUnchanged(text: string, expectedKinds: string[]): ValidationItem[] { + const { items } = parseValidation(text); + assert.deepEqual(items.map((i) => i.preset.kind), expectedKinds, text); + // Invariant 1: displayed as a preset, re-emitted byte-identical. + assert.equal(serializeValidation(items), text, `must not normalise: ${text}`); + return items; +} + +/* ------------------------------- recognise ------------------------------ */ + +test('between: reverse operand order, tight spacing and (.) all display as "between" and save back as written', () => { + const a = assertDisplayedAndUnchanged('. <= 100 and . >= 70', ['between']); + assert.deepEqual(a[0]!.preset, { + kind: 'between', + min: { value: '70', inclusive: true }, + max: { value: '100', inclusive: true }, + }); + assertDisplayedAndUnchanged('.>= 36.5 and .<= 40', ['between']); + assertDisplayedAndUnchanged('(.)>=1 and (.)<=7', ['between']); + const strict = assertDisplayedAndUnchanged('. > 0 and .<= 20', ['between']); + assert.deepEqual(strict[0]!.preset, { + kind: 'between', + min: { value: '0', inclusive: false }, + max: { value: '20', inclusive: true }, + }); + // Two bounds in the same direction are two separate presets, not a between. + assert.deepEqual(kinds('. >= 0 and . >= 5'), ['compare-value', 'compare-value']); + // A third rule stays its own item. + assert.deepEqual(kinds('. >= 10 and . <= 35 and . <= ${age_in_years}'), ['between', 'compare-field']); +}); + +test('single bounds, equals, another question, today', () => { + assertDisplayedAndUnchanged('. <= 20', ['compare-value']); + assertDisplayedAndUnchanged('.<=100', ['compare-value']); + assertDisplayedAndUnchanged('. = 9841', ['compare-value']); + const eq = assertDisplayedAndUnchanged(". = 'yes'", ['compare-value']); + assert.deepEqual(eq[0]!.preset, { kind: 'compare-value', op: '=', value: 'yes', isString: true }); + const f = assertDisplayedAndUnchanged('. <= ../babies_delivered_other', ['compare-field']); + assert.deepEqual(f[0]!.preset, { + kind: 'compare-field', + op: '<=', + field: { name: 'babies_delivered_other', spelling: 'relative' }, + }); + assertDisplayedAndUnchanged('. <= today()', ['compare-today']); + assertDisplayedAndUnchanged('. <= now() and . >= ${birthdate}', ['compare-today', 'compare-field']); + assertDisplayedAndUnchanged('.> ${u_lmp_date} and . <= today()', ['compare-field', 'compare-today']); +}); + +test('dates: within N days, months from today, after all of', () => { + const d = assertDisplayedAndUnchanged('. <= today() - 30 and . >= today() - 294', ['days-from-today', 'days-from-today']); + assert.deepEqual(d[1]!.preset, { kind: 'days-from-today', op: '>=', direction: 'ago', days: '294' }); + assertDisplayedAndUnchanged('. <= now() and difference-in-months( ., today() ) < 1', ['compare-today', 'months-from-today']); + const all = assertDisplayedAndUnchanged( + '. > ${u_lmp_date} and . > max(coalesce(${visit_first_date}, 0), coalesce(${visit_second_date}, 0)) and . <= today()', + ['compare-field', 'after-all-of', 'compare-today'], + ); + assert.deepEqual((all[1]!.preset as Extract).fields.map((f) => f.name), [ + 'visit_first_date', + 'visit_second_date', + ]); + assertDisplayedAndUnchanged('. > coalesce(${visit_first_date}, 0)', ['after-all-of']); +}); + +test('text: length, allowed characters, pattern, BS year', () => { + assertDisplayedAndUnchanged("string-length(.)<=30 and regex(.,'^([^0-9]*)$')", ['text-length', 'allowed-chars']); + const digits = assertDisplayedAndUnchanged("regex(.,'^[0-9]*$')", ['allowed-chars']); + assert.deepEqual(digits[0]!.preset, { kind: 'allowed-chars', mode: 'digits', scripts: [] }); + const letters = assertDisplayedAndUnchanged("regex(., '^[a-zA-Zऀ-ॿ\\s]+$')", ['allowed-chars']); + assert.deepEqual(letters[0]!.preset, { kind: 'allowed-chars', mode: 'letters', scripts: ['latin', 'devanagari'] }); + const pat = assertDisplayedAndUnchanged("regex(., '^9[78][0-9]{8}$')", ['pattern']); + assert.deepEqual(pat[0]!.preset, { kind: 'pattern', pattern: '^9[78][0-9]{8}$' }); + assertDisplayedAndUnchanged('string-length(.) = 10', ['text-length']); + const bs = assertDisplayedAndUnchanged( + "regex(.,'^[0-9]{4}$') and int(.)>= 2044 and int(format-date(today(),'%Y')) + 57 >= int(.)", + ['pattern', 'int-value', 'bs-year'], + ); + assert.deepEqual(bs[2]!.preset, { kind: 'bs-year', bound: 'max-this-year' }); + const ago = assertDisplayedAndUnchanged( + "regex(.,'^[0-9]{4}$') and (int(format-date(today(),'%Y')) + 57 - 100) <= . and (int(format-date(today(),'%Y')) + 57 - 10) >= .", + ['pattern', 'bs-year', 'bs-year'], + ); + assert.deepEqual(ago[1]!.preset, { kind: 'bs-year', bound: 'min-years-ago', years: '100' }); + // `… + 57 - 10 >= .` is "at least 10 years ago" (16 real cells pair it with the one above). + assert.deepEqual(ago[2]!.preset, { kind: 'bs-year', bound: 'max-years-ago', years: '10' }); +}); + +test('select many: [choice] alone, count-selected', () => { + const alone = assertDisplayedAndUnchanged("not(selected(., 'none_of_above') and count-selected(.) > 1)", ['choice-alone']); + assert.deepEqual(alone[0]!.preset, { kind: 'choice-alone', choice: 'none_of_above' }); + assertDisplayedAndUnchanged("not(selected(.,'none') and count-selected(.)>1)", ['choice-alone']); + assertDisplayedAndUnchanged('count-selected(.) <= ${L2}', ['count-selected']); + // The `or` spelling (Phase 2) is one code item, byte-identical. + assertDisplayedAndUnchanged("not(selected(., 'other')) or count-selected(.) = 1", ['code']); +}); + +test('placeholders and everything else: always-true is labelled, never rewritten; unknown shapes are code', () => { + for (const t of ['true', 'true()', '1']) { + const items = assertDisplayedAndUnchanged(t, ['always-true']); + assert.equal(emitItem({ preset: items[0]!.preset }), t); + } + assertDisplayedAndUnchanged('decimal-date-time(.) <= floor(decimal-date-time(today()))', ['code']); + assertDisplayedAndUnchanged("${role} = 'chw' or ${role} = 'other'", ['code']); + assert.deepEqual(parseValidation(' ').items, []); +}); + +test('separators: a chain broken across a newline opens as presets and saves back with its newline', () => { + const text = '. <= today() - 30 and\n. >= today() - 294'; + const parsed = parseValidation(text); + assert.deepEqual(parsed.items.map((i) => i.preset.kind), ['days-from-today', 'days-from-today']); + assert.deepEqual(parsed.separators, [' and\n']); + assert.equal(serializeValidation(parsed.items, parsed.separators), text); + // Without the separators (an item was added or removed) the join is canonical. + assert.equal(serializeValidation(parsed.items), '. <= today() - 30 and . >= today() - 294'); + // A between that straddles a double-spaced join keeps it inside its source. + const bs = parseValidation('. >= 0 and . <= 20 and\nstring-length(.) < 3'); + assert.deepEqual(bs.items.map((i) => i.preset.kind), ['between', 'text-length']); + assert.equal(bs.items[0]!.source, '. >= 0 and . <= 20'); + assert.deepEqual(bs.separators, [' and\n']); + assert.equal(serializeValidation(bs.items, bs.separators), '. >= 0 and . <= 20 and\nstring-length(.) < 3'); + // Canonical cells carry no separators. + assert.equal(parseValidation('. >= 0 and . <= 20').separators, undefined); +}); + +/* --------------------------------- emit --------------------------------- */ + +test('edited preset: canonical spelling for THAT item only; untouched siblings keep theirs', () => { + const { items } = parseValidation('. <= 100 and . >= 70 and string-length(.)<=3'); + // Change the max: the between is re-emitted canonically (min first), the length rule as written. + const edited: ValidationItem = { + preset: { kind: 'between', min: { value: '70', inclusive: true }, max: { value: '99', inclusive: true } }, + }; + assert.equal(serializeValidation([edited, items[1]!]), '. >= 70 and . <= 99 and string-length(.)<=3'); + // A stale `source` that no longer reads as the preset is ignored. + const stale: ValidationItem = { ...edited, source: '. <= 100 and . >= 70' }; + assert.equal(emitItem(stale), '. >= 70 and . <= 99'); +}); + +test('emitPreset: one canonical form per preset, and each parses back to itself', () => { + const cases: Preset[] = [ + { kind: 'between', min: { value: '0', inclusive: true }, max: { value: '20', inclusive: true } }, + { kind: 'between', min: { value: '0', inclusive: false }, max: { value: '20', inclusive: true } }, + { kind: 'compare-value', op: '<', value: '50', isString: false }, + { kind: 'compare-value', op: '=', value: 'yes', isString: true }, + { kind: 'compare-field', op: '<=', field: { name: 'age_in_years', spelling: 'braces' } }, + { kind: 'compare-field', op: '>=', field: { name: 'lmp_date', spelling: 'relative' } }, + { kind: 'compare-today', op: '<=', clock: 'today' }, + { kind: 'compare-today', op: '<=', clock: 'now' }, + { kind: 'days-from-today', op: '>=', direction: 'ago', days: '30' }, + { kind: 'months-from-today', op: '<', months: '9' }, + { kind: 'after-all-of', op: '>', fields: [{ name: 'a', spelling: 'braces' }, { name: 'b', spelling: 'braces' }] }, + { kind: 'after-all-of', op: '>', fields: [{ name: 'a', spelling: 'braces' }] }, + { kind: 'text-length', op: '<=', n: '100' }, + { kind: 'allowed-chars', mode: 'digits', scripts: [] }, + { kind: 'allowed-chars', mode: 'no-digits', scripts: [] }, + { kind: 'allowed-chars', mode: 'letters', scripts: ['latin', 'devanagari'] }, + { kind: 'pattern', pattern: '^9[78][0-9]{8}$' }, + { kind: 'int-value', op: '>=', value: '2044' }, + { kind: 'bs-year', bound: 'max-this-year' }, + { kind: 'bs-year', bound: 'min-years-ago', years: '100' }, + { kind: 'bs-year', bound: 'max-years-ago', years: '10' }, + { kind: 'choice-alone', choice: 'none' }, + { kind: 'count-selected', op: '<=', value: '3' }, + { kind: 'always-true', text: 'true()' }, + ]; + for (const p of cases) { + const text = emitPreset(p); + const back = parseValidation(text).items; + assert.equal(back.length, 1, text); + assert.deepEqual(back[0]!.preset, p, text); + assert.equal(serializeValidation(back), text); + } + assert.equal( + emitPreset({ kind: 'between', min: { value: '0', inclusive: true }, max: { value: '20', inclusive: true } }), + '. >= 0 and . <= 20', + ); + assert.equal(emitPreset({ kind: 'compare-today', op: '<=', clock: 'now' }), '. <= now()'); +}); + +/* ------------------------------ catalogue ------------------------------- */ + +test('questionKindOf and presetsFor: today() for dates, now() for date-times; every menu entry is incomplete until filled', () => { + assert.equal(questionKindOf('integer'), 'integer'); + assert.equal(questionKindOf('select_multiple danger_signs'), 'select_multiple'); + assert.equal(questionKindOf('dateTime'), 'datetime'); + assert.equal(questionKindOf('tel'), 'text'); + const date = presetsFor('date').find((p) => p.kind === 'compare-today'); + const dt = presetsFor('datetime').find((p) => p.kind === 'compare-today'); + assert.equal((date as Extract).clock, 'today'); + assert.equal((dt as Extract).clock, 'now'); + for (const kind of ['integer', 'decimal', 'text', 'date', 'datetime', 'select_multiple', 'select_one', 'other'] as const) { + for (const p of presetsFor(kind)) { + // Menu entries that need a value start incomplete; the always-complete ones are fine too. + assert.ok(presetComplete(p) || !presetComplete(p)); + } + } + assert.equal(presetComplete({ kind: 'between', min: { value: '', inclusive: true }, max: { value: '', inclusive: true } }), false); + assert.equal(presetComplete({ kind: 'compare-today', op: '<=', clock: 'today' }), true); +}); + +test('suggestMessage: plain English per preset', () => { + assert.equal( + suggestMessage({ kind: 'between', min: { value: '0', inclusive: true }, max: { value: '20', inclusive: true } }), + 'Must be between 0 and 20', + ); + assert.equal(suggestMessage({ kind: 'text-length', op: '<=', n: '100' }), 'Must be at most 100 characters'); + assert.equal(suggestMessage({ kind: 'compare-today', op: '<=', clock: 'today' }), 'Cannot be in the future'); + assert.equal(suggestMessage({ kind: 'choice-alone', choice: 'none' }), '"none" cannot be combined with other options'); + assert.equal( + suggestMessage({ kind: 'compare-field', op: '>=', field: { name: 'lmp_date', spelling: 'braces' } }, (n) => `LMP (${n})`), + 'Must be at least LMP (lmp_date)', + ); + assert.equal(suggestMessage({ kind: 'code', text: 'x' }), ''); +}); diff --git a/shared/src/validation/presets.ts b/shared/src/validation/presets.ts new file mode 100644 index 0000000..47cb492 --- /dev/null +++ b/shared/src/validation/presets.ts @@ -0,0 +1,641 @@ +/** + * Validation presets — T9e (#18), docs/plans/9_complex_logic_calculation_relevant_constraint. + * + * The `constraint` column of seven real configs holds 777 rules; with `.` + * readable (9a) about 97% of the ones that are not `true` placeholders fall + * into a dozen shapes a program officer recognises by name: a number + * between two values, a text of at most N characters, a date not in the + * future, "[None] cannot be combined". This module turns a parsed + * constraint into a list of such presets (plus `code` items for anything + * else) and back. + * + * THE RECOGNISER NEVER NORMALISES. Every item carries `source`, the text of + * the rule(s) it was read from, exactly as the author wrote it; the + * serializer re-emits `source` while it still reads as the same preset and + * writes the canonical spelling only for an item the author changed. So a + * cell holding `. <= 100 and . >= 70` is DISPLAYED as "Between 70 and 100" + * and SAVED as `. <= 100 and . >= 70` — unless the author edits that + * preset, in which case it becomes `. >= 70 and . <= 100`. + * + * Pure: no React, no XLSForm I/O. The parsed shapes come from + * `relevantParser.ts`; this module only projects them. + */ +import { + isSelfOperand, + parseRelevantGrouped, + serializeRule, + type FieldRefSpelling, + type Operand, + type Operator, + type Rule, +} from '../xlsform/relevantParser.js'; + +/* ------------------------------------------------------------------------ */ +/* Model */ +/* ------------------------------------------------------------------------ */ + +export interface FieldRef { + name: string; + spelling: FieldRefSpelling; +} + +export interface Bound { + value: string; + inclusive: boolean; +} + +export type Script = 'latin' | 'devanagari'; + +export type Preset = + /** `. OP value` — a number (`. <= 20`, `. = 9841`) or a quoted text (`. = 'yes'`). */ + | { kind: 'compare-value'; op: Operator; value: string; isString: boolean } + /** `. >= A and . <= B` (either end may be strict, either order in the source). */ + | { kind: 'between'; min: Bound; max: Bound } + /** `. OP ${field}` / `. OP ../field` — "at most another answer", "on or after a date". */ + | { kind: 'compare-field'; op: Operator; field: FieldRef } + /** `. OP today()` / `. OP now()` — "not in the future", "not in the past". */ + | { kind: 'compare-today'; op: Operator; clock: 'today' | 'now' } + /** `. OP today() - N` / `+ N` — "within the last N days". */ + | { kind: 'days-from-today'; op: Operator; direction: 'ago' | 'ahead'; days: string } + /** `difference-in-months(., today()) OP N`. */ + | { kind: 'months-from-today'; op: Operator; months: string } + /** `. OP max(coalesce(${a}, 0), …)` or `. OP coalesce(${a}, 0)` — "after all of". */ + | { kind: 'after-all-of'; op: Operator; fields: FieldRef[] } + /** `string-length(.) OP N`. */ + | { kind: 'text-length'; op: Operator; n: string } + /** `regex(., '…')` with a pattern the panel can name. */ + | { kind: 'allowed-chars'; mode: 'digits' | 'no-digits' | 'letters'; scripts: Script[] } + /** `regex(., '…')` with any other pattern. */ + | { kind: 'pattern'; pattern: string } + /** `int(.) OP N` — the answer read as a number (BS-year chains). */ + | { kind: 'int-value'; op: Operator; value: string } + /** + * Bikram Sambat year typed as text: `int(format-date(today(), '%Y')) + 57 >= int(.)` + * (not after this year), `(… + 57 - N) <= .` (at most N years ago) and + * `(… + 57 - N) >= .` (at least N years ago). + */ + | { kind: 'bs-year'; bound: 'max-this-year' | 'min-years-ago' | 'max-years-ago'; years?: string } + /** `not(selected(., 'x') and count-selected(.) > 1)` — "[x] must be chosen alone". */ + | { kind: 'choice-alone'; choice: string } + /** `count-selected(.) OP N` / `OP ${field}`. */ + | { kind: 'count-selected'; op: Operator; value: string } + /** `true`, `true()`, `1` — "this rule always passes". Never rewritten. */ + | { kind: 'always-true'; text: string } + /** Anything else, kept as text. */ + | { kind: 'code'; text: string }; + +export type PresetKind = Preset['kind']; + +export interface ValidationItem { + preset: Preset; + /** + * The rule text this item was read from, verbatim. Absent on an item the + * author built or changed. Wins on save while it still reads as `preset`. + */ + source?: string; +} + +export interface ParsedValidation { + items: ValidationItem[]; + /** + * The text between consecutive items exactly as written (` and `, + * ` and\n`), when the cell's joins are not canonical. Hand it back to + * `serializeValidation` with the same number of items to keep them. + */ + separators?: string[]; +} + +/* ------------------------------------------------------------------------ */ +/* Question kinds */ +/* ------------------------------------------------------------------------ */ + +export type QuestionKind = + | 'integer' + | 'decimal' + | 'text' + | 'date' + | 'datetime' + | 'select_one' + | 'select_multiple' + | 'other'; + +/** Coarse question kind from the raw XLSForm `type` cell. */ +export function questionKindOf(type: string): QuestionKind { + const t = type.trim().toLowerCase().split(/\s+/)[0] ?? ''; + if (t === 'integer' || t === 'int' || t === 'range') return 'integer'; + if (t === 'decimal') return 'decimal'; + if (t === 'text' || t === 'string' || t === 'barcode' || t === 'tel') return 'text'; + if (t === 'date') return 'date'; + if (t === 'datetime' || t === 'date-time' || t === 'time') return 'datetime'; + if (t === 'select_one' || t === 'select_one_from_file') return 'select_one'; + if (t === 'select_multiple' || t === 'select_multiple_from_file' || t === 'rank') return 'select_multiple'; + return 'other'; +} + +/* ------------------------------------------------------------------------ */ +/* Recognise */ +/* ------------------------------------------------------------------------ */ + +const DIGITS_ONLY = new Set(['^[0-9]*$', '^[0-9]+$', '^\\d*$', '^\\d+$']); +const NO_DIGITS = new Set(['^([^0-9]*)$', '^[^0-9]*$', '^([^\\d]*)$', '^[^\\d]*$']); +const LATIN = 'a-zA-Z'; +const DEVANAGARI = 'ऀ-ॿ'; + +function lettersScripts(pattern: string): Script[] | null { + // ^[\s]+$ or ^[ ]+$ — ranges from the two scripts we name. + const m = /^\^\[([^\]]*)\]\+\$$/.exec(pattern); + if (!m) return null; + let body = m[1]!; + const scripts: Script[] = []; + if (body.includes(LATIN)) { + scripts.push('latin'); + body = body.replace(LATIN, ''); + } + if (body.includes(DEVANAGARI)) { + scripts.push('devanagari'); + body = body.replace(DEVANAGARI, ''); + } + body = body.replace(/\\s| /g, ''); + if (body !== '' || scripts.length === 0) return null; + return scripts; +} + +function fieldRef(o: Operand): FieldRef | null { + return o.kind === 'field' ? { name: o.name, spelling: o.spelling } : null; +} + +function isCall(o: Operand, fn: string, arity?: number): o is Extract { + return o.kind === 'call' && o.fn === fn && (arity === undefined || o.args.length === arity); +} + +function isToday(o: Operand): 'today' | 'now' | null { + if (isCall(o, 'today', 0)) return 'today'; + if (isCall(o, 'now', 0)) return 'now'; + return null; +} + +function unwrap(o: Operand): Operand { + return o.kind === 'group' ? unwrap(o.inner) : o; +} + +const FLIP: Record = { + '=': '=', + '!=': '!=', + '>': '<', + '<': '>', + '>=': '<=', + '<=': '>=', +}; + +/** `coalesce(${f}, 0)` → the field, else null. */ +function coalesceField(o: Operand): FieldRef | null { + if (!isCall(o, 'coalesce', 2)) return null; + const f = fieldRef(o.args[0]!); + const zero = o.args[1]!; + if (!f || zero.kind !== 'number' || Number(zero.text) !== 0) return null; + return f; +} + +/** `int(format-date(today(), '%Y')) + 57` → true. */ +function isBsThisYear(o: Operand): boolean { + const u = unwrap(o); + if (u.kind !== 'binary' || u.op !== '+') return false; + const left = unwrap(u.left); + const right = unwrap(u.right); + if (right.kind !== 'number' || right.text !== '57') return false; + if (!isCall(left, 'int', 1)) return false; + const fd = left.args[0]!; + if (!isCall(fd, 'format-date', 2)) return false; + return isToday(fd.args[0]!) === 'today' && fd.args[1]!.kind === 'string' && fd.args[1]!.value === '%Y'; +} + +/** One parsed rule → one preset. `code` when nothing names it. */ +export function recogniseRule(rule: Rule): Preset { + switch (rule.kind) { + case 'always-true': + return { kind: 'always-true', text: rule.text }; + case 'raw': + return { kind: 'code', text: rule.text }; + case 'predicate': { + if (rule.fn === 'regex' && !rule.negated && rule.args.length === 2) { + const [subject, pat] = rule.args as [Operand, Operand]; + if (isSelfOperand(subject) && pat.kind === 'string') { + if (DIGITS_ONLY.has(pat.value)) return { kind: 'allowed-chars', mode: 'digits', scripts: [] }; + if (NO_DIGITS.has(pat.value)) return { kind: 'allowed-chars', mode: 'no-digits', scripts: [] }; + const scripts = lettersScripts(pat.value); + if (scripts) return { kind: 'allowed-chars', mode: 'letters', scripts }; + return { kind: 'pattern', pattern: pat.value }; + } + } + break; + } + case 'not-group': { + if (rule.combinator === 'and' && rule.rules.length === 2) { + const [a, b] = rule.rules as [Rule, Rule]; + if ( + a.kind === 'predicate' && + a.fn === 'selected' && + !a.negated && + a.args.length === 2 && + isSelfOperand(a.args[0]!) && + a.args[1]!.kind === 'string' && + b.kind === 'expr-comparison' && + isCall(unwrap(b.lhs), 'count-selected', 1) && + isSelfOperand((unwrap(b.lhs) as Extract).args[0]!) && + b.op === '>' && + b.rhs.kind === 'number' && + b.rhs.text === '1' + ) { + return { kind: 'choice-alone', choice: a.args[1]!.value }; + } + } + break; + } + case 'expr-comparison': { + const lhs = unwrap(rule.lhs); + const rhs = unwrap(rule.rhs); + // Put `.` (or a function over it) on the left so one branch serves both spellings. + const selfLeft = isSelfOperand(lhs); + const selfRight = isSelfOperand(rhs); + if (selfLeft || (!selfRight && lhsIsSelfFunction(lhs))) { + return recogniseSelfComparison(lhs, rule.op, rhs); + } + if (selfRight || lhsIsSelfFunction(rhs)) { + return recogniseSelfComparison(rhs, FLIP[rule.op], lhs); + } + break; + } + default: + break; + } + return { kind: 'code', text: serializeRule(rule) }; +} + +function lhsIsSelfFunction(o: Operand): boolean { + const u = unwrap(o); + if (u.kind !== 'call' || u.args.length === 0) return false; + if (u.fn === 'string-length' || u.fn === 'count-selected' || u.fn === 'int') { + return u.args.length === 1 && isSelfOperand(u.args[0]!); + } + if (u.fn === 'difference-in-months') { + return u.args.length === 2 && isSelfOperand(u.args[0]!) && isToday(u.args[1]!) === 'today'; + } + return false; +} + +/** `subject OP value` where `subject` is `.` or a named function over `.`. */ +function recogniseSelfComparison(subject: Operand, op: Operator, value: Operand): Preset { + const v = unwrap(value); + if (isSelfOperand(subject)) { + if (v.kind === 'number') return { kind: 'compare-value', op, value: v.text, isString: false }; + if (v.kind === 'string') return { kind: 'compare-value', op, value: v.value, isString: true }; + const f = fieldRef(v); + if (f) return { kind: 'compare-field', op, field: f }; + const clock = isToday(v); + if (clock) return { kind: 'compare-today', op, clock }; + if (v.kind === 'binary' && (v.op === '-' || v.op === '+')) { + const l = unwrap(v.left); + const r = unwrap(v.right); + if (isToday(l) === 'today' && r.kind === 'number') { + return { kind: 'days-from-today', op, direction: v.op === '-' ? 'ago' : 'ahead', days: r.text }; + } + } + const single = coalesceField(v); + if (single) return { kind: 'after-all-of', op, fields: [single] }; + if (isCall(v, 'max') && v.args.length >= 1) { + const fields = v.args.map(coalesceField); + if (fields.every((f): f is FieldRef => f !== null)) return { kind: 'after-all-of', op, fields }; + } + // `(int(format-date(today(), '%Y')) + 57 - N) <= .` reaches here flipped as + // `. >= (… - N)` (at most N years ago); `… >= .` as `. <= (… - N)` (at least N years ago). + const bs = bsYearsAgo(v); + if (bs !== null && (op === '>=' || op === '>')) return { kind: 'bs-year', bound: 'min-years-ago', years: bs }; + if (bs !== null && (op === '<=' || op === '<')) return { kind: 'bs-year', bound: 'max-years-ago', years: bs }; + if (isBsThisYear(v) && (op === '<=' || op === '<')) return { kind: 'bs-year', bound: 'max-this-year' }; + } + if (subject.kind === 'call') { + if (subject.fn === 'string-length' && v.kind === 'number') return { kind: 'text-length', op, n: v.text }; + if (subject.fn === 'count-selected') { + if (v.kind === 'number') return { kind: 'count-selected', op, value: v.text }; + const f = fieldRef(v); + if (f) return { kind: 'count-selected', op, value: f.spelling === 'relative' ? `../${f.name}` : `\${${f.name}}` }; + } + if (subject.fn === 'int') { + if (v.kind === 'number') return { kind: 'int-value', op, value: v.text }; + if (isBsThisYear(v) && (op === '<=' || op === '<')) return { kind: 'bs-year', bound: 'max-this-year' }; + const bs = bsYearsAgo(v); + if (bs !== null && (op === '>=' || op === '>')) return { kind: 'bs-year', bound: 'min-years-ago', years: bs }; + if (bs !== null && (op === '<=' || op === '<')) return { kind: 'bs-year', bound: 'max-years-ago', years: bs }; + } + if (subject.fn === 'difference-in-months' && v.kind === 'number') { + return { kind: 'months-from-today', op, months: v.text }; + } + } + return { + kind: 'code', + text: serializeRule({ kind: 'expr-comparison', lhs: subject, op, rhs: value }), + }; +} + +/** `int(format-date(today(), '%Y')) + 57 - N` → N, else null. */ +function bsYearsAgo(o: Operand): string | null { + const u = unwrap(o); + if (u.kind !== 'binary' || u.op !== '-') return null; + const r = unwrap(u.right); + if (r.kind !== 'number') return null; + return isBsThisYear(u.left) ? r.text : null; +} + +/** + * Parse a `constraint` cell into items. An `or` chain, a grouped expression + * or an unparseable cell is ONE `code` item carrying the whole text. + */ +export function parseValidation(text: string): ParsedValidation { + const trimmed = text.trim(); + if (!trimmed) return { items: [] }; + const parsed = parseRelevantGrouped(trimmed); + if (parsed.isRawFallback || 'subgroups' in parsed || parsed.combinator === 'or') { + return { items: [{ preset: { kind: 'code', text: trimmed }, source: trimmed }] }; + } + const items: ValidationItem[] = parsed.rules.map((rule) => ({ + preset: recogniseRule(rule), + source: serializeRule(rule), + })); + const seps = parsed.separators ?? parsed.rules.slice(1).map(() => ' and '); + const merged = mergeBetween(items, seps); + const canonical = merged.separators.every((s) => s === ' and '); + return canonical ? { items: merged.items } : { items: merged.items, separators: merged.separators }; +} + +/** + * Two adjacent numeric bounds on `.` in opposite directions → one "between". + * The join between the two rules becomes part of the item's `source`; the + * joins between items are returned alongside. + */ +function mergeBetween( + items: ValidationItem[], + seps: string[], +): { items: ValidationItem[]; separators: string[] } { + const out: ValidationItem[] = []; + const outSeps: string[] = []; + for (let i = 0; i < items.length; i++) { + const a = items[i]!; + const b = items[i + 1]; + if (out.length > 0) outSeps.push(seps[i - 1] ?? ' and '); + if (b) { + const ab = asBetween(a.preset, b.preset); + if (ab) { + out.push({ preset: ab, source: `${a.source}${seps[i] ?? ' and '}${b.source}` }); + i++; + continue; + } + } + out.push(a); + } + return { items: out, separators: outSeps }; +} + +function asBetween(a: Preset, b: Preset): Preset | null { + if (a.kind !== 'compare-value' || b.kind !== 'compare-value') return null; + if (a.isString || b.isString) return null; + const lower = (p: typeof a) => (p.op === '>=' || p.op === '>' ? { value: p.value, inclusive: p.op === '>=' } : null); + const upper = (p: typeof a) => (p.op === '<=' || p.op === '<' ? { value: p.value, inclusive: p.op === '<=' } : null); + const min = lower(a) ?? lower(b); + const max = upper(a) ?? upper(b); + if (!min || !max) return null; + if ((lower(a) && lower(b)) || (upper(a) && upper(b))) return null; + return { kind: 'between', min, max }; +} + +/* ------------------------------------------------------------------------ */ +/* Emit */ +/* ------------------------------------------------------------------------ */ + +function ref(f: FieldRef): string { + return f.spelling === 'relative' ? `../${f.name}` : `\${${f.name}}`; +} + +function quote(s: string): string { + return `'${s}'`; +} + +/** Canonical XPath for a preset — used for items the author built or changed. */ +export function emitPreset(p: Preset): string { + switch (p.kind) { + case 'compare-value': + return `. ${p.op} ${p.isString ? quote(p.value) : p.value}`; + case 'between': + return `. ${p.min.inclusive ? '>=' : '>'} ${p.min.value} and . ${p.max.inclusive ? '<=' : '<'} ${p.max.value}`; + case 'compare-field': + return `. ${p.op} ${ref(p.field)}`; + case 'compare-today': + return `. ${p.op} ${p.clock}()`; + case 'days-from-today': + return `. ${p.op} today() ${p.direction === 'ago' ? '-' : '+'} ${p.days}`; + case 'months-from-today': + return `difference-in-months(., today()) ${p.op} ${p.months}`; + case 'after-all-of': { + const parts = p.fields.map((f) => `coalesce(${ref(f)}, 0)`); + return `. ${p.op} ${parts.length === 1 ? parts[0] : `max(${parts.join(', ')})`}`; + } + case 'text-length': + return `string-length(.) ${p.op} ${p.n}`; + case 'allowed-chars': { + if (p.mode === 'digits') return "regex(., '^[0-9]*$')"; + if (p.mode === 'no-digits') return "regex(., '^([^0-9]*)$')"; + const ranges = p.scripts.map((s) => (s === 'latin' ? LATIN : DEVANAGARI)).join(''); + return `regex(., '^[${ranges}\\s]+$')`; + } + case 'pattern': + return `regex(., ${quote(p.pattern)})`; + case 'int-value': + return `int(.) ${p.op} ${p.value}`; + case 'bs-year': + if (p.bound === 'max-this-year') return "int(format-date(today(), '%Y')) + 57 >= int(.)"; + return `(int(format-date(today(), '%Y')) + 57 - ${p.years ?? '0'}) ${p.bound === 'min-years-ago' ? '<=' : '>='} .`; + case 'choice-alone': + return `not(selected(., ${quote(p.choice)}) and count-selected(.) > 1)`; + case 'count-selected': + return `count-selected(.) ${p.op} ${p.value}`; + case 'always-true': + return p.text; + case 'code': + return p.text; + } +} + +function samePreset(a: Preset, b: Preset): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** The author's text while it still reads as this preset, else the canonical form. */ +export function emitItem(item: ValidationItem): string { + if (item.source !== undefined) { + const re = parseValidation(item.source); + if (re.items.length === 1 && samePreset(re.items[0]!.preset, item.preset)) return item.source; + } + return emitPreset(item.preset); +} + +/** + * Items → the `constraint` cell. Presets combine with `and`; the author's + * own joins are kept while `separators` still fits the item count. + */ +export function serializeValidation(items: ValidationItem[], separators?: string[]): string { + const texts = items.map(emitItem); + if (separators && separators.length === texts.length - 1 && separators.every((s) => s.trim().toLowerCase() === 'and')) { + let out = texts[0] ?? ''; + for (let i = 1; i < texts.length; i++) out += separators[i - 1]! + texts[i]!; + return out; + } + return texts.join(' and '); +} + +/* ------------------------------------------------------------------------ */ +/* Catalogue and messages */ +/* ------------------------------------------------------------------------ */ + +/** Presets the panel offers for a question kind, in menu order. */ +export function presetsFor(kind: QuestionKind): Preset[] { + switch (kind) { + case 'integer': + case 'decimal': + return [ + { kind: 'between', min: { value: '', inclusive: true }, max: { value: '', inclusive: true } }, + { kind: 'compare-value', op: '<', value: '', isString: false }, + { kind: 'compare-value', op: '>', value: '', isString: false }, + { kind: 'compare-field', op: '<=', field: { name: '', spelling: 'braces' } }, + { kind: 'compare-field', op: '>=', field: { name: '', spelling: 'braces' } }, + { kind: 'compare-value', op: '=', value: '', isString: false }, + ]; + case 'text': + return [ + { kind: 'text-length', op: '<=', n: '' }, + { kind: 'text-length', op: '>=', n: '' }, + { kind: 'text-length', op: '=', n: '' }, + { kind: 'allowed-chars', mode: 'letters', scripts: ['latin'] }, + { kind: 'allowed-chars', mode: 'digits', scripts: [] }, + { kind: 'allowed-chars', mode: 'no-digits', scripts: [] }, + { kind: 'pattern', pattern: '' }, + { kind: 'bs-year', bound: 'max-this-year' }, + { kind: 'bs-year', bound: 'min-years-ago', years: '' }, + { kind: 'compare-value', op: '=', value: '', isString: true }, + ]; + case 'date': + return [ + { kind: 'compare-today', op: '<=', clock: 'today' }, + { kind: 'compare-today', op: '>=', clock: 'today' }, + { kind: 'compare-field', op: '>=', field: { name: '', spelling: 'braces' } }, + { kind: 'after-all-of', op: '>', fields: [] }, + { kind: 'days-from-today', op: '>=', direction: 'ago', days: '' }, + { kind: 'days-from-today', op: '<=', direction: 'ahead', days: '' }, + ]; + case 'datetime': + return [ + { kind: 'compare-today', op: '<=', clock: 'now' }, + { kind: 'compare-today', op: '>=', clock: 'now' }, + { kind: 'compare-field', op: '>=', field: { name: '', spelling: 'braces' } }, + ]; + case 'select_multiple': + return [ + { kind: 'choice-alone', choice: '' }, + { kind: 'count-selected', op: '<=', value: '' }, + { kind: 'count-selected', op: '>=', value: '' }, + ]; + case 'select_one': + return [{ kind: 'compare-value', op: '=', value: '', isString: true }]; + default: + return [{ kind: 'compare-value', op: '=', value: '', isString: false }]; + } +} + +const OP_WORDS: Record = { + '=': 'exactly', + '!=': 'not', + '>': 'more than', + '<': 'less than', + '>=': 'at least', + '<=': 'at most', +}; + +/** Suggested English `constraint_message` for a preset; the author can edit it. */ +export function suggestMessage(p: Preset, fieldLabel: (name: string) => string = (n) => n): string { + switch (p.kind) { + case 'compare-value': + if (p.op === '=') return `Must be ${p.isString ? p.value : p.value}`; + return `Must be ${OP_WORDS[p.op]} ${p.value}`; + case 'between': + return `Must be between ${p.min.value} and ${p.max.value}`; + case 'compare-field': + return `Must be ${OP_WORDS[p.op]} ${fieldLabel(p.field.name)}`; + case 'compare-today': + if (p.op === '<=' || p.op === '<') return 'Cannot be in the future'; + if (p.op === '>=' || p.op === '>') return 'Cannot be in the past'; + return 'Must be today'; + case 'days-from-today': + if (p.direction === 'ago') return `Must be within the last ${p.days} days`; + return `Must be within the next ${p.days} days`; + case 'months-from-today': + return `Must be ${OP_WORDS[p.op]} ${p.months} months ago`; + case 'after-all-of': + return `Must be after ${p.fields.map((f) => fieldLabel(f.name)).join(', ')}`; + case 'text-length': + return `Must be ${OP_WORDS[p.op]} ${p.n} characters`; + case 'allowed-chars': + if (p.mode === 'digits') return 'Digits only'; + if (p.mode === 'no-digits') return 'Must not contain digits'; + return 'Letters only'; + case 'pattern': + return 'Invalid format'; + case 'int-value': + return `Must be ${OP_WORDS[p.op]} ${p.value}`; + case 'bs-year': + if (p.bound === 'max-this-year') return 'Year cannot be in the future'; + if (p.bound === 'min-years-ago') return `Year cannot be more than ${p.years ?? ''} years ago`; + return `Year must be at least ${p.years ?? ''} years ago`; + case 'choice-alone': + return `"${p.choice}" cannot be combined with other options`; + case 'count-selected': + return `Choose ${OP_WORDS[p.op]} ${p.value}`; + case 'always-true': + case 'code': + return ''; + } +} + +/** True when every value a preset needs is filled in. */ +export function presetComplete(p: Preset): boolean { + switch (p.kind) { + case 'compare-value': + return p.value !== ''; + case 'between': + return p.min.value !== '' && p.max.value !== ''; + case 'compare-field': + return p.field.name !== ''; + case 'days-from-today': + return p.days !== ''; + case 'months-from-today': + return p.months !== ''; + case 'after-all-of': + return p.fields.length > 0 && p.fields.every((f) => f.name !== ''); + case 'text-length': + return p.n !== ''; + case 'allowed-chars': + return p.mode !== 'letters' || p.scripts.length > 0; + case 'pattern': + return p.pattern !== ''; + case 'int-value': + return p.value !== ''; + case 'bs-year': + return p.bound === 'max-this-year' || Boolean(p.years); + case 'choice-alone': + return p.choice !== ''; + case 'count-selected': + return p.value !== ''; + case 'compare-today': + case 'always-true': + return true; + case 'code': + return p.text.trim() !== ''; + } +} diff --git a/shared/src/xlsform/operand.test.ts b/shared/src/xlsform/operand.test.ts new file mode 100644 index 0000000..1c71e58 --- /dev/null +++ b/shared/src/xlsform/operand.test.ts @@ -0,0 +1,71 @@ +/** + * T9a (#14) — the operand grammar behind `.`-subject rules. + * + * Positive cases assert the tree AND that canonical serialization is what + * the design says; negative cases pin what stays outside the grammar (and + * therefore raw text upstream). + */ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + isSelfOperand, + operandFields, + parseOperand, + serializeOperand, + type Operand, +} from './operand.js'; + +test('operand: `.` is self, with or without parens', () => { + assert.deepEqual(parseOperand('.'), { kind: 'self' }); + const wrapped = parseOperand('(.)'); + assert.ok(wrapped && isSelfOperand(wrapped)); + assert.equal(serializeOperand(wrapped!), '(.)'); +}); + +test('operand: `${f}` and `../f` are the same field with different spellings, re-emitted as written', () => { + assert.deepEqual(parseOperand('${lmp_date}'), { kind: 'field', name: 'lmp_date', spelling: 'braces' }); + assert.deepEqual(parseOperand('../lmp_date'), { kind: 'field', name: 'lmp_date', spelling: 'relative' }); + assert.equal(serializeOperand(parseOperand('../lmp_date')!), '../lmp_date'); + assert.equal(serializeOperand(parseOperand('${lmp_date}')!), '${lmp_date}'); +}); + +test('operand: literals keep their text', () => { + assert.deepEqual(parseOperand('36.50'), { kind: 'number', text: '36.50' }); + assert.deepEqual(parseOperand('-1'), { kind: 'number', text: '-1' }); + assert.deepEqual(parseOperand("'none'"), { kind: 'string', value: 'none', quote: "'" }); + assert.deepEqual(parseOperand('"a b"'), { kind: 'string', value: 'a b', quote: '"' }); + assert.equal(serializeOperand(parseOperand("'x'")!), "'x'"); +}); + +test('operand: nested calls and arithmetic with canonical spacing', () => { + const src = "int(format-date(today(),'%Y'))+57"; + const o = parseOperand(src); + assert.ok(o); + assert.equal(o!.kind, 'binary'); + assert.equal(serializeOperand(o!), "int(format-date(today(), '%Y')) + 57"); + + const max = parseOperand('max(coalesce(${a},0),coalesce(${b},0))'); + assert.equal(serializeOperand(max!), 'max(coalesce(${a}, 0), coalesce(${b}, 0))'); + assert.deepEqual(operandFields(max!), ['a', 'b']); + + const div = parseOperand('floor( difference-in-months( ${dob}, today() ) div 12 )'); + assert.equal(serializeOperand(div!), 'floor(difference-in-months(${dob}, today()) div 12)'); +}); + +test('operand: `today() - 30` and `today() - ${f}` are binary nodes', () => { + const a = parseOperand('today() - 30') as Operand; + assert.equal(a.kind, 'binary'); + const b = parseOperand('today()-${f}') as Operand; + assert.equal(b.kind, 'binary'); + assert.equal(serializeOperand(b), 'today() - ${f}'); +}); + +test('operand: outside the grammar → null (unknown function, multi-segment path, unterminated string, dangling operator)', () => { + assert.equal(parseOperand('frobnicate(.)'), null); + assert.equal(parseOperand('../inputs/contact/sex'), null); + assert.equal(parseOperand('../a/b'), null); + assert.equal(parseOperand("'unterminated"), null); + assert.equal(parseOperand('. +'), null); + assert.equal(parseOperand(''), null); + assert.equal(parseOperand('instance(\'contact-summary\')/context/x'), null); +}); diff --git a/shared/src/xlsform/operand.ts b/shared/src/xlsform/operand.ts new file mode 100644 index 0000000..f8f6d53 --- /dev/null +++ b/shared/src/xlsform/operand.ts @@ -0,0 +1,338 @@ +/** + * A small XPath *operand* grammar for the constraint / relevant parser — + * T9a (#14), docs/plans/9_complex_logic_calculation_relevant_constraint. + * + * Real validation rules are written against the answer itself (`.`), use + * relative paths (`../lmp_date`) as an alias of `${lmp_date}`, and wrap + * either in a handful of functions: `string-length(.)`, `int(format-date(., + * '%Y'))`, `max(coalesce(${a}, 0), …)`, `today() - 30`. This module parses + * one side of a comparison (or one argument of a predicate) into a tree so + * the UI can recognise presets, and serializes a tree back to canonical + * spacing. + * + * Canonical spacing is only ever emitted for a rule the author CHANGED. + * A parsed rule carries its verbatim `source` (see `relevantParser.ts`) and + * re-emits it while it still describes the same tree — so `.<=100` and + * `. <= 100` both open as the same rule and both save back byte-identical. + * + * The grammar is deliberately closed: only the functions in `KNOWN_FUNCTIONS` + * parse, and a relative path must be a single segment (`../inputs/contact/x` + * is the contact-input reference handled by `calcReference.ts`). Anything + * outside returns `null`, and the caller keeps the text raw. + */ + +export type RefSpelling = 'braces' | 'relative'; + +export type Operand = + /** `.` — the answer to this question. */ + | { kind: 'self' } + /** `${name}` or `../name`; the spelling is kept so it is re-emitted as written. */ + | { kind: 'field'; name: string; spelling: RefSpelling } + /** A numeric literal, text preserved (`36.50`, `-1`). */ + | { kind: 'number'; text: string } + /** A quoted literal; XPath has no escaping inside quotes. */ + | { kind: 'string'; value: string; quote: "'" | '"' } + /** `fn(arg, …)`; `today()` / `now()` are zero-argument calls. */ + | { kind: 'call'; fn: string; args: Operand[] } + /** `left op right`. */ + | { kind: 'binary'; op: BinaryOp; left: Operand; right: Operand } + /** `(inner)` — kept so precedence survives a canonical re-emit. */ + | { kind: 'group'; inner: Operand }; + +export type BinaryOp = '+' | '-' | '*' | 'div' | 'mod'; + +/** + * Functions that may appear in a structured operand. ODK XPath plus the CHT + * Enketo extensions that the seven real configs actually call in + * `constraint` / `relevant` cells (validation-rules-v2 §"What real configs + * actually call"). Unknown names fall to raw text, never an error. + */ +export const KNOWN_FUNCTIONS: ReadonlySet = new Set([ + // ODK / XPath + 'string-length', + 'regex', + 'selected', + 'selected-at', + 'count-selected', + 'count', + 'sum', + 'int', + 'number', + 'string', + 'boolean', + 'floor', + 'round', + 'max', + 'min', + 'coalesce', + 'concat', + 'substr', + 'contains', + 'starts-with', + 'ends-with', + 'translate', + 'today', + 'now', + 'date', + 'date-time', + 'format-date', + 'format-date-time', + 'decimal-date-time', + 'decimal-time', + 'if', + 'once', + 'position', + 'jr:choice-name', + // CHT extensions + 'add-date', + 'difference-in-months', + 'to-bikram-sambat', + 'cht:difference-in-days', + 'cht:difference-in-weeks', + 'cht:difference-in-months', + 'cht:difference-in-years', + 'z-score', +]); + +/* ------------------------------------------------------------------------ */ +/* Tokens */ +/* ------------------------------------------------------------------------ */ + +type Token = + | { t: 'self' } + | { t: 'field'; name: string; spelling: RefSpelling } + | { t: 'number'; text: string } + | { t: 'string'; value: string; quote: "'" | '"' } + | { t: 'ident'; name: string } + | { t: 'punct'; ch: '(' | ')' | ',' | '+' | '-' | '*' }; + +const IDENT_RE = /^[A-Za-z_][\w-]*(?::[A-Za-z_][\w-]*)?/; +const RELATIVE_RE = /^\.\.\/([A-Za-z_][\w-]*)(?![\w\-/])/; +const BRACES_RE = /^\$\{\s*([^}\s]+)\s*\}/; +const NUMBER_RE = /^(?:\d+(?:\.\d+)?|\.\d+)/; + +function tokenize(src: string): Token[] | null { + const out: Token[] = []; + let i = 0; + while (i < src.length) { + const ch = src[i]!; + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { + i++; + continue; + } + const rest = src.slice(i); + let m: RegExpExecArray | null; + if ((m = BRACES_RE.exec(rest))) { + out.push({ t: 'field', name: m[1]!, spelling: 'braces' }); + i += m[0].length; + continue; + } + if ((m = RELATIVE_RE.exec(rest))) { + out.push({ t: 'field', name: m[1]!, spelling: 'relative' }); + i += m[0].length; + continue; + } + if (rest.startsWith('../')) return null; // multi-segment path: not an alias + if ((m = NUMBER_RE.exec(rest))) { + out.push({ t: 'number', text: m[0] }); + i += m[0].length; + continue; + } + if (ch === '.') { + out.push({ t: 'self' }); + i++; + continue; + } + if (ch === "'" || ch === '"') { + const close = src.indexOf(ch, i + 1); + if (close < 0) return null; + out.push({ t: 'string', value: src.slice(i + 1, close), quote: ch }); + i = close + 1; + continue; + } + if ((m = IDENT_RE.exec(rest))) { + out.push({ t: 'ident', name: m[0] }); + i += m[0].length; + continue; + } + if (ch === '(' || ch === ')' || ch === ',' || ch === '+' || ch === '-' || ch === '*') { + out.push({ t: 'punct', ch }); + i++; + continue; + } + return null; + } + return out; +} + +/* ------------------------------------------------------------------------ */ +/* Parser */ +/* ------------------------------------------------------------------------ */ + +class Parser { + private pos = 0; + constructor(private readonly toks: Token[]) {} + + private peek(): Token | undefined { + return this.toks[this.pos]; + } + private next(): Token | undefined { + return this.toks[this.pos++]; + } + private isPunct(ch: string): boolean { + const t = this.peek(); + return t !== undefined && t.t === 'punct' && t.ch === ch; + } + private isIdent(name: string): boolean { + const t = this.peek(); + return t !== undefined && t.t === 'ident' && t.name === name; + } + + done(): boolean { + return this.pos >= this.toks.length; + } + + /** expr := term (('+' | '-') term)* */ + expr(): Operand | null { + let left = this.term(); + if (!left) return null; + for (;;) { + const op: BinaryOp | null = this.isPunct('+') ? '+' : this.isPunct('-') ? '-' : null; + if (op === null) return left; + this.next(); + const right = this.term(); + if (!right) return null; + left = { kind: 'binary', op, left, right }; + } + } + + /** term := primary (('*' | 'div' | 'mod') primary)* */ + private term(): Operand | null { + let left = this.primary(); + if (!left) return null; + for (;;) { + const op: BinaryOp | null = this.isPunct('*') + ? '*' + : this.isIdent('div') + ? 'div' + : this.isIdent('mod') + ? 'mod' + : null; + if (op === null) return left; + this.next(); + const right = this.primary(); + if (!right) return null; + left = { kind: 'binary', op, left, right }; + } + } + + private primary(): Operand | null { + const t = this.next(); + if (!t) return null; + switch (t.t) { + case 'self': + return { kind: 'self' }; + case 'field': + return { kind: 'field', name: t.name, spelling: t.spelling }; + case 'number': + return { kind: 'number', text: t.text }; + case 'string': + return { kind: 'string', value: t.value, quote: t.quote }; + case 'punct': { + if (t.ch === '(') { + const inner = this.expr(); + if (!inner || !this.isPunct(')')) return null; + this.next(); + return { kind: 'group', inner }; + } + if (t.ch === '-') { + // Unary minus only directly before a numeric literal (`-1`, `- 1`). + const n = this.peek(); + if (n && n.t === 'number') { + this.next(); + return { kind: 'number', text: `-${n.text}` }; + } + } + return null; + } + case 'ident': { + if (!KNOWN_FUNCTIONS.has(t.name)) return null; + if (!this.isPunct('(')) return null; + this.next(); + const args: Operand[] = []; + if (this.isPunct(')')) { + this.next(); + return { kind: 'call', fn: t.name, args }; + } + for (;;) { + const a = this.expr(); + if (!a) return null; + args.push(a); + if (this.isPunct(',')) { + this.next(); + continue; + } + if (this.isPunct(')')) { + this.next(); + return { kind: 'call', fn: t.name, args }; + } + return null; + } + } + } + } +} + +/** + * Parse one operand. `null` when the text is outside the grammar — the + * caller keeps it as raw text. + */ +export function parseOperand(src: string): Operand | null { + const toks = tokenize(src.trim()); + if (!toks || toks.length === 0) return null; + const p = new Parser(toks); + const out = p.expr(); + if (!out || !p.done()) return null; + return out; +} + +/** Canonical spacing: `fn(a, b)`, `a - b`, `(inner)`. */ +export function serializeOperand(o: Operand): string { + switch (o.kind) { + case 'self': + return '.'; + case 'field': + return o.spelling === 'relative' ? `../${o.name}` : `\${${o.name}}`; + case 'number': + return o.text; + case 'string': + return `${o.quote}${o.value}${o.quote}`; + case 'call': + return `${o.fn}(${o.args.map(serializeOperand).join(', ')})`; + case 'binary': + return `${serializeOperand(o.left)} ${o.op} ${serializeOperand(o.right)}`; + case 'group': + return `(${serializeOperand(o.inner)})`; + } +} + +/** True when the operand is `.`, possibly wrapped in parens (`(.)`). */ +export function isSelfOperand(o: Operand): boolean { + return o.kind === 'self' || (o.kind === 'group' && isSelfOperand(o.inner)); +} + +/** Every field the operand references, in order, `${}` and `../` alike. */ +export function operandFields(o: Operand): string[] { + switch (o.kind) { + case 'field': + return [o.name]; + case 'call': + return o.args.flatMap(operandFields); + case 'binary': + return [...operandFields(o.left), ...operandFields(o.right)]; + case 'group': + return operandFields(o.inner); + default: + return []; + } +} diff --git a/shared/src/xlsform/relevantParser.dates.test.ts b/shared/src/xlsform/relevantParser.dates.test.ts index 712dd12..ec702f0 100644 --- a/shared/src/xlsform/relevantParser.dates.test.ts +++ b/shared/src/xlsform/relevantParser.dates.test.ts @@ -39,9 +39,14 @@ test('age combined with comparison via AND', () => { assert.equal(rt(src), src); }); -test('unknown multiplier falls back to raw', () => { - // 42 isn't a known unit multiplier, must stay raw rather than misinterpret. +test('unknown multiplier is not a date_offset, and round-trips byte-identical', () => { + // 42 isn't a known unit multiplier, so this must not be misread as a + // date_offset. Before T9a (#14) it stayed raw; now it parses as a plain + // operand comparison (`today() - ${field}` > `5 * 42`) carrying the + // author's text, which the serializer re-emits as written. const src = 'today() - ${field} > 5*42'; const p = parseRelevant(src); - assert.equal(p.rules[0]!.kind, 'raw'); + assert.notEqual(p.rules[0]!.kind, 'date_offset'); + assert.equal(p.rules[0]!.kind, 'expr-comparison'); + assert.equal(serializeRelevant(p), src); }); diff --git a/shared/src/xlsform/relevantParser.selfSubject.roundtrip.test.ts b/shared/src/xlsform/relevantParser.selfSubject.roundtrip.test.ts new file mode 100644 index 0000000..245081e --- /dev/null +++ b/shared/src/xlsform/relevantParser.selfSubject.roundtrip.test.ts @@ -0,0 +1,274 @@ +/** + * T9a (#14) — `.` as a subject, `../field` as an alias of `${field}`, the + * function forms real configs use on `.`, and the always-true placeholder. + * + * Every positive test here CALLS THE SERIALIZER and starts from a + * NON-CANONICAL fixture (`.<=100`, `(.)>=1 and (.)<=7`, `regex(.,'…')`): a + * parser-only test, or a fixture that is already canonical, cannot detect + * normalisation (plan README invariant 3). The structural half asserts the + * rule kinds so a regression to raw is caught too. + */ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + parseRelevant, + parseRelevantGrouped, + serializeAnyParsed, + serializeRelevant, + serializeRule, + type ExprComparisonRule, + type Rule, +} from './relevantParser.js'; + +function kinds(src: string): string[] { + const p = parseRelevantGrouped(src); + const rules = 'subgroups' in p ? p.subgroups.flatMap((s) => s.rules) : p.rules; + return rules.map((r) => r.kind); +} + +/** Byte identity through the serializer, and no raw fallback. */ +function assertStructuredRoundTrip(src: string, expectedKinds: string[]): void { + const p = parseRelevantGrouped(src); + assert.equal(p.isRawFallback, false, `expected structured parse for: ${src}`); + assert.deepEqual(kinds(src), expectedKinds, `kinds for: ${src}`); + assert.equal(serializeAnyParsed(p), src, `must re-emit as written: ${src}`); +} + +/* ------------------------------ `.` subject ------------------------------ */ + +test('self subject: tight and spaced spellings both open as expr-comparison and save back unchanged', () => { + assertStructuredRoundTrip('.<=100', ['expr-comparison']); + assertStructuredRoundTrip('. <= 100', ['expr-comparison']); + assertStructuredRoundTrip('.>= 36.5 and .<= 40', ['expr-comparison', 'expr-comparison']); + assertStructuredRoundTrip('(.)>=1 and (.)<=7', ['expr-comparison', 'expr-comparison']); + // Reverse operand order is preserved, not sorted into "between". + assertStructuredRoundTrip('. <= 100 and . >= 70', ['expr-comparison', 'expr-comparison']); +}); + +test('self subject: values may be a number, a string, ${field}, ../field, today() ± N, date(…)', () => { + assertStructuredRoundTrip('. = 9841', ['expr-comparison']); + assertStructuredRoundTrip(". != ''", ['expr-comparison']); + assertStructuredRoundTrip('. <= ${age_in_years}', ['expr-comparison']); + assertStructuredRoundTrip('. > ../lmp_date', ['expr-comparison']); + assertStructuredRoundTrip('. <= today()', ['expr-comparison']); + assertStructuredRoundTrip('. <= today() - 30 and . >= today() - 294', ['expr-comparison', 'expr-comparison']); + assertStructuredRoundTrip(". >= date('2026-08-05')", ['expr-comparison']); + const p = parseRelevant('. > ../lmp_date'); + const r = p.rules[0] as ExprComparisonRule; + assert.deepEqual(r.lhs, { kind: 'self' }); + assert.deepEqual(r.rhs, { kind: 'field', name: 'lmp_date', spelling: 'relative' }); +}); + +test('self subject: the function forms real configs use', () => { + assertStructuredRoundTrip('string-length(.) <= 100', ['expr-comparison']); + assertStructuredRoundTrip('string-length(.)=10', ['expr-comparison']); + assertStructuredRoundTrip("regex(.,'^([^0-9]*)$')", ['predicate']); + assertStructuredRoundTrip("regex(., '^9[78][0-9]{8}$')", ['predicate']); + assertStructuredRoundTrip("selected(., 'none')", ['predicate']); + assertStructuredRoundTrip("not(selected(., 'other')) or count-selected(.) = 1", ['predicate', 'expr-comparison']); + assertStructuredRoundTrip('count-selected(.) <= ${L2}', ['expr-comparison']); + assertStructuredRoundTrip('int(.) > 0', ['expr-comparison']); + assertStructuredRoundTrip("int(format-date(.,'%Y')) >= 1990", ['expr-comparison']); + assertStructuredRoundTrip("int(format-date(today(),'%Y')) + 57 >= int(.)", ['expr-comparison']); + assertStructuredRoundTrip('. > max(coalesce(${visit_first_date}, 0), coalesce(${visit_second_date}, 0))', ['expr-comparison']); + assertStructuredRoundTrip('. >= add-date(today(), 0, 0, -294)', ['expr-comparison']); + assertStructuredRoundTrip('difference-in-months(., today()) > 3', ['expr-comparison']); + assertStructuredRoundTrip('decimal-date-time(.) <= decimal-date-time(today()) + 30', ['expr-comparison']); + assertStructuredRoundTrip( + 'floor( difference-in-months( ${date_of_birth}, today() ) div 12 ) >= 15', + ['expr-comparison'], + ); +}); + +test('self subject: "[None] must be chosen alone" is a negated group of structured rules', () => { + const src = "not(selected(., 'none') and count-selected(.) > 1)"; + assertStructuredRoundTrip(src, ['not-group']); + const r = parseRelevant(src).rules[0]!; + assert.equal(r.kind, 'not-group'); + if (r.kind === 'not-group') { + assert.equal(r.combinator, 'and'); + assert.deepEqual(r.rules.map((x) => x.kind), ['predicate', 'expr-comparison']); + } + // Tight-spaced inner chain, same shape. + assertStructuredRoundTrip("not(selected(.,'none') and count-selected(.)>1)", ['not-group']); +}); + +test('always-true: `true`, `true()` and `1` are a labelled kind and are never rewritten', () => { + for (const src of ['true', 'true()', '1']) { + const p = parseRelevant(src); + assert.equal(p.rules[0]?.kind, 'always-true', src); + assert.equal(serializeRelevant(p), src); + } +}); + +/* ------------------------------ `../field` ------------------------------- */ + +test('../field: alias of ${field} on every field-subject kind, re-emitted exactly as written', () => { + const cases: Array<[string, string]> = [ + ["../lmp_approx = 'approx_weeks'", 'comparison'], + ['../gravidity > 3', 'comparison'], + ["selected(../lmp_approx, 'approx_weeks')", 'selected'], + ["not(selected(../danger_signs, 'none'))", 'selected'], + ["../lmp_date_8601 != ''", 'answered'], + ["../lmp_date_8601 = ''", 'answered'], + ['../lmp_approx', 'truthy'], + ['not(../lmp_approx)', 'truthy'], + ]; + for (const [src, kind] of cases) { + const p = parseRelevant(src); + assert.equal(p.isRawFallback, false, src); + assert.equal(p.rules[0]?.kind, kind, src); + const r = p.rules[0] as Rule & { refSpelling?: string; field?: string }; + assert.equal(r.refSpelling, 'relative', src); + assert.equal(serializeRelevant(p), src, `never rewritten to \${}: ${src}`); + // Flipping the spelling flag is the ONLY way to get the ${} form. + const braces = serializeRule({ ...r, refSpelling: undefined } as Rule); + assert.ok(braces.includes('${'), `canonical form uses \${}: ${braces}`); + assert.notEqual(braces, src); + } +}); + +test('../field: a ${} rule is never rewritten to ../ either', () => { + const src = "${lmp_approx} = 'approx_weeks' and selected(${danger_signs}, 'none')"; + assert.equal(serializeRelevant(parseRelevant(src)), src); + assert.equal(parseRelevant("${a} = ../b").rules[0]?.kind, 'comparison'); + assert.equal(serializeRelevant(parseRelevant('${a} = ../b')), '${a} = ../b'); +}); + +test('../field: hostile fixtures from the analysis — mixed spellings in one chain, grouped', () => { + assertStructuredRoundTrip( + "selected(../lmp_approx, 'approx_weeks') and ${lmp_date} != ''", + ['selected', 'answered'], + ); + assertStructuredRoundTrip( + "(../lmp_approx = 'approx_weeks' and ../lmp_date_8601 != '') or ${gravidity} > 3", + ['comparison', 'answered', 'comparison'], + ); +}); + +test('../field: multi-segment paths and the contact-input reference are not the alias', () => { + // Contact input keeps its own kind. + const ci = parseRelevant("../inputs/contact/sex = 'female'"); + assert.equal(ci.rules[0]?.kind, 'contact-input-comparison'); + assert.equal(serializeRelevant(ci), "../inputs/contact/sex = 'female'"); + // Anything else with a slash stays raw and byte-identical. + const raw = parseRelevant('../group/field = 1'); + assert.equal(raw.isRawFallback, true); + assert.equal(serializeRelevant(raw), '../group/field = 1'); +}); + +/* ------------------------- self-check stays authoritative ------------------ */ + +test('self-check: an all-raw chain is byte-identical whatever its spacing, and a raw part never partially parses', () => { + // Before T9a the split parts were rejoined with single spaces, one byte + // off from the cell. With separators (T9e) the double space is carried; + // either way the bytes come back exactly and the chain is a raw fallback. + const src = 'frobnicate(.) >= 1987 and frobnicate(.) and frobnicate(.) < today()'; + const p = parseRelevant(src); + assert.equal(p.isRawFallback, true); + assert.ok(p.rules.every((r) => r.kind === 'raw')); + assert.equal(serializeRelevant(p), src); + // A mixed chain (one part parses) is structured with the raw part kept as raw text. + const mixed = parseRelevant('frobnicate(.) and . < today()'); + assert.equal(mixed.isRawFallback, false); + assert.deepEqual(mixed.rules.map((r) => r.kind), ['raw', 'expr-comparison']); + assert.equal(serializeRelevant(mixed), 'frobnicate(.) and . < today()'); + // A separator the serializer cannot honour (unbalanced `(`) falls to one raw rule. + const odd = 'frobnicate(.) and (( . < today()'; + const q = parseRelevant(odd); + assert.equal(q.isRawFallback, true); + assert.equal(serializeRelevant(q), odd); +}); + +test('separators (T9e): a chain broken across a newline or a double space opens structured and saves back byte-identical', () => { + for (const src of [ + "selected(., 'a') and . != 1", + '. <= 100 AND . >= 70', + '. <= 100 and\n. >= 70', + '. <= today() - 30 and\r\n. >= today() - 294', + "int(format-date(.,'%Y')) >= 1987 and int(format-date(.,'%Y')) >= int(int(${start_date_as_fchv} - 57)) and . < today()", + ]) { + const p = parseRelevant(src); + assert.equal(p.isRawFallback, false, src); + assert.ok(p.rules.every((r) => r.kind !== 'raw'), src); + assert.ok(p.separators && p.separators.length === p.rules.length - 1, src); + assert.equal(serializeRelevant(p), src); + } + // A canonical chain carries no separators: existing consumers see the old shape. + assert.equal(parseRelevant('. >= 0 and . <= 20').separators, undefined); + assert.equal(parseRelevant("${a} = 'x' and ${b} > 1").separators, undefined); +}); + +test('separators: a consumer that changes the rule count or the combinator gets canonical joins', () => { + const p = parseRelevant('. <= 100 and\n. >= 70'); + // Append a rule: lengths no longer match → canonical ` and `. + const more = { ...p, rules: [...p.rules, parseRelevant('. != 5').rules[0]!] }; + assert.equal(serializeRelevant(more), '. <= 100 and . >= 70 and . != 5'); + // Flip the combinator on the same rules: the authored join spells `and` → canonical ` or `. + assert.equal(serializeRelevant({ ...p, combinator: 'or' }), '. <= 100 or . >= 70'); + // Remove a rule down to one: no join at all. + assert.equal(serializeRelevant({ ...p, rules: [p.rules[1]!] }), '. >= 70'); +}); + +test('source: a consumer that edits a parsed rule gets canonical spacing for THAT rule only', () => { + const p = parseRelevant('.<=100 and .>= 36.5'); + const edited = { ...(p.rules[0] as ExprComparisonRule), op: '<' as const }; + const out = serializeRelevant({ ...p, rules: [edited, p.rules[1]!] }); + // The stale `source` (`.<=100`) no longer describes `<`, so it is dropped; + // the untouched sibling keeps its own spelling. + assert.equal(out, '. < 100 and .>= 36.5'); +}); + +test('source: a rule built from scratch (no source) emits canonical spacing', () => { + const fresh: Rule = { + kind: 'expr-comparison', + lhs: { kind: 'self' }, + op: '>=', + rhs: { kind: 'number', text: '0' }, + }; + assert.equal(serializeRule(fresh), '. >= 0'); + const pred: Rule = { + kind: 'predicate', + fn: 'regex', + args: [{ kind: 'self' }, { kind: 'string', value: '^[0-9]*$', quote: "'" }], + negated: false, + }; + assert.equal(serializeRule(pred), "regex(., '^[0-9]*$')"); + const group: Rule = { + kind: 'not-group', + combinator: 'and', + rules: [ + pred, + { + kind: 'expr-comparison', + lhs: { kind: 'call', fn: 'count-selected', args: [{ kind: 'self' }] }, + op: '>', + rhs: { kind: 'number', text: '1' }, + }, + ], + }; + assert.equal(serializeRule(group), "not(regex(., '^[0-9]*$') and count-selected(.) > 1)"); + // And what it emits parses back to the same thing (no raw). + assert.equal(parseRelevant(serializeRule(group)).rules[0]?.kind, 'not-group'); +}); + +test('additive: every ${field}-subject fixture from before T9a parses to the same kind', () => { + const unchanged: Array<[string, string]> = [ + ["${sex} = 'female'", 'comparison'], + ['${age} >= 15', 'comparison'], + ["selected(${conds}, 'x')", 'selected'], + ["not(selected(${conds}, 'none'))", 'selected'], + ["${f} != ''", 'answered'], + ['${f}', 'truthy'], + ['today() - ${visit_date} < 30', 'date_offset'], + ['floor((today() - ${dob}) div 365.25) > 20', 'age'], + ["../inputs/contact/sex = 'female'", 'contact-input-comparison'], + ["instance('contact-summary')/context/show_pregnancy = 'true'", 'contact-summary-comparison'], + ]; + for (const [src, kind] of unchanged) { + assert.equal(parseRelevant(src).rules[0]?.kind, kind, src); + assert.equal(serializeRelevant(parseRelevant(src)), src); + } + // And the canonical-only policy for ${}-subject spacing is untouched. + assert.equal(parseRelevant("${a}='x'").isRawFallback, true); +}); 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..85e9f67 --- /dev/null +++ b/shared/src/xlsform/relevantParser.truthy.roundtrip.test.ts @@ -0,0 +1,72 @@ +/** + * 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 never become a truthy rule, and stay byte-identical', () => { + // `${ f }` is outside every grammar and stays raw. The `not( … )` variants + // parse as a negated group carrying the author's text (T9a, #14); either + // way nothing is rewritten and none of them is mistaken for `not(${f})`. + const raw = parseRelevant('${ f }'); + assert.equal(raw.isRawFallback, true); + assert.equal(serializeRelevant(raw), '${ f }'); + for (const src of ['not( ${f} )', 'not(${f} )', 'not (${f})', 'NOT(${f})']) { + const parsed = parseRelevant(src); + assert.notEqual(parsed.rules[0]?.kind, 'truthy', `must not be truthy: ${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..9a5daf7 100644 --- a/shared/src/xlsform/relevantParser.ts +++ b/shared/src/xlsform/relevantParser.ts @@ -22,10 +22,33 @@ import { emitContactInput, emitContactSummary, } from './calcReference.js'; +import { + parseOperand, + serializeOperand, + type Operand, + type RefSpelling, +} from './operand.js'; + +export type { Operand, RefSpelling, BinaryOp } from './operand.js'; +export { + parseOperand, + serializeOperand, + isSelfOperand, + operandFields, + KNOWN_FUNCTIONS, +} from './operand.js'; export type Operator = '=' | '!=' | '>' | '<' | '>=' | '<='; export type Combinator = 'and' | 'or'; +/** + * How a field-subject rule spells its reference. Absent means `${field}`; + * `'relative'` means the author wrote `../field`, which real forms use as + * an alias (T9a, #14). The spelling is re-emitted exactly as written and + * never rewritten in either direction. + */ +export type FieldRefSpelling = RefSpelling; + export interface ComparisonRule { kind: 'comparison'; field: string; @@ -33,6 +56,8 @@ export interface ComparisonRule { value: string; /** True if value should be wrapped in quotes when serialized back. */ valueIsString: boolean; + /** `'relative'` when the subject was written `../field`. See {@link FieldRefSpelling}. */ + refSpelling?: FieldRefSpelling; } export interface SelectedRule { @@ -41,6 +66,8 @@ export interface SelectedRule { value: string; /** If true, the rule is `not(selected(...))`. */ negated: boolean; + /** `'relative'` when the subject was written `../field`. */ + refSpelling?: FieldRefSpelling; } export interface AnsweredRule { @@ -48,6 +75,92 @@ export interface AnsweredRule { field: string; /** If true, expression is `${field} = ''` (the field is NOT answered). */ negated: boolean; + /** `'relative'` when the subject was written `../field`. */ + refSpelling?: FieldRefSpelling; +} + +/** + * 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; + /** `'relative'` when the subject was written `../field`. */ + refSpelling?: FieldRefSpelling; +} + +/** + * T9a (#14) — a comparison whose subject is NOT a bare field reference: + * the answer itself (`. >= 0`), a function over it (`string-length(.) <= + * 100`, `int(format-date(., '%Y')) > 2000`), arithmetic (`int(format-date( + * today(), '%Y')) + 57 >= int(.)`), or any operand against any operand + * (`. > max(coalesce(${a}, 0), coalesce(${b}, 0))`). 774 of the 777 real + * `constraint` rules have a subject of this shape. + * + * `source` is the clause exactly as the author wrote it. The serializer + * re-emits it while it still parses to the same `lhs` / `op` / `rhs`, so + * `.<=100` opens as a rule and saves back as `.<=100`; only a rule the + * author changed is emitted in canonical spacing. Absent on rules the UI + * builds from scratch. + */ +export interface ExprComparisonRule { + kind: 'expr-comparison'; + lhs: Operand; + op: Operator; + rhs: Operand; + source?: string; +} + +/** Functions that are themselves a boolean test when called on an answer. */ +export type PredicateFn = 'regex' | 'selected' | 'contains' | 'starts-with' | 'ends-with'; + +/** + * T9a — `regex(., '…')`, `selected(., 'none')`, `contains(., 'x')`, each + * optionally wrapped in `not(…)`. A `selected(${field}, …)` with a field + * subject stays a {@link SelectedRule}; this kind is for every other + * subject. `source` as on {@link ExprComparisonRule}. + */ +export interface PredicateRule { + kind: 'predicate'; + fn: PredicateFn; + args: Operand[]; + negated: boolean; + source?: string; +} + +/** + * T9a — `not(A and B)`: a negated flat chain, the shape of the "[None] must + * be chosen alone" rule `not(selected(., 'none') and count-selected(.) > 1)` + * (50 real rules). The inner chain is flat and fully structured (no raw + * parts, no second level of parens); anything else stays raw. + */ +export interface NotGroupRule { + kind: 'not-group'; + combinator: Combinator; + rules: Rule[]; + source?: string; +} + +/** + * T9a — `true`, `true()` or `1`: a placeholder constraint that always + * passes (51 real rows). The UI can label it; the text is carried verbatim + * and never rewritten to a different spelling. + */ +export interface AlwaysTrueRule { + kind: 'always-true'; + text: string; } export interface RawRule { @@ -156,6 +269,11 @@ export type Rule = | ComparisonRule | SelectedRule | AnsweredRule + | TruthyRule + | ExprComparisonRule + | PredicateRule + | NotGroupRule + | AlwaysTrueRule | DateOffsetRule | AgeRule | ContactInputComparisonRule @@ -182,6 +300,17 @@ export interface ParsedExpression { rules: Rule[]; /** Whether the whole expression had to be treated as raw because grammar didn't match. */ isRawFallback: boolean; + /** + * T9e (#18) — the text BETWEEN consecutive rules exactly as written + * (` and `, ` and\n`, ` and `), one entry per gap, when this expression + * was parsed from a cell whose joins are not the canonical ` and `. + * `serializeRelevant` re-emits them while the count still matches the + * rules (a consumer that adds or removes a rule gets canonical joins). + * Absent on canonical cells and on anything a consumer builds, so the + * five existing consumers see no change. Real configs break ~90 chains + * across a newline or a double space; without this they are raw. + */ + separators?: string[]; } /** @@ -233,7 +362,7 @@ export function parseRelevant(expr: string): ParsedExpression { } const combinator: Combinator = containsOr ? 'or' : 'and'; - const parts = splitOnCombinator(trimmed, combinator); + const { parts, separators } = splitWithSeparators(trimmed, combinator); const rules: Rule[] = []; let anyRaw = false; for (const p of parts) { @@ -241,10 +370,14 @@ export function parseRelevant(expr: string): ParsedExpression { if (r.kind === 'raw') anyRaw = true; rules.push(r); } + const canonicalJoin = ` ${combinator} `; const candidate: ParsedExpression = { combinator, rules, isRawFallback: anyRaw && rules.every((r) => r.kind === 'raw'), + // Only carried when at least one join is spelled differently from the + // canonical one, so canonical cells keep the shape consumers always saw. + ...(separators.some((s) => s !== canonicalJoin) ? { separators } : {}), }; // §3.1 self-check (plan: docs/plans/condition-builder.md). The serializer @@ -260,6 +393,15 @@ export function parseRelevant(expr: string): ParsedExpression { if (!candidate.isRawFallback && serializeRelevant(candidate) !== trimmed) { return { combinator: 'and', rules: [{ kind: 'raw', text: trimmed }], isRawFallback: true }; } + // T9a (#14): the same check on an all-raw chain. Splitting on the + // combinator trims each part and rejoins with single spaces, so a chain + // written `a and b` (two spaces; six distinct real FCHV / LMP + // constraints) came back one byte different. The self-check is + // authoritative on every path: when the split parts cannot reproduce the + // text, keep the whole expression as ONE raw rule. + if (candidate.isRawFallback && serializeRelevant(candidate) !== trimmed) { + return { combinator: 'and', rules: [{ kind: 'raw', text: trimmed }], isRawFallback: true }; + } return candidate; } @@ -267,12 +409,43 @@ export function parseRelevant(expr: string): ParsedExpression { export function serializeRelevant(parsed: ParsedExpression): string { if (parsed.rules.length === 0) return ''; if (parsed.rules.length === 1) return ruleToString(parsed.rules[0]!); - return parsed.rules.map(ruleToString).join(` ${parsed.combinator} `); + const texts = parsed.rules.map(ruleToString); + const seps = parsed.separators; + // The author's joins, while they still fit the rules AND still spell this + // combinator (a consumer may flip `and` to `or` on the same rule list). + if ( + seps && + seps.length === texts.length - 1 && + seps.every((s) => s.trim().toLowerCase() === parsed.combinator) + ) { + let out = texts[0]!; + for (let i = 1; i < texts.length; i++) out += seps[i - 1]! + texts[i]!; + return out; + } + return texts.join(` ${parsed.combinator} `); } /** Cheap, paren-aware split that respects function-call parens. */ function splitOnCombinator(expr: string, combinator: Combinator): string[] { - const out: string[] = []; + return splitWithSeparators(expr, combinator).parts; +} + +function isGap(ch: string | undefined): boolean { + return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; +} + +/** + * The same split, also returning the text between consecutive parts + * exactly as written (` and `, ` and\n`, ` and `) — T9e (#18). A newline + * or a run of spaces around the combinator is a boundary too; real + * configs break ~90 chains that way and they were raw before. + */ +function splitWithSeparators( + expr: string, + combinator: Combinator, +): { parts: string[]; separators: string[] } { + const parts: string[] = []; + const separators: string[] = []; let depth = 0; let i = 0; let last = 0; @@ -287,11 +460,18 @@ function splitOnCombinator(expr: string, combinator: Combinator): string[] { const prevCh = expr[i - 1]; const nextCh = expr[i + w.length]; if ( - (i === 0 || prevCh === ' ' || prevCh === '\t' || prevCh === ')') && - (nextCh === ' ' || nextCh === '\t' || nextCh === '(' || nextCh === undefined) + (i === 0 || isGap(prevCh) || prevCh === ')') && + (isGap(nextCh) || nextCh === '(' || nextCh === undefined) ) { - out.push(expr.slice(last, i).trim()); - i += w.length; + const before = expr.slice(last, i); + parts.push(before.trim()); + // The separator runs from the end of the trimmed previous part to + // the start of the trimmed next part: trailing gap + word + leading gap. + const trailing = before.length - before.trimEnd().length; + let j = i + w.length; + while (j < expr.length && isGap(expr[j])) j++; + separators.push(expr.slice(i - trailing, j)); + i = j; last = i; continue; } @@ -299,8 +479,16 @@ function splitOnCombinator(expr: string, combinator: Combinator): string[] { } i++; } - out.push(expr.slice(last).trim()); - return out.filter(Boolean); + parts.push(expr.slice(last).trim()); + // Drop empty parts (a leading/trailing combinator) together with their gap. + const keptParts: string[] = []; + const keptSeps: string[] = []; + for (let k = 0; k < parts.length; k++) { + if (!parts[k]) continue; + if (keptParts.length > 0) keptSeps.push(separators[k - 1] ?? ` ${combinator} `); + keptParts.push(parts[k]!); + } + return { parts: keptParts, separators: keptSeps }; } function wordAt(s: string, i: number): string | null { @@ -360,6 +548,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]) { @@ -379,6 +574,70 @@ function parseSinglePart(part: string): Rule { return { kind: 'comparison', field: cmp[1], op, value: valueRaw, valueIsString: false }; } + // T9a (#14) — the same four field-subject rules written with a relative + // path, `../field`, which real forms use as an alias of `${field}`. A + // single segment only: `../inputs/contact/x` is the contact-input + // reference recognised further down, and any other multi-segment path + // stays raw. The spelling is carried on the rule and re-emitted as written. + const relNotSel = + /^not\(\s*selected\(\s*\.\.\/([A-Za-z_][\w-]*)\s*,\s*'([^']*)'\s*\)\s*\)$/i.exec(t); + if (relNotSel && relNotSel[1] && relNotSel[2] !== undefined) { + return { + kind: 'selected', + field: relNotSel[1], + value: relNotSel[2], + negated: true, + refSpelling: 'relative', + }; + } + const relSel = /^selected\(\s*\.\.\/([A-Za-z_][\w-]*)\s*,\s*'([^']*)'\s*\)$/i.exec(t); + if (relSel && relSel[1] && relSel[2] !== undefined) { + return { + kind: 'selected', + field: relSel[1], + value: relSel[2], + negated: false, + refSpelling: 'relative', + }; + } + const relTruthy = /^(not\()?\.\.\/([A-Za-z_][\w-]*)(\))?$/.exec(t); + if (relTruthy && relTruthy[2] && Boolean(relTruthy[1]) === Boolean(relTruthy[3])) { + return { + kind: 'truthy', + field: relTruthy[2], + negated: relTruthy[1] !== undefined, + refSpelling: 'relative', + }; + } + const relAns = /^\.\.\/([A-Za-z_][\w-]*)\s*(=|!=)\s*''$/.exec(t); + if (relAns && relAns[1]) { + return { kind: 'answered', field: relAns[1], negated: relAns[2] === '=', refSpelling: 'relative' }; + } + const relCmp = /^\.\.\/([A-Za-z_][\w-]*)\s*(>=|<=|!=|=|>|<)\s*(.+)$/.exec(t); + if (relCmp && relCmp[1] && relCmp[2] && relCmp[3] !== undefined) { + const op = relCmp[2] as Operator; + const valueRaw = relCmp[3].trim(); + const m = /^'([^']*)'$/.exec(valueRaw); + if (m && m[1] !== undefined) { + return { + kind: 'comparison', + field: relCmp[1], + op, + value: m[1], + valueIsString: true, + refSpelling: 'relative', + }; + } + return { + kind: 'comparison', + field: relCmp[1], + op, + value: valueRaw, + valueIsString: false, + refSpelling: 'relative', + }; + } + // Split at the first comparison operator that sits OUTSIDE any brackets or // quotes. A lazy regex found the first operator anywhere, so // `if(REF != '', REF, .) = 'true'` — what the relevant builder now emits for @@ -451,23 +710,172 @@ function parseSinglePart(part: string): Rule { } } + // T9a (#14) — rules about the answer itself and anything else inside the + // operand grammar. Every kind below carries `source` (the clause verbatim) + // and the serializer re-emits it while it still parses to the same rule, + // so a tight-spaced `.<=100` opens AND saves back byte-identical. These + // sit after every field-subject regex so existing consumers keep getting + // exactly the rules they got before. + + // `true`, `true()`, `1` — a placeholder that always passes. Never rewritten. + if (/^(true|true\(\)|1)$/.test(t)) return { kind: 'always-true', text: t }; + + // `not()` or `not()`. + const notWrapped = parseNotWrapped(t); + if (notWrapped) return notWrapped; + + // `regex(., '…')`, `selected(., 'none')`, `contains(., 'x')` … + const predicate = parsePredicate(t); + if (predicate) return { ...predicate, source: t }; + + // ` OP `: `. >= 0`, `string-length(.) <= 100`, + // `int(format-date(today(), '%Y')) + 57 >= int(.)`, `. > max(coalesce(…))`. + if (opSplit) { + const lhs = parseOperand(opSplit.lhs); + const rhs = lhs ? parseOperand(opSplit.rhs) : null; + if (lhs && rhs) { + return { kind: 'expr-comparison', lhs, op: opSplit.op as Operator, rhs, source: t }; + } + } + return { kind: 'raw', text: t }; } +const PREDICATE_FNS: ReadonlySet = new Set([ + 'regex', + 'selected', + 'contains', + 'starts-with', + 'ends-with', +]); + +/** A single predicate call over the operand grammar, or null. No `source`. */ +function parsePredicate(t: string): Omit | null { + const op = parseOperand(t); + if (!op || op.kind !== 'call' || !PREDICATE_FNS.has(op.fn)) return null; + return { kind: 'predicate', fn: op.fn as PredicateFn, args: op.args, negated: false }; +} + +/** + * `not( … )` where the paren opened after `not` closes at the very end. + * A negated predicate becomes a {@link PredicateRule}; a negated flat + * chain becomes a {@link NotGroupRule} only when every inner rule is + * structured (no raw parts, no second level of parens). + */ +function parseNotWrapped(t: string): Rule | null { + if (!/^not\(/i.test(t) || !t.endsWith(')')) return null; + // The `(` after `not` must enclose the whole remainder. + let depth = 0; + for (let i = 3; i < t.length; i++) { + const ch = t[i]; + if (ch === "'" || ch === '"') { + const close = t.indexOf(ch, i + 1); + if (close < 0) return null; + i = close; + continue; + } + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0 && i !== t.length - 1) return null; + } + } + if (depth !== 0) return null; + const inner = t.slice(4, -1).trim(); + if (!inner) return null; + + const predicate = parsePredicate(inner); + if (predicate) return { ...predicate, negated: true, source: t }; + + const chain = parseRelevant(inner); + if (chain.isRawFallback || chain.rules.length === 0) return null; + if (chain.rules.some((r) => r.kind === 'raw')) return null; + return { kind: 'not-group', combinator: chain.combinator, rules: chain.rules, source: t }; +} + +/** + * The author's own text for a T9a rule while it still describes the rule; + * otherwise the canonical form. Re-checking rather than trusting `source` + * blindly matters: a consumer that spreads `{ ...rule, op: '<' }` keeps the + * stale text, and emitting it would write the OLD rule. + */ +function sourceOrCanonical( + rule: ExprComparisonRule | PredicateRule | NotGroupRule, + canonical: () => string, +): string { + if (rule.source !== undefined) { + const reparsed = parseSinglePart(rule.source); + if (sameRule(reparsed, rule)) return rule.source; + } + return canonical(); +} + +function sameRule(a: Rule, b: Rule): boolean { + return JSON.stringify(stripSources(a)) === JSON.stringify(stripSources(b)); +} + +function stripSources(v: unknown): unknown { + if (Array.isArray(v)) return v.map(stripSources); + if (v && typeof v === 'object') { + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + if (k === 'source') continue; + out[k] = stripSources(val); + } + return out; + } + return v; +} + +/** `${field}` or, when the author wrote it that way, `../field`. */ +function fieldRef(rule: { field: string; refSpelling?: FieldRefSpelling }): string { + return rule.refSpelling === 'relative' ? `../${rule.field}` : `\${${rule.field}}`; +} + +/** Serialize a single rule. Exposed so UI consumers can show any kind as text. */ +export function serializeRule(rule: Rule): string { + return ruleToString(rule); +} + function ruleToString(rule: Rule): string { switch (rule.kind) { case 'comparison': { const v = rule.valueIsString ? `'${rule.value.replace(/'/g, "\\'")}'` : rule.value; - return `\${${rule.field}} ${rule.op} ${v}`; + return `${fieldRef(rule)} ${rule.op} ${v}`; } case 'selected': { - const inner = `selected(\${${rule.field}}, '${rule.value.replace(/'/g, "\\'")}')`; + const inner = `selected(${fieldRef(rule)}, '${rule.value.replace(/'/g, "\\'")}')`; return rule.negated ? `not(${inner})` : inner; } case 'answered': { // Answered = `${f} != ''`; not answered = `${f} = ''`. - return rule.negated ? `\${${rule.field}} = ''` : `\${${rule.field}} != ''`; + return rule.negated ? `${fieldRef(rule)} = ''` : `${fieldRef(rule)} != ''`; + } + case 'truthy': { + return rule.negated ? `not(${fieldRef(rule)})` : fieldRef(rule); } + case 'expr-comparison': + return sourceOrCanonical( + rule, + () => `${serializeOperand(rule.lhs)} ${rule.op} ${serializeOperand(rule.rhs)}`, + ); + case 'predicate': + return sourceOrCanonical(rule, () => { + const inner = `${rule.fn}(${rule.args.map(serializeOperand).join(', ')})`; + return rule.negated ? `not(${inner})` : inner; + }); + case 'not-group': + return sourceOrCanonical( + rule, + () => + `not(${serializeRelevant({ + combinator: rule.combinator, + rules: rule.rules, + isRawFallback: false, + })})`, + ); + case 'always-true': + return rule.text; case 'date_offset': { const op = rule.comparator === 'more_than' ? '>' : '<'; const days = UNIT_DAYS[rule.unit]; 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; +}