aboutsummaryrefslogtreecommitdiff
path: root/internal/plan/chain_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 12:58:14 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 12:58:14 +0200
commit24a84671ace373ae331fa83a1ff484990f4dff0e (patch)
treea6b6e3949d7dd241f1d13e079dfb982d758c89a2 /internal/plan/chain_test.go
parent3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff)
downloadkrino-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.go143
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)
+ }
+ }
+}