diff --git a/src/extractors/value-type-extractors/long-extractor.ts b/src/extractors/value-type-extractors/long-extractor.ts new file mode 100644 index 0000000..960d6f0 --- /dev/null +++ b/src/extractors/value-type-extractors/long-extractor.ts @@ -0,0 +1,37 @@ +import { BaseExtractor } from '../base-extractor.js'; + +const CQL_TYPE_EXTENSION_URL = 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType'; + +function hasCqlLongType(parameter: any): boolean { + const extensions = Array.isArray(parameter?.extension) ? parameter.extension : []; + return extensions.some( + (extension: any) => + extension?.url === CQL_TYPE_EXTENSION_URL && + String(extension?.valueString ?? '').toLowerCase() === 'system.long' + ); +} + +function parseLong(value: unknown): bigint | undefined { + if (value === undefined || value === null) return undefined; + + const text = String(value).trim(); + const normalized = text.endsWith('L') || text.endsWith('l') ? text.slice(0, -1) : text; + + if (!/^[+-]?\d+$/.test(normalized)) return undefined; + + return BigInt(normalized); +} + +export class LongExtractor extends BaseExtractor { + protected _process(parameter: any): any { + if (parameter.hasOwnProperty('valueInteger64')) { + return parseLong(parameter.valueInteger64); + } + + if (parameter.hasOwnProperty('valueString') && hasCqlLongType(parameter)) { + return parseLong(parameter.valueString); + } + + return undefined; + } +} diff --git a/src/server/extractor-builder.ts b/src/server/extractor-builder.ts index 41e125a..a55f658 100644 --- a/src/server/extractor-builder.ts +++ b/src/server/extractor-builder.ts @@ -2,6 +2,7 @@ import { EvaluationErrorExtractor } from '../extractors/evaluation-error-extract import { NullEmptyExtractor } from '../extractors/null-empty-extractor.js'; import { UndefinedExtractor } from '../extractors/undefined-extractor.js'; import { StringExtractor } from '../extractors/value-type-extractors/string-extractor.js'; +import { LongExtractor } from '../extractors/value-type-extractors/long-extractor.js'; import { BooleanExtractor } from '../extractors/value-type-extractors/boolean-extractor.js'; import { IntegerExtractor } from '../extractors/value-type-extractors/integer-extractor.js'; import { DecimalExtractor } from '../extractors/value-type-extractors/decimal-extractor.js'; @@ -24,6 +25,7 @@ export function buildExtractor(): ResultExtractor { extractors .setNextExtractor(new NullEmptyExtractor()) .setNextExtractor(new UndefinedExtractor()) + .setNextExtractor(new LongExtractor()) .setNextExtractor(new StringExtractor()) .setNextExtractor(new BooleanExtractor()) .setNextExtractor(new IntegerExtractor()) diff --git a/src/services/test-runner.ts b/src/services/test-runner.ts index b68627d..883ceb8 100644 --- a/src/services/test-runner.ts +++ b/src/services/test-runner.ts @@ -16,6 +16,7 @@ export interface TestRunnerOptions { useAxios?: boolean; // For backward compatibility with run-tests-command } + export class TestRunner { public async runTests( configData: any, @@ -29,20 +30,20 @@ export class TestRunner { // Verify server connectivity before proceeding await ServerConnectivity.verifyServerConnectivity(serverBaseUrl); - const build = config.Build; - const cqlEngine = new CQLEngine( - serverBaseUrl, - cqlEndpoint, - build.cqlTranslator ?? '', - build.cqlTranslatorVersion ?? '', - build.cqlEngine ?? '', - build.cqlEngineVersion ?? '' - ); - cqlEngine.cqlVersion = '1.5'; //default value - const cqlVersion = config.Build?.CqlVersion; - if (typeof cqlVersion === 'string' && cqlVersion.trim() !== '') { - cqlEngine.cqlVersion = cqlVersion; - } + const build = config.Build; + const cqlEngine = new CQLEngine( + serverBaseUrl, + cqlEndpoint, + build.cqlTranslator ?? '', + build.cqlTranslatorVersion ?? '', + build.cqlEngine ?? '', + build.cqlEngineVersion ?? '' + ); + cqlEngine.cqlVersion = '1.5'; //default value + const cqlVersion = config.Build?.CqlVersion; + if (typeof cqlVersion === 'string' && cqlVersion.trim() !== '') { + cqlEngine.cqlVersion = cqlVersion; + } // Load CVL using dynamic import // @ts-ignore @@ -130,7 +131,7 @@ export class TestRunner { ); return result; } else if (onlySet.size > 0 && !onlySet.has(key)) { - result.SkipMessage = 'Skipped by OnlyList filter'; + result.skipMessage = 'Skipped by OnlyList filter'; result.testStatus = 'skip'; return result; } else if (skipMap.has(key)) { diff --git a/src/shared/results-shared.ts b/src/shared/results-shared.ts index b910d55..dad12cb 100644 --- a/src/shared/results-shared.ts +++ b/src/shared/results-shared.ts @@ -53,9 +53,13 @@ export class Result implements InternalTestResult { this.skipMessage = 'No output specified'; } - this.capability = Array.isArray(test.capability) - ? test.capability.map(({ code, value }) => ({ code, value })) - : []; + const testCapabilities = Array.isArray(test.capability) + ? test.capability + : test.capability + ? [test.capability] + : []; + + this.capability = testCapabilities.map(({ code, value }) => ({ code, value })); } } diff --git a/src/shared/results-utils.ts b/src/shared/results-utils.ts index 1fa30b5..3cef30a 100644 --- a/src/shared/results-utils.ts +++ b/src/shared/results-utils.ts @@ -11,13 +11,21 @@ export function resultsEqual(expected: any, actual: any): boolean { } if (typeof expected === 'number') { - return Math.abs(actual - expected) < 0.00000001; + return Math.abs(Number(actual) - expected) < 0.00000001; } if (expected === actual) { return true; } + if (cqlDateTimesEqual(expected, actual)) { + return true; + } + + if (quantitiesEqual(expected, actual)) { + return true; + } + if ( typeof expected !== 'object' || expected === null || @@ -40,3 +48,154 @@ export function resultsEqual(expected: any, actual: any): boolean { return true; } + +interface CqlDateTimeParts { + base: string; + timezone?: string; +} + +function cqlDateTimesEqual(expected: any, actual: any): boolean { + if (typeof expected !== 'string' || typeof actual !== 'string') { + return false; + } + + const expectedDateTime = parseCqlDateTime(expected); + const actualDateTime = parseCqlDateTime(actual); + + if (!expectedDateTime || !actualDateTime) { + return false; + } + + if (expectedDateTime.base !== actualDateTime.base) { + return false; + } + + // If either side omits timezone, compare only the local DateTime components. + // Some engines return the server default timezone for DateTimes that were + // authored without an explicit timezone, e.g. @2014-01-01T08 becomes + // @2014-01-01T08-06:00. For these conformance tests, that offset should not + // make the value fail comparison when the expected value also omits it. + if (!expectedDateTime.timezone || !actualDateTime.timezone) { + return true; + } + + return normalizeTimezone(expectedDateTime.timezone) === normalizeTimezone(actualDateTime.timezone); +} + +function parseCqlDateTime(value: string): CqlDateTimeParts | undefined { + // DateTime literals have a T component. Date-only literals do not. + if (!/^@\d{4}(?:-\d{2})?(?:-\d{2})?T/.test(value)) { + return undefined; + } + + const match = value.match(/^(.*?)(Z|[+-]\d{2}:\d{2})?$/); + if (!match) { + return undefined; + } + + return { + base: match[1], + timezone: match[2], + }; +} + +function normalizeTimezone(timezone: string): string { + return timezone === 'Z' ? '+00:00' : timezone; +} + +interface ParsedQuantity { + value: number; + unit: string; +} + +interface UcumConversion { + canonicalUnit: string; + factor: number; +} + +const UCUM_CONVERSIONS: Record = { + mm: { canonicalUnit: 'm', factor: 0.001 }, + cm: { canonicalUnit: 'm', factor: 0.01 }, + m: { canonicalUnit: 'm', factor: 1 }, + km: { canonicalUnit: 'm', factor: 1000 }, + mm2: { canonicalUnit: 'm2', factor: 0.000001 }, + cm2: { canonicalUnit: 'm2', factor: 0.0001 }, + m2: { canonicalUnit: 'm2', factor: 1 }, + km2: { canonicalUnit: 'm2', factor: 1000000 }, + mg: { canonicalUnit: 'g', factor: 0.001 }, + g: { canonicalUnit: 'g', factor: 1 }, + kg: { canonicalUnit: 'g', factor: 1000 }, + ms: { canonicalUnit: 's', factor: 0.001 }, + s: { canonicalUnit: 's', factor: 1 }, + min: { canonicalUnit: 's', factor: 60 }, + h: { canonicalUnit: 's', factor: 3600 }, + d: { canonicalUnit: 's', factor: 86400 }, +}; + +function quantitiesEqual(expected: any, actual: any): boolean { + const expectedQuantity = parseQuantity(expected); + const actualQuantity = parseQuantity(actual); + + if (!expectedQuantity || !actualQuantity) { + return false; + } + + // Same unit can be compared directly, even if we do not have a conversion rule. + if (expectedQuantity.unit === actualQuantity.unit) { + return numbersEqual(expectedQuantity.value, actualQuantity.value); + } + + const expectedNormalized = normalizeQuantity(expectedQuantity); + const actualNormalized = normalizeQuantity(actualQuantity); + + if (!expectedNormalized || !actualNormalized) { + return false; + } + + return ( + expectedNormalized.unit === actualNormalized.unit && + numbersEqual(expectedNormalized.value, actualNormalized.value) + ); +} + +function parseQuantity(value: any): ParsedQuantity | undefined { + if (typeof value === 'string') { + const quantityMatch = /^\s*(-?\d+(?:\.\d+)?)\s*'([^']+)'\s*$/.exec(value); + if (!quantityMatch) return undefined; + + return { + value: Number(quantityMatch[1]), + unit: quantityMatch[2], + }; + } + + if (value && typeof value === 'object' && 'value' in value) { + const quantityValue = Number(value.value); + const unit = value.unit ?? value.code; + + if (!Number.isFinite(quantityValue) || typeof unit !== 'string') { + return undefined; + } + + return { + value: quantityValue, + unit, + }; + } + + return undefined; +} + +function normalizeQuantity(quantity: ParsedQuantity): ParsedQuantity | undefined { + const conversion = UCUM_CONVERSIONS[quantity.unit]; + if (!conversion) return undefined; + + return { + value: quantity.value * conversion.factor, + unit: conversion.canonicalUnit, + }; +} + +function numbersEqual(a: number, b: number): boolean { + return Math.abs(a - b) < 0.00000001; +} diff --git a/src/test-results/cql-test-results.ts b/src/test-results/cql-test-results.ts index eb3b26d..c52f349 100644 --- a/src/test-results/cql-test-results.ts +++ b/src/test-results/cql-test-results.ts @@ -106,7 +106,7 @@ export class CQLTestResults { ...(result.responseStatus !== undefined && { responseStatus: result.responseStatus, }), - ...(result.actual !== undefined && { actual: String(result.actual) }), + ...(result.actual !== undefined && { actual: this.formatResultValue(result.actual) }), ...(result.expected && { expected: result.expected }), ...(result.error && { error: { @@ -172,6 +172,37 @@ export class CQLTestResults { return filePath; } + + /** + * Formats a value for JSON output without losing structured CQL values. + */ + private formatResultValue(value: any): string { + if (typeof value === 'bigint') { + return `${value.toString()}L`; + } + + if (value && typeof value === 'object' && 'value' in value) { + const unit = value.unit ?? value.code; + if (unit !== undefined) { + return `${this.formatNumber(value.value)}'${unit}'`; + } + } + + if (value && typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + } + + private formatNumber(value: any): string { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return String(value); + } + return Number(numericValue.toPrecision(15)).toString(); + } + /** * Equalizes value types for comparison */ @@ -188,8 +219,7 @@ export class CQLTestResults { } else if (typeof act === 'number' && typeof exp === 'string') { r.actual = String(act); } else if (act !== undefined && act !== null && typeof act !== 'string') { - // Convert any non-string value to string for schema compliance - r.actual = String(act); + r.actual = this.formatResultValue(act); } } } diff --git a/test/extractResults-cql_operations.test.ts b/test/extractResults-cql_operations.test.ts index 3f751d5..2a80ab1 100644 --- a/test/extractResults-cql_operations.test.ts +++ b/test/extractResults-cql_operations.test.ts @@ -108,6 +108,61 @@ test('string response check', () => { ).toBe('abc'); }); + +test('R4 System.Long response check using valueString with L suffix', () => { + expect( + extractor!.extract({ + resourceType: 'Parameters', + parameter: [ + { + name: 'return', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType', + valueString: 'System.Long', + }, + ], + valueString: '1L', + }, + ], + }) + ).toBe(1n); +}); + +test('R4 System.Long response check using valueString without L suffix', () => { + expect( + extractor!.extract({ + resourceType: 'Parameters', + parameter: [ + { + name: 'return', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType', + valueString: 'System.Long', + }, + ], + valueString: '1', + }, + ], + }) + ).toBe(1n); +}); + +test('R5 System.Long response check using valueInteger64', () => { + expect( + extractor!.extract({ + resourceType: 'Parameters', + parameter: [ + { + name: 'return', + valueInteger64: '1', + }, + ], + }) + ).toBe(1n); +}); + test('singleton list-typed return stays array when singletonListKeys includes return (issue #82)', () => { expect( extractor!.extract(