diff --git a/src/converter/choice-handler.ts b/src/converter/choice-handler.ts index e612384..7f88cd5 100644 --- a/src/converter/choice-handler.ts +++ b/src/converter/choice-handler.ts @@ -24,6 +24,84 @@ function canonicalToName(url: string): string { return parts[parts.length - 1]; } +function isChoiceTypeSlicingRoot(element: StructureDefinitionElement): boolean { + if (!element.path.endsWith('[x]') || element.sliceName) return false; + const discriminator = element.slicing?.discriminator; + return ( + !!discriminator && + discriminator.length === 1 && + discriminator[0].type === 'type' && + discriminator[0].path.trim() === '$this' + ); +} + +/** + * Collapse type slicing on choice elements ($this type discriminator) into the + * plain choice representation: the sliced element becomes the choice declaration + * (accumulating every variant in `choices` and keeping the slicing metadata so + * open/closed rules survive), and each slice becomes a regular typed variant + * element. Children of a slice are re-parented under its variant element. + */ +export function collapseChoiceTypeSlicing( + elements: StructureDefinitionElement[], +): StructureDefinitionElement[] { + const result: StructureDefinitionElement[] = []; + let i = 0; + + while (i < elements.length) { + const element = elements[i]; + if (!isChoiceTypeSlicingRoot(element)) { + result.push(element); + i++; + continue; + } + + const rootPath = element.path; + const basePath = rootPath.replace(/\[x\]$/, ''); + const fieldName = basePath.split('.').pop() || ''; + const { slicing, type, ...rootRest } = element; + + const choices = (type || []).map((t) => fieldName + capitalize(canonicalToName(t.code))); + const slicedVariants = new Set(); + const collapsed: StructureDefinitionElement[] = []; + let variantPath: string | undefined; + + let j = i + 1; + for (; j < elements.length && elements[j].path.startsWith(rootPath); j++) { + const el = elements[j]; + if (el.path === rootPath && el.sliceName && el.type?.length === 1) { + const typeName = capitalize(canonicalToName(el.type[0].code)); + const variantName = fieldName + typeName; + variantPath = basePath + typeName; + if (!choices.includes(variantName)) choices.push(variantName); + slicedVariants.add(variantName); + const { sliceName, ...sliceRest } = el; + collapsed.push({ ...sliceRest, path: variantPath, choiceOf: fieldName }); + } else if (el.path.startsWith(`${rootPath}.`) && variantPath) { + collapsed.push({ ...el, path: variantPath + el.path.slice(rootPath.length) }); + } else { + collapsed.push(el); + variantPath = undefined; + } + } + + result.push({ ...rootRest, path: basePath, choices, _choiceSlicing: slicing }); + + // Explicit root types without a matching slice keep plain variant elements, + // mirroring expandChoiceElement for unsliced choice elements. + for (const t of type || []) { + const typeName = capitalize(canonicalToName(t.code)); + if (slicedVariants.has(fieldName + typeName)) continue; + const { binding, ...variantRest } = rootRest; + result.push({ ...variantRest, path: basePath + typeName, type: [t], choiceOf: fieldName }); + } + result.push(...collapsed); + i = j; + } + + return result; +} + export function expandChoiceElement( element: StructureDefinitionElement, ): StructureDefinitionElement[] { diff --git a/src/converter/index.ts b/src/converter/index.ts index 09b7197..85130ea 100644 --- a/src/converter/index.ts +++ b/src/converter/index.ts @@ -1,5 +1,9 @@ import { calculateActions } from './action-calculator.js'; -import { expandChoiceElement, isChoiceElement } from './choice-handler.js'; +import { + collapseChoiceTypeSlicing, + expandChoiceElement, + isChoiceElement, +} from './choice-handler.js'; import { transformElement } from './element-transformer.js'; import { enrichPath, parsePath } from './path-parser.js'; import { applyActions } from './stack-processor.js'; @@ -185,7 +189,7 @@ export function translate( } const header = buildResourceHeader(structureDefinition, context); - const elements = getDifferential(structureDefinition); + const elements = collapseChoiceTypeSlicing(getDifferential(structureDefinition)); // Note: Root element constraints are not included in the output @@ -217,8 +221,16 @@ export function translate( const actions = calculateActions(prevPath, enrichedPath); // Transform element - const transformedElement = transformElement(element, structureDefinition); - const elementWithIndex: Record = { ...transformedElement, index: index++ }; + const transformedElement = transformElement(element, structureDefinition) as Record< + string, + unknown + >; + // Slicing metadata from collapsed choice type slicing becomes plain slicing + // on the choice declaration (rules/discriminator, no slices) + const { _choiceSlicing, ...transformedRest } = transformedElement; + const elementWithIndex: Record = _choiceSlicing + ? { ...transformedRest, slicing: _choiceSlicing, index: index++ } + : { ...transformedRest, index: index++ }; // Apply actions stack = applyActions(stack, actions, elementWithIndex); diff --git a/test/unit/converter.test.ts b/test/unit/converter.test.ts index b679244..84fe596 100644 --- a/test/unit/converter.test.ts +++ b/test/unit/converter.test.ts @@ -998,4 +998,69 @@ describe('Converter Algorithm Tests', () => { }); }); }); + + describe('choice type slicing', () => { + // Mirrors profiles like KBV_PR_Base_Condition_Diagnosis (kbv.basis), which + // slice a choice element by type ($this discriminator) with open rules to + // attach per-variant constraints without restricting the choice. + const openOnsetSlicing: Partial[] = [ + { + path: 'Test.onset[x]', + slicing: { discriminator: [{ type: 'type', path: '$this' }], rules: 'open' }, + }, + { + path: 'Test.onset[x]', + sliceName: 'onsetPeriod', + min: 0, + max: '1', + type: [{ code: 'Period' }], + }, + { + path: 'Test.onset[x]', + sliceName: 'onsetAge', + min: 0, + max: '1', + type: [{ code: 'Age' }], + }, + ]; + + it('keeps every declared slice variant as a choice', () => { + const result = translate(createTestStructureDefinition(openOnsetSlicing)); + + expect(result.elements?.onset?.choices).toEqual(['onsetPeriod', 'onsetAge']); + expect(result.elements?.onsetPeriod).toMatchObject({ type: 'Period', choiceOf: 'onset' }); + expect(result.elements?.onsetAge).toMatchObject({ type: 'Age', choiceOf: 'onset' }); + }); + + it('preserves open slicing rules and discriminator on the choice element', () => { + const result = translate(createTestStructureDefinition(openOnsetSlicing)); + + expect(result.elements?.onset?.slicing?.rules).toBe('open'); + expect(result.elements?.onset?.slicing?.discriminator).toEqual([ + { type: 'type', path: '$this' }, + ]); + }); + + it('preserves closed slicing rules on the choice element', () => { + // Closed type slicing (e.g. KBV Apgar Score value[x]) forbids variants + // without a slice, so consumers must be able to see rules: closed. + const result = translate( + createTestStructureDefinition([ + { + path: 'Test.value[x]', + slicing: { discriminator: [{ type: 'type', path: '$this' }], rules: 'closed' }, + type: [{ code: 'CodeableConcept' }], + }, + { + path: 'Test.value[x]', + sliceName: 'valueCodeableConcept', + max: '1', + type: [{ code: 'CodeableConcept' }], + }, + ]), + ); + + expect(result.elements?.value?.slicing?.rules).toBe('closed'); + }); + }); });