1
2
3
4
5
6
7(function (global, factory) {
8 if (typeof module === 'object' && module.exports) {
9 module.exports = factory;
10 } else {
11 global.runFinanceTests = factory;
12 }
13})(typeof self !== 'undefined' ? self : this, function (FD, FC) {
14 'use strict';
15
16 var results = [];
17
18 function test(name, fn) {
19 try {
20 fn();
21 results.push({ name: name, pass: true });
22 } catch (e) {
23 results.push({ name: name, pass: false, message: e.message });
24 }
25 }
26
27 function ok(cond, msg) {
28 if (!cond) { throw new Error(msg || 'expected truthy'); }
29 }
30
31 function eq(actual, expected, msg) {
32 if (actual !== expected) {
33 throw new Error((msg || 'mismatch') + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual));
34 }
35 }
36
37 function deepEq(actual, expected, msg) {
38 var a = JSON.stringify(actual);
39 var b = JSON.stringify(expected);
40 if (a !== b) {
41 throw new Error((msg || 'deep mismatch') + ' — expected ' + b + ', got ' + a);
42 }
43 }
44
45 function approx(actual, expected, msg) {
46 if (Math.abs(actual - expected) > 1e-9) {
47 throw new Error((msg || 'approx mismatch') + ' — expected ~' + expected + ', got ' + actual);
48 }
49 }
50
51 function throws(fn, msg) {
52 try {
53 fn();
54 } catch (e) {
55 return e;
56 }
57 throw new Error(msg || 'expected an exception');
58 }
59
60 function MemStorage() { this.map = {}; }
61 MemStorage.prototype.getItem = function (k) { return k in this.map ? this.map[k] : null; };
62 MemStorage.prototype.setItem = function (k, v) { this.map[k] = String(v); };
63
64 function sample() {
65 var list = [];
66 list = FD.addTransaction(list, { date: '2026-06-01', description: 'Salary', category: 'Income', amount: 3800 });
67 list = FD.addTransaction(list, { date: '2026-06-01', description: 'Rent', category: 'Rent', amount: 1600 });
68 list = FD.addTransaction(list, { date: '2026-06-08', description: 'Weekly groceries', category: 'Groceries', amount: 92.4 });
69 list = FD.addTransaction(list, { date: '2026-07-03', description: 'Groceries at market', category: 'Groceries', amount: 110.1 });
70 list = FD.addTransaction(list, { date: '2026-07-10', description: 'Pizza night', category: 'Dining', amount: 42 });
71 return list;
72 }
73
74
75
76 test('parseCSV: basic rows and fields', function () {
77 deepEq(FD.parseCSV('a,b,c\n1,2,3'), [['a', 'b', 'c'], ['1', '2', '3']]);
78 });
79
80 test('parseCSV: quoted field containing commas', function () {
81 deepEq(FD.parseCSV('a,"b, with comma",c'), [['a', 'b, with comma', 'c']]);
82 });
83
84 test('parseCSV: escaped double quotes inside quoted field', function () {
85 deepEq(FD.parseCSV('"say ""hi""",x'), [['say "hi"', 'x']]);
86 });
87
88 test('parseCSV: newline inside quoted field', function () {
89 deepEq(FD.parseCSV('"line1\nline2",x\ny,z'), [['line1\nline2', 'x'], ['y', 'z']]);
90 });
91
92 test('parseCSV: CRLF endings and trailing newline ignored', function () {
93 deepEq(FD.parseCSV('a,b\r\nc,d\r\n'), [['a', 'b'], ['c', 'd']]);
94 });
95
96 test('parseCSV: empty fields preserved, empty input → no rows', function () {
97 deepEq(FD.parseCSV('a,,c'), [['a', '', 'c']]);
98 deepEq(FD.parseCSV(''), []);
99 });
100
101
102
103 test('toCSV: header plus escaping of commas and quotes', function () {
104 var csv = FD.toCSV([{ id: 'x', date: '2026-07-01', description: 'Lunch, "special"', category: 'Dining', amount: -12.5 }]);
105 var lines = csv.trim().split('\n');
106 eq(lines[0], 'date,description,category,amount');
107 eq(lines[1], '2026-07-01,"Lunch, ""special""",Dining,-12.50');
108 });
109
110 test('CSV round-trip: export then import preserves every field', function () {
111 var list = sample();
112 var back = FD.transactionsFromCSV(FD.toCSV(list));
113 eq(back.errors.length, 0, 'round-trip should produce no errors');
114 eq(back.transactions.length, list.length);
115 for (var i = 0; i < list.length; i++) {
116 eq(back.transactions[i].date, list[i].date, 'date ' + i);
117 eq(back.transactions[i].description, list[i].description, 'description ' + i);
118 eq(back.transactions[i].category, list[i].category, 'category ' + i);
119 approx(back.transactions[i].amount, list[i].amount, 'amount ' + i);
120 }
121 });
122
123
124
125 test('import: header order flexible, $ and thousands commas parsed', function () {
126 var r = FD.transactionsFromCSV('amount,category,description,date\n"-$1,234.56",Rent,June rent,2026-06-01');
127 eq(r.errors.length, 0);
128 eq(r.transactions.length, 1);
129 approx(r.transactions[0].amount, -1234.56);
130 eq(r.transactions[0].category, 'Rent');
131 });
132
133 test('import: parentheses accounting style is negative', function () {
134 var r = FD.transactionsFromCSV('date,description,category,amount\n2026-06-02,Coffee,Dining,(4.50)');
135 eq(r.errors.length, 0);
136 approx(r.transactions[0].amount, -4.5);
137 });
138
139 test('import: category matching is case-insensitive; unknown → Other', function () {
140 var r = FD.transactionsFromCSV('date,description,category,amount\n2026-06-02,A,dining,-5\n2026-06-03,B,Spaceships,-6');
141 eq(r.transactions[0].category, 'Dining');
142 eq(r.transactions[1].category, 'Other');
143 });
144
145 test('import: no category column → sign decides Income vs Other', function () {
146 var r = FD.transactionsFromCSV('date,description,amount\n2026-06-01,Paycheck,2000\n2026-06-02,Snacks,-8');
147 eq(r.transactions[0].category, 'Income');
148 ok(r.transactions[0].amount > 0, 'income stays positive');
149 eq(r.transactions[1].category, 'Other');
150 ok(r.transactions[1].amount < 0, 'expense goes negative');
151 });
152
153 test('import: bad rows reported with row numbers, good rows kept', function () {
154 var r = FD.transactionsFromCSV('date,description,category,amount\nnot-a-date,A,Dining,-5\n2026-06-02,B,Dining,abc\n2026-06-03,C,Dining,-7');
155 eq(r.transactions.length, 1);
156 eq(r.transactions[0].description, 'C');
157 eq(r.errors.length, 2);
158 ok(r.errors[0].indexOf('Row 2') === 0, 'first error names row 2: ' + r.errors[0]);
159 ok(r.errors[1].indexOf('Row 3') === 0, 'second error names row 3: ' + r.errors[1]);
160 });
161
162 test('import: empty file and missing required columns are errors', function () {
163 eq(FD.transactionsFromCSV('').errors.length, 1);
164 var r = FD.transactionsFromCSV('foo,bar\n1,2');
165 eq(r.transactions.length, 0);
166 ok(r.errors[0].indexOf('Missing required column') !== -1, r.errors[0]);
167 });
168
169
170
171 test('addTransaction: appends, assigns id, leaves original untouched', function () {
172 var orig = [];
173 var next = FD.addTransaction(orig, { date: '2026-07-01', description: 'Coffee', category: 'Dining', amount: 4.5 });
174 eq(orig.length, 0, 'original array must not change');
175 eq(next.length, 1);
176 ok(typeof next[0].id === 'string' && next[0].id.length > 0, 'id assigned');
177 });
178
179 test('addTransaction: sign derived from category (expense −, Income +)', function () {
180 var list = FD.addTransaction([], { date: '2026-07-01', description: 'Coffee', category: 'Dining', amount: 4.5 });
181 approx(list[0].amount, -4.5);
182 list = FD.addTransaction(list, { date: '2026-07-01', description: 'Paycheck', category: 'Income', amount: -2000 });
183 approx(list[1].amount, 2000, 'Income is always positive, even if entered negative');
184 });
185
186 test('addTransaction: invalid input throws with a message', function () {
187 var e1 = throws(function () {
188 FD.addTransaction([], { date: '07/01/2026', description: 'X', category: 'Dining', amount: 5 });
189 });
190 ok(/date/.test(e1.message), 'message mentions the date: ' + e1.message);
191 throws(function () {
192 FD.addTransaction([], { date: '2026-07-01', description: 'X', category: 'Dining', amount: 0 });
193 }, 'zero amount must throw');
194 });
195
196 test('updateTransaction: merges patch, re-validates, immutable', function () {
197 var list = sample();
198 var id = list[4].id;
199 var next = FD.updateTransaction(list, id, { amount: 55, description: 'Pizza & wings' });
200 eq(list[4].description, 'Pizza night', 'original untouched');
201 var updated = FD.getTransaction(next, id);
202 eq(updated.description, 'Pizza & wings');
203 approx(updated.amount, -55);
204 eq(updated.date, '2026-07-10', 'unpatched fields survive');
205 });
206
207 test('updateTransaction: unknown id and invalid patch both throw', function () {
208 var list = sample();
209 throws(function () { FD.updateTransaction(list, 'nope', { amount: 5 }); });
210 throws(function () { FD.updateTransaction(list, list[0].id, { date: 'garbage' }); });
211 });
212
213 test('deleteTransaction: removes only the matching id', function () {
214 var list = sample();
215 var next = FD.deleteTransaction(list, list[0].id);
216 eq(next.length, list.length - 1);
217 eq(FD.getTransaction(next, list[0].id), null);
218 eq(FD.deleteTransaction(list, 'missing').length, list.length, 'missing id is a no-op');
219 });
220
221
222
223 test('validateTransaction: rejects bad formats and impossible dates', function () {
224 function errsFor(t) { return FD.validateTransaction(FD.normalizeTransaction(t)); }
225 ok(errsFor({ date: '2026-1-5', description: 'X', category: 'Dining', amount: 5 }).length > 0, 'non-padded date');
226 ok(errsFor({ date: '2026-02-31', description: 'X', category: 'Dining', amount: 5 }).length > 0, 'Feb 31 is not real');
227 ok(errsFor({ date: '2026-07-01', description: ' ', category: 'Dining', amount: 5 }).length > 0, 'blank description');
228 ok(errsFor({ date: '2026-07-01', description: 'X', category: 'Blimps', amount: 5 }).length > 0, 'unknown category');
229 ok(errsFor({ date: '2026-07-01', description: 'X', category: 'Dining', amount: 'abc' }).length > 0, 'non-numeric amount');
230 });
231
232 test('validateTransaction: a normal transaction passes clean', function () {
233 var t = FD.normalizeTransaction({ date: '2026-02-29', description: ' Coffee ', category: 'Dining', amount: '4.50' });
234 deepEq(FD.validateTransaction(t), []);
235 eq(t.description, 'Coffee', 'whitespace trimmed');
236 });
237
238
239
240 test('filterTransactions: by category', function () {
241 var list = sample();
242 eq(FD.filterTransactions(list, { category: 'Groceries' }).length, 2);
243 eq(FD.filterTransactions(list, { category: 'all' }).length, list.length);
244 });
245
246 test('filterTransactions: search is case-insensitive on description', function () {
247 var list = sample();
248 eq(FD.filterTransactions(list, { search: 'PIZZA' }).length, 1);
249 eq(FD.filterTransactions(list, { search: 'groceries' }).length, 2);
250 eq(FD.filterTransactions(list, { search: 'zebra' }).length, 0);
251 });
252
253 test('filterTransactions: date range bounds are inclusive', function () {
254 var list = sample();
255 var r = FD.filterTransactions(list, { from: '2026-06-01', to: '2026-06-30' });
256 eq(r.length, 3);
257 eq(FD.filterTransactions(list, { from: '2026-07-03' }).length, 2, 'from includes its own date');
258 eq(FD.filterTransactions(list, { to: '2026-06-01' }).length, 2, 'to includes its own date');
259 });
260
261
262
263 test('monthKey / monthAdd / monthRange handle year boundaries', function () {
264 eq(FD.monthKey('2026-07-28'), '2026-07');
265 eq(FD.monthAdd('2026-01', -1), '2025-12');
266 eq(FD.monthAdd('2025-11', 3), '2026-02');
267 deepEq(FD.monthRange('2025-11', '2026-02'), ['2025-11', '2025-12', '2026-01', '2026-02']);
268 deepEq(FD.monthRange('2026-05', '2026-01'), [], 'inverted range is empty');
269 });
270
271 test('monthlySeries: expenses only, gap months filled with zero', function () {
272 var list = [];
273 list = FD.addTransaction(list, { date: '2026-03-10', description: 'A', category: 'Dining', amount: 50 });
274 list = FD.addTransaction(list, { date: '2026-03-20', description: 'B', category: 'Dining', amount: 25 });
275 list = FD.addTransaction(list, { date: '2026-04-05', description: 'Pay', category: 'Income', amount: 1000 });
276 list = FD.addTransaction(list, { date: '2026-05-15', description: 'C', category: 'Rent', amount: 800 });
277 var s = FD.monthlySeries(list);
278 deepEq(s, [
279 { month: '2026-03', total: 75 },
280 { month: '2026-04', total: 0 },
281 { month: '2026-05', total: 800 }
282 ], 'income excluded; April present as zero');
283 });
284
285 test('monthlySeries: explicit fromMonth/toMonth bounds are honored', function () {
286 var list = FD.addTransaction([], { date: '2026-06-10', description: 'A', category: 'Dining', amount: 30 });
287 var s = FD.monthlySeries(list, { fromMonth: '2026-04', toMonth: '2026-07' });
288 eq(s.length, 4);
289 eq(s[0].month, '2026-04');
290 approx(s[0].total, 0);
291 approx(s[2].total, 30);
292 deepEq(FD.monthlySeries([], {}), [], 'no data and no bounds → empty');
293 });
294
295 test('spendingByCategory: month-scoped, expenses only', function () {
296 var list = sample();
297 var june = FD.spendingByCategory(list, '2026-06');
298 approx(june.Rent, 1600);
299 approx(june.Groceries, 92.4);
300 ok(!('Income' in june), 'income never counts as spending');
301 var all = FD.spendingByCategory(list, null);
302 approx(all.Groceries, 202.5, 'null month = all time');
303 });
304
305 test('totals: spent / income / net for a month', function () {
306 var t = FD.totals(sample(), '2026-06');
307 approx(t.spent, 1692.4);
308 approx(t.income, 3800);
309 approx(t.net, 2107.6);
310 });
311
312
313
314 test('budgetStatus: ok under 80%, warning at 80%–100%', function () {
315 eq(FD.budgetStatus(79.9, 100).level, 'ok');
316 eq(FD.budgetStatus(80, 100).level, 'warning');
317 eq(FD.budgetStatus(100, 100).level, 'warning', 'exactly at budget is not over');
318 approx(FD.budgetStatus(50, 100).ratio, 0.5);
319 });
320
321 test('budgetStatus: over past 100%; zero/absent budget is "none"', function () {
322 eq(FD.budgetStatus(100.01, 100).level, 'over');
323 eq(FD.budgetStatus(5, 0).level, 'none');
324 eq(FD.budgetStatus(5, undefined).level, 'none');
325 eq(FD.budgetStatus(5, 0).ratio, null);
326 });
327
328
329
330 test('formatCurrency and formatCompactCurrency', function () {
331 eq(FD.formatCurrency(1234.5), '$1,234.50');
332 ok(FD.formatCurrency(-42).indexOf('42') !== -1 && FD.formatCurrency(-42).indexOf('-') !== -1, 'negative keeps sign');
333 eq(FD.formatCompactCurrency(0), '$0');
334 eq(FD.formatCompactCurrency(250), '$250');
335 eq(FD.formatCompactCurrency(1000), '$1K');
336 eq(FD.formatCompactCurrency(1500), '$1.5K');
337 });
338
339 test('round2 snaps float dust', function () {
340 approx(FD.round2(0.1 + 0.2), 0.3);
341 approx(FD.round2(10.005), 10.01);
342 });
343
344
345
346 test('storage: save/load round-trip', function () {
347 var mem = new MemStorage();
348 var list = sample();
349 FD.saveTransactions(mem, list);
350 deepEq(FD.loadTransactions(mem), list);
351 });
352
353 test('storage: never-saved → null, corrupt JSON → []', function () {
354 var mem = new MemStorage();
355 eq(FD.loadTransactions(mem), null, 'null signals "seed demo data"');
356 mem.setItem(FD.STORAGE_KEYS.transactions, '{oops');
357 deepEq(FD.loadTransactions(mem), [], 'corrupt data must not crash');
358 });
359
360 test('storage: malformed entries are dropped, valid ones kept', function () {
361 var mem = new MemStorage();
362 var good = { id: 'a1', date: '2026-07-01', description: 'Keep me', category: 'Dining', amount: -5 };
363 mem.setItem(FD.STORAGE_KEYS.transactions, JSON.stringify([good, { id: 'a2', date: 'bad' }, 'junk', null]));
364 var loaded = FD.loadTransactions(mem);
365 eq(loaded.length, 1);
366 eq(loaded[0].id, 'a1');
367 });
368
369 test('storage: budgets merge over defaults and round-trip', function () {
370 var mem = new MemStorage();
371 deepEq(FD.loadBudgets(mem), FD.DEFAULT_BUDGETS, 'defaults when nothing saved');
372 var edited = FD.loadBudgets(mem);
373 edited.Dining = 300;
374 FD.saveBudgets(mem, edited);
375 eq(FD.loadBudgets(mem).Dining, 300);
376 eq(FD.loadBudgets(mem).Rent, FD.DEFAULT_BUDGETS.Rent, 'untouched categories keep defaults');
377 mem.setItem(FD.STORAGE_KEYS.budgets, JSON.stringify({ Dining: -50, Rocketry: 900 }));
378 var guarded = FD.loadBudgets(mem);
379 eq(guarded.Dining, FD.DEFAULT_BUDGETS.Dining, 'negative values rejected');
380 ok(!('Rocketry' in guarded), 'unknown categories ignored');
381 });
382
383
384
385 test('demoData: valid, dated in the past, deterministic', function () {
386 var today = '2026-07-28';
387 var demo = FD.demoData(today);
388 ok(demo.length > 20, 'a healthy amount of demo data');
389 demo.forEach(function (t) {
390 deepEq(FD.validateTransaction(t), [], 'demo txn valid: ' + t.description);
391 ok(t.date <= today, 'no future dates');
392 });
393 ok(demo.some(function (t) { return t.amount > 0; }), 'has income');
394 ok(demo.some(function (t) { return t.amount < 0; }), 'has expenses');
395 var a = FD.demoData(today).map(function (t) { return t.date + t.description + t.amount; }).join('|');
396 var b = FD.demoData(today).map(function (t) { return t.date + t.description + t.amount; }).join('|');
397 eq(a, b, 'same input → same demo data');
398 });
399
400
401
402 test('niceTicks: clean money steps', function () {
403 var t = FC.niceTicks(970);
404 eq(t.top, 1000);
405 eq(t.step, 250);
406 deepEq(t.ticks, [0, 250, 500, 750, 1000]);
407 eq(FC.niceTicks(4).top, 4);
408 deepEq(FC.niceTicks(0).ticks, [0, 25, 50, 75, 100], 'zero/empty guard');
409 });
410
411 test('niceTicks: top always covers the max', function () {
412 var samples = [0.4, 1, 7, 12, 82, 99.9, 250, 970, 1234.56, 88000];
413 samples.forEach(function (m) {
414 var t = FC.niceTicks(m);
415 ok(t.top >= m - 1e-9, 'top ' + t.top + ' covers ' + m);
416 ok(t.ticks[0] === 0, 'ticks start at zero');
417 approx(t.ticks[t.ticks.length - 1], t.top, 'ticks end at top');
418 });
419 });
420
421 test('monthTickLabel: short names, optional year', function () {
422 eq(FC.monthTickLabel('2026-07'), 'Jul');
423 eq(FC.monthTickLabel('2026-01', true), 'Jan ’26');
424 });
425
426 test('drawSpendingChart: safe no-op without a canvas (headless)', function () {
427 eq(FC.drawSpendingChart(null, [], {}), null);
428 eq(FC.drawSpendingChart({}, [], {}), null, 'object without getContext');
429 });
430
431 var passed = results.filter(function (r) { return r.pass; }).length;
432 return { results: results, passed: passed, failed: results.length - passed };
433});
434
435
436if (typeof module === 'object' && module.exports && typeof require === 'function' && typeof process !== 'undefined' && require.main === module) {
437 var FD = require('./data.js');
438 var FC = require('./charts.js');
439 var out = module.exports(FD, FC);
440 out.results.forEach(function (r) {
441 console.log((r.pass ? ' ✓ ' : ' ✗ ') + r.name + (r.pass ? '' : '\n ' + r.message));
442 });
443 console.log('\n' + out.passed + '/' + out.results.length + ' tests passed' + (out.failed ? ' — ' + out.failed + ' FAILED' : ''));
444 process.exitCode = out.failed ? 1 : 0;
445}
446
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.