diff --git a/src/fhirtypes/StructureDefinition.ts b/src/fhirtypes/StructureDefinition.ts index 5f99be289..abb5f014c 100644 --- a/src/fhirtypes/StructureDefinition.ts +++ b/src/fhirtypes/StructureDefinition.ts @@ -300,7 +300,12 @@ export class StructureDefinition { unfoldedElements = matchingElements[0].unfoldChoiceElementTypes(fisher); newMatchingElements = unfoldedElements.filter(e => e.path.startsWith(fhirPathString)); } - } else if (matchingElements[0].id.endsWith('[x]')) { + } else if ( + matchingElements[0].id.endsWith('[x]') || + (matchingElements[0].path.endsWith('[x]') && matchingElements[0].sliceName != null) + ) { + // The element is a choice element or a slice of a choice element with multiple types. + // Find the common ancestor of all types to navigate into sub-paths (e.g., .extension). unfoldedElements = matchingElements[0].unfoldChoiceElementTypes(fisher); newMatchingElements = unfoldedElements.filter(e => e.path.startsWith(fhirPathString)); } @@ -931,13 +936,17 @@ export class StructureDefinition { findObsoleteChoices(baseElement: ElementDefinition, oldTypes: ElementDefinitionType[]): string[] { // first, find all the elements representing choices for the same choice element const parentSlice = baseElement.parent()?.sliceName; + const baseSliceName = baseElement.sliceName; const choiceElements = this.elements.filter(e => { const eParentSlice = e.parent()?.sliceName; return ( e.path === baseElement.path && (parentSlice == null || parentSlice === eParentSlice || - eParentSlice?.startsWith(`${parentSlice}/`)) + eParentSlice?.startsWith(`${parentSlice}/`)) && + // When baseElement is itself a named slice, only consider its sub-slices, + // not sibling slices at the same level + (baseSliceName == null || e.sliceName?.startsWith(`${baseSliceName}/`)) ); }); const matchedThings: ElementDefinition[] = []; diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts index db0ace444..7e0613e21 100644 --- a/test/export/StructureDefinitionExporter.test.ts +++ b/test/export/StructureDefinitionExporter.test.ts @@ -5442,6 +5442,60 @@ describe('StructureDefinitionExporter R4', () => { expect(loggerSpy.getAllLogs()).toHaveLength(0); }); + it('should not log an error when a rule path has a subpath of a sliced choice element', () => { + loggerSpy.reset(); + const profile = new Profile('ConstrainedObservation'); + profile.parent = 'Observation'; + // * value[x] ^slicing.discriminator[0].type = #type + // * value[x] ^slicing.discriminator[0].path = "$this" + // * value[x] ^slicing.rules = #open + // * value[x] contains valueString 0..1 and valueOther 0..1 + // * value[x][valueString] only string + // * value[x][valueOther] only Quantity or CodeableConcept + // * value[x][valueOther].extension 1..1 + const slicingType = new CaretValueRule('value[x]'); + slicingType.caretPath = 'slicing.discriminator[0].type'; + slicingType.value = new FshCode('type'); + const slicingPath = new CaretValueRule('value[x]'); + slicingPath.caretPath = 'slicing.discriminator[0].path'; + slicingPath.value = '$this'; + const slicingRules = new CaretValueRule('value[x]'); + slicingRules.caretPath = 'slicing.rules'; + slicingRules.value = new FshCode('open'); + const valueXSlices = new ContainsRule('value[x]'); + valueXSlices.items = [{ name: 'valueString' }, { name: 'valueOther' }]; + const stringCard = new CardRule('value[x][valueString]'); + stringCard.min = 0; + stringCard.max = '1'; + const otherCard = new CardRule('value[x][valueOther]'); + otherCard.min = 0; + otherCard.max = '1'; + const stringType = new OnlyRule('value[x][valueString]'); + stringType.types = [{ type: 'string' }]; + const otherType = new OnlyRule('value[x][valueOther]'); + otherType.types = [{ type: 'Quantity' }, { type: 'CodeableConcept' }]; + const otherExtensionCard = new CardRule('value[x][valueOther].extension'); + otherExtensionCard.min = 1; + otherExtensionCard.max = '1'; + + profile.rules.push( + slicingType, + slicingPath, + slicingRules, + valueXSlices, + stringCard, + otherCard, + stringType, + otherType, + otherExtensionCard + ); + + exporter.exportStructDef(profile); + const sd = pkg.profiles[0]; + expect(sd).toBeTruthy(); + expect(loggerSpy.getAllLogs()).toHaveLength(0); + }); + it('should log an error when extension is constrained with a modifier extension', () => { const extension = new Extension('StrangeExtension'); const modifier = new FlagRule('.'); diff --git a/test/import/FSHImporter.SDRules.test.ts b/test/import/FSHImporter.SDRules.test.ts index 728dba128..dcf227c57 100644 --- a/test/import/FSHImporter.SDRules.test.ts +++ b/test/import/FSHImporter.SDRules.test.ts @@ -1728,6 +1728,32 @@ describe('FSHImporter', () => { undefined ); }); + + it('should parse contains rule on a choice with two items', () => { + const input = leftAlign(` + Profile: ObservationProfile + Parent: Observation + * value[x] contains valueString 0..1 and valueOther 0..1 + * value[x][valueString] only string + * value[x][valueOther] only Quantity or CodeableConcept + * value[x][valueOther].extension 1..1 + `); + + const result = importSingleText(input); + const profile = result.profiles.get('ObservationProfile'); + expect(profile.rules).toHaveLength(6); + assertContainsRule(profile.rules[0], 'value[x]', 'valueString', 'valueOther'); + assertCardRule(profile.rules[1], 'value[x][valueString]', 0, 1); + assertCardRule(profile.rules[2], 'value[x][valueOther]', 0, 1); + assertOnlyRule(profile.rules[3], 'value[x][valueString]', { type: 'string' }); + assertOnlyRule( + profile.rules[4], + 'value[x][valueOther]', + { type: 'Quantity' }, + { type: 'CodeableConcept' } + ); + assertCardRule(profile.rules[5], 'value[x][valueOther].extension', 1, 1); + }); }); describe('#caretValueRule', () => { diff --git a/test/tsconfig.json b/test/tsconfig.json index 43e9b46be..822dd6b67 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,9 @@ { - "extends": "../tsconfig", + "extends": "../tsconfig.json", "compilerOptions": { - "strictNullChecks": false + "strictNullChecks": false, + "noEmit": true, + "types": ["jest", "node"] }, "include": [ "./**/*.ts"