Skip to content
Closed
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
37 changes: 37 additions & 0 deletions src/extractors/value-type-extractors/long-extractor.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions src/server/extractor-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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())
Expand Down
31 changes: 16 additions & 15 deletions src/services/test-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface TestRunnerOptions {
useAxios?: boolean; // For backward compatibility with run-tests-command
}


export class TestRunner {
public async runTests(
configData: any,
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
10 changes: 7 additions & 3 deletions src/shared/results-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
}

Expand Down
161 changes: 160 additions & 1 deletion src/shared/results-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The runner shouldn't be casting here, the implementation should be returning the correct type of value, yes?

}

if (expected === actual) {
return true;
}

if (cqlDateTimesEqual(expected, actual)) {
return true;
}

if (quantitiesEqual(expected, actual)) {
return true;
}

if (
typeof expected !== 'object' ||
expected === null ||
Expand All @@ -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<string, UcumConversion> = {
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;
}
36 changes: 33 additions & 3 deletions src/test-results/cql-test-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
*/
Expand All @@ -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);
}
}
}
Expand Down
Loading