From da96868c092cb0e6820a32865f376df21ceed35d Mon Sep 17 00:00:00 2001 From: Bryant Austin Date: Mon, 1 Jun 2026 13:53:55 -0600 Subject: [PATCH] fix display and comparison of concept codes --- src/shared/results-utils.ts | 48 ++++++++- src/test-results/cql-test-results.ts | 156 ++++++++++++++++++++++++++- 2 files changed, 199 insertions(+), 5 deletions(-) diff --git a/src/shared/results-utils.ts b/src/shared/results-utils.ts index 1fa30b5..b6c8271 100644 --- a/src/shared/results-utils.ts +++ b/src/shared/results-utils.ts @@ -1,7 +1,51 @@ /** - * Compares two results for equality, handling nested objects and numbers + * Compares two results for equality, handling nested objects and numbers. + * + * Normalization handles representation differences that are not semantically + * meaningful for CQL comparison: + * - CVL can represent singleton Concept.codes as a single Code object while + * the extractor can preserve codes as Code[]. + * - Extracted runtime objects can include optional properties with undefined + * values, such as system/version/display. Those should not make an object + * unequal to the same object with those properties omitted. */ export function resultsEqual(expected: any, actual: any): boolean { + return resultsEqualNormalized( + normalizeForComparison(expected), + normalizeForComparison(actual) + ); +} + +function normalizeForComparison(value: any): any { + if (Array.isArray(value)) { + return value.map(normalizeForComparison); + } + + if (value && typeof value === 'object') { + const normalized: any = {}; + + for (const [key, child] of Object.entries(value)) { + // Ignore optional fields that are present only as undefined on runtime + // objects. Object.keys includes these fields, causing false mismatches + // against expected values where the fields are absent. + if (child === undefined) { + continue; + } + + if (key === 'codes' && Array.isArray(child) && child.length === 1) { + normalized[key] = normalizeForComparison(child[0]); + } else { + normalized[key] = normalizeForComparison(child); + } + } + + return normalized; + } + + return value; +} + +function resultsEqualNormalized(expected: any, actual: any): boolean { if (expected === undefined && actual === undefined) { return true; } @@ -33,7 +77,7 @@ export function resultsEqual(expected: any, actual: any): boolean { if (expectedKeys.length !== actualKeys.length) return false; for (const key of expectedKeys) { - if (!actualKeys.includes(key) || !resultsEqual(expected[key], actual[key])) { + if (!actualKeys.includes(key) || !resultsEqualNormalized(expected[key], actual[key])) { return false; } } diff --git a/src/test-results/cql-test-results.ts b/src/test-results/cql-test-results.ts index eb3b26d..9d8448c 100644 --- a/src/test-results/cql-test-results.ts +++ b/src/test-results/cql-test-results.ts @@ -27,6 +27,155 @@ export class CQLTestResults { */ public results: InternalTestResult[] = []; + + /** + * Returns true when a value is shaped like the runner's internal CQL Interval + * representation. + */ + private static isIntervalValue(value: any): boolean { + return ( + value !== null && + typeof value === 'object' && + ('low' in value || 'high' in value) && + ('lowClosed' in value || 'highClosed' in value) + ); + } + + /** + * Preserve temporal values exactly as returned by the engine. + */ + private static formatCqlTemporalValue(value: string): string { + return value; + } + + /** + * Formats a primitive value for compact CQL-style report output. + */ + private static formatCqlValue(value: any): string { + if (value === null || value === undefined) { + return 'null'; + } + + if (typeof value === 'string') { + return CQLTestResults.formatCqlTemporalValue(value); + } + + return String(value); + } + + /** + * Formats an internal interval object as CQL interval syntax. + */ + private static formatIntervalValue(interval: any): string { + const lowDelimiter = interval.lowClosed === false ? '(' : '['; + const highDelimiter = interval.highClosed === false ? ')' : ']'; + return `Interval${lowDelimiter}${CQLTestResults.formatCqlValue(interval.low)}, ${CQLTestResults.formatCqlValue(interval.high)}${highDelimiter}`; + } + + /** + * Returns true when a value is shaped like the CQL System.Code runtime representation. + */ + private static isCodeValue(value: any): boolean { + return value !== null && typeof value === 'object' && 'code' in value; + } + + /** + * Formats a CQL System.Code value as CQL constructor syntax. + */ + private static formatCodeValue(code: any): string { + const parts: string[] = []; + + if (code.code !== undefined) { + parts.push(`code: '${code.code}'`); + } + if (code.system !== undefined) { + parts.push(`system: '${code.system}'`); + } + if (code.version !== undefined) { + parts.push(`version: '${code.version}'`); + } + if (code.display !== undefined) { + parts.push(`display: '${code.display}'`); + } + + return `Code { ${parts.join(', ')} }`; + } + + /** + * Returns true when a value is shaped like the CQL System.Concept runtime representation. + */ + private static isConceptValue(value: any): boolean { + return ( + value !== null && + typeof value === 'object' && + Array.isArray(value.codes) && + value.codes.every((code: any) => CQLTestResults.isCodeValue(code)) + ); + } + + /** + * Formats a CQL System.Concept value as CQL constructor syntax. + */ + private static formatConceptValue(concept: any): string { + const codes = concept.codes + .map((code: any) => CQLTestResults.formatCodeValue(code)) + .join(', '); + const parts = [`codes: { ${codes} }`]; + + if (concept.display !== undefined) { + parts.push(`display: '${concept.display}'`); + } + + return `Concept { ${parts.join(', ')} }`; + } + + /** + * Formats an actual result value for schema-compliant JSON and console output. + * Complex CQL values are rendered in compact CQL syntax when possible; + * otherwise they are JSON-serialized. String(object) produces + * "[object Object]" and loses structure. + */ + public static formatActualValue(actual: any): string { + if (actual === null) { + return 'null'; + } + + if (typeof actual === 'string') { + return actual; + } + + if (typeof actual === 'boolean' || typeof actual === 'number' || typeof actual === 'bigint') { + return String(actual); + } + + if (CQLTestResults.isConceptValue(actual)) { + return CQLTestResults.formatConceptValue(actual); + } + + if (CQLTestResults.isCodeValue(actual)) { + return CQLTestResults.formatCodeValue(actual); + } + + if (CQLTestResults.isIntervalValue(actual)) { + return CQLTestResults.formatIntervalValue(actual); + } + + if (Array.isArray(actual) && actual.every(CQLTestResults.isIntervalValue)) { + return `{ ${actual.map(interval => CQLTestResults.formatIntervalValue(interval)).join(', ')} }`; + } + + if (Array.isArray(actual) && actual.every(value => typeof value === 'string')) { + return `{ ${actual.join(', ')} }`; + } + + try { + const serialized = JSON.stringify(actual, null, 2); + return serialized === undefined ? String(actual) : serialized; + } catch { + return String(actual); + } + } + /** * Initializes CQLTestResults object with counts and results array. * @param cqlengine - The CQL engine instance used to run the tests. @@ -106,7 +255,7 @@ export class CQLTestResults { ...(result.responseStatus !== undefined && { responseStatus: result.responseStatus, }), - ...(result.actual !== undefined && { actual: String(result.actual) }), + ...(result.actual !== undefined && { actual: CQLTestResults.formatActualValue(result.actual) }), ...(result.expected && { expected: result.expected }), ...(result.error && { error: { @@ -188,8 +337,9 @@ 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); + // Convert any non-string value to a schema-compliant string while + // preserving structure for arrays/objects. + r.actual = CQLTestResults.formatActualValue(act); } } }