aboutsummaryrefslogtreecommitdiff
path: root/internal/engine/property_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 20:16:38 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 20:16:38 +0200
commit7b2c020a08b3b9f291ba14c71b5fbb5693b6cf6f (patch)
tree51dd0d05a7a7d14bcf344adf67bc418d061e92c2 /internal/engine/property_test.go
parent754f362da077b06420ab8350dfb62bd2d8d89d75 (diff)
downloadkrino-7b2c020a08b3b9f291ba14c71b5fbb5693b6cf6f.tar.gz
krino-7b2c020a08b3b9f291ba14c71b5fbb5693b6cf6f.zip
plan 8: generated apply and undo round trip; undo reverses chains within one file
Diffstat (limited to 'internal/engine/property_test.go')
-rw-r--r--internal/engine/property_test.go239
1 files changed, 239 insertions, 0 deletions
diff --git a/internal/engine/property_test.go b/internal/engine/property_test.go
new file mode 100644
index 0000000..39589da
--- /dev/null
+++ b/internal/engine/property_test.go
@@ -0,0 +1,239 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/journal"
+ "krino/internal/plan"
+)
+
+// propertyNames are the file names generated trees draw from: ordinary,
+// spaced, Polish, dash-led, newline-holding, double-extension and hidden.
+var propertyNames = []string{"a.pdf", "b.pdf", "a copy.pdf", "zażółć.txt", "-dash.pdf", "new\nline.pdf", "notes.txt", "x.tar.gz", ".hidden.pdf"}
+
+// propertyCase is one generated tree and rule set.
+type propertyCase struct {
+ files map[string]string // path under ~/dl -> content
+ existing map[string]string // path under ~ -> content, there before planning to force conflicts
+ rules string // dirs/dl.conf
+}
+
+// genCase draws a case from r: one to six files (some nested, some sharing
+// content), up to two files already sitting in destinations, and one to
+// three rules mixing moves, copies, renames and trash under every conflict
+// policy, with and without (stop). A (delete) is only ever a rule's last
+// action - the config refuses any action after it. Permanent delete is left
+// out: it cannot be undone by design.
+func genCase(r *rand.Rand) propertyCase {
+ c := propertyCase{files: map[string]string{}, existing: map[string]string{}}
+ for i := 0; i < 1+r.Intn(6); i++ {
+ name := propertyNames[r.Intn(len(propertyNames))]
+ if r.Intn(3) == 0 {
+ name = "sub/" + name
+ }
+ c.files[name] = []string{"one", "two", "one"}[r.Intn(3)] + " " + strconv.Itoa(r.Intn(3))
+ }
+ for i := 0; i < r.Intn(3); i++ {
+ dir := []string{"dl/Out", "dl/Out/2026", "backup"}[r.Intn(3)]
+ c.existing[dir+"/"+propertyNames[r.Intn(len(propertyNames))]] = "already here"
+ }
+ conds := []string{"", "(when (type pdf))", `(when (name "^a"))`, "(when (not (type txt)))"}
+ actions := []string{`(move "Out")`, `(move "Out/{mtime:%Y}")`, `(copy "~/backup")`, `(rename "r-{name}")`}
+ conflicts := []string{"", "(on-conflict suffix)", "(on-conflict skip)", "(on-conflict overwrite)"}
+ var b strings.Builder
+ b.WriteString("(path \"~/dl\")\n")
+ if r.Intn(2) == 0 {
+ b.WriteString("(recursive yes)\n")
+ }
+ for i := 0; i < 1+r.Intn(3); i++ {
+ fmt.Fprintf(&b, "(rule \"r%d\" %s %s", i, conflicts[r.Intn(len(conflicts))], conds[r.Intn(len(conds))])
+ for j := 0; j < 1+r.Intn(2); j++ {
+ b.WriteString(" " + actions[r.Intn(len(actions))])
+ }
+ if r.Intn(4) == 0 {
+ b.WriteString(" (delete)")
+ }
+ if r.Intn(2) == 0 {
+ b.WriteString(" (stop)")
+ }
+ b.WriteString(")\n")
+ }
+ c.rules = b.String()
+ return c
+}
+
+// propertySeeds is the seeds TestApplyUndoProperty runs: KRINO_PROPERTY_SEED
+// alone when set (to rerun a failure), else 1..KRINO_PROPERTY_RUNS, 40 by
+// default - raise it (say 2000) before a release.
+func propertySeeds(t *testing.T) []int64 {
+ if v := os.Getenv("KRINO_PROPERTY_SEED"); v != "" {
+ s, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ t.Fatalf("KRINO_PROPERTY_SEED=%q", v)
+ }
+ return []int64{s}
+ }
+ n := 40
+ if v := os.Getenv("KRINO_PROPERTY_RUNS"); v != "" {
+ var err error
+ if n, err = strconv.Atoi(v); err != nil || n < 1 {
+ t.Fatalf("KRINO_PROPERTY_RUNS=%q", v)
+ }
+ }
+ seeds := make([]int64, n)
+ for i := range seeds {
+ seeds[i] = int64(i + 1)
+ }
+ return seeds
+}
+
+// TestApplyUndoProperty: for generated trees and rules, applying every
+// chain loses no content - every file's content is still somewhere under
+// the home directory, the Trash included - and undoing the run then puts
+// the home directory back exactly: every file's content, mode and
+// modification time, and nothing extra. A failing subtest names its seed.
+func TestApplyUndoProperty(t *testing.T) {
+ for _, seed := range propertySeeds(t) {
+ t.Run(fmt.Sprintf("seed=%d", seed), func(t *testing.T) {
+ checkApplyUndo(t, genCase(rand.New(rand.NewSource(seed))))
+ })
+ }
+}
+
+// contentCounts counts the files of a snapshot by content hash.
+func contentCounts(snap map[string]string) map[string]int {
+ counts := map[string]int{}
+ for _, v := range snap {
+ counts[strings.Fields(v)[0]]++
+ }
+ return counts
+}
+
+// checkApplyUndo builds c in a sandbox, applies every chain, checks nothing
+// was lost, undoes the run and checks the home directory is as it was.
+func checkApplyUndo(t *testing.T, c propertyCase) {
+ h := sandbox(t)
+ old := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
+ put := func(p, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(p, old, old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.MkdirAll(filepath.Join(h, "dl"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for rel, body := range c.files {
+ put(filepath.Join(h, "dl", rel), body)
+ }
+ for rel, body := range c.existing {
+ put(filepath.Join(h, rel), body)
+ }
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": c.rules})
+ // userTree is the home directory without krino's own config and state.
+ userTree := func() map[string]string {
+ snap := snapshot(t, h)
+ for rel := range snap {
+ if strings.HasPrefix(rel, ".config/") || strings.HasPrefix(rel, ".local/") {
+ delete(snap, rel)
+ }
+ }
+ return snap
+ }
+ before := userTree()
+ t.Logf("rules:\n%s", c.rules)
+
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatalf("generated rules do not load: %v", errs)
+ }
+ ctx := context.Background()
+ dp, err := e.Plan(ctx, e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ approved := map[string]bool{}
+ for _, ch := range dp.Chains {
+ approved[ch.File.Rel] = true
+ }
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ run := journal.NewRunID(time.Now())
+ res, err := e.Apply(ctx, dp, approved, j, run)
+ j.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Failed != 0 {
+ t.Fatalf("%d files failed to apply: %+v", res.Failed, res.Files)
+ }
+ if len(res.Files) == 0 {
+ // No file had a step, so nothing ran and nothing was logged: there
+ // is no run to undo, and the tree must simply be untouched.
+ if after := userTree(); !reflect.DeepEqual(after, before) {
+ t.Fatalf("nothing was applied, yet the tree changed")
+ }
+ return
+ }
+
+ have := contentCounts(snapshot(t, h))
+ for hash, n := range contentCounts(before) {
+ if have[hash] < n {
+ t.Errorf("after apply, content %s is in %d files, was in %d", hash[:12], have[hash], n)
+ }
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range up.Files {
+ if f.Refused != "" {
+ t.Fatalf("undo refused %q: %s", f.File, f.Refused)
+ }
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ures, err := e.ApplyUndo(ctx, up, j2, journal.NewRunID(time.Now()))
+ j2.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ures.Failed != 0 {
+ t.Fatalf("%d files failed to undo: %+v", ures.Failed, ures.Files)
+ }
+ if after := userTree(); !reflect.DeepEqual(after, before) {
+ for rel, v := range before {
+ if after[rel] != v {
+ t.Errorf("%q after undo: %q, want %q", rel, after[rel], v)
+ }
+ }
+ for rel := range after {
+ if _, ok := before[rel]; !ok {
+ t.Errorf("%q is left behind after undo", rel)
+ }
+ }
+ }
+}