Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
26 changes: 26 additions & 0 deletions src/extractors/value-type-extractors/value-type-extractor-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
/**
* Formats a Period boundary of an `Interval<System.Date>` 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<System.Time>` 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) {
Expand Down
33 changes: 30 additions & 3 deletions src/test-results/cql-test-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
83 changes: 83 additions & 0 deletions test/cql-test-results-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}');
});
});
Loading