diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
| commit | 3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch) | |
| tree | ec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /cmd/krino/history_test.go | |
| parent | 24a84671ace373ae331fa83a1ff484990f4dff0e (diff) | |
| download | krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip | |
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'cmd/krino/history_test.go')
| -rw-r--r-- | cmd/krino/history_test.go | 317 |
1 files changed, 317 insertions, 0 deletions
diff --git a/cmd/krino/history_test.go b/cmd/krino/history_test.go new file mode 100644 index 0000000..2667b63 --- /dev/null +++ b/cmd/krino/history_test.go @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "krino/internal/engine" +) + +func TestLogListsRunsAndUndoReverses(t *testing.T) { + h := matchingFixture(t) + if code, _, errOut := runCLI(t, "-y"); code != 0 { + t.Fatalf("apply: %d %s", code, errOut) + } + filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") + if _, err := os.Stat(filed); err != nil { + t.Fatalf("nothing was filed: %v", err) + } + + code, out, errOut := runCLI(t, "log") + if code != 0 { + t.Fatalf("log: %d %s", code, errOut) + } + if !strings.Contains(out, "moved") || !strings.Contains(out, "dl") { + t.Errorf("log output:\n%s", out) + } + + if code, _, errOut = runCLI(t, "undo", "-y"); code != 0 { + t.Fatalf("undo: %d %s", code, errOut) + } + if _, err := os.Stat(filepath.Join(h, "dl", "inv1.txt")); err != nil { + t.Errorf("undo did not put the file back: %v", err) + } + if _, err := os.Stat(filed); !os.IsNotExist(err) { + t.Error("the filed copy survived the undo") + } + + if _, out, _ = runCLI(t, "log"); !strings.Contains(out, "undone") { + t.Errorf("log does not mark the run undone:\n%s", out) + } + if code, _, errOut = runCLI(t, "undo", "-y"); code == 0 { + t.Errorf("undoing an undo run succeeded: %q", errOut) + } +} + +func TestUndoDryRunChangesNothing(t *testing.T) { + h := matchingFixture(t) + runCLI(t, "-y") + filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") + if code, out, _ := runCLI(t, "undo", "-n"); code != 0 || !strings.Contains(out, "undo-move") { + t.Errorf("undo -n: %d\n%s", code, out) + } + if _, err := os.Stat(filed); err != nil { + t.Error("undo -n moved a file") + } +} + +// TestUndoFailsImmediatelyWithHeldLock is fix round 2026-09-12, item 1: an +// undo moves files just as an apply does, so it needs the same per-directory +// guard sort's TestSecondRunFailsImmediatelyWithYes already pins for the +// forward path (spec §11: "a second krino on the same directory ... fails +// immediately with -y"). The lock file is held under the config NAME "dl", +// not any filesystem path - UndoFile.Dir is the journal's `dir` column, +// which is the directory's name from krino.conf, not its root. +// +// Ruling (fix round 2026-09-12, follow-up): the lock is acquired before the +// plan is even shown, matching cmdSort's own window (acquired before +// Plan/review, held across both) rather than only around ApplyUndo - so +// this also asserts the refusal is noticed before any plan output reaches +// stdout. A version of this test that only checked the exit code and +// stderr would pass equally whether the lock were taken early or late, and +// so would not be pinning the thing this ruling is actually about. +func TestUndoFailsImmediatelyWithHeldLock(t *testing.T) { + h := matchingFixture(t) + if code, _, errOut := runCLI(t, "-y"); code != 0 { + t.Fatalf("apply: %d %s", code, errOut) + } + filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") + if _, err := os.Stat(filed); err != nil { + t.Fatalf("nothing was filed: %v", err) + } + + held := filepath.Join(h, ".local", "state", "krino", "dl.lock") + if err := os.MkdirAll(filepath.Dir(held), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(held, []byte(fmt.Sprintf("pid %d\n", os.Getpid())), 0o644); err != nil { + t.Fatal(err) + } + + code, out, errOut := runCLI(t, "undo", "-y") + if code != 1 || !strings.Contains(errOut, "another krino") { + t.Errorf("undo -y against a held lock: %d %q", code, errOut) + } + if strings.Contains(out, "to reverse") || strings.Contains(out, "undo-move") { + t.Errorf("undo printed the plan before noticing the held lock:\n%s", out) + } + if _, err := os.Stat(filed); err != nil { + t.Errorf("undo reversed the file despite the held lock: %v", err) + } +} + +// undoFiles builds minimal engine.UndoFile fixtures for reviewUndoFiles/ +// reviewUndoPerFile, named by rel path only (Dir left blank - the tests +// below never render it). +func undoFiles(rels ...string) []engine.UndoFile { + out := make([]engine.UndoFile, len(rels)) + for i, r := range rels { + out[i] = engine.UndoFile{File: r, Steps: []engine.UndoStep{{Action: "undo-move", Src: "/t/" + r, Dst: "/s/" + r}}} + } + return out +} + +// TestFinalizeUndoPlanMarksUnapprovedAsDeclined is the wiring point for fix +// round 2026-09-12, item 2: a refused file rides through untouched (its own +// Refused reason is what ApplyUndo checks first), an approved file rides +// through untouched too, and anything else - explicitly declined, or never +// reached because [d]/[q] cut a per-file review short - comes out with +// Declined set rather than being dropped from the plan. +func TestFinalizeUndoPlanMarksUnapprovedAsDeclined(t *testing.T) { + up := &engine.UndoPlan{Run: "r1", Files: []engine.UndoFile{ + {File: "a"}, // index 0: approved + {File: "b"}, // index 1: not approved -> declined + {File: "c", Refused: "gone"}, // index 2: refused, never declined + }} + out := finalizeUndoPlan(up, map[int]bool{0: true}) + if len(out.Files) != 3 { + t.Fatalf("files = %+v, want all three carried through", out.Files) + } + if out.Files[0].Declined || out.Files[0].Refused != "" { + t.Errorf("approved file changed: %+v", out.Files[0]) + } + if !out.Files[1].Declined || out.Files[1].Refused != "" { + t.Errorf("unapproved file not marked declined: %+v", out.Files[1]) + } + if out.Files[2].Declined { + t.Errorf("a refused file must not also be marked declined: %+v", out.Files[2]) + } + if out.Files[2].Refused != "gone" { + t.Errorf("refused file's reason changed: %+v", out.Files[2]) + } +} + +// TestReviewUndoApplyAll: [a] approves every reversible file by index. +func TestReviewUndoApplyAll(t *testing.T) { + approved, action, err := reviewUndoFiles(strings.NewReader("a"), new(strings.Builder), undoFiles("a", "b")) + if err != nil { + t.Fatal(err) + } + if action != 'a' || len(approved) != 2 || !approved[0] || !approved[1] { + t.Errorf("approved = %v action = %q; want both approved", approved, action) + } +} + +// TestReviewUndoSkipAndQuit: [s] and [q] both approve nothing. +func TestReviewUndoSkipAndQuit(t *testing.T) { + approved, action, _ := reviewUndoFiles(strings.NewReader("s"), new(strings.Builder), undoFiles("a", "b")) + if action != 's' || len(approved) != 0 { + t.Errorf("[s] = %q %v; want nothing approved", action, approved) + } + approved, action, _ = reviewUndoFiles(strings.NewReader("q"), new(strings.Builder), undoFiles("a", "b")) + if action != 'q' || len(approved) != 0 { + t.Errorf("[q] = %q %v; want nothing approved", action, approved) + } +} + +// TestReviewUndoChoosePerFile: [c] then per-file y/n, keyed by index. +func TestReviewUndoChoosePerFile(t *testing.T) { + approved, action, err := reviewUndoFiles(strings.NewReader("cyn"), new(strings.Builder), undoFiles("a", "b")) + if err != nil { + t.Fatal(err) + } + if action != 'c' || !approved[0] || approved[1] { + t.Errorf("approved = %v action = %q; want only index 0", approved, action) + } +} + +// TestReviewUndoRefusedFileNotPrompted is spec §10: a file PlanUndo already +// refused is shown (with its reason - covered end to end by +// TestUndoRefusesChangedDestination-style flows through cmdUndo) but never +// asked about, so a [c] session reading one key per file must not stall +// waiting for a key that reviewUndoPerFile never asks for. Three files, one +// key each for the two reversible ones ("y", "n"), none for the refused +// middle one: if it were prompted, the second key ("n") would answer for it +// instead of the third file, and this test would see index 2 approved +// instead of unset. +func TestReviewUndoRefusedFileNotPrompted(t *testing.T) { + files := undoFiles("a", "b", "c") + files[1].Refused = "b changed since the run" + out := new(strings.Builder) + approved, action, err := reviewUndoFiles(strings.NewReader("cyn"), out, files) + if err != nil { + t.Fatal(err) + } + if action != 'c' || !approved[0] || approved[1] || approved[2] { + t.Errorf("approved = %v action = %q; want only index 0 (1 is refused, 2 never reached)", approved, action) + } + if !strings.Contains(out.String(), "b changed since the run") { + t.Errorf("refused file's reason not shown:\n%s", out) + } +} + +// TestPrintUndoPlan is fix wave item 3 (Important), rebuilding fix round +// 2026-09-12's own golden test: that version hand-built its UndoSteps, +// including a Dst on the undo-copy step the real code never sets (Dst is +// deliberately left "" - trash.Put only chooses the entry name at execution +// time), so it was structurally incapable of catching the bug it was meant +// to guard against - an undo-copy row rendering as a bare +// "undo-copy → " with nothing said about what it would do to the +// user's backup copy, the single most destructive step an undo plan takes. +// The same lesson as Task 9's review: a hand-assembled fixture hides a test +// that cannot detect a broken copy-undo. This version runs a REAL forward +// apply (copy then move, so mkdir, copy and move all appear in one file's +// own chain) and a REAL PlanUndo, editing one file's result afterward so +// the plan also carries a genuinely refused row, then renders that. +func TestPrintUndoPlan(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + if err := os.MkdirAll(filepath.Join(dl, "Work"), 0o755); err != nil { + t.Fatal(err) + } + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for _, n := range []string{"inv1.pdf", "notes.pdf"} { + p := filepath.Join(dl, n) + if err := os.WriteFile(p, []byte("content of "+n), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + // "keep" mixes all three action kinds a single file's own chain can + // carry: copy needs a fresh ~/backup (one mkdir), move lands in the + // pre-created Work (no mkdir of its own). + rules := "(path \"~/dl\")\n(min-age 0s)\n(rule \"keep\" (when (type pdf)) (copy \"~/backup\") (move \"Work\"))\n" + if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(rules), 0o644); err != nil { + t.Fatal(err) + } + if code, _, errOut := runCLI(t, "-y"); code != 0 { + t.Fatalf("apply: %d %s", code, errOut) + } + + // notes.pdf's moved copy is edited after the run, so PlanUndo genuinely + // refuses its reversal - the row this exercises must still say + // something true, never render blank. + moved := filepath.Join(dl, "Work", "notes.pdf") + if err := os.WriteFile(moved, []byte("edited since the run"), 0o644); err != nil { + t.Fatal(err) + } + + e, errs := engine.Load(filepath.Join(h, ".config", "krino", "krino.conf")) + if len(errs) > 0 { + t.Fatal(errs) + } + runs, err := e.Runs(1) + if err != nil || len(runs) != 1 { + t.Fatalf("runs = %+v, err = %v", runs, err) + } + up, err := e.PlanUndo(runs[0].ID) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + printUndoPlan(&buf, up) + out := buf.String() + + for _, want := range []string{ + "2 files · 1 to reverse · 1 refused\n", + "undo-move → ~/dl/inv1.pdf\n", + "undo-copy ~/backup/inv1.pdf → trash\n", + "undo-mkdir ~/backup\n", + // Minor 4 / fix wave item 5: the refusal reason must be abbreviated + // against $HOME exactly like every step cell above it, not printed + // as a raw absolute path. + "refused: ~/dl/Work/notes.pdf changed since the run\n", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, "undo-copy → ") { + t.Errorf("undo-copy rendered a blank destination:\n%s", out) + } + if strings.Contains(out, h) { + t.Errorf("output leaked a raw absolute path instead of abbreviating against $HOME:\n%s", out) + } +} + +// TestReviewUndoInvalidKeyReprompts mirrors review_test.go's +// TestInvalidKeyReprompts for the undo-specific menu. +func TestReviewUndoInvalidKeyReprompts(t *testing.T) { + out := new(strings.Builder) + approved, action, err := reviewUndoFiles(strings.NewReader("zs"), out, undoFiles("a")) + if err != nil { + t.Fatal(err) + } + if action != 's' || len(approved) != 0 { + t.Errorf("approved = %v action = %q; want [s] after the bad key", approved, action) + } + if !strings.Contains(out.String(), "z") { + t.Errorf("no mention of the rejected key:\n%s", out) + } +} |
