diff --git a/src/extractors/value-type-extractors/datetime-interval-extractor.ts b/src/extractors/value-type-extractors/datetime-interval-extractor.ts index 3df8b8f..5d9f1df 100644 --- a/src/extractors/value-type-extractors/datetime-interval-extractor.ts +++ b/src/extractors/value-type-extractors/datetime-interval-extractor.ts @@ -1,15 +1,45 @@ import { BaseExtractor } from '../base-extractor.js'; -import { format_datetime } from './value-type-extractor-utils.js'; +import { format_date, format_datetime, format_time } from './value-type-extractor-utils.js'; + +const CQL_TYPE_URL = 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType'; +const TEMPORAL_INTERVAL_TYPE = /^Interval<(?:System\.)?(Date|DateTime|Time)>$/; + +/** + * The point type declared by the parameter's cqf-cqlType extension, when it names a + * temporal interval (the `System.` prefix is optional). FHIR Period boundaries are + * always dateTimes — a time interval arrives anchored to a placeholder date, and a + * date interval may pick up a time part — so only the extension identifies the real + * point type. + */ +function declaredPointType(parameter: any): 'Date' | 'DateTime' | 'Time' | 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 + ); + if (extension === undefined || typeof extension.valueString !== 'string') { + return undefined; + } + const match = TEMPORAL_INTERVAL_TYPE.exec(extension.valueString); + return match === null ? undefined : (match[1] as 'Date' | 'DateTime' | 'Time'); +} + +const BOUNDARY_FORMATS = { + Date: format_date, + DateTime: format_datetime, + Time: format_time, +}; export class DateTimeIntervalExtractor extends BaseExtractor { protected _process(parameter: any): any { - // TODO: Use the cqlType extension to establish the point type of the interval if (parameter.hasOwnProperty('valuePeriod')) { + const format = BOUNDARY_FORMATS[declaredPointType(parameter) ?? 'DateTime']; const low = parameter.valuePeriod.hasOwnProperty('start') - ? format_datetime(parameter.valuePeriod.start) + ? format(parameter.valuePeriod.start) : null; const high = parameter.valuePeriod.hasOwnProperty('end') - ? format_datetime(parameter.valuePeriod.end) + ? format(parameter.valuePeriod.end) : null; return { lowClosed: low !== null, diff --git a/src/extractors/value-type-extractors/value-type-extractor-utils.ts b/src/extractors/value-type-extractors/value-type-extractor-utils.ts index af59a24..57ea76c 100644 --- a/src/extractors/value-type-extractors/value-type-extractor-utils.ts +++ b/src/extractors/value-type-extractors/value-type-extractor-utils.ts @@ -1,3 +1,29 @@ +/** + * Formats a Period boundary of an `Interval` as a CQL Date literal. + * Unlike `format_datetime`, no trailing `T` is appended: `@2018-01-01T` is a + * DateTime literal, while a date interval's boundaries must stay `@2018-01-01`. + * A time part, if an engine includes one, is an artifact of the Period mapping + * and is stripped. + */ +export function format_date(datetime: string): string { + const value = datetime.toString(); + const timeStart = value.indexOf('T'); + return `@${timeStart >= 0 ? value.slice(0, timeStart) : value}`; +} + +/** + * Formats a Period boundary of an `Interval` as a CQL Time literal. + * FHIR Period boundaries are dateTimes, so engines anchor the time-of-day to a + * placeholder date (e.g. 0001-01-01); that date — and any timezone offset, which + * a CQL Time cannot carry — is an artifact of the mapping and is stripped. + */ +export function format_time(datetime: string): string { + const value = datetime.toString(); + const timeStart = value.indexOf('T'); + const time = timeStart >= 0 ? value.slice(timeStart + 1) : value; + return `@T${time.replace(/(?:Z|[+-]\d{2}:\d{2})$/, '')}`; +} + export function format_datetime(datetime: string): string { let dt = `@${datetime.toString()}`; if (dt.length <= 11) { diff --git a/src/test-results/cql-test-results.ts b/src/test-results/cql-test-results.ts index 489cad9..a4fdf58 100644 --- a/src/test-results/cql-test-results.ts +++ b/src/test-results/cql-test-results.ts @@ -6,20 +6,47 @@ import { TestResultsSummary, CQLTestResultsData } from '../models/results-types. import { ResultsValidator } from '../conf/results-validator.js'; /** - * Formats an actual value for the results file. Lists are rendered in CQL list - * syntax (`{ a, b, c }`) so they read like the expected value, which is kept in - * its original CQL/CVL notation. + * Formats an actual value for the results file. Lists (`{ a, b, c }`), intervals + * (`Interval[low, high)`) and quantities (`2.0 'cm2'`) are rendered in CQL syntax + * so they read like the expected value, which is kept in its original CQL/CVL + * notation. */ function formatActualValue(value: any): string { if (Array.isArray(value)) { return value.length === 0 ? '{}' : `{ ${value.map(formatActualValue).join(', ')} }`; } if (value !== null && typeof value === 'object') { + if (isIntervalShaped(value)) { + const open = value.lowClosed === true ? '[' : '('; + const close = value.highClosed === true ? ']' : ')'; + return `Interval${open}${formatActualValue(value.low)}, ${formatActualValue(value.high)}${close}`; + } + if (isQuantityShaped(value)) { + return `${value.value} '${value.unit}'`; + } return JSON.stringify(value); } return String(value); } +function isIntervalShaped(value: any): boolean { + const keys = Object.keys(value); + return ( + keys.length === 4 && + ['lowClosed', 'low', 'highClosed', 'high'].every((key) => keys.includes(key)) + ); +} + +function isQuantityShaped(value: any): boolean { + const keys = Object.keys(value); + return ( + keys.length === 2 && + keys.includes('value') && + keys.includes('unit') && + typeof value.unit === 'string' + ); +} + /** * Represents the results of running CQL tests. */ diff --git a/test/cql-test-results-validator.test.ts b/test/cql-test-results-validator.test.ts index fcff34f..5398e22 100644 --- a/test/cql-test-results-validator.test.ts +++ b/test/cql-test-results-validator.test.ts @@ -192,4 +192,87 @@ describe('CQLTestResults.toJSON actual serialization', () => { const json = results.toJSON(); expect(json.results[0].actual).toBe('{ { 1, 2 }, {} }'); }); + + it('renders interval actuals in CQL interval syntax', () => { + const results = new CQLTestResults(new CQLEngine('http://localhost:8080/fhir/$cql')); + results.add({ + testsName: 'CqlIntervalOperatorsTest', + groupName: 'Interval', + testName: 'TimeIntervalTest', + expression: 'Interval[@T00:00:00.000, @T23:59:59.599]', + testStatus: 'pass', + invalid: 'false', + actual: { + lowClosed: true, + low: '@T00:00:00.000', + highClosed: true, + high: '@T23:59:59.599', + }, + expected: 'Interval[@T00:00:00.000, @T23:59:59.599]', + } as any); + results.add({ + testsName: 'T', + groupName: 'G', + testName: 'OpenHigh', + expression: 'e', + testStatus: 'pass', + invalid: 'false', + actual: { lowClosed: true, low: 1, highClosed: false, high: null }, + expected: 'Interval[1, null)', + } as any); + + const json = results.toJSON(); + expect(json.results[0].actual).toBe('Interval[@T00:00:00.000, @T23:59:59.599]'); + expect(json.results[1].actual).toBe('Interval[1, null)'); + }); + + it('renders quantity actuals in CQL quantity syntax, standalone and as boundaries', () => { + const results = new CQLTestResults(new CQLEngine('http://localhost:8080/fhir/$cql')); + results.add({ + testsName: 'CqlArithmeticFunctionsTest', + groupName: 'Multiply', + testName: 'Multiply1CMBy2CM', + expression: "1.0 'cm' * 2.0 'cm'", + testStatus: 'fail', + invalid: 'false', + actual: { value: 0.0002, unit: 'm2' }, + expected: "2.0'cm2'", + } as any); + results.add({ + testsName: 'T', + groupName: 'G', + testName: 'QuantityInterval', + expression: 'e', + testStatus: 'pass', + invalid: 'false', + actual: { + lowClosed: true, + low: { value: 1, unit: 'ml' }, + highClosed: true, + high: { value: 2, unit: 'ml' }, + }, + expected: "Interval[1 'ml', 2 'ml']", + } as any); + + const json = results.toJSON(); + expect(json.results[0].actual).toBe("0.0002 'm2'"); + expect(json.results[1].actual).toBe("Interval[1 'ml', 2 'ml']"); + }); + + it('keeps JSON rendering for objects that are not intervals or quantities', () => { + const results = new CQLTestResults(new CQLEngine('http://localhost:8080/fhir/$cql')); + results.add({ + testsName: 'T', + groupName: 'G', + testName: 'Tuple', + expression: 'e', + testStatus: 'pass', + invalid: 'false', + actual: { value: 1, unit: null }, + expected: 'Tuple { value: 1, unit: null }', + } as any); + + const json = results.toJSON(); + expect(json.results[0].actual).toBe('{"value":1,"unit":null}'); + }); }); diff --git a/test/extractResults-cql_operations.test.ts b/test/extractResults-cql_operations.test.ts index 3f751d5..e6d5b0c 100644 --- a/test/extractResults-cql_operations.test.ts +++ b/test/extractResults-cql_operations.test.ts @@ -365,6 +365,209 @@ test('period datetime response check', () => { }); }); +// FHIR Period boundaries are dateTimes, so an Interval arrives with its +// times anchored to a placeholder date; the cqf-cqlType extension identifies the real +// point type and the date anchor is stripped back off. +test('period with Interval cqlType yields time literals', () => { + expect( + extractor!.extract({ + resourceType: 'Parameters', + parameter: [ + { + name: 'return', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType', + valueString: 'Interval', + }, + ], + valuePeriod: { + start: '0001-01-01T00:00:00.000', + end: '0001-01-01T23:59:59.599', + }, + }, + ], + }) + ).toStrictEqual({ + lowClosed: true, + low: '@T00:00:00.000', + highClosed: true, + high: '@T23:59:59.599', + }); +}); + +test('time interval cqlType matches without the System prefix and strips offsets', () => { + expect( + extractor!.extract({ + resourceType: 'Parameters', + parameter: [ + { + name: 'return', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType', + valueString: 'Interval