Skip to content
Merged
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
23 changes: 16 additions & 7 deletions src/converter/choice-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ function isChoiceTypeSlicingRoot(element: StructureDefinitionElement): boolean {
/**
* 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.
* (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.
*
* `choices` is derived only from the explicit ElementDefinition.type ceiling —
* never from slice names. Slices constrain types the element already allows, so
* for a typed root the slice set adds nothing, and for an untyped root under
* open rules the slices are additive documentation: emitting them as `choices`
* would misstate the allowed set (the inherited ceiling stays in effect).
*/
export function collapseChoiceTypeSlicing(
elements: StructureDefinitionElement[],
Expand Down Expand Up @@ -71,10 +77,8 @@ export function collapseChoiceTypeSlicing(
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);
slicedVariants.add(fieldName + typeName);
const { sliceName, ...sliceRest } = el;
collapsed.push({ ...sliceRest, path: variantPath, choiceOf: fieldName });
} else if (el.path.startsWith(`${rootPath}.`) && variantPath) {
Expand All @@ -85,7 +89,12 @@ export function collapseChoiceTypeSlicing(
}
}

result.push({ ...rootRest, path: basePath, choices, _choiceSlicing: slicing });
result.push({
...rootRest,
path: basePath,
...(choices.length > 0 ? { choices } : {}),
_choiceSlicing: slicing,
});

// Explicit root types without a matching slice keep plain variant elements,
// mirroring expandChoiceElement for unsliced choice elements.
Expand Down
66 changes: 64 additions & 2 deletions test/unit/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,14 +1024,44 @@ describe('Converter Algorithm Tests', () => {
},
];

it('keeps every declared slice variant as a choice', () => {
it('materializes slice variants without narrowing choices for an untyped root', () => {
const result = translate(createTestStructureDefinition(openOnsetSlicing));

expect(result.elements?.onset?.choices).toEqual(['onsetPeriod', 'onsetAge']);
// Open slices are additive: without an explicit type ceiling on the root,
// the inherited choice set stays in effect, so no `choices` is emitted.
expect(result.elements?.onset?.choices).toBeUndefined();
expect(result.elements?.onsetPeriod).toMatchObject({ type: 'Period', choiceOf: 'onset' });
expect(result.elements?.onsetAge).toMatchObject({ type: 'Age', choiceOf: 'onset' });
});

it('derives choices from the explicit type ceiling of a sliced root', () => {
const result = translate(
createTestStructureDefinition([
{
path: 'Test.value[x]',
slicing: { discriminator: [{ type: 'type', path: '$this' }], rules: 'open' },
type: [{ code: 'Quantity' }, { code: 'CodeableConcept' }],
},
{
path: 'Test.value[x]',
sliceName: 'valueQuantity',
max: '1',
type: [{ code: 'Quantity' }],
},
]),
);

expect(result.elements?.value?.choices).toEqual(['valueQuantity', 'valueCodeableConcept']);
expect(result.elements?.valueQuantity).toMatchObject({
type: 'Quantity',
choiceOf: 'value',
});
expect(result.elements?.valueCodeableConcept).toMatchObject({
type: 'CodeableConcept',
choiceOf: 'value',
});
});

it('preserves open slicing rules and discriminator on the choice element', () => {
const result = translate(createTestStructureDefinition(openOnsetSlicing));

Expand Down Expand Up @@ -1062,5 +1092,37 @@ describe('Converter Algorithm Tests', () => {

expect(result.elements?.value?.slicing?.rules).toBe('closed');
});

it('emits no choices for an untyped closed-sliced root', () => {
// Without a type restatement on the root, `choices` stays absent even
// under closed rules: the materialized slice variants plus rules: closed
// are the signal that unlisted variants are forbidden. Consumers must
// intersect with the variant elements, not with `choices`.
const result = translate(
createTestStructureDefinition([
{
path: 'Test.onset[x]',
slicing: { discriminator: [{ type: 'type', path: '$this' }], rules: 'closed' },
},
{
path: 'Test.onset[x]',
sliceName: 'onsetPeriod',
max: '1',
type: [{ code: 'Period' }],
},
{
path: 'Test.onset[x]',
sliceName: 'onsetAge',
max: '1',
type: [{ code: 'Age' }],
},
]),
);

expect(result.elements?.onset?.choices).toBeUndefined();
expect(result.elements?.onset?.slicing?.rules).toBe('closed');
expect(result.elements?.onsetPeriod).toMatchObject({ type: 'Period', choiceOf: 'onset' });
expect(result.elements?.onsetAge).toMatchObject({ type: 'Age', choiceOf: 'onset' });
});
});
});