- Plan is a pure function (inventory, manifest, disk facts, injected path mapping) emitting exactly six actions: deploy, update, skip+warn, leave+warn, remove, keep — deterministic order, no filesystem access - full 3x3 matrix (disk unmodified/user-modified/never-managed x upstream added/changed/removed) table-tested plus missing-disk restore cases, a mixed fixture and a purity/determinism guarantee test - executor applies plans item by item (atomic deploy/remove per item), rewrites the manifest once, appends timestamped SKIP/LEAVE lines with item name and reason to the sync log - sync now plans before mutating: unmanaged occupants block new deploys, upstream deletions remove only unmodified copies, local modifications always win and are warned about
212 lines
6.7 KiB
Go
212 lines
6.7 KiB
Go
package client
|
||
|
||
import "testing"
|
||
|
||
const (
|
||
hashV1 = "sha256:aaa"
|
||
hashV2 = "sha256:bbb"
|
||
hashMod = "sha256:localmod"
|
||
)
|
||
|
||
func testPathFor(typ, name string) string { return "/home/u/target/" + typ + "/" + name }
|
||
|
||
func manifestWith(entries map[string]*ManifestItem) *Manifest {
|
||
m := NewManifest()
|
||
for k, v := range entries {
|
||
m.Items[k] = v
|
||
}
|
||
return m
|
||
}
|
||
|
||
// TestPlanMatrix walks the complete three-state matrix (disk × upstream)
|
||
// plus the missing-disk restore cases, asserting the single planned action.
|
||
func TestPlanMatrix(t *testing.T) {
|
||
type disk string
|
||
const (
|
||
diskNone disk = "missing"
|
||
diskClean disk = "unmodified"
|
||
diskMod disk = "user-modified"
|
||
diskForeign disk = "never-managed"
|
||
)
|
||
type upstream string
|
||
const (
|
||
upAdded upstream = "added"
|
||
upChanged upstream = "changed"
|
||
upSame upstream = "unchanged"
|
||
upGone upstream = "removed"
|
||
upAbsent upstream = "not-in-repo"
|
||
)
|
||
|
||
cases := []struct {
|
||
disk disk
|
||
up upstream
|
||
want Action
|
||
}{
|
||
{diskNone, upAdded, ActDeploy},
|
||
{diskClean, upAdded, ActSkip},
|
||
{diskMod, upAdded, ActSkip},
|
||
{diskForeign, upAdded, ActSkip},
|
||
|
||
{diskNone, upChanged, ActUpdate},
|
||
{diskClean, upChanged, ActUpdate},
|
||
{diskMod, upChanged, ActSkip},
|
||
{diskForeign, upChanged, ActSkip},
|
||
|
||
{diskNone, upSame, ActUpdate},
|
||
{diskClean, upSame, ActKeep},
|
||
{diskMod, upSame, ActSkip},
|
||
{diskForeign, upSame, ActSkip},
|
||
|
||
{diskNone, upGone, ActRemove},
|
||
{diskClean, upGone, ActRemove},
|
||
{diskMod, upGone, ActSkip},
|
||
{diskForeign, upGone, ActSkip},
|
||
|
||
{diskClean, upAbsent, ActLeave},
|
||
{diskNone, upAbsent, ActKeep}, // marker for "no plan entry at all"
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
name := string(tc.disk) + "/" + string(tc.up)
|
||
t.Run(name, func(t *testing.T) {
|
||
key := manifestKey("skills", "alpha")
|
||
var items []PlannedItem
|
||
manifest := NewManifest()
|
||
diskFacts := map[string]DiskFact{}
|
||
|
||
switch tc.up {
|
||
case upAdded:
|
||
items = append(items, PlannedItem{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hashV1})
|
||
case upChanged, upSame:
|
||
hash := hashV2
|
||
if tc.up == upSame {
|
||
hash = hashV1
|
||
}
|
||
items = append(items, PlannedItem{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hash})
|
||
manifest.Items[key] = &ManifestItem{Name: "alpha", Type: "skills", Hash: hashV1}
|
||
case upGone:
|
||
manifest.Items[key] = &ManifestItem{Name: "alpha", Type: "skills", Hash: hashV1}
|
||
case upAbsent:
|
||
// neither inventory nor manifest
|
||
}
|
||
|
||
switch tc.disk {
|
||
case diskNone:
|
||
case diskClean:
|
||
diskFacts[key] = DiskFact{Exists: true, Hash: hashV1}
|
||
case diskMod, diskForeign:
|
||
diskFacts[key] = DiskFact{Exists: true, Hash: hashMod}
|
||
}
|
||
|
||
plan := Plan(items, manifest, diskFacts, testPathFor)
|
||
if tc.disk == diskNone && tc.up == upAbsent {
|
||
if len(plan) != 0 {
|
||
t.Fatalf("a key present nowhere must produce no entry, got %+v", plan)
|
||
}
|
||
return
|
||
}
|
||
if len(plan) != 1 {
|
||
t.Fatalf("want exactly 1 entry, got %d: %+v", len(plan), plan)
|
||
}
|
||
if plan[0].Action != tc.want {
|
||
t.Errorf("action = %s, want %s (reason: %s)", plan[0].Action, tc.want, plan[0].Reason)
|
||
}
|
||
if plan[0].DstPath != testPathFor("skills", "alpha") {
|
||
t.Errorf("DstPath not mapped: %q", plan[0].DstPath)
|
||
}
|
||
if tc.want == ActSkip || tc.want == ActLeave {
|
||
if plan[0].Reason == "" {
|
||
t.Error("skip/leave entries must carry a reason")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestPlanMixedFixture combines several states in one plan and asserts the
|
||
// full deterministic output.
|
||
func TestPlanMixedFixture(t *testing.T) {
|
||
items := []PlannedItem{
|
||
{Type: "commands", Name: "deploy", RelPath: "commands/deploy.md", Hash: hashV1},
|
||
{Type: "skills", Name: "fresh", RelPath: "skills/fresh", Hash: hashV1},
|
||
{Type: "skills", Name: "tweaked", RelPath: "skills/tweaked", Hash: hashV2},
|
||
{Type: "skills", Name: "keeper", RelPath: "skills/keeper", Hash: hashV1},
|
||
{Type: "agents", Name: "helper", RelPath: "agents/helper.md", Hash: hashV1},
|
||
}
|
||
manifest := manifestWith(map[string]*ManifestItem{
|
||
"skills/tweaked": {Name: "tweaked", Type: "skills", Hash: hashV1},
|
||
"skills/goner": {Name: "goner", Type: "skills", Hash: hashV1},
|
||
"skills/keeper": {Name: "keeper", Type: "skills", Hash: hashV1},
|
||
"skills/blocked": {Name: "blocked", Type: "skills", Hash: hashV1},
|
||
"agents/helper": {Name: "helper", Type: "agents", Hash: hashV1},
|
||
})
|
||
disk := map[string]DiskFact{
|
||
"skills/fresh": {Exists: true, Hash: hashMod}, // never-managed × added
|
||
"skills/tweaked": {Exists: true, Hash: hashMod}, // user-modified × changed
|
||
"skills/goner": {Exists: true, Hash: hashV1}, // unmodified × removed
|
||
"skills/keeper": {Exists: true, Hash: hashV1}, // unmodified × unchanged
|
||
"skills/blocked": {Exists: true, Hash: hashMod}, // user-modified × removed
|
||
"skills/mine": {Exists: true}, // never-managed × absent
|
||
"commands/deploy": {}, // missing × added
|
||
"agents/helper": {Exists: true, Hash: hashV1}, // unmodified × unchanged
|
||
}
|
||
|
||
plan := Plan(items, manifest, disk, testPathFor)
|
||
got := map[string]Action{}
|
||
for _, e := range plan {
|
||
got[e.Type+"/"+e.Name] = e.Action
|
||
}
|
||
want := map[string]Action{
|
||
"commands/deploy": ActDeploy,
|
||
"skills/fresh": ActSkip,
|
||
"skills/tweaked": ActSkip,
|
||
"agents/helper": ActKeep,
|
||
"skills/goner": ActRemove,
|
||
"skills/keeper": ActKeep,
|
||
"skills/blocked": ActSkip,
|
||
"skills/mine": ActLeave,
|
||
}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("plan has %d entries, want %d: %+v", len(got), len(want), plan)
|
||
}
|
||
for key, action := range want {
|
||
if got[key] != action {
|
||
t.Errorf("%s: action = %s, want %s", key, got[key], action)
|
||
}
|
||
}
|
||
|
||
sorted := true
|
||
for i := 1; i < len(plan); i++ {
|
||
prev := plan[i-1].Type + "/" + plan[i-1].Name
|
||
curr := plan[i].Type + "/" + plan[i].Name
|
||
if prev > curr {
|
||
sorted = false
|
||
}
|
||
}
|
||
if !sorted {
|
||
t.Error("plan must be deterministically sorted by type/name")
|
||
}
|
||
}
|
||
|
||
// TestPlanPureGuarantee compiles the purity contract: planning identical
|
||
// inputs twice yields identical plans, and no input is mutated.
|
||
func TestPlanPureGuarantee(t *testing.T) {
|
||
items := []PlannedItem{{Type: "skills", Name: "a", RelPath: "skills/a", Hash: hashV1}}
|
||
manifest := manifestWith(map[string]*ManifestItem{"skills/b": {Name: "b", Type: "skills", Hash: hashV1}})
|
||
disk := map[string]DiskFact{"skills/b": {Exists: true, Hash: hashV1}}
|
||
|
||
p1 := Plan(items, manifest, disk, testPathFor)
|
||
p2 := Plan(items, manifest, disk, testPathFor)
|
||
if len(p1) != len(p2) {
|
||
t.Fatal("same inputs must yield same plan length")
|
||
}
|
||
for i := range p1 {
|
||
if p1[i] != p2[i] {
|
||
t.Fatalf("plan not deterministic at %d: %+v vs %+v", i, p1[i], p2[i])
|
||
}
|
||
}
|
||
if manifest.Items["skills/b"] == nil || len(items) != 1 {
|
||
t.Error("planner must not mutate its inputs")
|
||
}
|
||
}
|