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
48 changes: 46 additions & 2 deletions src/shared/results-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -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;
}
}
Expand Down
156 changes: 153 additions & 3 deletions src/test-results/cql-test-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
}
}
}
Expand Down