Preserve item helper history in source order
This commit is contained in:
@@ -53,7 +53,13 @@ declaration remains inspectable. Existing item/helper evidence tests still pass.
|
|||||||
|
|
||||||
## 2. Preserve source chronology when expanding item history — P2
|
## 2. Preserve source chronology when expanding item history — P2
|
||||||
|
|
||||||
- [ ] Addressed and regression-tested.
|
- [x] Addressed and regression-tested.
|
||||||
|
|
||||||
|
Completion: structural fragments use their local singleton index; item body and
|
||||||
|
helper events are expanded beside their declaring contribution. Mapping history
|
||||||
|
queries ignore unrelated sibling fields. A public regression covers preceding
|
||||||
|
authored records, two item sources, nested helpers, field origins, and root
|
||||||
|
traversal order. Focused facade/history tests and the everyday check passed.
|
||||||
|
|
||||||
**Owner:** `projectNestedEvidence` in
|
**Owner:** `projectNestedEvidence` in
|
||||||
[imports.go](../../internal/engine/imports.go), especially lines 592–608.
|
[imports.go](../../internal/engine/imports.go), especially lines 592–608.
|
||||||
|
|||||||
@@ -6,10 +6,71 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||||
|
"one.yml": {Data: []byte("id: one\nconfigkit: {imports: [{path: helper-one.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n")},
|
||||||
|
"two.yml": {Data: []byte("id: two\nconfigkit: {imports: [{path: helper-two.yml}]}\n")},
|
||||||
|
"helper-one.yml": {Data: []byte("text: first helper\n")},
|
||||||
|
"helper-two.yml": {Data: []byte("text: second helper\n")},
|
||||||
|
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||||
|
"child-helper.yml": {Data: []byte("text: nested helper\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||||
|
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sourceOrder := make(map[string]int)
|
||||||
|
sourceFiles := make(map[string]string)
|
||||||
|
for index, source := range result.Sources() {
|
||||||
|
sourceOrder[source.ID] = index
|
||||||
|
sourceFiles[source.ID] = source.Path
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
path configkit.Path
|
||||||
|
file, text string
|
||||||
|
}{
|
||||||
|
{configkit.Keys("records").Index(1).Key("text"), "helper-one.yml", "first helper\n"},
|
||||||
|
{configkit.Keys("records").Index(2).Key("text"), "helper-two.yml", "second helper\n"},
|
||||||
|
{configkit.Keys("records").Index(1).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
||||||
|
} {
|
||||||
|
explanation, ok := result.Explain(test.path)
|
||||||
|
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
||||||
|
t.Fatalf("helper history at %s = %#v", test.path.String(), explanation)
|
||||||
|
}
|
||||||
|
locations := explanation.History[0].Origin.Locations
|
||||||
|
if len(locations) != 1 || sourceFiles[locations[0].SourceID] != test.file {
|
||||||
|
t.Fatalf("helper origin at %s = %#v", test.path.String(), locations)
|
||||||
|
}
|
||||||
|
info, ok := result.Metadata().Lookup(test.path)
|
||||||
|
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != locations[0].SourceID {
|
||||||
|
t.Fatal("metadata and explanation disagree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(configkit.Keys())
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("root explanation missing")
|
||||||
|
}
|
||||||
|
previous := -1
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
current := sourceOrder[location.SourceID]
|
||||||
|
if current < previous {
|
||||||
|
t.Fatalf("source %s replayed out of traversal order", location.SourceID)
|
||||||
|
}
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
|
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ func applicationOperationPath(application historyApplication, query Path, lookup
|
|||||||
if !application.path.valid() || application.value == nil {
|
if !application.path.valid() || application.value == nil {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
if application.path.String() != "" {
|
if !pathContains(application.path, query) {
|
||||||
if pathsIntersect(application.path, query) {
|
if pathContains(query, application.path) {
|
||||||
return application.path.clone(), true
|
return application.path.clone(), true
|
||||||
}
|
}
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
@@ -148,17 +148,18 @@ func applicationOperationPath(application historyApplication, query Path, lookup
|
|||||||
if sequencePathMatched {
|
if sequencePathMatched {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
if len(query.segments) == 0 {
|
if len(query.segments) == len(application.path.segments) || application.value.Kind != document.Mapping {
|
||||||
return Path{}, true
|
return application.path.clone(), true
|
||||||
}
|
}
|
||||||
for length := 1; length <= len(query.segments); length++ {
|
relative, _ := relativeHistoryPath(application.path, query)
|
||||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
for length := 1; length <= len(relative.segments); length++ {
|
||||||
|
prefix := Path{segments: clonePathSegments(relative.segments[:length], 0)}
|
||||||
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
|
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
|
||||||
if !present {
|
if !present {
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
if length == len(query.segments) || node.Kind != document.Mapping {
|
if length == len(relative.segments) || node.Kind != document.Mapping {
|
||||||
return prefix, true
|
return appendDestination(application.path, prefix), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
|
|||||||
@@ -578,8 +578,11 @@ func projectNestedEvidence(composed *composedDocument, scope acquisitionScope) {
|
|||||||
if composed == nil {
|
if composed == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
applications := composed.applications
|
||||||
|
projectedApplications := make([]historyApplication, 0, len(applications))
|
||||||
for sourceIndex, contribution := range scope.contributions {
|
for sourceIndex, contribution := range scope.contributions {
|
||||||
if contribution.nested == nil {
|
if contribution.nested == nil {
|
||||||
|
projectedApplications = append(projectedApplications, applications[sourceIndex])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
start := 0
|
start := 0
|
||||||
@@ -590,26 +593,26 @@ func projectNestedEvidence(composed *composedDocument, scope acquisitionScope) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefix := contribution.destination.Index(start)
|
prefix := contribution.destination.Index(start)
|
||||||
if sourceIndex < len(composed.applications) {
|
if sourceIndex < len(applications) {
|
||||||
outer := composed.applications[sourceIndex]
|
outer := applications[sourceIndex]
|
||||||
structural := document.Clone(outer.value)
|
structural := document.Clone(outer.value)
|
||||||
if node, found := documentAtPath(structural, contribution.destination); found && node.Kind == document.Sequence && len(node.Sequence) > start {
|
if node, found := documentAtPath(structural, contribution.destination); found && node.Kind == document.Sequence && len(node.Sequence) > 0 {
|
||||||
node.Sequence[start] = &document.Node{Kind: document.Mapping, Location: node.Sequence[start].Location}
|
// The retained fragment is still a local singleton. Its assigned
|
||||||
|
// outer index belongs to the projection, not this source tree.
|
||||||
|
node.Sequence[0] = &document.Node{Kind: document.Mapping, Location: node.Sequence[0].Location}
|
||||||
}
|
}
|
||||||
outer.value = structural
|
outer.value = structural
|
||||||
composed.applications[sourceIndex] = outer
|
projectedApplications = append(projectedApplications, outer)
|
||||||
for _, nested := range contribution.nested.applications {
|
for _, nested := range contribution.nested.applications {
|
||||||
projected := nested
|
projected := nested
|
||||||
projected.path = appendDestination(prefix, nested.path)
|
projected.path = appendDestination(prefix, nested.path)
|
||||||
projected.sequences = cloneSequenceHistoryContributions(nested.sequences)
|
projected.sequences = cloneSequenceHistoryContributions(nested.sequences)
|
||||||
for index := range projected.sequences {
|
projectedApplications = append(projectedApplications, projected)
|
||||||
projected.sequences[index].path = appendDestination(prefix, projected.sequences[index].path)
|
|
||||||
}
|
|
||||||
composed.applications = append(composed.applications, projected)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
projectNestedOrigins(composed.origins, contribution.nested.root, contribution.nested.origins, prefix)
|
projectNestedOrigins(composed.origins, contribution.nested.root, contribution.nested.origins, prefix)
|
||||||
}
|
}
|
||||||
|
composed.applications = projectedApplications
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectNestedOrigins(target map[string]Origin, root *document.Node, origins map[string]Origin, prefix Path) {
|
func projectNestedOrigins(target map[string]Origin, root *document.Node, origins map[string]Origin, prefix Path) {
|
||||||
|
|||||||
Reference in New Issue
Block a user