496 lines
24 KiB
JavaScript
496 lines
24 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, update_interval: 2,
|
|
};
|
|
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);
|
|
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);
|
|
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);
|
|
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('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');
|
|
urls.value = ' https://site.test/1\n\nhttps://site.test/2 ';
|
|
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.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').map((el) => el.textContent);
|
|
assert.deepEqual(headings, ['', '#', '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]);
|
|
// "Select all" moved above the table, so a hidden table header on mobile does not hide it.
|
|
assert.ok(nodes(main).find((el) => el.tagName === 'label' && el.textContent.trim() === 'Select all'));
|
|
});
|
|
|
|
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 check']);
|
|
});
|
|
|
|
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('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('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('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'));
|
|
});
|