Skip to main content

max / balanced_breakfast

Stop minifying CSS; drop the hand-rolled JS test runner for node --test BB is a Tauri app reading CSS from a bundled local folder, so there is no transfer to shrink: build-css.js only bought 38KB nobody downloads, at the cost of an unpinned npx clean-css fetch at build time and node being needed to build at all. index.html links styles.css directly and the tauri.conf hooks are gone. The frontend tests move to node --test, one suite per module over a shared setup.js holding the browser-API mocks. The old runner called async test bodies without awaiting them, so five assertions had never actually run; four were wrong about behaviour (toggleStar/toggleRead reload from the API, so the mock now writes back to its fixtures) and one hit a missing document.createTextNode. All 55 pass.
Author: Max Johnson <me@maxj.phd> · 2026-08-09 14:44 UTC
Signed with PGP, not checked
Commit: 476bf0908279970219df982ab896044ac253037a
Parent: fde1587
15 files changed, +741 insertions, -595 deletions
M .gitignore -3
@@ -1,9 +1,6 @@
1 1 # Build artifacts
2 2 /target/
3 3
4 - # Minified CSS (regenerated from styles.css by beforeBuildCommand/beforeDevCommand)
5 - src-tauri/frontend/css/styles.min.css
6 -
7 4 # Environment
8 5 .env
9 6
M CONTRIBUTING.md +1 -1
@@ -238,7 +238,7 @@
238 238
239 239 ### JS Tests
240 240
241 - Node.js test runner at `src-tauri/frontend/js/tests/run.js` with browser API mocks. Run with `node src-tauri/frontend/js/tests/run.js`.
241 + `node --test` over `src-tauri/frontend/js/tests/*.test.js`, one suite per module. `setup.js` holds the browser API mocks and loads the frontend modules in index.html order; every suite requires it first, and each file gets its own process, so no state crosses between them. Run with `node --test 'src-tauri/frontend/js/tests/*.test.js'` (quote the glob; node expands it, the shell must not).
242 242
243 243 ### Pre-commit gate
244 244
@@ -4,8 +4,6 @@
4 4 "version": "0.3.3",
5 5 "identifier": "com.balancedbreakfast.app",
6 6 "build": {
7 - "beforeBuildCommand": "node src-tauri/frontend/build-css.js",
8 - "beforeDevCommand": "node src-tauri/frontend/build-css.js",
9 7 "frontendDist": "../src-tauri/frontend"
10 8 },
11 9 "app": {
@@ -50,7 +50,7 @@
50 50 fi
51 51
52 52 echo "pre-commit: JS tests..."
53 - if ! node src-tauri/frontend/js/tests/run.js; then
53 + if ! node --test 'src-tauri/frontend/js/tests/*.test.js'; then
54 54 echo "pre-commit: JS tests failed, commit aborted (use --no-verify to bypass)."
55 55 exit 1
56 56 fi
@@ -23,7 +23,7 @@
23 23 stylesheet can read --gap-* and --step-* off :root. -->
24 24 <link rel="stylesheet" href="css/geometry.css">
25 25 <link rel="stylesheet" href="css/layout.css">
26 - <link rel="stylesheet" href="css/styles.min.css">
26 + <link rel="stylesheet" href="css/styles.css">
27 27 </head>
28 28 <body>
29 29 <div id="app">
@@ -1150,7 +1150,7 @@
1150 1150 flex-shrink: 0;
1151 1151 }
1152 1152 /* F5 (2026-06-02): dropped legacy color-only .health-* classes; render
1153 - site (sources.js) and tests/run.js:472 updated to use [data-health]. */
1153 + site (sources.js) and the sources render tests updated to use [data-health]. */
1154 1154
1155 1155 /* F3 fix (2026-06-02): state-by-color violation retired. Each state now
1156 1156 pairs color with a SHAPE modifier via [data-health], so the indicator
@@ -1,17 +1,0 @@
1 - const { execSync } = require("child_process");
2 - const fs = require("fs");
3 - const path = require("path");
4 -
5 - const cssDir = path.join(__dirname, "css");
6 - const src = path.join(cssDir, "styles.css");
7 - const dest = path.join(cssDir, "styles.min.css");
8 -
9 - if (!fs.existsSync(dest) || fs.statSync(src).mtimeMs > fs.statSync(dest).mtimeMs) {
10 - console.log("Minifying CSS...");
11 - execSync(`npx --yes clean-css-cli "${src}" -o "${dest}"`, { stdio: "inherit" });
12 - const srcSize = fs.statSync(src).size;
13 - const destSize = fs.statSync(dest).size;
14 - console.log(`CSS minified: ${srcSize} -> ${destSize} bytes`);
15 - } else {
16 - console.log("CSS already up to date");
17 - }
@@ -1,0 +1,118 @@
1 + const { describe, test, beforeEach } = require('node:test');
2 + const assert = require('node:assert');
3 + const { BB, resetMockElement, apiCalls, resetItemFixtures } = require('./setup');
4 +
5 + // The item mocks write back to their fixtures, so each test starts from the
6 + // same two items rather than from whatever the last toggle left behind.
7 + beforeEach(() => {
8 + resetItemFixtures();
9 + apiCalls.length = 0;
10 + });
11 +
12 + describe('BB.items.load', () => {
13 + test('populates state with items', async () => {
14 + await BB.items.load();
15 + assert.strictEqual(BB.state.items.length, 2);
16 + assert.strictEqual(BB.state.items[0].title, 'First');
17 + assert.strictEqual(BB.state.hasMore, true);
18 + });
19 +
20 + test('appends items when append=true', async () => {
21 + BB.state.set('items', [{ id: 'old', title: 'Old' }]);
22 + await BB.items.load(true);
23 + assert.strictEqual(BB.state.items.length, 3);
24 + assert.strictEqual(BB.state.items[0].id, 'old');
25 + });
26 + });
27 +
28 + describe('BB.items.selectItem', () => {
29 + test('sets selectedItemId', async () => {
30 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
31 + await BB.items.selectItem('i1');
32 + assert.strictEqual(BB.state.selectedItemId, 'i1');
33 + });
34 +
35 + test('marks item as read via API', async () => {
36 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
37 + await BB.items.selectItem('i1');
38 + assert.ok(apiCalls.some(c => c.cmd === 'markRead' && c.id === 'i1'));
39 + });
40 + });
41 +
42 + describe('BB.items.toggleStar', () => {
43 + test('unstars a starred item', async () => {
44 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: true }]);
45 + await BB.items.toggleStar('i1', true);
46 + assert.ok(apiCalls.some(c => c.cmd === 'unstar'));
47 + assert.strictEqual(BB.state.items[0].isStarred, false);
48 + });
49 +
50 + test('stars an unstarred item', async () => {
51 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]);
52 + await BB.items.toggleStar('i1', false);
53 + assert.ok(apiCalls.some(c => c.cmd === 'star'));
54 + assert.strictEqual(BB.state.items[0].isStarred, true);
55 + });
56 + });
57 +
58 + describe('BB.items.toggleRead', () => {
59 + test('marks read item as unread', async () => {
60 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]);
61 + await BB.items.toggleRead('i1', true);
62 + assert.ok(apiCalls.some(c => c.cmd === 'markUnread'));
63 + assert.strictEqual(BB.state.items[0].isRead, false);
64 + });
65 +
66 + test('marks unread item as read', async () => {
67 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
68 + await BB.items.toggleRead('i1', false);
69 + assert.ok(apiCalls.some(c => c.cmd === 'markRead' && c.id === 'i1'));
70 + assert.strictEqual(BB.state.items[0].isRead, true);
71 + });
72 + });
73 +
74 + describe('BB.items.loadMore', () => {
75 + test('increments page', async () => {
76 + BB.state.set('currentPage', 0);
77 + await BB.items.loadMore();
78 + assert.strictEqual(BB.state.currentPage, 1);
79 + });
80 + });
81 +
82 + describe('BB.items.render', () => {
83 + test('empty items array renders placeholder message', () => {
84 + resetMockElement('items-list');
85 + BB.state.set('sources', [{ id: 's1', name: 'F' }]);
86 + BB.items.render([]);
87 + const list = document.getElementById('items-list');
88 + assert.ok(list.innerHTML.includes('empty-state'), 'Should show empty state');
89 + });
90 +
91 + test('item with isStarred=true has starred class', () => {
92 + resetMockElement('items-list');
93 + BB.items.render([
94 + { id: 'i1', title: 'T', author: 'A', isRead: false, isStarred: true, timeAgo: '1m' },
95 + ]);
96 + const list = document.getElementById('items-list');
97 + const item = list.children[0];
98 + assert.ok(item.innerHTML.includes('starred'), 'Should have starred class');
99 + });
100 +
101 + test('item with isRead=true has read class', () => {
102 + resetMockElement('items-list');
103 + BB.items.render([
104 + { id: 'i1', title: 'T', author: 'A', isRead: true, isStarred: false, timeAgo: '1m' },
105 + ]);
106 + const list = document.getElementById('items-list');
107 + const item = list.children[0];
108 + assert.ok(item.className.includes('read'), 'Should have read class');
109 + });
110 +
111 + test('loadMore with hasMore=false is a no-op for display', () => {
112 + resetMockElement('items-list');
113 + BB.state.set('hasMore', false);
114 + const loadMoreEl = document.getElementById('load-more');
115 + BB.items.render([{ id: 'i1', title: 'T', author: 'A', isRead: false, isStarred: false, timeAgo: '1m' }]);
116 + assert.strictEqual(loadMoreEl.style.display, 'none');
117 + });
118 + });
@@ -1,720 +1,0 @@
1 - #!/usr/bin/env node
2 - /**
3 - * BB 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 BB frontend tests...\n');
14 -
15 - // Global environment setup (mocks for browser APIs)
16 -
17 - const mockElements = {};
18 -
19 - // Serialize a mock element to an HTML-ish string so tests can assert against
20 - // rows built from child elements (BB.ui.renderRow), not just innerHTML
21 - // template strings. Reflects className + attributes set via setAttribute.
22 - function outerHTML(el) {
23 - const tag = (el.tagName || 'div').toLowerCase();
24 - let attrs = '';
25 - if (el.className) attrs += ` class="${el.className}"`;
26 - for (const [k, v] of Object.entries(el._attrs || {})) attrs += ` ${k}="${v}"`;
27 - return `<${tag}${attrs}>${el.innerHTML}</${tag}>`;
28 - }
29 -
30 - function createMockElement(tag) {
31 - const el = {
32 - tagName: (tag || 'div').toUpperCase(),
33 - className: '',
34 - id: '',
35 - style: { cssText: '' },
36 - dataset: {},
37 - _html: '',
38 - _attrs: {},
39 - _text: '',
40 - set textContent(v) {
41 - this._text = v;
42 - this.innerHTML = String(v)
43 - .replace(/&/g, '&amp;')
44 - .replace(/</g, '&lt;')
45 - .replace(/>/g, '&gt;')
46 - .replace(/"/g, '&quot;')
47 - .replace(/'/g, '&#039;');
48 - },
49 - get textContent() { return this._text; },
50 - // innerHTML: explicit assignment wins; otherwise serialize children so
51 - // element-built rows are inspectable.
52 - get innerHTML() {
53 - if (this._html) return this._html;
54 - return this.children.map(outerHTML).join('');
55 - },
56 - set innerHTML(v) { this._html = v; },
57 - children: [],
58 - attributes: [],
59 - _listeners: {},
60 - setAttribute(k, v) { this._attrs[k] = v; this[k] = v; },
61 - getAttribute(k) { return this._attrs[k] != null ? this._attrs[k] : (this[k] || null); },
62 - addEventListener(ev, fn) { this._listeners[ev] = fn; },
63 - querySelector() { return createMockElement('span'); },
64 - querySelectorAll() { return []; },
65 - appendChild(child) {
66 - if (child.tagName === 'FRAGMENT') {
67 - this.children.push(...child.children);
68 - } else {
69 - this.children.push(child);
70 - }
71 - },
72 - remove() {},
73 - parentNode: { insertBefore() {} },
74 - };
75 - el.classList = {
76 - _el: el,
77 - add(cls) { if (!this._el.className.includes(cls)) this._el.className = (this._el.className + ' ' + cls).trim(); },
78 - remove(cls) { this._el.className = this._el.className.replace(cls, '').trim(); },
79 - toggle(cls, force) {
80 - if (force === undefined) force = !this._el.className.includes(cls);
81 - if (force) this.add(cls); else this.remove(cls);
82 - },
83 - contains(cls) { return this._el.className.includes(cls); },
84 - };
85 - return el;
86 - }
87 -
88 - globalThis.document = {
89 - createElement: createMockElement,
90 - createDocumentFragment: () => {
91 - const frag = createMockElement('fragment');
92 - return frag;
93 - },
94 - getElementById: (id) => {
95 - if (!mockElements[id]) {
96 - mockElements[id] = createMockElement('div');
97 - mockElements[id].id = id;
98 - }
99 - return mockElements[id];
100 - },
101 - };
102 -
103 - globalThis.window = {};
104 - globalThis.BB = {};
105 - globalThis.confirm = () => true;
106 -
107 - // Load source modules (same order as index.html)
108 -
109 - require('../bb'); // Initializes BB namespace
110 - require('../state'); // BB.state (Proxy-based pub/sub)
111 - require('../utils'); // BB.utils (escapeHtml, escapeAttr, debounce)
112 -
113 - // Mock BB.api before loading modules that depend on it
114 - BB.api = {
115 - sources: {
116 - list: async () => [
117 - { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
118 - { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
119 - ],
120 - },
121 - items: {
122 - list: async () => ({
123 - items: [
124 - { id: 'i1', title: 'First', author: 'Alice', isRead: false, isStarred: false, timeAgo: '2m' },
125 - { id: 'i2', title: 'Second', author: 'Bob', isRead: true, isStarred: true, timeAgo: '5m' },
126 - ],
127 - hasMore: true,
128 - }),
129 - markRead: async () => {},
130 - markUnread: async () => {},
131 - star: async () => {},
132 - unstar: async () => {},
133 - },
134 - feeds: {
135 - listAllTags: async () => ['news', 'tech'],
136 - deleteByBusser: async () => {},
137 - getByBusser: async (id) => [{ busserId: id, name: 'Test', config: {} }],
138 - create: async () => {},
139 - setTags: async () => {},
140 - get: async () => ({ name: 'Test', config: {} }),
141 - update: async () => {},
142 - },
143 - plugins: { schema: async () => ({ fields: [] }) },
144 - };
145 - // Load the real BB.ui (renderRow, renderEmptyState, etc.); components.js has
146 - // no load-time DOM side effects. Then stub the methods that touch document.body
147 - // / overlays, matching the harness's prior no-op mock so render tests stay pure.
148 - require('../components');
149 - BB.ui.showToast = () => {};
150 - BB.ui.openFormModal = () => {};
151 - BB.detail = { load() {}, collapseReader() {}, updateSavedBadge() {} };
152 - BB.queryFeeds = { load() {}, select() {}, openBuilder() {}, deleteFeed() {} };
153 -
154 - // Modules that depend on BB.utils and BB.api
155 - require('../sources'); // BB.sources
156 - require('../items'); // BB.items
157 -
158 - // Test: BB.state
159 -
160 - describe('BB.state', () => {
161 - test('subscribe registers callback and fires on set', () => {
162 - let called = false;
163 - BB.state.subscribe('_t1', () => { called = true; });
164 - BB.state.set('_t1', 'hello');
165 - assert(called, 'Subscriber should fire');
166 - });
167 -
168 - test('set passes old and new values to subscriber', () => {
169 - BB.state.set('_t2', 'first');
170 - let capturedOld;
171 - BB.state.subscribe('_t2', (n, o) => { capturedOld = o; });
172 - BB.state.set('_t2', 'second');
173 - assertEqual(capturedOld, 'first');
174 - });
175 -
176 - test('set does not trigger unrelated subscribers', () => {
177 - let called = false;
178 - BB.state.subscribe('_t3_a', () => { called = true; });
179 - BB.state.set('_t3_b', 'val');
180 - assert(!called, 'Unrelated subscriber should not fire');
181 - });
182 -
183 - test('unsubscribe removes callback', () => {
184 - let count = 0;
185 - const unsub = BB.state.subscribe('_t4', () => { count++; });
186 - BB.state.set('_t4', 'a');
187 - assertEqual(count, 1);
188 - unsub();
189 - BB.state.set('_t4', 'b');
190 - assertEqual(count, 1);
191 - });
192 -
193 - test('set with same value still triggers', () => {
194 - BB.state.set('_t5', 'same');
195 - let count = 0;
196 - BB.state.subscribe('_t5', () => { count++; });
197 - BB.state.set('_t5', 'same');
198 - assertEqual(count, 1);
199 - });
200 -
201 - test('multiple subscribers on same key all fire', () => {
202 - let a = false, b = false;
203 - BB.state.subscribe('_t6', () => { a = true; });
204 - BB.state.subscribe('_t6', () => { b = true; });
205 - BB.state.set('_t6', 'v');
206 - assert(a && b, 'Both should fire');
207 - });
208 -
209 - test('direct property assignment triggers via Proxy', () => {
210 - let called = false;
211 - BB.state.subscribe('_t7', () => { called = true; });
212 - BB.state._t7 = 'proxy';
213 - assert(called, 'Proxy set should trigger');
214 - assertEqual(BB.state._t7, 'proxy');
215 - });
216 -
217 - test('get returns current value', () => {
218 - BB.state.set('_t8', 42);
219 - assertEqual(BB.state.get('_t8'), 42);
220 - });
221 -
222 - test('initial state has expected default keys', () => {
223 - assert(Array.isArray(BB.state.sources));
224 - assertEqual(BB.state.currentOrder, 'chronological');
225 - assertEqual(BB.state.hasMore, false);
226 - assertEqual(BB.state.selectedItemId, null);
227 - });
228 - });
229 -
230 - // Test: BB.utils.escapeHtml
231 -
232 - describe('BB.utils.escapeHtml', () => {
233 - test('escapes angle brackets', () => {
234 - const r = BB.utils.escapeHtml('<b>hi</b>');
235 - assert(r.includes('&lt;') && r.includes('&gt;'));
236 - });
237 -
238 - test('escapes ampersand', () => {
239 - assert(BB.utils.escapeHtml('A & B').includes('&amp;'));
240 - });
241 -
242 - test('returns empty for falsy', () => {
243 - assertEqual(BB.utils.escapeHtml(''), '');
244 - assertEqual(BB.utils.escapeHtml(null), '');
245 - assertEqual(BB.utils.escapeHtml(undefined), '');
246 - });
247 -
248 - test('passes safe strings through', () => {
249 - assertEqual(BB.utils.escapeHtml('hello'), 'hello');
250 - });
251 - });
252 -
253 - // Test: BB.utils.escapeAttr
254 -
255 - describe('BB.utils.escapeAttr', () => {
256 - test('escapes double quotes', () => {
257 - assert(BB.utils.escapeAttr('a"b').includes('&quot;'));
258 - });
259 -
260 - test('escapes single quotes', () => {
261 - assert(BB.utils.escapeAttr("a'b").includes('&#39;'));
262 - });
263 -
264 - test('escapes < and >', () => {
265 - const r = BB.utils.escapeAttr('<>');
266 - assert(r.includes('&lt;') && r.includes('&gt;'));
267 - });
268 -
269 - test('escapes ampersand', () => {
270 - assertEqual(BB.utils.escapeAttr('a&b'), 'a&amp;b');
271 - });
272 -
273 - test('returns empty for falsy', () => {
274 - assertEqual(BB.utils.escapeAttr(''), '');
275 - assertEqual(BB.utils.escapeAttr(null), '');
276 - });
277 -
278 - test('handles all special chars together', () => {
279 - const r = BB.utils.escapeAttr(`<"&'>`);
280 - assert(!r.includes('<') || r.includes('&lt;'));
281 - });
282 -
283 - test('converts non-string to string', () => {
284 - assertEqual(BB.utils.escapeAttr(123), '123');
285 - });
286 - });
287 -
288 - // Test: BB.utils.debounce
289 -
290 - describe('BB.utils.debounce', () => {
291 - test('does not fire immediately', () => {
292 - let called = false;
293 - const fn = BB.utils.debounce(() => { called = true; }, 10);
294 - fn();
295 - assert(!called, 'Should not fire immediately');
296 - });
297 -
298 - test('rapid calls only execute last one', () => {
299 - let callCount = 0, lastArg = null;
300 - const origST = globalThis.setTimeout;
301 - const origCT = globalThis.clearTimeout;
302 - let pendingCb = null;
303 - globalThis.setTimeout = (cb) => { pendingCb = cb; return 1; };
304 - globalThis.clearTimeout = () => { pendingCb = null; };
305 -
306 - const fn = BB.utils.debounce((arg) => { callCount++; lastArg = arg; }, 100);
307 - fn('a');
308 - fn('b');
309 - fn('c');
310 - if (pendingCb) pendingCb();
311 -
312 - assertEqual(callCount, 1);
313 - assertEqual(lastArg, 'c');
314 -
315 - globalThis.setTimeout = origST;
316 - globalThis.clearTimeout = origCT;
317 - });
318 - });
319 -
320 - // Test: BB.sources
321 -
322 - describe('BB.sources.select', () => {
323 - test('sets currentSource state', () => {
324 - BB.sources.select('s1');
325 - assertEqual(BB.state.currentSource, 's1');
326 - });
327 -
328 - test('resets pagination', () => {
329 - BB.state.set('currentPage', 5);
330 - BB.sources.select('s2');
331 - assertEqual(BB.state.currentPage, 0);
332 - });
333 -
334 - test('clears selectedItemId', () => {
335 - BB.state.set('selectedItemId', 'x');
336 - BB.sources.select('');
337 - assertEqual(BB.state.selectedItemId, null);
338 - });
339 -
340 - test('clears currentQueryFeed', () => {
341 - BB.state.set('currentQueryFeed', 'qf');
342 - BB.sources.select('s1');
343 - assertEqual(BB.state.currentQueryFeed, null);
344 - });
345 - });
346 -
347 - describe('BB.sources.selectTag', () => {
348 - test('sets currentTag', () => {
349 - BB.sources.selectTag('tech');
350 - assertEqual(BB.state.currentTag, 'tech');
351 - });
352 -
353 - test('resets pagination', () => {
354 - BB.state.set('currentPage', 3);
355 - BB.sources.selectTag('news');
356 - assertEqual(BB.state.currentPage, 0);
357 - });
358 - });
359 -
360 - describe('BB.sources.load', () => {
361 - test('populates state', async () => {
362 - await BB.sources.load();
363 - assertEqual(BB.state.sources.length, 2);
364 - assertDeepEqual(BB.state.allTags, ['news', 'tech']);
365 - });
366 - });
367 -
368 - // Test: BB.items
369 -
370 - // Track API calls
371 - let apiCalls = [];
372 - BB.api.items.markRead = async (id) => { apiCalls.push({ cmd: 'markRead', id }); };
373 - BB.api.items.markUnread = async (id) => { apiCalls.push({ cmd: 'markUnread', id }); };
374 - BB.api.items.star = async (id) => { apiCalls.push({ cmd: 'star', id }); };
375 - BB.api.items.unstar = async (id) => { apiCalls.push({ cmd: 'unstar', id }); };
376 -
377 - describe('BB.items.load', () => {
378 - test('populates state with items', async () => {
379 - await BB.items.load();
380 - assertEqual(BB.state.items.length, 2);
381 - assertEqual(BB.state.items[0].title, 'First');
382 - assertEqual(BB.state.hasMore, true);
383 - });
384 -
385 - test('appends items when append=true', async () => {
386 - BB.state.set('items', [{ id: 'old', title: 'Old' }]);
387 - await BB.items.load(true);
388 - assertEqual(BB.state.items.length, 3);
389 - assertEqual(BB.state.items[0].id, 'old');
390 - });
391 - });
392 -
393 - describe('BB.items.selectItem', () => {
394 - test('sets selectedItemId', async () => {
395 - BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
396 - await BB.items.selectItem('i1');
397 - assertEqual(BB.state.selectedItemId, 'i1');
398 - });
399 -
400 - test('marks item as read via API', async () => {
401 - apiCalls = [];
402 - BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
403 - await BB.items.selectItem('i1');
404 - assert(apiCalls.some(c => c.cmd === 'markRead' && c.id === 'i1'));
405 - });
406 - });
407 -
408 - describe('BB.items.toggleStar', () => {
409 - test('unstars a starred item', async () => {
410 - apiCalls = [];
411 - BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: true }]);
412 - await BB.items.toggleStar('i1', true);
413 - assert(apiCalls.some(c => c.cmd === 'unstar'));
414 - assertEqual(BB.state.items[0].isStarred, false);
415 - });
416 -
417 - test('stars an unstarred item', async () => {
418 - apiCalls = [];
419 - BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]);
420 - await BB.items.toggleStar('i1', false);
421 - assert(apiCalls.some(c => c.cmd === 'star'));
422 - assertEqual(BB.state.items[0].isStarred, true);
423 - });
424 - });
425 -
426 - describe('BB.items.toggleRead', () => {
427 - test('marks read item as unread', async () => {
428 - apiCalls = [];
429 - BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]);
430 - await BB.items.toggleRead('i1', true);
431 - assert(apiCalls.some(c => c.cmd === 'markUnread'));
432 - assertEqual(BB.state.items[0].isRead, false);
433 - });
434 - });
435 -
436 - describe('BB.items.loadMore', () => {
437 - test('increments page', async () => {
438 - BB.state.set('currentPage', 0);
439 - await BB.items.loadMore();
440 - assertEqual(BB.state.currentPage, 1);
441 - });
442 - });
443 -
444 - // Test: BB.sources.render (rendering edge cases)
445 -
446 - // Helper to reset a mock element's children array
447 - function resetMockElement(id) {
448 - const el = mockElements[id];
449 - if (el) el.children = [];
450 - }
451 -
452 - describe('BB.sources.render', () => {
453 - test('renderSourceList creates correct number of source elements', () => {
454 - resetMockElement('sources-list');
455 - const sources = [
456 - { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
457 - { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
458 - { id: 's3', name: 'Feed C', totalCount: 0, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' },
459 - ];
460 - BB.state.set('currentSource', '');
461 - BB.state.set('queryFeeds', []);
462 - BB.sources.render(sources);
463 - const list = document.getElementById('sources-list');
464 - // 1 All + 3 source items + 1 "+ Query Feed" button
465 - assertEqual(list.children.length, 5);
466 - });
467 -
468 - test('source health indicator shows correct class for yellow', () => {
469 - resetMockElement('sources-list');
470 - const sources = [
471 - { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
472 - ];
473 - BB.state.set('currentSource', '');
474 - BB.state.set('queryFeeds', []);
475 - BB.sources.render(sources);
476 - const list = document.getElementById('sources-list');
477 - const sourceItem = list.children[1]; // children[0] is "All", sources start at [1]
478 - assert(sourceItem.innerHTML.includes('data-health="yellow"'), 'Should have data-health="yellow" attribute');
479 - });
480 -
481 - test('source with unreadCount=0 shows total via all-read style (no slash, no checkmark)', () => {
482 - resetMockElement('sources-list');
483 - const sources = [
484 - { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'green' },
485 - ];
486 - BB.state.set('currentSource', '');
487 - BB.state.set('queryFeeds', []);
488 - BB.sources.render(sources);
489 - const list = document.getElementById('sources-list');
490 - const sourceItem = list.children[1]; // children[0] is "All"
491 - assert(sourceItem.innerHTML.includes('all-read'), 'Should mark the count all-read (green)');
492 - assert(sourceItem.innerHTML.includes('5'), 'Should show total count');
493 - assert(!sourceItem.innerHTML.includes('0/5'), 'Should not show 0/X format');
494 - assert(!sourceItem.innerHTML.includes('\u2713'), 'Should not show a checkmark');
495 - });
496 -
497 - test('source with lastError shows error text in health dot title', () => {
498 - resetMockElement('sources-list');
499 - const sources = [
500 - { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' },
Lines truncated
@@ -1,0 +1,138 @@
1 + const { describe, test } = require('node:test');
2 + const assert = require('node:assert');
3 + const { BB } = require('./setup');
4 +
5 + // settings-sync reads BB.api.sync and BB.ui at load time, so both are stood up
6 + // before the module is required.
7 + let syncStatusResult = {};
8 + let syncToasts = [];
9 + BB.ui.showToast = (msg, type) => { syncToasts.push({ msg, type }); };
10 + BB.ui.openModal = () => {};
11 +
12 + BB.api.sync = {
13 + status: async () => syncStatusResult,
14 + startAuth: async () => ({ authUrl: 'https://test.com', state: 's', codeVerifier: 'cv', port: 8080 }),
15 + completeAuth: async () => {},
16 + setupEncryptionNew: async () => {},
17 + setupEncryptionExisting: async () => {},
18 + now: async () => ({ pushed: 1, pulled: 2 }),
19 + updateSettings: async () => {},
20 + disconnect: async () => {},
21 + };
22 +
23 + require('../settings-sync');
24 +
25 + // Clear the modal body between renders: openSettings appends into it.
26 + function freshModalBody() {
27 + const body = document.getElementById('modal-body');
28 + body.innerHTML = '';
29 + body.children = [];
30 + return body;
31 + }
32 +
33 + describe('BB.sync.openSettings: renderState routing', () => {
34 + test('not configured shows Connect button', async () => {
35 + syncStatusResult = { configured: false, authenticated: false };
36 + const body = freshModalBody();
37 + await BB.sync.openSettings();
38 + // renderConnect adds a div with class sync-connect
39 + assert.ok(
40 + body.children.some(c => c.className === 'sync-connect'),
41 + 'Should render connect state'
42 + );
43 + });
44 +
45 + test('configured but not authenticated shows Connect button', async () => {
46 + syncStatusResult = { configured: true, authenticated: false };
47 + const body = freshModalBody();
48 + await BB.sync.openSettings();
49 + assert.ok(
50 + body.children.some(c => c.className === 'sync-connect'),
51 + 'Should render connect state when not authenticated'
52 + );
53 + });
54 +
55 + test('authenticated but no encryption and no server key shows Set Password', async () => {
56 + syncStatusResult = { configured: true, authenticated: true, encryptionReady: false, hasServerKey: false };
57 + const body = freshModalBody();
58 + await BB.sync.openSettings();
59 + // renderEncryption adds a div, check for Set Password button text
60 + const hasForm = body.children.some(c => {
61 + // The child div contains a form with submit button
62 + return c.children && c.children.some(f =>
63 + f.children && f.children.some(a =>
64 + a.children && a.children.some(b => b._text === 'Set Password')
65 + )
66 + );
67 + });
68 + assert.ok(hasForm, 'Should show Set Password button');
69 + });
70 +
71 + test('authenticated but no encryption with server key shows Unlock', async () => {
72 + syncStatusResult = { configured: true, authenticated: true, encryptionReady: false, hasServerKey: true };
73 + const body = freshModalBody();
74 + await BB.sync.openSettings();
75 + const hasUnlock = body.children.some(c => {
76 + return c.children && c.children.some(f =>
77 + f.children && f.children.some(a =>
78 + a.children && a.children.some(b => b._text === 'Unlock')
79 + )
80 + );
81 + });
82 + assert.ok(hasUnlock, 'Should show Unlock button');
83 + });
84 +
85 + test('fully ready shows sync-ready with Sync Now button', async () => {
86 + syncStatusResult = {
87 + configured: true, authenticated: true, encryptionReady: true,
88 + lastSyncAt: null, pendingChanges: 0, autoSyncEnabled: false,
89 + syncIntervalMinutes: 15,
90 + };
91 + const body = freshModalBody();
92 + await BB.sync.openSettings();
93 + assert.ok(
94 + body.children.some(c => c.className === 'sync-ready'),
95 + 'Should render ready state'
96 + );
97 + });
98 +
99 + test('ready state shows Never for null lastSyncAt', async () => {
100 + syncStatusResult = {
101 + configured: true, authenticated: true, encryptionReady: true,
102 + lastSyncAt: null, pendingChanges: 0, autoSyncEnabled: true,
103 + syncIntervalMinutes: 15,
104 + };
105 + const body = freshModalBody();
106 + await BB.sync.openSettings();
107 + const readyDiv = body.children.find(c => c.className === 'sync-ready');
108 + assert.ok(readyDiv, 'Should have sync-ready div');
109 + // The info div should contain "Never"
110 + const infoDiv = readyDiv.children.find(c => c.className === 'sync-info');
111 + assert.ok(infoDiv && infoDiv.innerHTML.includes('Never'), 'Should show Never for null lastSyncAt');
112 + });
113 +
114 + test('openSettings shows error toast on API failure', async () => {
115 + syncToasts = [];
116 + BB.api.sync.status = async () => { throw new Error('network error'); };
117 + await BB.sync.openSettings();
118 + assert.ok(syncToasts.some(t => t.type === 'error'), 'Should show error toast');
119 + // Restore status mock
120 + BB.api.sync.status = async () => syncStatusResult;
121 + });
122 +
123 + test('ready state has disconnect button', async () => {
124 + syncStatusResult = {
125 + configured: true, authenticated: true, encryptionReady: true,
126 + lastSyncAt: '2026-01-01T00:00:00Z', pendingChanges: 3,
127 + autoSyncEnabled: true, syncIntervalMinutes: 30,
128 + };
129 + const body = freshModalBody();
130 + await BB.sync.openSettings();
131 + const readyDiv = body.children.find(c => c.className === 'sync-ready');
132 + assert.ok(readyDiv, 'Should have sync-ready div');
133 + const hasDisconnect = readyDiv.children.some(c =>
134 + c.className === 'button sync-disconnect' && c._text === 'Disconnect'
135 + );
136 + assert.ok(hasDisconnect, 'Should have Disconnect button');
137 + });
138 + });
@@ -1,0 +1,197 @@
1 + /**
2 + * Shared environment for the BB frontend tests.
3 + *
4 + * The frontend is plain browser JS loaded by index.html, so testing it under
5 + * node means standing up the handful of browser APIs it touches and loading
6 + * the modules in the same order the page does. That is what this file is: the
7 + * mocks are the real work, and every suite requires it first.
8 + *
9 + * `node --test` runs each test file in its own process, so requiring this from
10 + * several suites gives each of them a fresh BB namespace rather than a shared
11 + * one that leaks state between files.
12 + */
13 +
14 + const mockElements = {};
15 +
16 + // Serialize a mock element to an HTML-ish string so tests can assert against
17 + // rows built from child elements (BB.ui.renderRow), not just innerHTML
18 + // template strings. Reflects className + attributes set via setAttribute.
19 + function outerHTML(el) {
20 + if (el.nodeType === 3) return el._text;
21 + const tag = (el.tagName || 'div').toLowerCase();
22 + let attrs = '';
23 + if (el.className) attrs += ` class="${el.className}"`;
24 + for (const [k, v] of Object.entries(el._attrs || {})) attrs += ` ${k}="${v}"`;
25 + return `<${tag}${attrs}>${el.innerHTML}</${tag}>`;
26 + }
27 +
28 + function createMockElement(tag) {
29 + const el = {
30 + tagName: (tag || 'div').toUpperCase(),
31 + className: '',
32 + id: '',
33 + style: { cssText: '' },
34 + dataset: {},
35 + _html: '',
36 + _attrs: {},
37 + _text: '',
38 + set textContent(v) {
39 + this._text = v;
40 + this.innerHTML = String(v)
41 + .replace(/&/g, '&amp;')
42 + .replace(/</g, '&lt;')
43 + .replace(/>/g, '&gt;')
44 + .replace(/"/g, '&quot;')
45 + .replace(/'/g, '&#039;');
46 + },
47 + get textContent() { return this._text; },
48 + // innerHTML: explicit assignment wins; otherwise serialize children so
49 + // element-built rows are inspectable.
50 + get innerHTML() {
51 + if (this._html) return this._html;
52 + return this.children.map(outerHTML).join('');
53 + },
54 + set innerHTML(v) { this._html = v; },
55 + children: [],
56 + attributes: [],
57 + _listeners: {},
58 + setAttribute(k, v) { this._attrs[k] = v; this[k] = v; },
59 + getAttribute(k) { return this._attrs[k] != null ? this._attrs[k] : (this[k] || null); },
60 + addEventListener(ev, fn) { this._listeners[ev] = fn; },
61 + querySelector() { return createMockElement('span'); },
62 + querySelectorAll() { return []; },
63 + appendChild(child) {
64 + if (child.tagName === 'FRAGMENT') {
65 + this.children.push(...child.children);
66 + } else {
67 + this.children.push(child);
68 + }
69 + },
70 + remove() {},
71 + parentNode: { insertBefore() {} },
72 + };
73 + el.classList = {
74 + _el: el,
75 + add(cls) { if (!this._el.className.includes(cls)) this._el.className = (this._el.className + ' ' + cls).trim(); },
76 + remove(cls) { this._el.className = this._el.className.replace(cls, '').trim(); },
77 + toggle(cls, force) {
78 + if (force === undefined) force = !this._el.className.includes(cls);
79 + if (force) this.add(cls); else this.remove(cls);
80 + },
81 + contains(cls) { return this._el.className.includes(cls); },
82 + };
83 + return el;
84 + }
85 +
86 + // Reset a mock element's children array between render assertions.
87 + function resetMockElement(id) {
88 + const el = mockElements[id];
89 + if (el) el.children = [];
90 + }
91 +
92 + // Text nodes carry no tag, so outerHTML serializes them as their text. Only
93 + // settings-sync builds one (the auto-sync toggle label).
94 + function createMockTextNode(text) {
95 + return { nodeType: 3, _text: String(text), textContent: String(text), children: [] };
96 + }
97 +
98 + globalThis.document = {
99 + createElement: createMockElement,
100 + createTextNode: createMockTextNode,
101 + createDocumentFragment: () => createMockElement('fragment'),
102 + getElementById: (id) => {
103 + if (!mockElements[id]) {
104 + mockElements[id] = createMockElement('div');
105 + mockElements[id].id = id;
106 + }
107 + return mockElements[id];
108 + },
109 + };
110 +
111 + globalThis.window = {};
112 + globalThis.BB = {};
113 + globalThis.confirm = () => true;
114 +
115 + // Load source modules (same order as index.html)
116 +
117 + require('../bb'); // Initializes BB namespace
118 + require('../state'); // BB.state (Proxy-based pub/sub)
119 + require('../utils'); // BB.utils (escapeHtml, escapeAttr, debounce)
120 +
121 + // Every item command a test asserts on lands here as {cmd, id}. Truncate it at
122 + // the top of a test rather than replacing it, so the mocks below keep pointing
123 + // at the array the suite reads.
124 + const apiCalls = [];
125 +
126 + // The backing store the item mocks read and write. BB.items.toggleStar and
127 + // toggleRead reload from the API after the write, so a static list would hand
128 + // every read-back the old flag and no toggle could be asserted end to end.
129 + const ITEM_FIXTURES = [
130 + { id: 'i1', title: 'First', author: 'Alice', isRead: false, isStarred: false, timeAgo: '2m' },
131 + { id: 'i2', title: 'Second', author: 'Bob', isRead: true, isStarred: true, timeAgo: '5m' },
132 + ];
133 + let itemFixtures = ITEM_FIXTURES.map(i => ({ ...i }));
134 +
135 + function resetItemFixtures() {
136 + itemFixtures = ITEM_FIXTURES.map(i => ({ ...i }));
137 + }
138 +
139 + function setItemFlag(id, key, value) {
140 + const item = itemFixtures.find(i => i.id === id);
141 + if (item) item[key] = value;
142 + }
143 +
144 + // Mock BB.api before loading modules that depend on it
145 + BB.api = {
146 + sources: {
147 + list: async () => [
148 + { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
149 + { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
150 + ],
151 + },
152 + items: {
153 + list: async () => ({
154 + items: itemFixtures.map(i => ({ ...i })),
155 + hasMore: true,
156 + }),
157 + markRead: async (id) => { apiCalls.push({ cmd: 'markRead', id }); setItemFlag(id, 'isRead', true); },
158 + markUnread: async (id) => { apiCalls.push({ cmd: 'markUnread', id }); setItemFlag(id, 'isRead', false); },
159 + star: async (id) => { apiCalls.push({ cmd: 'star', id }); setItemFlag(id, 'isStarred', true); },
160 + unstar: async (id) => { apiCalls.push({ cmd: 'unstar', id }); setItemFlag(id, 'isStarred', false); },
161 + },
162 + feeds: {
163 + listAllTags: async () => ['news', 'tech'],
164 + deleteByBusser: async () => {},
165 + getByBusser: async (id) => [{ busserId: id, name: 'Test', config: {} }],
166 + create: async () => {},
167 + setTags: async () => {},
168 + get: async () => ({ name: 'Test', config: {} }),
169 + update: async () => {},
170 + },
171 + plugins: { schema: async () => ({ fields: [] }) },
172 + };
173 +
174 + // Load the real BB.ui (renderRow, renderEmptyState, etc.); components.js has
175 + // no load-time DOM side effects. Then stub the methods that touch document.body
176 + // / overlays, matching the harness's prior no-op mock so render tests stay pure.
177 + require('../components');
178 + BB.ui.showToast = () => {};
179 + BB.ui.openFormModal = () => {};
180 + BB.detail = { load() {}, collapseReader() {}, updateSavedBadge() {} };
181 + BB.queryFeeds = { load() {}, select() {}, openBuilder() {}, deleteFeed() {} };
182 + // sources.select ends by handing the selection to navigation for the mobile
183 + // tab switch. Nothing here tests mobile layout, so it is a no-op.
184 + BB.navigation = { onSourceSelected() {} };
185 +
186 + // Modules that depend on BB.utils and BB.api
187 + require('../sources'); // BB.sources
188 + require('../items'); // BB.items
189 +
190 + module.exports = {
191 + BB,
192 + mockElements,
193 + createMockElement,
194 + resetMockElement,
195 + apiCalls,
196 + resetItemFixtures,
197 + };
@@ -1,0 +1,120 @@
1 + const { describe, test } = require('node:test');
2 + const assert = require('node:assert');
3 + const { BB, resetMockElement } = require('./setup');
4 +
5 + describe('BB.sources.select', () => {
6 + test('sets currentSource state', async () => {
7 + await BB.sources.select('s1');
8 + assert.strictEqual(BB.state.currentSource, 's1');
9 + });
10 +
11 + test('resets pagination', async () => {
12 + BB.state.set('currentPage', 5);
13 + await BB.sources.select('s2');
14 + assert.strictEqual(BB.state.currentPage, 0);
15 + });
16 +
17 + test('clears selectedItemId', async () => {
18 + BB.state.set('selectedItemId', 'x');
19 + await BB.sources.select('');
20 + assert.strictEqual(BB.state.selectedItemId, null);
21 + });
22 +
23 + test('clears currentQueryFeed', async () => {
24 + BB.state.set('currentQueryFeed', 'qf');
25 + await BB.sources.select('s1');
26 + assert.strictEqual(BB.state.currentQueryFeed, null);
27 + });
28 + });
29 +
30 + describe('BB.sources.selectTag', () => {
31 + test('sets currentTag', async () => {
32 + await BB.sources.selectTag('tech');
33 + assert.strictEqual(BB.state.currentTag, 'tech');
34 + });
35 +
36 + test('resets pagination', async () => {
37 + BB.state.set('currentPage', 3);
38 + await BB.sources.selectTag('news');
39 + assert.strictEqual(BB.state.currentPage, 0);
40 + });
41 + });
42 +
43 + describe('BB.sources.load', () => {
44 + test('populates state', async () => {
45 + await BB.sources.load();
46 + assert.strictEqual(BB.state.sources.length, 2);
47 + assert.deepStrictEqual(BB.state.allTags, ['news', 'tech']);
48 + });
49 + });
50 +
51 + describe('BB.sources.render', () => {
52 + test('renderSourceList creates correct number of source elements', () => {
53 + resetMockElement('sources-list');
54 + const sources = [
55 + { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
56 + { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
57 + { id: 's3', name: 'Feed C', totalCount: 0, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' },
58 + ];
59 + BB.state.set('currentSource', '');
60 + BB.state.set('queryFeeds', []);
61 + BB.sources.render(sources);
62 + const list = document.getElementById('sources-list');
63 + // 1 All + 3 source items + 1 "+ Query Feed" button
64 + assert.strictEqual(list.children.length, 5);
65 + });
66 +
67 + test('source health indicator shows correct class for yellow', () => {
68 + resetMockElement('sources-list');
69 + const sources = [
70 + { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
71 + ];
72 + BB.state.set('currentSource', '');
73 + BB.state.set('queryFeeds', []);
74 + BB.sources.render(sources);
75 + const list = document.getElementById('sources-list');
76 + const sourceItem = list.children[1]; // children[0] is "All", sources start at [1]
77 + assert.ok(sourceItem.innerHTML.includes('data-health="yellow"'), 'Should have data-health="yellow" attribute');
78 + });
79 +
80 + test('source with unreadCount=0 shows total via all-read style (no slash, no checkmark)', () => {
81 + resetMockElement('sources-list');
82 + const sources = [
83 + { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'green' },
84 + ];
85 + BB.state.set('currentSource', '');
86 + BB.state.set('queryFeeds', []);
87 + BB.sources.render(sources);
88 + const list = document.getElementById('sources-list');
89 + const sourceItem = list.children[1]; // children[0] is "All"
90 + assert.ok(sourceItem.innerHTML.includes('all-read'), 'Should mark the count all-read (green)');
91 + assert.ok(sourceItem.innerHTML.includes('5'), 'Should show total count');
92 + assert.ok(!sourceItem.innerHTML.includes('0/5'), 'Should not show 0/X format');
93 + assert.ok(!sourceItem.innerHTML.includes('\u2713'), 'Should not show a checkmark');
94 + });
95 +
96 + test('source with lastError shows error text in health dot title', () => {
97 + resetMockElement('sources-list');
98 + const sources = [
99 + { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' },
100 + ];
101 + BB.state.set('currentSource', '');
102 + BB.state.set('queryFeeds', []);
103 + BB.sources.render(sources);
104 + const list = document.getElementById('sources-list');
105 + const sourceItem = list.children[1]; // children[0] is "All"
106 + assert.ok(sourceItem.innerHTML.includes('dns fail'), 'Should include error text');
107 + });
108 +
109 + test('empty sources array renders All item, onboarding, and + button', () => {
110 + resetMockElement('sources-list');
111 + BB.state.set('currentSource', '');
112 + BB.state.set('queryFeeds', []);
113 + BB.sources.render([]);
114 + const list = document.getElementById('sources-list');
115 + // All item + onboarding message + "+ Query Feed" button
116 + assert.strictEqual(list.children.length, 3);
117 + assert.ok(list.children[0].innerHTML.includes('All'), 'Should have All entry');
118 + assert.ok(list.children[1].innerHTML.includes('Add your first feed'), 'Should have onboarding message');
119 + });
120 + });
@@ -1,0 +1,73 @@
1 + const { describe, test } = require('node:test');
2 + const assert = require('node:assert');
3 + const { BB } = require('./setup');
4 +
5 + describe('BB.state', () => {
6 + test('subscribe registers callback and fires on set', () => {
7 + let called = false;
8 + BB.state.subscribe('_t1', () => { called = true; });
9 + BB.state.set('_t1', 'hello');
10 + assert.ok(called, 'Subscriber should fire');
11 + });
12 +
13 + test('set passes old and new values to subscriber', () => {
14 + BB.state.set('_t2', 'first');
15 + let capturedOld;
16 + BB.state.subscribe('_t2', (n, o) => { capturedOld = o; });
17 + BB.state.set('_t2', 'second');
18 + assert.strictEqual(capturedOld, 'first');
19 + });
20 +
21 + test('set does not trigger unrelated subscribers', () => {
22 + let called = false;
23 + BB.state.subscribe('_t3_a', () => { called = true; });
24 + BB.state.set('_t3_b', 'val');
25 + assert.ok(!called, 'Unrelated subscriber should not fire');
26 + });
27 +
28 + test('unsubscribe removes callback', () => {
29 + let count = 0;
30 + const unsub = BB.state.subscribe('_t4', () => { count++; });
31 + BB.state.set('_t4', 'a');
32 + assert.strictEqual(count, 1);
33 + unsub();
34 + BB.state.set('_t4', 'b');
35 + assert.strictEqual(count, 1);
36 + });
37 +
38 + test('set with same value still triggers', () => {
39 + BB.state.set('_t5', 'same');
40 + let count = 0;
41 + BB.state.subscribe('_t5', () => { count++; });
42 + BB.state.set('_t5', 'same');
43 + assert.strictEqual(count, 1);
44 + });
45 +
46 + test('multiple subscribers on same key all fire', () => {
47 + let a = false, b = false;
48 + BB.state.subscribe('_t6', () => { a = true; });
49 + BB.state.subscribe('_t6', () => { b = true; });
50 + BB.state.set('_t6', 'v');
51 + assert.ok(a && b, 'Both should fire');
52 + });
53 +
54 + test('direct property assignment triggers via Proxy', () => {
55 + let called = false;
56 + BB.state.subscribe('_t7', () => { called = true; });
57 + BB.state._t7 = 'proxy';
58 + assert.ok(called, 'Proxy set should trigger');
59 + assert.strictEqual(BB.state._t7, 'proxy');
60 + });
61 +
62 + test('get returns current value', () => {
63 + BB.state.set('_t8', 42);
64 + assert.strictEqual(BB.state.get('_t8'), 42);
65 + });
66 +
67 + test('initial state has expected default keys', () => {
68 + assert.ok(Array.isArray(BB.state.sources));
69 + assert.strictEqual(BB.state.currentOrder, 'chronological');
70 + assert.strictEqual(BB.state.hasMore, false);
71 + assert.strictEqual(BB.state.selectedItemId, null);
72 + });
73 + });