diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 20:16:38 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 20:16:38 +0200 |
| commit | 7b2c020a08b3b9f291ba14c71b5fbb5693b6cf6f (patch) | |
| tree | 51dd0d05a7a7d14bcf344adf67bc418d061e92c2 /internal/engine/property_test.go | |
| parent | 754f362da077b06420ab8350dfb62bd2d8d89d75 (diff) | |
| download | krino-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.go | 239 |
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) + } + } + } +} |
