Files
bindery/tests/js/layout.test.mjs
T
tim 46de77c228 Show an estimated page count for each built book
Bindery counts the pages as the last step of a build, from the text of the ePub. The story list and the OPDS catalog show the count. A story has no count until its book is built.

The method follows "Paragraphs (APNX Accurate)" of the Count Pages plugin for calibre. Bindery counts each paragraph that a reader sees, so the same text gives the same count as <p> elements, as <div> elements and as lines with <br> between them. An empty paragraph is not counted.
2026-10-04 02:05:47 +02:00

207 lines
10 KiB
JavaScript

// Stylesheet contracts. Check the rendered layout in a browser.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const normalize = (selector) => selector.trim().replace(/\s+/g, ' ');
function declarations(selector, source = css) {
const rules = [...source.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const rule = rules.find(([, selectors]) => normalize(selectors) === selector);
assert.ok(rule, `Missing CSS rule: ${selector}`);
return Object.fromEntries(rule[2].split(';').filter((entry) => entry.trim()).map((entry) => {
const colon = entry.indexOf(':');
return [entry.slice(0, colon).trim(), entry.slice(colon + 1).trim()];
}));
}
test('header and main share a centered width without a gap between them', () => {
const shared = declarations('header, main');
assert.equal(shared.width, 'min(1100px, calc(100% - 32px))');
assert.equal(shared.margin, '0px auto');
for (const selector of ['header', 'main']) {
const styles = declarations(selector);
assert.equal(styles.width, undefined);
assert.equal(styles.margin, undefined);
}
});
test('header and main share a theme-aware frame without adding a second login frame', () => {
const frame = declarations('header, main:not(.login)');
assert.equal(frame.border, '1px solid var(--border)');
assert.equal(frame.background, 'var(--surface)');
assert.ok(frame['box-shadow']);
assert.equal(declarations('header')['border-top-left-radius'], '12px');
assert.equal(declarations('header')['border-top-right-radius'], '12px');
assert.equal(declarations('main')['border-bottom-left-radius'], '12px');
assert.equal(declarations('main')['border-bottom-right-radius'], '12px');
assert.equal(declarations('main:not(.login)')['border-top'], '0px');
assert.equal(declarations('main.login')['max-width'], '400px');
assert.equal(declarations('main > :last-child')['margin-bottom'], '0');
});
test('header and main keep matching gutters on narrow screens', () => {
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'));
const shared = declarations('header, main', mobile);
assert.equal(shared.width, 'calc(100% - 24px)');
assert.equal(shared.margin, undefined);
assert.equal(shared.padding, '12px');
assert.equal(declarations('header', mobile)['padding-bottom'], '0');
});
test('navigation looks like tabs and joins the active link to the main surface', () => {
const navigation = declarations('header nav');
assert.equal(navigation['flex-basis'], '100%');
assert.equal(navigation['margin-bottom'], '-1px');
assert.equal(declarations('header nav a')['border-radius'], '8px 8px 0 0');
const current = declarations("header nav a[aria-current='page']");
assert.equal(current.background, 'var(--surface)');
assert.equal(current.color, 'var(--text)');
assert.equal(current['border-bottom-color'], 'var(--surface)');
assert.equal(current['box-shadow'], undefined);
const focus = declarations('a:focus-visible, button:focus-visible, summary:focus-visible');
assert.equal(focus.outline, '2px solid var(--accent)');
});
test('inactive tabs have a visible outline and fill without a shadow', () => {
const tab = declarations('header nav a');
assert.equal(tab.border, '1px solid var(--border)');
assert.equal(tab.background, 'var(--bg)');
assert.equal(tab['box-shadow'], undefined);
const hover = declarations('header nav a:hover');
assert.equal(hover.background, 'var(--surface)');
assert.equal(hover['box-shadow'], undefined);
});
test('the header title and icon grow without changing the login title', () => {
assert.equal(declarations('header .brand')['font-size'], '1.65rem');
assert.equal(declarations('header .brand img').width, '32px');
assert.equal(declarations('header .brand img').height, '32px');
assert.equal(declarations('.brand')['font-size'], '1.1rem');
});
test('the Add stories textarea uses the smaller text size without shrinking other textareas', () => {
assert.equal(declarations('.ini, .story-urls')['font-size'], 'var(--text-sm)');
assert.equal(declarations('textarea')['font-size'], undefined);
});
test('story timestamps fit their text and chapter details use plain small text', () => {
const desktop = css.slice(css.indexOf('@media (min-width: 601px)'));
const date = declarations('.date');
assert.equal(date['white-space'], 'nowrap');
assert.equal(date['min-inline-size'], undefined);
const storyDate = declarations('.stories-table .date', desktop);
assert.equal(storyDate['inline-size'], '1%');
assert.equal(storyDate['min-inline-size'], undefined);
const details = declarations(".stories-table td[data-label='Chapters'] small");
assert.equal(details.display, 'block');
assert.equal(details['white-space'], 'nowrap');
assert.equal(details.background, undefined);
assert.equal(details.border, undefined);
assert.equal(details.padding, undefined);
});
test('sortable headings show direction and remain usable above story cards on narrow screens', () => {
assert.equal(declarations('.stories-table th button').background, 'none');
assert.equal(declarations('.stories-table th[aria-sort] button::after').content, "'↕'");
assert.equal(declarations(".stories-table th[aria-sort='ascending'] button::after").content, "'↑'");
assert.equal(declarations(".stories-table th[aria-sort='descending'] button::after").content, "'↓'");
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'));
assert.equal(declarations('.stories-table thead tr', mobile).display, 'flex');
assert.notEqual(declarations('.stories-table th', mobile).display, 'none');
assert.doesNotMatch(css, /\.stories-table thead\s*\{[^}]*clip-path/);
});
test('on narrow screens a story cell with no value shows no line', () => {
const start = css.indexOf('@media (max-width: 600px)', css.indexOf('.stories-table th button'));
assert.equal(declarations('.stories-table td:empty', css.slice(start)).display, 'none');
assert.doesNotMatch(css.slice(0, start), /\.stories-table td:empty/);
});
test('on narrow screens the headings are sort chips, and only the sorted chip is tinted with an arrow', () => {
const start = css.indexOf('@media (max-width: 600px)', css.indexOf('.stories-table th button'));
const mobile = css.slice(start);
assert.equal(declarations('.stories-table thead tr', mobile)['flex-wrap'], 'wrap');
const caption = declarations('.stories-table thead tr::before', mobile);
assert.equal(caption.content, "'Sort by'");
assert.equal(caption['flex-basis'], '100%');
const chip = declarations('.stories-table th button', mobile);
assert.equal(chip['border-radius'], '999px');
assert.equal(chip['inline-size'], 'auto');
assert.equal(chip.padding, '3px 10px');
assert.equal(chip['font-size'], 'var(--text-sm)');
assert.equal(chip.background, 'var(--surface)');
assert.equal(declarations(".stories-table th[aria-sort='none'] button::after", mobile).display, 'none');
const sorted = declarations(".stories-table th:not([aria-sort='none']) button", mobile);
assert.equal(sorted.background, 'var(--info-bg)');
assert.equal(sorted.color, 'var(--info)');
assert.equal(sorted['border-color'], undefined, 'The sorted chip keeps the soft border');
// The wide layout keeps plain headings.
const wide = css.slice(0, start);
assert.doesNotMatch(wide, /\.stories-table thead tr::before/);
assert.equal(declarations('.stories-table th button', wide).border, '0');
});
test('sortable headings fill the column with one clickable highlight without shifting layout', () => {
assert.equal(declarations('.stories-table th').padding, '0');
const button = declarations('.stories-table th button');
assert.equal(button['inline-size'], '100%');
assert.equal(button['justify-content'], 'flex-start');
assert.equal(button.padding, '6px 8px');
assert.equal(button.margin, undefined);
const highlight = declarations('.stories-table th button:is(:hover, :focus-visible)');
assert.equal(highlight.background, 'var(--info-bg)');
assert.equal(highlight.color, 'var(--info)');
assert.equal(highlight['text-decoration'], undefined);
assert.equal(highlight.padding, undefined);
assert.equal(highlight.border, undefined);
assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/);
});
test('disabled checkbox labels use muted text', () => {
assert.equal(declarations("label:has(> input[type='checkbox']:disabled)").color, 'var(--muted)');
});
function luminance(hex) {
const channels = hex.slice(1).match(/../g).map((channel) => {
const value = parseInt(channel, 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
test('cards keep their borders and use a subtle background in both themes', () => {
const card = declarations('.card');
assert.equal(card.background, 'var(--card-bg)');
assert.equal(card.border, '1px solid var(--border)');
assert.equal(card['border-radius'], '8px');
assert.equal(declarations('.card-danger')['border-color'], 'var(--err)');
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
for (const source of [css, dark]) {
const theme = declarations(':root', source);
assert.notEqual(theme['--card-bg'], theme['--surface']);
assert.notEqual(theme['--card-bg'], theme['--bg']);
const channels = theme['--card-bg'].slice(1).match(/../g).map((channel) => parseInt(channel, 16));
assert.ok(channels.every((channel) => channel === channels[0]), 'Cards use neutral grey without a colour tint');
for (const foreground of ['--text', '--muted', '--accent', '--err']) {
const light = [luminance(theme[foreground]), luminance(theme['--card-bg'])].sort((a, b) => b - a);
assert.ok((light[0] + 0.05) / (light[1] + 0.05) >= 4.5, `${foreground} on a card`);
}
}
});
test('header text has at least 4.5:1 contrast in both color themes', () => {
assert.equal(declarations('header').background, 'var(--header-bg)');
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
for (const source of [css, dark]) {
const theme = declarations(':root', source);
assert.notEqual(theme['--header-bg'], theme['--surface']);
for (const foreground of ['--text', '--muted']) {
const light = [luminance(theme[foreground]), luminance(theme['--header-bg'])].sort((a, b) => b - a);
assert.ok((light[0] + 0.05) / (light[1] + 0.05) >= 4.5, `${foreground} on the header`);
}
}
});