From 6cafb69d7383cd099e38f8a049a5750a28ef3383 Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Mon, 2 Mar 2026 13:48:18 -0700 Subject: [PATCH 1/7] update from upstream main --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 334f53c..7d474e9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "dev": "tsc --watch", "test": "vitest run test/", "test:cql": "node --import tsx src/bin/cql-tests.ts run-tests conf/localhost.json ./results", + "cql-run-tests:dev": "tsx src/bin/cql-tests.ts run-tests conf/development.json ./results", + "cql-run-tests:local": "tsx src/bin/cql-tests.ts run-tests conf/localhost.json ./local_results", "build-libs": "npm run build && node dist/bin/cql-tests.js build-cql conf/localhost.json ./cql", "unit-tests": "vitest run test/", "clean": "rm -rf dist", @@ -51,4 +53,4 @@ "typescript": "^5.9.3", "vitest": "^4.0.18" } -} \ No newline at end of file +} From aa9b3bd693b717590528389baa0da6379c0d200c Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Tue, 17 Mar 2026 14:16:22 -0600 Subject: [PATCH 2/7] code to test Timezone Offset --- .../schema/cql-test-configuration.schema.json | 78 +++--- conf/localhost.json | 18 +- src/bin/cql-tests.ts | 11 +- src/commands/run-tests-command.ts | 6 +- src/conf/config-loader.ts | 14 +- src/cql-engine/cql-engine.ts | 116 ++++++--- src/jobs/job-processor.ts | 2 +- src/models/config-types.ts | 1 + src/models/results-types.ts | 1 + src/models/test-types.ts | 2 + src/server/config-utils.ts | 18 +- src/services/test-runner.ts | 246 ------------------ src/shared/results-shared.ts | 16 +- src/test-results/cql-test-results.ts | 2 +- test/server-command.test.ts | 33 +-- 15 files changed, 196 insertions(+), 368 deletions(-) delete mode 100644 src/services/test-runner.ts diff --git a/assets/schema/cql-test-configuration.schema.json b/assets/schema/cql-test-configuration.schema.json index 51ea5dc..121416b 100644 --- a/assets/schema/cql-test-configuration.schema.json +++ b/assets/schema/cql-test-configuration.schema.json @@ -31,41 +31,49 @@ "type": "object", "description": "Build configuration settings", "properties": { - "CqlFileVersion": { - "type": "string", - "pattern": "^\\d+\\.\\d+\\.\\d+$", - "description": "CQL file version in semantic versioning format" - }, - "CqlOutputPath": { - "type": "string", - "description": "Path where CQL files are output" - }, - "CqlVersion": { - "type": "string", - "pattern": "^\\d+\\.\\d+(\\.\\d+)?$", - "description": "CQL engine version for version-based test filtering (optional)" - }, - "testsRunDescription":{ - "type": "string", - "description": "Information about this test run" - }, - "cqlTranslator": { - "type": "string", - "description": "Which CQL Translator is used in this test run." - }, - "cqlTranslatorVersion": { - "type": "string", - "description": "Which CQL translator version is used in this test run" - }, - "cqlEngine": { - "type": "string", - "description": "Which CQL engine is used in this test run" - }, - "cqlEngineVersion": { - "type": "string", - "description": "Which version of the CQL engine is used in this test run" - } - }, + "CqlFileVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "CQL file version in semantic versioning format" + }, + "CqlOutputPath": { + "type": "string", + "description": "Path where CQL files are output" + }, + "CqlVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?$", + "description": "CQL engine version for version-based test filtering (optional)" + }, + "testsRunDescription": { + "type": "string", + "description": "Information about this test run" + }, + "cqlTranslator": { + "type": "string", + "description": "Which CQL Translator is used in this test run." + }, + "cqlTranslatorVersion": { + "type": "string", + "description": "Which CQL translator version is used in this test run" + }, + "cqlEngine": { + "type": "string", + "description": "Which CQL engine is used in this test run" + }, + "cqlEngineVersion": { + "type": "string", + "description": "Which version of the CQL engine is used in this test run" + }, + "SERVER_OFFSET_ISO": { + "type": "string", + "description": "ISO 8601 formatted date string to offset the server start time" + }, + "TimeZoneOffsetPolicy": { + "type": "string", + "description": "Which timezone offset policy is used in this test run." + } + }, "required": ["CqlFileVersion", "CqlOutputPath"], "additionalProperties": false }, diff --git a/conf/localhost.json b/conf/localhost.json index 1b711a0..2b54a2e 100644 --- a/conf/localhost.json +++ b/conf/localhost.json @@ -4,14 +4,16 @@ "CqlOperation": "$cql" }, "Build": { - "CqlFileVersion": "1.0.000", - "CqlOutputPath": "./cql", - "CqlVersion": "1.5", - "testsRunDescription": "Local host test run", - "cqlTranslator": "Java CQFramework Translator", - "cqlTranslatorVersion": "Unknown", - "cqlEngine": "Java CQFramework Engine", - "cqlEngineVersion": "4.1.0" + "CqlFileVersion": "1.0.000", + "CqlOutputPath": "./cql", + "CqlVersion": "1.5", + "testsRunDescription": "Local host test run", + "cqlTranslator": "Java CQFramework Translator", + "cqlTranslatorVersion": "Unknown", + "cqlEngine": "Java CQFramework Engine", + "cqlEngineVersion": "4.1.0", + "SERVER_OFFSET_ISO": "-06:00", + "TimeZoneOffsetPolicy": "timezone-offset-policy.default-server-offset" }, "Debug": { "QuickTest": false diff --git a/src/bin/cql-tests.ts b/src/bin/cql-tests.ts index db035a5..542d6d0 100644 --- a/src/bin/cql-tests.ts +++ b/src/bin/cql-tests.ts @@ -43,7 +43,8 @@ program ); process.exit(1); } - throw error; + console.error(error); + process.exit(1); } }); @@ -73,7 +74,8 @@ program ); process.exit(1); } - throw error; + console.error(error); + process.exit(1); } }); @@ -92,4 +94,7 @@ program await serverCommand.start(); }); -program.parse(); +program.parseAsync().catch((error: any) => { + console.error(error); + process.exit(1); +}); diff --git a/src/commands/run-tests-command.ts b/src/commands/run-tests-command.ts index 803e57c..3b601f4 100644 --- a/src/commands/run-tests-command.ts +++ b/src/commands/run-tests-command.ts @@ -1,4 +1,4 @@ -import { TestRunner } from '../services/test-runner.js'; +import { TestRunner } from '../services/test-runner'; import { ConfigLoader } from '../conf/config-loader.js'; // Type declaration for CVL loader @@ -54,7 +54,9 @@ export class RunCommand { cqlTranslator: config.Build?.cqlTranslator, cqlTranslatorVersion: config.Build?.cqlTranslatorVersion, cqlEngine: config.Build?.cqlEngine, - cqlEngineVersion: config.Build?.cqlEngineVersion + cqlEngineVersion: config.Build?.cqlEngineVersion, + SERVER_OFFSET_ISO: config.Build?.SERVER_OFFSET_ISO, + TimeZoneOffsetPolicy: config.Build?.TimeZoneOffsetPolicy, }, Tests: { ResultsPath: config.Tests.ResultsPath, diff --git a/src/conf/config-loader.ts b/src/conf/config-loader.ts index f53ca3b..ff7bcbe 100644 --- a/src/conf/config-loader.ts +++ b/src/conf/config-loader.ts @@ -17,6 +17,8 @@ export class ConfigLoader implements Config { cqlTranslatorVersion: string; cqlEngine: string; cqlEngineVersion: string; + SERVER_OFFSET_ISO: string; + TimeZoneOffsetPolicy: string; }; Tests: { ResultsPath: string; @@ -55,14 +57,18 @@ export class ConfigLoader implements Config { process.env.CQL_VERSION || configData.Build?.CqlVersion, testsRunDescription: process.env.TESTS_RUN_DESCRIPTION || configData.Build?.testsRunDescription, - cqlTranslator: + cqlTranslator: process.env.CQL_TRANSLATOR || configData.Build?.cqlTranslator || 'Unknown', - cqlTranslatorVersion: + cqlTranslatorVersion: process.env.CQL_TRANSLATOR_VERSION || configData.Build?.cqlTranslatorVersion || 'Unknown', cqlEngine: process.env.CQL_ENGINE || configData.Build?.cqlEngine || 'Unknown', - cqlEngineVersion: - process.env.CQL_ENGINE_VERSION || configData.Build?.cqlEngineVersion || 'Unknown' + cqlEngineVersion: + process.env.CQL_ENGINE_VERSION || configData.Build?.cqlEngineVersion || 'Unknown', + SERVER_OFFSET_ISO: + process.env.SERVER_OFFSET_ISO || configData.Build?.SERVER_OFFSET_ISO || '+00:00', + TimeZoneOffsetPolicy: + process.env.TIME_ZONE_OFFSET_POLICY || configData.Build?.TimeZoneOffsetPolicy || '', }; this.Tests = { diff --git a/src/cql-engine/cql-engine.ts b/src/cql-engine/cql-engine.ts index 7754784..e2730d5 100644 --- a/src/cql-engine/cql-engine.ts +++ b/src/cql-engine/cql-engine.ts @@ -1,5 +1,6 @@ import axios, { AxiosResponse } from 'axios'; import { CQLEngineInfo } from '../models/results-types.js'; +import { response } from 'express'; /** * Represents a CQL Engine. @@ -39,19 +40,36 @@ export class CQLEngine { private baseURL?: string; private metadata?: any; private description?: string; + private _SERVER_OFFSET_ISO?: string; - /** - * Creates an instance of CQLEngine. - * @param baseURL - The base URL for the CQL engine. - * @param cqlPath - The path for the CQL engine (optional). - */ - constructor(baseURL: string, cqlPath: string | null = null, - cqlTranslator: string, cqlTranslatorVersion: string, - cqlEngine: string, cqlEngineVersion: string) { - this._prepareBaseURL(baseURL, cqlPath); - this._setInformationFields(cqlTranslator, cqlTranslatorVersion, - cqlEngine, cqlEngineVersion); - } + /** + * Creates a new CQLEngine instance. + * + * @param baseURL Base URL of the FHIR server (e.g. http://localhost:8080/fhir/$cql) + * @param cqlPath Optional path to local CQL files + * @param cqlTranslator Name of the CQL translator + * @param cqlTranslatorVersion Version of the CQL translator + * @param cqlEngine Name of the CQL engine + * @param cqlEngineVersion Version of the CQL engine + * @param SERVER_OFFSET_ISO Timezone offset used by the server (e.g. "+00:00", "-06:00") + */ constructor( + baseURL: string, + cqlPath: string | null = null, + cqlTranslator: string = '', + cqlTranslatorVersion: string = '', + cqlEngine: string = '', + cqlEngineVersion: string = '', + SERVER_OFFSET_ISO: string = '' + ) { + this._prepareBaseURL(baseURL, cqlPath); + this._setInformationFields( + cqlTranslator, + cqlTranslatorVersion, + cqlEngine, + cqlEngineVersion + ); + this.SERVER_OFFSET_ISO = SERVER_OFFSET_ISO; + } /** * Prepares the base URL. @@ -86,31 +104,31 @@ export class CQLEngine { private _setInformationFields(cqlTranslator: string, cqlTranslatorVersion: string, cqlEngine: string, cqlEngineVersion: string) { - this.info.cqlTranslator = cqlTranslator; - this.info.cqlTranslatorVersion = cqlTranslatorVersion; - this.info.cqlEngine = cqlEngine; - this.info.cqlEngineVersion = cqlEngineVersion; - } - - /** - * Fetches metadata from the CQL engine. - * @param force - Whether to force fetching metadata. - * @returns A Promise that resolves when metadata is fetched. - */ - async fetch(force: boolean = false): Promise { - if (this.baseURL) { - if (!this.metadata || force) { - try { - const response: AxiosResponse = await axios.get(`${this.baseURL}/metadata`); - if (response?.data) { - this.metadata = response.data; - } - } catch (e) { - console.error(e); - } - } - } - } + this.info.cqlTranslator = cqlTranslator; + this.info.cqlTranslatorVersion = cqlTranslatorVersion; + this.info.cqlEngine = cqlEngine; + this.info.cqlEngineVersion = cqlEngineVersion; + } + + /** + * Fetches metadata from the CQL engine. + * @param force - Whether to force fetching metadata. + * @returns A Promise that resolves when metadata is fetched. + */ + async fetch(force: boolean = false): Promise { + if (this.baseURL) { + if (!this.metadata || force) { + try { + const response: AxiosResponse = await axios.get(`${this.baseURL}/metadata`); + if (response?.data) { + this.metadata = response.data; + } + } catch (e) { + console.error(e); + } + } + } + } /** * Sets the API URL. @@ -208,6 +226,14 @@ export class CQLEngine { return this.info?.cqlEngineVersion ?? null; } + get SERVER_OFFSET_ISO(): string { + return this._SERVER_OFFSET_ISO; + } + + set SERVER_OFFSET_ISO(value: string) { + this._SERVER_OFFSET_ISO = value; + } + /** * Converts the CQLEngine object to JSON. * @returns The JSON representation of the CQLEngine object. @@ -222,6 +248,7 @@ export class CQLEngine { cqlTranslatorVersion: this.info.cqlTranslatorVersion || '', cqlEngine: this.info.cqlEngine || '', cqlEngineVersion: this.info.cqlEngineVersion || '', + SERVER_OFFSET_ISO: this.SERVER_OFFSET_ISO || '', }; } @@ -231,9 +258,15 @@ export class CQLEngine { * @returns The CQLEngine instance. */ static fromJSON(cqlInfo: CQLEngineInfo): CQLEngine { - const engine = new CQLEngine(cqlInfo.apiUrl || '', null, - cqlInfo.cqlTranslator, cqlInfo.cqlTranslatorVersion, - cqlInfo.cqlEngine, cqlInfo.cqlEngineVersion); + const engine = new CQLEngine( + cqlInfo.apiUrl || '', + null, + cqlInfo.cqlTranslator, + cqlInfo.cqlTranslatorVersion, + cqlInfo.cqlEngine, + cqlInfo.cqlEngineVersion, + cqlInfo.SERVER_OFFSET_ISO + ); if (cqlInfo?.cqlVersion) { engine.cqlVersion = cqlInfo.cqlVersion; } @@ -249,6 +282,9 @@ export class CQLEngine { if (cqlInfo?.cqlEngineVersion) { engine.cqlEngineVersion = cqlInfo.cqlEngineVersion; } + if (cqlInfo?.SERVER_OFFSET_ISO) { + engine._SERVER_OFFSET_ISO = cqlInfo.SERVER_OFFSET_ISO; + } return engine; } } diff --git a/src/jobs/job-processor.ts b/src/jobs/job-processor.ts index e56198a..9ce636f 100644 --- a/src/jobs/job-processor.ts +++ b/src/jobs/job-processor.ts @@ -30,7 +30,7 @@ export class JobProcessor { // Process the tests using the shared TestRunner const results = await this.testRunner.runTests(jobRequest.config, { - onProgress: async (current, total, message) => { + onProgress: async (current: number, total: number, message?: string) => { await this.jobManager.updateJobProgress(jobId, current, total, message); }, }); diff --git a/src/models/config-types.ts b/src/models/config-types.ts index 6cf9b3c..396c294 100644 --- a/src/models/config-types.ts +++ b/src/models/config-types.ts @@ -17,6 +17,7 @@ export interface Config { CqlOutputPath: string; CqlVersion?: string; testsRunDescription?: string; // Note: schema has this misplaced but it's used in code + SERVER_OFFSET_ISO: string; }; Tests: { ResultsPath: string; diff --git a/src/models/results-types.ts b/src/models/results-types.ts index c1a5740..c263929 100644 --- a/src/models/results-types.ts +++ b/src/models/results-types.ts @@ -28,4 +28,5 @@ export interface CQLEngineInfo { cqlTranslatorVersion: string; cqlEngine: string; cqlEngineVersion: string; + SERVER_OFFSET_ISO: string; } diff --git a/src/models/test-types.ts b/src/models/test-types.ts index 2ec86c3..d0e1964 100644 --- a/src/models/test-types.ts +++ b/src/models/test-types.ts @@ -40,6 +40,7 @@ export interface TestGroup { description?: string; reference?: string; notes?: string; + capability?: CapabilityKV[]; test: Test[]; } @@ -79,6 +80,7 @@ export interface InternalTestResult { invalid?: 'false' | 'true' | 'semantic' | 'undefined'; expression: string; capability?: CapabilityKV[]; + groupCapability?: CapabilityKV[]; SkipMessage?: string; } diff --git a/src/server/config-utils.ts b/src/server/config-utils.ts index 6f686dd..b5626b3 100644 --- a/src/server/config-utils.ts +++ b/src/server/config-utils.ts @@ -50,14 +50,16 @@ export function createConfigFromData(configData: any): ConfigLoader { }; config.Build = { - CqlFileVersion: process.env.CQL_FILE_VERSION || configData.Build?.CqlFileVersion || '1.0.000', - CqlOutputPath: process.env.CQL_OUTPUT_PATH || configData.Build?.CqlOutputPath || './cql', - CqlVersion: process.env.CQL_VERSION || configData.Build?.CqlVersion, - testsRunDescription: process.env.TESTS_RUN_DESCRIPTION || configData.Build?.testsRunDescription || 'No configuration provided', - cqlTranslator: process.env.CQL_TRANSLATOR || configData.Build?.cqlTranslator || 'No configuration provided', - cqlTranslatorVersion: process.env.CQL_TRANSLATOR_VERSION || configData.Build?.cqlTranslatorVersion || 'No configuration provided', - cqlEngine: process.env.CQL_ENGINE || configData.Build?.cqlEngine || 'No configuration provided', - cqlEngineVersion: process.env.CQL_ENGINE_VERSION || configData.Build?.cqlEngineVersion || 'No configuration provided' + CqlFileVersion: process.env.CQL_FILE_VERSION || configData.Build?.CqlFileVersion || '1.0.000', + CqlOutputPath: process.env.CQL_OUTPUT_PATH || configData.Build?.CqlOutputPath || './cql', + CqlVersion: process.env.CQL_VERSION || configData.Build?.CqlVersion, + testsRunDescription: process.env.TESTS_RUN_DESCRIPTION || configData.Build?.testsRunDescription || 'No configuration provided', + cqlTranslator: process.env.CQL_TRANSLATOR || configData.Build?.cqlTranslator || 'No configuration provided', + cqlTranslatorVersion: process.env.CQL_TRANSLATOR_VERSION || configData.Build?.cqlTranslatorVersion || 'No configuration provided', + cqlEngine: process.env.CQL_ENGINE || configData.Build?.cqlEngine || 'No configuration provided', + cqlEngineVersion: process.env.CQL_ENGINE_VERSION || configData.Build?.cqlEngineVersion || 'No configuration provided', + SERVER_OFFSET_ISO: process.env.SERVER_OFFSET_ISO || configData.Build?.SERVER_OFFSET_ISO || '+00:00', + TimeZoneOffsetPolicy: process.env.TIME_ZONE_OFFSET_POLICY || configData.Build?.TimeZoneOffsetPolicy || '', }; config.Tests = { diff --git a/src/services/test-runner.ts b/src/services/test-runner.ts deleted file mode 100644 index 2d6727a..0000000 --- a/src/services/test-runner.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { ConfigLoader } from '../conf/config-loader.js'; -import { CQLEngine } from '../cql-engine/cql-engine.js'; -import { TestLoader } from '../loaders/test-loader.js'; -import { CQLTestResults } from '../test-results/cql-test-results.js'; -import { generateEmptyResults, generateParametersResource } from '../shared/results-shared.js'; -import { InternalTestResult } from '../models/test-types.js'; -import { ResultExtractor } from '../extractors/result-extractor.js'; -import { ServerConnectivity } from '../shared/server-connectivity.js'; -import { buildExtractor } from '../server/extractor-builder.js'; -import { createConfigFromData } from '../server/config-utils.js'; -import { resultsEqual } from '../shared/results-utils.js'; - -export interface TestRunnerOptions { - onProgress?: (current: number, total: number, message?: string) => Promise; - useAxios?: boolean; // For backward compatibility with run-tests-command -} - -export class TestRunner { - public async runTests( - configData: any, - options: TestRunnerOptions = {} - ): Promise { - // Create a temporary config loader from the provided data - const config = createConfigFromData(configData); - const serverBaseUrl = config.FhirServer.BaseUrl; - const cqlEndpoint = config.CqlEndpoint; - - // Verify server connectivity before proceeding - await ServerConnectivity.verifyServerConnectivity(serverBaseUrl); - - const cqlEngine = new CQLEngine( - serverBaseUrl, - cqlEndpoint, - configData.Build.cqlTranslator, - configData.Build.cqlTranslatorVersion, - configData.Build.cqlEngine, - configData.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 - const cvlModule = await import('../../cvl/cvl.mjs'); - const cvl = cvlModule.default; - - const tests = TestLoader.load(); - const quickTest = config.Debug?.QuickTest || false; - const resultExtractor = buildExtractor(); - const emptyResults = await generateEmptyResults(tests, quickTest); - const skipMap = config.skipListMap(); - - const results = new CQLTestResults(cqlEngine); - - const totalTests = emptyResults.reduce((sum, testFile) => sum + testFile.length, 0); - let completedTests = 0; - - for (const testFile of emptyResults) { - for (const result of testFile) { - if (this.shouldSkipVersionTest(cqlEngine, result)) { - //add to skipMap - const skipReason = - 'test version ' + - result.testVersion + - ' not applicable to engine version ' + - cqlEngine.cqlVersion; - this.addToSkipList( - skipMap, - result.testsName, - result.groupName, - result.testName, - skipReason - ); - } - await this.runTest( - result, - cqlEngine.apiUrl!, - cvl, - resultExtractor, - skipMap, - config, - options.useAxios - ); - results.add(result); - - completedTests++; - if (options.onProgress) { - await options.onProgress( - completedTests, - totalTests, - `Running test ${result.testsName}:${result.groupName}:${result.testName}` - ); - } - } - } - // Return the CQLTestResults instance - return results; - } - - private async runTest( - result: InternalTestResult, - apiUrl: string, - cvl: any, - resultExtractor: ResultExtractor, - skipMap: Map, - config: ConfigLoader, - useAxios: boolean = false - ): Promise { - const key = `${result.testsName}-${result.groupName}-${result.testName}`; - - if (result.testStatus === 'skip') { - result.SkipMessage = 'Skipped by cql-tests-runner'; - return result; - } else if (skipMap.has(key)) { - const reason = skipMap.get(key) || ''; - result.SkipMessage = `Skipped by config: ${reason}`; - result.testStatus = 'skip'; - return result; - } - - const data = generateParametersResource(result, config.FhirServer.CqlOperation); - - try { - console.log( - 'Running test %s:%s:%s', - result.testsName, - result.groupName, - result.testName - ); - - let response: any; - if (useAxios) { - // Use axios for backward compatibility - const axios = await import('axios'); - const axiosResponse = await axios.default.post(apiUrl, data, { - headers: { - 'Content-Type': 'application/json', - }, - }); - response = { - status: axiosResponse.status, - data: axiosResponse.data, - }; - } else { - // Use fetch (default for new code) - const fetchResponse = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); - response = { - status: fetchResponse.status, - data: await fetchResponse.json(), - }; - } - - result.responseStatus = response.status; - const responseBody = response.data; - result.actual = resultExtractor.extract(responseBody); - const invalid = result.invalid; - - if (invalid === 'true' || invalid === 'semantic') { - result.testStatus = response.status === 200 ? 'fail' : 'pass'; - } else { - if (response.status === 200) { - result.testStatus = resultsEqual(cvl.parse(result.expected), result.actual) - ? 'pass' - : 'fail'; - } else { - result.testStatus = 'fail'; - } - } - } catch (error: any) { - result.testStatus = 'error'; - result.error = { - message: error.message, - name: error.name || 'Error', - stack: error.stack, - }; - } - - console.log( - 'Test %s:%s:%s status: %s expected: %s actual: %s', - result.testsName, - result.groupName, - result.testName, - result.testStatus, - result.expected, - result.actual - ); - - return result; - } - - private compareVersions(versionA: string | undefined, versionB: string | undefined): number { - // Split into numeric parts (e.g., "1.5.2" β†’ [1,5,2]) - const partsA = String(versionA ?? '') - .trim() - .split('.') - .map(n => parseInt(n, 10) || 0); - const partsB = String(versionB ?? '') - .trim() - .split('.') - .map(n => parseInt(n, 10) || 0); - - const maxLength = Math.max(partsA.length, partsB.length); - - for (let i = 0; i < maxLength; i++) { - const numA = partsA[i] ?? 0; - const numB = partsB[i] ?? 0; - if (numA !== numB) { - return numA < numB ? -1 : 1; // -1 if A < B, 1 if A > B - } - } - return 0; // versions are equal - } - - private shouldSkipVersionTest(cqlEngine: CQLEngine, result: InternalTestResult): boolean { - const engineVersion = cqlEngine?.cqlVersion; - if (!engineVersion) return false; // no version to compare against - // Rule 1: if test.version is set, engine must be >= test.version - if (result.testVersion && this.compareVersions(engineVersion, result.testVersion) < 0) { - return true; - } - // Rule 2: if test.versionTo is set, engine must be <= test.versionTo - if (result.testVersionTo && this.compareVersions(engineVersion, result.testVersionTo) > 0) { - return true; - } - return false; // passes all checks - } - - private addToSkipList( - skipMap: Map, - testsName: string, - groupName: string, - testName: string, - reason: string - ): void { - skipMap.set(`${testsName}-${groupName}-${testName}`, reason); - } -} diff --git a/src/shared/results-shared.ts b/src/shared/results-shared.ts index 50b77a4..afb75c2 100644 --- a/src/shared/results-shared.ts +++ b/src/shared/results-shared.ts @@ -19,8 +19,14 @@ export class Result implements InternalTestResult { invalid: 'false' | 'true' | 'semantic' | 'undefined'; expression: string; capability: CapabilityKV[] = []; - - constructor(testsName: string, groupName: string, test: Test) { + groupCapability: CapabilityKV[] = []; + + constructor( + testsName: string, + groupName: string, + test: Test, + groupCapability: CapabilityKV[] = [] + ) { this.testsName = testsName; this.groupName = groupName; this.testName = test.name; @@ -54,7 +60,9 @@ export class Result implements InternalTestResult { this.capability = Array.isArray(test.capability) ? test.capability.map(({ code, value }) => ({ code, value })) : []; - } + this.groupCapability = Array.isArray(groupCapability) + ? groupCapability.map(({ code, value }) => ({ code, value })) + : []; } } export async function generateEmptyResults( @@ -77,7 +85,7 @@ export async function generateEmptyResults( if (test != undefined) { for (const t of test) { console.log(' Test: ' + t.name); - const r = new Result(ts.name, group.name, t); + const r = new Result(ts.name, group.name, t, group.capability || []); results.push(r); groupTests.push(r); } diff --git a/src/test-results/cql-test-results.ts b/src/test-results/cql-test-results.ts index 4a6df87..5b3de47 100644 --- a/src/test-results/cql-test-results.ts +++ b/src/test-results/cql-test-results.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { CQLEngine } from '../cql-engine/cql-engine.js'; -import { TestResult, InternalTestResult } from '../models/test-types.js'; +import { TestResult, InternalTestResult } from '../models/test-types.orig'; import { TestResultsSummary, CQLTestResultsData } from '../models/results-types.js'; import { ResultsValidator } from '../conf/results-validator.js'; diff --git a/test/server-command.test.ts b/test/server-command.test.ts index 392e436..7a12fda 100644 --- a/test/server-command.test.ts +++ b/test/server-command.test.ts @@ -6,22 +6,23 @@ import { ServerCommand } from '../src/commands/server-command.js'; // Test data and mock helpers const createMockConfig = (overrides = {}) => ({ - FhirServer: { - BaseUrl: 'http://localhost:8080/fhir/', - CqlOperation: '$cql', - }, - Build: { - CqlFileVersion: '1.0.000', - CqlOutputPath: './cql', - }, - Debug: { - QuickTest: false, - }, - Tests: { - ResultsPath: './results', - SkipList: [], - }, - ...overrides, + FhirServer: { + BaseUrl: 'http://localhost:8080/fhir/', + CqlOperation: '$cql', + }, + Build: { + CqlFileVersion: '1.0.000', + CqlOutputPath: './cql', + SERVER_OFFSET_ISO: '+00:00' + }, + Debug: { + QuickTest: false, + }, + Tests: { + ResultsPath: './results', + SkipList: [], + }, + ...overrides, }); const createMockResults = () => ({ From 4266546a85cfec05108c9f36caeb8f9362e9e697 Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Tue, 17 Mar 2026 14:21:01 -0600 Subject: [PATCH 3/7] updated documentation about configuration settings concerning timezone offset --- README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0db30d6..422b11e 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,17 @@ Configuration settings are set in a JSON configuration file. The file `conf/loca "CqlOperation": "$cql" }, "Build": { - "CqlFileVersion": "1.0.000", - "CqlOutputPath": "./cql", - "testsRunDescription": '', - "testsRunDescription": "Local host test run", - "cqlTranslator": "Java CQFramework Translator", - "cqlTranslatorVersion": "Unknown", - "cqlEngine": "Java CQFramework Engine", - "cqlEngineVersion": "4.1.0" - }, + "CqlFileVersion": "1.0.000", + "CqlOutputPath": "./cql", + "CqlVersion": "1.5", + "testsRunDescription": "Local host test run", + "cqlTranslator": "Java CQFramework Translator", + "cqlTranslatorVersion": "Unknown", + "cqlEngine": "Java CQFramework Engine", + "cqlEngineVersion": "4.1.0", + "SERVER_OFFSET_ISO": "-06:00", + "TimeZoneOffsetPolicy": "timezone-offset-policy.default-server-offset" + }, "Tests": { "ResultsPath": "./results", "SkipList": [] From f386e4cc0071a5063b9f3d1b3829f5fb23dd7177 Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Fri, 20 Mar 2026 12:02:38 -0600 Subject: [PATCH 4/7] documentation for timezone offset testing --- README.md | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/README.md b/README.md index 422b11e..9761d4a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,180 @@ Configuration settings are set in a JSON configuration file. The file `conf/loca Create your own configuration file and reference it when running the commands. You can use `conf/localhost.json` as a template for a new configuration with your own settings. +### Time Zone Configuration + +The CQL Tests Runner uses two settings to control how **DateTime** values are evaluated: + +- `SERVER_OFFSET_ISO` +- `TimeZoneOffsetPolicy` + +These settings are required because CQL allows **DateTime values without a timezone offset**, and different engines interpret those values differently. Without explicitly setting these, tests involving DateTime comparison and extraction may produce inconsistent results (pass, fail, or null). + +Reference: http://cql.hl7.org/CodeSystem/cql-language-capabilities + +--- + +#### TimeZoneOffsetPolicy (what it means) + +A DateTime like this: + +```cql +@2012-04-01T00:00 +``` + +has **no timezone offset**. + +When a CQL engine evaluates this, it must decide: + +πŸ‘‰ Should this value be treated as having a timezone, or not? + +That decision is the **timezone offset policy**. + +--- + +#### Supported policies + +##### `timezone-offset-policy.default-server-offset` + +Offset-less DateTime values are interpreted using the server’s timezone. + +Example (with `SERVER_OFFSET_ISO = -06:00`): + +```cql +@2012-04-01T00:00 +``` + +is treated as: + +```cql +@2012-04-01T00:00-06:00 +``` + +**Result behavior:** +- DateTime comparisons behave as if all values have offsets +- Equality comparisons with explicit offsets often return **true** +- `timezoneoffset from` returns a numeric offset (e.g. `-6`) +- Tests expecting normalization to a server offset β†’ **pass** +- Tests expecting strict offset behavior β†’ **skip** + +--- + +##### `timezone-offset-policy.no-default-offset` + +Offset-less DateTime values remain **without a timezone**. + +```cql +@2012-04-01T00:00 +``` + +remains unchanged. + +**Result behavior:** +- No timezone is assumed +- Comparisons between offset-less and offset values may return **null** or **false** +- `timezoneoffset from` returns **null** +- Tests expecting strict offset behavior β†’ **pass** +- Tests expecting server normalization β†’ **skip** + +--- + +#### SERVER_OFFSET_ISO (what it does) + +Provides the timezone offset value used in test expressions. + +- Format: ISO 8601 offset (e.g. `-06:00`, `+00:00`, `+05:30`) +- Used when a test includes the placeholder `{{SERVER_OFFSET_ISO}}` + +Example: + +```cql +@2012-04-01T00:00 = @2012-04-01T00:00{{SERVER_OFFSET_ISO}} +``` + +With: + +```json +"SERVER_OFFSET_ISO": "-06:00" +``` + +Becomes: + +```cql +@2012-04-01T00:00 = @2012-04-01T00:00-06:00 +``` + +--- + +#### How they work together + +- `TimeZoneOffsetPolicy` determines **whether offset-less DateTime values get a timezone** +- `SERVER_OFFSET_ISO` provides **the timezone value used when needed** + +Example: + +```json +{ + "Build": { + "SERVER_OFFSET_ISO": "-06:00", + "TimeZoneOffsetPolicy": "timezone-offset-policy.default-server-offset" + } +} +``` + +This means: +- Use `-06:00` as the server timezone +- Apply that offset to DateTime values that do not include one + +--- + +#### How to set these values + +In your configuration file: + +```json +{ + "Build": { + "SERVER_OFFSET_ISO": "-06:00", + "TimeZoneOffsetPolicy": "timezone-offset-policy.default-server-offset" + } +} +``` + +Or using an environment variable (policy only): + +```bash +export TIME_ZONE_OFFSET_POLICY=timezone-offset-policy.no-default-offset +``` + +--- + +#### How the runner determines the active policy + +The runner resolves `TimeZoneOffsetPolicy` in this order: + +1. FHIR server metadata (CapabilityStatement) +2. Environment variable (`TIME_ZONE_OFFSET_POLICY`) +3. Configuration file (`TimeZoneOffsetPolicy`) +4. Runtime probe (detect behavior automatically) +5. Default: `timezone-offset-policy.default-server-offset` + +--- + +#### Expected results summary + +| Policy | Offset-less DateTime | timezoneoffset | Equality vs offset | +|--------|---------------------|----------------|-------------------| +| default-server-offset | gets server offset | number (e.g. `-6`) | often `true` | +| no-default-offset | remains offset-less | `null` | `null` or `false` | + +--- + +#### Notes + +- These settings do not change server behavior; they only control how the runner evaluates tests +- If the server declares a policy in metadata, it overrides configuration +- `SERVER_OFFSET_ISO` is only used where explicitly referenced in test expressions + ### Running the tests The CLI now requires a configuration file path as an argument. Run the tests with the following commands: From c84fe0c958d31e73706e38077faf0efe13d83c92 Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Mon, 23 Mar 2026 13:19:10 -0600 Subject: [PATCH 5/7] fix typo --- src/test-results/cql-test-results.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test-results/cql-test-results.ts b/src/test-results/cql-test-results.ts index 5b3de47..4a6df87 100644 --- a/src/test-results/cql-test-results.ts +++ b/src/test-results/cql-test-results.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { CQLEngine } from '../cql-engine/cql-engine.js'; -import { TestResult, InternalTestResult } from '../models/test-types.orig'; +import { TestResult, InternalTestResult } from '../models/test-types.js'; import { TestResultsSummary, CQLTestResultsData } from '../models/results-types.js'; import { ResultsValidator } from '../conf/results-validator.js'; From 77aa24e77c93f0cd0ef1cc0a3c3b23a53668dc58 Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Mon, 23 Mar 2026 14:28:25 -0600 Subject: [PATCH 6/7] adjustments to clean up --- src/services/test-runner.ts | 493 ++++++++++++++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 src/services/test-runner.ts diff --git a/src/services/test-runner.ts b/src/services/test-runner.ts new file mode 100644 index 0000000..732b89d --- /dev/null +++ b/src/services/test-runner.ts @@ -0,0 +1,493 @@ +import { ConfigLoader } from '../conf/config-loader.js'; +import { CQLEngine } from '../cql-engine/cql-engine.js'; +import { TestLoader } from '../loaders/test-loader.js'; +import { CQLTestResults } from '../test-results/cql-test-results.js'; +import { generateEmptyResults, generateParametersResource } from '../shared/results-shared.js'; +import { InternalTestResult } from '../models/test-types.js'; +import { ResultExtractor } from '../extractors/result-extractor.js'; +import { ServerConnectivity } from '../shared/server-connectivity.js'; +import { buildExtractor } from '../server/extractor-builder.js'; +import { createConfigFromData } from '../server/config-utils.js'; +import { resultsEqual } from '../shared/results-utils.js'; + +export interface TestRunnerOptions { + onProgress?: (current: number, total: number, message?: string) => Promise; + useAxios?: boolean; // For backward compatibility with run-tests-command +} + +export class TestRunner { + public async runTests( + configData: any, + options: TestRunnerOptions = {} + ): Promise { + // Create a temporary config loader from the provided data + const config = createConfigFromData(configData); + const serverBaseUrl = config.FhirServer.BaseUrl; + const cqlEndpoint = config.CqlEndpoint; + + // Verify server connectivity before proceeding + await ServerConnectivity.verifyServerConnectivity(serverBaseUrl); + + const cqlEngine = new CQLEngine( + serverBaseUrl, + cqlEndpoint, + configData.Build.cqlTranslator, + configData.Build.cqlTranslatorVersion, + configData.Build.cqlEngine, + configData.Build.cqlEngineVersion, + configData.Build.SERVER_OFFSET_ISO + ); + cqlEngine.cqlVersion = '1.5'; //default value + const cqlVersion = config.Build?.CqlVersion; + if (typeof cqlVersion === 'string' && cqlVersion.trim() !== '') { + cqlEngine.cqlVersion = cqlVersion; + } + + await cqlEngine.fetch(); + + const activeTimeZonePolicy = await this.resolveTimeZoneOffsetPolicy( + config, + cqlEngine.apiUrl!, + cqlEngine['metadata'], + options.useAxios + ); + console.log('Resolved timezone policy:', activeTimeZonePolicy); + // Load CVL using dynamic import + // @ts-ignore + const cvlModule = await import('../../cvl/cvl.mjs'); + const cvl = cvlModule.default; + + const tests = TestLoader.load(); + const quickTest = config.Debug?.QuickTest || false; + const resultExtractor = buildExtractor(); + const emptyResults = await generateEmptyResults(tests, quickTest); + const skipMap = config.skipListMap(); + + const results = new CQLTestResults(cqlEngine); + + const totalTests = emptyResults.reduce((sum, testFile) => sum + testFile.length, 0); + let completedTests = 0; + + for (const testFile of emptyResults) { + for (const result of testFile) { + if (this.shouldSkipVersionTest(cqlEngine, result)) { + //add to skipMap + const skipReason = + 'test version ' + + result.testVersion + + ' not applicable to engine version ' + + cqlEngine.cqlVersion; + this.addToSkipList( + skipMap, + result.testsName, + result.groupName, + result.testName, + skipReason + ); + } + await this.runTest( + result, + cqlEngine.apiUrl!, + cvl, + resultExtractor, + skipMap, + config, + cqlEngine, + activeTimeZonePolicy, + options.useAxios + ); + results.add(result); + + completedTests++; + if (options.onProgress) { + await options.onProgress( + completedTests, + totalTests, + `Running test ${result.testsName}:${result.groupName}:${result.testName}` + ); + } + } + } + // Return the CQLTestResults instance + return results; + } + + private async runTest( + result: InternalTestResult, + apiUrl: string, + cvl: any, + resultExtractor: ResultExtractor, + skipMap: Map, + config: ConfigLoader, + cqlEngine: CQLEngine, + activeTimeZonePolicy: string, + useAxios: boolean = false + ): Promise { + const key = `${result.testsName}-${result.groupName}-${result.testName}`; + + if (result.testStatus === 'skip') { + result.SkipMessage = 'Skipped by cql-tests-runner'; + return result; + } else if (skipMap.has(key)) { + const reason = skipMap.get(key) || ''; + result.SkipMessage = `Skipped by config: ${reason}`; + result.testStatus = 'skip'; + return result; + } + + const timezonePolicySkipReason = this.shouldSkipTimezonePolicyTest( + result, + activeTimeZonePolicy + ); + if (timezonePolicySkipReason) { + result.SkipMessage = timezonePolicySkipReason; + result.testStatus = 'skip'; + return result; + } + + const data = generateParametersResource(result, config.FhirServer.CqlOperation); + + try { + console.log( + 'Running test %s:%s:%s', + result.testsName, + result.groupName, + result.testName + ); + + this.applyServerOffsetToParameters(data, cqlEngine); + + let response: any; + if (useAxios) { + // Use axios for backward compatibility + const axios = await import('axios'); + const axiosResponse = await axios.default.post(apiUrl, data, { + headers: { + 'Content-Type': 'application/json', + }, + }); + response = { + status: axiosResponse.status, + data: axiosResponse.data, + }; + } else { + // Use fetch (default for new code) + const fetchResponse = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + response = { + status: fetchResponse.status, + data: await fetchResponse.json(), + }; + } + + result.responseStatus = response.status; + const responseBody = response.data; + result.actual = resultExtractor.extract(responseBody); + const invalid = result.invalid; + + if (invalid === 'true' || invalid === 'semantic') { + result.testStatus = response.status === 200 ? 'fail' : 'pass'; + } else { + if (response.status === 200) { + result.testStatus = resultsEqual(cvl.parse(result.expected), result.actual) + ? 'pass' + : 'fail'; + } else { + result.testStatus = 'fail'; + } + } + } catch (error: any) { + result.testStatus = 'error'; + result.error = { + message: error.message, + name: error.name || 'Error', + stack: error.stack, + }; + } + + console.log( + 'Test %s:%s:%s status: %s expected: %s actual: %s', + result.testsName, + result.groupName, + result.testName, + result.testStatus, + result.expected, + result.actual + ); + + return result; + } + + private async resolveTimeZoneOffsetPolicy( + config: ConfigLoader, + apiUrl: string, + serverMetadata?: any, + useAxios: boolean = false + ): Promise { + // + const metadataPolicy = this.extractTimeZonePolicyFromMetadata(serverMetadata); + if (metadataPolicy) { + console.log('Resolved timezone policy from metadata:', metadataPolicy); + return metadataPolicy; + } + + const configuredPolicy = + process.env.TIME_ZONE_OFFSET_POLICY?.trim() || + config.Build?.TimeZoneOffsetPolicy?.trim(); + + if (configuredPolicy) { + console.log('Resolved timezone policy from env/config:', configuredPolicy); + return configuredPolicy; + } + + const probedPolicy = await this.detectTimeZoneOffsetPolicy(apiUrl, useAxios); + if (probedPolicy) { + console.log('Resolved timezone policy from probe:', probedPolicy); + return probedPolicy; + } + + const fallbackPolicy = 'timezone-offset-policy.default-server-offset'; + console.log('Resolved timezone policy from fallback:', fallbackPolicy); + return fallbackPolicy; + } + + private async detectTimeZoneOffsetPolicy( + apiUrl: string, + useAxios: boolean = false + ): Promise { + // order of setting timezone offset policy: metadata -> env/config -> probe -> fallback + const data = { + resourceType: 'Parameters', + parameter: [ + { + name: 'expression', + valueString: 'timezoneoffset from @2012-04-01T00:00', + }, + ], + }; + + let response: any; + + if (useAxios) { + const axios = await import('axios'); + const axiosResponse = await axios.default.post(apiUrl, data, { + headers: { 'Content-Type': 'application/json' }, + }); + response = { + status: axiosResponse.status, + data: axiosResponse.data, + }; + } else { + const fetchResponse = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + response = { + status: fetchResponse.status, + data: await fetchResponse.json(), + }; + } + + if (response.status !== 200) { + return null; + } + + const extracted = this.extractProbeResult(response.data); + + if (extracted === 'null') { + return 'timezone-offset-policy.no-default-offset'; + } + + if (typeof extracted === 'number') { + return 'timezone-offset-policy.default-server-offset'; + } + + if (typeof extracted === 'string') { + const trimmed = extracted.trim().toLowerCase(); + if (trimmed === 'null') { + return 'timezone-offset-policy.no-default-offset'; + } + if (/^-?\d+$/.test(trimmed)) { + return 'timezone-offset-policy.default-server-offset'; + } + } + + return null; + } + + private extractProbeResult(responseBody: any): any { + const parameter = responseBody?.parameter; + if (!Array.isArray(parameter) || parameter.length === 0) { + return null; + } + + const returnParam = parameter.find((p: any) => p.name === 'return') || parameter[0]; + + if (returnParam.valueInteger !== undefined) { + return returnParam.valueInteger; + } + if (returnParam.valueDecimal !== undefined) { + return returnParam.valueDecimal; + } + if (returnParam.valueString !== undefined) { + return returnParam.valueString; + } + if (returnParam.valueBoolean !== undefined) { + return returnParam.valueBoolean; + } + + return null; + } + + private extractTimeZonePolicyFromMetadata(metadata: any): string | null { + if (!metadata || typeof metadata !== 'object') { + return null; + } + + const policyCodes = [ + 'timezone-offset-policy.no-default-offset', + 'timezone-offset-policy.default-server-offset', + ]; + + function findInObject(obj: any): string | null { + if (!obj || typeof obj !== 'object') { + return null; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + const found = findInObject(item); + if (found) return found; + } + return null; + } + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string' && policyCodes.includes(value)) { + return value; + } + + if (key === 'code' && typeof value === 'string' && policyCodes.includes(value)) { + return value; + } + + if ( + key === 'valueCode' && + typeof value === 'string' && + policyCodes.includes(value) + ) { + return value; + } + + if ( + key === 'valueString' && + typeof value === 'string' && + policyCodes.includes(value) + ) { + return value; + } + + if (typeof value === 'object') { + const found = findInObject(value); + if (found) return found; + } + } + + return null; + } + + return findInObject(metadata); + } + + private applyServerOffsetToParameters(data: any, engine: CQLEngine): void { + const expressionParam = data?.parameter?.find((p: any) => p.name === 'expression'); + if (!expressionParam || typeof expressionParam.valueString !== 'string') { + return; + } + + const offset = engine.SERVER_OFFSET_ISO; + if (typeof offset !== 'string' || offset.trim() === '') { + return; + } + + expressionParam.valueString = this.replaceServerOffsetPlaceholder( + expressionParam.valueString, + offset + ); + } + + private replaceServerOffsetPlaceholder(expression: string, serverOffsetISO: string): string { + return expression.replace(/\{\{SERVER_OFFSET_ISO\}\}/g, serverOffsetISO); + } + + private shouldSkipTimezonePolicyTest(test: any, activeTimeZonePolicy: string): string | null { + const requiredCapabilities = test.capability || []; + + const requiredPolicy = requiredCapabilities.find((c: any) => + c.code?.startsWith('timezone-offset-policy.') + )?.code; + + if (!requiredPolicy) { + return null; + } + + if (requiredPolicy !== activeTimeZonePolicy) { + return `requires ${requiredPolicy} but server is ${activeTimeZonePolicy}`; + } + + return null; + } + + private compareVersions(versionA: string | undefined, versionB: string | undefined): number { + // Split into numeric parts (e.g., "1.5.2" β†’ [1,5,2]) + const partsA = String(versionA ?? '') + .trim() + .split('.') + .map(n => parseInt(n, 10) || 0); + const partsB = String(versionB ?? '') + .trim() + .split('.') + .map(n => parseInt(n, 10) || 0); + + const maxLength = Math.max(partsA.length, partsB.length); + + for (let i = 0; i < maxLength; i++) { + const numA = partsA[i] ?? 0; + const numB = partsB[i] ?? 0; + if (numA !== numB) { + return numA < numB ? -1 : 1; // -1 if A < B, 1 if A > B + } + } + return 0; // versions are equal + } + + private shouldSkipVersionTest(cqlEngine: CQLEngine, result: InternalTestResult): boolean { + const engineVersion = cqlEngine?.cqlVersion; + if (!engineVersion) return false; // no version to compare against + // Rule 1: if test.version is set, engine must be >= test.version + if (result.testVersion && this.compareVersions(engineVersion, result.testVersion) < 0) { + return true; + } + // Rule 2: if test.versionTo is set, engine must be <= test.versionTo + if (result.testVersionTo && this.compareVersions(engineVersion, result.testVersionTo) > 0) { + return true; + } + return false; // passes all checks + } + + private addToSkipList( + skipMap: Map, + testsName: string, + groupName: string, + testName: string, + reason: string + ): void { + skipMap.set(`${testsName}-${groupName}-${testName}`, reason); + } +} From cdff70caadc06683e6fb7c2312716008a9121e85 Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Fri, 27 Mar 2026 10:30:02 -0600 Subject: [PATCH 7/7] adjusted README to better state timezone offset --- README.md | 181 ++++++++++++++++++++++++++---------------------------- 1 file changed, 86 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 9761d4a..c7bd126 100644 --- a/README.md +++ b/README.md @@ -73,158 +73,149 @@ Reference: http://cql.hl7.org/CodeSystem/cql-language-capabilities #### TimeZoneOffsetPolicy (what it means) -A DateTime like this: +# πŸ”„ Timezone Offset Policy -```cql +## Background + +CQL allows `DateTime` literals to be specified **with or without a timezone offset**. + +For example: + +``` @2012-04-01T00:00 ``` -has **no timezone offset**. +This value does **not include an explicit timezone offset**. -When a CQL engine evaluates this, it must decide: +--- -πŸ‘‰ Should this value be treated as having a timezone, or not? +## What the CQL Specification Says -That decision is the **timezone offset policy**. +> If no timezone offset is specified, the timezone offset of the evaluation request timestamp is used. ---- +This means: -#### Supported policies +- A `DateTime` literal may omit an offset in its **source representation** +- But at **evaluation time**, the engine must apply an offset +- That offset comes from the **evaluation request timestamp** -##### `timezone-offset-policy.default-server-offset` +πŸ‘‰ In other words, under CQL semantics: -Offset-less DateTime values are interpreted using the server’s timezone. +- DateTimes are **always evaluated with an effective timezone offset** +- The only question is **which offset is applied** -Example (with `SERVER_OFFSET_ISO = -06:00`): +--- -```cql -@2012-04-01T00:00 -``` +## Why This Capability Exists -is treated as: +Although the specification is clear, implementations have historically differed in how they handle `DateTime` values without explicit offsets. -```cql -@2012-04-01T00:00-06:00 -``` +Observed variations include: -**Result behavior:** -- DateTime comparisons behave as if all values have offsets -- Equality comparisons with explicit offsets often return **true** -- `timezoneoffset from` returns a numeric offset (e.g. `-6`) -- Tests expecting normalization to a server offset β†’ **pass** -- Tests expecting strict offset behavior β†’ **skip** +- Applying the evaluation request timestamp offset (spec-compliant behavior) +- Treating the value as offset-less in some operations +- Returning `null` for operations like `timezoneoffset(...)` +- Applying server-local or implicit defaults inconsistently + +This capability exists to explicitly test and document how an engine behaves in these scenarios. --- -##### `timezone-offset-policy.no-default-offset` +## What Is Being Tested -Offset-less DateTime values remain **without a timezone**. +This capability evaluates how an engine handles `DateTime` values that omit a timezone offset, including: -```cql -@2012-04-01T00:00 -``` +- Whether the engine applies the evaluation request timestamp offset +- How functions like `timezoneoffset(...)` behave +- Whether comparisons and arithmetic treat the value consistently -remains unchanged. +--- -**Result behavior:** -- No timezone is assumed -- Comparisons between offset-less and offset values may return **null** or **false** -- `timezoneoffset from` returns **null** -- Tests expecting strict offset behavior β†’ **pass** -- Tests expecting server normalization β†’ **skip** +## Clarification ---- +This capability does **not** test whether a `DateTime` β€œhas a timezone or not.” -#### SERVER_OFFSET_ISO (what it does) +Per the CQL specification: -Provides the timezone offset value used in test expressions. +- A `DateTime` without an explicit offset is still evaluated **as if it has one** +- The offset is derived from the evaluation context (evaluation request timestamp) -- Format: ISO 8601 offset (e.g. `-06:00`, `+00:00`, `+05:30`) -- Used when a test includes the placeholder `{{SERVER_OFFSET_ISO}}` +πŸ‘‰ The purpose of this capability is to verify that engines implement this behavior **correctly and consistently**. -Example: +--- -```cql -@2012-04-01T00:00 = @2012-04-01T00:00{{SERVER_OFFSET_ISO}} -``` +## Suggested Terminology -With: +- ❌ β€œDoes the DateTime have a timezone?” +- βœ… β€œHow does the engine determine the effective timezone offset for DateTimes without an explicit offset?” -```json -"SERVER_OFFSET_ISO": "-06:00" -``` +--- -Becomes: +## Key Clarification + +> This capability is testing **implementation consistency**, not specification ambiguity. -```cql -@2012-04-01T00:00 = @2012-04-01T00:00-06:00 -``` --- -#### How they work together +# πŸ”— CapabilityTests Alignment & Mapping -- `TimeZoneOffsetPolicy` determines **whether offset-less DateTime values get a timezone** -- `SERVER_OFFSET_ISO` provides **the timezone value used when needed** +## Capability Definition -Example: +- Capability: `timezone-offset-policy` +- Focus: Handling of DateTime values without explicit timezone offsets -```json -{ - "Build": { - "SERVER_OFFSET_ISO": "-06:00", - "TimeZoneOffsetPolicy": "timezone-offset-policy.default-server-offset" - } -} -``` +--- -This means: -- Use `-06:00` as the server timezone -- Apply that offset to DateTime values that do not include one +## Test Mapping + +### 1. Default Offset Application +- Tests: `timezone-offset-default` +- Verifies: + - Missing offset uses evaluation request timestamp + - No null offset produced --- -#### How to set these values +### 2. timezoneoffset(...) Behavior +- Tests: `timezoneoffset-from-datetime` +- Verifies: + - Returns evaluation offset when not explicitly provided -In your configuration file: +--- -```json -{ - "Build": { - "SERVER_OFFSET_ISO": "-06:00", - "TimeZoneOffsetPolicy": "timezone-offset-policy.default-server-offset" - } -} -``` +### 3. Equality / Comparison Consistency +- Tests: `datetime-equality-offset` +- Verifies: + - `@2012-04-01T00:00` equals equivalent explicit-offset value -Or using an environment variable (policy only): +--- -```bash -export TIME_ZONE_OFFSET_POLICY=timezone-offset-policy.no-default-offset -``` +### 4. Arithmetic Behavior +- Tests: `datetime-arithmetic-offset` +- Verifies: + - Duration calculations respect derived offset --- -#### How the runner determines the active policy +## Reviewer Traceability -The runner resolves `TimeZoneOffsetPolicy` in this order: +Spec β†’ Behavior β†’ Test -1. FHIR server metadata (CapabilityStatement) -2. Environment variable (`TIME_ZONE_OFFSET_POLICY`) -3. Configuration file (`TimeZoneOffsetPolicy`) -4. Runtime probe (detect behavior automatically) -5. Default: `timezone-offset-policy.default-server-offset` +- Spec: Offset defaults to evaluation timestamp +- Behavior: Engine applies offset consistently +- Tests: Confirm consistency across functions and operators --- -#### Expected results summary +## Summary -| Policy | Offset-less DateTime | timezoneoffset | Equality vs offset | -|--------|---------------------|----------------|-------------------| -| default-server-offset | gets server offset | number (e.g. `-6`) | often `true` | -| no-default-offset | remains offset-less | `null` | `null` or `false` | +This capability ensures engines: + +- Correctly apply implicit timezone offsets +- Do not treat DateTimes as offset-less at runtime +- Maintain consistency across operations ---- #### Notes