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: '
Description
', 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, { opds_enabled: false, 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: 'diff
' }; } }; 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\nTwoOne
Two <three>
' } }); 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: 'diff
', 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 & Magic <1>'; 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 & 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); });