diff --git a/README.md b/README.md index c18a8d6..2c12e79 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,8 @@ Key points Use FHIR Range + cqf-cqlType for intervals. Avoid string comparison; compare structured values. +Numeric intervals (`Interval`, `Interval`, `Interval`) are mapped per [FHIR-56226](https://jira.hl7.org/browse/FHIR-56226): a FHIR `Range` whose boundaries are unity quantities (`code: "1"`, `system: http://unitsofmeasure.org`) carrying a [`quantity-precision`](http://hl7.org/fhir/StructureDefinition/quantity-precision) extension. Because `Range` cannot express open boundaries, an open boundary is sent as its closed equivalent at the stated precision (e.g., `Interval[1.0, 1.4)` becomes `Range [1.0, 1.3]` with precision 1). The runner identifies numeric intervals by the [`cqf-cqlType`](http://hl7.org/fhir/StructureDefinition/cqf-cqlType) extension on the returned parameter (e.g., `Interval`); a `Range` returned without it is treated as a quantity interval, so engines must declare the type for these tests to pass. Declared numeric intervals are extracted as plain numeric intervals and, when comparing, the runner normalizes open and closed boundary forms using the declared precision (falling back to the CQL defaults: step 1 for Integer/Long, 10^-8 for Decimal), so an expected `Interval[1.0, 1.4)` matches both `[1.0, 1.3]` at precision 1 and an open-boundary representation. + ```mermaid flowchart LR %% Top row (left → right) diff --git a/docs/plan-fhir-56226-numeric-interval-checks.md b/docs/plan-fhir-56226-numeric-interval-checks.md new file mode 100644 index 0000000..029cff7 --- /dev/null +++ b/docs/plan-fhir-56226-numeric-interval-checks.md @@ -0,0 +1,155 @@ +# Plan: Numeric interval (`Interval`) checks per FHIR-56226 + +Tracker: [FHIR-56226](https://jira.hl7.org/browse/FHIR-56226) — "Add mapping for Intervals of Integer, Decimal, and Long" +Related runner issue: [#85 — Test Runner Not Detecting Equal Intervals (Open vs Closed Boundaries)](https://github.com/cqframework/cql-tests-runner/issues/85) + +## Background + +The CQL-to-FHIR mapping did not specify how to represent intervals of Integer, Decimal, or +Long. FHIR-56226 (spun out of issue #85 after the US Realm Connectathon 2026 discussion) +proposes mapping these as FHIR `Range`, with each boundary a unity `Quantity` +(`code: "1"`, `system: http://unitsofmeasure.org`) carrying a +`http://hl7.org/fhir/StructureDefinition/quantity-precision` extension (`valueInteger`). +Because `Range` cannot express open boundaries, an open boundary is converted to its +closed equivalent *at the stated precision*, and the precision extension preserves enough +information that this conversion does not force a precision choice: + +``` +Interval[1.0, 1.4) ⇒ Range { + low: { value: 1.0, precision: 1 }, // code "1", system UCUM + high: { value: 1.3, precision: 1 } // predecessor of 1.4 at precision 1 +} +``` + +(The Jira example writes the extension URL as `quanity-precision`; that is a typo in the +ticket — the real extension is `quantity-precision`.) + +## Current state of the runner + +| Concern | Where | Behavior today | +|---|---|---| +| Actual: interval extraction | `src/extractors/value-type-extractors/quantity-interval-extractor.ts` | Any `valueRange` becomes `{lowClosed, low: {value, unit}, highClosed, high: {value, unit}}`; boundaries are always closed when present; unity code `"1"` is kept as `unit: "1"`; precision extensions are ignored. | +| Actual: extractor chain | `src/server/extractor-builder.ts` | No numeric-interval extractor exists; unity-coded ranges fall through to `QuantityIntervalExtractor`. | +| Expected: parsing | `cvl/cvl.mjs` (`visitIntervalSelector`) | `Interval[1.0, 1.4)` → `{lowClosed: true, low: 1.0, highClosed: false, high: 1.4}` with plain-number boundaries (`BigInt` for `L` literals). Open/closed flags are preserved. | +| Comparison | `src/shared/results-utils.ts` (`resultsEqual`) | Generic structural compare with a `1e-8` numeric epsilon. An expected open interval never equals an actual closed `Range`, and a numeric expected boundary never equals a `{value, unit}` quantity object. | +| Symptom | `conf/cql-execution-local.json` | Five `CqlIntervalOperatorsTest` skips citing issue #85 (`DecimalIntervalExcept1to3`, `Except12`, `ExceptTime2`, `ExceptTimeInterval`, `IntegerIntervalExcept1to3`). | + +Both gaps must be fixed for numeric intervals to pass: + +1. **Shape gap** — a unity-coded `Range` must extract to plain numeric boundaries, not + quantity objects. +2. **Boundary-semantics gap** — expected values may be written with open boundaries + (`Interval[1.0, 1.4)`) while the actual `Range` is always closed; the comparison must + equate the two using the declared precision. + +## Step 1 — `NumericIntervalExtractor` + +New file `src/extractors/value-type-extractors/numeric-interval-extractor.ts`: + +- Handles a parameter with `valueRange` only when the interval is *declared* numeric by the + `http://hl7.org/fhir/StructureDefinition/cqf-cqlType` extension on the parameter naming + `Interval`, `Interval` or `Interval` (the + `System.` prefix is optional). Detection is strictly this: no such extension (or a + non-numeric interval type) means the range is not claimed and falls through to + `QuantityIntervalExtractor` unchanged. This also resolves the existing TODO pattern in + `datetime-interval-extractor.ts`. + - A fallback that inferred a numeric interval from boundary quantities with `code === '1'`, + `system === 'http://unitsofmeasure.org'` and no human `unit` was implemented and then + rejected in review: FHIR-56226 only defines the forward mapping (how a numeric interval + is represented), not an inverse detection rule, and unity coding is ambiguous with a + genuinely dimensionless `Interval`. +- Extracts to the same shape CVL produces: `{lowClosed, low, highClosed, high}` with + **plain-number boundaries** (missing boundary → `null` + `*Closed: false`, mirroring + `QuantityIntervalExtractor`). +- Reads the `quantity-precision` extension from both plausible placements and records it: + - `low.extension[]` (placement shown in the Jira example), and + - `low._value.extension[]` (primitive-extension placement, since the extension is + defined on `Quantity.value`). +- Precision must ride along for the comparison step **without breaking structural + equality** (`resultsEqual` compares `Object.keys` counts). Attach it under a shared + `Symbol` key (e.g. `INTERVAL_PRECISION` exported from a small + `src/shared/interval-utils.ts`): symbols are invisible to `Object.keys`/JSON but + readable by the comparator. +- Register it in `src/server/extractor-builder.ts` **before** `QuantityIntervalExtractor` + so declared numeric ranges are claimed first; every other range (including unity-coded + ranges without a cqlType extension) keeps its current behavior. + +## Step 2 — Interval-aware comparison + +In `src/shared/results-utils.ts` (helpers in `src/shared/interval-utils.ts`): + +- In `resultsEqual`, before the generic object walk, detect "interval-shaped" operands + (objects owning `lowClosed` and `highClosed`) and delegate to `intervalsEqual(expected, actual)`. +- `intervalsEqual` normalizes both sides to closed boundaries, then compares numerically + (reusing the existing `1e-8` epsilon): + - **Comparison step size**: `10^-p` from the actual boundary's `quantity-precision` + extension when present; otherwise the CQL default — `1` for Integer/Long boundaries, + `1e-8` for Decimal (this is why suite expecteds are written like + `Interval[1.0, 3.99999999]`). + - **Expected side**: if `highClosed === false && high !== null`, replace `high` with + `high - step` (successor for open low). With the ticket's example, expected + `[1.0, 1.4)` at the actual's declared precision 1 → `[1.0, 1.3]`, matching the + actual Range; expected `[1.0, 3.99999999]` (already closed) still matches an + engine that sends the full-precision predecessor. + - **Actual side**: extracted Ranges are already closed; the flags stay as extracted. + Intervals from other representations (e.g. the `part`-based Tuple form in issue #85) + may carry `highClosed: false` — normalize those the same way so open-vs-closed + equivalence works for every numeric interval representation, not just Range. + - **Null boundaries** compare as today (both null → equal). + - **Long**: CVL yields `BigInt` for `1L` literals; coerce BigInt↔number safely before + the epsilon compare (values inside FHIR decimals are within `Number` range in the + suite; guard with `Number.isSafeInteger` and fall back to `BigInt` equality when both + sides are integral). +- Deliberately *not* over-lenient: sides are compared after normalizing at the **actual's + declared precision** (or CQL default). An engine that truncates a predecessor at a + precision it did not declare (e.g. returns `3.9` with no precision extension for + `[1.0, 4.0)`) still fails against `[1.0, 3.99999999]` — that is a genuinely different + interval. + +## Step 3 — Unit tests (vitest, `test/`) + +- `test/extractResults-cql_operations.test.ts` (and the `$evaluate` twin): + - cqlType-declared Range with precision extensions (both `extension` and + `_value.extension` placements) → numeric interval with plain-number boundaries; + - integer range (no precision extension) and half-open range (missing `high`); + - real quantity Range (e.g. `ml`), an `Interval` cqlType over unity + boundaries, and a unity-coded range with **no** cqlType extension all still extract via + `QuantityIntervalExtractor` (regression guards on chain ordering and strict detection). +- `test/results-utils.test.ts`: + - ticket example: expected `Interval[1.0, 1.4)` vs actual `[1.0, 1.3]` @ precision 1 → pass; + - issue #85 case: expected `Interval[1.0, 3.99999999]` vs actual open `[1.0, 4.0)` + (part-form) and vs closed `[1.0, 3.99999999]` → pass; + - `IntegerIntervalExcept1to3`-style: expected `Interval[1, 4)` vs actual `[1, 3]` → pass; + - negative cases: mismatched precision (`3.9`, no extension, vs `3.99999999`), + differing closed flags with differing values, null vs non-null boundary; + - Long boundaries (`Interval[1L, 4L)`). +- `test/cvl-parser.test.ts`: add open-boundary interval parse cases if not covered. + +## Step 4 — Docs and configuration cleanup + +- Update the README Test Flow notes (currently "Interval → Range + cqf-cqlType") to + describe the numeric-interval mapping and the precision-based open→closed + normalization. +- Once verified against a live engine, drop (or re-word) the five issue-85 skip entries in + `conf/cql-execution-local.json`; they are engine-specific config, so removal can be a + follow-up commit after a `run-tests` pass against cql-execution. + +## Risks / open questions + +- **FHIR-56226 is still "Submitted"** — the mapping is proposed, not yet applied to the + CQL IG. Detection *requires* the `cqf-cqlType` extension, so existing representations keep + working unchanged (part-based Tuple form, plain quantity ranges), but an engine that omits + `cqf-cqlType` will have its numeric-interval results extracted as quantity intervals and + will fail those tests until it declares the type. +- **Extension placement ambiguity** — the ticket shows `low.extension`, FHIR primitive + extension rules imply `low._value.extension`; support both until the IG publishes the + normative shape. +- **String decimal values** — the ticket example shows `value: "1.0"` (a string) to keep + trailing zeros; FHIR JSON says `decimal`. Tolerate both (`typeof value === 'string'` → + `Number(value)`, and take digits-after-dot as an implicit precision fallback when no + extension is present). +- **Quantity intervals** — the same open/closed problem exists for `Interval` + (skips `ExceptTime2`/`ExceptTimeInterval` are Time, and quantity ranges can be open + too). The `intervalsEqual` normalization is written boundary-type-agnostic where cheap + (numbers and `{value, unit}` quantities); Date/DateTime/Time predecessor logic is out of + scope here and tracked by issues #80/#84. diff --git a/src/extractors/value-type-extractors/numeric-interval-extractor.ts b/src/extractors/value-type-extractors/numeric-interval-extractor.ts new file mode 100644 index 0000000..3b30b01 --- /dev/null +++ b/src/extractors/value-type-extractors/numeric-interval-extractor.ts @@ -0,0 +1,127 @@ +import { BaseExtractor } from '../base-extractor.js'; +import { IntervalMeta, IntervalPointType, setIntervalMeta } from '../../shared/interval-utils.js'; + +const CQL_TYPE_URL = 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType'; +const PRECISION_URL = 'http://hl7.org/fhir/StructureDefinition/quantity-precision'; +const NUMERIC_INTERVAL_TYPE = /^Interval<(?:System\.)?(Integer|Long|Decimal)>$/; + +function cqlType(parameter: any): string | undefined { + if (!Array.isArray(parameter.extension)) { + return undefined; + } + const extension = parameter.extension.find( + (e: any) => e !== null && typeof e === 'object' && e.url === CQL_TYPE_URL + ); + return extension !== undefined && typeof extension.valueString === 'string' + ? extension.valueString + : undefined; +} + +function precisionExtension(extensions: any): number | undefined { + if (!Array.isArray(extensions)) { + return undefined; + } + const extension = extensions.find( + (e: any) => e !== null && typeof e === 'object' && e.url === PRECISION_URL + ); + return extension !== undefined && typeof extension.valueInteger === 'number' + ? extension.valueInteger + : undefined; +} + +/** + * Decimal places declared for a boundary: the quantity-precision extension on the + * Quantity itself (as in the FHIR-56226 example) or on Quantity.value as a primitive + * extension, falling back to the digits of a string-encoded value. + */ +function precisionOf(quantity: any): number | undefined { + const declared = + precisionExtension(quantity.extension) ?? + precisionExtension( + quantity._value !== null && typeof quantity._value === 'object' + ? quantity._value.extension + : undefined + ); + if (declared !== undefined) { + return declared; + } + + if (typeof quantity.value === 'string') { + const separator = quantity.value.indexOf('.'); + if (separator >= 0) { + return quantity.value.length - separator - 1; + } + } + + return undefined; +} + +function boundaryValue(quantity: any): number | null { + if (quantity === null || typeof quantity !== 'object' || !quantity.hasOwnProperty('value')) { + return null; + } + const value = typeof quantity.value === 'string' ? Number(quantity.value) : quantity.value; + // A value that is missing or not numeric makes the boundary unusable; treat it as + // absent so the closed flag stays consistent and no NaN/undefined reaches comparison. + return typeof value === 'number' && !Number.isNaN(value) ? value : null; +} + +/** + * Extracts a `valueRange` that represents a numeric CQL interval + * (`Interval`, FHIR-56226) into the plain-number shape CVL + * produces: `{lowClosed, low, highClosed, high}`. + * + * Detection is strict: the parameter must carry a `cqf-cqlType` extension naming + * `Interval`, `Interval` or `Interval` (the `System.` prefix is + * optional). Any other `valueRange` — including one whose boundaries are unity quantities + * (`code: "1"`, UCUM) but which declares no cqlType — is left to + * `QuantityIntervalExtractor`. FHIR-56226 only defines the forward mapping, and unity + * coding alone is ambiguous with a dimensionless `Interval`. + */ +export class NumericIntervalExtractor extends BaseExtractor { + protected _process(parameter: any): any { + if (!parameter.hasOwnProperty('valueRange')) { + return undefined; + } + + const range = parameter.valueRange; + if (range === null || typeof range !== 'object') { + return undefined; + } + + // The cqlType extension is required and authoritative: without a numeric interval + // type this range is not ours, whatever its boundaries look like. + const declaredType = cqlType(parameter); + const match = declaredType !== undefined ? NUMERIC_INTERVAL_TYPE.exec(declaredType) : null; + if (match === null) { + return undefined; + } + const pointType = match[1] as IntervalPointType; + + const lowQuantity = range.hasOwnProperty('low') ? range.low : undefined; + const highQuantity = range.hasOwnProperty('high') ? range.high : undefined; + + const low = lowQuantity !== undefined ? boundaryValue(lowQuantity) : null; + const high = highQuantity !== undefined ? boundaryValue(highQuantity) : null; + + const result = { + lowClosed: low !== null, + low: low, + highClosed: high !== null, + high: high, + }; + + const meta: IntervalMeta = { pointType: pointType }; + const lowPrecision = low !== null ? precisionOf(lowQuantity) : undefined; + if (lowPrecision !== undefined) { + meta.lowPrecision = lowPrecision; + } + const highPrecision = high !== null ? precisionOf(highQuantity) : undefined; + if (highPrecision !== undefined) { + meta.highPrecision = highPrecision; + } + setIntervalMeta(result, meta); + + return result; + } +} diff --git a/src/server/extractor-builder.ts b/src/server/extractor-builder.ts index 41e125a..015ec6c 100644 --- a/src/server/extractor-builder.ts +++ b/src/server/extractor-builder.ts @@ -11,6 +11,7 @@ import { TimeExtractor } from '../extractors/value-type-extractors/time-extracto import { QuantityExtractor } from '../extractors/value-type-extractors/quantity-extractor.js'; import { RatioExtractor } from '../extractors/value-type-extractors/ratio-extractor.js'; import { DateTimeIntervalExtractor } from '../extractors/value-type-extractors/datetime-interval-extractor.js'; +import { NumericIntervalExtractor } from '../extractors/value-type-extractors/numeric-interval-extractor.js'; import { QuantityIntervalExtractor } from '../extractors/value-type-extractors/quantity-interval-extractor.js'; import { CodeExtractor } from '../extractors/value-type-extractors/code-extractor.js'; import { ConceptExtractor } from '../extractors/value-type-extractors/concept-extractor.js'; @@ -20,23 +21,24 @@ import { ResultExtractor } from '../extractors/result-extractor.js'; * Builds a ResultExtractor with the full chain of extractors */ export function buildExtractor(): ResultExtractor { - const extractors = new EvaluationErrorExtractor(); - extractors - .setNextExtractor(new NullEmptyExtractor()) - .setNextExtractor(new UndefinedExtractor()) - .setNextExtractor(new StringExtractor()) - .setNextExtractor(new BooleanExtractor()) - .setNextExtractor(new IntegerExtractor()) - .setNextExtractor(new DecimalExtractor()) - .setNextExtractor(new DateExtractor()) - .setNextExtractor(new DateTimeExtractor()) - .setNextExtractor(new TimeExtractor()) - .setNextExtractor(new QuantityExtractor()) - .setNextExtractor(new RatioExtractor()) - .setNextExtractor(new DateTimeIntervalExtractor()) - .setNextExtractor(new QuantityIntervalExtractor()) - .setNextExtractor(new CodeExtractor()) - .setNextExtractor(new ConceptExtractor()); - - return new ResultExtractor(extractors); + const extractors = new EvaluationErrorExtractor(); + extractors + .setNextExtractor(new NullEmptyExtractor()) + .setNextExtractor(new UndefinedExtractor()) + .setNextExtractor(new StringExtractor()) + .setNextExtractor(new BooleanExtractor()) + .setNextExtractor(new IntegerExtractor()) + .setNextExtractor(new DecimalExtractor()) + .setNextExtractor(new DateExtractor()) + .setNextExtractor(new DateTimeExtractor()) + .setNextExtractor(new TimeExtractor()) + .setNextExtractor(new QuantityExtractor()) + .setNextExtractor(new RatioExtractor()) + .setNextExtractor(new DateTimeIntervalExtractor()) + .setNextExtractor(new NumericIntervalExtractor()) + .setNextExtractor(new QuantityIntervalExtractor()) + .setNextExtractor(new CodeExtractor()) + .setNextExtractor(new ConceptExtractor()); + + return new ResultExtractor(extractors); } diff --git a/src/shared/interval-utils.ts b/src/shared/interval-utils.ts new file mode 100644 index 0000000..462e2c4 --- /dev/null +++ b/src/shared/interval-utils.ts @@ -0,0 +1,52 @@ +/** + * Shared helpers for numeric interval handling (FHIR-56226). + * + * Numeric intervals (Interval) are mapped to FHIR Range with + * unity-coded Quantity boundaries carrying a quantity-precision extension. The + * extractor records that precision (and the CQL point type) as metadata on the + * extracted interval object so the comparison can equate open and closed boundary + * forms. The metadata lives under a Symbol key and is non-enumerable, so it is + * invisible to Object.keys-based structural comparison and JSON serialization. + */ + +export const INTERVAL_META = Symbol.for('cql-tests-runner.intervalMeta'); + +export type IntervalPointType = 'Integer' | 'Long' | 'Decimal'; + +export interface IntervalMeta { + pointType?: IntervalPointType; + /** Decimal places declared via the quantity-precision extension on the low boundary. */ + lowPrecision?: number; + /** Decimal places declared via the quantity-precision extension on the high boundary. */ + highPrecision?: number; +} + +export function setIntervalMeta(interval: object, meta: IntervalMeta): void { + Object.defineProperty(interval, INTERVAL_META, { + value: meta, + enumerable: false, + writable: true, + configurable: true, + }); +} + +export function getIntervalMeta(interval: any): IntervalMeta | undefined { + if (interval === null || typeof interval !== 'object') { + return undefined; + } + return (interval as any)[INTERVAL_META]; +} + +/** + * True when a value has the {lowClosed, low, highClosed, high} interval shape + * produced by both the CVL parser and the interval extractors. + */ +export function isIntervalShaped(value: any): boolean { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.prototype.hasOwnProperty.call(value, 'lowClosed') && + Object.prototype.hasOwnProperty.call(value, 'highClosed') + ); +} diff --git a/src/shared/results-utils.ts b/src/shared/results-utils.ts index 4175b3e..f6068eb 100644 --- a/src/shared/results-utils.ts +++ b/src/shared/results-utils.ts @@ -1,3 +1,11 @@ +import { getIntervalMeta, isIntervalShaped, type IntervalMeta } from './interval-utils.js'; + +/** Numeric tolerance shared by scalar and interval-boundary comparison. */ +const EPSILON = 0.00000001; + +/** CQL decimal successor/predecessor step (10^-8). */ +const DECIMAL_STEP = 0.00000001; + /** * Compares an expected CQL Long (parsed to a BigInt by cvl) against the actual value. * FHIR R4 has no integer64 type, so a Long result is returned as valueString and the @@ -30,7 +38,7 @@ export function resultsEqual(expected: any, actual: any): boolean { } if (typeof expected === 'number') { - return Math.abs(actual - expected) < 0.00000001; + return Math.abs(actual - expected) < EPSILON; } if (typeof expected === 'bigint') { @@ -50,16 +58,277 @@ export function resultsEqual(expected: any, actual: any): boolean { return false; } + if (isIntervalShaped(expected) && isIntervalShaped(actual)) { + return intervalsEqual(expected, actual); + } + + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual); + + if (expectedKeys.length !== actualKeys.length) return false; + + for (const key of expectedKeys) { + if (!actualKeys.includes(key) || !resultsEqual(expected[key], actual[key])) { + return false; + } + } + + return true; +} + +const INTERVAL_KEYS = ['lowClosed', 'low', 'highClosed', 'high']; + +/** + * Compares two {lowClosed, low, highClosed, high} intervals, equating open and closed + * forms of the same interval (FHIR-56226 / issue #85). + * + * FHIR `Range` cannot express open boundaries, so an engine reports `Interval[1.0, 1.4)` + * as the closed `[1.0, 1.3]` plus a `quantity-precision` extension (precision 1). Expected + * values parsed from the test suite may use either form. Both sides are therefore + * normalized to closed boundaries by moving an open boundary one step inwards, and the + * closed flags are consumed by that normalization rather than compared directly. + * + * Boundaries that are not numeric (date/time strings, non-numeric quantity values) fall + * back to the pre-existing structural comparison: closed flags must match and the values + * must be `resultsEqual`. + */ +export function intervalsEqual(expected: any, actual: any): boolean { + // Guard against interval-shaped objects that carry additional properties: anything + // beyond the four interval keys is compared exactly as before. + if (!extraKeysEqual(expected, actual)) return false; + + const meta = getIntervalMeta(actual); + const integral = allBoundariesIntegral(expected, actual); + + return ( + boundariesEqual( + expected.low, + expected.lowClosed === true, + actual.low, + actual.lowClosed === true, + 'low', + stepFor('low', meta, integral) + ) && + boundariesEqual( + expected.high, + expected.highClosed === true, + actual.high, + actual.highClosed === true, + 'high', + stepFor('high', meta, integral) + ) + ); +} + +function extraKeysEqual(expected: any, actual: any): boolean { const expectedKeys = Object.keys(expected); const actualKeys = Object.keys(actual); if (expectedKeys.length !== actualKeys.length) return false; for (const key of expectedKeys) { + if (INTERVAL_KEYS.includes(key)) continue; if (!actualKeys.includes(key) || !resultsEqual(expected[key], actual[key])) { return false; } } + // The four interval keys must be present on both sides (isIntervalShaped only + // guarantees the two closed flags). + for (const key of INTERVAL_KEYS) { + if (expectedKeys.includes(key) !== actualKeys.includes(key)) return false; + } + return true; } + +/** + * Distance between adjacent points of the interval's point type, used to convert an open + * boundary to its closed equivalent. + */ +function stepFor( + side: 'low' | 'high', + meta: IntervalMeta | undefined, + allIntegral: boolean +): number { + // Integer and Long points are one apart by definition; a precision extension cannot + // change that (a valid precision of 0 would yield the same step anyway). + if (meta?.pointType === 'Integer' || meta?.pointType === 'Long') { + return 1; + } + + // Only a non-negative integer is a meaningful decimal-place count; anything else is + // ignored rather than producing a step greater than 1 or a fractional power of ten. + const precision = side === 'low' ? meta?.lowPrecision : meta?.highPrecision; + if (typeof precision === 'number' && Number.isInteger(precision) && precision >= 0) { + return Math.pow(10, -precision); + } + + // An explicitly declared Decimal point type overrides the integrality heuristic below: + // 1.0 and 1 are the same JS number, so only the declaration can tell them apart. + if (meta?.pointType === 'Decimal') { + return DECIMAL_STEP; + } + + if (allIntegral) { + return 1; + } + + return DECIMAL_STEP; +} + +/** + * True when every non-null numeric boundary on both sides is an integer (or a BigInt). + * Heuristic for integer/long intervals whose expected values carry no type information. + * Quantity boundaries never qualify: a CQL Quantity value is always a Decimal, so the + * predecessor of 2 'ml' is 1.99999999 'ml', however integral the values look. + */ +function allBoundariesIntegral(expected: any, actual: any): boolean { + let sawValue = false; + + for (const interval of [expected, actual]) { + for (const side of ['low', 'high']) { + const boundary = interval[side]; + if (isQuantityBoundary(boundary)) return false; + const value = numericValueOf(boundary); + if (value === null || value === undefined) continue; + sawValue = true; + if (typeof value === 'number' && !Number.isInteger(value)) return false; + } + } + + return sawValue; +} + +/** + * The numeric value of a boundary: the boundary itself for numbers/BigInts, `.value` for + * quantity boundaries, `null` for absent boundaries, `undefined` when not numeric at all. + */ +function numericValueOf(boundary: any): number | bigint | null | undefined { + if (boundary === null || boundary === undefined) return null; + if (typeof boundary === 'number' || typeof boundary === 'bigint') return boundary; + + if (isQuantityBoundary(boundary)) { + const value = boundary.value; + if (value === null || value === undefined) return null; + if (typeof value === 'number' || typeof value === 'bigint') return value; + } + + return undefined; +} + +function isQuantityBoundary(boundary: any): boolean { + return ( + boundary !== null && + typeof boundary === 'object' && + !Array.isArray(boundary) && + Object.prototype.hasOwnProperty.call(boundary, 'value') + ); +} + +function boundariesEqual( + expectedBoundary: any, + expectedClosed: boolean, + actualBoundary: any, + actualClosed: boolean, + side: 'low' | 'high', + step: number +): boolean { + const expectedValue = numericValueOf(expectedBoundary); + const actualValue = numericValueOf(actualBoundary); + + if (expectedValue === null && actualValue === null) { + // A missing boundary carries no value to normalize, so the flags matter directly. + return expectedClosed === actualClosed; + } + if (expectedValue === null || actualValue === null) { + return false; + } + + // Non-numeric boundaries (date/time strings, coded values, ...) keep the pre-existing + // structural comparison; predecessor/successor logic for those is out of scope. + if (expectedValue === undefined || actualValue === undefined) { + return expectedClosed === actualClosed && resultsEqual(expectedBoundary, actualBoundary); + } + + const expectedIsQuantity = isQuantityBoundary(expectedBoundary); + const actualIsQuantity = isQuantityBoundary(actualBoundary); + if (expectedIsQuantity !== actualIsQuantity) return false; + + if (expectedIsQuantity) { + if (!unitsEqual(expectedBoundary.unit, actualBoundary.unit)) return false; + + // Everything on the quantity other than the numeric value must match structurally. + const expectedKeys = Object.keys(expectedBoundary); + const actualKeys = Object.keys(actualBoundary); + if (expectedKeys.length !== actualKeys.length) return false; + for (const key of expectedKeys) { + if (key === 'value' || key === 'unit') continue; + if ( + !actualKeys.includes(key) || + !resultsEqual(expectedBoundary[key], actualBoundary[key]) + ) { + return false; + } + } + } + + return scalarsEqual( + closeBoundary(expectedValue, expectedClosed, side, step), + closeBoundary(actualValue, actualClosed, side, step) + ); +} + +function unitsEqual(expectedUnit: any, actualUnit: any): boolean { + const expected = expectedUnit === undefined ? null : expectedUnit; + const actual = actualUnit === undefined ? null : actualUnit; + return expected === actual; +} + +/** Converts an open boundary to its closed equivalent by moving it one step inwards. */ +function closeBoundary( + value: number | bigint, + closed: boolean, + side: 'low' | 'high', + step: number +): number | bigint { + if (closed) return value; + + if (typeof value === 'bigint') { + if (Number.isInteger(step)) { + const bigStep = BigInt(step); + return side === 'high' ? value - bigStep : value + bigStep; + } + const asNumber = Number(value); + return side === 'high' ? asNumber - step : asNumber + step; + } + + return side === 'high' ? value - step : value + step; +} + +function scalarsEqual(expected: number | bigint, actual: number | bigint): boolean { + if (typeof expected === 'bigint' && typeof actual === 'bigint') { + if (isSafeBigInt(expected) && isSafeBigInt(actual)) { + return Math.abs(Number(expected) - Number(actual)) < EPSILON; + } + return expected === actual; + } + + if (typeof expected === 'bigint' || typeof actual === 'bigint') { + const big = (typeof expected === 'bigint' ? expected : actual) as bigint; + const other = (typeof expected === 'bigint' ? actual : expected) as number; + + if (isSafeBigInt(big)) { + return Math.abs(Number(big) - other) < EPSILON; + } + + // Outside the safe integer range only an exact integral comparison is meaningful. + return Number.isInteger(other) && BigInt(other) === big; + } + + return Math.abs(expected - actual) < EPSILON; +} + +function isSafeBigInt(value: bigint): boolean { + return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER); +} diff --git a/test/extractResults-cql_operations.test.ts b/test/extractResults-cql_operations.test.ts index 3f751d5..a969ebc 100644 --- a/test/extractResults-cql_operations.test.ts +++ b/test/extractResults-cql_operations.test.ts @@ -3,6 +3,11 @@ import { beforeAll, expect, test } from 'vitest'; import { ResultExtractor } from '../src/extractors/result-extractor.js'; import { ValueMap } from '../src/extractors/value-map.js'; import { buildExtractor } from '../src/server/extractor-builder.js'; +import { getIntervalMeta } from '../src/shared/interval-utils.js'; + +const CQL_TYPE_URL = 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType'; +const PRECISION_URL = 'http://hl7.org/fhir/StructureDefinition/quantity-precision'; +const UCUM_SYSTEM = 'http://unitsofmeasure.org'; let extractor: ResultExtractor | null = null; @@ -10,6 +15,19 @@ beforeAll(() => { extractor = buildExtractor(); }); +function extractRange(range: any, extension?: any[]): any { + return extractor!.extract({ + resourceType: 'Parameters', + parameter: [ + { + name: 'return', + ...(extension === undefined ? {} : { extension: extension }), + valueRange: range, + }, + ], + }); +} + test('boolean response check', () => { expect( extractor!.extract({ @@ -338,7 +356,12 @@ test('evaluation error does not throw when resource shape is minimal', () => { expect( extractor!.extract({ resourceType: 'Parameters', - parameter: [{ name: 'evaluation error', resource: { resourceType: 'OperationOutcome', issue: [] } }], + parameter: [ + { + name: 'evaluation error', + resource: { resourceType: 'OperationOutcome', issue: [] }, + }, + ], }) ).toBe('EvaluationError:{"resourceType":"OperationOutcome","issue":[]}'); }); @@ -591,3 +614,164 @@ test('nested list of integers response check', () => { [4, 5, 6], ]); }); + +// Numeric intervals mapped to Range with unity-coded boundaries (FHIR-56226) + +test('decimal interval response check (precision extension on the quantity)', () => { + const result = extractRange( + { + low: { + value: 1.0, + system: UCUM_SYSTEM, + code: '1', + extension: [{ url: PRECISION_URL, valueInteger: 1 }], + }, + high: { + value: 1.3, + system: UCUM_SYSTEM, + code: '1', + extension: [{ url: PRECISION_URL, valueInteger: 1 }], + }, + }, + [{ url: CQL_TYPE_URL, valueString: 'Interval' }] + ); + expect(result).toStrictEqual({ lowClosed: true, low: 1.0, highClosed: true, high: 1.3 }); + expect(getIntervalMeta(result)).toStrictEqual({ + pointType: 'Decimal', + lowPrecision: 1, + highPrecision: 1, + }); +}); + +test('decimal interval response check (precision extension on Quantity.value)', () => { + const result = extractRange( + { + low: { + value: 1.0, + system: UCUM_SYSTEM, + code: '1', + _value: { extension: [{ url: PRECISION_URL, valueInteger: 1 }] }, + }, + high: { + value: 1.3, + system: UCUM_SYSTEM, + code: '1', + _value: { extension: [{ url: PRECISION_URL, valueInteger: 1 }] }, + }, + }, + [{ url: CQL_TYPE_URL, valueString: 'Interval' }] + ); + expect(result).toStrictEqual({ lowClosed: true, low: 1.0, highClosed: true, high: 1.3 }); + expect(getIntervalMeta(result)).toStrictEqual({ + pointType: 'Decimal', + lowPrecision: 1, + highPrecision: 1, + }); +}); + +test('integer interval response check (typed by the cqlType extension)', () => { + const result = extractRange( + { + low: { value: 1, system: UCUM_SYSTEM, code: '1' }, + high: { value: 3, system: UCUM_SYSTEM, code: '1' }, + }, + [{ url: CQL_TYPE_URL, valueString: 'Interval' }] + ); + expect(result).toStrictEqual({ lowClosed: true, low: 1, highClosed: true, high: 3 }); + expect(getIntervalMeta(result)).toStrictEqual({ pointType: 'Integer' }); +}); + +test('numeric interval response check with string-encoded boundary values', () => { + const result = extractRange( + { + low: { value: '1.0', system: UCUM_SYSTEM, code: '1' }, + high: { value: '1.40', system: UCUM_SYSTEM, code: '1' }, + }, + [{ url: CQL_TYPE_URL, valueString: 'Interval' }] + ); + expect(result).toStrictEqual({ lowClosed: true, low: 1.0, highClosed: true, high: 1.4 }); + expect(getIntervalMeta(result)).toStrictEqual({ + pointType: 'Decimal', + lowPrecision: 1, + highPrecision: 2, + }); +}); + +test('half-open numeric interval response check (no high boundary)', () => { + const result = extractRange({ low: { value: 1, system: UCUM_SYSTEM, code: '1' } }, [ + { url: CQL_TYPE_URL, valueString: 'Interval' }, + ]); + expect(result).toStrictEqual({ lowClosed: true, low: 1, highClosed: false, high: null }); + expect(getIntervalMeta(result)).toStrictEqual({ pointType: 'Decimal' }); +}); + +test('quantity interval response check still yields quantity boundaries', () => { + const result = extractRange({ + low: { value: 1, unit: 'ml', system: UCUM_SYSTEM, code: 'ml' }, + high: { value: 2, unit: 'ml', system: UCUM_SYSTEM, code: 'ml' }, + }); + expect(result).toStrictEqual({ + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: true, + high: { value: 2, unit: 'ml' }, + }); + expect(getIntervalMeta(result)).toBeUndefined(); +}); + +test('quantity interval declared by cqlType is not treated as numeric', () => { + const result = extractRange( + { + low: { value: 1, system: UCUM_SYSTEM, code: '1' }, + high: { value: 2, system: UCUM_SYSTEM, code: '1' }, + }, + [{ url: CQL_TYPE_URL, valueString: 'Interval' }] + ); + expect(result).toStrictEqual({ + lowClosed: true, + low: { value: 1, unit: '1' }, + highClosed: true, + high: { value: 2, unit: '1' }, + }); + expect(getIntervalMeta(result)).toBeUndefined(); +}); + +test('unity-coded range without a cqlType extension stays a quantity interval', () => { + // Strict detection: unity coding alone does not identify a numeric interval, so the + // range falls through to QuantityIntervalExtractor. + const result = extractRange({ + low: { value: 1, system: UCUM_SYSTEM, code: '1' }, + high: { value: 2, system: UCUM_SYSTEM, code: '1' }, + }); + expect(result).toStrictEqual({ + lowClosed: true, + low: { value: 1, unit: '1' }, + highClosed: true, + high: { value: 2, unit: '1' }, + }); + expect(getIntervalMeta(result)).toBeUndefined(); +}); + +test('boundary without a usable numeric value is treated as absent', () => { + // A present boundary quantity whose value is missing or not numeric must not produce + // a closed boundary with an unusable value (PR #110 review). + const result = extractRange( + { + low: { value: 'abc', code: '1', system: 'http://unitsofmeasure.org' }, + high: { value: 2, code: '1', system: 'http://unitsofmeasure.org' }, + }, + [{ url: CQL_TYPE_URL, valueString: 'Interval' }] + ); + + expect(result).toStrictEqual({ lowClosed: false, low: null, highClosed: true, high: 2 }); + + expect( + extractRange( + { + low: { value: 1, code: '1', system: 'http://unitsofmeasure.org' }, + high: { value: true, code: '1', system: 'http://unitsofmeasure.org' }, + }, + [{ url: CQL_TYPE_URL, valueString: 'Interval' }] + ) + ).toStrictEqual({ lowClosed: true, low: 1, highClosed: false, high: null }); +}); diff --git a/test/extractResults-library_evaluate_operations.test.ts b/test/extractResults-library_evaluate_operations.test.ts index 56d8fec..867e423 100644 --- a/test/extractResults-library_evaluate_operations.test.ts +++ b/test/extractResults-library_evaluate_operations.test.ts @@ -2,6 +2,11 @@ import { beforeAll, expect, test } from 'vitest'; import { ResultExtractor } from '../src/extractors/result-extractor.js'; import { buildExtractor } from '../src/server/extractor-builder.js'; +import { getIntervalMeta } from '../src/shared/interval-utils.js'; + +const CQL_TYPE_URL = 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType'; +const PRECISION_URL = 'http://hl7.org/fhir/StructureDefinition/quantity-precision'; +const UCUM_SYSTEM = 'http://unitsofmeasure.org'; let extractor: ResultExtractor | null = null; @@ -320,3 +325,150 @@ test('lists response check', () => { ], }); }); + +// Numeric intervals mapped to Range with unity-coded boundaries (FHIR-56226) + +test('numeric interval types response check', () => { + const result = extractor!.extract({ + resourceType: 'Parameters', + parameter: [ + { + name: 'get_decimal_interval', + extension: [{ url: CQL_TYPE_URL, valueString: 'Interval' }], + valueRange: { + low: { + value: 1.0, + system: UCUM_SYSTEM, + code: '1', + extension: [{ url: PRECISION_URL, valueInteger: 1 }], + }, + high: { + value: 1.3, + system: UCUM_SYSTEM, + code: '1', + extension: [{ url: PRECISION_URL, valueInteger: 1 }], + }, + }, + }, + { + name: 'get_decimal_interval_primitive_extension', + extension: [{ url: CQL_TYPE_URL, valueString: 'Interval' }], + valueRange: { + low: { + value: 1.0, + system: UCUM_SYSTEM, + code: '1', + _value: { extension: [{ url: PRECISION_URL, valueInteger: 1 }] }, + }, + high: { + value: 1.3, + system: UCUM_SYSTEM, + code: '1', + _value: { extension: [{ url: PRECISION_URL, valueInteger: 1 }] }, + }, + }, + }, + { + name: 'get_integer_interval', + extension: [{ url: CQL_TYPE_URL, valueString: 'Interval' }], + valueRange: { + low: { value: 1, system: UCUM_SYSTEM, code: '1' }, + high: { value: 3, system: UCUM_SYSTEM, code: '1' }, + }, + }, + { + name: 'get_string_valued_interval', + extension: [{ url: CQL_TYPE_URL, valueString: 'Interval' }], + valueRange: { + low: { value: '1.0', system: UCUM_SYSTEM, code: '1' }, + high: { value: '1.40', system: UCUM_SYSTEM, code: '1' }, + }, + }, + { + name: 'get_half_open_interval', + extension: [{ url: CQL_TYPE_URL, valueString: 'Interval' }], + valueRange: { + low: { value: 1, system: UCUM_SYSTEM, code: '1' }, + }, + }, + { + name: 'get_quantity_interval', + valueRange: { + low: { value: 1, unit: 'ml', system: UCUM_SYSTEM, code: 'ml' }, + high: { value: 2, unit: 'ml', system: UCUM_SYSTEM, code: 'ml' }, + }, + }, + { + name: 'get_quantity_interval_by_cql_type', + extension: [{ url: CQL_TYPE_URL, valueString: 'Interval' }], + valueRange: { + low: { value: 1, system: UCUM_SYSTEM, code: '1' }, + high: { value: 2, system: UCUM_SYSTEM, code: '1' }, + }, + }, + { + // Strict detection: unity coding alone is not enough, so this falls through + // to QuantityIntervalExtractor. + name: 'get_unity_range_without_cql_type', + valueRange: { + low: { value: 1, system: UCUM_SYSTEM, code: '1' }, + high: { value: 2, system: UCUM_SYSTEM, code: '1' }, + }, + }, + ], + }); + + expect(result).toStrictEqual({ + get_decimal_interval: { lowClosed: true, low: 1.0, highClosed: true, high: 1.3 }, + get_decimal_interval_primitive_extension: { + lowClosed: true, + low: 1.0, + highClosed: true, + high: 1.3, + }, + get_integer_interval: { lowClosed: true, low: 1, highClosed: true, high: 3 }, + get_string_valued_interval: { lowClosed: true, low: 1.0, highClosed: true, high: 1.4 }, + get_half_open_interval: { lowClosed: true, low: 1, highClosed: false, high: null }, + get_quantity_interval: { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: true, + high: { value: 2, unit: 'ml' }, + }, + get_quantity_interval_by_cql_type: { + lowClosed: true, + low: { value: 1, unit: '1' }, + highClosed: true, + high: { value: 2, unit: '1' }, + }, + get_unity_range_without_cql_type: { + lowClosed: true, + low: { value: 1, unit: '1' }, + highClosed: true, + high: { value: 2, unit: '1' }, + }, + }); + + expect(getIntervalMeta(result.get_decimal_interval)).toStrictEqual({ + pointType: 'Decimal', + lowPrecision: 1, + highPrecision: 1, + }); + expect(getIntervalMeta(result.get_decimal_interval_primitive_extension)).toStrictEqual({ + pointType: 'Decimal', + lowPrecision: 1, + highPrecision: 1, + }); + expect(getIntervalMeta(result.get_integer_interval)).toStrictEqual({ pointType: 'Integer' }); + expect(getIntervalMeta(result.get_string_valued_interval)).toStrictEqual({ + pointType: 'Decimal', + lowPrecision: 1, + highPrecision: 2, + }); + expect(getIntervalMeta(result.get_half_open_interval)).toStrictEqual({ + pointType: 'Decimal', + }); + expect(getIntervalMeta(result.get_quantity_interval)).toBeUndefined(); + expect(getIntervalMeta(result.get_quantity_interval_by_cql_type)).toBeUndefined(); + expect(getIntervalMeta(result.get_unity_range_without_cql_type)).toBeUndefined(); +}); diff --git a/test/results-utils.test.ts b/test/results-utils.test.ts index cfbce24..c4cdf49 100644 --- a/test/results-utils.test.ts +++ b/test/results-utils.test.ts @@ -2,8 +2,17 @@ import { expect, test } from 'vitest'; +import { setIntervalMeta, type IntervalMeta } from '../src/shared/interval-utils.js'; import { resultsEqual } from '../src/shared/results-utils.js'; +/** Builds an "actual" interval carrying extractor metadata, as the extractors produce it. */ +function actualInterval(interval: Record, meta?: IntervalMeta): any { + if (meta) { + setIntervalMeta(interval, meta); + } + return interval; +} + test('singleton list does not equal scalar (comparison stays strict)', () => { expect(resultsEqual(['a'], 'a')).toBe(false); expect(resultsEqual('a', ['a'])).toBe(false); @@ -18,6 +27,295 @@ test('nested structures compared key-wise', () => { expect(resultsEqual({ x: 1 }, { x: 1 })).toBe(true); }); +// FHIR-56226 / issue #85: numeric intervals arrive as FHIR Range, which is always +// closed-boundary; open boundaries are converted at a declared precision. + +test('FHIR-56226 ticket example: open expected high equals closed actual at declared precision', () => { + const expected = { lowClosed: true, low: 1.0, highClosed: false, high: 1.4 }; + const actual = actualInterval( + { lowClosed: true, low: 1.0, highClosed: true, high: 1.3 }, + { pointType: 'Decimal', lowPrecision: 1, highPrecision: 1 } + ); + + expect(resultsEqual(expected, actual)).toBe(true); +}); + +test('declared precision on an already-closed boundary changes nothing', () => { + const expected = { lowClosed: true, low: 1.0, highClosed: true, high: 1.3 }; + const actual = actualInterval( + { lowClosed: true, low: 1.0, highClosed: true, high: 1.3 }, + { pointType: 'Decimal', lowPrecision: 1, highPrecision: 1 } + ); + + expect(resultsEqual(expected, actual)).toBe(true); +}); + +test('issue #85: full-precision closed expected equals open actual part-form (decimal step)', () => { + const expected = { lowClosed: true, low: 1.0, highClosed: true, high: 3.99999999 }; + const actual = { lowClosed: true, low: 1, highClosed: false, high: 4 }; + + expect(resultsEqual(expected, actual)).toBe(true); +}); + +test('issue #85: closed expected equals closed actual of the same decimal predecessor', () => { + const expected = { lowClosed: true, low: 1.0, highClosed: true, high: 3.99999999 }; + const actual = { lowClosed: true, low: 1.0, highClosed: true, high: 3.99999999 }; + + expect(resultsEqual(expected, actual)).toBe(true); +}); + +test('integer interval: open expected [1, 4) equals closed actual [1, 3]', () => { + const expected = { lowClosed: true, low: 1, highClosed: false, high: 4 }; + + // No metadata: all boundaries are integral, so the integer step is inferred. + expect(resultsEqual(expected, { lowClosed: true, low: 1, highClosed: true, high: 3 })).toBe( + true + ); + + // With a declared Integer point type the step is explicit. + expect( + resultsEqual( + expected, + actualInterval( + { lowClosed: true, low: 1, highClosed: true, high: 3 }, + { pointType: 'Integer' } + ) + ) + ).toBe(true); +}); + +test('integer interval: open low boundary is closed by adding the step', () => { + const expected = { lowClosed: false, low: 0, highClosed: true, high: 3 }; + const actual = actualInterval( + { lowClosed: true, low: 1, highClosed: true, high: 3 }, + { pointType: 'Integer' } + ); + + expect(resultsEqual(expected, actual)).toBe(true); +}); + +test('long interval: BigInt expected boundaries compare against numeric actuals', () => { + const expected = { lowClosed: true, low: 1n, highClosed: false, high: 4n }; + + expect(resultsEqual(expected, { lowClosed: true, low: 1, highClosed: true, high: 3 })).toBe( + true + ); + expect( + resultsEqual( + expected, + actualInterval( + { lowClosed: true, low: 1, highClosed: true, high: 3 }, + { pointType: 'Long' } + ) + ) + ).toBe(true); + expect(resultsEqual(expected, { lowClosed: true, low: 1, highClosed: true, high: 4 })).toBe( + false + ); +}); + +test('long interval: BigInt boundaries beyond the safe integer range compare exactly', () => { + const expected = { lowClosed: true, low: 1n, highClosed: false, high: 9007199254740995n }; + const actual = { lowClosed: true, low: 1n, highClosed: true, high: 9007199254740994n }; + + expect(resultsEqual(expected, actual)).toBe(true); + expect( + resultsEqual(expected, { + lowClosed: true, + low: 1n, + highClosed: true, + high: 9007199254740995n, + }) + ).toBe(false); +}); + +test('both boundaries open with the same values are equal', () => { + const expected = { lowClosed: true, low: 1, highClosed: false, high: 4 }; + const actual = { lowClosed: true, low: 1, highClosed: false, high: 4 }; + + expect(resultsEqual(expected, actual)).toBe(true); +}); + +test('undeclared precision truncation is still a mismatch', () => { + const expected = { lowClosed: true, low: 1.0, highClosed: true, high: 3.99999999 }; + const actual = { lowClosed: true, low: 1.0, highClosed: true, high: 3.9 }; + + expect(resultsEqual(expected, actual)).toBe(false); +}); + +test('decimal interval: open expected high does not equal a whole-number truncation', () => { + // A declared Decimal point type keeps the integer-step heuristic from firing: the + // predecessor of 4.0 is 3.99999999, not 3.0. + const expected = { lowClosed: true, low: 1.0, highClosed: false, high: 4.0 }; + const actual = actualInterval( + { lowClosed: true, low: 1.0, highClosed: true, high: 3.0 }, + { pointType: 'Decimal' } + ); + + expect(resultsEqual(expected, actual)).toBe(false); + + // Same conclusion without metadata once any boundary is non-integral. + expect( + resultsEqual( + { lowClosed: true, low: 1.5, highClosed: false, high: 4.0 }, + { lowClosed: true, low: 1.5, highClosed: true, high: 3.0 } + ) + ).toBe(false); +}); + +test('null boundaries: equal only when the closed flags agree', () => { + const expected = { lowClosed: true, low: 1, highClosed: false, high: null }; + + expect(resultsEqual(expected, { lowClosed: true, low: 1, highClosed: false, high: null })).toBe( + true + ); + expect(resultsEqual(expected, { lowClosed: true, low: 1, highClosed: true, high: null })).toBe( + false + ); + expect(resultsEqual(expected, { lowClosed: true, low: 1, highClosed: true, high: 5 })).toBe( + false + ); + expect(resultsEqual({ lowClosed: true, low: 1, highClosed: true, high: 5 }, expected)).toBe( + false + ); +}); + +test('quantity-boundary intervals keep comparing as before', () => { + const expected = { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: true, + high: { value: 2, unit: 'ml' }, + }; + + expect( + resultsEqual(expected, { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: true, + high: { value: 2, unit: 'ml' }, + }) + ).toBe(true); + + // Differing units are never equal, however the boundaries are normalized. + expect( + resultsEqual(expected, { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: true, + high: { value: 2, unit: 'g' }, + }) + ).toBe(false); +}); + +test('quantity-boundary interval: open expected high equals the closed predecessor', () => { + const expected = { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: false, + high: { value: 2, unit: 'ml' }, + }; + const actual = { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: true, + high: { value: 1.99999999, unit: 'ml' }, + }; + + expect(resultsEqual(expected, actual)).toBe(true); +}); + +test('date/time intervals keep the structural comparison', () => { + const expected = { + lowClosed: true, + low: '@2012-01-01', + highClosed: true, + high: '@2012-01-31', + }; + + expect( + resultsEqual(expected, { + lowClosed: true, + low: '@2012-01-01', + highClosed: true, + high: '@2012-01-31', + }) + ).toBe(true); + expect( + resultsEqual(expected, { + lowClosed: true, + low: '@2012-01-01', + highClosed: true, + high: '@2012-02-01', + }) + ).toBe(false); + expect( + resultsEqual(expected, { + lowClosed: true, + low: '@2012-01-01', + highClosed: false, + high: '@2012-01-31', + }) + ).toBe(false); +}); + +test('lists of intervals compare element-wise', () => { + const expected = [ + { lowClosed: true, low: 1, highClosed: false, high: 4 }, + { lowClosed: true, low: 1.0, highClosed: false, high: 1.4 }, + ]; + const actual = [ + { lowClosed: true, low: 1, highClosed: true, high: 3 }, + actualInterval( + { lowClosed: true, low: 1.0, highClosed: true, high: 1.3 }, + { pointType: 'Decimal', lowPrecision: 1, highPrecision: 1 } + ), + ]; + + expect(resultsEqual(expected, actual)).toBe(true); + expect(resultsEqual(expected, [actual[0]])).toBe(false); +}); + +test('interval-shaped objects with extra properties still compare structurally', () => { + expect( + resultsEqual( + { lowClosed: true, low: 1, highClosed: false, high: 4, name: 'a' }, + { lowClosed: true, low: 1, highClosed: true, high: 3, name: 'a' } + ) + ).toBe(true); + expect( + resultsEqual( + { lowClosed: true, low: 1, highClosed: false, high: 4, name: 'a' }, + { lowClosed: true, low: 1, highClosed: true, high: 3, name: 'b' } + ) + ).toBe(false); + expect( + resultsEqual( + { lowClosed: true, low: 1, highClosed: false, high: 4 }, + { lowClosed: true, low: 1, highClosed: true, high: 3, name: 'b' } + ) + ).toBe(false); +}); + +test('quantity boundaries never use the integer-step heuristic', () => { + // Quantity values are CQL Decimals: the predecessor of 2 'ml' is 1.99999999 'ml', + // so a whole-number truncation must not match the open expected boundary. + const expected = { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: false, + high: { value: 2, unit: 'ml' }, + }; + const actual = { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: true, + high: { value: 1, unit: 'ml' }, + }; + + expect(resultsEqual(expected, actual)).toBe(false); +}); + test('expected Long matches FHIR R4 valueString encoding', () => { expect(resultsEqual(1n, '1')).toBe(true); expect(resultsEqual(-1n, '-1')).toBe(true); @@ -47,3 +345,42 @@ test('Long values compare inside lists and structures', () => { expect(resultsEqual({ x: 1n }, { x: '1' })).toBe(true); expect(resultsEqual([1n, 2n], ['1', '3'])).toBe(false); }); + +test('integer/long point type always steps by one, whatever precision declares', () => { + // A precision extension cannot change the distance between integer points (PR #110 + // review): step stays 1 even when a (bogus) precision is declared. + const expected = { lowClosed: true, low: 1, highClosed: false, high: 4 }; + const actual = actualInterval( + { lowClosed: true, low: 1, highClosed: true, high: 3 }, + { pointType: 'Integer', highPrecision: 2 } + ); + + expect(resultsEqual(expected, actual)).toBe(true); +}); + +test('negative or fractional precision values are ignored', () => { + // Only a non-negative integer is a meaningful decimal-place count; invalid values + // fall back to the point-type default instead of producing a step > 1. + const expected = { lowClosed: true, low: 1.0, highClosed: false, high: 4.0 }; + const actualHigh = 4.0 - 0.00000001; + + expect( + resultsEqual( + expected, + actualInterval( + { lowClosed: true, low: 1.0, highClosed: true, high: actualHigh }, + { pointType: 'Decimal', highPrecision: -1 } + ) + ) + ).toBe(true); + + expect( + resultsEqual( + expected, + actualInterval( + { lowClosed: true, low: 1.0, highClosed: true, high: actualHigh }, + { pointType: 'Decimal', highPrecision: 0.5 } + ) + ) + ).toBe(true); +});