diff options
Diffstat (limited to 'cmd/krino/render_test.go')
| -rw-r--r-- | cmd/krino/render_test.go | 283 |
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) + } +} |
