Files
agent-lib/internal/client/planner_test.go
T
m3ta-chiron 63619b90a4 feat: three-state sync planner with warning semantics
- 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
2026-08-23 10:36:44 +02:00

212 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}