Files
bindery/tests/js/flows.test.mjs
T
tim 03ed1a69cb Stack the story and chapter lists on a phone instead of scrolling sideways
Each table had six columns, which squeezed down to unreadable widths on a
narrow screen and forced horizontal scrolling.

Below 600px, each row becomes a bordered card. The main value (the story
or chapter title) stays unlabelled at the top, and every other value gets
a small label in front of it, taken from its column heading.

The chapter table's "select all" checkbox lived in the table header,
which mobile now hides in favour of the per-row labels. It moves into
the sticky actions bar above the list instead, next to the buttons that
already act on the selection.
2026-09-27 23:38:28 +02:00

486 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('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 &lt;three&gt;</p>' } });
await click(section(main, 'Checking for new chapters'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { auto_update: false, recheck_days: 5 });
input(field(main, 'recheck-days'), '');
await click(section(main, 'Checking for new chapters'), 'Save');
assert.equal(api.sent('PUT', 'stories/1/settings').at(-1).body.recheck_days, 0);
});
test('metadata saves skip unchanged drafts, allow edits without a rebuild, and restore site values', async () => {
await mountStory();
api.on('PUT', 'stories/1/settings', {});
api.on('PUT', 'stories/1/metadata', { job: null });
input(field(main, 'Title'), 'Edited title');
await click(section(main, 'Metadata'), 'Save');
assert.equal(api.sent('PUT', 'stories/1/metadata').length, 0);
input(field(main, 'Title'), 'New title');
await click(section(main, 'Metadata'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body, { values: { title: 'New title' } });
await click(main, 'Use the site value');
assert.deepEqual(api.sent('PUT', 'stories/1/metadata').at(-1).body, { values: { title: null } });
});
test('the metadata card shows the site value only when it differs, and drops the button once locked', async () => {
await mountStory();
const card = section(main, 'Metadata');
assert.match(card.textContent, /Site value differs/);
assert.equal(nodes(card).filter((el) => el.className === 'field-site').length, 1);
assert.ok(find(card, 'button', 'Use the site value'));
change(field(main, 'Lock Title'), true);
assert.match(card.textContent, /Site value differs/);
assert.equal(find(card, 'button', 'Use the site value'), undefined);
});
test('the Save button of the metadata card is disabled until something changes', async () => {
await mountStory();
const save = find(section(main, 'Metadata'), 'button', 'Save');
assert.equal(save.disabled, true);
input(field(main, 'Authors'), 'Someone else');
assert.equal(save.disabled, false);
assert.match(section(main, 'Metadata').textContent, /Unsaved changes/);
});
test('failed rule saves do not queue a rebuild', async () => {
await mountStory();
const card = section(main, 'Replacement rules');
api.on('PUT', 'stories/1/rules', { error: 'Bad rule' }, 400);
await click(card, 'Save and rebuild');
assert.equal(api.sent('POST', 'stories/1/download').length, 0);
api.on('PUT', 'stories/1/rules', emptyRules);
api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' });
await click(card, 'Save and rebuild');
assert.deepEqual(api.sent('POST', 'stories/1/download')[0].body, { mode: 'rebuild' });
});
test('library search trims the query and links by UUID', async () => {
await mountStory();
const form = nodes(section(main, 'Library link')).find((el) => el.tagName === 'form');
emit(form, 'submit');
assert.equal(api.requests.some((r) => r.path.startsWith('library/search')), false);
field(main, 'Search the library').value = ' A & B ';
api.on('GET', 'library/search?q=A%20%26%20B', []);
emit(form, 'submit');
await settle();
assert.match(main.textContent, /No library books match/);
api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] },
{ id: 8, uuid: 'book-8', title: 'Tracked', authors: 'Writer', tracked_as: 'Other' }]);
emit(form, 'submit');
await settle();
const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link');
assert.equal(links[1].disabled, true);
api.on('POST', 'stories/1/link', {});
await emit(links[0], 'click');
assert.deepEqual(api.sent('POST', 'stories/1/link')[0].body, { uuid: 'book-7' });
assert.equal(api.sent('GET', 'stories/1').length, 2);
});
test('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 &amp; Magic &lt;1&gt;';
await mountStory();
assert.ok(find(main, 'a', 'Folly & Magic <1>'));
const { el, render } = jobPanel();
render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A &amp; B' }] } }]);
assert.ok(find(el, 'a', 'A & B'));
});