Add shape-aware history selection core
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Interface Contract Remediation Implementation Plan
|
||||
|
||||
Status: in progress. Stages 1–2 are complete; Stages 3–18 remain pending.
|
||||
Status: in progress. Stages 1–3 are complete; Stages 4–18 remain pending.
|
||||
Baseline: `f6d54a094a2b5ada5748686620659399dce7ebf0`.
|
||||
Audience: a Luna coding agent executing the stages sequentially.
|
||||
|
||||
@@ -337,7 +337,11 @@ input remains explicit. Focused check:
|
||||
## Stage 3 — Introduce The Shared Evidence And Selection Core
|
||||
|
||||
Finding 3 architecture. Owners: new `history_model.go` and `history_select.go`,
|
||||
existing path/lookup helpers, focused history-selection tests. Status: pending.
|
||||
existing path/lookup helpers, focused history-selection tests. Status: complete.
|
||||
Validation:
|
||||
`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'History.*Select|History.*Shape|Path' -count=1`,
|
||||
`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -count=1`,
|
||||
and `git diff --check`.
|
||||
|
||||
1. Implement the selected model, builder, phase/role enums, payload ID references,
|
||||
and semantic observation types. Keep phase producers in later stages; do not
|
||||
|
||||
225
internal/engine/history_model.go
Normal file
225
internal/engine/history_model.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type historyPhase uint8
|
||||
|
||||
const (
|
||||
historyPhaseBaseline historyPhase = iota
|
||||
historyPhaseSources
|
||||
historyPhaseInheritance
|
||||
historyPhasePreCLI
|
||||
historyPhaseCLI
|
||||
historyPhasePostCLI
|
||||
historyPhaseResolution
|
||||
)
|
||||
|
||||
type historyRole uint8
|
||||
|
||||
const (
|
||||
historyRoleDefinition historyRole = iota
|
||||
historyRoleCompositeDefinition
|
||||
historyRoleOperation
|
||||
historyRoleControl
|
||||
historyRoleCheckpoint
|
||||
historyRoleInventory
|
||||
)
|
||||
|
||||
type historyPayloadRef struct {
|
||||
phase historyPhase
|
||||
id int
|
||||
substep int
|
||||
}
|
||||
|
||||
type historyAttributionRef int
|
||||
|
||||
const noHistoryAttribution historyAttributionRef = -1
|
||||
|
||||
type historyAttribution struct {
|
||||
origin Origin
|
||||
reasons []string
|
||||
dependencies []Path
|
||||
}
|
||||
|
||||
type historyObservation struct {
|
||||
path Path
|
||||
phase historyPhase
|
||||
payload historyPayloadRef
|
||||
reportedPath Path
|
||||
role historyRole
|
||||
beforePresent bool
|
||||
afterPresent bool
|
||||
sensitive bool
|
||||
attributionRef historyAttributionRef
|
||||
}
|
||||
|
||||
type historyModel struct {
|
||||
observations []historyObservation
|
||||
attributions []historyAttribution
|
||||
|
||||
shapesByPointer map[string][]Path
|
||||
shapeOrder []Path
|
||||
observationsByPointer map[string][]int
|
||||
reportedPathIndex pathPrefixIndex
|
||||
|
||||
frozen bool
|
||||
}
|
||||
|
||||
// historyBuilder owns the model while loading. Its scratch document is one
|
||||
// mutable working projection used to resolve concrete sides of relative
|
||||
// producer effects; it is not retained by the published model.
|
||||
type historyBuilder struct {
|
||||
model *historyModel
|
||||
nextPayloadIDs map[historyPhase]int
|
||||
scratch *document.Node
|
||||
}
|
||||
|
||||
func newHistoryBuilder() *historyBuilder {
|
||||
return &historyBuilder{
|
||||
model: &historyModel{
|
||||
shapesByPointer: make(map[string][]Path),
|
||||
observationsByPointer: make(map[string][]int),
|
||||
},
|
||||
nextPayloadIDs: make(map[historyPhase]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) nextPayload(phase historyPhase) historyPayloadRef {
|
||||
builder.requireOpen()
|
||||
id := builder.nextPayloadIDs[phase]
|
||||
builder.nextPayloadIDs[phase] = id + 1
|
||||
return historyPayloadRef{phase: phase, id: id}
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addAttribution(origin Origin, reasons []string, dependencies []Path) historyAttributionRef {
|
||||
builder.requireOpen()
|
||||
ref := historyAttributionRef(len(builder.model.attributions))
|
||||
builder.model.attributions = append(builder.model.attributions, historyAttribution{
|
||||
origin: cloneOrigin(origin),
|
||||
reasons: append([]string(nil), reasons...),
|
||||
dependencies: clonePaths(dependencies),
|
||||
})
|
||||
return ref
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addObservation(observation historyObservation) int {
|
||||
builder.requireOpen()
|
||||
if !observation.path.valid() || !concreteHistoryPath(observation.path) || !observation.reportedPath.valid() {
|
||||
return -1
|
||||
}
|
||||
if observation.attributionRef < noHistoryAttribution || int(observation.attributionRef) >= len(builder.model.attributions) {
|
||||
return -1
|
||||
}
|
||||
observation.path = observation.path.clone()
|
||||
observation.reportedPath = observation.reportedPath.clone()
|
||||
observationID := len(builder.model.observations)
|
||||
builder.model.observations = append(builder.model.observations, observation)
|
||||
builder.addShape(observation.path)
|
||||
switch observation.role {
|
||||
case historyRoleDefinition, historyRoleCompositeDefinition, historyRoleCheckpoint:
|
||||
pointer := observation.path.String()
|
||||
builder.model.observationsByPointer[pointer] = append(builder.model.observationsByPointer[pointer], observationID)
|
||||
case historyRoleOperation, historyRoleControl:
|
||||
builder.model.reportedPathIndex.insert(observation.reportedPath.String(), observationID)
|
||||
}
|
||||
return observationID
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) observeDocument(root *document.Node, phase historyPhase) {
|
||||
builder.requireOpen()
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
payload := builder.nextPayload(phase)
|
||||
walkDocument(root, Path{}, func(path Path, node *document.Node) {
|
||||
builder.addObservation(historyObservation{
|
||||
path: path,
|
||||
phase: phase,
|
||||
payload: payload,
|
||||
reportedPath: path,
|
||||
role: historyRoleInventory,
|
||||
sensitive: node.Sensitive,
|
||||
attributionRef: noHistoryAttribution,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) setScratch(root *document.Node) {
|
||||
builder.requireOpen()
|
||||
builder.scratch = document.Clone(root)
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) scratchAt(path Path) (*document.Node, bool) {
|
||||
builder.requireOpen()
|
||||
return documentAtPath(builder.scratch, path)
|
||||
}
|
||||
|
||||
// advanceScratch transfers the next working projection to the builder. Callers
|
||||
// use it after applying existing value-replay helpers to the current scratch
|
||||
// document, avoiding a retained tree copy for each operation.
|
||||
func (builder *historyBuilder) advanceScratch(root *document.Node) {
|
||||
builder.requireOpen()
|
||||
builder.scratch = root
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) freeze() *historyModel {
|
||||
builder.requireOpen()
|
||||
builder.scratch = nil
|
||||
builder.model.frozen = true
|
||||
model := builder.model
|
||||
builder.model = nil
|
||||
builder.nextPayloadIDs = nil
|
||||
return model
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) addShape(path Path) {
|
||||
pointer := path.String()
|
||||
for _, existing := range builder.model.shapesByPointer[pointer] {
|
||||
if sameConcreteHistoryPath(existing, path) {
|
||||
return
|
||||
}
|
||||
}
|
||||
owned := path.clone()
|
||||
builder.model.shapesByPointer[pointer] = append(builder.model.shapesByPointer[pointer], owned)
|
||||
builder.model.shapeOrder = append(builder.model.shapeOrder, owned.clone())
|
||||
}
|
||||
|
||||
func (builder *historyBuilder) requireOpen() {
|
||||
if builder == nil || builder.model == nil || builder.model.frozen {
|
||||
panic("configkit: history builder is no longer active")
|
||||
}
|
||||
}
|
||||
|
||||
func concreteHistoryPath(path Path) bool {
|
||||
for _, segment := range path.segments {
|
||||
if segment.kind == pathSegmentParsed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameConcreteHistoryPath(left, right Path) bool {
|
||||
if len(left.segments) != len(right.segments) {
|
||||
return false
|
||||
}
|
||||
for index, leftSegment := range left.segments {
|
||||
rightSegment := right.segments[index]
|
||||
if leftSegment.kind != rightSegment.kind {
|
||||
return false
|
||||
}
|
||||
switch leftSegment.kind {
|
||||
case pathSegmentKey:
|
||||
if leftSegment.key != rightSegment.key {
|
||||
return false
|
||||
}
|
||||
case pathSegmentIndex:
|
||||
if leftSegment.index != rightSegment.index {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
158
internal/engine/history_select.go
Normal file
158
internal/engine/history_select.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type historySelection struct {
|
||||
path Path
|
||||
phase historyPhase
|
||||
payload historyPayloadRef
|
||||
reportedPath Path
|
||||
role historyRole
|
||||
before bool
|
||||
after bool
|
||||
attribution historyAttribution
|
||||
hasAttribution bool
|
||||
previous []Origin
|
||||
}
|
||||
|
||||
func (model *historyModel) selectHistory(path Path) []historySelection {
|
||||
if model == nil || !path.valid() || !model.hasObservedShape(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidateSet := make(map[int]struct{})
|
||||
for _, observationID := range model.observationsByPointer[path.String()] {
|
||||
candidateSet[observationID] = struct{}{}
|
||||
}
|
||||
model.reportedPathIndex.visitAncestors(path.String(), func(observationID int) {
|
||||
candidateSet[observationID] = struct{}{}
|
||||
})
|
||||
candidates := make([]int, 0, len(candidateSet))
|
||||
for observationID := range candidateSet {
|
||||
candidates = append(candidates, observationID)
|
||||
}
|
||||
slices.Sort(candidates)
|
||||
|
||||
selections := make([]historySelection, 0, len(candidates))
|
||||
selectionByPayload := make(map[historyPayloadRef]int)
|
||||
for _, observationID := range candidates {
|
||||
observation := model.observations[observationID]
|
||||
if observation.role == historyRoleInventory || !matchesPathShape(path, observation.path) {
|
||||
continue
|
||||
}
|
||||
selectionIndex, found := selectionByPayload[observation.payload]
|
||||
if !found {
|
||||
selectionIndex = len(selections)
|
||||
selectionByPayload[observation.payload] = selectionIndex
|
||||
selection := historySelection{
|
||||
path: path.clone(),
|
||||
phase: observation.phase,
|
||||
payload: observation.payload,
|
||||
reportedPath: observation.reportedPath.clone(),
|
||||
role: observation.role,
|
||||
}
|
||||
if attribution, ok := model.resolveAttribution(observation.attributionRef); ok {
|
||||
selection.attribution = attribution
|
||||
selection.hasAttribution = true
|
||||
}
|
||||
selections = append(selections, selection)
|
||||
}
|
||||
selection := &selections[selectionIndex]
|
||||
selection.before = selection.before || observation.beforePresent
|
||||
selection.after = selection.after || observation.afterPresent
|
||||
}
|
||||
if len(selections) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var previous *Origin
|
||||
for index := range selections {
|
||||
selection := &selections[index]
|
||||
if selection.hasAttribution && metadataHistoryRole(selection.role) {
|
||||
if previous != nil {
|
||||
selection.previous = []Origin{cloneOrigin(*previous)}
|
||||
}
|
||||
origin := cloneOrigin(selection.attribution.origin)
|
||||
previous = &origin
|
||||
}
|
||||
}
|
||||
return selections
|
||||
}
|
||||
|
||||
func (model *historyModel) hasObservedShape(path Path) bool {
|
||||
if model == nil || !path.valid() {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range model.shapesByPointer[path.String()] {
|
||||
if matchesPathShape(path, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (model *historyModel) concreteShapes(pointer string) []Path {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
shapes := model.shapesByPointer[pointer]
|
||||
cloned := make([]Path, len(shapes))
|
||||
for index, path := range shapes {
|
||||
cloned[index] = path.clone()
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (model *historyModel) observedPaths() []Path {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
paths := make([]Path, len(model.shapeOrder))
|
||||
for index, path := range model.shapeOrder {
|
||||
paths[index] = path.clone()
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (model *historyModel) historicalPaths() []Path {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
pointers := make([]string, 0, len(model.shapesByPointer))
|
||||
for pointer := range model.shapesByPointer {
|
||||
pointers = append(pointers, pointer)
|
||||
}
|
||||
sort.Strings(pointers)
|
||||
paths := make([]Path, 0, len(pointers))
|
||||
for _, pointer := range pointers {
|
||||
path, err := ParsePath(pointer)
|
||||
if err == nil {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (model *historyModel) resolveAttribution(ref historyAttributionRef) (historyAttribution, bool) {
|
||||
if model == nil || ref < 0 || int(ref) >= len(model.attributions) {
|
||||
return historyAttribution{}, false
|
||||
}
|
||||
attribution := model.attributions[ref]
|
||||
return historyAttribution{
|
||||
origin: cloneOrigin(attribution.origin),
|
||||
reasons: append([]string(nil), attribution.reasons...),
|
||||
dependencies: clonePaths(attribution.dependencies),
|
||||
}, true
|
||||
}
|
||||
|
||||
func metadataHistoryRole(role historyRole) bool {
|
||||
switch role {
|
||||
case historyRoleDefinition, historyRoleCompositeDefinition, historyRoleOperation, historyRoleControl:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
184
internal/engine/history_select_test.go
Normal file
184
internal/engine/history_select_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user