Files
bindery/tests/js/story-tab.test.mjs
T
tim 2f4271314b Add the web interface
Plain HTML, CSS and JavaScript modules, with no build step. Hash routes
and relative URLs make it work under any path. The job list is polled:
every 2 seconds while a job runs, and less often otherwise.
2026-09-27 23:38:28 +02:00

94 lines
4.0 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { install, nodes, server, settle } from './flow-helpers.mjs';
const ui = install();
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
const title = 'A very long story title that the tab cuts off with an ellipsis';
const story = (id, storyTitle) => ({
story: { id, title: storyTitle, author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok' },
chapters: [{ id: 2, title: 'One', url: 'chapter-1', included: true, on_site: true, current_rev: 1, revisions: 1 }],
recent_chapters: 0,
});
const diff = { chapter: { id: 2, title: 'One', url: 'https://example.com/c/1', current_rev: 1 }, story_title: title,
a: 1, b: 1, identical: true, html: '', changed: 0, added: 0, removed: 0, revisions: [{ rev: 1, size: 10 }] };
const rules = { rules: [], paragraphs: null, global_paragraphs: false };
const tabs = () => nodes(ui.app).filter((el) => el.tagName === 'nav').flatMap((nav) => nav.children);
const storyTabs = () => tabs().filter((a) => a.className === 'story-tab');
const current = () => tabs().filter((a) => a.attributes['aria-current'] === 'page').map((a) => a.textContent);
const go = async (hash) => {
window.location.hash = hash;
ui.windowEvents.hashchange();
await settle();
};
test('an open story has a tab with its title, also on its chapter pages', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(stopPolling);
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: true, mode: 'none', user: '' });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'settings', { recent_chapters: 0, personal_ini: '' });
backend.on('GET', 'rules', rules);
backend.on('GET', 'stories/1', story(1, title));
backend.on('GET', 'stories/1/rules', rules);
backend.on('GET', 'stories/2', story(2, 'Second'));
backend.on('GET', 'stories/2/rules', rules);
backend.on('GET', 'chapters/2/diff?', diff);
await import('../../bindery/static/js/main.js');
await settle();
assert.deepEqual(storyTabs(), []);
await go('#/story/1');
const [tab] = storyTabs();
assert.equal(tab.textContent, title);
assert.equal(tab.attributes.title, title);
assert.equal(tab.attributes.href, '#/story/1');
assert.deepEqual(current(), [title]);
await go('#/story/1/chapter/2');
assert.deepEqual(storyTabs(), [tab]);
assert.deepEqual(current(), [title]);
await go('#/story/2');
assert.equal(storyTabs().length, 1);
assert.deepEqual(current(), ['Second']);
await go('#/settings');
assert.deepEqual(storyTabs(), []);
assert.deepEqual(current(), ['Settings']);
});
test('a story that loads after the user left it gets no tab', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(stopPolling);
const backend = server(t);
let release;
backend.on('GET', 'stories', []);
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories/3', () => new Promise((resolve) => { release = () => resolve(story(3, 'Late')); }));
backend.on('GET', 'stories/3/rules', rules);
await go('#/story/3');
await go('#/');
release();
await settle();
assert.deepEqual(storyTabs(), []);
assert.deepEqual(current(), ['Stories']);
});
test('a long title ends in an ellipsis', () => {
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const rule = css.match(/header nav a\.story-tab \{([^}]*)\}/)[1];
for (const declaration of ['max-width: 24ch', 'overflow: hidden', 'text-overflow: ellipsis', 'white-space: nowrap']) {
assert.ok(rule.includes(`${declaration};`), declaration);
}
// Without it, the nav keeps the width of the whole title and runs past the header on a phone.
assert.ok(css.match(/header nav \{([^}]*)\}/)[1].includes('min-width: 0;'));
});