Skip to content

Commit 11976d3

Browse files
mmelkoclaude
andcommitted
fix(DataMapper): Preserve descendant overrides across choice selection changes
Fix #3234 Fix #3232 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4b3dd21 commit 11976d3

4 files changed

Lines changed: 394 additions & 49 deletions

File tree

packages/ui/src/services/document/choice-selection.service.test.ts

Lines changed: 326 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,50 @@
1-
import { DocumentDefinition, DocumentDefinitionType, DocumentType } from '../../models/datamapper';
1+
import { DocumentDefinition, DocumentDefinitionType, DocumentType, IField } from '../../models/datamapper';
22
import { NS_XML_SCHEMA } from '../../models/datamapper/standard-namespaces';
3+
import { FieldOverrideVariant } from '../../models/datamapper/types';
4+
import { getChoiceWithAbstractXsd } from '../../stubs/datamapper/data-mapper';
35
import { XmlSchemaCollection } from '../../xml-schema-ts';
46
import { SchemaPathService } from '../schema-path.service';
57
import { ChoiceSelectionService } from './choice-selection.service';
8+
import { FieldOverrideService } from './field-override.service';
69
import { XmlSchemaDocument, XmlSchemaField } from './xml-schema/xml-schema-document.model';
10+
import { XmlSchemaDocumentService } from './xml-schema/xml-schema-document.service';
11+
12+
const NS_CHOICE_ABSTRACT = 'http://www.example.com/CHOICE_ABSTRACT';
13+
14+
function createChoiceWithAbstractDoc() {
15+
const definition = new DocumentDefinition(DocumentType.SOURCE_BODY, DocumentDefinitionType.XML_SCHEMA, 'test-doc', {
16+
'ChoiceWithAbstract.xsd': getChoiceWithAbstractXsd(),
17+
});
18+
definition.rootElementChoice = { namespaceUri: NS_CHOICE_ABSTRACT, name: 'Notification' };
19+
const result = XmlSchemaDocumentService.createXmlSchemaDocument(definition);
20+
if (result.validationStatus !== 'success' || !result.document) {
21+
throw new Error(
22+
result.errors?.map((e) => e.message).join('; ') || 'Failed to create choice+abstract test document',
23+
);
24+
}
25+
return result.document;
26+
}
27+
28+
function findChoiceField(doc: XmlSchemaDocument): XmlSchemaField {
29+
const root = doc.fields[0];
30+
const choice = root.fields.find((f) => f.wrapperKind === 'choice');
31+
if (!choice) throw new Error('Choice field not found in Notification');
32+
return choice;
33+
}
34+
35+
function findAbstractField(choiceField: IField): XmlSchemaField {
36+
const abstract = choiceField.fields.find((f) => f.wrapperKind === 'abstract');
37+
if (!abstract) throw new Error('Abstract field not found inside choice');
38+
return abstract as XmlSchemaField;
39+
}
40+
41+
function findDescendantLeaf(field: IField): IField {
42+
let current = field;
43+
while (current.fields.length > 0) {
44+
current = current.fields[0];
45+
}
46+
return current;
47+
}
748

849
describe('ChoiceSelectionService', () => {
950
let document: XmlSchemaDocument;
@@ -145,7 +186,7 @@ describe('ChoiceSelectionService', () => {
145186
expect(document.definition.choiceSelections?.length).toBe(2);
146187
});
147188

148-
it('should cascade invalidate true descendant selections', () => {
189+
it('should preserve descendant choice selections', () => {
149190
const parentChoice = document.fields[0].fields[0];
150191
const emailMember = parentChoice.fields[0];
151192
const nestedChoice = emailMember.fields[0];
@@ -155,9 +196,9 @@ describe('ChoiceSelectionService', () => {
155196

156197
ChoiceSelectionService.setChoiceSelection(document, parentChoice, 1, namespaceMap);
157198

158-
expect(document.definition.choiceSelections?.length).toBe(1);
159-
expect(document.definition.choiceSelections?.[0].schemaPath).not.toContain('email');
160-
expect(nestedChoice.selectedMemberIndex).toBeUndefined();
199+
// Both parent and nested selections preserved
200+
expect(document.definition.choiceSelections?.length).toBe(2);
201+
expect(nestedChoice.selectedMemberIndex).toBe(0);
161202
});
162203
});
163204

@@ -214,7 +255,7 @@ describe('ChoiceSelectionService', () => {
214255
expect(document.definition.choiceSelections?.[0].schemaPath).toContain('Container');
215256
});
216257

217-
it('should cascade invalidate true descendant selections on clear', () => {
258+
it('should preserve descendant choice selections on clear', () => {
218259
const parentChoice = document.fields[0].fields[0];
219260
const emailMember = parentChoice.fields[0];
220261
const nestedChoice = emailMember.fields[0];
@@ -225,8 +266,285 @@ describe('ChoiceSelectionService', () => {
225266

226267
ChoiceSelectionService.clearChoiceSelection(document, parentChoice, namespaceMap);
227268

228-
expect(document.definition.choiceSelections?.length).toBe(0);
229-
expect(nestedChoice.selectedMemberIndex).toBeUndefined();
269+
// Nested selection preserved
270+
expect(document.definition.choiceSelections?.length).toBe(1);
271+
expect(nestedChoice.selectedMemberIndex).toBe(1);
272+
});
273+
});
274+
275+
describe('choice + descendant override interaction (issue #3234)', () => {
276+
describe('abstract substitution preserved across choice changes', () => {
277+
it('should preserve descendant abstract substitution when setting choice selection', () => {
278+
const doc = createChoiceWithAbstractDoc();
279+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
280+
const choiceField = findChoiceField(doc);
281+
const abstractField = findAbstractField(choiceField);
282+
283+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
284+
const emailIndex = abstractField.selectedMemberIndex;
285+
expect(emailIndex).toBeDefined();
286+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
287+
288+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
289+
290+
// Substitution stays in both definition and live field — consistent state
291+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
292+
expect(abstractField.selectedMemberIndex).toBe(emailIndex);
293+
});
294+
295+
it('should preserve descendant abstract substitution when clearing choice selection', () => {
296+
const doc = createChoiceWithAbstractDoc();
297+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
298+
const choiceField = findChoiceField(doc);
299+
const abstractField = findAbstractField(choiceField);
300+
301+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
302+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:SMS', nsMap);
303+
const smsIndex = abstractField.selectedMemberIndex;
304+
expect(smsIndex).toBeDefined();
305+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
306+
307+
ChoiceSelectionService.clearChoiceSelection(doc, choiceField, nsMap);
308+
309+
// Substitution preserved
310+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
311+
expect(abstractField.selectedMemberIndex).toBe(smsIndex);
312+
});
313+
314+
it('should allow reverting abstract substitution after parent choice selection (issue #3234)', () => {
315+
const doc = createChoiceWithAbstractDoc();
316+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
317+
const choiceField = findChoiceField(doc);
318+
const abstractField = findAbstractField(choiceField);
319+
320+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
321+
expect(abstractField.selectedMemberIndex).toBeDefined();
322+
323+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
324+
325+
// Substitution is preserved, so revert should work
326+
FieldOverrideService.revertFieldSubstitution(abstractField, nsMap);
327+
328+
expect(abstractField.selectedMemberIndex).toBeUndefined();
329+
expect(doc.definition.fieldSubstitutions ?? []).toHaveLength(0);
330+
});
331+
332+
it('should allow switching substitution after choice selection', () => {
333+
const doc = createChoiceWithAbstractDoc();
334+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
335+
const choiceField = findChoiceField(doc);
336+
const abstractField = findAbstractField(choiceField);
337+
338+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
339+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
340+
341+
// Switch from Email to SMS
342+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:SMS', nsMap);
343+
344+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
345+
expect(doc.definition.fieldSubstitutions![0].name).toBe('ca:SMS');
346+
const smsIndex = abstractField.fields.findIndex((f) => f.name === 'SMS');
347+
expect(abstractField.selectedMemberIndex).toBe(smsIndex);
348+
});
349+
350+
it('should allow revert after switching substitution across choice changes', () => {
351+
const doc = createChoiceWithAbstractDoc();
352+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
353+
const choiceField = findChoiceField(doc);
354+
const abstractField = findAbstractField(choiceField);
355+
356+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
357+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
358+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:SMS', nsMap);
359+
360+
FieldOverrideService.revertFieldSubstitution(abstractField, nsMap);
361+
362+
expect(abstractField.selectedMemberIndex).toBeUndefined();
363+
expect(doc.definition.fieldSubstitutions).toHaveLength(0);
364+
});
365+
});
366+
367+
describe('type override preserved across choice changes', () => {
368+
it('should preserve descendant type override when setting choice selection', () => {
369+
const doc = createChoiceWithAbstractDoc();
370+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
371+
const choiceField = findChoiceField(doc);
372+
373+
const firstMember = choiceField.fields[0];
374+
const leafField = findDescendantLeaf(firstMember);
375+
376+
const leafPath = SchemaPathService.build(leafField, nsMap);
377+
leafField.typeOverride = FieldOverrideVariant.SAFE;
378+
leafField.originalField = {
379+
name: leafField.name,
380+
displayName: leafField.name,
381+
namespaceURI: '',
382+
namespacePrefix: null,
383+
type: leafField.type,
384+
typeQName: null,
385+
namedTypeFragmentRefs: [],
386+
};
387+
doc.definition.fieldTypeOverrides = [
388+
{ schemaPath: leafPath, type: 'xs:int', originalType: 'xs:string', variant: FieldOverrideVariant.SAFE },
389+
];
390+
391+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
392+
393+
expect(doc.definition.fieldTypeOverrides).toHaveLength(1);
394+
expect(leafField.typeOverride).toBe(FieldOverrideVariant.SAFE);
395+
});
396+
397+
it('should preserve descendant type override when clearing choice selection', () => {
398+
const doc = createChoiceWithAbstractDoc();
399+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
400+
const choiceField = findChoiceField(doc);
401+
402+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
403+
404+
const firstMember = choiceField.fields[0];
405+
const leafField = findDescendantLeaf(firstMember);
406+
407+
const leafPath = SchemaPathService.build(leafField, nsMap);
408+
leafField.typeOverride = FieldOverrideVariant.SAFE;
409+
leafField.originalField = {
410+
name: leafField.name,
411+
displayName: leafField.name,
412+
namespaceURI: '',
413+
namespacePrefix: null,
414+
type: leafField.type,
415+
typeQName: null,
416+
namedTypeFragmentRefs: [],
417+
};
418+
doc.definition.fieldTypeOverrides = [
419+
{ schemaPath: leafPath, type: 'xs:int', originalType: 'xs:string', variant: FieldOverrideVariant.SAFE },
420+
];
421+
422+
ChoiceSelectionService.clearChoiceSelection(doc, choiceField, nsMap);
423+
424+
expect(doc.definition.fieldTypeOverrides).toHaveLength(1);
425+
expect(leafField.typeOverride).toBe(FieldOverrideVariant.SAFE);
426+
});
427+
});
428+
429+
describe('round-trip — overrides survive choice switch and switch-back (issue #3232)', () => {
430+
it('should preserve nested choice selection across parent choice round-trip', () => {
431+
const doc = createTestDocumentWithChoices();
432+
const nsMap = { xs: NS_XML_SCHEMA, ns0: 'io.kaoto.test' };
433+
const parentChoice = doc.fields[0].fields[0]; // [email, phone, fax]
434+
const emailMember = parentChoice.fields[0];
435+
const nestedChoice = emailMember.fields[0]; // [work, personal]
436+
437+
// Select nested choice inside email
438+
ChoiceSelectionService.setChoiceSelection(doc, parentChoice, 0, nsMap);
439+
ChoiceSelectionService.setChoiceSelection(doc, nestedChoice, 1, nsMap);
440+
expect(nestedChoice.selectedMemberIndex).toBe(1);
441+
442+
// Switch parent away from email
443+
ChoiceSelectionService.setChoiceSelection(doc, parentChoice, 2, nsMap);
444+
445+
// Switch parent back to email
446+
ChoiceSelectionService.setChoiceSelection(doc, parentChoice, 0, nsMap);
447+
448+
// Nested selection survived the round-trip
449+
expect(nestedChoice.selectedMemberIndex).toBe(1);
450+
expect(doc.definition.choiceSelections?.length).toBe(2);
451+
});
452+
453+
it('should preserve substitution across parent choice round-trip', () => {
454+
const doc = createChoiceWithAbstractDoc();
455+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
456+
const choiceField = findChoiceField(doc);
457+
const abstractField = findAbstractField(choiceField);
458+
459+
// Select substitution
460+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
461+
const emailIndex = abstractField.selectedMemberIndex;
462+
463+
// Switch choice away and back
464+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 1, nsMap);
465+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
466+
467+
// Substitution survived
468+
expect(abstractField.selectedMemberIndex).toBe(emailIndex);
469+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
470+
expect(doc.definition.fieldSubstitutions![0].name).toBe('ca:Email');
471+
});
472+
473+
it('should preserve substitution through multiple choice changes', () => {
474+
const doc = createChoiceWithAbstractDoc();
475+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
476+
const choiceField = findChoiceField(doc);
477+
const abstractField = findAbstractField(choiceField);
478+
479+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:SMS', nsMap);
480+
const smsIndex = abstractField.selectedMemberIndex;
481+
482+
// Multiple choice changes
483+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
484+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 1, nsMap);
485+
ChoiceSelectionService.clearChoiceSelection(doc, choiceField, nsMap);
486+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
487+
488+
// Substitution survived all transitions
489+
expect(abstractField.selectedMemberIndex).toBe(smsIndex);
490+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
491+
});
492+
});
493+
494+
describe('negative cases — sibling overrides preserved', () => {
495+
it('should preserve all substitutions — both descendant and sibling — on choice change', () => {
496+
const doc = createChoiceWithAbstractDoc();
497+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
498+
const choiceField = findChoiceField(doc);
499+
const abstractField = findAbstractField(choiceField);
500+
501+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
502+
503+
// Manually add a sibling substitution entry (simulating another abstract field outside this choice)
504+
const siblingPath = '/ca:Notification/ca:SiblingAbstract';
505+
doc.definition.fieldSubstitutions!.push({
506+
schemaPath: siblingPath,
507+
name: 'ca:SiblingType',
508+
originalName: 'ca:SiblingAbstract',
509+
});
510+
expect(doc.definition.fieldSubstitutions).toHaveLength(2);
511+
512+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
513+
514+
// Both substitutions preserved
515+
expect(doc.definition.fieldSubstitutions).toHaveLength(2);
516+
});
517+
518+
it('should not affect sibling type override outside the choice subtree', () => {
519+
const doc = createChoiceWithAbstractDoc();
520+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
521+
const choiceField = findChoiceField(doc);
522+
523+
// Manually add a type override for a sibling field (id field, outside the choice)
524+
const root = doc.fields[0];
525+
const idField = root.fields.find((f) => f.name === 'id');
526+
if (!idField) throw new Error('id field not found');
527+
const idPath = SchemaPathService.build(idField, nsMap);
528+
idField.typeOverride = FieldOverrideVariant.SAFE;
529+
idField.originalField = {
530+
name: idField.name,
531+
displayName: idField.name,
532+
namespaceURI: '',
533+
namespacePrefix: null,
534+
type: idField.type,
535+
typeQName: null,
536+
namedTypeFragmentRefs: [],
537+
};
538+
doc.definition.fieldTypeOverrides = [
539+
{ schemaPath: idPath, type: 'xs:int', originalType: 'xs:string', variant: FieldOverrideVariant.SAFE },
540+
];
541+
542+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
543+
544+
// Sibling type override should be preserved
545+
expect(doc.definition.fieldTypeOverrides).toHaveLength(1);
546+
expect(idField.typeOverride).toBe(FieldOverrideVariant.SAFE);
547+
});
230548
});
231549
});
232550
});

0 commit comments

Comments
 (0)