clerk/internal/linter/linter_test.go (view raw)
| 1 | package linter |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "olexsmir.xyz/clerk/internal/analyzer" |
| 10 | "olexsmir.xyz/clerk/internal/testutil/golden" |
| 11 | "olexsmir.xyz/clerk/journal" |
| 12 | ) |
| 13 | |
| 14 | var tests = map[string][]Rule{ |
| 15 | "correct": allRules(), |
| 16 | "invalid-include": {&InvalidInclude{}}, |
| 17 | "empty-postings": {&EmptyPostings{}}, |
| 18 | "parse-error": {&ParseError{}}, |
| 19 | "omitted-precision": {&OmittedPrecision{}}, |
| 20 | "missing-commodity": {&MissingCommodity{}}, |
| 21 | "missing-status": {&MissingStatus{}}, |
| 22 | "missing-payee": {&MissingPayee{}}, |
| 23 | "account-depth": {&AccountDepthLimit{MaxDepth: 3}}, |
| 24 | "multiple-omitted-amounts": {&MultipleOmittedAmounts{}}, |
| 25 | "orderdate": {&OrderDate{}}, |
| 26 | "duplicated-account": {&DuplicatedAccount{}}, |
| 27 | "duplicated-commodity": {&DuplicatedCommodity{}}, |
| 28 | "duplicated-transaction": {&DuplicatedTransaction{}}, |
| 29 | "duplicated-tag": {&DuplicatedTag{}}, |
| 30 | "undeclared-tag": {&UndeclaredTag{}}, |
| 31 | "undeclared-commodity": {&UndeclaredCommodity{}}, |
| 32 | "undeclared-account": {&UndeclaredAccount{}}, |
| 33 | "undeclared-payee": {&UndeclaredPayee{}}, |
| 34 | "unbalanced-transaction": {&UnbalancedTransaction{}}, |
| 35 | "unused-account": {&UnusedAccount{}}, |
| 36 | "unused-tag": {&UnusedTag{}}, |
| 37 | "invalid-date-tag": {&InvalidDateTag{}}, |
| 38 | "invalid-type-tag": {&InvalidTypeTag{}}, |
| 39 | } |
| 40 | |
| 41 | func TestLinter(t *testing.T) { |
| 42 | for tname, trules := range tests { |
| 43 | t.Run(tname, func(t *testing.T) { |
| 44 | a := golden.Read(t, tname) |
| 45 | fsys, err := a.FS() |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | |
| 50 | l := journal.NewLoader() |
| 51 | rj, err := l.ResolveFS(fsys, "in.journal") |
| 52 | if err != nil { |
| 53 | t.Fatalf("failed to load test journal: %v", err) |
| 54 | } |
| 55 | |
| 56 | ctx := analyzer.Build(rj) |
| 57 | finds := (&Linter{rules: trules}).Run(ctx) |
| 58 | |
| 59 | var b strings.Builder |
| 60 | fprint(&b, PathBasename, finds) |
| 61 | golden.Assert(t, a, b.String()) |
| 62 | }) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func BenchmarkLinter(b *testing.B) { |
| 67 | ldr := journal.NewLoader() |
| 68 | rj, err := ldr.Resolve("../../journal/testdata/journals/actual-1ktxns-100accts.journal") |
| 69 | if err != nil { |
| 70 | b.Fatalf("failed to load benchmark journal: %v", err) |
| 71 | } |
| 72 | |
| 73 | ctx := analyzer.Build(rj) |
| 74 | l, err := NewLinter(Config{}) |
| 75 | if err != nil { |
| 76 | b.Fatal(err) |
| 77 | } |
| 78 | |
| 79 | b.ResetTimer() |
| 80 | b.ReportAllocs() |
| 81 | for b.Loop() { |
| 82 | l.Run(ctx) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func allRules() []Rule { |
| 87 | out := make([]Rule, 0, len(Rules)) |
| 88 | for _, b := range Rules { |
| 89 | out = append(out, b.Rule) |
| 90 | } |
| 91 | return out |
| 92 | } |
| 93 | |
| 94 | func TestNewLinter(t *testing.T) { |
| 95 | offByDefault := []RuleID{UndeclaredPayeeID, UndeclaredTagID} |
| 96 | t.Run("all-default", func(t *testing.T) { |
| 97 | l, err := NewLinter(Config{}) |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | var got []RuleID |
| 102 | for _, r := range l.rules { |
| 103 | got = append(got, r.ID()) |
| 104 | } |
| 105 | if want := without(builtinIDs(), offByDefault...); !slices.Equal(got, want) { |
| 106 | t.Errorf("got %v, want %v", got, want) |
| 107 | } |
| 108 | }) |
| 109 | |
| 110 | t.Run("disabled-omitted", func(t *testing.T) { |
| 111 | l, err := NewLinter(Config{Rules: map[RuleID]RuleConfig{ |
| 112 | OrderDateID: {Disabled: true}, |
| 113 | MissingPayeeID: {Disabled: true}, |
| 114 | }}) |
| 115 | if err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | var got []RuleID |
| 119 | for _, r := range l.rules { |
| 120 | got = append(got, r.ID()) |
| 121 | } |
| 122 | want := without(builtinIDs(), OrderDateID, MissingPayeeID) |
| 123 | want = without(want, offByDefault...) |
| 124 | if !slices.Equal(got, want) { |
| 125 | t.Errorf("got %v, want %v", got, want) |
| 126 | } |
| 127 | }) |
| 128 | |
| 129 | t.Run("severity-none-enabled-by-config", func(t *testing.T) { |
| 130 | l, err := NewLinter(Config{Rules: map[RuleID]RuleConfig{UndeclaredPayeeID: {Severity: SeverityWarning}}}) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | var got []RuleID |
| 135 | for _, r := range l.rules { |
| 136 | got = append(got, r.ID()) |
| 137 | } |
| 138 | want := without(builtinIDs(), UndeclaredTagID) |
| 139 | if !slices.Equal(got, want) { |
| 140 | t.Errorf("got %v, want %v", got, want) |
| 141 | } |
| 142 | }) |
| 143 | |
| 144 | t.Run("options-applied", func(t *testing.T) { |
| 145 | l, err := NewLinter(Config{Rules: map[RuleID]RuleConfig{ |
| 146 | AccountDepthLimitID: {Options: json.RawMessage(`{"max-depth": 2}`)}, |
| 147 | }}) |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | for _, r := range l.rules { |
| 152 | if r.ID() != AccountDepthLimitID { |
| 153 | continue |
| 154 | } |
| 155 | if got := r.(*AccountDepthLimit).MaxDepth; got != 2 { |
| 156 | t.Errorf("MaxDepth = %d, want 2", got) |
| 157 | } |
| 158 | if def := Rules[AccountDepthLimitID].Rule.(*AccountDepthLimit).MaxDepth; def != 4 { |
| 159 | t.Errorf("shared built-in mutated: MaxDepth = %d, want 4", def) |
| 160 | } |
| 161 | return |
| 162 | } |
| 163 | t.Error("account-depth rule not found") |
| 164 | }) |
| 165 | |
| 166 | t.Run("options-on-non-optionable-rule", func(t *testing.T) { |
| 167 | cfg := Config{Rules: map[RuleID]RuleConfig{ |
| 168 | ParseErrorID: {Options: json.RawMessage(`{"x":1}`)}, |
| 169 | }} |
| 170 | if _, err := NewLinter(cfg); err == nil { |
| 171 | t.Error("expected error") |
| 172 | } |
| 173 | }) |
| 174 | |
| 175 | t.Run("unknown-option-key", func(t *testing.T) { |
| 176 | cfg := Config{Rules: map[RuleID]RuleConfig{ |
| 177 | AccountDepthLimitID: {Options: json.RawMessage(`{"nope": 1}`)}, |
| 178 | }} |
| 179 | if _, err := NewLinter(cfg); err == nil { |
| 180 | t.Error("expected error") |
| 181 | } |
| 182 | }) |
| 183 | } |
| 184 | |
| 185 | func builtinIDs() []RuleID { |
| 186 | ids := make([]RuleID, 0, len(Rules)) |
| 187 | for id := range Rules { |
| 188 | ids = append(ids, id) |
| 189 | } |
| 190 | slices.Sort(ids) |
| 191 | return ids |
| 192 | } |
| 193 | |
| 194 | func without(ids []RuleID, drop ...RuleID) []RuleID { |
| 195 | out := ids[:0:0] |
| 196 | for _, id := range ids { |
| 197 | if !slices.Contains(drop, id) { |
| 198 | out = append(out, id) |
| 199 | } |
| 200 | } |
| 201 | return out |
| 202 | } |