diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
| commit | 24a84671ace373ae331fa83a1ff484990f4dff0e (patch) | |
| tree | a6b6e3949d7dd241f1d13e079dfb982d758c89a2 /internal/plan/chain_test.go | |
| parent | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff) | |
| download | krino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip | |
krino: planning — chains, placeholders, conflicts, JSON
Diffstat (limited to 'internal/plan/chain_test.go')
| -rw-r--r-- | internal/plan/chain_test.go | 143 |
1 files changed, 143 insertions, 0 deletions
diff --git a/internal/plan/chain_test.go b/internal/plan/chain_test.go new file mode 100644 index 0000000..b6e3dfe --- /dev/null +++ b/internal/plan/chain_test.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package plan + +import ( + "path/filepath" + "testing" + "time" + + "krino/internal/config" + "krino/internal/scan" +) + +func file(root, rel string) scan.File { + return scan.File{ + Path: filepath.Join(root, rel), + Rel: rel, + Name: filepath.Base(rel), + Size: 10, + ModTime: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC), + } +} + +func act(k config.ActionKind, arg string) config.Action { return config.Action{Kind: k, Arg: arg} } + +func TestBuildChain(t *testing.T) { + root := "/r" + now := time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC) + in := []Input{{ + File: file(root, "inv1.pdf"), + Rules: []RuleMatch{ + {Name: "backup", Actions: []config.Action{act(config.Copy, "/backup/{mtime:%Y}")}}, + {Name: "acme", Actions: []config.Action{ + act(config.Rename, "{mtime:%Y-%m-%d}_{name}"), + act(config.Move, "Work/Acme/{mtime:%Y}"), + }}, + }, + }} + got := Build(root, in, now, NoDisk{}, NewClaims()) + if len(got) != 1 || len(got[0].Steps) != 3 { + t.Fatalf("got %d chains / %d steps", len(got), len(got[0].Steps)) + } + want := []Step{ + {Kind: Copy, Rule: "backup", Src: "/r/inv1.pdf", Dst: "/backup/2026/inv1.pdf"}, + {Kind: Rename, Rule: "acme", Src: "/r/inv1.pdf", Dst: "/r/2026-08-15_inv1.pdf"}, + {Kind: Move, Rule: "acme", Src: "/r/2026-08-15_inv1.pdf", Dst: "/r/Work/Acme/2026/2026-08-15_inv1.pdf"}, + } + for i, w := range want { + g := got[0].Steps[i] + if g.Kind != w.Kind || g.Rule != w.Rule || g.Src != w.Src || g.Dst != w.Dst || g.Skip != "" { + t.Errorf("step %d = %+v; want %+v", i, g, w) + } + } + if len(got[0].Warnings) != 0 { + t.Errorf("unexpected warnings: %v", got[0].Warnings) + } +} + +func TestBuildDeleteEndsChain(t *testing.T) { + in := []Input{{ + File: file("/r", "old.iso"), + Rules: []RuleMatch{ + {Name: "dups", Actions: []config.Action{act(config.Delete, "")}}, + {Name: "archive", Actions: []config.Action{act(config.Move, "Archive")}}, + }, + }} + steps := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0].Steps + if len(steps) != 2 || steps[0].Kind != Trash || steps[0].Dst != "" { + t.Fatalf("steps = %+v", steps) + } + if steps[1].Skip != "deleted by rule dups" { + t.Errorf("step after delete: Skip = %q", steps[1].Skip) + } +} + +func TestBuildWarnsOnTwoMoves(t *testing.T) { + in := []Input{{ + File: file("/r", "x.pdf"), + Rules: []RuleMatch{ + {Name: "a", Actions: []config.Action{act(config.Move, "A")}}, + {Name: "b", Actions: []config.Action{act(config.Move, "B")}}, + }, + }} + c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0] + if len(c.Warnings) != 1 || c.Warnings[0] != "moved more than once; a (stop) is probably missing" { + t.Errorf("warnings = %v", c.Warnings) + } + if c.Steps[1].Src != "/r/A/x.pdf" { + t.Errorf("second move reads from %q, want the path after the first move", c.Steps[1].Src) + } +} + +func TestBuildBadPlaceholderSkipsOneStep(t *testing.T) { + in := []Input{{ + File: file("/r", "x.pdf"), + Rules: []RuleMatch{{Name: "a", Actions: []config.Action{ + act(config.Move, "Work/{1}"), + act(config.Rename, "ok-{name}"), + }}}, + }} + c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0] + if c.Steps[0].Skip == "" { + t.Errorf("step with {1} and no captures should be skipped: %+v", c.Steps[0]) + } + if c.Steps[1].Skip != "" || c.Steps[1].Dst != "/r/ok-x.pdf" { + t.Errorf("chain should continue from the unchanged path: %+v", c.Steps[1]) + } +} + +func TestBuildRenameWithSlash(t *testing.T) { + in := []Input{{ + File: file("/r", "x.pdf"), + Rules: []RuleMatch{{Name: "a", Actions: []config.Action{act(config.Rename, "sub/{name}")}}}, + }} + c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0] + if c.Steps[0].Skip != `rename produced a name containing "/"` { + t.Errorf("Skip = %q", c.Steps[0].Skip) + } +} + +// TestBuildKeepsSteplessChains is D6: Build returns one Chain per Input even +// when a file's rules contribute no actions, so a caller can tell "matched a +// rule that does nothing" (an exclusion) from "not matched at all". Plan 3's +// "to act on" count and the JSON document's empty steps array both rest on +// this. +func TestBuildKeepsSteplessChains(t *testing.T) { + in := []Input{ + {File: file("/r", "excluded.txt"), Rules: []RuleMatch{{Name: "only-stop"}}}, + {File: file("/r", "untouched.txt")}, + } + chains := Build("/r", in, time.Now(), NoDisk{}, NewClaims()) + if len(chains) != 2 { + t.Fatalf("got %d chains, want one per Input", len(chains)) + } + for i, c := range chains { + if len(c.Steps) != 0 { + t.Errorf("chain %d: got %d steps, want none", i, len(c.Steps)) + } + if c.File.Rel != in[i].File.Rel { + t.Errorf("chain %d: File.Rel = %q, want %q (positional alignment)", i, c.File.Rel, in[i].File.Rel) + } + } +} |
