import assert from 'node:assert/strict'; import { beforeEach, test } from 'node:test'; import { click, field, find, input, install, nodes, server, settle } from './flow-helpers.mjs'; const { app, toasts } = install(); const SORT_KEY = 'bindery.story-sort'; beforeEach(() => { const values = new Map(); window.localStorage = { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), }; toasts.replaceChildren(); }); const { storiesPage } = await import('../../bindery/static/js/stories.js'); const { refreshJobs, startPolling, stopPolling } = await import('../../bindery/static/js/jobs.js'); const { fmtDate } = await import('../../bindery/static/js/lib.js'); // The server gives each story its sort key: the title in lower case, without a leading article. const story = (id, title, changes = {}) => ({ id, title, url: `https://example.com/${id}`, sort_title: (title || `https://example.com/${id}`).trim().replace(/^(?:the|an?)\s+/i, '').toLowerCase(), author: 'Reader', site: 'example.com', chapter_count: 42, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes', last_changed: null, last_checked: null, ...changes, }); const rows = () => { const table = nodes(app).find((el) => el.className === 'table-wrap stories-table'); return nodes(table).find((el) => el.tagName === 'tbody').children; }; const titles = () => rows().map((row) => row.children[0].textContent); const cell = (row, label) => row.children.find((el) => el.attributes['data-label'] === label); const heading = (label) => nodes(app).find((el) => el.tagName === 'th' && el.textContent === label); async function mount(t, list) { const backend = server(t); backend.on('GET', 'stories', list); backend.on('GET', 'jobs', []); const cleanup = storiesPage(app, {}, { library_available: false, main_jobs_hours: 24, main_jobs_count: 10 }); t.after(() => { cleanup(); stopPolling(); }); await settle(); return backend; } test('column headings toggle sorting, with newest dates first and missing dates always last', async (t) => { const backend = await mount(t, [ story(3, 'Gamma', { last_changed: '2026-01-01T12:00:00Z', last_checked: '2026-01-03T12:00:00Z' }), story(2, 'Beta', { last_changed: '2026-01-02T12:00:00Z', last_checked: '2026-01-02T12:00:00Z' }), story(4, 'No date'), story(1, 'Alpha', { last_changed: '2026-01-03T12:00:00Z', last_checked: '2026-01-01T12:00:00Z' }), ]); assert.equal(field(app, 'Sort stories by'), undefined); assert.equal(heading('Title').attributes['aria-sort'], 'ascending'); assert.equal(heading('Last change').attributes['aria-sort'], 'none'); assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); assert.equal(find(app, 'button', 'Last change').attributes['aria-label'], 'Sort by last change, newest first'); await click(app, 'Last change'); assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); assert.equal(heading('Title').attributes['aria-sort'], 'none'); assert.equal(heading('Last change').attributes['aria-sort'], 'descending'); assert.equal(document.activeElement, find(app, 'button', 'Last change')); assert.equal(find(app, 'button', 'Last change').attributes['aria-label'], 'Sort by last change, oldest first'); await click(app, 'Last change'); assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'No date']); assert.equal(heading('Last change').attributes['aria-sort'], 'ascending'); await click(app, 'Last check'); assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'No date']); assert.equal(heading('Last check').attributes['aria-sort'], 'descending'); await click(app, 'Last check'); assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); assert.equal(heading('Last check').attributes['aria-sort'], 'ascending'); await click(app, 'Title'); assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); await click(app, 'Title'); assert.deepEqual(titles(), ['No date', 'Gamma', 'Beta', 'Alpha']); assert.equal(heading('Title').attributes['aria-sort'], 'descending'); await click(app, 'Last change'); assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']); assert.equal(backend.sent('GET', 'stories').length, 1); }); test('title sorting and tie breaks keep ignoring leading articles', async (t) => { await mount(t, ['The Zebra', 'an Owl', 'Anthem', 'A Bee', 'Theory', 'Cat'].map((title, i) => story(i + 1, title))); const order = ['Anthem', 'A Bee', 'Cat', 'an Owl', 'Theory', 'The Zebra']; assert.deepEqual(titles(), order); await click(app, 'Title'); assert.deepEqual(titles(), [...order].reverse()); await click(app, 'Chapters'); assert.deepEqual(titles(), order); }); test('chapter sorting compares counts numerically and keeps equal counts in title order', async (t) => { await mount(t, [ story(1, 'Zeta', { chapter_count: 2, offsite_count: 2, revised_count: 1 }), story(2, 'Alpha', { chapter_count: 10 }), story(3, 'Beta', { chapter_count: 10 }), story(4, 'Gamma', { chapter_count: 0 }), story(5, 'Omega', { chapter_count: 100 }), ]); assert.equal(find(app, 'button', 'Chapters').attributes['aria-label'], 'Sort by chapters, most first'); await click(app, 'Chapters'); assert.deepEqual(titles(), ['Omega', 'Alpha', 'Beta', 'Zeta', 'Gamma']); assert.equal(heading('Chapters').attributes['aria-sort'], 'descending'); await click(app, 'Chapters'); assert.deepEqual(titles(), ['Gamma', 'Zeta', 'Alpha', 'Beta', 'Omega']); assert.equal(heading('Chapters').attributes['aria-sort'], 'ascending'); }); for (const [label, key] of [['Author', 'author'], ['Site', 'site'], ['Status', 'status']]) { test(`${label} sorts alphabetically in either direction and uses titles to break ties`, async (t) => { await mount(t, [ story(1, 'Zeta', { [key]: 'ok' }), story(3, 'Beta', { [key]: 'error' }), story(2, 'Alpha', { [key]: 'ERROR' }), story(4, 'Gamma', { [key]: null }), story(5, 'Omega', { [key]: 'queued' }), ]); await click(app, label); assert.deepEqual(titles(), ['Gamma', 'Alpha', 'Beta', 'Zeta', 'Omega']); assert.equal(heading(label).attributes['aria-sort'], 'ascending'); await click(app, label); assert.deepEqual(titles(), ['Omega', 'Zeta', 'Alpha', 'Beta', 'Gamma']); assert.equal(heading(label).attributes['aria-sort'], 'descending'); }); } test('sorting saves the column and direction and restores both when the list reopens', async (t) => { const list = [ story(1, 'Alpha', { author: 'Zed', site: 'z.test', status: 'ok', chapter_count: 2, last_changed: '2026-01-01T00:00:00Z', last_checked: '2026-01-03T00:00:00Z' }), story(2, 'Beta', { author: 'Anne', site: 'a.test', status: 'error', chapter_count: 10, last_changed: '2026-01-02T00:00:00Z', last_checked: '2026-01-02T00:00:00Z' }), ]; await mount(t, list); for (const [label, column, direction, order] of [ ['Last change', 'last_changed', -1, ['Beta', 'Alpha']], ['Last change', 'last_changed', 1, ['Alpha', 'Beta']], ['Last check', 'last_checked', -1, ['Alpha', 'Beta']], ['Last check', 'last_checked', 1, ['Beta', 'Alpha']], ['Title', 'title', 1, ['Alpha', 'Beta']], ['Title', 'title', -1, ['Beta', 'Alpha']], ['Author', 'author', 1, ['Beta', 'Alpha']], ['Author', 'author', -1, ['Alpha', 'Beta']], ['Site', 'site', 1, ['Beta', 'Alpha']], ['Site', 'site', -1, ['Alpha', 'Beta']], ['Status', 'status', 1, ['Beta', 'Alpha']], ['Status', 'status', -1, ['Alpha', 'Beta']], ['Chapters', 'chapter_count', -1, ['Beta', 'Alpha']], ['Chapters', 'chapter_count', 1, ['Alpha', 'Beta']], ]) { await click(app, label); assert.deepEqual(JSON.parse(window.localStorage.getItem(SORT_KEY)), { column, direction }); await mount(t, list); assert.deepEqual(titles(), order); assert.equal(heading(label).attributes['aria-sort'], direction === 1 ? 'ascending' : 'descending'); } }); for (const saved of ['not JSON', 'null', '{}', '{"column":"message","direction":-1}', '{"column":"last_changed","direction":0}', '{"column":"last_checked","direction":"-1"}']) { test(`invalid saved sorting falls back to title order: ${saved}`, async (t) => { window.localStorage.setItem(SORT_KEY, saved); await mount(t, [story(2, 'Beta'), story(1, 'Alpha')]); assert.deepEqual(titles(), ['Alpha', 'Beta']); assert.equal(heading('Title').attributes['aria-sort'], 'ascending'); await click(app, 'Title'); assert.deepEqual(JSON.parse(window.localStorage.getItem(SORT_KEY)), { column: 'title', direction: -1 }); }); } test('sorting works when local storage cannot be read or written', async (t) => { window.localStorage.getItem = () => { throw new Error('Storage blocked'); }; window.localStorage.setItem = () => { throw new Error('Storage full'); }; await mount(t, [story(2, 'Beta'), story(1, 'Alpha')]); assert.deepEqual(titles(), ['Alpha', 'Beta']); await click(app, 'Title'); assert.deepEqual(titles(), ['Beta', 'Alpha']); assert.equal(heading('Title').attributes['aria-sort'], 'descending'); assert.equal(toasts.textContent, ''); }); test('date sorting compares instants, handles invalid dates and breaks ties by title and id', async (t) => { await mount(t, [ story(7, 'Missing', { last_changed: 'not a date' }), story(6, 'Unknown'), story(5, 'Epoch', { last_changed: '1970-01-01T00:00:00Z' }), story(4, 'Before epoch', { last_changed: '1969-01-01T00:00:00Z' }), story(3, 'Zulu', { last_changed: '2026-01-02T00:00:00+02:00' }), story(2, 'alpha', { last_changed: '2026-01-01T23:00:00Z' }), story(1, 'Alpha', { last_changed: '2026-01-01T23:00:00Z' }), story(8, '', { last_changed: '2026-01-01T23:00:00Z' }), ]); await click(app, 'Last change'); assert.deepEqual(titles(), ['Alpha', 'alpha', 'https://example.com/8', 'Zulu', 'Epoch', 'Before epoch', 'Missing', 'Unknown']); assert.equal(cell(rows()[6], 'Last change').textContent, 'not a date'); assert.equal(cell(rows()[7], 'Last change').textContent, ''); await click(app, 'Last change'); assert.deepEqual(titles(), ['Before epoch', 'Epoch', 'Zulu', 'Alpha', 'alpha', 'https://example.com/8', 'Missing', 'Unknown']); }); test('headings sort the filtered list after the initial story request finishes', async (t) => { let resolve; await mount(t, () => new Promise((done) => { resolve = done; })); assert.equal(find(app, 'button', 'Last check'), undefined); input(field(app, 'Filter stories'), 'Reader'); resolve([ story(1, 'Alpha', { last_checked: '2026-01-01T00:00:00Z' }), story(2, 'Beta', { last_checked: '2026-01-02T00:00:00Z' }), ]); await settle(); await click(app, 'Last check'); assert.deepEqual(titles(), ['Beta', 'Alpha']); }); test('filtering and job refreshes keep the selected sort without dropping hidden stories', async (t) => { let list = [ story(1, 'Alpha', { last_changed: '2026-01-01T00:00:00Z' }), story(2, 'Beta', { last_changed: '2026-01-02T00:00:00Z' }), story(3, 'Gamma', { last_changed: '2026-01-03T00:00:00Z', author: 'Someone else' }), ]; const backend = await mount(t, () => list); await click(app, 'Last change'); input(field(app, 'Filter stories'), 'Reader'); assert.deepEqual(titles(), ['Beta', 'Alpha']); backend.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'running' }]); startPolling(); await settle(); list = [{ ...list[0], last_changed: '2026-01-04T00:00:00Z' }, ...list.slice(1)]; backend.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]); await refreshJobs(); await settle(); assert.equal(backend.sent('GET', 'stories').length, 2); assert.equal(heading('Last change').attributes['aria-sort'], 'descending'); assert.equal(document.activeElement, find(app, 'button', 'Last change')); assert.deepEqual(titles(), ['Alpha', 'Beta']); input(field(app, 'Filter stories'), 'missing'); assert.match(app.textContent, /No stories match/); input(field(app, 'Filter stories'), ''); assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Beta']); await click(app, 'Last check'); assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma']); await click(app, 'Last change'); assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Beta']); }); test('date columns keep each timestamp on one line, with last change before last check', async (t) => { const changed = '2026-01-02T03:04:00Z'; const checked = '2026-01-03T05:06:00Z'; await mount(t, [story(1, 'Alpha', { last_changed: changed, last_checked: checked })]); const headings = nodes(app).filter((el) => el.tagName === 'th').map((el) => el.textContent); assert.deepEqual(headings, ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check']); for (const [label, value] of [['Last change', changed], ['Last check', checked]]) { const dateCell = cell(rows()[0], label); const time = nodes(dateCell).find((el) => el.tagName === 'time'); assert.equal(time.attributes.datetime, value); assert.equal(time.attributes.title, fmtDate(value)); assert.equal(nodes(time).filter((el) => el.tagName === 'br').length, 0); assert.equal(time.textContent, fmtDate(value)); } }); test('status messages move into accessible pill descriptions without hiding held or chapter counts', async (t) => { await mount(t, [ story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true, approval_held: true }), story(2, 'Beta', { status: 'error', message: '' }), story(3, 'Gamma'), story(4, 'New', { status: '', message: '' }), story(5, 'Queued', { status: 'queued', message: '' }), ]); for (const [row, label, message] of [ [rows()[0], 'ok', 'Updated 2 chapters'], [rows()[1], 'error', ''], [rows()[2], 'ok', 'No changes'], ]) { const status = cell(row, 'Status'); const pill = status.children[0]; assert.equal(pill.textContent, label); assert.equal(pill.attributes.title, message); assert.equal(pill.attributes['aria-description'], message); assert.equal(pill.attributes.tabindex, '0'); assert.ok(!status.textContent.includes(message)); assert.equal(nodes(status).filter((el) => el.tagName === 'small' || el.tagName === 'script').length, 0); } assert.match(cell(rows()[0], 'Status').textContent, /held/); const chapters = cell(rows()[0], 'Chapters'); assert.match(chapters.textContent, /^42/); assert.equal(nodes(chapters).filter((el) => el.className.startsWith('badge')).length, 0); const counts = nodes(chapters).filter((el) => el.tagName === 'small'); assert.deepEqual(counts.map((el) => el.textContent), ['3 removed', '2 revised']); assert.deepEqual(counts.map((el) => el.attributes.title), [ 'Chapters the site no longer lists', 'Chapters the site changed since Bindery added them', ]); assert.equal(cell(rows()[1], 'Chapters').textContent, '42'); assert.equal(cell(rows()[3], 'Status').textContent, ''); assert.equal(cell(rows()[4], 'Status').textContent, 'queued'); });