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.
260 lines
8.4 KiB
Go
260 lines
8.4 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, 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")
|
||
}
|
||
}
|