Files
study-bible-project/src/App.flow.test.jsx
T
nmemmertandClaude Sonnet 5.5 57db2310dd Outline: restyle the share page and the printed / Word outline
- Read-only share page: a calm paper-toned page rendered natively (no
  iframe), with outline tabs, Print / PDF, numbered points with accent
  numerals, type tags, notes and verse quotes; works on phones
- Printable / PDF page restyled to match (serif, hanging numerals, tidy
  spacing, footer)
- Word outline restyled: eyebrow + title block, bold main points, accent
  numerals, set-off verse quotes, tidy margins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:10:42 -04:00

394 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
import App from './App';
import { resetLexiconCaches } from './utils/wordStudy.js';
import { switchStorageUser } from './utils/bibleUtils.jsx';
import { resetSyncBases } from './utils/syncBase.js';
const chapterData = {
chapter: { content: [
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
] },
};
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
function stubServer({ user = null, putStatus = 200 } = {}) {
const calls = [];
const state = { putStatus, outlineToken: null };
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
calls.push({ route, url, opts });
if (url.includes('bible.helloao.org')) return json(200, chapterData);
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route.endsWith('/outline-share')) {
if (opts.method === 'POST') state.outlineToken = 'abc123';
if (opts.method === 'DELETE') state.outlineToken = null;
return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null });
}
if (route === 'GET /api/share-outline/abc123') {
return json(200, { title: 'Titus', translation: 'BSB', outlines: [
{ scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
{ scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
] });
}
if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' });
if (route.startsWith('PUT /api/projects/')) {
return state.putStatus === 409
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
}
return json(404, { error: 'not mocked' });
}));
return { calls, state };
}
beforeEach(() => {
localStorage.clear();
resetLexiconCaches();
resetSyncBases();
switchStorageUser(null);
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
});
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
async function renderApp() {
render(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
const project = {
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
}],
};
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
localStorage.setItem('bible-study-index', JSON.stringify(index));
}
describe('Back button', () => {
test('moves back through the pages you visited instead of leaving the app', async () => {
const user = userEvent.setup();
await renderApp();
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /reading plans/i }));
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
window.history.back();
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
window.history.forward();
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
});
});
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
describe('Search palette (⌘K)', () => {
test('a typed passage goes straight to the Bible reader', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await user.type(await paletteBox(), 'john 3:16');
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
await user.keyboard('{Enter}');
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
});
test('finds a note you wrote and opens that study', async () => {
seedProject({ observation: 'Grace abounds to all.' });
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await user.type(await paletteBox(), 'abounds');
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
await user.click(hit);
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
});
test('Escape closes it', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await paletteBox();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
});
test('? lists the keyboard shortcuts', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('?');
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
});
});
describe('Undo', () => {
test('deleting a project, then Undo, keeps its notes', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
await user.click(screen.getByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
});
});
describe('Sync conflict', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
async function startStudy(u) {
await renderApp();
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
}
test('a save the server refuses shows the choice instead of overwriting', async () => {
const server = stubServer({ user, putStatus: 409 });
const u = userEvent.setup();
await startStudy(u);
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
expect(banner).toHaveTextContent(/changed on another device/i);
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
// "Keep this version" sends the save again, this time as an overwrite
server.state.putStatus = 200;
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
}, 15000);
test('opening a study without editing it does not touch the server', async () => {
seedProject();
const server = stubServer({ user });
const u = userEvent.setup();
await renderApp();
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
await u.click(await screen.findByRole('button', { name: /read bible/i }));
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
});
});
describe('Outline stage', () => {
async function openOutline() {
// local-only mode (the account server is unreachable), with chapter text available
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
// one chunk covering verses 1-2
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
const user = await openOutline();
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
});
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
const first = await screen.findByRole('textbox', { name: 'Point I.' });
await user.type(first, 'Creation{Enter}');
const second = await screen.findByRole('textbox', { name: 'Point II.' });
await user.type(second, 'Light{Tab}');
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
});
test('deleting a point can be undone', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
await user.click(screen.getByRole('button', { name: /delete i\./i }));
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
});
});
describe('Outline: per chunk, details, print and share', () => {
async function openOutline() {
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('each chunk has its own outline, separate from the whole study\'s', async () => {
const user = await openOutline();
const picker = screen.getByRole('combobox', { name: 'Outline for' });
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
});
test('a point can be given a type and minutes, and the total shows', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
await user.click(screen.getByRole('button', { name: /details for i\./i }));
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
await user.type(screen.getByLabelText('Minutes'), '5');
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
expect(screen.getByText('Illustration')).toBeInTheDocument();
});
test('"Add verse text" puts the passage\'s words into the point', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
await user.click(screen.getByRole('button', { name: /add verse text/i }));
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Passage'));
await user.type(screen.getByLabelText('Passage'), 'nonsense');
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
});
test('Share opens a sheet; with no account it offers the text, which is copied', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
await user.click(screen.getByRole('button', { name: /^share$/i }));
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument();
await user.click(within(sheet).getByRole('button', { name: /share as text/i }));
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
});
test('Print opens a page of just the outline', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
const doc = { write: vi.fn(), close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
});
});
describe('Read-only outline link', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
test('create the link, see it, and turn it off', async () => {
const server = stubServer({ user });
const u = userEvent.setup();
await renderApp();
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /^outline$/i }));
await u.click(await screen.findByRole('button', { name: /start from my chunks/i }));
await u.click(await screen.findByRole('button', { name: /^share$/i }));
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i }));
const box = await within(sheet).findByLabelText('Outline link');
expect(box.value).toMatch(/\?outline=abc123$/);
expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true);
await u.click(within(sheet).getByRole('button', { name: /turn off link/i }));
expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument();
}, 15000);
});
describe('Opening a read-only outline link', () => {
afterEach(() => window.history.replaceState({}, '', '/'));
test('shows the outline with no sign-in and a way to switch between outlines', async () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=abc123');
render(<App />);
expect(await screen.findByRole('heading', { name: 'Titus' })).toBeInTheDocument();
expect(screen.getByText('Appoint elders')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); // nothing to edit
await userEvent.setup().click(screen.getByRole('button', { name: 'Titus 1:1–4' }));
expect(await screen.findByRole('heading', { name: 'Titus — Titus 1:1–4' })).toBeInTheDocument();
expect(screen.getByText('Greeting')).toBeInTheDocument();
expect(screen.queryByText('Appoint elders')).not.toBeInTheDocument();
});
test('a turned-off or wrong link says so', async () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=nope');
render(<App />);
expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument();
});
});
describe('Study cards on Home', () => {
test('a long run of chapters and many tags stay short', async () => {
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
await renderApp();
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
expect(screen.getByText('+3 more')).toBeInTheDocument();
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
});
});
describe('Window title', () => {
test('names the page you are on, including the chapter in the reader', async () => {
const user = userEvent.setup();
await renderApp();
expect(document.title).toBe('My Studies · Bible Study');
await user.click(screen.getByRole('button', { name: /reading plans/i }));
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
await user.click(screen.getByRole('button', { name: /read bible/i }));
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
});
});