Skip to content

Commit 8a9fa17

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 8a9fa17

4 files changed

Lines changed: 363 additions & 49 deletions

File tree

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

Lines changed: 295 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
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';
711

812
describe('ChoiceSelectionService', () => {
913
let document: XmlSchemaDocument;
@@ -145,7 +149,7 @@ describe('ChoiceSelectionService', () => {
145149
expect(document.definition.choiceSelections?.length).toBe(2);
146150
});
147151

148-
it('should cascade invalidate true descendant selections', () => {
152+
it('should preserve descendant choice selections', () => {
149153
const parentChoice = document.fields[0].fields[0];
150154
const emailMember = parentChoice.fields[0];
151155
const nestedChoice = emailMember.fields[0];
@@ -155,9 +159,9 @@ describe('ChoiceSelectionService', () => {
155159

156160
ChoiceSelectionService.setChoiceSelection(document, parentChoice, 1, namespaceMap);
157161

158-
expect(document.definition.choiceSelections?.length).toBe(1);
159-
expect(document.definition.choiceSelections?.[0].schemaPath).not.toContain('email');
160-
expect(nestedChoice.selectedMemberIndex).toBeUndefined();
162+
// Both parent and nested selections preserved
163+
expect(document.definition.choiceSelections?.length).toBe(2);
164+
expect(nestedChoice.selectedMemberIndex).toBe(0);
161165
});
162166
});
163167

@@ -214,7 +218,7 @@ describe('ChoiceSelectionService', () => {
214218
expect(document.definition.choiceSelections?.[0].schemaPath).toContain('Container');
215219
});
216220

217-
it('should cascade invalidate true descendant selections on clear', () => {
221+
it('should preserve descendant choice selections on clear', () => {
218222
const parentChoice = document.fields[0].fields[0];
219223
const emailMember = parentChoice.fields[0];
220224
const nestedChoice = emailMember.fields[0];
@@ -225,8 +229,291 @@ describe('ChoiceSelectionService', () => {
225229

226230
ChoiceSelectionService.clearChoiceSelection(document, parentChoice, namespaceMap);
227231

228-
expect(document.definition.choiceSelections?.length).toBe(0);
229-
expect(nestedChoice.selectedMemberIndex).toBeUndefined();
232+
// Nested selection preserved
233+
expect(document.definition.choiceSelections?.length).toBe(1);
234+
expect(nestedChoice.selectedMemberIndex).toBe(1);
235+
});
236+
});
237+
238+
describe('choice + descendant override interaction (issue #3234)', () => {
239+
const NS_CHOICE_ABSTRACT = 'http://www.example.com/CHOICE_ABSTRACT';
240+
241+
function createChoiceWithAbstractDoc() {
242+
const definition = new DocumentDefinition(
243+
DocumentType.SOURCE_BODY,
244+
DocumentDefinitionType.XML_SCHEMA,
245+
'test-doc',
246+
{ 'ChoiceWithAbstract.xsd': getChoiceWithAbstractXsd() },
247+
);
248+
definition.rootElementChoice = { namespaceUri: NS_CHOICE_ABSTRACT, name: 'Notification' };
249+
const result = XmlSchemaDocumentService.createXmlSchemaDocument(definition);
250+
if (result.validationStatus !== 'success' || !result.document) {
251+
throw new Error(
252+
result.errors?.map((e) => e.message).join('; ') || 'Failed to create choice+abstract test document',
253+
);
254+
}
255+
return result.document;
256+
}
257+
258+
function findChoiceField(doc: XmlSchemaDocument): XmlSchemaField {
259+
const root = doc.fields[0];
260+
const choice = root.fields.find((f) => f.wrapperKind === 'choice');
261+
if (!choice) throw new Error('Choice field not found in Notification');
262+
return choice;
263+
}
264+
265+
function findAbstractField(choiceField: IField): XmlSchemaField {
266+
const abstract = choiceField.fields.find((f) => f.wrapperKind === 'abstract');
267+
if (!abstract) throw new Error('Abstract field not found inside choice');
268+
return abstract as XmlSchemaField;
269+
}
270+
271+
describe('abstract substitution preserved across choice changes', () => {
272+
it('should preserve descendant abstract substitution when setting choice selection', () => {
273+
const doc = createChoiceWithAbstractDoc();
274+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
275+
const choiceField = findChoiceField(doc);
276+
const abstractField = findAbstractField(choiceField);
277+
278+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
279+
const emailIndex = abstractField.selectedMemberIndex;
280+
expect(emailIndex).toBeDefined();
281+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
282+
283+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
284+
285+
// Substitution stays in both definition and live field — consistent state
286+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
287+
expect(abstractField.selectedMemberIndex).toBe(emailIndex);
288+
});
289+
290+
it('should preserve descendant abstract substitution when clearing choice selection', () => {
291+
const doc = createChoiceWithAbstractDoc();
292+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
293+
const choiceField = findChoiceField(doc);
294+
const abstractField = findAbstractField(choiceField);
295+
296+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
297+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:SMS', nsMap);
298+
const smsIndex = abstractField.selectedMemberIndex;
299+
expect(smsIndex).toBeDefined();
300+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
301+
302+
ChoiceSelectionService.clearChoiceSelection(doc, choiceField, nsMap);
303+
304+
// Substitution preserved
305+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
306+
expect(abstractField.selectedMemberIndex).toBe(smsIndex);
307+
});
308+
309+
it('should allow reverting abstract substitution after parent choice selection (issue #3234)', () => {
310+
const doc = createChoiceWithAbstractDoc();
311+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
312+
const choiceField = findChoiceField(doc);
313+
const abstractField = findAbstractField(choiceField);
314+
315+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
316+
expect(abstractField.selectedMemberIndex).toBeDefined();
317+
318+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
319+
320+
// Substitution is preserved, so revert should work
321+
FieldOverrideService.revertFieldSubstitution(abstractField, nsMap);
322+
323+
expect(abstractField.selectedMemberIndex).toBeUndefined();
324+
expect(doc.definition.fieldSubstitutions ?? []).toHaveLength(0);
325+
});
326+
327+
it('should allow switching substitution after choice selection', () => {
328+
const doc = createChoiceWithAbstractDoc();
329+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
330+
const choiceField = findChoiceField(doc);
331+
const abstractField = findAbstractField(choiceField);
332+
333+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
334+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
335+
336+
// Switch from Email to SMS
337+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:SMS', nsMap);
338+
339+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
340+
expect(doc.definition.fieldSubstitutions![0].name).toBe('ca:SMS');
341+
const smsIndex = abstractField.fields.findIndex((f) => f.name === 'SMS');
342+
expect(abstractField.selectedMemberIndex).toBe(smsIndex);
343+
});
344+
345+
it('should allow revert after switching substitution across choice changes', () => {
346+
const doc = createChoiceWithAbstractDoc();
347+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
348+
const choiceField = findChoiceField(doc);
349+
const abstractField = findAbstractField(choiceField);
350+
351+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
352+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
353+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:SMS', nsMap);
354+
355+
FieldOverrideService.revertFieldSubstitution(abstractField, nsMap);
356+
357+
expect(abstractField.selectedMemberIndex).toBeUndefined();
358+
expect(doc.definition.fieldSubstitutions).toHaveLength(0);
359+
});
360+
});
361+
362+
describe('type override preserved across choice changes', () => {
363+
function findDescendantLeaf(field: IField): IField {
364+
let current = field;
365+
while (current.fields.length > 0) {
366+
current = current.fields[0];
367+
}
368+
return current;
369+
}
370+
371+
it('should preserve descendant type override when setting choice selection', () => {
372+
const doc = createChoiceWithAbstractDoc();
373+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
374+
const choiceField = findChoiceField(doc);
375+
376+
const firstMember = choiceField.fields[0];
377+
const leafField = findDescendantLeaf(firstMember);
378+
379+
const leafPath = SchemaPathService.build(leafField, nsMap);
380+
leafField.typeOverride = FieldOverrideVariant.SAFE;
381+
leafField.originalField = { name: leafField.name, type: leafField.type };
382+
doc.definition.fieldTypeOverrides = [{ schemaPath: leafPath, typeString: 'xs:int' }];
383+
384+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
385+
386+
expect(doc.definition.fieldTypeOverrides).toHaveLength(1);
387+
expect(leafField.typeOverride).toBe(FieldOverrideVariant.SAFE);
388+
});
389+
390+
it('should preserve descendant type override when clearing choice selection', () => {
391+
const doc = createChoiceWithAbstractDoc();
392+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
393+
const choiceField = findChoiceField(doc);
394+
395+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
396+
397+
const firstMember = choiceField.fields[0];
398+
const leafField = findDescendantLeaf(firstMember);
399+
400+
const leafPath = SchemaPathService.build(leafField, nsMap);
401+
leafField.typeOverride = FieldOverrideVariant.SAFE;
402+
leafField.originalField = { name: leafField.name, type: leafField.type };
403+
doc.definition.fieldTypeOverrides = [{ schemaPath: leafPath, typeString: 'xs:int' }];
404+
405+
ChoiceSelectionService.clearChoiceSelection(doc, choiceField, nsMap);
406+
407+
expect(doc.definition.fieldTypeOverrides).toHaveLength(1);
408+
expect(leafField.typeOverride).toBe(FieldOverrideVariant.SAFE);
409+
});
410+
});
411+
412+
describe('round-trip — overrides survive choice switch and switch-back (issue #3232)', () => {
413+
it('should preserve nested choice selection across parent choice round-trip', () => {
414+
const doc = createTestDocumentWithChoices();
415+
const nsMap = { xs: NS_XML_SCHEMA, ns0: 'io.kaoto.test' };
416+
const parentChoice = doc.fields[0].fields[0]; // [email, phone, fax]
417+
const emailMember = parentChoice.fields[0];
418+
const nestedChoice = emailMember.fields[0]; // [work, personal]
419+
420+
// Select nested choice inside email
421+
ChoiceSelectionService.setChoiceSelection(doc, parentChoice, 0, nsMap);
422+
ChoiceSelectionService.setChoiceSelection(doc, nestedChoice, 1, nsMap);
423+
expect(nestedChoice.selectedMemberIndex).toBe(1);
424+
425+
// Switch parent away from email
426+
ChoiceSelectionService.setChoiceSelection(doc, parentChoice, 2, nsMap);
427+
428+
// Switch parent back to email
429+
ChoiceSelectionService.setChoiceSelection(doc, parentChoice, 0, nsMap);
430+
431+
// Nested selection survived the round-trip
432+
expect(nestedChoice.selectedMemberIndex).toBe(1);
433+
expect(doc.definition.choiceSelections?.length).toBe(2);
434+
});
435+
436+
it('should preserve substitution across parent choice round-trip', () => {
437+
const doc = createChoiceWithAbstractDoc();
438+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
439+
const choiceField = findChoiceField(doc);
440+
const abstractField = findAbstractField(choiceField);
441+
442+
// Select substitution
443+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
444+
const emailIndex = abstractField.selectedMemberIndex;
445+
446+
// Switch choice away and back
447+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 1, nsMap);
448+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
449+
450+
// Substitution survived
451+
expect(abstractField.selectedMemberIndex).toBe(emailIndex);
452+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
453+
expect(doc.definition.fieldSubstitutions![0].name).toBe('ca:Email');
454+
});
455+
456+
it('should preserve substitution through multiple choice changes', () => {
457+
const doc = createChoiceWithAbstractDoc();
458+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
459+
const choiceField = findChoiceField(doc);
460+
const abstractField = findAbstractField(choiceField);
461+
462+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:SMS', nsMap);
463+
const smsIndex = abstractField.selectedMemberIndex;
464+
465+
// Multiple choice changes
466+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
467+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 1, nsMap);
468+
ChoiceSelectionService.clearChoiceSelection(doc, choiceField, nsMap);
469+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
470+
471+
// Substitution survived all transitions
472+
expect(abstractField.selectedMemberIndex).toBe(smsIndex);
473+
expect(doc.definition.fieldSubstitutions).toHaveLength(1);
474+
});
475+
});
476+
477+
describe('negative cases — sibling overrides preserved', () => {
478+
it('should preserve all substitutions — both descendant and sibling — on choice change', () => {
479+
const doc = createChoiceWithAbstractDoc();
480+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
481+
const choiceField = findChoiceField(doc);
482+
const abstractField = findAbstractField(choiceField);
483+
484+
FieldOverrideService.applyFieldSubstitution(abstractField, 'ca:Email', nsMap);
485+
486+
// Manually add a sibling substitution entry (simulating another abstract field outside this choice)
487+
const siblingPath = '/ca:Notification/ca:SiblingAbstract';
488+
doc.definition.fieldSubstitutions!.push({ schemaPath: siblingPath, name: 'ca:SiblingType' });
489+
expect(doc.definition.fieldSubstitutions).toHaveLength(2);
490+
491+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
492+
493+
// Both substitutions preserved
494+
expect(doc.definition.fieldSubstitutions).toHaveLength(2);
495+
});
496+
497+
it('should not affect sibling type override outside the choice subtree', () => {
498+
const doc = createChoiceWithAbstractDoc();
499+
const nsMap = { ca: NS_CHOICE_ABSTRACT };
500+
const choiceField = findChoiceField(doc);
501+
502+
// Manually add a type override for a sibling field (id field, outside the choice)
503+
const root = doc.fields[0];
504+
const idField = root.fields.find((f) => f.name === 'id');
505+
if (!idField) throw new Error('id field not found');
506+
const idPath = SchemaPathService.build(idField, nsMap);
507+
idField.typeOverride = FieldOverrideVariant.SAFE;
508+
idField.originalField = { name: idField.name, type: idField.type };
509+
doc.definition.fieldTypeOverrides = [{ schemaPath: idPath, typeString: 'xs:int' }];
510+
511+
ChoiceSelectionService.setChoiceSelection(doc, choiceField, 0, nsMap);
512+
513+
// Sibling type override should be preserved
514+
expect(doc.definition.fieldTypeOverrides).toHaveLength(1);
515+
expect(idField.typeOverride).toBe(FieldOverrideVariant.SAFE);
516+
});
230517
});
231518
});
232519
});

0 commit comments

Comments
 (0)