summaryrefslogtreecommitdiff
path: root/cmd/krino/render_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 /cmd/krino/render_test.go
parent3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff)
downloadkrino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz
krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip
krino: planning — chains, placeholders, conflicts, JSON
Diffstat (limited to 'cmd/krino/render_test.go')
-rw-r--r--cmd/krino/render_test.go283
1 files changed, 283 insertions, 0 deletions
diff --git a/cmd/krino/render_test.go b/cmd/krino/render_test.go
new file mode 100644
index 0000000..0a6ae26
--- /dev/null
+++ b/cmd/krino/render_test.go
@@ -0,0 +1,283 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/engine"
+ "krino/internal/plan"
+ "krino/internal/scan"
+)
+
+// TestPrintPlan is the golden render test of spec §8.2. The DirPlan is
+// built by hand, not by running a scan, so the expected output cannot
+// drift with a fixture: it asserts the header row, a numbered row, a
+// continuation line, the "DELETE permanently" capitalisation and the
+// counts line.
+//
+// The two destinations pin both branches of destText: scan001.pdf's and
+// fv_123.pdf's "acme" step lands inside root and renders root-relative
+// ("Work/Acme/2026/"), while fv_123.pdf's "backup" step lands under $HOME
+// but outside root and stays home-abbreviated ("~/backup/invoices/2026/")
+// - spec §8.2's own worked example draws exactly this distinction.
+//
+// "excluded.txt" matched a rule with no actions (an exclusion, spec §4.5):
+// it has zero steps, so it must not appear in the table and must not
+// inflate "to act on" (ruling 2026-09-12).
+func TestPrintPlan(t *testing.T) {
+ h := home(t)
+ root := filepath.Join(h, "downloads")
+
+ scan001 := plan.Chain{
+ File: scan.File{Rel: "scan001.pdf"},
+ Steps: []plan.Step{
+ {
+ Kind: plan.Move,
+ Rule: "acme",
+ Src: filepath.Join(root, "scan001.pdf"),
+ Dst: filepath.Join(root, "Work", "Acme", "2026", "scan001.pdf"),
+ Reason: `content "acme ltd"`,
+ },
+ },
+ }
+ fv123 := plan.Chain{
+ File: scan.File{Rel: "fv_123.pdf"},
+ Steps: []plan.Step{
+ {
+ Kind: plan.Copy,
+ Rule: "backup",
+ Src: filepath.Join(root, "fv_123.pdf"),
+ Dst: filepath.Join(h, "backup", "invoices", "2026", "fv_123.pdf"),
+ Reason: `content "invoice"`,
+ },
+ {
+ Kind: plan.Move,
+ Rule: "acme",
+ Src: filepath.Join(h, "backup", "invoices", "2026", "fv_123.pdf"),
+ Dst: filepath.Join(root, "Work", "Acme", "2026", "fv_123.pdf"),
+ Reason: `name \bacme\b`,
+ },
+ },
+ }
+ setup := plan.Chain{
+ File: scan.File{Rel: "setup-1.2.deb"},
+ Steps: []plan.Step{
+ {Kind: plan.DeletePermanent, Rule: "old-pkgs", Src: filepath.Join(root, "setup-1.2.deb"), Reason: "age 94d"},
+ },
+ }
+ excluded := plan.Chain{File: scan.File{Rel: "excluded.txt"}} // matched a stop-only rule: no actions, no steps
+
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "downloads", Root: root},
+ Chains: []plan.Chain{scan001, fv123, setup, excluded},
+ Elapsed: 420 * time.Millisecond, // D12: the counts line renders DirPlan.Elapsed (Match plus Build), not Result.Elapsed alone
+ Result: &engine.Result{
+ Matched: []engine.FileMatch{
+ {File: scan.File{Rel: "scan001.pdf"}, Warnings: []string{"acme: content unreadable: needs pdftotext, not installed"}},
+ {File: scan.File{Rel: "fv_123.pdf"}},
+ {File: scan.File{Rel: "setup-1.2.deb"}},
+ {File: scan.File{Rel: "excluded.txt"}},
+ },
+ Unmatched: []engine.FileMatch{{File: scan.File{Rel: "unmatched.txt"}}},
+ Skipped: []scan.Skipped{{Rel: "busy.tmp", Reason: scan.Busy}},
+ },
+ }
+
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false)
+ out := buf.String()
+
+ for _, want := range []string{
+ "6 scanned · 3 to act on · 1 warnings · 0.42s\n",
+ " # file actions rule\n",
+ " 1 scan001.pdf move → Work/Acme/2026/ acme content \"acme ltd\"\n",
+ " 2 fv_123.pdf copy → ~/backup/invoices/2026/ backup content \"invoice\"\n",
+ " move → Work/Acme/2026/ acme name \\bacme\\b\n",
+ " 3 setup-1.2.deb DELETE permanently old-pkgs age 94d\n",
+ "warnings\n scan001.pdf acme: content unreadable: needs pdftotext, not installed\n",
+ "not acted on: 1 busy · 1 excluded · 1 unmatched (-v lists them)\n",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("output lacks %q:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "excluded.txt") {
+ t.Errorf("excluded.txt has no steps and must not appear in the table:\n%s", out)
+ }
+}
+
+// TestPrintPlanSkippedStep: a step with Skip set shows its reason in place
+// of the destination, and a Displaces step notes that it replaces the
+// existing file.
+func TestPrintPlanSkippedStep(t *testing.T) {
+ home(t) // isolate HOME even though these paths do not use it
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: "/r"},
+ Chains: []plan.Chain{
+ {
+ File: scan.File{Rel: "a.pdf"},
+ Steps: []plan.Step{
+ {Kind: plan.Copy, Rule: "backup", Src: "/r/a.pdf", Dst: "/backup/a.pdf", Skip: "target exists"},
+ },
+ },
+ {
+ File: scan.File{Rel: "b.pdf"},
+ Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "acme", Src: "/r/b.pdf", Dst: "/r/Work/b.pdf", Displaces: "/r/Work/b.pdf"},
+ },
+ },
+ },
+ Result: &engine.Result{
+ Matched: []engine.FileMatch{{File: scan.File{Rel: "a.pdf"}}, {File: scan.File{Rel: "b.pdf"}}},
+ },
+ }
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false)
+ out := buf.String()
+ if !strings.Contains(out, "copy skipped: target exists") {
+ t.Errorf("skipped step should show its reason in place of the destination:\n%s", out)
+ }
+ if !strings.Contains(out, "move → Work/ (replaces the existing file)") {
+ t.Errorf("a Displaces step should note it replaces the existing file:\n%s", out)
+ }
+ if !strings.Contains(out, "2 scanned · 1 to act on · 0 warnings ·") {
+ t.Errorf("a.pdf's only step is skipped, so it must not count as \"to act on\":\n%s", out)
+ }
+}
+
+// TestPrintPlanRowNumberAlignment pins the table layout plan 4's review UI
+// inherits: with 11 acted-on files the row-number column has to widen past
+// a single digit, and the row number is right-aligned so "#" stays flush.
+// The fixture also carries a file name past the 40-character cap and a
+// rule name noticeably longer than the rest, exercising the file and rule
+// columns' own per-section widths at the same time.
+func TestPrintPlanRowNumberAlignment(t *testing.T) {
+ h := home(t)
+ root := filepath.Join(h, "dl")
+ long := strings.Repeat("z", 42) + ".txt" // 46 runes: past the 40-column cap
+
+ names := make([]string, 11)
+ for i := range names {
+ names[i] = fmt.Sprintf("f%02d.txt", i+1)
+ }
+ names[10] = long // row 11 carries the long name
+
+ var chains []plan.Chain
+ var matched []engine.FileMatch
+ for i, name := range names {
+ rule := "r"
+ if i == 5 {
+ rule = "a-noticeably-longer-rule-name"
+ }
+ chains = append(chains, plan.Chain{
+ File: scan.File{Rel: name},
+ Steps: []plan.Step{
+ {Kind: plan.Move, Rule: rule, Src: filepath.Join(root, name), Dst: filepath.Join(root, "Out", name), Reason: "type txt"},
+ },
+ })
+ matched = append(matched, engine.FileMatch{File: scan.File{Rel: name}})
+ }
+
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: root},
+ Chains: chains,
+ Result: &engine.Result{Matched: matched},
+ }
+
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false)
+ out := buf.String()
+
+ for _, want := range []string{
+ " # file actions rule\n",
+ " 1 f01.txt move → Out/ r type txt\n",
+ " 6 f06.txt move → Out/ a-noticeably-longer-rule-name type txt\n",
+ " 10 f10.txt move → Out/ r type txt\n",
+ " 11 " + long + " move → Out/ r type txt\n",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("output lacks %q:\n%s", want, out)
+ }
+ }
+}
+
+// TestPrintPlanCountsFileOnceWithBothWarningKinds: B2 - a file may carry
+// both a match warning (Result.Matched[i].Warnings) and a chain warning
+// (Chain.Warnings, e.g. "moved more than once"). Both must reach the
+// warnings section, but the counts line's "N warnings" counts files with at
+// least one warning, not warning lines, so this one file must still count
+// as 1, not 2.
+func TestPrintPlanCountsFileOnceWithBothWarningKinds(t *testing.T) {
+ h := home(t)
+ root := filepath.Join(h, "dl")
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: root},
+ Chains: []plan.Chain{
+ {
+ File: scan.File{Rel: "a.pdf"},
+ Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "r1", Src: filepath.Join(root, "a.pdf"), Dst: filepath.Join(root, "Out", "a.pdf")},
+ {Kind: plan.Move, Rule: "r2", Src: filepath.Join(root, "Out", "a.pdf"), Dst: filepath.Join(root, "Out2", "a.pdf")},
+ },
+ Warnings: []string{"moved more than once; a (stop) is probably missing"},
+ },
+ },
+ Result: &engine.Result{
+ Matched: []engine.FileMatch{
+ {File: scan.File{Rel: "a.pdf"}, Warnings: []string{"r1: content unreadable: needs pdftotext, not installed"}},
+ },
+ },
+ }
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false)
+ out := buf.String()
+
+ for _, want := range []string{
+ "1 scanned · 1 to act on · 1 warnings ·",
+ " a.pdf r1: content unreadable: needs pdftotext, not installed\n",
+ " a.pdf moved more than once; a (stop) is probably missing\n",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("output lacks %q:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "2 warnings") {
+ t.Errorf("one file with two warnings must count once, not twice:\n%s", out)
+ }
+}
+
+// TestSkipSummaryLineAccountsForEveryFile pins that the footer's categories
+// add up to "scanned". The real downloads folder reported "267 scanned · 172
+// to act on" while saying nothing about the other 95, which had matched an
+// exclusion rule carrying no actions: they were neither acted on, nor
+// unmatched, nor skipped by the walk.
+func TestSkipSummaryLineAccountsForEveryFile(t *testing.T) {
+ r := &engine.Result{
+ Matched: make([]engine.FileMatch, 4),
+ Unmatched: make([]engine.FileMatch, 2),
+ Skipped: []scan.Skipped{{Rel: "a.part", Reason: scan.Ignored}, {Rel: "b.iso", Reason: scan.Busy}},
+ }
+ chains := []plan.Chain{
+ {Steps: []plan.Step{{Kind: plan.Move, Dst: "/r/W/x"}}}, // acting
+ {}, // excluded: matched an action-less rule
+ {}, // excluded
+ {Steps: []plan.Step{{Kind: plan.Move, Skip: "target exists"}}}, // every step skipped
+ }
+ got := skipSummaryLine(r, chains, false)
+ want := "not acted on: 1 ignored · 1 busy · 2 excluded · 1 all steps skipped · 2 unmatched (-v lists them)"
+ if got != want {
+ t.Errorf("line =\n%q\nwant\n%q", got, want)
+ }
+ scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped)
+ excluded, allSkipped := chainOutcomes(chains)
+ if acting := countActing(chains); acting+excluded+allSkipped+len(r.Unmatched)+len(r.Skipped) != scanned {
+ t.Errorf("categories do not sum to scanned: %d acting + %d excluded + %d all-skipped + %d unmatched + %d skipped != %d",
+ acting, excluded, allSkipped, len(r.Unmatched), len(r.Skipped), scanned)
+ }
+}