1
2
3
4
5
6(function (root) {
7 'use strict';
8
9 var isNode = typeof module === 'object' && !!module.exports && typeof require === 'function';
10 var F = isNode ? require('./data.js') : root.Finance;
11 var Charts = isNode ? require('./charts.js').Charts : root.Charts;
12
13 var results = [];
14
15 function test(name, fn) {
16 try {
17 fn();
18 results.push({ name: name, pass: true });
19 } catch (e) {
20 results.push({ name: name, pass: false, error: e && e.message ? e.message : String(e) });
21 }
22 }
23
24 function assert(cond, msg) {
25 if (!cond) throw new Error(msg || 'assertion failed');
26 }
27
28 function eq(actual, expected, msg) {
29 var a = JSON.stringify(actual);
30 var b = JSON.stringify(expected);
31 if (a !== b) throw new Error((msg || 'not equal') + ': got ' + a + ', want ' + b);
32 }
33
34 function approx(actual, expected, msg) {
35 if (Math.abs(actual - expected) > 1e-9) {
36 throw new Error((msg || 'not approx equal') + ': got ' + actual + ', want ' + expected);
37 }
38 }
39
40 function throws(fn, msg) {
41 var threw = false;
42 try { fn(); } catch (e) { threw = true; }
43 if (!threw) throw new Error(msg || 'expected an error to be thrown');
44 }
45
46 function tx(date, desc, cat, amount, id) {
47 var t = { date: date, description: desc, category: cat, amount: amount };
48 if (id !== undefined) t.id = id;
49 return t;
50 }
51
52
53
54 test('validateTransaction accepts a valid expense', function () {
55 var v = F.validateTransaction(tx('2026-07-05', 'Lunch', 'Dining', -12.5));
56 assert(v.ok, v.errors.join('; '));
57 });
58
59 test('validateTransaction accepts a valid income', function () {
60 assert(F.validateTransaction(tx('2026-07-01', 'Salary', 'Income', 4200)).ok);
61 });
62
63 test('validateTransaction rejects impossible calendar dates', function () {
64 assert(!F.validateTransaction(tx('2026-02-30', 'x', 'Dining', -1)).ok, 'Feb 30');
65 assert(!F.validateTransaction(tx('2026-13-01', 'x', 'Dining', -1)).ok, 'month 13');
66 assert(!F.validateTransaction(tx('2026-00-10', 'x', 'Dining', -1)).ok, 'month 0');
67 assert(!F.validateTransaction(tx('26-01-01', 'x', 'Dining', -1)).ok, 'short year');
68 });
69
70 test('isValidDate handles leap years', function () {
71 assert(F.isValidDate('2024-02-29'), '2024 is a leap year');
72 assert(!F.isValidDate('2023-02-29'), '2023 is not');
73 assert(F.isValidDate('2000-02-29'), '2000 is (divisible by 400)');
74 assert(!F.isValidDate('1900-02-29'), '1900 is not (divisible by 100)');
75 });
76
77 test('validateTransaction rejects empty description', function () {
78 assert(!F.validateTransaction(tx('2026-07-05', ' ', 'Dining', -5)).ok);
79 });
80
81 test('validateTransaction rejects unknown category', function () {
82 assert(!F.validateTransaction(tx('2026-07-05', 'x', 'Yachts', -5)).ok);
83 });
84
85 test('validateTransaction rejects zero, NaN, and empty amounts', function () {
86 assert(!F.validateTransaction(tx('2026-07-05', 'x', 'Dining', 0)).ok, 'zero');
87 assert(!F.validateTransaction(tx('2026-07-05', 'x', 'Dining', 'abc')).ok, 'NaN');
88 assert(!F.validateTransaction(tx('2026-07-05', 'x', 'Dining', '')).ok, 'empty string');
89 assert(!F.validateTransaction(tx('2026-07-05', 'x', 'Dining', Infinity)).ok, 'Infinity');
90 });
91
92
93
94 test('nextId is 1 for empty list, max+1 otherwise', function () {
95 eq(F.nextId([]), 1);
96 eq(F.nextId([tx('2026-01-01', 'a', 'Other', -1, 3), tx('2026-01-02', 'b', 'Other', -1, 7)]), 8);
97 });
98
99 test('addTransaction assigns ids and does not mutate the input', function () {
100 var a = [];
101 var b = F.addTransaction(a, tx('2026-07-05', 'Lunch', 'Dining', -12.5));
102 var c = F.addTransaction(b, tx('2026-07-06', 'Bus', 'Transport', -2.75));
103 eq(a.length, 0, 'original untouched');
104 eq(b.length, 1);
105 eq(c.length, 2);
106 eq(b[0].id, 1);
107 eq(c[1].id, 2);
108 });
109
110 test('addTransaction trims description and rounds amount to cents', function () {
111 var out = F.addTransaction([], tx('2026-07-05', ' Coffee ', 'Dining', -10.567));
112 eq(out[0].description, 'Coffee');
113 approx(out[0].amount, -10.57);
114 });
115
116 test('addTransaction throws on invalid input', function () {
117 throws(function () { F.addTransaction([], tx('bad', 'x', 'Dining', -1)); });
118 });
119
120 test('updateTransaction patches fields and keeps the id', function () {
121 var list = F.addTransaction([], tx('2026-07-05', 'Lunch', 'Dining', -12.5));
122 var out = F.updateTransaction(list, 1, { amount: -20, description: 'Big lunch' });
123 eq(out[0].id, 1);
124 eq(out[0].description, 'Big lunch');
125 approx(out[0].amount, -20);
126 eq(out[0].date, '2026-07-05', 'unpatched field kept');
127 eq(list[0].description, 'Lunch', 'original untouched');
128 });
129
130 test('updateTransaction throws for unknown id and invalid patch', function () {
131 var list = F.addTransaction([], tx('2026-07-05', 'Lunch', 'Dining', -12.5));
132 throws(function () { F.updateTransaction(list, 99, { amount: -1 }); }, 'unknown id');
133 throws(function () { F.updateTransaction(list, 1, { date: '2026-02-31' }); }, 'invalid patch');
134 });
135
136 test('deleteTransaction removes exactly one and throws on unknown id', function () {
137 var list = F.addTransaction([], tx('2026-07-05', 'Lunch', 'Dining', -12.5));
138 list = F.addTransaction(list, tx('2026-07-06', 'Bus', 'Transport', -2.75));
139 var out = F.deleteTransaction(list, 1);
140 eq(out.length, 1);
141 eq(out[0].id, 2);
142 eq(list.length, 2, 'original untouched');
143 throws(function () { F.deleteTransaction(out, 42); });
144 });
145
146
147
148 test('monthKey and monthLabel', function () {
149 eq(F.monthKey('2026-07-28'), '2026-07');
150 eq(F.monthLabel('2026-07'), 'Jul 2026');
151 eq(F.monthLabel('2025-01'), 'Jan 2025');
152 });
153
154 test('shiftMonth crosses year boundaries both ways', function () {
155 eq(F.shiftMonth('2026-01', -1), '2025-12');
156 eq(F.shiftMonth('2025-12', 1), '2026-01');
157 eq(F.shiftMonth('2026-07', -12), '2025-07');
158 eq(F.shiftMonth('2026-07', 0), '2026-07');
159 });
160
161 test('monthRange returns consecutive months ending at endMonth', function () {
162 eq(F.monthRange('2026-03', 3), ['2026-01', '2026-02', '2026-03']);
163 eq(F.monthRange('2026-01', 2), ['2025-12', '2026-01']);
164 });
165
166 test('monthlyTotals splits spending and income per month', function () {
167 var txs = [
168 tx('2026-07-01', 'Salary', 'Income', 4200, 1),
169 tx('2026-07-02', 'Rent', 'Housing', -1800, 2),
170 tx('2026-07-10', 'Groceries', 'Groceries', -55.25, 3),
171 tx('2026-06-15', 'Snack', 'Dining', -10, 4)
172 ];
173 var totals = F.monthlyTotals(txs);
174 approx(totals['2026-07'].spent, 1855.25);
175 approx(totals['2026-07'].income, 4200);
176 approx(totals['2026-07'].net, 2344.75);
177 approx(totals['2026-06'].spent, 10);
178 approx(totals['2026-06'].income, 0);
179 });
180
181 test('monthlySeries fills quiet months with zeros, in order', function () {
182 var txs = [tx('2026-05-10', 'a', 'Dining', -30, 1), tx('2026-07-01', 'b', 'Dining', -20, 2)];
183 var s = F.monthlySeries(txs, '2026-07', 4);
184 eq(s.map(function (d) { return d.month; }), ['2026-04', '2026-05', '2026-06', '2026-07']);
185 eq(s.map(function (d) { return d.spent; }), [0, 30, 0, 20]);
186 });
187
188 test('categoryTotals sums only expenses, optionally per month', function () {
189 var txs = [
190 tx('2026-07-01', 'Salary', 'Income', 4200, 1),
191 tx('2026-07-02', 'Mart', 'Groceries', -40.1, 2),
192 tx('2026-07-05', 'Mart', 'Groceries', -9.9, 3),
193 tx('2026-06-02', 'Mart', 'Groceries', -100, 4)
194 ];
195 eq(F.categoryTotals(txs, '2026-07'), { Groceries: 50 });
196 eq(F.categoryTotals(txs)['Groceries'], 150);
197 assert(!('Income' in F.categoryTotals(txs)), 'income excluded');
198 });
199
200 test('budgetStatus computes pct/remaining/over and sorts most-consumed first', function () {
201 var txs = [
202 tx('2026-07-02', 'Mart', 'Groceries', -450, 1),
203 tx('2026-07-03', 'Feast', 'Dining', -300, 2)
204 ];
205 var rows = F.budgetStatus(txs, { Groceries: 500, Dining: 250 }, '2026-07');
206 eq(rows[0].category, 'Dining', 'over-budget category first');
207 approx(rows[0].pct, 1.2);
208 assert(rows[0].over);
209 approx(rows[0].remaining, -50);
210 eq(rows[1].category, 'Groceries');
211 approx(rows[1].pct, 0.9);
212 assert(!rows[1].over);
213 approx(rows[1].remaining, 50);
214 });
215
216 test('budgetStatus ignores transactions from other months', function () {
217 var txs = [tx('2026-06-02', 'Mart', 'Groceries', -450, 1)];
218 var rows = F.budgetStatus(txs, { Groceries: 500 }, '2026-07');
219 approx(rows[0].spent, 0);
220 });
221
222
223
224 var filterFixture = [
225 tx('2026-07-01', 'Fresh Mart', 'Groceries', -40, 1),
226 tx('2026-07-02', 'Cafe Lumen', 'Dining', -12, 2),
227 tx('2026-06-20', 'Fresh Mart', 'Groceries', -33, 3),
228 tx('2026-07-03', 'Salary', 'Income', 4200, 4)
229 ];
230
231 test('filterTransactions by category', function () {
232 var out = F.filterTransactions(filterFixture, { category: 'Groceries' });
233 eq(out.length, 2);
234 eq(F.filterTransactions(filterFixture, { category: 'All' }).length, 4);
235 });
236
237 test('filterTransactions by search is case-insensitive substring', function () {
238 eq(F.filterTransactions(filterFixture, { search: 'MART' }).length, 2);
239 eq(F.filterTransactions(filterFixture, { search: 'lumen' }).length, 1);
240 eq(F.filterTransactions(filterFixture, { search: 'zzz' }).length, 0);
241 });
242
243 test('filterTransactions by month and combined filters', function () {
244 eq(F.filterTransactions(filterFixture, { month: '2026-06' }).length, 1);
245 var out = F.filterTransactions(filterFixture, { month: '2026-07', category: 'Groceries', search: 'fresh' });
246 eq(out.length, 1);
247 eq(out[0].id, 1);
248 });
249
250 test('sortByDateDesc sorts by date desc, id desc as tiebreak', function () {
251 var txs = [
252 tx('2026-07-01', 'a', 'Other', -1, 1),
253 tx('2026-07-03', 'b', 'Other', -1, 2),
254 tx('2026-07-03', 'c', 'Other', -1, 3)
255 ];
256 var out = F.sortByDateDesc(txs);
257 eq(out.map(function (t) { return t.id; }), [3, 2, 1]);
258 eq(txs[0].id, 1, 'original order untouched');
259 });
260
261
262
263 test('toCSV writes a header and escapes commas, quotes, newlines', function () {
264 var txs = [tx('2026-07-05', 'Coffee, "special" beans', 'Groceries', -12.5, 1)];
265 var csv = F.toCSV(txs);
266 var lines = csv.split('\n');
267 eq(lines[0], 'date,description,category,amount');
268 eq(lines[1], '2026-07-05,"Coffee, ""special"" beans",Groceries,-12.5');
269 });
270
271 test('parseCSV handles quoted fields with commas, quotes, and newlines', function () {
272 var csvText = 'date,description,category,amount\n' +
273 '2026-07-05,"Coffee, beans",Groceries,-12.5\n' +
274 '2026-07-06,"He said ""hi""",Other,-3\n' +
275 '2026-07-07,"line one\nline two",Other,-4';
276 var out = F.parseCSV(csvText);
277 eq(out.errors, []);
278 eq(out.transactions.length, 3);
279 eq(out.transactions[0].description, 'Coffee, beans');
280 eq(out.transactions[1].description, 'He said "hi"');
281 eq(out.transactions[2].description, 'line one\nline two');
282 });
283
284 test('parseCSV accepts CRLF line endings and trailing newline', function () {
285 var out = F.parseCSV('date,description,category,amount\r\n2026-07-05,Lunch,Dining,-12\r\n');
286 eq(out.errors, []);
287 eq(out.transactions.length, 1);
288 approx(out.transactions[0].amount, -12);
289 });
290
291 test('parseCSV works without a header row', function () {
292 var out = F.parseCSV('2026-07-05,Lunch,Dining,-12');
293 eq(out.errors, []);
294 eq(out.transactions.length, 1);
295 });
296
297 test('parseCSV reports bad rows with row numbers and keeps good ones', function () {
298 var csvText = 'date,description,category,amount\n' +
299 '2026-07-05,Lunch,Dining,-12\n' +
300 'not-a-date,Foo,Dining,-5\n' +
301 '2026-07-06,Bar,Nonsense,-5\n' +
302 '2026-07-07,Baz,Dining,abc\n' +
303 'only,three,fields';
304 var out = F.parseCSV(csvText);
305 eq(out.transactions.length, 1);
306 eq(out.errors.length, 4);
307 assert(out.errors[0].indexOf('row 3') === 0, 'row number in error: ' + out.errors[0]);
308 });
309
310 test('CSV round-trip preserves every field', function () {
311 var txs = [];
312 txs = F.addTransaction(txs, tx('2026-07-05', 'Coffee, "special"', 'Groceries', -12.5));
313 txs = F.addTransaction(txs, tx('2026-07-06', 'Salary\nJuly', 'Income', 4200));
314 var out = F.parseCSV(F.toCSV(txs));
315 eq(out.errors, []);
316 eq(out.transactions.map(function (t) { return [t.date, t.description, t.category, t.amount]; }),
317 txs.map(function (t) { return [t.date, t.description, t.category, t.amount]; }));
318 });
319
320 test('importCSV appends with fresh ids and reports counts', function () {
321 var existing = F.addTransaction([], tx('2026-07-01', 'Existing', 'Other', -1));
322 var result = F.importCSV(existing,
323 'date,description,category,amount\n2026-07-05,Lunch,Dining,-12\nbad-row,x,Dining,-1\n2026-07-06,Bus,Transport,-2.75');
324 eq(result.added, 2);
325 eq(result.errors.length, 1);
326 eq(result.txs.length, 3);
327 eq(result.txs.map(function (t) { return t.id; }), [1, 2, 3]);
328 eq(existing.length, 1, 'existing untouched');
329 });
330
331
332
333 test('formatMoney adds thousands separators and cents', function () {
334 eq(F.formatMoney(1234.5), '$1,234.50');
335 eq(F.formatMoney(-0.5), '-$0.50');
336 eq(F.formatMoney(0), '$0.00');
337 eq(F.formatMoney(1000000), '$1,000,000.00');
338 });
339
340 test('formatMoneyShort compacts thousands for axis ticks', function () {
341 eq(F.formatMoneyShort(950), '$950');
342 eq(F.formatMoneyShort(1234), '$1.2k');
343 eq(F.formatMoneyShort(15000), '$15k');
344 eq(F.formatMoneyShort(-500), '-$500');
345 eq(F.formatMoneyShort(0), '$0');
346 });
347
348
349
350 test('sampleData is deterministic and internally valid', function () {
351 var a = F.sampleData('2026-07-28');
352 var b = F.sampleData('2026-07-28');
353 eq(a, b, 'same seed, same data');
354 assert(a.length > 20, 'has a useful amount of data');
355 var ids = {};
356 a.forEach(function (t) {
357 assert(F.validateTransaction(t).ok, 'every sample tx valid');
358 assert(!ids[t.id], 'ids unique');
359 ids[t.id] = true;
360 });
361 var months = {};
362 a.forEach(function (t) { months[F.monthKey(t.date)] = true; });
363 eq(Object.keys(months).length, 6, 'spans six months');
364 assert(a.some(function (t) { return t.amount > 0; }), 'has income');
365 assert(a.some(function (t) { return t.amount < 0; }), 'has expenses');
366 });
367
368
369
370 test('niceScale picks clean axis steps', function () {
371 eq(Charts.niceScale(950), { max: 1000, step: 250 });
372 eq(Charts.niceScale(0), { max: 100, step: 25 });
373 eq(Charts.niceScale(4), { max: 4, step: 1 });
374 var s = Charts.niceScale(3120);
375 assert(s.max >= 3120, 'max covers the data');
376 assert(s.max / s.step >= 3 && s.max / s.step <= 6, 'reasonable tick count');
377 });
378
379
380
381 var failed = results.filter(function (r) { return !r.pass; });
382 var summary = {
383 total: results.length,
384 passed: results.length - failed.length,
385 failed: failed.length,
386 results: results
387 };
388
389 if (isNode) {
390 results.forEach(function (r) {
391 console.log((r.pass ? 'PASS' : 'FAIL') + ' ' + r.name + (r.pass ? '' : ' -- ' + r.error));
392 });
393 console.log('\n' + summary.passed + '/' + summary.total + ' tests passed');
394 if (failed.length) process.exitCode = 1;
395 } else {
396 root.TestResults = summary;
397 if (typeof root.onTestResults === 'function') root.onTestResults(summary);
398 }
399})(typeof self !== 'undefined' ? self : this);
400
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.