Files
agent-lib/internal/client/planner_test.go
T
m3ta-chiron 1ab97bff43 feat(client): sync --force to escape the locally-modified skip
Managed items with local modifications were kept (skip + warn) with no
escape hatch other than deleting the deployed file by hand — after an
upstream change the deployed content silently stayed old, which reads
exactly like a stale cache.

--force turns the two skip cases into update (repository version wins)
and remove (upstream deleted + modified). Unmanaged items and never-
managed path occupancies stay untouched even with --force; the default
behavior is unchanged.
2026-10-02 12:34:57 +02:00

260 lines
8.4 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, false)
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")
}
}
})
}
}
// TestPlanForceMatrix asserts how --force changes the skip cases: locally
// modified managed items become updates (or removes after upstream
// deletion), while never-managed occupancies stay skipped even with force.
func TestPlanForceMatrix(t *testing.T) {
cases := []struct {
name string
up string // "changed", "unchanged", "gone"
want Action
}{
{"modified/changed", "changed", ActUpdate},
{"modified/unchanged", "unchanged", ActUpdate},
{"modified/gone", "gone", ActRemove},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
key := manifestKey("skills", "alpha")
var items []PlannedItem
manifest := NewManifest()
manifest.Items[key] = &ManifestItem{Name: "alpha", Type: "skills", Hash: hashV1}
if tc.up != "gone" {
hash := hashV2
if tc.up == "unchanged" {
hash = hashV1
}
items = append(items, PlannedItem{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hash})
}
disk := map[string]DiskFact{key: {Exists: true, Hash: hashMod}}
plan := Plan(items, manifest, disk, testPathFor, true)
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)
}
})
}
t.Run("never-managed occupancy stays skipped", func(t *testing.T) {
items := []PlannedItem{{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hashV1}}
disk := map[string]DiskFact{manifestKey("skills", "alpha"): {Exists: true, Hash: hashMod}}
plan := Plan(items, NewManifest(), disk, testPathFor, true)
if len(plan) != 1 || plan[0].Action != ActSkip {
t.Fatalf("force must not deploy over a never-managed item: %+v", plan)
}
})
}
// 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, false)
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, false)
p2 := Plan(items, manifest, disk, testPathFor, false)
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")
}
}