Files
bindery/tests/js/flow-helpers.mjs
T
tim 8faab2463b Move and delete chapters by hand in the chapter list
The merge cannot always tell where new chapters go. For example, a site
can list an announcement before the chapters of an old book. You can now
select chapters and move them, in their order, to after another chapter
or to the start. You can also delete them with their revisions. A story
keeps at least one chapter. The book is then rebuilt. A deleted chapter
that the site still lists comes back with the next download.

The Move… and Delete… buttons open a dialog that names the selected
chapters before anything changes. A shift-click selects a range of
chapters.

The Select all checkbox moves into the first heading of the chapter
list. On a phone, that heading shows with the text "Select all".
2026-09-27 23:38:28 +02:00

104 lines
4.7 KiB
JavaScript

// Small event harness for testing requests and state transitions, not layout.
import assert from 'node:assert/strict';
export class Element {
constructor(tag) {
this.tagName = tag;
this.attributes = {};
this.children = [];
this.listeners = {};
this.className = '';
this.value = '';
this.checked = false;
this.disabled = false;
this.files = [];
this.style = {};
}
append(...children) {
for (const child of children) {
if (child instanceof Element) child.parent = this;
this.children.push(child);
}
}
replaceChildren(...children) {
for (const child of this.children) if (child instanceof Element) child.parent = null;
this.children = [];
this.append(...children);
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(name, fn) { this.listeners[name] = fn; }
focus() { document.activeElement = this; }
showModal() { this.open = true; }
close() { this.open = false; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); }
get firstElementChild() { return this.children.find((child) => child instanceof Element); }
get textContent() { return this.children.map((child) => child instanceof Element ? child.textContent : String(child)).join(''); }
set textContent(value) { this.replaceChildren(value); }
}
export const nodes = (root) => root instanceof Element ? [root, ...root.children.flatMap(nodes)] : [];
export const find = (root, tag, text) => nodes(root).find((el) => el.tagName === tag && el.textContent === text);
export const field = (root, label) => nodes(root).find((el) => el.attributes['aria-label'] === label || el.attributes.id === label || el.attributes.name === label);
export const section = (root, title) => nodes(root).find((el) => ['section', 'details'].includes(el.tagName) && (find(el, 'h2', title) || find(el, 'h1', title)));
export const emit = (el, event) => {
assert.ok(el, `Missing control for ${event}`);
assert.ok(el.listeners[event], `No ${event} handler on ${el.tagName}`);
return el.listeners[event]({ target: el, preventDefault() {} });
};
export const click = (root, label) => emit(find(root, 'button', label), 'click');
export const input = (el, value) => { el.value = value; return emit(el, 'input'); };
export const change = (el, value) => {
if (el.attributes.type === 'checkbox') el.checked = value;
else el.value = value;
return emit(el, 'change');
};
export const settle = async () => { for (let i = 0; i < 4; i++) await new Promise((resolve) => setImmediate(resolve)); };
export function install() {
const app = new Element('div');
const toasts = new Element('div');
const documentEvents = {};
const windowEvents = {};
globalThis.document = {
hidden: false,
createElement: (tag) => new Element(tag),
getElementById: (id) => id === 'bindery-app' ? app : id === 'toasts' ? toasts : nodes(app).find((el) => el.attributes.id === id),
addEventListener: (name, fn) => { documentEvents[name] = fn; },
querySelectorAll: () => nodes(app).filter((el) => el.tagName === 'nav').flatMap((el) => nodes(el).filter((child) => child.tagName === 'a')),
};
globalThis.window = {
location: { hash: '#/', reload() {} }, confirm: () => true, scrollTo() {},
addEventListener: (name, fn) => { windowEvents[name] = fn; },
};
globalThis.DOMParser = class {
parseFromString(html) {
return { body: { textContent: html.replace(/<[^>]*>/g, ''), querySelectorAll: () => [] } };
}
};
return { app, toasts, documentEvents, windowEvents };
}
export function server(t) {
const routes = new Map();
const requests = [];
const unexpected = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
const request = { method: options.method, path: url.replace(/^api\//, ''),
body: typeof options.body === 'string' ? JSON.parse(options.body) : options.body, headers: options.headers };
requests.push(request);
const key = `${request.method} ${request.path}`;
const route = routes.get(key);
if (!route) { unexpected.push(key); throw new Error(`Unexpected request: ${key}`); }
const body = typeof route.body === 'function' ? await route.body(request) : route.body;
return { ok: route.status < 400, status: route.status, statusText: 'Error', json: async () => structuredClone(body) };
});
t.after(() => assert.deepEqual(unexpected, [], 'No unexpected API calls'));
return {
on(method, path, body, status = 200) { routes.set(`${method} ${path}`, { body, status }); },
requests,
sent: (method, path) => requests.filter((r) => r.method === method && r.path === path),
};
}