Skip to main content

max / goingson

17.6 KB · 501 lines History Blame Raw
1 #!/usr/bin/env node
2 /**
3 * GO Frontend JS Test Runner
4 *
5 * Sets up the global environment once, loads all source modules,
6 * then runs all test suites.
7 *
8 * Usage: node src-tauri/frontend/js/tests/run.js
9 */
10
11 const { describe, test, assert, assertEqual, assertDeepEqual, report } = require('./test-runner');
12
13 console.log('Running GO frontend tests...\n');
14
15 // ============================================================
16 // Global environment setup (mocks for browser APIs)
17 // ============================================================
18
19 const mockElements = {};
20
21 function createMockElement(tag) {
22 return {
23 tagName: (tag || 'div').toUpperCase(),
24 className: '',
25 id: '',
26 style: {},
27 dataset: {},
28 innerHTML: '',
29 _text: '',
30 set textContent(v) {
31 this._text = v;
32 this.innerHTML = String(v)
33 .replace(/&/g, '&')
34 .replace(/</g, '&lt;')
35 .replace(/>/g, '&gt;')
36 .replace(/"/g, '&quot;')
37 .replace(/'/g, '&#039;');
38 },
39 get textContent() { return this._text; },
40 children: [],
41 classList: {
42 _classes: new Set(),
43 add(c) { this._classes.add(c); },
44 remove(c) { this._classes.delete(c); },
45 contains(c) { return this._classes.has(c); },
46 },
47 attributes: [],
48 _listeners: {},
49 setAttribute(k, v) { this[k] = v; },
50 getAttribute(k) { return this[k] || null; },
51 removeAttribute(k) { delete this[k]; },
52 addEventListener(ev, fn) { this._listeners[ev] = fn; },
53 querySelector() { return createMockElement('span'); },
54 querySelectorAll() { return []; },
55 appendChild(child) { this.children.push(child); },
56 remove() {},
57 parentNode: { insertBefore() {} },
58 };
59 }
60
61 globalThis.document = {
62 createElement: createMockElement,
63 getElementById: (id) => {
64 if (!mockElements[id]) {
65 mockElements[id] = createMockElement('div');
66 mockElements[id].id = id;
67 }
68 return mockElements[id];
69 },
70 querySelectorAll: () => [],
71 };
72
73 globalThis.window = { GoingsOn: {} };
74 globalThis.GoingsOn = window.GoingsOn;
75
76 // ============================================================
77 // Load source modules (same order as index.html)
78 // ============================================================
79
80 require('../goingson'); // Initializes GoingsOn namespace
81 require('../state'); // GoingsOn.state (AppStateManager)
82 require('../utils'); // GoingsOn.utils (escapeHtml, escapeAttr, etc.)
83 require('../pagination-manager'); // GoingsOn.PaginationManager
84 require('../selection-manager'); // GoingsOn.SelectionManager
85
86 // ============================================================
87 // Test: AppStateManager / GoingsOn.state
88 // ============================================================
89
90 describe('GoingsOn.state', () => {
91 test('set() stores value and notifies subscribers', () => {
92 let called = false;
93 GoingsOn.state.subscribe('_t1', () => { called = true; });
94 GoingsOn.state.set('_t1', 'hello');
95 assert(called, 'Subscriber should fire');
96 assertEqual(GoingsOn.state._t1, 'hello');
97 });
98
99 test('subscribe() returns working unsubscribe function', () => {
100 let count = 0;
101 const unsub = GoingsOn.state.subscribe('_t2', () => { count++; });
102 GoingsOn.state.set('_t2', 'a');
103 assertEqual(count, 1);
104 unsub();
105 GoingsOn.state.set('_t2', 'b');
106 assertEqual(count, 1);
107 });
108
109 test('notify() passes new and old values to callback', () => {
110 GoingsOn.state.set('_t3', 'first');
111 let capturedOld, capturedNew;
112 GoingsOn.state.subscribe('_t3', (n, o) => { capturedNew = n; capturedOld = o; });
113 GoingsOn.state.set('_t3', 'second');
114 assertEqual(capturedOld, 'first');
115 assertEqual(capturedNew, 'second');
116 });
117
118 test('update() batch-updates multiple properties', () => {
119 let aFired = false, bFired = false;
120 GoingsOn.state.subscribe('_t4a', () => { aFired = true; });
121 GoingsOn.state.subscribe('_t4b', () => { bFired = true; });
122 GoingsOn.state.update({ _t4a: 'x', _t4b: 'y' });
123 assert(aFired && bFired, 'Both subscribers should fire');
124 assertEqual(GoingsOn.state._t4a, 'x');
125 assertEqual(GoingsOn.state._t4b, 'y');
126 });
127
128 test('resetPagination(task) resets taskPage to 1', () => {
129 GoingsOn.state.set('taskPage', 5);
130 GoingsOn.state.resetPagination('task');
131 assertEqual(GoingsOn.state.taskPage, 1);
132 });
133
134 test('resetPagination(email) resets emailPage to 1', () => {
135 GoingsOn.state.set('emailPage', 3);
136 GoingsOn.state.resetPagination('email');
137 assertEqual(GoingsOn.state.emailPage, 1);
138 });
139
140 test('clearSelection(task) clears selectedTaskIds Set', () => {
141 GoingsOn.state.selectedTaskIds.add('t1');
142 GoingsOn.state.selectedTaskIds.add('t2');
143 GoingsOn.state.clearSelection('task');
144 assertEqual(GoingsOn.state.selectedTaskIds.size, 0);
145 });
146
147 test('clearSelection(email) clears selectedEmailIds Set', () => {
148 GoingsOn.state.selectedEmailIds.add('e1');
149 GoingsOn.state.clearSelection('email');
150 assertEqual(GoingsOn.state.selectedEmailIds.size, 0);
151 });
152
153 test('multiple subscribers on same key all fire', () => {
154 let a = false, b = false;
155 GoingsOn.state.subscribe('_t5', () => { a = true; });
156 GoingsOn.state.subscribe('_t5', () => { b = true; });
157 GoingsOn.state.set('_t5', 'v');
158 assert(a && b, 'Both should fire');
159 });
160
161 test('subscriber error does not break other subscribers', () => {
162 let secondFired = false;
163 GoingsOn.state.subscribe('_t6', () => { throw new Error('boom'); });
164 GoingsOn.state.subscribe('_t6', () => { secondFired = true; });
165 // Suppress console.error for this test
166 const origError = console.error;
167 console.error = () => {};
168 GoingsOn.state.set('_t6', 'v');
169 console.error = origError;
170 assert(secondFired, 'Second subscriber should still fire');
171 });
172 });
173
174 // ============================================================
175 // Test: GoingsOn.utils — escapeAttr
176 // ============================================================
177
178 describe('GoingsOn.utils.escapeAttr', () => {
179 test('escapes backslashes, quotes, newlines', () => {
180 const r = GoingsOn.utils.escapeAttr('a\\b"c\'d\ne');
181 assert(r.includes('\\\\'), 'Should escape backslash');
182 assert(r.includes('\\"'), 'Should escape double quote');
183 assert(r.includes("\\'"), 'Should escape single quote');
184 assert(r.includes('\\n'), 'Should escape newline');
185 });
186
187 test('returns empty for null/undefined', () => {
188 assertEqual(GoingsOn.utils.escapeAttr(null), '');
189 assertEqual(GoingsOn.utils.escapeAttr(undefined), '');
190 });
191
192 test('converts non-string to string', () => {
193 assertEqual(GoingsOn.utils.escapeAttr(123), '123');
194 assertEqual(GoingsOn.utils.escapeAttr(true), 'true');
195 });
196 });
197
198 // ============================================================
199 // Test: GoingsOn.utils — getErrorMessage
200 // ============================================================
201
202 describe('GoingsOn.utils.getErrorMessage', () => {
203 test('extracts from string', () => {
204 assertEqual(GoingsOn.utils.getErrorMessage('oops'), 'oops');
205 });
206
207 test('extracts from Error object (.message)', () => {
208 assertEqual(GoingsOn.utils.getErrorMessage(new Error('fail')), 'fail');
209 });
210
211 test('uses fallback for unknown type', () => {
212 assertEqual(GoingsOn.utils.getErrorMessage(42, 'fallback'), 'fallback');
213 });
214
215 test('uses default fallback when none provided', () => {
216 assertEqual(GoingsOn.utils.getErrorMessage({}, undefined), 'An error occurred');
217 });
218 });
219
220 // ============================================================
221 // Test: GoingsOn.utils — validateLength, validateEmail
222 // ============================================================
223
224 describe('GoingsOn.utils.validateLength', () => {
225 test('accepts valid length', () => {
226 assert(GoingsOn.utils.validateLength('hello', 10), 'Should accept');
227 });
228
229 test('rejects too long', () => {
230 assert(!GoingsOn.utils.validateLength('hello world', 5), 'Should reject');
231 });
232
233 test('accepts null/empty', () => {
234 assert(GoingsOn.utils.validateLength('', 10), 'Empty should be valid');
235 assert(GoingsOn.utils.validateLength(null, 10), 'Null should be valid');
236 });
237 });
238
239 describe('GoingsOn.utils.validateEmail', () => {
240 test('accepts valid addresses', () => {
241 assert(GoingsOn.utils.validateEmail('user@example.com'), 'Should accept valid');
242 });
243
244 test('rejects invalid', () => {
245 assert(!GoingsOn.utils.validateEmail('notanemail'), 'Should reject invalid');
246 });
247
248 test('accepts empty (optional field)', () => {
249 assert(GoingsOn.utils.validateEmail(''), 'Empty should be valid');
250 });
251 });
252
253 // ============================================================
254 // Test: GoingsOn.utils — parseEmailAddress
255 // ============================================================
256
257 describe('GoingsOn.utils.parseEmailAddress', () => {
258 test('extracts "Name <email>" format', () => {
259 const r = GoingsOn.utils.parseEmailAddress('Jane Smith <jane@example.com>');
260 assertEqual(r.name, 'Jane Smith');
261 assertEqual(r.email, 'jane@example.com');
262 });
263
264 test('handles bare email address', () => {
265 const r = GoingsOn.utils.parseEmailAddress('jane@example.com');
266 assertEqual(r.name, null);
267 assertEqual(r.email, 'jane@example.com');
268 });
269
270 test('handles null/empty input', () => {
271 const r1 = GoingsOn.utils.parseEmailAddress(null);
272 assertEqual(r1.name, null);
273 assertEqual(r1.email, null);
274 const r2 = GoingsOn.utils.parseEmailAddress('');
275 assertEqual(r2.name, null);
276 assertEqual(r2.email, null);
277 });
278 });
279
280 // ============================================================
281 // Test: GoingsOn.utils — debounce
282 // ============================================================
283
284 describe('GoingsOn.utils.debounce', () => {
285 test('does not fire immediately', () => {
286 let called = false;
287 const fn = GoingsOn.utils.debounce(() => { called = true; }, 10);
288 fn();
289 assert(!called, 'Should not fire immediately');
290 });
291
292 test('rapid calls only execute last one', () => {
293 let callCount = 0, lastArg = null;
294 const origST = globalThis.setTimeout;
295 const origCT = globalThis.clearTimeout;
296 let pendingCb = null;
297 globalThis.setTimeout = (cb) => { pendingCb = cb; return 1; };
298 globalThis.clearTimeout = () => { pendingCb = null; };
299
300 const fn = GoingsOn.utils.debounce((arg) => { callCount++; lastArg = arg; }, 100);
301 fn('a');
302 fn('b');
303 fn('c');
304 if (pendingCb) pendingCb();
305
306 assertEqual(callCount, 1);
307 assertEqual(lastArg, 'c');
308
309 globalThis.setTimeout = origST;
310 globalThis.clearTimeout = origCT;
311 });
312 });
313
314 // ============================================================
315 // Test: GoingsOn.utils — escapeHtml
316 // ============================================================
317
318 describe('GoingsOn.utils.escapeHtml', () => {
319 test('escapes angle brackets', () => {
320 const r = GoingsOn.utils.escapeHtml('<b>hi</b>');
321 assert(r.includes('&lt;') && r.includes('&gt;'));
322 });
323
324 test('returns empty for falsy', () => {
325 assertEqual(GoingsOn.utils.escapeHtml(''), '');
326 assertEqual(GoingsOn.utils.escapeHtml(null), '');
327 });
328 });
329
330 // ============================================================
331 // Test: PaginationManager
332 // ============================================================
333
334 describe('PaginationManager', () => {
335 test('constructor sets defaults (page=1, totalItems=0)', () => {
336 const pm = new GoingsOn.PaginationManager('test', 10);
337 assertEqual(pm.currentPage, 1);
338 assertEqual(pm.totalItems, 0);
339 assertEqual(pm.itemsPerPage, 10);
340 });
341
342 test('goToPage(next) increments page', () => {
343 const pm = new GoingsOn.PaginationManager('test', 10);
344 pm.totalItems = 30;
345 pm.goToPage('next');
346 assertEqual(pm.currentPage, 2);
347 });
348
349 test('goToPage(prev) decrements page, clamps to 1', () => {
350 const pm = new GoingsOn.PaginationManager('test', 10);
351 pm.totalItems = 30;
352 pm.currentPage = 2;
353 pm.goToPage('prev');
354 assertEqual(pm.currentPage, 1);
355 pm.goToPage('prev');
356 assertEqual(pm.currentPage, 1);
357 });
358
359 test('goToPage(n) sets specific page, clamped', () => {
360 const pm = new GoingsOn.PaginationManager('test', 10);
361 pm.totalItems = 30;
362 pm.goToPage(3);
363 assertEqual(pm.currentPage, 3);
364 pm.goToPage(99);
365 assertEqual(pm.currentPage, 3); // max page is 3
366 pm.goToPage(0);
367 assertEqual(pm.currentPage, 1);
368 });
369
370 test('getMaxPage() calculates ceil(total/perPage)', () => {
371 const pm = new GoingsOn.PaginationManager('test', 10);
372 pm.totalItems = 25;
373 assertEqual(pm.getMaxPage(), 3);
374 pm.totalItems = 30;
375 assertEqual(pm.getMaxPage(), 3);
376 pm.totalItems = 0;
377 assertEqual(pm.getMaxPage(), 1); // min 1
378 });
379
380 test('setTotalItems() updates and clamps currentPage', () => {
381 const pm = new GoingsOn.PaginationManager('test', 10);
382 pm.currentPage = 5;
383 pm.setTotalItems(20);
384 assertEqual(pm.totalItems, 20);
385 assertEqual(pm.currentPage, 2); // clamped to max page
386 });
387
388 test('reset() returns to page 1', () => {
389 const pm = new GoingsOn.PaginationManager('test', 10);
390 pm.currentPage = 3;
391 pm.reset();
392 assertEqual(pm.currentPage, 1);
393 });
394
395 test('getOffset() returns (page-1)*perPage', () => {
396 const pm = new GoingsOn.PaginationManager('test', 10);
397 pm.currentPage = 3;
398 assertEqual(pm.getOffset(), 20);
399 });
400
401 test('paginate() slices array correctly', () => {
402 const pm = new GoingsOn.PaginationManager('test', 3);
403 pm.totalItems = 7;
404 const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
405 pm.currentPage = 2;
406 assertDeepEqual(pm.paginate(items), ['d', 'e', 'f']);
407 pm.currentPage = 3;
408 assertDeepEqual(pm.paginate(items), ['g']);
409 });
410
411 test('getInfo() returns correct summary object', () => {
412 const pm = new GoingsOn.PaginationManager('test', 10);
413 pm.totalItems = 25;
414 pm.currentPage = 2;
415 const info = pm.getInfo();
416 assertEqual(info.currentPage, 2);
417 assertEqual(info.maxPage, 3);
418 assertEqual(info.start, 11);
419 assertEqual(info.end, 20);
420 assertEqual(info.total, 25);
421 assertEqual(info.hasPrev, true);
422 assertEqual(info.hasNext, true);
423 });
424 });
425
426 // ============================================================
427 // Test: SelectionManager
428 // ============================================================
429
430 describe('SelectionManager', () => {
431 test('constructor initializes empty Set', () => {
432 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
433 assertEqual(sm.selectedIds.size, 0);
434 assertEqual(sm.lastClickedIndex, -1);
435 });
436
437 test('setItems() stores items array', () => {
438 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
439 const items = [{ id: '1' }, { id: '2' }];
440 sm.setItems(items);
441 assertEqual(sm.items.length, 2);
442 });
443
444 test('getSelected() returns the Set', () => {
445 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
446 sm.selectedIds.add('x');
447 assert(sm.getSelected() instanceof Set);
448 assert(sm.getSelected().has('x'));
449 });
450
451 test('hasSelection() returns true when items selected, false when empty', () => {
452 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
453 assert(!sm.hasSelection());
454 sm.selectedIds.add('a');
455 assert(sm.hasSelection());
456 });
457
458 test('getCount() returns correct count', () => {
459 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
460 assertEqual(sm.getCount(), 0);
461 sm.selectedIds.add('a');
462 sm.selectedIds.add('b');
463 assertEqual(sm.getCount(), 2);
464 });
465
466 test('isSelected(id) returns boolean correctly', () => {
467 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
468 sm.selectedIds.add('x');
469 assert(sm.isSelected('x'));
470 assert(!sm.isSelected('y'));
471 });
472
473 test('toggle adds/removes from selectedIds', () => {
474 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
475 sm.setItems([{ id: 'a' }, { id: 'b' }]);
476 // Simulate checking
477 sm.toggle('a', { checked: true }, null);
478 assert(sm.selectedIds.has('a'));
479 // Simulate unchecking
480 sm.toggle('a', { checked: false }, null);
481 assert(!sm.selectedIds.has('a'));
482 });
483
484 test('clear() empties Set and resets lastClickedIndex', () => {
485 const sm = new GoingsOn.SelectionManager('test', '.test', 'bulk-bar');
486 sm.selectedIds.add('a');
487 sm.selectedIds.add('b');
488 sm.lastClickedIndex = 3;
489 sm.clear();
490 assertEqual(sm.selectedIds.size, 0);
491 assertEqual(sm.lastClickedIndex, -1);
492 });
493 });
494
495 // ============================================================
496 // Report
497 // ============================================================
498
499 const success = report();
500 process.exit(success ? 0 : 1);
501