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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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);
});
});