mirror of https://github.com/Nezreka/SoulSync.git
Merge pull request #522 from Nezreka/feat/discover-section-controller
Feat/discover section controllerpull/526/head
commit
1f2b8f8ccd
@ -0,0 +1,707 @@
|
||||
// Tests for `createDiscoverSectionController` in
|
||||
// `webui/static/discover-section-controller.js`. Run via:
|
||||
//
|
||||
// node --test tests/static/
|
||||
//
|
||||
// Or through the Python wrapper at
|
||||
// tests/test_discover_section_controller_js.py which shells out to
|
||||
// `node --test` and surfaces the result inside the regular pytest run.
|
||||
//
|
||||
// The controller is loaded into a sandboxed `vm` context with stubbed
|
||||
// `window` / `document` / `Element` / `fetch`. No DOM or network — just
|
||||
// the lifecycle contract.
|
||||
|
||||
import { test, describe, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONTROLLER_PATH = resolve(__dirname, '..', '..', 'webui', 'static', 'discover-section-controller.js');
|
||||
|
||||
// Minimal Element stub — controller uses `instanceof Element` to tell
|
||||
// strings (selectors) apart from DOM refs.
|
||||
class Element {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.innerHTML = '';
|
||||
this.style = { display: '' };
|
||||
}
|
||||
}
|
||||
|
||||
// Build a fresh sandbox per test so state doesn't leak between cases.
|
||||
function makeSandbox(opts = {}) {
|
||||
const elements = new Map();
|
||||
const ensureEl = (sel) => {
|
||||
if (!elements.has(sel)) elements.set(sel, new Element(sel));
|
||||
return elements.get(sel);
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
Element,
|
||||
window: {},
|
||||
console: {
|
||||
// Quiet by default — turn on by passing { logCalls: true }
|
||||
debug: opts.logCalls ? console.debug : () => {},
|
||||
error: opts.logCalls ? console.error : () => {},
|
||||
log: opts.logCalls ? console.log : () => {},
|
||||
},
|
||||
document: {
|
||||
querySelector: (sel) => ensureEl(sel),
|
||||
},
|
||||
fetch: opts.fetch || (async () => {
|
||||
throw new Error('fetch not stubbed for this test');
|
||||
}),
|
||||
// Toast spy — when controller calls window.showToast, capture it
|
||||
_toasts: [],
|
||||
};
|
||||
sandbox.window.showToast = (msg, type) => sandbox._toasts.push({ msg, type });
|
||||
sandbox._elements = elements;
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
let CONTROLLER_SOURCE;
|
||||
before(() => {
|
||||
CONTROLLER_SOURCE = readFileSync(CONTROLLER_PATH, 'utf8');
|
||||
});
|
||||
|
||||
function loadController(sandbox) {
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(CONTROLLER_SOURCE, sandbox);
|
||||
return sandbox.window.createDiscoverSectionController;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Config validation
|
||||
// =========================================================================
|
||||
|
||||
describe('config validation', () => {
|
||||
test('throws on missing id', () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
assert.throws(() => create({
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: () => [],
|
||||
renderItems: () => '',
|
||||
}), /config.id required/);
|
||||
});
|
||||
|
||||
test('throws on missing contentEl', () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
assert.throws(() => create({
|
||||
id: 'x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: () => [],
|
||||
renderItems: () => '',
|
||||
}), /contentEl required/);
|
||||
});
|
||||
|
||||
test('throws when both fetchUrl and data provided', () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
assert.throws(() => create({
|
||||
id: 'x',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
data: { ok: true },
|
||||
extractItems: () => [],
|
||||
renderItems: () => '',
|
||||
}), /mutually exclusive/);
|
||||
});
|
||||
|
||||
test('throws when neither fetchUrl nor data provided', () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
assert.throws(() => create({
|
||||
id: 'x',
|
||||
contentEl: '#x',
|
||||
extractItems: () => [],
|
||||
renderItems: () => '',
|
||||
}), /either config.fetchUrl or config.data required/);
|
||||
});
|
||||
|
||||
test('throws when extractItems missing', () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
assert.throws(() => create({
|
||||
id: 'x',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
renderItems: () => '',
|
||||
}), /extractItems required/);
|
||||
});
|
||||
|
||||
test('throws when renderItems missing', () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
assert.throws(() => create({
|
||||
id: 'x',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: () => [],
|
||||
}), /renderItems required/);
|
||||
});
|
||||
|
||||
test('accepts function fetchUrl', () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
assert.doesNotThrow(() => create({
|
||||
id: 'x',
|
||||
contentEl: '#x',
|
||||
fetchUrl: () => '/u',
|
||||
extractItems: () => [],
|
||||
renderItems: () => '',
|
||||
}));
|
||||
});
|
||||
|
||||
test('accepts data instead of fetchUrl', () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
assert.doesNotThrow(() => create({
|
||||
id: 'x',
|
||||
contentEl: '#x',
|
||||
data: { ok: true },
|
||||
extractItems: () => [],
|
||||
renderItems: () => '',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Happy path — fetch → render
|
||||
// =========================================================================
|
||||
|
||||
describe('fetch + render lifecycle', () => {
|
||||
test('fetches, parses, calls renderItems, writes innerHTML', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async (url) => {
|
||||
assert.equal(url, '/api/test');
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ success: true, items: [{ id: 1 }, { id: 2 }] }),
|
||||
};
|
||||
},
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
let renderCalls = 0;
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#carousel',
|
||||
fetchUrl: '/api/test',
|
||||
extractItems: (data) => data.items,
|
||||
renderItems: (items) => {
|
||||
renderCalls++;
|
||||
return `<x>${items.length}</x>`;
|
||||
},
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(renderCalls, 1);
|
||||
assert.equal(sandbox._elements.get('#carousel').innerHTML, '<x>2</x>');
|
||||
assert.equal(ctrl.getState().phase, 'rendered');
|
||||
});
|
||||
|
||||
test('fires onRendered hook after render', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, items: [{ id: 1 }] }),
|
||||
}),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
let hookCalls = 0;
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => 'rendered',
|
||||
onRendered: (ctx) => {
|
||||
hookCalls++;
|
||||
assert.ok(ctx.contentEl);
|
||||
assert.ok(ctx.items);
|
||||
assert.ok(ctx.data);
|
||||
},
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(hookCalls, 1);
|
||||
});
|
||||
|
||||
test('fires onSuccess hook after success gate, before render', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, items: [], stats: { count: 5 } }),
|
||||
}),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const order = [];
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => { order.push('render'); return ''; },
|
||||
onSuccess: (data) => { order.push(`success:${data.stats.count}`); },
|
||||
});
|
||||
await ctrl.load();
|
||||
// Empty items → no render. onSuccess still fires.
|
||||
assert.deepEqual(order, ['success:5']);
|
||||
});
|
||||
|
||||
test('fires beforeLoad hook before spinner shows', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, items: [{ id: 1 }] }),
|
||||
}),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const order = [];
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => { order.push('render'); return 'r'; },
|
||||
beforeLoad: () => { order.push('before'); },
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(order[0], 'before');
|
||||
assert.equal(order[1], 'render');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Empty state
|
||||
// =========================================================================
|
||||
|
||||
describe('empty state', () => {
|
||||
test('renders empty message when items array is empty', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [] }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => '<should-not-appear/>',
|
||||
emptyMessage: 'Nothing here',
|
||||
});
|
||||
await ctrl.load();
|
||||
const html = sandbox._elements.get('#x').innerHTML;
|
||||
assert.match(html, /Nothing here/);
|
||||
assert.doesNotMatch(html, /should-not-appear/);
|
||||
assert.equal(ctrl.getState().phase, 'empty');
|
||||
});
|
||||
|
||||
test('hides whole section when hideWhenEmpty + empty', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [] }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
sectionEl: '#wrapper',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => '',
|
||||
hideWhenEmpty: true,
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(sandbox._elements.get('#wrapper').style.display, 'none');
|
||||
});
|
||||
|
||||
test('treats success=false as empty (default)', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: false, items: [{ id: 1 }] }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => '<should-not-appear/>',
|
||||
emptyMessage: 'X',
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(ctrl.getState().phase, 'empty');
|
||||
});
|
||||
|
||||
test('custom isSuccess overrides default success-flag check', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ status: 'ok', items: [{ id: 1 }] }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
isSuccess: (d) => d.status === 'ok',
|
||||
renderItems: (items) => `r:${items.length}`,
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(sandbox._elements.get('#x').innerHTML, 'r:1');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Stale state
|
||||
// =========================================================================
|
||||
|
||||
describe('stale state', () => {
|
||||
test('renders stale UI + fires onStale when isStale returns true', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [], stale: true }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
let staleHookCalled = false;
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
isStale: (items, data) => data.stale && items.length === 0,
|
||||
renderItems: () => '<should-not-appear/>',
|
||||
staleMessage: 'Updating from upstream',
|
||||
onStale: () => { staleHookCalled = true; },
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(ctrl.getState().phase, 'stale');
|
||||
assert.equal(staleHookCalled, true);
|
||||
assert.match(sandbox._elements.get('#x').innerHTML, /Updating from upstream/);
|
||||
});
|
||||
|
||||
test('stale wins over empty when both apply', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [], stale: true }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
isStale: () => true,
|
||||
renderItems: () => '',
|
||||
emptyMessage: 'EMPTY',
|
||||
staleMessage: 'STALE',
|
||||
});
|
||||
await ctrl.load();
|
||||
const html = sandbox._elements.get('#x').innerHTML;
|
||||
assert.match(html, /STALE/);
|
||||
assert.doesNotMatch(html, /EMPTY/);
|
||||
});
|
||||
|
||||
test('custom renderStale overrides default stale UI', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [], stale: true }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
isStale: () => true,
|
||||
renderItems: () => '',
|
||||
renderStale: () => '<custom-stale/>',
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(sandbox._elements.get('#x').innerHTML, '<custom-stale/>');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Error state
|
||||
// =========================================================================
|
||||
|
||||
describe('error state', () => {
|
||||
test('renders error block on HTTP non-ok', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: false, status: 500, json: async () => ({}) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => '',
|
||||
errorMessage: 'load failed',
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(ctrl.getState().phase, 'error');
|
||||
assert.match(sandbox._elements.get('#x').innerHTML, /load failed/);
|
||||
});
|
||||
|
||||
test('renders error block when fetch throws', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => { throw new Error('network down'); },
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => '',
|
||||
errorMessage: 'oops',
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(ctrl.getState().phase, 'error');
|
||||
assert.match(sandbox._elements.get('#x').innerHTML, /oops/);
|
||||
});
|
||||
|
||||
test('fires showToast on error when showErrorToast: true', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => { throw new Error('boom'); },
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => '',
|
||||
errorMessage: 'load broke',
|
||||
showErrorToast: true,
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(sandbox._toasts.length, 1);
|
||||
assert.equal(sandbox._toasts[0].msg, 'load broke');
|
||||
assert.equal(sandbox._toasts[0].type, 'error');
|
||||
});
|
||||
|
||||
test('does NOT fire toast when showErrorToast omitted', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => { throw new Error('boom'); },
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => '',
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(sandbox._toasts.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// No-fetch data: mode
|
||||
// =========================================================================
|
||||
|
||||
describe('no-fetch data mode', () => {
|
||||
test('renders provided data without calling fetch', async () => {
|
||||
let fetchCalled = false;
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => { fetchCalled = true; throw new Error('should not fetch'); },
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
data: { success: true, items: [{ id: 1 }, { id: 2 }] },
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: (items) => `n:${items.length}`,
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(fetchCalled, false);
|
||||
assert.equal(sandbox._elements.get('#x').innerHTML, 'n:2');
|
||||
});
|
||||
|
||||
test('accepts data as a function', async () => {
|
||||
const sandbox = makeSandbox();
|
||||
const create = loadController(sandbox);
|
||||
let dataCalls = 0;
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
data: () => { dataCalls++; return { success: true, items: [{ id: 9 }] }; },
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: (items) => `f:${items[0].id}`,
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(dataCalls, 1);
|
||||
assert.equal(sandbox._elements.get('#x').innerHTML, 'f:9');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// manualDom mode
|
||||
// =========================================================================
|
||||
|
||||
describe('manualDom mode', () => {
|
||||
test('does NOT write renderItems return into contentEl', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
manualDom: true,
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => '<should-not-appear/>',
|
||||
});
|
||||
await ctrl.load();
|
||||
const html = sandbox._elements.get('#x').innerHTML;
|
||||
// Spinner from _showLoading was the last write; manualDom mode
|
||||
// didn't replace it. The renderer gets called for side-effects
|
||||
// (which the test doesn't exercise here) but innerHTML stays
|
||||
// whatever the loading spinner left.
|
||||
assert.doesNotMatch(html, /should-not-appear/);
|
||||
assert.equal(ctrl.getState().phase, 'rendered');
|
||||
});
|
||||
|
||||
test('still fires renderItems for side-effects', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
let renderCalled = false;
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
manualDom: true,
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => { renderCalled = true; },
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(renderCalled, true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Fetch URL forms
|
||||
// =========================================================================
|
||||
|
||||
describe('fetchUrl forms', () => {
|
||||
test('callable fetchUrl is invoked at load time', async () => {
|
||||
let urlCalls = 0;
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async (url) => {
|
||||
assert.equal(url, '/u/computed');
|
||||
return { ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) };
|
||||
},
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: () => { urlCalls++; return '/u/computed'; },
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => 'r',
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(urlCalls, 1);
|
||||
// Calling refresh re-resolves the URL — important for sections
|
||||
// whose URL depends on runtime state (e.g. season key).
|
||||
await ctrl.refresh();
|
||||
assert.equal(urlCalls, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Coalescing + refresh
|
||||
// =========================================================================
|
||||
|
||||
describe('load coalescing and refresh', () => {
|
||||
test('two concurrent load() calls share one fetch', async () => {
|
||||
let fetchCalls = 0;
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => {
|
||||
fetchCalls++;
|
||||
// Yield once so both load() calls land on the same in-flight promise.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
return { ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) };
|
||||
},
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => 'r',
|
||||
});
|
||||
await Promise.all([ctrl.load(), ctrl.load(), ctrl.load()]);
|
||||
assert.equal(fetchCalls, 1);
|
||||
});
|
||||
|
||||
test('refresh() bypasses the coalesce and re-fetches', async () => {
|
||||
let fetchCalls = 0;
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => {
|
||||
fetchCalls++;
|
||||
return { ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) };
|
||||
},
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => 'r',
|
||||
});
|
||||
await ctrl.load();
|
||||
await ctrl.refresh();
|
||||
await ctrl.refresh();
|
||||
assert.equal(fetchCalls, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Hook error containment
|
||||
// =========================================================================
|
||||
|
||||
describe('hook error containment', () => {
|
||||
test('throwing renderer hook does not crash the controller', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => 'r',
|
||||
onRendered: () => { throw new Error('hook boom'); },
|
||||
});
|
||||
// Test passes if this doesn't throw out of the await.
|
||||
await ctrl.load();
|
||||
assert.equal(ctrl.getState().phase, 'rendered');
|
||||
});
|
||||
|
||||
test('throwing onSuccess hook does not block the render', async () => {
|
||||
const sandbox = makeSandbox({
|
||||
fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) }),
|
||||
});
|
||||
const create = loadController(sandbox);
|
||||
const ctrl = create({
|
||||
id: 'test',
|
||||
contentEl: '#x',
|
||||
fetchUrl: '/u',
|
||||
extractItems: (d) => d.items,
|
||||
renderItems: () => 'rendered-anyway',
|
||||
onSuccess: () => { throw new Error('boom'); },
|
||||
});
|
||||
await ctrl.load();
|
||||
assert.equal(sandbox._elements.get('#x').innerHTML, 'rendered-anyway');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,76 @@
|
||||
"""Run the JS tests for `webui/static/discover-section-controller.js`
|
||||
under the regular pytest sweep.
|
||||
|
||||
The actual contract tests live in
|
||||
`tests/static/test_discover_section_controller.mjs` and run via
|
||||
Node.js's stable built-in test runner (`node --test`). This shim
|
||||
shells out to that runner and asserts a clean exit so the JS tests
|
||||
fail the suite if the controller contract drifts.
|
||||
|
||||
Skipped when:
|
||||
- `node` isn't on PATH (e.g. Python-only dev container).
|
||||
- Node version < 22 (the built-in `--test` runner went stable in 18
|
||||
but the assert-flavor we use is 22+).
|
||||
|
||||
Run directly:
|
||||
node --test tests/static/test_discover_section_controller.mjs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
_TEST_FILE = _REPO_ROOT / "tests" / "static" / "test_discover_section_controller.mjs"
|
||||
|
||||
|
||||
def _node_available() -> bool:
|
||||
if not shutil.which("node"):
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["node", "--version"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
# Output looks like "v22.21.0"
|
||||
raw = (result.stdout or "").strip().lstrip("v")
|
||||
try:
|
||||
major = int(raw.split(".")[0])
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
return major >= 22
|
||||
|
||||
|
||||
def test_discover_section_controller_js():
|
||||
"""Pin the JS controller's lifecycle contract via `node --test`."""
|
||||
if not _node_available():
|
||||
pytest.skip("Node.js >= 22 required to run the JS controller tests")
|
||||
|
||||
if not _TEST_FILE.exists():
|
||||
pytest.skip(f"JS test file missing: {_TEST_FILE}")
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "--test", str(_TEST_FILE)],
|
||||
capture_output=True, text=True,
|
||||
cwd=str(_REPO_ROOT),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Surface the node test runner output so failures are
|
||||
# debuggable from the pytest log without re-running by hand.
|
||||
pytest.fail(
|
||||
"JS controller tests failed:\n\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}",
|
||||
pytrace=False,
|
||||
)
|
||||
@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Discover Section Controller
|
||||
* ---------------------------
|
||||
*
|
||||
* Owns the lifecycle every discover-page section already does by hand:
|
||||
*
|
||||
* 1. show a loading spinner in the carousel container
|
||||
* 2. fetch the section's endpoint (or use pre-fetched data)
|
||||
* 3. parse the response, decide whether the data is empty
|
||||
* 4. either show the empty state, render the items, show a stale
|
||||
* "still updating" state, or show an error
|
||||
* 5. wire any post-render handlers (download buttons, hover, etc)
|
||||
* 6. expose a refresh() method so the same lifecycle can re-fire
|
||||
*
|
||||
* Each section currently re-implements this by hand in `discover.js`
|
||||
* with subtle drift — different empty-state messages, inconsistent
|
||||
* error handling, inconsistent refresh-button feedback, no consistent
|
||||
* error toast. This controller is the "lift what's truly shared"
|
||||
* extraction: register a section once, the controller handles the
|
||||
* lifecycle, the section provides only its renderer.
|
||||
*
|
||||
* Renderers stay per-section because section data shapes legitimately
|
||||
* differ (album cards vs artist circles vs playlist tiles vs track
|
||||
* rows). The controller is the lifecycle wrapper around those
|
||||
* renderers, not a forced visual abstraction.
|
||||
*
|
||||
* USAGE:
|
||||
*
|
||||
* const ctrl = createDiscoverSectionController({
|
||||
* id: 'recent-releases',
|
||||
* contentEl: '#recent-releases-carousel',
|
||||
* fetchUrl: '/api/discover/recent-releases',
|
||||
* extractItems: (data) => data.albums || [],
|
||||
* renderItems: (items, data, ctx) => buildCardsHtml(items),
|
||||
* onRendered: (ctx) => attachClickHandlers(ctx.contentEl),
|
||||
* loadingMessage: 'Loading recent releases...',
|
||||
* emptyMessage: 'No recent releases found',
|
||||
* errorMessage: 'Failed to load recent releases',
|
||||
* });
|
||||
* ctrl.load();
|
||||
*
|
||||
* EXTENSIONS:
|
||||
*
|
||||
* `fetchUrl` accepts a function returning a string for sections
|
||||
* whose endpoint depends on runtime state (e.g. seasonal playlist
|
||||
* keyed by `currentSeasonKey`).
|
||||
*
|
||||
* `data` lets a section bypass fetch entirely — the controller still
|
||||
* runs success / empty / render / onRendered, just without going to
|
||||
* the network. Use when a parent already fetched and just wants the
|
||||
* shared lifecycle. `data` may be a value or a `() => value`
|
||||
* function. Sections must supply EITHER `fetchUrl` OR `data`, not
|
||||
* both.
|
||||
*
|
||||
* `beforeLoad(ctx)` runs before the spinner shows. Useful for
|
||||
* ensuring `contentEl` exists (e.g. dynamically inserted sections)
|
||||
* or updating sibling headers / subtitles before any visual change.
|
||||
*
|
||||
* `onSuccess(data, ctx)` runs after the success check passes but
|
||||
* before isEmpty / isStale checks. Cleaner home for header text
|
||||
* updates that depend on response data (vs folding them into
|
||||
* renderItems).
|
||||
*
|
||||
* `isStale(items, data)` + `onStale(ctx)` give sections a third
|
||||
* render state for "data is empty but the upstream is still
|
||||
* discovering". Returning true from `isStale` renders the stale
|
||||
* state (default: spinner + "Updating..." copy, override via
|
||||
* `renderStale` or `staleMessage`) and fires `onStale` so the
|
||||
* section can start a poller. Stale wins over empty when both apply.
|
||||
*
|
||||
* `showErrorToast: true` opens a global `showToast(...)` on error
|
||||
* in addition to the in-section error block. Default off — sections
|
||||
* that have no recovery action shouldn't shout at the user.
|
||||
*
|
||||
* `manualDom: true` tells the controller to NOT write the
|
||||
* `renderItems` return value into `contentEl`. The renderer takes
|
||||
* full responsibility for the DOM (e.g. delegating to an existing
|
||||
* grid renderer that targets a child element). The renderer is
|
||||
* still called, just for its side-effects. Default false.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function _validateConfig(cfg) {
|
||||
if (!cfg || typeof cfg !== 'object') {
|
||||
throw new Error('createDiscoverSectionController: config required');
|
||||
}
|
||||
if (typeof cfg.id !== 'string' || !cfg.id) {
|
||||
throw new Error('createDiscoverSectionController: config.id required (string)');
|
||||
}
|
||||
if (typeof cfg.contentEl !== 'string' && !(cfg.contentEl instanceof Element)) {
|
||||
throw new Error(`[discover:${cfg.id}] config.contentEl required (selector or Element)`);
|
||||
}
|
||||
const hasFetch = (typeof cfg.fetchUrl === 'string' && cfg.fetchUrl) || typeof cfg.fetchUrl === 'function';
|
||||
const hasData = cfg.data !== undefined;
|
||||
if (!hasFetch && !hasData) {
|
||||
throw new Error(`[discover:${cfg.id}] either config.fetchUrl or config.data required`);
|
||||
}
|
||||
if (hasFetch && hasData) {
|
||||
throw new Error(`[discover:${cfg.id}] config.fetchUrl and config.data are mutually exclusive`);
|
||||
}
|
||||
if (typeof cfg.renderItems !== 'function') {
|
||||
throw new Error(`[discover:${cfg.id}] config.renderItems required (function)`);
|
||||
}
|
||||
// Cin standard — explicit > implicit. Each section knows its own
|
||||
// response shape; the controller refusing to guess prevents
|
||||
// silent wrong-key bugs (e.g. an endpoint that returns
|
||||
// `data.results` getting auto-pulled instead of the intended
|
||||
// `data.tracks`).
|
||||
if (typeof cfg.extractItems !== 'function') {
|
||||
throw new Error(`[discover:${cfg.id}] config.extractItems required (function returning array)`);
|
||||
}
|
||||
}
|
||||
|
||||
function _resolveEl(el) {
|
||||
if (el instanceof Element) return el;
|
||||
if (typeof el === 'string') return document.querySelector(el);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} cfg - Section config (see file header for shape)
|
||||
* @returns {Object} Public API: { load, refresh, destroy, getState }
|
||||
*/
|
||||
function createDiscoverSectionController(cfg) {
|
||||
_validateConfig(cfg);
|
||||
|
||||
const config = Object.assign({
|
||||
sectionEl: null,
|
||||
hideWhenEmpty: false,
|
||||
renderEmptyState: true,
|
||||
fetchMethod: 'GET',
|
||||
fetchOptions: null,
|
||||
// Either fetchUrl (string or () => string) or data
|
||||
// (value or () => value). Validated mutually exclusive above.
|
||||
extractItems: null,
|
||||
isSuccess: null,
|
||||
isEmpty: null,
|
||||
// Stale = data is empty but upstream is still discovering.
|
||||
// Returning true here renders the stale state instead of
|
||||
// empty, and fires onStale so the section can poll.
|
||||
isStale: null,
|
||||
renderStale: null,
|
||||
staleMessage: 'Updating...',
|
||||
// Hooks
|
||||
beforeLoad: null, // (ctx) => void — before spinner shows
|
||||
onSuccess: null, // (data, ctx) => void — after success gate
|
||||
onStale: null, // (ctx) => void — when stale state renders
|
||||
onRendered: null, // (ctx) => void — after content renders
|
||||
// UX copy
|
||||
loadingMessage: 'Loading...',
|
||||
emptyMessage: 'Nothing to show',
|
||||
errorMessage: 'Failed to load',
|
||||
loadingClass: 'discover-loading',
|
||||
emptyClass: 'discover-empty',
|
||||
errorClass: 'discover-empty',
|
||||
staleClass: 'discover-loading',
|
||||
// Errors
|
||||
verboseErrors: false,
|
||||
showErrorToast: false, // also fire window.showToast on error
|
||||
// Renderer takes responsibility for the DOM — controller
|
||||
// calls renderItems but does NOT write its return value
|
||||
// into contentEl. Use when delegating to an existing
|
||||
// renderer that targets a child element.
|
||||
manualDom: false,
|
||||
}, cfg);
|
||||
|
||||
const state = {
|
||||
phase: 'idle', // idle | loading | rendered | empty | stale | error
|
||||
lastData: null,
|
||||
lastError: null,
|
||||
inFlight: null,
|
||||
};
|
||||
|
||||
function _setHtml(el, html) {
|
||||
if (el) el.innerHTML = html;
|
||||
}
|
||||
|
||||
function _ctx(extra) {
|
||||
return Object.assign(
|
||||
{ contentEl: _resolveEl(config.contentEl), config },
|
||||
extra || {},
|
||||
);
|
||||
}
|
||||
|
||||
function _showLoading() {
|
||||
const contentEl = _resolveEl(config.contentEl);
|
||||
if (!contentEl) return;
|
||||
const msg = config.loadingMessage
|
||||
? `<p>${config.loadingMessage}</p>`
|
||||
: '';
|
||||
_setHtml(contentEl, `
|
||||
<div class="${config.loadingClass}">
|
||||
<div class="loading-spinner"></div>
|
||||
${msg}
|
||||
</div>
|
||||
`);
|
||||
state.phase = 'loading';
|
||||
}
|
||||
|
||||
function _showEmpty() {
|
||||
const contentEl = _resolveEl(config.contentEl);
|
||||
if (!contentEl) return;
|
||||
if (config.hideWhenEmpty) {
|
||||
const sectionEl = _resolveEl(config.sectionEl);
|
||||
if (sectionEl) sectionEl.style.display = 'none';
|
||||
state.phase = 'empty';
|
||||
return;
|
||||
}
|
||||
if (config.renderEmptyState) {
|
||||
_setHtml(contentEl, `
|
||||
<div class="${config.emptyClass}">
|
||||
<p>${config.emptyMessage}</p>
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
_setHtml(contentEl, '');
|
||||
}
|
||||
state.phase = 'empty';
|
||||
}
|
||||
|
||||
function _showStale(items, data) {
|
||||
const contentEl = _resolveEl(config.contentEl);
|
||||
if (!contentEl) return;
|
||||
_showSection();
|
||||
// Custom renderStale wins. Otherwise default spinner + copy.
|
||||
let html;
|
||||
if (typeof config.renderStale === 'function') {
|
||||
try {
|
||||
html = config.renderStale(items, data, _ctx({ items, data }));
|
||||
} catch (err) {
|
||||
console.debug(`[discover:${config.id}] renderStale threw:`, err);
|
||||
html = null;
|
||||
}
|
||||
}
|
||||
if (html === null || html === undefined) {
|
||||
html = `
|
||||
<div class="${config.staleClass}">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>${config.staleMessage}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
_setHtml(contentEl, html);
|
||||
state.phase = 'stale';
|
||||
|
||||
if (typeof config.onStale === 'function') {
|
||||
try {
|
||||
config.onStale(_ctx({ items, data }));
|
||||
} catch (err) {
|
||||
console.debug(`[discover:${config.id}] onStale hook threw:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _showError(error) {
|
||||
const contentEl = _resolveEl(config.contentEl);
|
||||
if (!contentEl) return;
|
||||
_setHtml(contentEl, `
|
||||
<div class="${config.errorClass}">
|
||||
<p>${config.errorMessage}</p>
|
||||
</div>
|
||||
`);
|
||||
state.phase = 'error';
|
||||
state.lastError = error;
|
||||
const log = config.verboseErrors ? console.error : console.debug;
|
||||
log(`[discover:${config.id}]`, error);
|
||||
if (config.showErrorToast && typeof window.showToast === 'function') {
|
||||
try {
|
||||
window.showToast(config.errorMessage, 'error');
|
||||
} catch (toastErr) {
|
||||
console.debug(`[discover:${config.id}] toast failed:`, toastErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _showSection() {
|
||||
const sectionEl = _resolveEl(config.sectionEl);
|
||||
if (sectionEl) sectionEl.style.display = '';
|
||||
}
|
||||
|
||||
function _extractItems(data) {
|
||||
// Validation guarantees `extractItems` is a function. Wrap
|
||||
// the call so a renderer-side typo (returning undefined etc)
|
||||
// doesn't crash the loop — fall back to empty list.
|
||||
const items = config.extractItems(data);
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
function _isSuccess(data) {
|
||||
if (config.isSuccess) return config.isSuccess(data);
|
||||
if (data && Object.prototype.hasOwnProperty.call(data, 'success')) {
|
||||
return Boolean(data.success);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _isEmpty(items, data) {
|
||||
if (config.isEmpty) return config.isEmpty(items, data);
|
||||
return !Array.isArray(items) || items.length === 0;
|
||||
}
|
||||
|
||||
function _isStale(items, data) {
|
||||
if (typeof config.isStale !== 'function') return false;
|
||||
try {
|
||||
return Boolean(config.isStale(items, data));
|
||||
} catch (err) {
|
||||
console.debug(`[discover:${config.id}] isStale threw:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _resolveFetchUrl() {
|
||||
if (typeof config.fetchUrl === 'function') return config.fetchUrl();
|
||||
return config.fetchUrl;
|
||||
}
|
||||
|
||||
function _resolveStaticData() {
|
||||
if (typeof config.data === 'function') return config.data();
|
||||
return config.data;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
// Coalesce concurrent loads — refresh() bypasses the coalesce.
|
||||
if (state.inFlight) return state.inFlight;
|
||||
|
||||
// Run beforeLoad first so it can set up `contentEl` (dynamic
|
||||
// section creation) before the visibility check below.
|
||||
if (typeof config.beforeLoad === 'function') {
|
||||
try {
|
||||
config.beforeLoad(_ctx());
|
||||
} catch (err) {
|
||||
console.debug(`[discover:${config.id}] beforeLoad hook threw:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const contentEl = _resolveEl(config.contentEl);
|
||||
if (!contentEl) {
|
||||
console.debug(`[discover:${config.id}] contentEl not found, skipping load`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
_showLoading();
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
let data;
|
||||
if (config.data !== undefined) {
|
||||
// No-fetch mode — parent already has the data.
|
||||
data = _resolveStaticData();
|
||||
} else {
|
||||
const fetchOpts = (typeof config.fetchOptions === 'function')
|
||||
? (config.fetchOptions() || {})
|
||||
: {};
|
||||
const init = Object.assign(
|
||||
{ method: config.fetchMethod },
|
||||
fetchOpts,
|
||||
);
|
||||
const url = _resolveFetchUrl();
|
||||
const resp = await fetch(url, init);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}`);
|
||||
}
|
||||
data = await resp.json();
|
||||
}
|
||||
state.lastData = data;
|
||||
|
||||
if (!_isSuccess(data)) {
|
||||
_showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof config.onSuccess === 'function') {
|
||||
try {
|
||||
config.onSuccess(data, _ctx({ data }));
|
||||
} catch (err) {
|
||||
console.debug(`[discover:${config.id}] onSuccess hook threw:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const items = _extractItems(data);
|
||||
|
||||
// Stale wins over empty — section is empty *now* but
|
||||
// upstream is still discovering, so show updating UI
|
||||
// rather than the bare "nothing here" copy.
|
||||
if (_isStale(items, data)) {
|
||||
_showStale(items, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isEmpty(items, data)) {
|
||||
_showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
_showSection();
|
||||
const html = config.renderItems(items, data, _ctx({ items, data }));
|
||||
if (!config.manualDom) {
|
||||
// Default: controller owns the DOM. Renderer
|
||||
// returns the HTML, controller swaps it in.
|
||||
// Falsy returns become an empty container.
|
||||
_setHtml(contentEl, html || '');
|
||||
}
|
||||
// manualDom mode: renderer already wrote whatever
|
||||
// it needs into the page; controller leaves
|
||||
// contentEl alone.
|
||||
state.phase = 'rendered';
|
||||
|
||||
if (typeof config.onRendered === 'function') {
|
||||
try {
|
||||
config.onRendered(_ctx({ items, data }));
|
||||
} catch (hookErr) {
|
||||
console.debug(`[discover:${config.id}] onRendered hook threw:`, hookErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
} finally {
|
||||
state.inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
state.inFlight = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
state.inFlight = null;
|
||||
return load();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
state.inFlight = null;
|
||||
state.lastData = null;
|
||||
state.lastError = null;
|
||||
state.phase = 'idle';
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
phase: state.phase,
|
||||
hasData: state.lastData !== null,
|
||||
error: state.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
return { load, refresh, destroy, getState };
|
||||
}
|
||||
|
||||
window.createDiscoverSectionController = createDiscoverSectionController;
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue