diff --git a/src/extractor/AssignmentRuleExtractor.ts b/src/extractor/AssignmentRuleExtractor.ts index 69064c31..97a64624 100644 --- a/src/extractor/AssignmentRuleExtractor.ts +++ b/src/extractor/AssignmentRuleExtractor.ts @@ -1,11 +1,14 @@ import { fhirtypes, fshtypes } from 'fsh-sushi'; import { ProcessableElementDefinition } from '../processor'; import { ExportableAssignmentRule } from '../exportable'; -import { getPath } from '../utils'; +import { dateRegex, dateTimeRegex, getPath, instantRegex, timeRegex, logger } from '../utils'; import { fshifyString } from '../exportable/common'; export class AssignmentRuleExtractor { - static process(input: ProcessableElementDefinition): ExportableAssignmentRule[] { + static process( + input: ProcessableElementDefinition, + entityName: string + ): ExportableAssignmentRule[] { // check for fixedSomething or patternSomething // pattern and fixed are mutually exclusive // these are on one-type elements, so if our SD has value[x], @@ -28,6 +31,31 @@ export class AssignmentRuleExtractor { assignmentRule.value = BigInt(matchingValue); } else { assignmentRule.value = matchingValue; + if (matchingKey.endsWith('DateTime')) { + if (!dateTimeRegex.test(matchingValue)) { + logger.warn( + `Value ${matchingValue} on ${entityName} element ${assignmentRule.path} is not a valid FHIR dateTime` + ); + } + } else if (matchingKey.endsWith('Date')) { + if (!dateRegex.test(matchingValue)) { + logger.warn( + `Value ${matchingValue} on ${entityName} element ${assignmentRule.path} is not a valid FHIR date` + ); + } + } else if (matchingKey.endsWith('Time')) { + if (!timeRegex.test(matchingValue)) { + logger.warn( + `Value ${matchingValue} on ${entityName} element ${assignmentRule.path} is not a valid FHIR time` + ); + } + } else if (matchingKey.endsWith('Instant')) { + if (!instantRegex.test(matchingValue)) { + logger.warn( + `Value ${matchingValue} on ${entityName} element ${assignmentRule.path} is not a valid FHIR instant` + ); + } + } } } else { assignmentRule.value = matchingValue; diff --git a/src/extractor/CaretValueRuleExtractor.ts b/src/extractor/CaretValueRuleExtractor.ts index 443d51f7..73d5412e 100644 --- a/src/extractor/CaretValueRuleExtractor.ts +++ b/src/extractor/CaretValueRuleExtractor.ts @@ -24,10 +24,17 @@ export class CaretValueRuleExtractor { const remainingFlatElementArray = flatElementArray.filter( ([key]) => !input.processedPaths.includes(key) ); + const entityPath = path === '' ? structDef.name : `${structDef.name}.${path}`; remainingFlatElementArray.forEach(([key], i) => { const caretValueRule = new ExportableCaretValueRule(path); caretValueRule.caretPath = key; - caretValueRule.value = getFSHValue(i, remainingFlatElementArray, 'ElementDefinition', fisher); + caretValueRule.value = getFSHValue( + i, + remainingFlatElementArray, + 'ElementDefinition', + entityPath, + fisher + ); // If the value is empty, we can't use it. Log an error and give up on trying to use this key. if (isFSHValueEmpty(caretValueRule.value)) { logger.error( @@ -207,7 +214,7 @@ export class CaretValueRuleExtractor { } const caretValueRule = new ExportableCaretValueRule(''); caretValueRule.caretPath = key; - caretValueRule.value = getFSHValue(i, flatArray, 'StructureDefinition', fisher); + caretValueRule.value = getFSHValue(i, flatArray, 'StructureDefinition', input.name, fisher); if (isFSHValueEmpty(caretValueRule.value)) { logger.error( `Value in StructureDefinition ${input.name} for element ${key} is empty. No caret value rule will be created.` @@ -241,7 +248,13 @@ export class CaretValueRuleExtractor { } const caretValueRule = new ExportableCaretValueRule(''); caretValueRule.caretPath = key; - caretValueRule.value = getFSHValue(i, flatArray, resourceType, fisher); + caretValueRule.value = getFSHValue( + i, + flatArray, + resourceType, + input.name ?? input.id, + fisher + ); if (isFSHValueEmpty(caretValueRule.value)) { logger.error( `Value in ${resourceType} ${ @@ -272,7 +285,13 @@ export class CaretValueRuleExtractor { flatArray.forEach(([key], i) => { const caretValueRule = new ExportableCaretValueRule(''); caretValueRule.caretPath = key; - caretValueRule.value = getFSHValue(i, flatArray, 'Concept', fisher); + caretValueRule.value = getFSHValue( + i, + flatArray, + 'Concept', + `${entityName} ${pathArray.join('.')}`, + fisher + ); caretValueRule.isCodeCaretRule = true; caretValueRule.pathArray = [...pathArray]; if (isFSHValueEmpty(caretValueRule.value)) { diff --git a/src/extractor/InvariantExtractor.ts b/src/extractor/InvariantExtractor.ts index 89788fc1..0ed0a16e 100644 --- a/src/extractor/InvariantExtractor.ts +++ b/src/extractor/InvariantExtractor.ts @@ -6,7 +6,7 @@ import { ProcessableStructureDefinition, switchQuantityRules } from '../processor'; -import { getFSHValue, getPathValuePairs, isFSHValueEmpty } from '../utils'; +import { getFSHValue, getPath, getPathValuePairs, isFSHValueEmpty } from '../utils'; import { ExportableAssignmentRule } from '../exportable'; export class InvariantExtractor { @@ -55,21 +55,25 @@ export class InvariantExtractor { // so that we can get the FSH value correctly. // but, we want the original path for the rule itself. const flatPropertyArray = toPairs( - getPathValuePairs(workingConstraint, x => `constraint.${x}`) + getPathValuePairs(workingConstraint, x => `constraint[${i}].${x}`) ); + const elementPath = getPath(input); + const entityPath = + elementPath === '.' ? structDef.name : `${structDef.name}.${elementPath}`; flatPropertyArray.forEach(([path], propertyIdx) => { - const originalPath = path.replace('constraint.', ''); + const originalPath = path.replace(`constraint[${i}].`, ''); const assignmentRule = new ExportableAssignmentRule(originalPath); assignmentRule.value = getFSHValue( propertyIdx, flatPropertyArray, 'ElementDefinition', + entityPath, fisher ); if (!isFSHValueEmpty(assignmentRule.value)) { invariant.rules.push(assignmentRule); } - constraintPaths.push(`constraint[${i}].${originalPath}`); + constraintPaths.push(path); }); switchQuantityRules(invariant.rules); diff --git a/src/processor/InstanceProcessor.ts b/src/processor/InstanceProcessor.ts index 707dd9e5..d263e573 100644 --- a/src/processor/InstanceProcessor.ts +++ b/src/processor/InstanceProcessor.ts @@ -110,7 +110,13 @@ export class InstanceProcessor { const flatInstanceArray = toPairs(getPathValuePairs(inputJSON)); flatInstanceArray.forEach(([path], i) => { const assignmentRule = new ExportableAssignmentRule(path); - assignmentRule.value = getFSHValue(i, flatInstanceArray, instanceOfJSON.type, fisher); + assignmentRule.value = getFSHValue( + i, + flatInstanceArray, + instanceOfJSON.type, + `${target.instanceOf} ${target.id}`, + fisher + ); // if the value is empty, we can't use that if (isFSHValueEmpty(assignmentRule.value)) { logger.error( diff --git a/src/processor/StructureDefinitionProcessor.ts b/src/processor/StructureDefinitionProcessor.ts index 0370cafc..c526d1d4 100644 --- a/src/processor/StructureDefinitionProcessor.ts +++ b/src/processor/StructureDefinitionProcessor.ts @@ -225,13 +225,13 @@ export class StructureDefinitionProcessor { newRules.push( BindingRuleExtractor.process(element), ObeysRuleExtractor.process(element), - ...AssignmentRuleExtractor.process(element) + ...AssignmentRuleExtractor.process(element, target.name) ); } else if (isNewSlice) { newRules.push( ContainsRuleExtractor.process(element, input, fisher), OnlyRuleExtractor.process(element), - ...AssignmentRuleExtractor.process(element), + ...AssignmentRuleExtractor.process(element, target.name), BindingRuleExtractor.process(element), ObeysRuleExtractor.process(element) ); @@ -239,7 +239,7 @@ export class StructureDefinitionProcessor { newRules.push( CardRuleExtractor.process(element, input, fisher), OnlyRuleExtractor.process(element), - ...AssignmentRuleExtractor.process(element), + ...AssignmentRuleExtractor.process(element, target.name), FlagRuleExtractor.process(element), BindingRuleExtractor.process(element), ObeysRuleExtractor.process(element) diff --git a/src/utils/element.ts b/src/utils/element.ts index b90ff7ab..ea0e943c 100644 --- a/src/utils/element.ts +++ b/src/utils/element.ts @@ -2,6 +2,16 @@ import { flatten } from 'flat'; import { flatMap, flatten as flat, isObject, isEmpty, isNil } from 'lodash'; import { fhirtypes, fshtypes, utils } from 'fsh-sushi'; import { ProcessableStructureDefinition, ProcessableElementDefinition } from '../processor'; +import { logger } from './GoFSHLogger'; + +// See https://hl7.org/fhir/R5/datatypes.html#dateTime +export const dateTimeRegex = /^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?)?)?$/; +// See https://hl7.org/fhir/R5/datatypes.html#date +export const dateRegex = /^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1]))?)?$/; +// See https://hl7.org/fhir/R5/datatypes.html#time +export const timeRegex = /^([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]{1,9})?$/; +// See https://hl7.org/fhir/R5/datatypes.html#instant +export const instantRegex = /^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]{1,9})?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))$/; // This function depends on the id of an element to construct the path. // Per the specification https://www.hl7.org/fhir/elementdefinition.html#id, we should @@ -76,6 +86,7 @@ export function getFSHValue( index: number, flatArray: [string, string | number | boolean][], resourceType: string, + resourceName: string, fisher: utils.Fishable ): number | boolean | string | fshtypes.FshCode | bigint { const [key, value] = flatArray[index]; @@ -91,6 +102,35 @@ export function getFSHValue( return new fshtypes.FshCode(value.toString()); } else if (type === 'integer64') { return BigInt(value); + } else if (type === 'dateTime') { + if (!dateTimeRegex.test(value.toString())) { + logger.warn( + `Value ${value.toString()} on ${resourceName} element ${key} is not a valid FHIR dateTime` + ); + } + return value; + } else if (type === 'date') { + if (!dateRegex.test(value.toString())) { + logger.warn( + `Value ${value.toString()} on ${resourceName} element ${key} is not a valid FHIR date` + ); + } + return value; + } else if (type === 'time') { + if (!timeRegex.test(value.toString())) { + logger.warn( + `Value ${value.toString()} on ${resourceName} element ${key} is not a valid FHIR time` + ); + } + return value; + } else if (type === 'instant') { + typeCache.get(resourceType).set(pathWithoutIndex, 'instant'); + if (!instantRegex.test(value.toString())) { + logger.warn( + `Value ${value.toString()} on ${resourceName} element ${key} is not a valid FHIR instant` + ); + } + return value; } else if (type) { return value; } @@ -119,9 +159,10 @@ export function getFSHValue( ]) as [string, string | number | boolean][]; const containedResourceType = subArray.find(([key]) => key === 'resourceType')?.[1] as string; const newIndex = subArray.findIndex(([key]) => key === newKey); + const containedResourceName = `${resourceName}.${baseKey}`; // Get the FSH value based on the contained resource type. Use paths relative to the contained resource. - return getFSHValue(newIndex, subArray, containedResourceType, fisher); + return getFSHValue(newIndex, subArray, containedResourceType, containedResourceName, fisher); } if (!typeCache.has(resourceType)) { typeCache.set(resourceType, new Map()); @@ -136,9 +177,42 @@ export function getFSHValue( } else if (element?.type?.[0]?.code === 'integer64') { typeCache.get(resourceType).set(pathWithoutIndex, 'integer64'); return BigInt(value); + } else if (element?.type?.[0]?.code === 'dateTime') { + typeCache.get(resourceType).set(pathWithoutIndex, 'dateTime'); + if (!dateTimeRegex.test(value.toString())) { + logger.warn( + `Value ${value.toString()} on ${resourceName} element ${key} is not a valid FHIR dateTime` + ); + } + return value; + } else if (element?.type?.[0]?.code === 'date') { + typeCache.get(resourceType).set(pathWithoutIndex, 'date'); + if (!dateRegex.test(value.toString())) { + logger.warn( + `Value ${value.toString()} on ${resourceName} element ${key} is not a valid FHIR date` + ); + } + return value; + } else if (element?.type?.[0]?.code === 'time') { + typeCache.get(resourceType).set(pathWithoutIndex, 'time'); + if (!timeRegex.test(value.toString())) { + logger.warn( + `Value ${value.toString()} on ${resourceName} element ${key} is not a valid FHIR time` + ); + } + return value; + } else if (element?.type?.[0]?.code === 'instant') { + typeCache.get(resourceType).set(pathWithoutIndex, 'instant'); + if (!instantRegex.test(value.toString())) { + logger.warn( + `Value ${value.toString()} on ${resourceName} element ${key} is not a valid FHIR instant` + ); + } + return value; + } else { + typeCache.get(resourceType).set(pathWithoutIndex, typeof value); + return value; } - typeCache.get(resourceType).set(pathWithoutIndex, typeof value); - return value; } // Typical empty FSH values are: [], {}, null, undefined diff --git a/test/extractor/AssignmentRuleExtractor.test.ts b/test/extractor/AssignmentRuleExtractor.test.ts index 1cb56a75..d72b65a7 100644 --- a/test/extractor/AssignmentRuleExtractor.test.ts +++ b/test/extractor/AssignmentRuleExtractor.test.ts @@ -4,10 +4,16 @@ import { fhirtypes, fshtypes } from 'fsh-sushi'; import { AssignmentRuleExtractor } from '../../src/extractor'; import { ExportableAssignmentRule } from '../../src/exportable'; import { ProcessableElementDefinition } from '../../src/processor'; +import { loggerSpy } from '../helpers/loggerSpy'; describe('AssignmentRuleExtractor', () => { + beforeEach(() => { + loggerSpy.reset(); + }); + describe('#simple-values', () => { let looseSD: any; + const entityName = 'AssignedValueObservation'; beforeAll(() => { looseSD = JSON.parse( @@ -19,7 +25,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed number value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[0]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('valueInteger'); expectedRule.value = 0; expectedRule.exactly = true; @@ -30,7 +36,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a pattern number value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[1]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('component.valueInteger'); expectedRule.value = 8; expect(assignmentRules).toHaveLength(1); @@ -40,7 +46,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed string value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[2]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('effectiveInstant'); expectedRule.value = '2020-07-24T9:31:23.745-04:00'; expectedRule.exactly = true; @@ -51,7 +57,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a pattern string value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[3]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('note.text'); expectedRule.value = 'This is the "note" text.\nThere are two lines.'; expect(assignmentRules).toHaveLength(1); @@ -61,7 +67,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed boolean value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[4]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('valueBoolean'); expectedRule.value = true; expectedRule.exactly = true; @@ -72,7 +78,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a pattern boolean value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[5]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('component.valueBoolean'); expectedRule.value = false; expect(assignmentRules).toHaveLength(1); @@ -82,7 +88,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed integer64 value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[8]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('valueInteger64'); expectedRule.value = BigInt('9223372036854775807'); expectedRule.exactly = true; @@ -93,7 +99,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a pattern integer64 value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[9]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('valueInteger64'); expectedRule.value = BigInt('9223372036854775807'); expect(assignmentRules).toHaveLength(1); @@ -103,7 +109,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a pattern code', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[6]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('status'); expectedRule.value = new fshtypes.FshCode('final'); expect(assignmentRules).toHaveLength(1); @@ -113,14 +119,151 @@ describe('AssignmentRuleExtractor', () => { it('should return no rules when an element does not have a fixed or pattern value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[7]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); expect(assignmentRules).toHaveLength(0); expect(element.processedPaths).toHaveLength(0); }); }); + describe('#simple-date-and-time-values', () => { + let looseSD: any; + const entityName = 'TemporalAssignedValueObservation'; + + beforeAll(() => { + looseSD = JSON.parse( + fs + .readFileSync( + path.join(__dirname, 'fixtures', 'temporal-assigned-value-profile.json'), + 'utf-8' + ) + .trim() + ); + }); + + it('should extract an assigned value rule with an instant value', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[0]); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); + const expectedRule = new ExportableAssignmentRule('effectiveInstant'); + expectedRule.value = '2020-07-24T09:31:23.745-04:00'; + expect(assignmentRules).toHaveLength(1); + expect(assignmentRules[0]).toEqual(expectedRule); + expect(element.processedPaths).toContain('patternInstant'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should extract an assigned value with a dateTime value', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[1]); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); + const expectedRule = new ExportableAssignmentRule('valueDateTime'); + expectedRule.value = '2013-01-01T00:00:00.000Z'; + expect(assignmentRules).toHaveLength(1); + expect(assignmentRules[0]).toEqual(expectedRule); + expect(element.processedPaths).toContain('patternDateTime'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should extract an assigned value with a time value', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[2]); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); + const expectedRule = new ExportableAssignmentRule('valueTime'); + expectedRule.value = '15:45:00'; + expectedRule.exactly = true; + expect(assignmentRules).toHaveLength(1); + expect(assignmentRules[0]).toEqual(expectedRule); + expect(element.processedPaths).toContain('fixedTime'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should extract an assigned value with a date value', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[3]); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); + const expectedRule = new ExportableAssignmentRule('extension.valueDate'); + expectedRule.value = '2023-09-21'; + expectedRule.exactly = true; + expect(assignmentRules).toHaveLength(1); + expect(assignmentRules[0]).toEqual(expectedRule); + expect(element.processedPaths).toContain('fixedDate'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + }); + + describe('#simple-date-and-time-warnings', () => { + let looseSD: any; + const entityName = 'TemporalWarningValueObservation'; + + beforeAll(() => { + looseSD = JSON.parse( + fs + .readFileSync( + path.join(__dirname, 'fixtures', 'temporal-warning-value-profile.json'), + 'utf-8' + ) + .trim() + ); + }); + + it('should extract an assigned value rule with an incorrectly formatted instant value and log a warning', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[0]); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); + const expectedRule = new ExportableAssignmentRule('effectiveInstant'); + expectedRule.value = '2020-07-24 9:31:23.745-04:00'; + expect(assignmentRules).toHaveLength(1); + expect(assignmentRules[0]).toEqual(expectedRule); + expect(element.processedPaths).toContain('patternInstant'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2020-07-24 9:31:23\.745-04:00 on TemporalWarningValueObservation element effectiveInstant is not a valid FHIR instant/s + ); + }); + + it('should extract an assigned value with an incorrectly formatted dateTime value and log a warning', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[1]); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); + const expectedRule = new ExportableAssignmentRule('valueDateTime'); + expectedRule.value = '2013-01-01 00:00:00.000'; + expect(assignmentRules).toHaveLength(1); + expect(assignmentRules[0]).toEqual(expectedRule); + expect(element.processedPaths).toContain('patternDateTime'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2013-01-01 00:00:00\.000 on TemporalWarningValueObservation element valueDateTime is not a valid FHIR dateTime/s + ); + }); + + it('should extract an assigned value with an incorrectly formatted time value and log a warning', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[2]); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); + const expectedRule = new ExportableAssignmentRule('valueTime'); + expectedRule.value = '15:45'; + expectedRule.exactly = true; + expect(assignmentRules).toHaveLength(1); + expect(assignmentRules[0]).toEqual(expectedRule); + expect(element.processedPaths).toContain('fixedTime'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 15:45 on TemporalWarningValueObservation element valueTime is not a valid FHIR time/s + ); + }); + + it('should extract an assigned value with an incorrectly formatted date value and log a warning', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[3]); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); + const expectedRule = new ExportableAssignmentRule('extension.valueDate'); + expectedRule.value = '2023/09/21'; + expectedRule.exactly = true; + expect(assignmentRules).toHaveLength(1); + expect(assignmentRules[0]).toEqual(expectedRule); + expect(element.processedPaths).toContain('fixedDate'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2023\/09\/21 on TemporalWarningValueObservation element extension\.valueDate is not a valid FHIR date/s + ); + }); + }); + describe('#complex-values', () => { let looseSD: any; + const entityName = 'AssignedValueObservation'; beforeAll(() => { looseSD = JSON.parse( @@ -135,7 +278,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed Coding value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[5]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('dataAbsentReason.coding'); expectedRule.value = new fshtypes.FshCode('DNE', 'http://example.com/codes'); expect(assignmentRules).toHaveLength(1); @@ -148,7 +291,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed CodeableConcept value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[0]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('code'); expectedRule.value = new fshtypes.FshCode( '12343', @@ -165,7 +308,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed Quantity value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[1]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('valueQuantity'); expectedRule.value = new fshtypes.FshQuantity( 1.21, @@ -183,7 +326,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed Quantity value that does not have quantity.value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[1]); delete element.patternQuantity.value; - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('valueQuantity'); expectedRule.value = new fshtypes.FshCode('GW', 'http://unitsofmeasure.org'); expect(assignmentRules).toHaveLength(1); @@ -197,7 +340,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed Ratio value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[2]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('valueRatio'); expectedRule.value = new fshtypes.FshRatio( new fshtypes.FshQuantity(5, new fshtypes.FshCode('cm', 'http://other-units.org')), @@ -219,7 +362,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract assigned value rules when the numerator quantity does not contain a value property', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[2]); delete element.patternRatio.numerator.value; - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedNumeratorRule = new ExportableAssignmentRule('valueRatio.numerator'); expectedNumeratorRule.value = new fshtypes.FshCode('cm', 'http://other-units.org'); const expectedDenominatorRule = new ExportableAssignmentRule('valueRatio.denominator'); @@ -243,7 +386,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract assigned value rules when the denominator quantity does not contain a value property', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[2]); delete element.patternRatio.denominator.value; - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedNumeratorRule = new ExportableAssignmentRule('valueRatio.numerator'); expectedNumeratorRule.value = new fshtypes.FshQuantity( 5, @@ -266,7 +409,7 @@ describe('AssignmentRuleExtractor', () => { it('should extract an assigned value rule with a fixed Reference value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[3]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('subject'); expectedRule.value = new fshtypes.FshReference( 'http://example.com/PaulBunyan', @@ -281,7 +424,7 @@ describe('AssignmentRuleExtractor', () => { it.skip('should extract an assigned value rule with a fixed Instance value', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[4]); - const assignmentRules = AssignmentRuleExtractor.process(element); + const assignmentRules = AssignmentRuleExtractor.process(element, entityName); const expectedRule = new ExportableAssignmentRule('note'); const expectedValue = new fhirtypes.InstanceDefinition(); expectedValue.resourceType = 'Annotation'; diff --git a/test/extractor/CaretValueRuleExtractor.test.ts b/test/extractor/CaretValueRuleExtractor.test.ts index afb5230e..3f56a541 100644 --- a/test/extractor/CaretValueRuleExtractor.test.ts +++ b/test/extractor/CaretValueRuleExtractor.test.ts @@ -677,7 +677,7 @@ describe('CaretValueRuleExtractor', () => { const caretRules = CaretValueRuleExtractor.processConcept( testConcept, - ['testConcept'], + ['#testConcept'], 'testCS', 'CodeSystem', defs @@ -685,6 +685,7 @@ describe('CaretValueRuleExtractor', () => { expect(caretRules).toContainEqual( expect.objectContaining({ path: '', + pathArray: ['#testConcept'], caretPath: 'property[0].valueString' }) ); @@ -708,6 +709,34 @@ describe('CaretValueRuleExtractor', () => { ); }); + it('should log a warning with a concept path when a dateTime value is not correctly formatted', () => { + const testConcept = { + code: 'testConcept', + display: 'Test Concept', + definition: 'A concept, just for tests though', + property: [{ code: 'test', valueDateTime: '2013-01-01 00:00:00.000' }] + }; + const caretRules = CaretValueRuleExtractor.processConcept( + testConcept, + ['#bigConcept', '#testConcept'], + 'testCS', + 'CodeSystem', + defs + ); + expect(caretRules).toContainEqual( + expect.objectContaining({ + path: '', + pathArray: ['#bigConcept', '#testConcept'], + caretPath: 'property[0].valueDateTime', + value: '2013-01-01 00:00:00.000' + }) + ); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2013-01-01 00:00:00\.000 on testCS #bigConcept\.#testConcept element property\[0\]\.valueDateTime is not a valid FHIR dateTime/s + ); + }); + it('should not extract Concept caret rules when the value of the rule is empty', () => { const testConcept = { code: 'testConcept', @@ -1042,5 +1071,19 @@ describe('CaretValueRuleExtractor', () => { 'Value in StructureDefinition ObservationWithCaret for element partOf.base is empty.' ); }); + + it('should log a warning with element paths when a dateTime value is not correctly formatted', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[12]); + const caretRules = CaretValueRuleExtractor.process(element, looseSD, defs); + const expectedRule = new ExportableCaretValueRule('component.valueDateTime'); + expectedRule.caretPath = 'minValueDateTime'; + expectedRule.value = '2013-01-01 00:00:00.000'; + expect(caretRules).toHaveLength(1); + expect(caretRules[0]).toEqual(expectedRule); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2013-01-01 00:00:00\.000 on ObservationWithCaret\.component\.valueDateTime element minValueDateTime is not a valid FHIR dateTime/s + ); + }); }); }); diff --git a/test/extractor/InvariantExtractor.test.ts b/test/extractor/InvariantExtractor.test.ts index 4557cec3..435228e3 100644 --- a/test/extractor/InvariantExtractor.test.ts +++ b/test/extractor/InvariantExtractor.test.ts @@ -6,6 +6,7 @@ import { ExportableAssignmentRule, ExportableInvariant } from '../../src/exporta import { fshtypes } from 'fsh-sushi'; import { FHIRDefinitions } from '../../src/utils'; import { loadTestDefinitions } from '../helpers/loadTestDefinitions'; +import { loggerSpy } from '../helpers/loggerSpy'; const { FshCode } = fshtypes; describe('InvariantExtractor', () => { @@ -19,6 +20,10 @@ describe('InvariantExtractor', () => { ); }); + beforeEach(() => { + loggerSpy.reset(); + }); + it('should extract invariants from an element with one constraint', () => { const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[0]); const invariants = InvariantExtractor.process(element, looseSD, [], defs); @@ -99,6 +104,27 @@ describe('InvariantExtractor', () => { expect(element.processedPaths).toHaveLength(0); }); + it('should emit a warning when a rule value on an invariant is an incorrectly formatted date', () => { + const element = ProcessableElementDefinition.fromJSON(looseSD.differential.element[3]); + const invariants = InvariantExtractor.process(element, looseSD, [], defs); + const subjectInvariant = new ExportableInvariant('zig-6'); + subjectInvariant.severity = new FshCode('warning'); + subjectInvariant.description = 'This constraint has an incorrectly formatted date value.'; + subjectInvariant.expression = 'subject.date.exists()'; + const invExtensionUrl = new ExportableAssignmentRule('human.extension[0].url'); + invExtensionUrl.value = 'http://example.org/SomeExtension'; + const invExtensionValue = new ExportableAssignmentRule('human.extension[0].valueDate'); + invExtensionValue.value = '2023/09/21'; + subjectInvariant.rules.push(invExtensionUrl, invExtensionValue); + + expect(invariants).toHaveLength(1); + expect(invariants).toContainEqual(subjectInvariant); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2023\/09\/21 on ConstrainedObservation\.subject element constraint\[0\]\.human\.extension\[0\]\.valueDate is not a valid FHIR date/s + ); + }); + it('should not extract an invariant and should process paths if an equal invariant already exists', () => { const existingInvariant = new ExportableInvariant('zig-1'); existingInvariant.severity = new FshCode('warning'); diff --git a/test/extractor/fixtures/caret-value-profile.json b/test/extractor/fixtures/caret-value-profile.json index b6695bd6..02eeae49 100644 --- a/test/extractor/fixtures/caret-value-profile.json +++ b/test/extractor/fixtures/caret-value-profile.json @@ -89,10 +89,7 @@ { "id": "Observation.category", "path": "Observation.category", - "alias": [ - "foo", - "bar" - ] + "alias": ["foo", "bar"] }, { "id": "Observation.code", @@ -121,10 +118,7 @@ }, { "code": "baz", - "targetProfile": [ - "profile1", - "profile2" - ], + "targetProfile": ["profile1", "profile2"], "versioning": "specific" } ] @@ -177,7 +171,12 @@ "human": "It must choose to foo" } ] + }, + { + "id": "Observation.component.valueDateTime", + "path": "Observation.component.valueDateTime", + "minValueDateTime": "2013-01-01 00:00:00.000" } ] } -} \ No newline at end of file +} diff --git a/test/extractor/fixtures/obeys-profile.json b/test/extractor/fixtures/obeys-profile.json index c01ce747..34a3beb4 100644 --- a/test/extractor/fixtures/obeys-profile.json +++ b/test/extractor/fixtures/obeys-profile.json @@ -63,6 +63,26 @@ "id": "Observation.issued", "path": "Observation.issued", "min": 1 + }, + { + "id": "Observation.subject", + "path": "Observation.subject", + "constraint": [ + { + "key": "zig-6", + "severity": "warning", + "human": "This constraint has an incorrectly formatted date value.", + "_human": { + "extension": [ + { + "url": "http://example.org/SomeExtension", + "valueDate": "2023/09/21" + } + ] + }, + "expression": "subject.date.exists()" + } + ] } ] } diff --git a/test/extractor/fixtures/temporal-assigned-value-profile.json b/test/extractor/fixtures/temporal-assigned-value-profile.json new file mode 100644 index 00000000..2f29a1a9 --- /dev/null +++ b/test/extractor/fixtures/temporal-assigned-value-profile.json @@ -0,0 +1,26 @@ +{ + "resourceType": "StructureDefinition", + "kind": "resource", + "type": "Observation", + "name": "TemporalAssignedValueObservation", + "differential": { + "element": [ + { + "id": "Observation.effectiveInstant", + "patternInstant": "2020-07-24T09:31:23.745-04:00" + }, + { + "id": "Observation.valueDateTime", + "patternDateTime": "2013-01-01T00:00:00.000Z" + }, + { + "id": "Observation.valueTime", + "fixedTime": "15:45:00" + }, + { + "id": "Observation.extension.valueDate", + "fixedDate": "2023-09-21" + } + ] + } +} diff --git a/test/extractor/fixtures/temporal-warning-value-profile.json b/test/extractor/fixtures/temporal-warning-value-profile.json new file mode 100644 index 00000000..5e2c745c --- /dev/null +++ b/test/extractor/fixtures/temporal-warning-value-profile.json @@ -0,0 +1,26 @@ +{ + "resourceType": "StructureDefinition", + "kind": "resource", + "type": "Observation", + "name": "TemporalWarningValueObservation", + "differential": { + "element": [ + { + "id": "Observation.effectiveInstant", + "patternInstant": "2020-07-24 9:31:23.745-04:00" + }, + { + "id": "Observation.valueDateTime", + "patternDateTime": "2013-01-01 00:00:00.000" + }, + { + "id": "Observation.valueTime", + "fixedTime": "15:45" + }, + { + "id": "Observation.extension.valueDate", + "fixedDate": "2023/09/21" + } + ] + } +} diff --git a/test/processor/InstanceProcessor.test.ts b/test/processor/InstanceProcessor.test.ts index e6ae9377..6e0194a8 100644 --- a/test/processor/InstanceProcessor.test.ts +++ b/test/processor/InstanceProcessor.test.ts @@ -481,6 +481,23 @@ describe('InstanceProcessor', () => { expect(result.rules).toContainEqual(edgesRule); }); + it('should log a warning when a date value is not correctly formatted', () => { + const input = JSON.parse( + fs.readFileSync(path.join(__dirname, 'fixtures', 'invalid-date-patient.json'), 'utf-8') + ); + const result = InstanceProcessor.process(input, simpleIg, defs); + expect(result).toBeInstanceOf(ExportableInstance); + expect(result.name).toBe('invalid-date-patient-of-Patient'); + expect(result.id).toBe('invalid-date-patient'); + const birthDateRule = new ExportableAssignmentRule('birthDate'); + birthDateRule.value = '1985/07/12'; + expect(result.rules).toContainEqual(birthDateRule); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 1985\/07\/12 on Patient invalid-date-patient element birthDate is not a valid FHIR date/s + ); + }); + it('should use entry resource type to determine assignment rule value types on bundle entry resources', () => { const input = JSON.parse( fs.readFileSync(path.join(__dirname, 'fixtures', 'simple-bundle.json'), 'utf-8') diff --git a/test/processor/StructureDefinitionProcessor.test.ts b/test/processor/StructureDefinitionProcessor.test.ts index 6760442d..67ecb978 100644 --- a/test/processor/StructureDefinitionProcessor.test.ts +++ b/test/processor/StructureDefinitionProcessor.test.ts @@ -766,7 +766,7 @@ describe('StructureDefinitionProcessor', () => { }) ?? []; const result = StructureDefinitionProcessor.extractInvariants(input, elements, [], defs); - expect(result).toHaveLength(5); + expect(result).toHaveLength(6); }); }); diff --git a/test/processor/fixtures/invalid-date-patient.json b/test/processor/fixtures/invalid-date-patient.json new file mode 100644 index 00000000..b81a2af1 --- /dev/null +++ b/test/processor/fixtures/invalid-date-patient.json @@ -0,0 +1,5 @@ +{ + "resourceType": "Patient", + "id": "invalid-date-patient", + "birthDate": "1985/07/12" +} diff --git a/test/utils/element.test.ts b/test/utils/element.test.ts index fb32cea1..328b9b17 100644 --- a/test/utils/element.test.ts +++ b/test/utils/element.test.ts @@ -12,6 +12,7 @@ import { getAncestorSliceDefinition } from '../../src/utils'; import { ProcessableElementDefinition, ProcessableStructureDefinition } from '../../src/processor'; +import { loggerSpy } from '../helpers/loggerSpy'; import { loadTestDefinitions } from '../helpers/loadTestDefinitions'; @@ -22,6 +23,10 @@ describe('element', () => { defs = loadTestDefinitions(); }); + beforeEach(() => { + loggerSpy.reset(); + }); + describe('#getPath', () => { // Basic paths it('should get the path for an element', () => { @@ -138,11 +143,13 @@ describe('element', () => { }); describe('#getFSHValue', () => { + const entityName = 'MyObservation'; it('should convert a code value into a FSHCode', () => { const value = getFSHValue( 0, [['type[0].aggregation[0]', 'contained']], 'ElementDefinition', + entityName, defs ); expect(value).toEqual(new fshtypes.FshCode('contained')); @@ -153,10 +160,125 @@ describe('element', () => { 0, [['type[0].profile[0]', 'http://foo.com/bar']], 'ElementDefinition', + entityName, defs ); expect(value).toEqual('http://foo.com/bar'); }); + + it('should get a dateTime value', () => { + const value = getFSHValue( + 0, + [['valueDateTime', '2013-01-01T00:00:00.000Z']], + 'Observation', + entityName, + defs + ); + expect(value).toEqual('2013-01-01T00:00:00.000Z'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should emit a warning when it gets an incorrectly formatted dateTime value', () => { + const value = getFSHValue( + 0, + [['valueDateTime', '2013-01-01 00:00:00.000']], + 'Observation', + entityName, + defs + ); + expect(value).toEqual('2013-01-01 00:00:00.000'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2013-01-01 00:00:00\.000 on MyObservation element valueDateTime is not a valid FHIR dateTime/s + ); + }); + + it('should get a date value', () => { + const value = getFSHValue( + 0, + [['extension.valueDate', '2023-09-21']], + 'Observation', + entityName, + defs + ); + expect(value).toEqual('2023-09-21'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should emit a warning when it gets an incorrectly formatted date value', () => { + const value = getFSHValue( + 0, + [['extension.valueDate', '2023/09/21']], + 'Observation', + entityName, + defs + ); + expect(value).toEqual('2023/09/21'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2023\/09\/21 on MyObservation element extension\.valueDate is not a valid FHIR date/s + ); + }); + + it('should get a time value', () => { + const value = getFSHValue(0, [['valueTime', '15:45:00']], 'Observation', entityName, defs); + expect(value).toEqual('15:45:00'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should emit a warning when it gets an incorrectly formatted time value', () => { + const value = getFSHValue(0, [['valueTime', '15:45']], 'Observation', entityName, defs); + expect(value).toEqual('15:45'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 15:45 on MyObservation element valueTime is not a valid FHIR time/s + ); + }); + + it('should get a instant value', () => { + const value = getFSHValue( + 0, + [['effectiveInstant', '2020-07-24T09:31:23.745-04:00']], + 'Observation', + entityName, + defs + ); + expect(value).toEqual('2020-07-24T09:31:23.745-04:00'); + expect(loggerSpy.getAllMessages()).toHaveLength(0); + }); + + it('should emit a warning when it gets an incorrectly formatted instant value', () => { + const value = getFSHValue( + 0, + [['effectiveInstant', '2020-07-24 9:31:23.745-04:00']], + 'Observation', + entityName, + defs + ); + expect(value).toEqual('2020-07-24 9:31:23.745-04:00'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2020-07-24 9:31:23\.745-04:00 on MyObservation element effectiveInstant is not a valid FHIR instant/s + ); + }); + + it('should emit a warning with the full path to the contained resource element when it gets an incorrectly formatted dateTime value', () => { + const value = getFSHValue( + 0, + [ + ['entry[0].resource.valueDateTime', '2013-01-01 00:00:00.000'], + ['entry[0].resource.resourceType', 'Observation'] + ], + 'Bundle', + 'MyBundle', + defs + ); + expect(value).toEqual('2013-01-01 00:00:00.000'); + expect(loggerSpy.getAllMessages('warn')).toHaveLength(1); + expect(loggerSpy.getLastMessage('warn')).toMatch( + /Value 2013-01-01 00:00:00\.000 on MyBundle\.entry\[0\]\.resource element valueDateTime is not a valid FHIR dateTime/s + ); + }); }); describe('#getAncestorElement', () => {