diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index be646f7..0c6bef3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–2 complete. +Status: implementation in progress; Stages 1–6 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -312,6 +312,9 @@ for overlays and inheritance. Validation: focused combination tests and the everyday check. Do not change the ordinary recursive mapping rule or add unkeyed append. +**Completion:** Implemented and verified with focused sequence-combination tests +and `./scripts/check.sh`. + ## Stage 7 — Extend Sequence History Representation And Replay Scope: represent and replay sequence operations without yet changing all metadata diff --git a/internal/engine/sequence_combine.go b/internal/engine/sequence_combine.go new file mode 100644 index 0000000..a63f66d --- /dev/null +++ b/internal/engine/sequence_combine.go @@ -0,0 +1,256 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +type sequenceCombinationContext struct { + destination Path +} + +type sequenceItemProjection struct { + inputIndex int + outputIndex int +} + +type sequenceItemReplacement struct { + lowerIndex int + higherIndex int + outputIndex int +} + +type sequenceCombinationResult struct { + value *document.Node + lowerItems []sequenceItemProjection + higherItems []sequenceItemProjection + replacements []sequenceItemReplacement + appendedHigher []int + discardedLower []int +} + +func combineSequence( + lower, higher *document.Node, + policy collectionPolicyResolution, + context sequenceCombinationContext, +) (sequenceCombinationResult, []Diagnostic) { + if higher == nil { + return unchangedSequenceValue(lower), nil + } + switch policy.operation { + case sequenceModeReplace: + return replaceSequence(lower, higher, policy.effectiveIdentityFields, context) + case sequenceModeAppend, sequenceModeUpsert: + return combineIdentifiedSequences(lower, higher, policy.operation, policy.effectiveIdentityFields, context) + default: + return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic( + context.destination, "sequence operation is invalid", higher, + )} + } +} + +func unchangedSequenceValue(lower *document.Node) sequenceCombinationResult { + result := sequenceCombinationResult{value: document.Clone(lower)} + if lower != nil && lower.Kind == document.Sequence { + result.lowerItems = sequenceItemProjections(len(lower.Sequence), 0) + } + return result +} + +func replaceSequence( + lower, higher *document.Node, + identityFields []string, + context sequenceCombinationContext, +) (sequenceCombinationResult, []Diagnostic) { + if higher.Kind == document.Sequence && len(identityFields) != 0 { + if _, diagnostics := validateSequenceIdentity(higher, context.destination, identityFields, sequenceCombinationIdentityCodes); len(diagnostics) != 0 { + return sequenceCombinationResult{}, diagnostics + } + } + result := sequenceCombinationResult{value: document.Clone(higher)} + if higher.Kind == document.Sequence { + result.higherItems = sequenceItemProjections(len(higher.Sequence), 0) + } + if lower != nil && lower.Kind == document.Sequence { + result.discardedLower = sequenceIndexes(len(lower.Sequence)) + } + return result, nil +} + +func combineIdentifiedSequences( + lower, higher *document.Node, + operation sequenceMode, + identityFields []string, + context sequenceCombinationContext, +) (sequenceCombinationResult, []Diagnostic) { + if higher.Kind != document.Sequence { + return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic( + context.destination, "append and upsert require a sequence contribution", higher, + )} + } + if lower != nil && lower.Kind != document.Sequence { + return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic( + context.destination, "append and upsert require a lower sequence or an absent lower value", lower, + )} + } + if len(identityFields) == 0 { + return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic( + context.destination, "append and upsert require an identity definition", higher, + )} + } + + higherIndex, diagnostics := validateSequenceIdentity(higher, context.destination, identityFields, sequenceCombinationIdentityCodes) + if len(diagnostics) != 0 { + return sequenceCombinationResult{}, diagnostics + } + var lowerIndex map[string]sequenceIdentityRecord + if lower != nil { + lowerIndex, diagnostics = validateSequenceIdentity(lower, context.destination, identityFields, sequenceCombinationIdentityCodes) + if len(diagnostics) != 0 { + return sequenceCombinationResult{}, diagnostics + } + } + if operation == sequenceModeAppend { + if duplicate := duplicateAcrossSequenceContributions(lowerIndex, higherIndex, context.destination); duplicate != nil { + return sequenceCombinationResult{}, []Diagnostic{*duplicate} + } + } + + if operation == sequenceModeAppend { + return appendSequenceItems(lower, higher), nil + } + if operation == sequenceModeUpsert { + return upsertSequenceItems(lower, higher, lowerIndex, higherIndex), nil + } + return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic( + context.destination, "sequence operation is invalid", higher, + )} +} + +func appendSequenceItems(lower, higher *document.Node) sequenceCombinationResult { + lowerCount := 0 + if lower != nil { + lowerCount = len(lower.Sequence) + } + items := make([]*document.Node, 0, lowerCount+len(higher.Sequence)) + if lower != nil { + items = append(items, lower.Sequence...) + } + items = append(items, higher.Sequence...) + return sequenceCombinationResult{ + value: cloneSequenceWithItems(higher, items), + lowerItems: sequenceItemProjections(lowerCount, 0), + higherItems: sequenceItemProjections(len(higher.Sequence), lowerCount), + appendedHigher: sequenceIndexes(len(higher.Sequence)), + } +} + +func upsertSequenceItems( + lower, higher *document.Node, + lowerIndex, higherIndex map[string]sequenceIdentityRecord, +) sequenceCombinationResult { + lowerCount := 0 + var lowerItems []*document.Node + if lower != nil { + lowerCount = len(lower.Sequence) + lowerItems = lower.Sequence + } + matchedLower := make(map[int]sequenceIdentityRecord, len(higherIndex)) + matchedLowerByHigher := make([]int, len(higher.Sequence)) + for index := range matchedLowerByHigher { + matchedLowerByHigher[index] = -1 + } + for tuple, incoming := range higherIndex { + if retained, exists := lowerIndex[tuple]; exists { + matchedLower[retained.index] = incoming + matchedLowerByHigher[incoming.index] = retained.index + } + } + items := make([]*document.Node, lowerCount, lowerCount+len(higher.Sequence)) + result := sequenceCombinationResult{ + lowerItems: make([]sequenceItemProjection, 0, lowerCount-len(matchedLower)), + higherItems: sequenceItemProjections(len(higher.Sequence), 0), + } + for lowerPosition, item := range lowerItems { + if incoming, replaced := matchedLower[lowerPosition]; replaced { + items[lowerPosition] = higher.Sequence[incoming.index] + result.higherItems[incoming.index].outputIndex = lowerPosition + result.replacements = append(result.replacements, sequenceItemReplacement{ + lowerIndex: lowerPosition, higherIndex: incoming.index, outputIndex: lowerPosition, + }) + continue + } + items[lowerPosition] = item + result.lowerItems = append(result.lowerItems, sequenceItemProjection{inputIndex: lowerPosition, outputIndex: lowerPosition}) + } + for higherPosition := range higher.Sequence { + if matchedLowerByHigher[higherPosition] >= 0 { + continue + } + outputIndex := len(items) + items = append(items, higher.Sequence[higherPosition]) + result.appendedHigher = append(result.appendedHigher, higherPosition) + result.higherItems[higherPosition].outputIndex = outputIndex + } + result.value = cloneSequenceWithItems(higher, items) + return result +} + +func duplicateAcrossSequenceContributions( + lowerIndex, higherIndex map[string]sequenceIdentityRecord, + path Path, +) *Diagnostic { + higherTuples := make([]string, len(higherIndex)) + for tuple, record := range higherIndex { + higherTuples[record.index] = tuple + } + for _, tuple := range higherTuples { + if lower, exists := lowerIndex[tuple]; exists { + higher := higherIndex[tuple] + primary := publicLocation(lower.location) + related := publicLocation(higher.location) + return &Diagnostic{ + Code: CodeSequenceConflict, Severity: SeverityError, Path: path.clone(), + Message: "sequence contributions contain a duplicate identity", + Primary: &primary, Related: []Location{related}, + } + } + } + return nil +} + +func cloneSequenceWithItems(template *document.Node, items []*document.Node) *document.Node { + cloned := *template + cloned.Mapping = nil + cloned.Sequence = make([]*document.Node, len(items)) + for index, item := range items { + cloned.Sequence[index] = document.Clone(item) + } + return &cloned +} + +func sequenceItemProjections(length, outputOffset int) []sequenceItemProjection { + projections := make([]sequenceItemProjection, length) + for index := range projections { + projections[index] = sequenceItemProjection{inputIndex: index, outputIndex: index + outputOffset} + } + return projections +} + +func sequenceIndexes(length int) []int { + indexes := make([]int, length) + for index := range indexes { + indexes[index] = index + } + return indexes +} + +func sequenceCombinationDiagnostic(path Path, message string, node *document.Node) Diagnostic { + var primary *Location + if node != nil { + location := publicLocation(node.Location) + primary = &location + } + return Diagnostic{Code: CodeSequenceInvalid, Severity: SeverityError, Path: path.clone(), Message: message, Primary: primary} +} + +var sequenceCombinationIdentityCodes = sequenceIdentityDiagnosticCodes{ + invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict, +} diff --git a/internal/engine/sequence_combine_test.go b/internal/engine/sequence_combine_test.go new file mode 100644 index 0000000..eaea064 --- /dev/null +++ b/internal/engine/sequence_combine_test.go @@ -0,0 +1,302 @@ +package engine + +import ( + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestCombineSequenceOperationsAndItemEvidence(t *testing.T) { + tests := []struct { + name string + lower string + higher string + operation sequenceMode + want []string + wantLower []sequenceItemProjection + wantHigher []sequenceItemProjection + wantReplacements []sequenceItemReplacement + wantAppended []int + wantDiscarded []int + }{ + { + name: "replace uses only higher records", + lower: "items: [{id: a}, {id: b}]\n", higher: "items: [{id: b, text: revised}]\n", + operation: sequenceModeReplace, want: []string{"b"}, + wantHigher: []sequenceItemProjection{{inputIndex: 0, outputIndex: 0}}, wantDiscarded: []int{0, 1}, + }, + { + name: "append retains then appends", + lower: "items: [{id: a}, {id: b}]\n", higher: "items: [{id: c}]\n", + operation: sequenceModeAppend, want: []string{"a", "b", "c"}, + wantLower: []sequenceItemProjection{{inputIndex: 0, outputIndex: 0}, {inputIndex: 1, outputIndex: 1}}, + wantHigher: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}}, wantAppended: []int{0}, + }, + { + name: "upsert replaces whole records in place and appends new records", + lower: "items: [{id: a, text: old}, {id: b}, {id: c, remove: true}]\n", + higher: "items: [{id: c, text: revised}, {id: a, text: revised}, {id: d}]\n", + operation: sequenceModeUpsert, want: []string{"a", "b", "c", "d"}, + wantLower: []sequenceItemProjection{{inputIndex: 1, outputIndex: 1}}, + wantHigher: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}, {inputIndex: 1, outputIndex: 0}, {inputIndex: 2, outputIndex: 3}}, + wantReplacements: []sequenceItemReplacement{{lowerIndex: 0, higherIndex: 1, outputIndex: 0}, {lowerIndex: 2, higherIndex: 0, outputIndex: 2}}, + wantAppended: []int{2}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + lower := sequenceFromYAML(t, test.lower) + higher := sequenceFromYAML(t, test.higher) + policy := sequenceCombinationPolicy(test.operation, "id") + result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 { + t.Fatalf("combination diagnostics = %#v", diagnostics) + } + if got := sequenceIdentityValues(result.value, "id"); !reflect.DeepEqual(got, test.want) { + t.Fatalf("combined IDs = %#v, want %#v", got, test.want) + } + if !reflect.DeepEqual(result.lowerItems, test.wantLower) || !reflect.DeepEqual(result.higherItems, test.wantHigher) || !reflect.DeepEqual(result.replacements, test.wantReplacements) || !reflect.DeepEqual(result.appendedHigher, test.wantAppended) || !reflect.DeepEqual(result.discardedLower, test.wantDiscarded) { + t.Fatalf("item evidence = %#v", result) + } + }) + } +} + +func TestCombineSequenceDistinguishesAbsenceFromNullAndKindMismatch(t *testing.T) { + policy := sequenceCombinationPolicy(sequenceModeAppend, "id") + retained := sequenceFromYAML(t, "items: [{id: lower}]\n") + unchanged, diagnostics := combineSequence(retained, nil, policy, sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(unchanged.value, "id"), []string{"lower"}) || len(unchanged.lowerItems) != 1 { + t.Fatalf("absent higher value changed lower sequence: %#v, %#v", unchanged, diagnostics) + } + + tests := []struct { + name string + lower string + higher string + }{ + {name: "null higher value", lower: "items: [{id: lower}]\n", higher: "items: null\n"}, + {name: "mapping higher value", lower: "items: [{id: lower}]\n", higher: "items: {id: higher}\n"}, + {name: "null lower value", lower: "items: null\n", higher: "items: []\n"}, + {name: "scalar lower value", lower: "items: blocked\n", higher: "items: []\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, issues := combineSequence(sequenceFromYAML(t, test.lower), sequenceFromYAML(t, test.higher), policy, sequenceCombinationContext{destination: Keys("items")}) + if result.value != nil || len(issues) != 1 || issues[0].Code != CodeSequenceInvalid || issues[0].Path.String() != "/items" { + t.Fatalf("kind mismatch result = %#v, %#v", result, issues) + } + }) + } + + for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} { + result, issues := combineSequence(nil, sequenceFromYAML(t, "items: null\n"), sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")}) + if result.value != nil || len(issues) != 1 || issues[0].Code != CodeSequenceInvalid { + t.Fatalf("null incoming value under operation %d = %#v, %#v", operation, result, issues) + } + } +} + +func TestReplaceSequenceAllowsNullAndNonSequenceValues(t *testing.T) { + for _, test := range []struct { + name string + higher string + wantKind document.Kind + }{ + {name: "null", higher: "items: null\n", wantKind: document.Null}, + {name: "mapping", higher: "items: {value: replacement}\n", wantKind: document.Mapping}, + } { + t.Run(test.name, func(t *testing.T) { + lower := sequenceFromYAML(t, "items: [{id: lower}]\n") + higher := sequenceFromYAML(t, test.higher) + result, diagnostics := combineSequence(lower, higher, sequenceCombinationPolicy(sequenceModeReplace), sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 || result.value == nil || result.value.Kind != test.wantKind || !reflect.DeepEqual(result.discardedLower, []int{0}) { + t.Fatalf("replacement result = %#v, %#v", result, diagnostics) + } + }) + } + result, diagnostics := combineSequence(sequenceFromYAML(t, "items: null\n"), sequenceFromYAML(t, "items: [{id: new}]\n"), sequenceCombinationPolicy(sequenceModeReplace), sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "id"), []string{"new"}) { + t.Fatalf("replacement over explicit lower null = %#v, %#v", result, diagnostics) + } +} + +func TestCombineSequenceRequiresIdentityForAppendAndUpsertEvenWhenEmpty(t *testing.T) { + for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} { + result, diagnostics := combineSequence(nil, sequenceFromYAML(t, "items: []\n"), sequenceCombinationPolicy(operation), sequenceCombinationContext{destination: Keys("items")}) + if result.value != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid { + t.Fatalf("empty input bypassed identity requirement for operation %d: %#v, %#v", operation, result, diagnostics) + } + } + for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} { + result, diagnostics := combineSequence(nil, sequenceFromYAML(t, "items: [{name: no-id}]\n"), sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")}) + if result.value != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/items/0/id" { + t.Fatalf("missing identity for operation %d = %#v, %#v", operation, result, diagnostics) + } + } +} + +func TestCombineSequenceValidatesDuplicatesWithinEachContributionInEveryMode(t *testing.T) { + duplicate := "items: [{id: same}, {id: same}]\n" + for _, operation := range []sequenceMode{sequenceModeReplace, sequenceModeAppend, sequenceModeUpsert} { + _, diagnostics := combineSequence(nil, sequenceFromYAML(t, duplicate), sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict { + t.Fatalf("higher duplicate under operation %d = %#v", operation, diagnostics) + } + } + for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} { + _, diagnostics := combineSequence(sequenceFromYAML(t, duplicate), sequenceFromYAML(t, "items: [{id: other}]\n"), sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict { + t.Fatalf("lower duplicate under operation %d = %#v", operation, diagnostics) + } + } +} + +func TestAppendSequenceRejectsDuplicatesAcrossContributions(t *testing.T) { + const identityCanary = "identity-canary-64" + lower := sequenceFromYAML(t, "items: [{id: "+identityCanary+"}]\n") + higher := sequenceFromYAML(t, "items: [{id: "+identityCanary+"}]\n") + _, diagnostics := combineSequence(lower, higher, sequenceCombinationPolicy(sequenceModeAppend, "id"), sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/items" || diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 { + t.Fatalf("cross-contribution duplicate = %#v", diagnostics) + } + if strings.Contains(diagnostics[0].Message, identityCanary) { + t.Fatalf("identity value leaked in diagnostic: %#v", diagnostics[0]) + } +} + +func TestCombineSequenceReplacementResetsLowerIdentity(t *testing.T) { + lower := sequenceFromYAML(t, "items: [{id: old}]\n") + higher := sequenceFromYAML(t, "items: [{name: new}]\n") + policy := sequenceCombinationPolicy(sequenceModeReplace, "name") + result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "name"), []string{"new"}) || !reflect.DeepEqual(result.discardedLower, []int{0}) { + t.Fatalf("replacement applied obsolete identity: %#v, %#v", result, diagnostics) + } +} + +func TestCombineSequenceDifferentIdentityDeclarationsConflict(t *testing.T) { + declarations := extractSequenceDeclarationsYAML(t, `configkit: + sequences: + - destination: [items] + key: id + - destination: [items] + key: name +`, rootDirectiveContextSource).sequences + if _, diagnostics := resolveCollectionPolicy(Keys("items"), declarations, nil, nil); len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict { + t.Fatalf("different collection key declarations = %#v", diagnostics) + } +} + +func TestCombineSequenceEmptyInputsAndStableOrder(t *testing.T) { + for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} { + lower := sequenceFromYAML(t, "items: [{id: a}, {id: b}]\n") + empty := sequenceFromYAML(t, "items: []\n") + result, diagnostics := combineSequence(lower, empty, sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "id"), []string{"a", "b"}) { + t.Fatalf("empty higher sequence under operation %d = %#v, %#v", operation, result, diagnostics) + } + } + result, diagnostics := combineSequence(nil, sequenceFromYAML(t, "items: []\n"), sequenceCombinationPolicy(sequenceModeReplace, "id"), sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 || result.value == nil || result.value.Kind != document.Sequence || len(result.value.Sequence) != 0 { + t.Fatalf("explicit replacement with empty input = %#v, %#v", result, diagnostics) + } +} + +func TestCombineSequenceInitializesAbsentLowerCollection(t *testing.T) { + higher := sequenceFromYAML(t, "items: [{id: first}, {id: second}]\n") + for _, operation := range []sequenceMode{sequenceModeReplace, sequenceModeAppend, sequenceModeUpsert} { + result, diagnostics := combineSequence(nil, higher, sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "id"), []string{"first", "second"}) { + t.Fatalf("absent lower collection under operation %d = %#v, %#v", operation, result, diagnostics) + } + } +} + +func TestCombineSequenceClonesInputsAndDoesNotMutatePolicy(t *testing.T) { + for _, test := range []struct { + name string + operation sequenceMode + }{ + {name: "replace", operation: sequenceModeReplace}, + {name: "append", operation: sequenceModeAppend}, + {name: "upsert", operation: sequenceModeUpsert}, + } { + t.Run(test.name, func(t *testing.T) { + lower := sequenceFromYAML(t, "items: [{id: a, text: original}]\n") + higherID := "b" + if test.operation == sequenceModeUpsert { + higherID = "a" + } + higher := sequenceFromYAML(t, "items: [{id: "+higherID+", text: incoming}]\n") + policy := sequenceCombinationPolicy(test.operation, "id") + policyCopy := append([]string(nil), policy.effectiveIdentityFields...) + result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + for _, item := range result.value.Sequence { + for entryIndex := range item.Mapping { + if item.Mapping[entryIndex].Key == "text" { + item.Mapping[entryIndex].Value.Text = "changed" + } + } + } + if lower.Sequence[0].Mapping[1].Value.Text != "original" || higher.Sequence[0].Mapping[1].Value.Text != "incoming" { + t.Fatal("combined result aliases a mutable input node") + } + if !reflect.DeepEqual(policy.effectiveIdentityFields, policyCopy) { + t.Fatal("combination mutated the resolved policy") + } + }) + } +} + +func TestCombineSequenceEquivalentIdentityFieldSets(t *testing.T) { + lower := sequenceFromYAML(t, "items: [{id: a, namespace: one}]\n") + higher := sequenceFromYAML(t, "items: [{namespace: one, id: b}]\n") + policy, diagnostics := resolveCollectionPolicy(Keys("items"), extractSequenceDeclarationsYAML(t, `configkit: + sequences: + - destination: [items] + key: [namespace, id, id] +`, rootDirectiveContextSource).sequences, nil, nil) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")}) + if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "id"), []string{"a", "b"}) { + t.Fatalf("equivalent identity policy failed: %#v, %#v", result, diagnostics) + } +} + +func sequenceCombinationPolicy(operation sequenceMode, fields ...string) collectionPolicyResolution { + normalized, _ := normalizeSequenceIdentityFields(fields) + return collectionPolicyResolution{operation: operation, effectiveIdentityFields: normalized} +} + +func sequenceFromYAML(t *testing.T, input string) *document.Node { + t.Helper() + parsed := parsePolicyNode(t, input) + if len(parsed.Mapping) != 1 || parsed.Mapping[0].Key != "items" { + t.Fatalf("sequence fixture root = %#v", parsed.Mapping) + } + return parsed.Mapping[0].Value +} + +func sequenceIdentityValues(sequence *document.Node, field string) []string { + if sequence == nil || sequence.Kind != document.Sequence { + return nil + } + values := make([]string, len(sequence.Sequence)) + for index, item := range sequence.Sequence { + for _, entry := range item.Mapping { + if entry.Key == field { + values[index] = entry.Value.Text + break + } + } + } + return values +}