Files
bindery/tests/js/flows.test.mjs
T
tim 42e7287389 Fold each group of settings into an open card
The long help for the download template and the date format now folds shut.
2026-10-03 08:09:41 +02:00

713 lines
34 KiB
JavaScript

import assert from 'node:assert/strict';
import { beforeEach, afterEach, test } from 'node:test';
import { Element, install, server, nodes, find, field, section, emit, click, input, change, settle } from './flow-helpers.mjs';
let ui = install();
const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storiesPage, epubImport } = await import('../../bindery/static/js/stories.js');
const { storyPage } = await import('../../bindery/static/js/story.js');
const { chapterPage } = await import('../../bindery/static/js/chapter.js');
const { rulesEditor, rulesPreview } = await import('../../bindery/static/js/rules.js');
const { startPolling, stopPolling, refreshJobs, jobPanel } = await import('../../bindery/static/js/jobs.js');
const { setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js');
let api, main, details, cleanups;
const config = {
metadata_fields: { title: 'Title', authors: 'Authors', comments: 'Description', series: 'Series', pubdate: 'Published', tags: 'Tags', languages: 'Language' },
mode_labels: { new: 'New chapters' }, library_available: true, cwa_enabled: true, update_interval: 2,
job_list_max_hours: 720, job_list_max_count: 500,
download_fields: { title: 'The title', id: 'The story ID' },
};
const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: '', enabled: true };
const emptyRules = { rules: [], paragraphs: null, global_paragraphs: true };
beforeEach((t) => {
ui = install();
t.mock.timers.enable({ apis: ['setTimeout'] });
setUnauthorizedHandler(() => {});
api = server(t);
main = new Element('main');
cleanups = [];
details = {
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: { title: 'Edited title' }, site_values: { title: 'Site title', authors: ['Writer'],
comments: '<p>Description</p>', series: 'Saga', series_index: 2, pubdate: '2024-01-02T00:00:00', tags: ['Fantasy'], languages: 'en' },
auto_update: true, recheck_days: 3, last_full_recheck: '2026-01-01', last_checked: '2026-01-02', status: 'ok' },
chapters: [{ id: 2, title: 'One', url: 'chapter-1', included: true, on_site: true, current_rev: 1, revisions: 2 },
{ id: 3, title: 'Two', url: 'chapter-2', included: false, on_site: false, current_rev: 1, revisions: 1 }],
recent_chapters: 3,
};
api.on('GET', 'jobs', []);
api.on('GET', 'stories', () => [details.story]);
api.on('GET', 'stories/1', () => details);
api.on('GET', 'rules', emptyRules);
api.on('GET', 'stories/1/rules', emptyRules);
api.on('GET', 'settings', { recent_chapters: 3, personal_ini: '[defaults]' });
});
afterEach(() => { cleanups.forEach((fn) => fn?.()); stopPolling(); });
const mountStory = async (cfg = config) => { cleanups.push(storyPage(main, { storyId: '1' }, cfg)); await settle(); };
const mountStories = async (cfg = config) => { cleanups.push(storiesPage(main, {}, cfg)); await settle(); };
const writes = () => api.requests.filter((r) => r.method !== 'GET');
for (const value of ['7', '']) {
test(`settings save converts ${JSON.stringify(value)} to a count`, async () => {
settingsPage(main, {}, config);
await settle();
api.on('PUT', 'settings', {});
field(main, 'recent-chapters').value = value;
await click(section(main, 'Recheck recent chapters'), 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { recent_chapters: value === '' ? 0 : 7 });
assert.match(ui.toasts.textContent, /Saved/);
});
}
test('personal.ini save reports warnings and clears them after a clean save', async () => {
settingsPage(main, {}, config);
await settle();
const card = section(main, 'FanFicFare settings (personal.ini)');
field(main, 'personal.ini').value = '[defaults]\nis_adult: true';
api.on('PUT', 'settings', { warnings: ['Unknown setting'] });
await click(card, 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { personal_ini: '[defaults]\nis_adult: true' });
assert.match(card.textContent, /Unknown setting/);
api.on('PUT', 'settings', { warnings: [] });
await click(card, 'Save');
assert.doesNotMatch(card.textContent, /Unknown setting/);
});
test('failed settings saves retain edits and re-enable the action', async () => {
settingsPage(main, {}, config);
await settle();
const card = section(main, 'FanFicFare settings (personal.ini)');
field(main, 'personal.ini').value = 'invalid ini';
api.on('PUT', 'settings', { error: 'Invalid settings' }, 400);
await click(card, 'Save');
assert.equal(field(main, 'personal.ini').value, 'invalid ini');
assert.equal(find(card, 'button', 'Save').disabled, false);
assert.match(ui.toasts.textContent, /Invalid settings/);
});
test('the jobs list settings load and save all four fields', async () => {
api.on('GET', 'settings', {
recent_chapters: 0, personal_ini: '',
story_jobs_hours: 36, story_jobs_count: 10, main_jobs_hours: 6, main_jobs_count: 25,
});
settingsPage(main, {}, config);
await settle();
const card = section(main, 'Jobs list');
assert.equal(field(main, 'story-jobs-hours').value, 36);
assert.equal(field(main, 'main-jobs-count').value, 25);
field(main, 'story-jobs-hours').value = '48';
field(main, 'story-jobs-count').value = '20';
field(main, 'main-jobs-hours').value = '12';
field(main, 'main-jobs-count').value = '50';
api.on('PUT', 'settings', {});
await click(card, 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, {
story_jobs_hours: '48', story_jobs_count: '20', main_jobs_hours: '12', main_jobs_count: '50',
});
assert.match(ui.toasts.textContent, /Saved/);
});
test('each group of settings is an open card, and the download help starts closed', async () => {
settingsPage(main, {}, config);
await settle();
const cards = main.children.filter((el) => el.tagName !== 'h1');
assert.deepEqual(cards.map((el) => [el.tagName, el.className, el.open, el.children[0].textContent]), [
['details', 'card', true, 'Recheck recent chapters'],
['details', 'card', true, 'Uploads to CWA'],
['details', 'card', true, 'Book Downloads'],
['details', 'card', true, 'Jobs list'],
['details', 'card', true, 'Global replacement rules'],
['details', 'card', true, 'FanFicFare settings (personal.ini)'],
]);
const help = nodes(section(main, 'Book Downloads')).filter((el) => el.className === 'help');
assert.deepEqual(help.map((el) => [el.tagName, el.open, el.children[0].textContent]), [
['details', undefined, 'File name template help'],
['details', undefined, 'Date format help'],
]);
});
test('the download template loads, lists its fields, and saves the stored form', async () => {
api.on('GET', 'settings', {
recent_chapters: 0, personal_ini: '', download_template: '{title}', download_date_format: '%b, %Y',
});
settingsPage(main, {}, config);
await settle();
const card = section(main, 'Book Downloads');
assert.equal(field(main, 'download-template').value, '{title}');
assert.equal(field(main, 'download-date-format').value, '%b, %Y');
assert.ok(find(card, 'code', '{id}'));
assert.match(card.textContent, /\{title\}The title\{id\}The story ID/);
field(main, 'download-template').value = ' {title} - {authors} ';
field(main, 'download-date-format').value = '';
api.on('PUT', 'settings', { download_template: '{title} - {authors}', download_date_format: '%b, %Y' });
await click(card, 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, {
download_template: ' {title} - {authors} ', download_date_format: '',
});
assert.equal(field(main, 'download-template').value, '{title} - {authors}');
assert.equal(field(main, 'download-date-format').value, '%b, %Y');
api.on('PUT', 'settings', { error: 'Bindery does not support template functions' }, 400);
field(main, 'download-template').value = '{title:uppercase()}';
await click(card, 'Save');
assert.equal(field(main, 'download-template').value, '{title:uppercase()}');
assert.match(ui.toasts.textContent, /does not support template functions/);
});
test('adding stories keeps input on rejection and clears it after a mixed result', async () => {
await mountStories({ ...config, library_available: false });
const urls = nodes(main).find((el) => el.tagName === 'textarea');
Object.assign(urls, { scrollHeight: 90, clientHeight: 60, offsetHeight: 62 });
input(urls, ' https://site.test/1\n\nhttps://site.test/2 ');
assert.equal(urls.style.height, '92px');
Object.assign(urls, { scrollHeight: 50, clientHeight: 90, offsetHeight: 92 });
input(urls, urls.value);
assert.equal(urls.style.height, '92px');
api.on('POST', 'stories', { error: 'Unavailable' }, 503);
await click(main, 'Add');
assert.match(urls.value, /site.test/);
api.on('POST', 'stories', { jobs: [{ id: 'job' }], errors: ['Unsupported URL'] });
await click(main, 'Add');
assert.deepEqual(api.sent('POST', 'stories').at(-1).body, { urls: ' https://site.test/1\n\nhttps://site.test/2 ' });
assert.equal(urls.value, '');
assert.equal(urls.style.height, '');
assert.match(ui.toasts.textContent, /Unsupported URL/);
await click(main, 'Add');
assert.equal(api.sent('POST', 'stories').length, 2);
});
test('story filtering handles early input, empty results and case-insensitive matches', async () => {
let resolve;
api.on('GET', 'stories', () => new Promise((done) => { resolve = done; }));
cleanups.push(storiesPage(main, {}, config));
input(field(main, 'Filter stories'), ' WRITER ');
resolve([details.story]);
await settle();
assert.ok(find(main, 'a', 'Story'));
input(field(main, 'Filter stories'), 'missing');
assert.match(main.textContent, /No stories match/);
input(field(main, 'Filter stories'), 'example.com');
assert.ok(find(main, 'a', 'Story'));
cleanups.pop()();
api.on('GET', 'stories', []);
await mountStories();
assert.match(main.textContent, /No stories yet/);
});
for (const storyId of [undefined, 1]) {
test(`epub import ${storyId ?? 'new story'} sends the file and check option`, async () => {
const root = epubImport(storyId);
const path = storyId ? 'stories/1/import-epub' : 'stories/import-epub';
await click(root, 'Import');
assert.equal(writes().length, 0);
assert.match(ui.toasts.textContent, /Choose an ePub/);
const file = field(root, 'Epub file');
const book = new File(['book'], 'story.epub', { type: 'application/epub+zip' });
file.files = [book];
file.value = 'story.epub';
change(nodes(root).find((el) => el.attributes.type === 'checkbox'), false);
api.on('POST', path, { error: 'Wrong story' }, 400);
await click(root, 'Import');
assert.equal(file.value, 'story.epub');
api.on('POST', path, { id: 'import' });
await click(root, 'Import');
const sent = api.sent('POST', path).at(-1).body;
assert.equal(sent.get('check'), 'false');
assert.equal(sent.get('file').name, 'story.epub');
assert.equal(await sent.get('file').text(), 'book');
assert.equal(file.value, '');
});
}
test('dropping a file on a closed epub import card opens it and fills the file field', () => {
const root = epubImport(1);
assert.equal(root.open, undefined);
const file = field(root, 'Epub file');
const book = new File(['book'], 'story.epub', { type: 'application/epub+zip' });
root.listeners.drop({ preventDefault() {}, dataTransfer: { files: [book] } });
assert.equal(root.open, true);
assert.equal(file.files[0], book);
});
test('library import sends only selected books and handles an empty candidate list', async () => {
await mountStories();
api.on('GET', 'library/candidates', []);
await click(main, 'Find stories in the library');
assert.match(main.textContent, /tracked already/);
const books = [{ id: 8, uuid: 'a', title: 'A', authors: ['W'] }, { id: 9, uuid: 'b', title: 'B', authors: [] }];
api.on('GET', 'library/candidates', books);
await click(main, 'Find stories in the library');
change(field(main, 'Select all'), false);
await click(main, 'Import selected');
assert.equal(writes().length, 0);
change(field(main, 'Select all'), true);
change(field(main, 'A'), false);
change(field(main, 'A'), true);
change(field(main, 'B'), false);
const card = section(main, 'Import from the library');
change(nodes(card).find((el) => el.attributes.type === 'checkbox'), false);
api.on('POST', 'library/import', [{ id: 'job' }]);
await click(main, 'Import selected');
assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 8, uuid: 'a', title: 'A' }], check: false });
});
test('rules edits, moves, removal and paragraph choices are saved in order', async () => {
api.on('GET', 'stories/1/rules', { ...emptyRules, rules: [rule, { ...rule, pattern: 'second' }] });
const editor = rulesEditor('stories/1/rules');
await settle();
const root = editor.element;
const controls = (label) => nodes(root).filter((el) => el.attributes['aria-label'] === label);
emit(controls('Move up')[0], 'click');
emit(controls('Move down')[1], 'click');
emit(controls('Move down')[0], 'click');
input(controls('Pattern')[0], 'edited');
change(controls('Enabled')[0], false);
emit(controls('Move up')[1], 'click');
emit(controls('Remove')[0], 'click');
await click(root, 'Add rule');
input(controls('Replacement')[1], 'replacement');
api.on('PUT', 'stories/1/rules', (req) => ({ ...req.body, global_paragraphs: true }));
for (const [choice, expected] of [['off', false], ['on', true], ['', null]]) {
change(field(root, 'paragraphs'), choice);
await click(root, 'Save');
const sent = api.sent('PUT', 'stories/1/rules').at(-1).body;
assert.equal(sent.paragraphs, expected);
assert.equal(sent.rules[0].pattern, 'edited');
assert.equal(sent.rules[0].enabled, false);
assert.equal(sent.rules[1].replacement, 'replacement');
}
});
test('rule validation failures retain edits and preview uses unsaved values', async () => {
api.on('GET', 'rules', { ...emptyRules, rules: [rule], paragraphs: false });
const editor = rulesEditor('rules');
await settle();
input(field(editor.element, 'Pattern'), '[');
change(nodes(editor.element).find((el) => el.attributes.type === 'checkbox'), true);
api.on('PUT', 'rules', { error: 'Invalid pattern', index: 0 }, 400);
await click(editor.element, 'Save');
assert.equal(field(editor.element, 'Pattern').value, '[');
assert.match(ui.toasts.textContent, /Invalid pattern/);
api.on('POST', 'rules/preview', { error: 'Invalid pattern', index: 0 }, 400);
await assert.rejects(editor.preview(2, false), /Invalid pattern/);
input(field(editor.element, 'Pattern'), 'fixed');
api.on('POST', 'rules/preview', { identical: true });
assert.deepEqual(await editor.preview(2, true), { identical: true });
assert.deepEqual(api.sent('POST', 'rules/preview').at(-1).body,
{ rules: [{ ...rule, pattern: 'fixed' }], paragraphs: true, chapter_id: 2, source: true });
});
test('rule previews keep a valid chapter selection and skip empty lists', async () => {
const calls = [];
const editor = { preview: async (...args) => { calls.push(args); return { identical: calls.length < 3, html: '<p>diff</p>' }; } };
const preview = rulesPreview(editor);
await click(preview.element, 'Preview');
assert.equal(calls.length, 0);
preview.setChapters(details.chapters);
change(field(preview.element, 'Chapter to preview'), '3');
preview.setChapters(details.chapters);
await click(preview.element, 'Preview');
change(nodes(preview.element).find((el) => el.attributes.type === 'checkbox'), true);
await click(preview.element, 'Preview');
preview.setChapters([details.chapters[0]]);
await click(preview.element, 'Preview');
assert.deepEqual(calls, [[3, false], [3, true], [2, true]]);
preview.setChapters([]);
await click(preview.element, 'Preview');
assert.equal(calls.length, 3);
});
test('the chapter table stacks on a narrow screen instead of scrolling sideways', async () => {
await mountStory();
const wrap = nodes(main).find((el) => el.className === 'table-wrap chapters-table');
assert.ok(wrap);
const headings = nodes(wrap).filter((el) => el.tagName === 'th');
assert.deepEqual(headings.map((el) => el.textContent.trim()), ['Select all', '#', 'Title', 'State', 'Revisions', 'Added']);
const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']);
const perRow = [undefined, '#', undefined, 'State', 'Revisions', 'Added'];
assert.deepEqual(dataLabels, [...perRow, ...perRow]);
// The Select all checkbox is in the first heading. On a phone, it is the only heading that shows.
assert.ok(nodes(headings[0]).includes(field(main, 'Select all chapters')));
});
test('story list stacks on a narrow screen instead of scrolling sideways', async () => {
await mountStories();
const wrap = nodes(main).find((el) => el.className === 'table-wrap stories-table');
assert.ok(wrap);
const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']);
assert.deepEqual(dataLabels, [undefined, 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check']);
});
test('the story list shows removed and revised chapter counts, only when there are any', async () => {
details.story.offsite_count = 0;
details.story.revised_count = 0;
await mountStories();
assert.doesNotMatch(main.textContent, /removed/);
assert.doesNotMatch(main.textContent, /revised/);
cleanups.pop()();
details.story.offsite_count = 2;
details.story.revised_count = 3;
await mountStories();
assert.match(main.textContent, /2 removed/);
assert.match(main.textContent, /3 revised/);
});
test('story download modes send selected chapters and clear forced fields after success', async () => {
await mountStory();
api.on('POST', 'stories/1/download', { mode_label: 'Queued' });
const radio = (mode) => nodes(main).find((el) => el.attributes.type === 'radio' && el.value === mode);
change(radio('recheck'), 'recheck');
await click(main, 'Start');
await click(main, 'Recheck selected');
await click(main, 'Take out of book');
assert.equal(writes().length, 0);
change(field(main, 'Select One'), true);
change(field(main, 'Select One'), false);
change(field(main, 'Select all chapters'), true);
change(field(main, 'Select all chapters'), false);
change(field(main, 'Select One'), true);
const force = nodes(section(main, 'Download')).filter((el) => el.attributes.type === 'checkbox');
change(force[0], true);
change(force[1], true);
change(force[1], false);
await click(main, 'Start');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'recheck', force_fields: ['title'], chapter_ids: [2] });
for (const mode of ['new', 'recheck_all', 'full', 'metadata']) {
change(radio(mode), mode);
await click(main, 'Start');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode, force_fields: [], chapter_ids: [] });
}
await click(main, 'Recheck selected');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'recheck', chapter_ids: [2] });
await click(main, 'Rebuild book');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'rebuild' });
});
test('chapter inclusion changes reload the story after queuing the rebuild', async () => {
await mountStory();
change(field(main, 'Select One'), true);
api.on('POST', 'stories/1/chapters', {});
for (const [label, included] of [['Take out of book', false], ['Put back in book', true]]) {
await click(main, label);
assert.deepEqual(api.sent('POST', 'stories/1/chapters').at(-1).body, { chapter_ids: [2], included });
}
assert.equal(api.sent('GET', 'stories/1').length, 3);
});
test('shift-click selects a range of chapters, and Move sends them with the chapter to go after', async () => {
details.chapters.push({ id: 4, title: 'Three', url: 'chapter-3', included: true, on_site: true, current_rev: 1, revisions: 1 });
await mountStory();
api.on('POST', 'stories/1/chapters/move', {});
const dialog = nodes(main).find((el) => el.tagName === 'dialog');
const texts = (tag) => nodes(dialog).filter((el) => el.tagName === tag).map((el) => el.textContent);
await click(main, 'Move…');
assert.ok(!dialog.open);
const box = (title) => field(main, `Select ${title}`);
const shiftClick = (el, on) => {
el.listeners.click({ shiftKey: true, target: el });
return change(el, on);
};
change(box('One'), true);
shiftClick(box('Three'), true);
assert.equal(box('Two').checked, true);
// The dialog names the chapters, and they are not in the list of chapters to go after.
await click(main, 'Move…');
assert.ok(dialog.open);
assert.deepEqual(texts('h3'), ['Move 3 chapters']);
assert.deepEqual(texts('li'), ['One', 'Two', 'Three']);
assert.deepEqual(texts('option'), ['The start']);
await click(dialog, 'Cancel');
assert.ok(!dialog.open);
assert.equal(writes().length, 0);
await click(main, 'Move…');
await click(dialog, 'Move');
assert.deepEqual(api.sent('POST', 'stories/1/chapters/move').at(-1).body, { chapter_ids: [2, 3, 4], after: null });
assert.ok(!dialog.open);
shiftClick(box('One'), false);
change(box('Two'), true);
await click(main, 'Move…');
assert.deepEqual(texts('option'), ['The start', 'One', 'Three']);
field(dialog, 'move-after').value = '4';
await click(dialog, 'Move');
assert.deepEqual(api.sent('POST', 'stories/1/chapters/move').at(-1).body, { chapter_ids: [3], after: 4 });
// The next dialog starts with the last choice.
await click(main, 'Move…');
assert.equal(field(dialog, 'move-after').value, '4');
});
test('unsaved metadata and update settings survive job completion, then save typed values', async () => {
await mountStory();
startPolling();
await settle();
api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'running' }]);
await refreshJobs();
input(field(main, 'Title'), 'My title');
input(field(main, 'Description'), 'One\n\nTwo <three>');
input(field(main, 'Authors'), 'Writer');
change(field(main, 'Lock Title'), true);
change(field(main, 'Lock Authors'), true);
change(field(main, 'Lock Authors'), false);
input(field(main, 'recheck-days'), '5');
change(nodes(section(main, 'Checking for new chapters')).find((el) => el.attributes.type === 'checkbox'), false);
api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]);
await refreshJobs();
await settle();
assert.equal(field(main, 'Title').value, 'My title');
assert.equal(field(main, 'Lock Title').checked, true);
assert.equal(field(main, 'recheck-days').value, '5');
api.on('PUT', 'stories/1/settings', {});
api.on('PUT', 'stories/1/metadata', { job: { id: 'rebuild' } });
await click(section(main, 'Metadata'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { locked_fields: ['title'] });
assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body,
{ values: { title: 'My title', comments: '<p>One</p><p>Two &lt;three&gt;</p>' } });
await click(section(main, 'Checking for new chapters'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { auto_update: false, recheck_days: 5 });
input(field(main, 'recheck-days'), '');
await click(section(main, 'Checking for new chapters'), 'Save');
assert.equal(api.sent('PUT', 'stories/1/settings').at(-1).body.recheck_days, 0);
});
test('metadata saves skip unchanged drafts, allow edits without a rebuild, and restore site values', async () => {
await mountStory();
api.on('PUT', 'stories/1/settings', {});
api.on('PUT', 'stories/1/metadata', { job: null });
input(field(main, 'Title'), 'Edited title');
await click(section(main, 'Metadata'), 'Save');
assert.equal(api.sent('PUT', 'stories/1/metadata').length, 0);
input(field(main, 'Title'), 'New title');
await click(section(main, 'Metadata'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body, { values: { title: 'New title' } });
await click(main, 'Use the site value');
assert.deepEqual(api.sent('PUT', 'stories/1/metadata').at(-1).body, { values: { title: null } });
});
test('the metadata card shows the site value only when it differs, and drops the button once locked', async () => {
await mountStory();
const card = section(main, 'Metadata');
assert.match(card.textContent, /Site value differs/);
assert.equal(nodes(card).filter((el) => el.className === 'field-site').length, 1);
assert.ok(find(card, 'button', 'Use the site value'));
change(field(main, 'Lock Title'), true);
assert.match(card.textContent, /Site value differs/);
assert.equal(find(card, 'button', 'Use the site value'), undefined);
});
test('the Save button of the metadata card is disabled until something changes', async () => {
await mountStory();
const save = find(section(main, 'Metadata'), 'button', 'Save');
assert.equal(save.disabled, true);
input(field(main, 'Authors'), 'Someone else');
assert.equal(save.disabled, false);
assert.match(section(main, 'Metadata').textContent, /Unsaved changes/);
});
test('failed rule saves do not queue a rebuild', async () => {
await mountStory();
const card = section(main, 'Replacement rules');
api.on('PUT', 'stories/1/rules', { error: 'Bad rule' }, 400);
await click(card, 'Save and rebuild');
assert.equal(api.sent('POST', 'stories/1/download').length, 0);
api.on('PUT', 'stories/1/rules', emptyRules);
api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' });
await click(card, 'Save and rebuild');
assert.deepEqual(api.sent('POST', 'stories/1/download')[0].body, { mode: 'rebuild' });
});
test('library search trims the query and links by UUID', async () => {
await mountStory();
const form = nodes(section(main, 'Library link')).find((el) => el.tagName === 'form');
emit(form, 'submit');
assert.equal(api.requests.some((r) => r.path.startsWith('library/search')), false);
field(main, 'Search the library').value = ' A & B ';
api.on('GET', 'library/search?q=A%20%26%20B', []);
emit(form, 'submit');
await settle();
assert.match(main.textContent, /No library books match/);
api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] },
{ id: 8, uuid: 'book-8', title: 'Tracked', authors: ['Writer'], tracked_as: 'Other' }]);
emit(form, 'submit');
await settle();
const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link');
assert.equal(links[1].disabled, true);
api.on('POST', 'stories/1/link', {});
await emit(links[0], 'click');
assert.deepEqual(api.sent('POST', 'stories/1/link')[0].body, { uuid: 'book-7' });
assert.equal(api.sent('GET', 'stories/1').length, 2);
});
test('deleting chapters asks in a dialog and warns about chapters that are on the site', async () => {
await mountStory();
api.on('POST', 'stories/1/chapters/delete', {});
const dialog = nodes(main).find((el) => el.tagName === 'dialog');
const text = (tag) => nodes(dialog).find((el) => el.tagName === tag).textContent;
await click(main, 'Delete…');
assert.ok(!dialog.open);
change(field(main, 'Select Two'), true);
await click(main, 'Delete…');
assert.ok(dialog.open);
assert.equal(text('h3'), 'Delete 1 chapter');
assert.equal(text('p'), 'Bindery deletes this chapter and its revisions. You cannot undo this.');
await click(dialog, 'Cancel');
change(field(main, 'Select One'), true);
await click(main, 'Delete…');
assert.equal(text('h3'), 'Delete 2 chapters');
assert.match(text('p'), /^Bindery deletes these chapters and their revisions\..*comes back with the next download/);
assert.equal(writes().length, 0);
await click(dialog, 'Delete');
assert.deepEqual(api.sent('POST', 'stories/1/chapters/delete').at(-1).body, { chapter_ids: [2, 3] });
assert.ok(!dialog.open);
assert.equal(api.sent('GET', 'stories/1').length, 2);
});
test('sending as a new book and stopping tracking require confirmation', async () => {
details.story.ingested_at = '2026-01-01';
await mountStory();
window.confirm = () => false;
await click(main, 'Send to CWA as a new book');
await click(main, 'Stop tracking this story');
assert.equal(writes().length, 0);
window.confirm = () => true;
api.on('POST', 'stories/1/link', {});
api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' });
await click(main, 'Send to CWA as a new book');
assert.deepEqual(writes().map((r) => [r.path, r.body]), [['stories/1/link', { uuid: null }], ['stories/1/download', { mode: 'rebuild' }]]);
api.on('DELETE', 'stories/1', {});
window.location.hash = '#/story/1';
await click(main, 'Stop tracking this story');
assert.equal(api.sent('DELETE', 'stories/1').length, 1);
assert.equal(window.location.hash, '#/');
});
test('story load failures are reported and sparse offline stories remain usable', async () => {
api.on('GET', 'stories/1', { error: 'Missing story' }, 404);
await mountStory();
assert.match(ui.toasts.textContent, /Missing story/);
cleanups.pop()();
details.story.pinned = {};
details.story.site_values = {};
details.chapters = [];
details.recent_chapters = 0;
details.story.last_full_recheck = null;
details.story.calibre_id = 4;
details.library_error = 'Offline';
api.on('GET', 'stories/1', () => details);
await mountStory({ ...config, library_available: false, update_interval: 0 });
assert.equal(field(main, 'Title').value, '');
assert.match(main.textContent, /Offline/);
});
test('chapter comparisons and revision changes send the chosen revision numbers', async () => {
let diff = { chapter: details.chapters[0], a: 1, b: 2, identical: false, html: '<p>diff</p>',
changed: 1, added: 0, removed: 0, revisions: [{ rev: 1, size: 100 }, { rev: 2, size: 110 }, { rev: 3, size: 120 }] };
api.on('GET', 'chapters/2/diff?', () => diff);
cleanups.push(chapterPage(main, { storyId: '1', chapterId: '2' }));
await settle();
change(field(main, 'Older revision'), '2');
change(field(main, 'Newer revision'), '1');
change(nodes(main).find((el) => el.attributes.type === 'checkbox'), true);
api.on('GET', 'chapters/2/diff?a=2&b=1&source=1', () => diff);
await click(main, 'Compare');
assert.equal(api.sent('GET', 'chapters/2/diff?a=2&b=1&source=1').length, 1);
api.on('POST', 'chapters/2/revision', {});
api.on('GET', 'chapters/2/diff?a=1&b=2&source=1', () => diff);
await click(main, 'Use this revision');
assert.deepEqual(api.sent('POST', 'chapters/2/revision')[0].body, { rev: 2 });
window.confirm = () => false;
await click(main, 'Delete');
assert.equal(api.sent('DELETE', 'chapters/2/revisions/2').length, 0);
window.confirm = () => true;
api.on('DELETE', 'chapters/2/revisions/2', {});
diff = { ...diff, a: 1, b: 1, revisions: [{ rev: 1, size: 100 }] };
api.on('GET', 'chapters/2/diff?source=1', () => diff);
await click(main, 'Delete');
assert.equal(api.sent('DELETE', 'chapters/2/revisions/2').length, 1);
assert.equal(api.sent('GET', 'chapters/2/diff?source=1').length, 1);
});
test('a job panel shows a queued job however old, but limits others by age', () => {
const { el, render } = jobPanel({ hours: 1, count: 10 });
const now = Date.now();
const old = new Date(now - 2 * 3600000).toISOString();
const recent = new Date(now - 30 * 60000).toISOString();
render([
{ id: 'a', status: 'queued', title: 'Old queued', updated: old, created: old },
{ id: 'b', status: 'done', title: 'Old done', updated: old, created: old },
{ id: 'c', status: 'done', title: 'Recent done', updated: recent, created: recent },
]);
assert.match(el.textContent, /Old queued/);
assert.doesNotMatch(el.textContent, /Old done/);
assert.match(el.textContent, /Recent done/);
});
test('a job panel caps jobs other than queued by count, newest first', () => {
const { el, render } = jobPanel({ hours: 24, count: 2 });
const now = Date.now();
const at = (minsAgo) => new Date(now - minsAgo * 60000).toISOString();
render([
{ id: 'a', status: 'done', title: 'Oldest', updated: at(30), created: at(30) },
{ id: 'b', status: 'done', title: 'Middle', updated: at(20), created: at(20) },
{ id: 'c', status: 'done', title: 'Newest', updated: at(10), created: at(10) },
]);
assert.match(el.textContent, /Newest/);
assert.match(el.textContent, /Middle/);
assert.doesNotMatch(el.textContent, /Oldest/);
});
test('a job panel shows a running job however old, like a queued job', () => {
const { el, render } = jobPanel({ hours: 1, count: 10 });
const old = new Date(Date.now() - 2 * 3600000).toISOString();
render([
{ id: 'a', status: 'running', title: 'Old running', updated: old, created: old },
{ id: 'b', status: 'done', title: 'Old done', updated: old, created: old },
]);
assert.match(el.textContent, /Old running/);
assert.doesNotMatch(el.textContent, /Old done/);
});
test('job cancellation posts the job ID and refreshes polling', async () => {
startPolling();
await settle();
const { el: root, render } = jobPanel();
render([{ id: 'cancel-me', status: 'queued', url: 'story-url' },
{ id: 'running', status: 'running', story_id: 1, progress: 0.5,
result: { changed: [{ chapter_id: 2, title: 'One' }, { chapter_id: 3, title: 'Two' }] } }]);
api.on('POST', 'jobs/cancel-me/cancel', {});
await click(root, 'Cancel');
await settle();
assert.equal(api.sent('POST', 'jobs/cancel-me/cancel').length, 1);
assert.equal(api.sent('GET', 'jobs').length, 2);
});
test('chapter titles show the entities that FanFicFare keeps as text', async () => {
details.chapters[0].title = 'Folly &amp; Magic &lt;1&gt;';
await mountStory();
assert.ok(find(main, 'a', 'Folly & Magic <1>'));
const { el, render } = jobPanel();
render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A &amp; B' }] } }]);
assert.ok(find(el, 'a', 'A & B'));
});
test('with CWA off, the pages show nothing about CWA', async () => {
await mountStory();
assert.ok(section(main, 'Library link'));
const off = { ...config, cwa_enabled: false, library_available: false };
// Values that show a notice with CWA on.
Object.assign(details, { pause_uploads: true, ingest_available: false, link_books: false });
details.story.upload_held = true;
await mountStory(off);
assert.ok(section(main, 'Metadata'));
assert.equal(section(main, 'Library link'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
await mountStories(off);
assert.ok(section(main, 'Add stories'));
assert.equal(section(main, 'Import from the library'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
settingsPage(main, {}, off);
await settle();
assert.ok(section(main, 'Jobs list'));
assert.equal(section(main, 'Uploads to CWA'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
});