Files
study-bible-project/src/App.test.jsx
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

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

1442 lines
70 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.
import { render, screen, fireEvent, waitFor, within } 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';
// ---------------------------------------------------------------------------
// Shared mock data
// ---------------------------------------------------------------------------
const mockChapterData = {
chapter: {
content: [
{ type: 'verse', number: 1, content: ['Paul, a servant of God.'] },
{ type: 'verse', number: 2, content: ['In hope of eternal life.'] },
{ type: 'verse', number: 3, content: ['Grace and peace from God.'] },
],
},
};
// Mirrors the OpenScriptures Strong's Greek dictionary format loaded from jsdelivr.
const mockGreekDict = {
G4102: {
lemma: 'πίστις',
translit: 'pistis',
kjv_def: 'faith, belief',
strongs_def: 'persuasion, i.e. credence; moral conviction',
},
};
function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) {
return vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB', 'KJV']) });
}
if (url.includes('bible.helloao.org')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) });
}
if (url.includes('strongs-greek-dictionary') && greekData !== null) {
return Promise.resolve({
ok: true,
text: () => Promise.resolve(`var strongsGreekDictionary = ${JSON.stringify(greekData)};`),
});
}
return Promise.resolve({ ok: false });
});
}
// ---------------------------------------------------------------------------
// Test lifecycle
// ---------------------------------------------------------------------------
beforeEach(() => {
localStorage.clear();
resetLexiconCaches();
switchStorageUser(null); // a signed-in test leaves the storage namespace switched
vi.stubGlobal('fetch', buildFetchMock());
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(window, 'confirm').mockReturnValue(false);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// App checks /api/auth/me on mount before deciding whether to show the app or
// a login gate. In tests the mock fetch reports the server as unreachable, so
// it falls back to local-only mode — but that still takes a tick to resolve.
async function renderApp() {
render(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
// The account menu (avatar button in the app bar) holds the email, Settings, Admin and Log out.
async function openAccountMenu(user = userEvent.setup()) {
await user.click(await screen.findByRole('button', { name: /account menu/i }));
return user;
}
async function loadChapter() {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
// SetupPage is lazy-loaded, so wait for it on first open
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
return user;
}
function getVerseList() {
return screen.getByTestId('verse-list');
}
function clickVerse(textFragment) {
fireEvent.click(
within(getVerseList()).getByRole('button', { name: new RegExp(textFragment) }),
);
}
function shiftClickVerse(textFragment) {
fireEvent.click(
within(getVerseList()).getByRole('button', { name: new RegExp(textFragment) }),
{ shiftKey: true },
);
}
function addChunk(startFragment, endFragment) {
clickVerse(startFragment);
shiftClickVerse(endFragment);
}
function findChunkCounter(n, total) {
return screen.findByText((_, el) => el?.textContent === `Chunk ${n} of ${total}`);
}
// ---------------------------------------------------------------------------
// Initial render
// ---------------------------------------------------------------------------
describe('Initial render', () => {
test('shows the home page with "My Studies" heading', async () => {
await renderApp();
expect(screen.getByText('My Studies')).toBeInTheDocument();
});
test('shows "No projects yet" when storage is empty', async () => {
await renderApp();
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
test('shows "New Project" button on home page', async () => {
await renderApp();
expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0);
});
test('clicking "New Project" shows the project setup form', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(await screen.findByText('New study')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
});
test('setup form shows book and chapter inputs, and uses the BSB', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.queryByText('Translation')).not.toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument();
expect(screen.getByText('Chapter')).toBeInTheDocument();
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Loading a chapter
// ---------------------------------------------------------------------------
describe('Loading a chapter', () => {
test('transitions to the chunk setup view on success', async () => {
await loadChapter();
expect(screen.getByText(/Scripture & Chunks/i)).toBeInTheDocument();
});
test('renders verse text after loading', async () => {
await loadChapter();
expect(screen.getByText(/Paul, a servant of God/i)).toBeInTheDocument();
});
test('shows "0 created" initially', async () => {
await loadChapter();
expect(screen.getByText('0 created')).toBeInTheDocument();
});
test('shows an error message when the API returns a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB']) });
}
return Promise.resolve({ ok: false });
}));
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/unable to load chapter/i);
});
test('shows an error message when chapter data contains no verses', async () => {
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/invalid bible data/i);
});
});
// ---------------------------------------------------------------------------
// Chunk management
// ---------------------------------------------------------------------------
describe('Chunk management', () => {
test('creates a chunk by clicking a verse range', async () => {
await loadChapter();
addChunk('Paul, a servant', 'Grace and peace');
expect((await screen.findAllByText(/1[-–]3/)).length).toBeGreaterThan(0);
});
test('chunk count increments after each addition', async () => {
await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
addChunk('In hope of eternal', 'Grace and peace');
await screen.findByText('2 created');
});
test('prevents adding a duplicate chunk range', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText(/That chunk already exists/i);
});
test('deletes a chunk', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
await screen.findByText('0 created');
});
test('"Begin Studying" is disabled when no chunks exist', async () => {
await loadChapter();
expect(screen.getByRole('button', { name: /begin studying/i })).toBeDisabled();
});
test('"Begin Studying" is enabled once a chunk exists', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /begin studying/i })).not.toBeDisabled();
});
});
// ---------------------------------------------------------------------------
// Study view navigation
// ---------------------------------------------------------------------------
async function goToStudyWith3Chunks() {
await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
addChunk('In hope of eternal', 'In hope of eternal');
addChunk('Grace and peace', 'Grace and peace');
await screen.findByText('3 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(3, 3);
// addChunk always selects the newest chunk; navigate back to chunk 1
// so navigation tests start from a known position.
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
}
describe('Study view navigation', () => {
test('transitions to study view when "Begin Studying" is clicked', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
});
test('"Previous Chunk" is disabled on the first chunk', async () => {
await goToStudyWith3Chunks();
expect(screen.getByRole('button', { name: /previous chunk/i })).toBeDisabled();
});
test('"Next Chunk" advances to the second chunk', async () => {
await goToStudyWith3Chunks();
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
});
test('"Next Chunk" is disabled on the last chunk', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(3, 3)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next chunk/i })).toBeDisabled();
});
test('"Previous Chunk" returns to prior chunk after advancing', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Greek word lookup
// ---------------------------------------------------------------------------
async function goToStudyAndAddGreekWord(fetchMock) {
if (fetchMock) vi.stubGlobal('fetch', fetchMock);
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
fireEvent.click(screen.getByRole('button', { name: /add word/i }));
await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
}
describe('Greek word lookup', () => {
test('adds a Greek word entry form', async () => {
await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
});
test('populates fields after a successful lookup', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDict }));
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('πίστις');
expect(screen.getByDisplayValue('pistis')).toBeInTheDocument();
// With no gloss-map entry, the English gloss falls back to the KJV
// definition, so it matches the Short Definition field.
expect(screen.getAllByDisplayValue('faith, belief')).toHaveLength(2);
});
test('shows "No definition found." when the API returns an empty array', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: {} }));
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('No definition found.');
});
test('shows "Lookup failed." on network error', async () => {
await goToStudyAndAddGreekWord(
vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB']) });
}
if (url.includes('bible.helloao.org')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockChapterData) });
}
return Promise.reject(new Error('Network error'));
}),
);
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('Lookup failed.');
});
test('lookup is skipped when query is empty', async () => {
const fetchSpy = buildFetchMock();
await goToStudyAndAddGreekWord(fetchSpy);
const callCountBefore = fetchSpy.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await waitFor(() => {
expect(fetchSpy.mock.calls.length).toBe(callCountBefore);
});
});
test('removes a Greek word entry', async () => {
await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => {
expect(screen.queryByPlaceholderText(/G4102, H7225, 4102/i)).not.toBeInTheDocument();
});
});
});
// ---------------------------------------------------------------------------
// Authentication (useAuth + AuthPage/SettingsPage local form state)
// ---------------------------------------------------------------------------
describe('Authentication', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: 'My Show', isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
// A tiny fake API: `session` decides what /api/auth/me returns; login can
// optionally require a 2FA code first.
function stubApi({ session = null, mfaRequired = false } = {}) {
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return session ? json(200, session) : json(401, { error: 'Not signed in' });
if (route === 'POST /api/auth/login') return json(200, mfaRequired ? { mfaRequired: true } : user);
if (route === 'POST /api/auth/mfa/verify') return json(200, user);
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
return json(404, { error: 'not mocked' });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
const calledWith = (fetchMock, method, url) =>
fetchMock.mock.calls.some(([u, o = {}]) => u === url && (o.method ?? 'GET') === method);
async function signInWithForm(u) {
await u.type(await screen.findByLabelText(/email/i), user.email);
await u.type(screen.getByLabelText(/password/i), 'correct-horse');
await u.click(screen.getByRole('button', { name: /^sign in$/i }));
}
test('signing in with the form shows the app and loads reader ink', async () => {
const fetchMock = stubApi();
const u = userEvent.setup();
render(<App />);
await signInWithForm(u);
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
});
test('2FA: a correct code after the password signs the user in', async () => {
const fetchMock = stubApi({ mfaRequired: true });
const u = userEvent.setup();
render(<App />);
await signInWithForm(u);
await u.type(await screen.findByPlaceholderText('123456'), '123456');
await u.click(screen.getByRole('button', { name: /verify/i }));
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
});
test('an existing session skips the login screen, and Log out returns to it', async () => {
const fetchMock = stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
await u.click(screen.getByRole('menuitem', { name: /log out/i }));
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
});
test('Settings pre-fills the podcast name from the signed-in account', async () => {
stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Admin page (state is local to AdminPage)
// ---------------------------------------------------------------------------
describe('Admin page', () => {
const admin = { id: 'a1', email: 'owner@example.com', totpEnabled: true, podcastName: null, isAdmin: true };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
function stubAdminApi(sessionUser) {
let users = [
{ id: 'a1', email: 'owner@example.com', totpEnabled: true, projectCount: 1 },
{ id: 'u2', email: 'student@example.com', totpEnabled: false, projectCount: 0 },
];
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, sessionUser);
if (route === 'GET /api/admin/users') return json(200, users);
if (route === 'GET /api/admin/projects') return json(200, [{ id: 'p1', title: 'Romans Study', ownerEmail: 'owner@example.com', chapterSummary: 'Romans 1' }]);
if (route === 'DELETE /api/admin/users/u2') { users = users.filter((u) => u.id !== 'u2'); return json(200, { ok: true }); }
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
return json(404, { error: 'not mocked' });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
test('an admin can open the page and see users and projects', async () => {
stubAdminApi(admin);
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
expect(await screen.findByText('student@example.com')).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
});
test('deleting a user removes them after the list reloads', async () => {
const fetchMock = stubAdminApi(admin);
window.confirm.mockReturnValue(true);
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
const row = (await screen.findByText('student@example.com')).closest('tr');
await u.click(within(row).getByRole('button', { name: /delete/i }));
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
});
test('the Admin page leads back to the dashboard', async () => {
stubAdminApi(admin);
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
await screen.findByText('student@example.com');
await u.click(screen.getByRole('button', { name: /^back$/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('non-admins get no Admin button', async () => {
stubAdminApi({ ...admin, isAdmin: false });
render(<App />);
await openAccountMenu();
expect(await screen.findByText(admin.email)).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /admin/i })).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Import page (state is local to ImportPage)
// ---------------------------------------------------------------------------
describe('Import page', () => {
test('opens from Home, uses the BSB (no translation choice), and goes back', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument();
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^back$/i }));
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Home page (search/sort/rename local to HomePage; library + reading plan hooks)
// ---------------------------------------------------------------------------
describe('Home page', () => {
// Tests run in local-only mode (the mock API is unreachable), which uses the
// pre-account localStorage keys.
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));
}
test('searching 3+ characters finds matches inside study notes', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.type(screen.getByPlaceholderText(/search projects/i), 'grace');
expect(await screen.findByText(/notes matching "grace"/i)).toBeInTheDocument();
});
test('renaming a project updates its title and saved index', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /rename/i }));
const input = screen.getByDisplayValue('Titus Notes');
await user.clear(input);
await user.type(input, 'Titus Deep Dive');
await user.click(screen.getByRole('button', { name: /^save$/i }));
expect(await screen.findByText('Titus Deep Dive')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
});
test('deleting a project removes it at once, and Undo brings it back', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
expect(window.confirm).not.toHaveBeenCalled();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(localStorage.getItem('bible-study-project-p1')).not.toBeNull();
});
test('sort choice is remembered after leaving Home', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
await user.click(screen.getByRole('button', { name: /read bible/i }));
await user.click(await screen.findByRole('button', { name: /back to home/i }));
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
});
test('Continue reading opens the Reader inside the plan, in the plan\'s own translation', async () => {
seedProject();
localStorage.setItem('readingPlanProgress', JSON.stringify({
progress: [{ id: 'rp1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now(), translation: 'ESV' }],
customPlans: [], activity: [],
}));
const esvChapter = { translation: 'ESV', verses: [{ number: 1, text: 'In the beginning was the Word.' }], copyright: '' };
const fetchMock = vi.fn((url) => {
if (String(url).startsWith('/api/bible/ESV/')) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(esvChapter) });
return buildFetchMock()(url);
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /reading plans/i }));
await user.click(await screen.findByRole('button', { name: /continue reading/i }));
// The plan strip is up, on the plan's translation, and the chapter came from the ESV route
expect(await screen.findByText(/In the beginning was the Word/)).toBeInTheDocument();
expect(screen.getAllByText(/The Gospels in 30 Days · Day 1/).length).toBeGreaterThan(0);
expect(screen.getByText('ESV')).toBeInTheDocument(); // plan strip shows the plan's translation
expect(fetchMock.mock.calls.some(([u]) => String(u).startsWith('/api/bible/ESV/MAT/1'))).toBe(true);
// Leaving the reader ends the plan context: the reader's own translation applies again
await user.click(screen.getByRole('button', { name: /back to home/i }));
await user.click(screen.getByRole('button', { name: /read bible/i }));
expect(await screen.findByText(/Matthew 1 · BSB/)).toBeInTheDocument();
});
test('a reading plan can be started from the Reading Plans page', async () => {
seedProject(); // the nav button shows once there's at least one project
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /reading plans/i }));
// Tap a plan in the catalog, pick a translation for it, and start it.
await user.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
await user.selectOptions(screen.getByRole('combobox'), 'ESV');
await user.click(screen.getByRole('button', { name: /^start plan$/i }));
await waitFor(() => {
const stored = JSON.parse(localStorage.getItem('readingPlanProgress'));
expect(stored?.progress?.[0]?.planId).toBe('gospels-30');
expect(stored?.progress?.[0]?.translation).toBe('ESV');
});
});
});
// ---------------------------------------------------------------------------
// Export stage (useShareLink) and the shared read-only view
// ---------------------------------------------------------------------------
describe('Project header and sharing', () => {
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
test('Export stage → Markdown downloads a file', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^export$/i }));
await user.click(await screen.findByRole('button', { name: /export markdown/i }));
expect(URL.createObjectURL).toHaveBeenCalled();
});
test('enabling sharing shows a copyable link', async () => {
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (url.startsWith('/api/projects/') && url.endsWith('/share')) {
return (opts.method ?? 'GET') === 'POST' ? json(200, { shareToken: 'tok123' }) : json(200, { shareToken: null });
}
return fallback(url, opts);
});
vi.stubGlobal('fetch', fetchMock);
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^export$/i }));
await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
});
test('a ?share= link shows the read-only view without signing in', async () => {
window.history.pushState({}, '', '/?share=abc');
const fallback = buildFetchMock();
vi.stubGlobal('fetch', vi.fn((url, opts) => (url === '/api/share/abc'
? json(200, { id: 's1', title: 'Shared Titus', translation: 'BSB', chapters: [] })
: fallback(url, opts))));
try {
render(<App />);
expect(await screen.findByText(/read-only shared view/i)).toBeInTheDocument();
expect(screen.getByTitle('Shared study')).toBeInTheDocument();
} finally {
window.history.pushState({}, '', '/');
}
});
});
// ---------------------------------------------------------------------------
// Study editor (StudyPage local state + useCrossRefs / useWordStudy hooks)
// ---------------------------------------------------------------------------
describe('Study editor', () => {
test('study-page messages now show as a status toast', async () => {
await goToStudyWith3Chunks();
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
});
test('arrow keys move between chunks', async () => {
await goToStudyWith3Chunks();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
});
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('tab', { name: /cross-refs/i }));
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await screen.findByText('Rom 3:23')).toBeInTheDocument();
fireEvent.change(input, { target: { value: 'half typed' } });
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
await waitFor(() => expect(input).toHaveValue(''));
});
test('a tag can be added with Enter', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /^more/i }));
const input = screen.getByPlaceholderText(/add a tag/i);
fireEvent.change(input, { target: { value: 'grace' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await screen.findByText(/#?grace/)).toBeInTheDocument();
expect(input).toHaveValue('');
});
});
// ---------------------------------------------------------------------------
// Chunk builder (useChunkBuilder, local to SetupPage)
// ---------------------------------------------------------------------------
describe('Chunk builder', () => {
async function addSecondChapter(user) {
await user.click(screen.getByRole('button', { name: /add chapter/i }));
// The add-chapter form starts on the chapter after the project's last one.
expect(screen.getByDisplayValue('2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /load chapter/i }));
return screen.findByRole('button', { name: 'Titus 2' });
}
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
const user = await loadChapter();
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
});
test('the Chunks stage reopens the chapter of the chunk being studied', async () => {
const user = await loadChapter();
await addSecondChapter(user);
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
await user.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
await user.click(screen.getByRole('button', { name: /^chunks$/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
});
});
// ---------------------------------------------------------------------------
// Stage bar (Chunks / Study / Outline / Script / Export)
// ---------------------------------------------------------------------------
describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => {
await loadChapter();
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled();
}
expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
});
test('Script stage opens the Pronunciation Guide, and back returns to Script', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^script$/i }));
await user.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /back to script/i }));
expect(await screen.findByLabelText(/final script/i)).toBeInTheDocument();
});
test('a script written in the Script stage is kept on the chunk', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^script$/i }));
const script = await screen.findByLabelText(/final script/i);
fireEvent.change(script, { target: { value: 'Welcome to the episode.' } });
await user.click(screen.getByRole('button', { name: /^study$/i }));
await user.click(screen.getByRole('button', { name: /^script$/i }));
expect(await screen.findByDisplayValue('Welcome to the episode.')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Study workspace: outline editing, notes accordion, and the resource tabs
// ---------------------------------------------------------------------------
describe('Study workspace', () => {
const okJson = (data) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data), headers: { get: () => 'application/json' } });
// Wraps the default mock so individual tests can answer specific URLs.
const withRoutes = (routes) => {
const base = buildFetchMock();
return vi.fn((url, opts) => {
for (const [needle, data] of routes) if (String(url).includes(needle)) return okJson(data);
return base(url, opts);
});
};
async function studyOneChunk(routes = []) {
if (routes.length) vi.stubGlobal('fetch', withRoutes(routes));
const user = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
return user;
}
test('outline: clicking between verses ends a chunk; clicking again joins them', async () => {
const user = await loadChapter();
await user.click(screen.getByRole('button', { name: /end a chunk after verse 1/i }));
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /end a chunk after verse 2/i }));
await screen.findByText('2 created');
await user.click(screen.getByRole('button', { name: /join chunks at verse 1/i }));
await screen.findByText('1 created');
expect((await screen.findAllByText(/1[-–]2/)).length).toBeGreaterThan(0);
});
test('outline: "Suggest from headings" fills the chapter, and never touches existing chunks', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /suggest from headings/i }));
await screen.findByText('2 created');
expect(screen.getByRole('status')).toHaveTextContent(/added 1 chunk/i);
});
test('notes: one section is open at a time, and text is kept when switching', async () => {
const user = await studyOneChunk();
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul calls himself a servant.' } });
await user.click(screen.getByRole('button', { name: /interpret/i }));
expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /observe/i }));
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Paul calls himself a servant.');
});
test('notes: the outline dots and word count reflect what you wrote', async () => {
await studyOneChunk();
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'one two three' } });
expect(screen.getByText('3 words')).toBeInTheDocument();
});
test('interlinear: clicking an original word adds it to the word study', async () => {
const user = await studyOneChunk([['/interlinear/TIT.json', { 1: { 1: [{ o: 'Παῦλος', t: 'Paulos', g: 'Paul', s: 'G3972' }] } }]]);
await user.click(await screen.findByRole('button', { name: '1' })); // verse number opens its original words
await user.click(await screen.findByRole('button', { name: /Παῦλος/ }));
expect(await screen.findByRole('status')).toHaveTextContent(/added/i);
expect(screen.getByRole('tab', { name: /word study · 1/i })).toBeInTheDocument();
expect(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Παῦλος/ })).toBeInTheDocument(); // the new card
});
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
await user.click(screen.getByRole('button', { name: /^more/i }));
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
});
test('people & places: lists what is tagged in the chunk', async () => {
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
await user.click(screen.getByRole('tab', { name: /people & places/i }));
const panel = screen.getByRole('tabpanel');
expect(await within(panel).findByRole('button', { name: /paul/i })).toBeInTheDocument();
expect(within(panel).queryByText('Elsewhere')).not.toBeInTheDocument();
});
test('script: "Build outline from notes" starts the script from your notes', async () => {
const user = await studyOneChunk();
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul greets Titus.' } });
await user.click(screen.getByRole('button', { name: /^script$/i }));
await user.click(await screen.findByRole('button', { name: /build outline from notes/i }));
const script = screen.getByLabelText(/final script/i);
expect(script.value).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.');
expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// App bar and study layout
// ---------------------------------------------------------------------------
describe('App bar', () => {
test('shows the three destinations, and the account menu switches the theme', async () => {
const user = userEvent.setup();
await renderApp();
expect(screen.getByRole('button', { name: /^studies$/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: /read bible/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reading plans/i })).toBeInTheDocument();
await openAccountMenu(user);
await user.click(screen.getByRole('button', { name: /^dark$/i }));
expect(localStorage.getItem('reader-theme')).toBe('dark');
// Local-only mode has no signed-in user, so there is no Settings/Log out to show
expect(screen.queryByRole('menuitem', { name: /log out/i })).not.toBeInTheDocument();
});
test('the account menu closes on Escape', async () => {
const user = userEvent.setup();
await renderApp();
await openAccountMenu(user);
expect(screen.getByRole('button', { name: /^paper$/i })).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('button', { name: /^paper$/i })).not.toBeInTheDocument();
});
test('the brand button and Studies return Home from another page', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
await screen.findByRole('heading', { name: /import session list/i });
await user.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
});
describe('Study layout', () => {
test('notes sit in their own box above the resources, not under the passage', async () => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
const notes = screen.getByRole('region', { name: 'Notes' });
const resources = screen.getByRole('region', { name: 'Resources' });
expect(within(notes).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
expect(within(resources).getByRole('tab', { name: /commentary/i })).toBeInTheDocument();
// notes come first in the right column
expect(notes.compareDocumentPosition(resources) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// and the passage column no longer holds them
expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
});
});
describe('Reading plan with more days than chapters', () => {
test('Continue reading opens the plan on a real chapter instead of stranding the Reader', async () => {
localStorage.setItem('readingPlanProgress', JSON.stringify({
customPlans: [{ id: 'custom-luke', title: 'Luke', days: 90, books: ['LUK'], custom: true }],
progress: [{ id: 'rp1', planId: 'custom-luke', startedAt: Date.now(), completedDays: [0, 1], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now() }],
activity: [],
}));
const user = userEvent.setup();
await renderApp();
await user.click(await screen.findByRole('button', { name: /continue reading/i }));
expect(await screen.findByText(/Luke 3 · BSB/)).toBeInTheDocument();
expect(screen.getAllByText(/Luke · Day 3/).length).toBeGreaterThan(0);
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Bookmarks and highlights follow the account
// ---------------------------------------------------------------------------
describe('Bookmark and highlight sync', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 5, color: '#fef08a', ...over });
// A fake API that keeps the account's marks, like the real server does.
function stubServer(initial = { bookmarks: {}, highlights: [], updatedAt: 0 }) {
const server = { ...initial, puts: [], prefs: initial.prefs ?? {}, prefPuts: [] };
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, user);
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: server.bookmarks, highlights: server.highlights, updatedAt: server.updatedAt });
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
if (route === 'PUT /api/prefs') {
const patch = JSON.parse(opts.body).prefs;
server.prefPuts.push(patch);
server.prefs = { ...server.prefs, ...patch };
return json(200, { prefs: server.prefs });
}
if (route === 'PUT /api/reader/marks') {
const body = JSON.parse(opts.body);
server.puts.push(body);
server.bookmarks = body.bookmarks;
server.highlights = body.highlights;
server.updatedAt = Math.max(Date.now(), server.updatedAt + 1);
return json(200, { ok: true, updatedAt: server.updatedAt });
}
return json(404, { error: 'not mocked' });
});
vi.stubGlobal('fetch', fetchMock);
return server;
}
const local = (key) => JSON.parse(localStorage.getItem(key) ?? 'null');
test('a new device gets the account\'s bookmarks and highlights when you sign in', async () => {
stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
await renderApp();
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
expect(local('readerTextHighlights')).toEqual([hl()]);
});
test('marks already on this device are saved to an account that has none yet', async () => {
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
const server = stubServer();
await renderApp();
await waitFor(() => expect(server.puts).toHaveLength(1));
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0' });
});
test('marks on both sides are merged, and the merge is saved back', async () => {
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [], updatedAt: 500 });
await renderApp();
await waitFor(() => expect(server.puts).toHaveLength(1));
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
expect(local('reader-bookmarks')).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
});
test('a device that is up to date takes the newer server copy, dropping a removed bookmark', async () => {
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
localStorage.setItem('reader-marks-synced-at', '100');
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 900 }); // removed on another device
await renderApp();
await waitFor(() => expect(local('reader-bookmarks')).toEqual({}));
expect(server.puts).toHaveLength(0); // nothing to send back
});
test('bookmarking a verse in the Reader saves it to the account', async () => {
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 500 });
localStorage.setItem('reader-marks-synced-at', '500');
const u = userEvent.setup();
await renderApp();
await u.click(screen.getByRole('button', { name: /read bible/i }));
await u.click(await screen.findByText(/Paul, a servant of God/));
await u.click(await screen.findByRole('button', { name: /bookmark verse/i }));
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
expect(Object.keys(server.puts[0].bookmarks)).toEqual(['GEN-1-1']);
});
test('signing out clears this device\'s copy so the next person starts clean', async () => {
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
await waitFor(() => expect(localStorage.getItem('reader-marks-synced-at')).toBeNull());
expect(local('reader-bookmarks')).toEqual({});
expect(local('readerTextHighlights')).toEqual([]);
expect(server.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' }); // still safe on the account
});
});
describe('Preference sync', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
function stubPrefsServer(prefs = {}) {
const server = { prefs, puts: [] };
const fallback = buildFetchMock();
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, user);
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
if (route === 'PUT /api/prefs') {
const patch = JSON.parse(opts.body).prefs;
server.puts.push(patch);
server.prefs = { ...server.prefs, ...patch };
return json(200, { prefs: server.prefs });
}
return json(404, { error: 'not mocked' });
}));
return server;
}
test('signing in on a new device brings your theme, translation and place in the Reader', async () => {
stubPrefsServer({
theme: { v: 'dark', at: 900 }, translation: { v: 'NASB', at: 900 }, layout: { v: 'verses', at: 900 },
position: { v: { book: 'JHN', chapter: 3 }, at: 900 },
});
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(localStorage.getItem('reader-theme')).toBe('dark'));
expect(localStorage.getItem('reader-translation')).toBe('NASB');
expect(localStorage.getItem('reader-layout')).toBe('verses');
await u.click(screen.getByRole('button', { name: /read bible/i }));
expect(await screen.findByText(/John 3 · NASB/)).toBeInTheDocument();
});
test('changing the theme is saved to the account', async () => {
const server = stubPrefsServer({ theme: { v: 'paper', at: 10 } });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0)); // initial fill-in of the other keys
server.puts.length = 0;
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(screen.getByRole('button', { name: /^sepia$/i }));
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
expect(server.puts[0].theme.v).toBe('sepia');
expect(Object.keys(server.puts[0])).toEqual(['theme']); // only what changed
});
test('the commentary you pick is shared between the Reader and the Study stage', async () => {
stubPrefsServer({ commentarySource: { v: 'tyndale', at: 900 } });
await renderApp();
await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale'));
});
async function openSettings(u) {
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
return screen.findByRole('region', { name: 'Preferences' });
}
test('Settings shows your preferences, and choosing a default Bible saves it and syncs it', async () => {
const server = stubPrefsServer({ translation: { v: 'BSB', at: 10 } });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
server.puts.length = 0;
const prefs = within(await openSettings(u));
expect(prefs.getByLabelText('Default Bible')).toHaveValue('BSB');
await u.selectOptions(prefs.getByLabelText('Default Bible'), 'ESV');
expect(localStorage.getItem('reader-translation')).toBe('ESV');
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
expect(server.puts[0].translation.v).toBe('ESV');
});
test('theme, verse layout, commentary and new-plan Bible can all be set from Settings', async () => {
stubPrefsServer();
const u = userEvent.setup();
await renderApp();
const prefs = within(await openSettings(u));
await u.click(within(prefs.getByRole('group', { name: 'Theme' })).getByRole('button', { name: 'Dark' }));
await u.click(within(prefs.getByRole('group', { name: 'Verse layout' })).getByRole('button', { name: 'One per line' }));
await u.selectOptions(prefs.getByLabelText('Commentary'), 'tyndale');
await u.selectOptions(prefs.getByLabelText('New reading plans'), 'NLT');
expect(localStorage.getItem('reader-theme')).toBe('dark');
expect(localStorage.getItem('reader-layout')).toBe('verses');
expect(localStorage.getItem('reader-commentary')).toBe('tyndale');
expect(localStorage.getItem('plan-translation')).toBe('NLT');
});
test('text size, reading width and narrator are remembered on this device (not synced)', async () => {
const server = stubPrefsServer();
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
server.puts.length = 0;
const prefs = within(await openSettings(u));
await u.click(within(prefs.getByRole('group', { name: 'Text size' })).getByRole('button', { name: 'XL' }));
await u.click(within(prefs.getByRole('group', { name: 'Reading width' })).getByRole('button', { name: 'Two columns' }));
await u.selectOptions(prefs.getByLabelText('Audio narrator'), 'hays');
expect(localStorage.getItem('reader-font-size')).toBe('1.25');
expect(localStorage.getItem('reader-wide')).toBe('1');
expect(localStorage.getItem('audio-narrator')).toBe('hays');
await new Promise((r) => setTimeout(r, 1500));
expect(server.puts).toHaveLength(0); // per-device settings stay off the account
});
test('Settings leads back to the dashboard', async () => {
stubPrefsServer();
const u = userEvent.setup();
await renderApp();
await openSettings(u);
await u.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('a new reading plan starts in the Bible chosen for new plans', async () => {
localStorage.setItem('plan-translation', 'CSB');
stubPrefsServer({ planTranslation: { v: 'CSB', at: 10 } });
const u = userEvent.setup();
await renderApp();
await u.click(screen.getByRole('button', { name: /reading plans/i }));
await u.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
expect(screen.getByRole('combobox')).toHaveValue('CSB');
});
test('signing out forgets the sync timestamps', async () => {
stubPrefsServer({ theme: { v: 'dark', at: 900 } });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(localStorage.getItem('prefs-meta')).not.toBeNull());
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull());
});
});
// ---------------------------------------------------------------------------
// Every page has a way back to the dashboard
// ---------------------------------------------------------------------------
describe('Back to the dashboard', () => {
const home = /back to home|bible study home/i;
const dashboard = async () => expect(await screen.findByText('My Studies')).toBeInTheDocument();
test.each([
['the Reader', async (u) => { await u.click(screen.getByRole('button', { name: /read bible/i })); await screen.findByText(/Genesis 1 · BSB/); }],
['Reading Plans', async (u) => { await u.click(screen.getByRole('button', { name: /reading plans/i })); await screen.findByText(/browse plans/i); }],
['Import', async (u) => { await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); }],
])('%s', async (_name, open) => {
const u = userEvent.setup();
await renderApp();
await open(u);
await u.click(screen.getByRole('button', { name: home }));
await dashboard();
});
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: new RegExp(`^${stage}$`, 'i') }));
await screen.findByRole('navigation', { name: /study stages/i });
await u.click(screen.getByRole('button', { name: home }));
await dashboard();
});
test('the Pronunciation guide', async () => {
const u = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /^script$/i }));
await u.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
await screen.findByRole('heading', { name: /pronunciation guide/i });
await u.click(screen.getByRole('button', { name: home }));
await dashboard();
});
});
// ---------------------------------------------------------------------------
// The home page the user chooses
// ---------------------------------------------------------------------------
describe('Chosen home page', () => {
const homeBtn = /back to home|bible study home/i;
test('the app opens on the chosen page', async () => {
localStorage.setItem('start-page', 'reader');
render(<App />);
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
expect(screen.queryByText('My Studies')).not.toBeInTheDocument();
});
test('by default the app opens on My Studies', async () => {
await renderApp();
expect(screen.getByText('My Studies')).toBeInTheDocument();
});
test('Home buttons go to the chosen page, and to My Studies when you are already on it', async () => {
localStorage.setItem('start-page', 'reader');
const u = userEvent.setup();
render(<App />);
await screen.findByText(/Genesis 1 · BSB/);
// already on the chosen page: Home means My Studies
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
// from another page, Home is the chosen page
await u.click(screen.getByRole('button', { name: /reading plans/i }));
await screen.findByRole('heading', { name: /^reading plans$/i });
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
});
test('the logo on a page with an app bar goes to the chosen page, and Studies always goes to My Studies', async () => {
localStorage.setItem('start-page', 'plans');
const u = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /^reading plans$/i }); // opened on Reading Plans
await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies
await screen.findByText('My Studies');
await u.click(screen.getByRole('button', { name: /import session list/i }));
await screen.findByRole('heading', { name: /import session list/i });
await u.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again
await u.click(screen.getByRole('button', { name: /import session list/i }));
await u.click(screen.getByRole('button', { name: /^studies$/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('leaving a study with a different home page closes the study', async () => {
localStorage.setItem('start-page', 'plans');
const u = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /^reading plans$/i });
await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies
await u.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
});
test('Settings changes it, and it is saved for next time', async () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
const fallback = buildFetchMock();
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, user);
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
if (route === 'GET /api/prefs') return json(200, { prefs: {} });
if (route === 'PUT /api/prefs') return json(200, { prefs: {} });
return json(404, { error: 'not mocked' });
}));
const u = userEvent.setup();
await renderApp();
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
expect(localStorage.getItem('start-page')).toBe('plans');
await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
});
});
describe('Resizable study column', () => {
async function openStudy() {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
return u;
}
const columns = () => screen.getByRole('separator', { name: /resize side panel/i }).closest('aside').parentElement;
test('the handle widens and narrows the right column and the choice is remembered', async () => {
const u = await openStudy();
const handle = screen.getByRole('separator', { name: /resize side panel/i });
expect(columns().style.getPropertyValue('--study-right')).toBe(''); // default width
handle.focus();
await u.keyboard('{ArrowLeft}');
expect(columns().style.getPropertyValue('--study-right')).toBe('472px');
expect(localStorage.getItem('study-right-width')).toBe('472');
await u.keyboard('{Shift>}{ArrowRight}{/Shift}');
expect(columns().style.getPropertyValue('--study-right')).toBe('376px');
});
test('double-clicking the handle restores the default width', async () => {
const u = await openStudy();
const handle = screen.getByRole('separator', { name: /resize side panel/i });
handle.focus();
await u.keyboard('{ArrowLeft}');
await u.dblClick(handle);
expect(localStorage.getItem('study-right-width')).toBeNull();
expect(columns().style.getPropertyValue('--study-right')).toBe('');
});
test('a saved width is applied when the study opens', async () => {
localStorage.setItem('study-right-width', '560');
await openStudy();
expect(columns().style.getPropertyValue('--study-right')).toBe('560px');
});
});
describe('Import template', () => {
test('the Import page offers a Word template to download', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
await user.click(await screen.findByRole('button', { name: /download template/i }));
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalled());
const blob = URL.createObjectURL.mock.calls.at(-1)[0];
expect(blob.type).toMatch(/wordprocessingml/);
// and the button comes back to normal afterwards
expect(await screen.findByRole('button', { name: /download template/i })).toBeEnabled();
});
});
describe('System chrome follows the theme', () => {
test('the app tells the browser/OS the theme colour and scheme, and updates it when the theme changes', async () => {
localStorage.setItem('reader-theme', 'dark');
const user = userEvent.setup();
await renderApp();
const themeColor = () => document.head.querySelector('meta[name="theme-color"]')?.getAttribute('content');
expect(themeColor()).toBe('#262421');
expect(document.documentElement.style.colorScheme).toBe('dark');
await user.click(screen.getByRole('button', { name: /account menu/i }));
await user.click(screen.getByRole('button', { name: /^paper$/i }));
expect(themeColor()).toBe('#f1ece0');
expect(document.documentElement.style.colorScheme).toBe('light');
});
});
describe('New pages start at the top', () => {
test('moving to another page resets the scroll position', async () => {
const user = userEvent.setup();
await renderApp();
const scroller = document.scrollingElement ?? document.documentElement;
scroller.scrollTop = 480; // scrolled down on the dashboard
await user.click(screen.getByRole('button', { name: /reading plans/i }));
await screen.findByRole('heading', { name: /^reading plans$/i });
expect(scroller.scrollTop).toBe(0);
scroller.scrollTop = 300;
await user.click(screen.getByRole('button', { name: /back to home|bible study home/i }));
await screen.findByText('My Studies');
expect(scroller.scrollTop).toBe(0);
});
});