Files
configkit/internal/engine/history_select_test.go

281 lines
12 KiB
Go

package engine
import (
"reflect"
"slices"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestSensitivityCandidatesMergeRepeatedParsedPaths(t *testing.T) {
t.Parallel()
builder := newHistoryBuilder()
key := Keys("shape", "0")
index := Keys("shape").Index(0)
parsed := parseOverrideTestPath(t, "/shape/0")
dependency := parseOverrideTestPath(t, "/dependency")
attribution := builder.addAttribution(Origin{Layer: LayerResolution}, nil, []Path{dependency})
payload := builder.nextPayload(historyPhaseResolution)
for repeat := 0; repeat < 3; repeat++ {
for _, path := range []Path{key, index} {
builder.addObservation(historyObservation{
path: path, phase: historyPhaseResolution, payload: payload,
reportedPath: Keys("shape"), role: historyRoleOperation,
afterPresent: true, sensitive: repeat == 2,
attributionRef: attribution,
})
}
}
candidates := builder.freeze().sensitivityCandidates()
for _, path := range []Path{key, index, parsed, dependency} {
count := 0
for _, candidate := range candidates {
if slices.Equal(candidate.path.segments, path.segments) {
count++
if path.String() == "/shape/0" && !candidate.sensitive {
t.Fatal("merged candidate lost a later sensitivity fact")
}
}
}
if count != 1 {
t.Fatalf("semantic candidate %#v occurs %d times; want one", path, count)
}
}
}
func TestHistorySelectionSeparatesNumericShapesAndGroupsParsedSides(t *testing.T) {
t.Parallel()
builder := newHistoryBuilder()
mapping := parseDocumentForDecode(t, "shape: {'0': old}\n")
sequence := parseDocumentForDecode(t, "shape: [new]\n")
shapeNode, _ := documentAtPath(mapping, Keys("shape"))
shapeNode.Sensitive = true
builder.observeDocument(mapping, historyPhaseSources)
builder.observeDocument(mapping, historyPhaseBaseline)
builder.observeDocument(sequence, historyPhaseResolution)
baseOrigin := Origin{Layer: LayerBase}
baseAttribution := builder.addAttribution(baseOrigin, nil, nil)
keyPath := Keys("shape", "0")
indexPath := Keys("shape").Index(0)
definition := builder.nextPayload(historyPhaseSources)
builder.addObservation(historyObservation{
path: keyPath, phase: historyPhaseSources, payload: definition,
reportedPath: keyPath, role: historyRoleDefinition,
afterPresent: true, attributionRef: baseAttribution,
})
replacementAttribution := builder.addAttribution(
Origin{Layer: LayerResolution, Operation: ChangeNormalization},
[]string{"replace dynamic shape"}, []Path{Keys("shape")},
)
replacement := builder.nextPayload(historyPhaseResolution)
for _, observation := range []historyObservation{
{
path: keyPath, phase: historyPhaseResolution, payload: replacement,
reportedPath: Keys("shape"), role: historyRoleOperation,
beforePresent: true, attributionRef: replacementAttribution,
},
{
path: indexPath, phase: historyPhaseResolution, payload: replacement,
reportedPath: Keys("shape"), role: historyRoleOperation,
afterPresent: true, attributionRef: replacementAttribution,
},
} {
if got := builder.addObservation(observation); got < 0 {
t.Fatal("failed to add concrete history observation")
}
}
model := builder.freeze()
shapes := model.concreteShapes("/shape/0")
if len(shapes) != 2 || shapes[0].segments[1].kind != pathSegmentKey || shapes[1].segments[1].kind != pathSegmentIndex {
t.Fatalf("concrete shapes = %#v", shapes)
}
if !reflect.DeepEqual(model.observedPaths()[:2], []Path{Path{}, Keys("shape")}) {
t.Fatalf("first observed paths = %#v", model.observedPaths()[:2])
}
historicalPaths := model.historicalPaths()
var historicalPointers []string
for _, path := range historicalPaths {
historicalPointers = append(historicalPointers, path.String())
}
if !reflect.DeepEqual(historicalPointers, []string{"", "/shape", "/shape/0"}) {
t.Fatalf("historical paths = %#v", historicalPointers)
}
if len(model.concreteShapes("/shape/0")) != 2 {
t.Fatalf("repeated inventory added a duplicate concrete shape: %#v", model.concreteShapes("/shape/0"))
}
foundSensitive := false
for _, observation := range model.observations {
if observation.role == historyRoleInventory && observation.path.String() == "/shape" && observation.sensitive {
foundSensitive = true
break
}
}
if !foundSensitive {
t.Fatal("sensitive document fact was not retained with inventory evidence")
}
keySelections := model.selectHistory(keyPath)
if len(keySelections) != 2 || keySelections[0].role != historyRoleDefinition || keySelections[1].before != true || keySelections[1].after {
t.Fatalf("mapping-key selections = %#v", keySelections)
}
if len(keySelections[1].previous) != 1 || keySelections[1].previous[0].Layer != LayerBase {
t.Fatalf("mapping-key previous origins = %#v", keySelections[1].previous)
}
indexSelections := model.selectHistory(indexPath)
if len(indexSelections) != 1 || indexSelections[0].before || !indexSelections[0].after || len(indexSelections[0].previous) != 0 {
t.Fatalf("sequence-index selections = %#v", indexSelections)
}
parsed, err := ParsePath("/shape/0")
if err != nil {
t.Fatal(err)
}
parsedSelections := model.selectHistory(parsed)
if len(parsedSelections) != 2 || !parsedSelections[1].before || !parsedSelections[1].after {
t.Fatalf("parsed-path selections = %#v", parsedSelections)
}
if !reflect.DeepEqual(parsedSelections[1].attribution.reasons, []string{"replace dynamic shape"}) ||
len(parsedSelections[1].attribution.dependencies) != 1 || parsedSelections[1].attribution.dependencies[0].String() != "/shape" {
t.Fatalf("resolved attribution = %#v", parsedSelections[1].attribution)
}
parsedSelections[1].attribution.reasons[0] = "mutated"
parsedSelections[1].attribution.dependencies[0] = Path{}
again := model.selectHistory(parsed)
if again[1].attribution.reasons[0] != "replace dynamic shape" || again[1].attribution.dependencies[0].String() != "/shape" {
t.Fatalf("selection mutation changed model attribution: %#v", again[1].attribution)
}
if selections := model.selectHistory(Keys("shape").Index(9)); len(selections) != 0 {
t.Fatalf("never-observed index selections = %#v", selections)
}
}
func TestHistorySelectionMatchesDeepSidesOfAncestorShapeChange(t *testing.T) {
t.Parallel()
builder := newHistoryBuilder()
builder.observeDocument(parseDocumentForDecode(t, "shape: {'0': {leaf: old}}\n"), historyPhaseSources)
builder.observeDocument(parseDocumentForDecode(t, "shape: [{leaf: new}]\n"), historyPhaseResolution)
base := builder.addAttribution(Origin{Layer: LayerBase}, nil, nil)
oldPath := Keys("shape", "0", "leaf")
newPath := Keys("shape").Index(0).Key("leaf")
definition := builder.nextPayload(historyPhaseSources)
builder.addObservation(historyObservation{
path: oldPath, phase: historyPhaseSources, payload: definition,
reportedPath: oldPath, role: historyRoleDefinition,
afterPresent: true, attributionRef: base,
})
operation := builder.nextPayload(historyPhaseResolution)
operationAttribution := builder.addAttribution(Origin{Layer: LayerResolution, Operation: ChangeNormalization}, nil, nil)
for _, observation := range []historyObservation{
{path: oldPath, phase: historyPhaseResolution, payload: operation, reportedPath: Keys("shape"), role: historyRoleOperation, beforePresent: true, attributionRef: operationAttribution},
{path: newPath, phase: historyPhaseResolution, payload: operation, reportedPath: Keys("shape"), role: historyRoleOperation, afterPresent: true, attributionRef: operationAttribution},
} {
builder.addObservation(observation)
}
model := builder.freeze()
newSelections := model.selectHistory(newPath)
if len(newSelections) != 1 || newSelections[0].role != historyRoleOperation || newSelections[0].before || !newSelections[0].after || len(newSelections[0].previous) != 0 {
t.Fatalf("new descendant selections = %#v", newSelections)
}
oldSelections := model.selectHistory(oldPath)
if len(oldSelections) != 2 || !oldSelections[1].before || oldSelections[1].after || len(oldSelections[1].previous) != 1 || oldSelections[1].previous[0].Layer != LayerBase {
t.Fatalf("removed descendant selections = %#v", oldSelections)
}
if selections := model.selectHistory(Keys("shape").Index(0).Key("missing")); len(selections) != 0 {
t.Fatalf("never-observed descendant selections = %#v", selections)
}
}
func TestHistoryModelFinalizesLocationAndSensitivityWithoutRetainingInventoryTrees(t *testing.T) {
t.Parallel()
root := parseDocumentForDecode(t, "shape: {'0': {value: old}}\n")
value, _ := documentAtPath(root, Keys("shape", "0", "value"))
value.Sensitive = true
keyPath := Keys("shape", "0", "value")
dependency := Keys("credentials", "source")
builder := newHistoryBuilder()
builder.observeDocument(root, historyPhaseSources)
definitionAttribution := builder.addAttribution(Origin{
Layer: LayerBase, Locations: []Location{{SourceID: "base.yml", Line: 4, Column: 2}},
}, nil, nil)
definition := builder.nextPayload(historyPhaseSources)
builder.addObservation(historyObservation{
path: keyPath, phase: historyPhaseSources, payload: definition,
reportedPath: keyPath, role: historyRoleDefinition,
afterPresent: true, attributionRef: definitionAttribution,
})
resolutionAttribution := builder.addAttribution(
Origin{Layer: LayerResolution, Operation: ChangeNormalization},
[]string{"normalized credential"}, []Path{dependency},
)
resolution := builder.nextPayload(historyPhaseResolution)
builder.addObservation(historyObservation{
path: keyPath, phase: historyPhaseResolution, payload: resolution,
reportedPath: Keys("shape"), role: historyRoleOperation,
beforePresent: true, afterPresent: true, sensitive: true,
attributionRef: resolutionAttribution,
})
model := builder.freeze()
if got := model.documentRoots(); len(got) != 0 {
t.Fatalf("inventory-only document was retained: %#v", got)
}
if got, ok := model.documentAt(root, Keys("shape", "0")); ok || got != nil {
t.Fatalf("lookup retained an inventory-only document = %#v, %v", got, ok)
}
if got, ok := model.latestHistoricalLocation(keyPath); !ok || got.SourceID != "base.yml" || got.Line != 4 || got.Column != 2 {
t.Fatalf("latest historical location = %#v, %v", got, ok)
}
candidates := model.sensitivityCandidates()
var sawCandidate bool
for _, candidate := range candidates {
if !sameConcreteHistoryPath(candidate.path, keyPath) || !candidate.sensitive || len(candidate.dependencies) == 0 {
continue
}
if len(candidate.dependencies) != 1 || candidate.dependencies[0].String() != dependency.String() {
t.Fatalf("sensitivity dependencies = %#v", candidate.dependencies)
}
sawCandidate = true
}
if !sawCandidate {
t.Fatal("sensitivity candidate omitted the concrete historical path")
}
}
func TestHistoryBuilderScratchUsesOneDetachedWorkingDocument(t *testing.T) {
t.Parallel()
input := parseDocumentForDecode(t, "shape: {value: original}\n")
builder := newHistoryBuilder()
builder.setScratch(input)
inputValue, _ := documentAtPath(input, Keys("shape", "value"))
inputValue.Text = "producer mutation"
scratchValue, ok := builder.scratchAt(Keys("shape", "value"))
if !ok || scratchValue.Text != "original" {
t.Fatalf("scratch value after producer mutation = %#v, %v", scratchValue, ok)
}
next := document.Clone(builder.scratch)
nextValue, _ := documentAtPath(next, Keys("shape", "value"))
nextValue.Text = "replayed"
builder.advanceScratch(next)
scratchValue, ok = builder.scratchAt(Keys("shape", "value"))
if !ok || scratchValue.Text != "replayed" {
t.Fatalf("advanced scratch value = %#v, %v", scratchValue, ok)
}
model := builder.freeze()
if !model.frozen || builder.scratch != nil {
t.Fatal("freeze retained mutable scratch state")
}
}