clerk/journal/loader_test.go (view raw)
| 1 | package journal |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func TestLoader_LoadBytes(t *testing.T) { |
| 10 | t.Run("empty", func(t *testing.T) { |
| 11 | l := NewLoader() |
| 12 | pf, err := l.LoadBytes("empty.journal", []byte{}) |
| 13 | if err != nil { |
| 14 | t.Fatalf("unexpected error: %v", err) |
| 15 | } |
| 16 | if pf.Ast == nil { |
| 17 | t.Fatal("expected non-nil AST") |
| 18 | } |
| 19 | }) |
| 20 | |
| 21 | t.Run("transaction", func(t *testing.T) { |
| 22 | l := NewLoader() |
| 23 | src := []byte("2024/01/01 groceries\n expenses:food $10\n assets:checking\n") |
| 24 | pf, err := l.LoadBytes("t.journal", src) |
| 25 | if err != nil { |
| 26 | t.Fatalf("unexpected error: %v", err) |
| 27 | } |
| 28 | if len(pf.Errors) > 0 { |
| 29 | t.Fatalf("unexpected parse errors: %v", pf.Errors) |
| 30 | } |
| 31 | if len(pf.FileErrors) > 0 { |
| 32 | t.Fatalf("unexpected file errors: %v", pf.FileErrors) |
| 33 | } |
| 34 | }) |
| 35 | |
| 36 | t.Run("parse errors", func(t *testing.T) { |
| 37 | l := NewLoader() |
| 38 | src := []byte("@@@ garbage\n") |
| 39 | pf, err := l.LoadBytes("bad.journal", src) |
| 40 | if err != nil { |
| 41 | t.Fatalf("unexpected error: %v", err) |
| 42 | } |
| 43 | if len(pf.Errors) == 0 { |
| 44 | t.Fatal("expected parse errors") |
| 45 | } |
| 46 | }) |
| 47 | |
| 48 | t.Run("include not found", func(t *testing.T) { |
| 49 | l := NewLoader() |
| 50 | src := []byte("include nonexistent.journal\n") |
| 51 | pf, err := l.LoadBytes("parent.journal", src) |
| 52 | if err != nil { |
| 53 | t.Fatalf("unexpected error: %v", err) |
| 54 | } |
| 55 | if len(pf.FileErrors) == 0 { |
| 56 | t.Fatal("expected file errors for missing include") |
| 57 | } |
| 58 | if len(pf.Errors) > 0 { |
| 59 | t.Fatalf("expected no parse errors, got %d", len(pf.Errors)) |
| 60 | } |
| 61 | for _, fe := range pf.FileErrors { |
| 62 | if fe.Path == "" { |
| 63 | t.Error("expected non-empty path in FileError") |
| 64 | } |
| 65 | if fe.Message == "" { |
| 66 | t.Error("expected non-empty message in FileError") |
| 67 | } |
| 68 | } |
| 69 | }) |
| 70 | |
| 71 | t.Run("self include dedup", func(t *testing.T) { |
| 72 | l := NewLoader() |
| 73 | src := []byte("include self.journal\n2024/01/01 t\n a $1\n") |
| 74 | pf, err := l.LoadBytes("self.journal", src) |
| 75 | if err != nil { |
| 76 | t.Fatalf("unexpected error: %v", err) |
| 77 | } |
| 78 | // self-include is deduped (file already stored before includes resolved) |
| 79 | if len(pf.Includes) != 0 { |
| 80 | t.Fatalf("expected 0 includes (deduped), got %d", len(pf.Includes)) |
| 81 | } |
| 82 | }) |
| 83 | |
| 84 | t.Run("dedup", func(t *testing.T) { |
| 85 | l := NewLoader() |
| 86 | src := []byte("2024/01/01 t\n a $1\n") |
| 87 | pf1, err := l.LoadBytes("same.journal", src) |
| 88 | if err != nil { |
| 89 | t.Fatalf("unexpected error: %v", err) |
| 90 | } |
| 91 | pf2, err := l.LoadBytes("same.journal", src) |
| 92 | if err != nil { |
| 93 | t.Fatalf("unexpected error: %v", err) |
| 94 | } |
| 95 | if pf1 != pf2 { |
| 96 | t.Fatal("expected same pointer for deduplicated load") |
| 97 | } |
| 98 | // different path, same content — should NOT dedup |
| 99 | pf3, err := l.LoadBytes("other.journal", src) |
| 100 | if err != nil { |
| 101 | t.Fatalf("unexpected error: %v", err) |
| 102 | } |
| 103 | if pf1 == pf3 { |
| 104 | t.Fatal("expected different pointers for different paths") |
| 105 | } |
| 106 | }) |
| 107 | } |
| 108 | |
| 109 | func TestLoader_Load(t *testing.T) { |
| 110 | dir := t.TempDir() |
| 111 | mainPath := filepath.Join(dir, "main.journal") |
| 112 | if err := os.WriteFile(mainPath, []byte("2024/01/01 t\n a $1\n"), 0o644); err != nil { |
| 113 | t.Fatalf("writing temp file: %v", err) |
| 114 | } |
| 115 | |
| 116 | l := NewLoader() |
| 117 | pf, err := l.Load(mainPath) |
| 118 | if err != nil { |
| 119 | t.Fatalf("unexpected error: %v", err) |
| 120 | } |
| 121 | if pf.Path != mainPath { |
| 122 | t.Fatalf("expected path %q, got %q", mainPath, pf.Path) |
| 123 | } |
| 124 | if len(pf.Errors) > 0 { |
| 125 | t.Fatalf("unexpected errors: %v", pf.Errors) |
| 126 | } |
| 127 | |
| 128 | t.Run("reuse on repeated load", func(t *testing.T) { |
| 129 | pf2, err := l.Load(mainPath) |
| 130 | if err != nil { |
| 131 | t.Fatalf("unexpected error: %v", err) |
| 132 | } |
| 133 | if pf != pf2 { |
| 134 | t.Fatal("expected same pointer on repeated load") |
| 135 | } |
| 136 | }) |
| 137 | |
| 138 | t.Run("file not found", func(t *testing.T) { |
| 139 | _, err := l.Load(filepath.Join(dir, "nonexistent.journal")) |
| 140 | if err == nil { |
| 141 | t.Fatal("expected error for nonexistent file") |
| 142 | } |
| 143 | }) |
| 144 | } |
| 145 | |
| 146 | func TestLoader_Load_withInclude(t *testing.T) { |
| 147 | dir := t.TempDir() |
| 148 | |
| 149 | child := []byte("account expenses:food\n") |
| 150 | if err := os.WriteFile(filepath.Join(dir, "child.journal"), child, 0o644); err != nil { |
| 151 | t.Fatalf("writing child: %v", err) |
| 152 | } |
| 153 | |
| 154 | parent := []byte("include child.journal\n") |
| 155 | if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil { |
| 156 | t.Fatalf("writing parent: %v", err) |
| 157 | } |
| 158 | |
| 159 | l := NewLoader() |
| 160 | pf, err := l.Load(filepath.Join(dir, "parent.journal")) |
| 161 | if err != nil { |
| 162 | t.Fatalf("unexpected error: %v", err) |
| 163 | } |
| 164 | if len(pf.FileErrors) > 0 { |
| 165 | t.Fatalf("unexpected file errors: %v", pf.FileErrors) |
| 166 | } |
| 167 | if len(pf.Includes) != 1 { |
| 168 | t.Fatalf("expected 1 include, got %d", len(pf.Includes)) |
| 169 | } |
| 170 | included := pf.Includes[0] |
| 171 | if included.Path != filepath.Join(dir, "child.journal") { |
| 172 | t.Fatalf("expected child path, got %q", included.Path) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestLoader_Load_withGlobInclude(t *testing.T) { |
| 177 | dir := t.TempDir() |
| 178 | sub := filepath.Join(dir, "data") |
| 179 | os.MkdirAll(sub, 0o755) |
| 180 | |
| 181 | for _, name := range []string{"a.journal", "b.journal", "c.journal"} { |
| 182 | if err := os.WriteFile(filepath.Join(sub, name), []byte("account expenses:"+name[:1]+"\n"), 0o644); err != nil { |
| 183 | t.Fatalf("writing %s: %v", name, err) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | parent := []byte("include data/*.journal\n") |
| 188 | if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil { |
| 189 | t.Fatalf("writing parent: %v", err) |
| 190 | } |
| 191 | |
| 192 | l := NewLoader() |
| 193 | pf, err := l.Load(filepath.Join(dir, "parent.journal")) |
| 194 | if err != nil { |
| 195 | t.Fatalf("unexpected error: %v", err) |
| 196 | } |
| 197 | if len(pf.FileErrors) > 0 { |
| 198 | t.Fatalf("unexpected file errors: %v", pf.FileErrors) |
| 199 | } |
| 200 | if len(pf.Includes) != 3 { |
| 201 | t.Fatalf("expected 3 includes, got %d", len(pf.Includes)) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | func TestLoader_Load_includeNotFound(t *testing.T) { |
| 206 | dir := t.TempDir() |
| 207 | |
| 208 | parent := []byte("include data/*.journal\n") |
| 209 | if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil { |
| 210 | t.Fatalf("writing parent: %v", err) |
| 211 | } |
| 212 | |
| 213 | l := NewLoader() |
| 214 | pf, err := l.Load(filepath.Join(dir, "parent.journal")) |
| 215 | if err != nil { |
| 216 | t.Fatalf("unexpected error: %v", err) |
| 217 | } |
| 218 | if len(pf.FileErrors) != 1 { |
| 219 | t.Fatalf("expected 1 file error, got %d", len(pf.FileErrors)) |
| 220 | } |
| 221 | if len(pf.Errors) != 0 { |
| 222 | t.Fatalf("expected 0 parse errors, got %d", len(pf.Errors)) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func TestLoader_Load_cycleDedup(t *testing.T) { |
| 227 | dir := t.TempDir() |
| 228 | |
| 229 | aPath := filepath.Join(dir, "a.journal") |
| 230 | bPath := filepath.Join(dir, "b.journal") |
| 231 | |
| 232 | if err := os.WriteFile(aPath, []byte("include b.journal\n"), 0o644); err != nil { |
| 233 | t.Fatalf("writing a: %v", err) |
| 234 | } |
| 235 | if err := os.WriteFile(bPath, []byte("include a.journal\n"), 0o644); err != nil { |
| 236 | t.Fatalf("writing b: %v", err) |
| 237 | } |
| 238 | |
| 239 | l := NewLoader() |
| 240 | pf, err := l.Load(aPath) |
| 241 | if err != nil { |
| 242 | t.Fatalf("unexpected error: %v", err) |
| 243 | } |
| 244 | // circular A→B→A: A is deduped (already stored before includes resolved) |
| 245 | if len(pf.Includes) != 1 { |
| 246 | t.Fatalf("expected 1 include, got %d", len(pf.Includes)) |
| 247 | } |
| 248 | if pf.Includes[0].Path != bPath { |
| 249 | t.Fatalf("expected include path %q, got %q", bPath, pf.Includes[0].Path) |
| 250 | } |
| 251 | // B's include of A should resolve to the same pf (dedup) |
| 252 | if pf.Includes[0].Includes[0] != pf { |
| 253 | t.Fatal("expected circular dedup: B's include of A should point to original A") |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | func TestLoader_Ordered(t *testing.T) { |
| 258 | dir := t.TempDir() |
| 259 | |
| 260 | leaf := []byte("2024/01/01 t\n a $1\n") |
| 261 | if err := os.WriteFile(filepath.Join(dir, "leaf.journal"), leaf, 0o644); err != nil { |
| 262 | t.Fatalf("writing leaf: %v", err) |
| 263 | } |
| 264 | |
| 265 | middle := []byte("include leaf.journal\n") |
| 266 | if err := os.WriteFile(filepath.Join(dir, "middle.journal"), middle, 0o644); err != nil { |
| 267 | t.Fatalf("writing middle: %v", err) |
| 268 | } |
| 269 | |
| 270 | root := []byte("include middle.journal\n") |
| 271 | if err := os.WriteFile(filepath.Join(dir, "root.journal"), root, 0o644); err != nil { |
| 272 | t.Fatalf("writing root: %v", err) |
| 273 | } |
| 274 | |
| 275 | l := NewLoader() |
| 276 | pf, err := l.Load(filepath.Join(dir, "root.journal")) |
| 277 | if err != nil { |
| 278 | t.Fatalf("unexpected error: %v", err) |
| 279 | } |
| 280 | if len(pf.FileErrors) > 0 { |
| 281 | t.Fatalf("unexpected file errors: %v", pf.FileErrors) |
| 282 | } |
| 283 | |
| 284 | ordered := l.Ordered() |
| 285 | names := make([]string, len(ordered)) |
| 286 | for i, f := range ordered { |
| 287 | names[i] = filepath.Base(f.Path) |
| 288 | } |
| 289 | |
| 290 | // leaf before middle before root — includes before includer |
| 291 | if len(names) != 3 { |
| 292 | t.Fatalf("expected 3 ordered files, got %d: %v", len(names), names) |
| 293 | } |
| 294 | if names[0] != "leaf.journal" { |
| 295 | t.Fatalf("expected leaf first, got %q", names[0]) |
| 296 | } |
| 297 | if names[1] != "middle.journal" { |
| 298 | t.Fatalf("expected middle second, got %q", names[1]) |
| 299 | } |
| 300 | if names[2] != "root.journal" { |
| 301 | t.Fatalf("expected root last, got %q", names[2]) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | func TestLoader_Load_fileErrorsSeparateFromParseErrors(t *testing.T) { |
| 306 | l := NewLoader() |
| 307 | src := []byte("@@@ garbage\ninclude nonexistent.journal\n") |
| 308 | pf, err := l.LoadBytes("mixed.journal", src) |
| 309 | if err != nil { |
| 310 | t.Fatalf("unexpected error: %v", err) |
| 311 | } |
| 312 | if len(pf.Errors) == 0 { |
| 313 | t.Fatal("expected parse errors") |
| 314 | } |
| 315 | if len(pf.FileErrors) == 0 { |
| 316 | t.Fatal("expected file errors") |
| 317 | } |
| 318 | } |