AtlatestRepositorysigil-ledger

sigil-ledger / tree / testtest-ledger.sgl

1;;; Test suite for (ledger)
2
3(import (sigil test)
4 (sigil string)
5 (ledger))
6
7;; ========== Transaction Formatting ==========
8
9(test-group "format-transaction - basic"
10 (test "format simple transaction"
11 (let ((txn (journal-transaction
12 date: "2026-03-15"
13 status: "*"
14 description: "Grocery store"
15 postings: (list
16 (journal-posting
17 account: "expenses:food:groceries"
18 amount: (journal-amount quantity: 47.23 commodity: "USD"))
19 (journal-posting
20 account: "assets:checking")))))
21 (assert-equal
22 "2026-03-15 * Grocery store\n expenses:food:groceries 47.23 USD\n assets:checking"
23 (format-transaction txn))))
25 (test "format transaction with pending status"
26 (let ((txn (journal-transaction
27 date: "2026-03-15"
28 status: "!"
29 description: "Pending purchase"
30 postings: (list
31 (journal-posting
32 account: "expenses:food"
33 amount: (journal-amount quantity: 10 commodity: "USD"))
34 (journal-posting
35 account: "assets:checking")))))
36 (assert-equal
37 "2026-03-15 ! Pending purchase\n expenses:food 10.00 USD\n assets:checking"
38 (format-transaction txn))))
40 (test "format transaction with no status"
41 (let ((txn (journal-transaction
42 date: "2026-03-15"
43 description: "Some purchase"
44 postings: (list
45 (journal-posting
46 account: "expenses:food"
47 amount: (journal-amount quantity: 10 commodity: "USD"))
48 (journal-posting
49 account: "assets:checking")))))
50 (assert-equal
51 "2026-03-15 Some purchase\n expenses:food 10.00 USD\n assets:checking"
52 (format-transaction txn)))))
54(test-group "format-transaction - code and description"
55 (test "format transaction with code"
56 (let ((txn (journal-transaction
57 date: "2026-03-15"
58 status: "*"
59 code: "ref-001"
60 description: "Whole Foods | groceries"
61 postings: (list
62 (journal-posting
63 account: "expenses:food"
64 amount: (journal-amount quantity: 47.23 commodity: "$"))
65 (journal-posting
66 account: "assets:checking")))))
67 (assert-equal
68 "2026-03-15 * (ref-001) Whole Foods | groceries\n expenses:food $47.23\n assets:checking"
69 (format-transaction txn)))))
71(test-group "format-transaction - tags"
72 (test "format transaction with tags"
73 (let ((txn (journal-transaction
74 date: "2026-03-15"
75 status: "*"
76 description: "Dinner"
77 tags: (list (cons "trip" "hawaii"))
78 postings: (list
79 (journal-posting
80 account: "expenses:meals"
81 amount: (journal-amount quantity: 85 commodity: "$"))
82 (journal-posting
83 account: "assets:checking")))))
84 (assert-equal
85 "2026-03-15 * Dinner\n ; trip: hawaii\n expenses:meals $85.00\n assets:checking"
86 (format-transaction txn))))
88 (test "format posting with inline tags"
89 (let ((txn (journal-transaction
90 date: "2026-03-15"
91 status: "*"
92 description: "Test"
93 postings: (list
94 (journal-posting
95 account: "expenses:food"
96 amount: (journal-amount quantity: 50 commodity: "USD")
97 tags: (list (cons "receipt" "scan-001")))
98 (journal-posting
99 account: "assets:checking")))))
100 (assert-equal
101 "2026-03-15 * Test\n expenses:food 50.00 USD ; receipt: scan-001\n assets:checking"
102 (format-transaction txn)))))
104(test-group "format-transaction - cost notation"
105 (test "format total cost (@@)"
106 (let ((txn (journal-transaction
107 date: "2026-03-15"
108 status: "*"
109 description: "Currency conversion"
110 postings: (list
111 (journal-posting
112 account: "assets:wise:eur"
113 amount: (journal-amount quantity: -500 commodity: "EUR"))
114 (journal-posting
115 account: "assets:wise:usd"
116 amount: (journal-amount quantity: 540 commodity: "USD")
117 cost: (journal-cost
118 type: 'total
119 amount: (journal-amount quantity: 500 commodity: "EUR")))))))
120 (assert-equal
121 "2026-03-15 * Currency conversion\n assets:wise:eur -500.00 EUR\n assets:wise:usd 540.00 USD @@ 500.00 EUR"
122 (format-transaction txn))))
124 (test "format per-unit cost (@)"
125 (let ((txn (journal-transaction
126 date: "2026-03-15"
127 status: "*"
128 description: "Currency conversion"
129 postings: (list
130 (journal-posting
131 account: "assets:wise:eur"
132 amount: (journal-amount quantity: 100 commodity: "EUR")
133 cost: (journal-cost
134 type: 'per-unit
135 amount: (journal-amount quantity: 1.08 commodity: "USD")))
136 (journal-posting
137 account: "assets:wise:usd")))))
138 (assert-equal
139 "2026-03-15 * Currency conversion\n assets:wise:eur 100.00 EUR @ 1.08 USD\n assets:wise:usd"
140 (format-transaction txn)))))
142(test-group "format-transaction - balance assertions"
143 (test "format balance assertion"
144 (let ((txn (journal-transaction
145 date: "2026-03-15"
146 status: "*"
147 description: "Deposit"
148 postings: (list
149 (journal-posting
150 account: "assets:checking"
151 amount: (journal-amount quantity: 3000 commodity: "$")
152 balance-assertion: (journal-amount quantity: 5000 commodity: "$"))
153 (journal-posting
154 account: "income:salary")))))
155 (assert-equal
156 "2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary"
157 (format-transaction txn)))))
159(test-group "format-transaction - balance assertion types"
160 (test "format inclusive balance assertion =*"
161 (let ((txn (journal-transaction
162 date: "2026-03-15"
163 status: "*"
164 description: "Verify"
165 postings: (list
166 (journal-posting
167 account: "assets:checking"
168 amount: (journal-amount quantity: 0 commodity: "$")
169 balance-assertion: (journal-amount quantity: 5000 commodity: "$")
170 balance-assertion-type: "=*")
171 (journal-posting
172 account: "income:salary")))))
173 (assert-equal
174 "2026-03-15 * Verify\n assets:checking $0.00 =* $5000.00\n income:salary"
175 (format-transaction txn))))
177 (test "format strict balance assertion =="
178 (let ((txn (journal-transaction
179 date: "2026-03-15"
180 status: "*"
181 description: "Verify"
182 postings: (list
183 (journal-posting
184 account: "assets:checking"
185 amount: (journal-amount quantity: 0 commodity: "$")
186 balance-assertion: (journal-amount quantity: 5000 commodity: "$")
187 balance-assertion-type: "==")
188 (journal-posting
189 account: "income:salary")))))
190 (assert-equal
191 "2026-03-15 * Verify\n assets:checking $0.00 == $5000.00\n income:salary"
192 (format-transaction txn)))))
194(test-group "format-transactions"
195 (test "format multiple transactions"
196 (let ((txns (list
197 (journal-transaction
198 date: "2026-03-15"
199 status: "*"
200 description: "Groceries"
201 postings: (list
202 (journal-posting
203 account: "expenses:food"
204 amount: (journal-amount quantity: 47.23 commodity: "USD"))
205 (journal-posting
206 account: "assets:checking")))
207 (journal-transaction
208 date: "2026-03-16"
209 status: "*"
210 description: "Gas"
211 postings: (list
212 (journal-posting
213 account: "expenses:transport"
214 amount: (journal-amount quantity: 30 commodity: "USD"))
215 (journal-posting
216 account: "assets:checking"))))))
217 (assert-equal
218 "2026-03-15 * Groceries\n expenses:food 47.23 USD\n assets:checking\n\n2026-03-16 * Gas\n expenses:transport 30.00 USD\n assets:checking"
219 (format-transactions txns)))))
221;; ========== Journal Parsing ==========
223(test-group "parse-journal - basic"
224 (test "parse simple transaction"
225 (let* ((text "2026-03-15 * Grocery store\n expenses:food $47.23\n assets:checking\n")
226 (txns (parse-journal text)))
227 (assert-equal 1 (length txns))
228 (let ((txn (car txns)))
229 (assert-equal "2026-03-15" (journal-transaction-date txn))
230 (assert-equal "*" (journal-transaction-status txn))
231 (assert-equal "Grocery store" (journal-transaction-description txn))
232 (assert-equal 2 (length (journal-transaction-postings txn))))))
234 (test "parse transaction with pending status"
235 (let* ((text "2026-03-15 ! Pending purchase\n expenses:food $10.00\n assets:checking\n")
236 (txns (parse-journal text)))
237 (assert-equal "!" (journal-transaction-status (car txns)))))
239 (test "parse transaction with no status"
240 (let* ((text "2026-03-15 Some purchase\n expenses:food $10.00\n assets:checking\n")
241 (txns (parse-journal text)))
242 (assert-equal "" (journal-transaction-status (car txns)))
243 (assert-equal "Some purchase" (journal-transaction-description (car txns)))))
245 (test "parse transaction with code"
246 (let* ((text "2026-03-15 * (ref-001) Whole Foods\n expenses:food $47.23\n assets:checking\n")
247 (txns (parse-journal text)))
248 (let ((txn (car txns)))
249 (assert-equal "ref-001" (journal-transaction-code txn))
250 (assert-equal "Whole Foods" (journal-transaction-description txn)))))
252 (test "parse multiple transactions"
253 (let* ((text (str "2026-03-15 * Groceries\n"
254 " expenses:food $47.23\n"
255 " assets:checking\n"
256 "\n"
257 "2026-03-16 * Gas\n"
258 " expenses:transport $30.00\n"
259 " assets:checking\n"))
260 (txns (parse-journal text)))
261 (assert-equal 2 (length txns))
262 (assert-equal "Groceries" (journal-transaction-description (car txns)))
263 (assert-equal "Gas" (journal-transaction-description (cadr txns))))))
265(test-group "parse-journal - amounts"
266 (test "parse left-symbol commodity"
267 (let* ((text "2026-03-15 * Test\n expenses:food $47.23\n assets:checking\n")
268 (txns (parse-journal text))
269 (posting (car (journal-transaction-postings (car txns)))))
270 (assert-equal "$" (journal-amount-commodity (journal-posting-amount posting)))
271 (assert-equal 47.23 (journal-amount-quantity (journal-posting-amount posting)))))
273 (test "parse right-symbol commodity"
274 (let* ((text "2026-03-15 * Test\n assets:wise:eur 100.00 EUR\n assets:checking\n")
275 (txns (parse-journal text))
276 (posting (car (journal-transaction-postings (car txns)))))
277 (assert-equal "EUR" (journal-amount-commodity (journal-posting-amount posting)))
278 (assert-equal 100.0 (journal-amount-quantity (journal-posting-amount posting)))))
280 (test "parse negative amount"
281 (let* ((text "2026-03-15 * Test\n assets:wise:eur -500.00 EUR\n assets:checking\n")
282 (txns (parse-journal text))
283 (posting (car (journal-transaction-postings (car txns)))))
284 (assert-equal -500.0 (journal-amount-quantity (journal-posting-amount posting)))))
286 (test "parse negative left-symbol -$50"
287 (let* ((text "2026-03-15 * Test\n expenses:food -$50.00\n assets:checking\n")
288 (txns (parse-journal text))
289 (posting (car (journal-transaction-postings (car txns)))))
290 (assert-equal -50.0 (journal-amount-quantity (journal-posting-amount posting)))
291 (assert-equal "$" (journal-amount-commodity (journal-posting-amount posting)))))
293 (test "parse internal negative $-50"
294 (let* ((text "2026-03-15 * Test\n expenses:food $-50.00\n assets:checking\n")
295 (txns (parse-journal text))
296 (posting (car (journal-transaction-postings (car txns)))))
297 (assert-equal -50.0 (journal-amount-quantity (journal-posting-amount posting)))
298 (assert-equal "$" (journal-amount-commodity (journal-posting-amount posting)))))
300 (test "parse inferred amount posting"
301 (let* ((text "2026-03-15 * Test\n expenses:food $50.00\n assets:checking\n")
302 (txns (parse-journal text))
303 (posting (cadr (journal-transaction-postings (car txns)))))
304 (assert-false (journal-posting-amount posting)))))
306(test-group "parse-journal - cost notation"
307 (test "parse per-unit cost"
308 (let* ((text "2026-03-15 * Test\n assets:wise:eur 100.00 EUR @ 1.08 USD\n assets:wise:usd\n")
309 (txns (parse-journal text))
310 (posting (car (journal-transaction-postings (car txns))))
311 (cost (journal-posting-cost posting)))
312 (assert-true (journal-cost? cost))
313 (assert-equal 'per-unit (journal-cost-type cost))
314 (assert-equal 1.08 (journal-amount-quantity (journal-cost-amount cost)))
315 (assert-equal "USD" (journal-amount-commodity (journal-cost-amount cost)))))
317 (test "parse total cost"
318 (let* ((text "2026-03-15 * Test\n assets:wise:usd 540.00 USD @@ 500.00 EUR\n assets:wise:eur\n")
319 (txns (parse-journal text))
320 (posting (car (journal-transaction-postings (car txns))))
321 (cost (journal-posting-cost posting)))
322 (assert-true (journal-cost? cost))
323 (assert-equal 'total (journal-cost-type cost))
324 (assert-equal 500.0 (journal-amount-quantity (journal-cost-amount cost)))
325 (assert-equal "EUR" (journal-amount-commodity (journal-cost-amount cost))))))
327(test-group "parse-journal - comments and tags"
328 (test "parse transaction with tag comment"
329 (let* ((text "2026-03-15 * Dinner\n ; trip: hawaii\n expenses:meals $85.00\n assets:checking\n")
330 (txns (parse-journal text))
331 (txn (car txns))
332 (tags (journal-transaction-tags txn)))
333 (assert-equal 1 (length tags))
334 (assert-equal "trip" (caar tags))
335 (assert-equal "hawaii" (cdar tags))))
337 (test "parse posting with inline comment"
338 (let* ((text "2026-03-15 * Test\n expenses:food $50.00 ; receipt: scan-001\n assets:checking\n")
339 (txns (parse-journal text))
340 (posting (car (journal-transaction-postings (car txns)))))
341 (assert-equal 1 (length (journal-posting-tags posting)))
342 (assert-equal "receipt" (caar (journal-posting-tags posting)))
343 (assert-equal "scan-001" (cdar (journal-posting-tags posting))))))
345(test-group "parse-journal - balance assertions"
346 (test "parse balance assertion"
347 (let* ((text "2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary\n")
348 (txns (parse-journal text))
349 (posting (car (journal-transaction-postings (car txns))))
350 (bal (journal-posting-balance-assertion posting)))
351 (assert-true (journal-amount? bal))
352 (assert-equal 5000.0 (journal-amount-quantity bal))
353 (assert-equal "$" (journal-amount-commodity bal))))
355 (test "parse strict balance assertion (==)"
356 (let* ((text "2026-03-15 * Verify\n assets:checking $0 == $5000.00\n income:salary\n")
357 (txns (parse-journal text))
358 (posting (car (journal-transaction-postings (car txns))))
359 (bal (journal-posting-balance-assertion posting)))
360 (assert-true (journal-amount? bal))
361 (assert-equal 5000.0 (journal-amount-quantity bal))
362 (assert-equal "$" (journal-amount-commodity bal))
363 (assert-equal "==" (journal-posting-balance-assertion-type posting))))
365 (test "parse inclusive balance assertion (=*)"
366 (let* ((text "2026-03-15 * Verify\n assets:checking $0 =* $5000.00\n income:salary\n")
367 (txns (parse-journal text))
368 (posting (car (journal-transaction-postings (car txns))))
369 (bal (journal-posting-balance-assertion posting)))
370 (assert-true (journal-amount? bal))
371 (assert-equal 5000.0 (journal-amount-quantity bal))
372 (assert-equal "$" (journal-amount-commodity bal))
373 (assert-equal "=*" (journal-posting-balance-assertion-type posting))))
375 (test "parse normal balance assertion type is ="
376 (let* ((text "2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary\n")
377 (txns (parse-journal text))
378 (posting (car (journal-transaction-postings (car txns)))))
379 (assert-equal "=" (journal-posting-balance-assertion-type posting))))
381 (test "parse cost with balance assertion"
382 (let* ((text "2026-03-15 * Test\n assets:wise:eur 100.00 EUR @ 1.08 USD = 200.00 EUR\n assets:wise:usd\n")
383 (txns (parse-journal text))
384 (posting (car (journal-transaction-postings (car txns))))
385 (cost (journal-posting-cost posting))
386 (bal (journal-posting-balance-assertion posting)))
387 (assert-true (journal-cost? cost))
388 (assert-equal 'per-unit (journal-cost-type cost))
389 (assert-true (journal-amount? bal))
390 (assert-equal "EUR" (journal-amount-commodity bal))
391 ;; Verify quantity parses correctly (use = for numeric comparison
392 ;; since exact/inexact integers may not be equal? to each other)
393 (assert-true (= 200 (journal-amount-quantity bal))))))
395;; ========== Round-trip Tests ==========
397(test-group "round-trip"
398 (test "parse and re-format simple transaction"
399 (let* ((original (str "2026-03-15 * Grocery store\n"
400 " expenses:food:groceries 47.23 USD\n"
401 " assets:checking"))
402 (txns (parse-journal (str original "\n")))
403 (formatted (format-transaction (car txns))))
404 (assert-equal original formatted)))
406 (test "parse and re-format transaction with cost"
407 (let* ((original (str "2026-03-15 * Currency conversion\n"
408 " assets:wise:eur -500.00 EUR\n"
409 " assets:wise:usd 540.00 USD @@ 500.00 EUR"))
410 (txns (parse-journal (str original "\n")))
411 (formatted (format-transaction (car txns))))
412 (assert-equal original formatted)))
414 (test "parse and re-format prefix negative -$50"
415 (let* ((original (str "2026-03-15 * Refund\n"
416 " assets:checking -$50.00\n"
417 " expenses:food"))
418 (txns (parse-journal (str original "\n")))
419 (formatted (format-transaction (car txns))))
420 (assert-equal original formatted)))
422 (test "parse and re-format internal negative $-50"
423 (let* ((original (str "2026-03-15 * Refund\n"
424 " assets:checking $-50.00\n"
425 " expenses:food"))
426 (txns (parse-journal (str original "\n")))
427 (formatted (format-transaction (car txns))))
428 (assert-equal original formatted)))
430 (test "parse and re-format balance assertion with =="
431 (let* ((original (str "2026-03-15 * Verify\n"
432 " assets:checking $0.00 == $5000.00\n"
433 " income:salary"))
434 (txns (parse-journal (str original "\n")))
435 (formatted (format-transaction (car txns))))
436 (assert-equal original formatted)))
438 (test "parse and re-format balance assertion with =*"
439 (let* ((original (str "2026-03-15 * Verify\n"
440 " assets:checking $0.00 =* $5000.00\n"
441 " income:salary"))
442 (txns (parse-journal (str original "\n")))
443 (formatted (format-transaction (car txns))))
444 (assert-equal original formatted)))
446 (test "parse and re-format multiple transactions"
447 (let* ((original (str "2026-03-15 * Groceries\n"
448 " expenses:food 47.23 USD\n"
449 " assets:checking\n"
450 "\n"
451 "2026-03-16 * Gas\n"
452 " expenses:transport 30.00 USD\n"
453 " assets:checking"))
454 (txns (parse-journal (str original "\n")))
455 (formatted (format-transactions txns)))
456 (assert-equal original formatted))))
458;; ========== Deduplication ==========
460(test-group "deduplicate-transactions"
461 (test "filter out duplicate by ref tag"
462 (let* ((existing (list
463 (journal-transaction
464 date: "2026-03-15"
465 status: "*"
466 description: "Existing"
467 tags: (list (cons "ref" "TRF-001"))
468 postings: (list
469 (journal-posting account: "expenses:food"
470 amount: (journal-amount quantity: 50 commodity: "USD"))
471 (journal-posting account: "assets:checking")))))
472 (new-txns (list
473 (journal-transaction
474 date: "2026-03-16"
475 status: "*"
476 description: "Already imported"
477 tags: (list (cons "ref" "TRF-001"))
478 postings: (list
479 (journal-posting account: "expenses:food"
480 amount: (journal-amount quantity: 50 commodity: "USD"))
481 (journal-posting account: "assets:checking")))
482 (journal-transaction
483 date: "2026-03-17"
484 status: "*"
485 description: "New one"
486 tags: (list (cons "ref" "TRF-002"))
487 postings: (list
488 (journal-posting account: "expenses:food"
489 amount: (journal-amount quantity: 30 commodity: "USD"))
490 (journal-posting account: "assets:checking")))))
491 (result (deduplicate-transactions new-txns existing)))
492 (assert-equal 1 (length result))
493 (assert-equal "New one" (journal-transaction-description (car result)))))
495 (test "keep transactions without ref"
496 (let* ((existing '())
497 (new-txns (list
498 (journal-transaction
499 date: "2026-03-15"
500 description: "No ref"
501 postings: (list
502 (journal-posting account: "expenses:misc"
503 amount: (journal-amount quantity: 10 commodity: "USD"))
504 (journal-posting account: "assets:checking")))))
505 (result (deduplicate-transactions new-txns existing)))
506 (assert-equal 1 (length result)))))
508(run-tests)