The settings page has one Save button helper. The story list sorts by the sort key that the server sends, so the rule for a leading article exists once. The story page takes its list of download modes from the mode hints.
297 lines
15 KiB
JavaScript
297 lines
15 KiB
JavaScript
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: '<script>Connection failed</script>' }),
|
|
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', '<script>Connection failed</script>'],
|
|
[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');
|
|
});
|