The pause of the user and a missing ingest folder are separate checks. One function gives the upload state to the settings and the story page. A route that returns only a story no longer loads its chapters or asks CWA for its book. The story page data loses the values that no page reads.
666 lines
32 KiB
JavaScript
666 lines
32 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,
|
|
};
|
|
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, 'Settings'), '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('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 = [{ calibre_id: 8, uuid: 'a', title: 'A' }, { calibre_id: 9, uuid: 'b', title: 'B' }];
|
|
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 <three></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 & 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);
|
|
});
|