Files
study-bible-project/src/App.test.jsx
T
nmemmertandClaude Opus 5.5 a7e00a76b5 Split Home: local UI state, useProjectLibrary, useReadingPlan
- Search, tag filter, full-text note search, and rename state live in
  HomePage. The sort choice is remembered in localStorage.
- useProjectLibrary owns opening/renaming/deleting projects and the
  server reconcile (auto-restore + newer-on-server list).
- useReadingPlan owns the reading plan shared by Home and Reader.
- Tests: note search, rename, delete, sort memory, reading plan. Test
  setup now resets the storage namespace between tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:52:46 -04:00

589 lines
25 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());
}
async function loadChapter() {
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(/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('Project Setup')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
});
test('setup form shows translation, book, and chapter inputs', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Translation')).toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument();
expect(screen.getByText('Chapter')).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');
await screen.findByText(/1[-–]3/);
});
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 screen.findByText(/chunk editor/i);
// 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 screen.findByText(/chunk editor/i);
});
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 screen.findByText(/chunk editor/i);
fireEvent.click(screen.getByRole('button', { name: /add greek 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();
expect(screen.getByDisplayValue('faith, belief')).toBeInTheDocument();
});
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);
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 }));
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 />);
expect(await screen.findByText(user.email)).toBeInTheDocument();
await u.click(screen.getByRole('button', { 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 u.click(await screen.findByRole('button', { 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 u.click(await screen.findByRole('button', { 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 u.click(await screen.findByRole('button', { 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('non-admins get no Admin button', async () => {
stubAdminApi({ ...admin, isAdmin: false });
render(<App />);
expect(await screen.findByText(admin.email)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Import page (state is local to ImportPage)
// ---------------------------------------------------------------------------
describe('Import page', () => {
test('opens from Home, lists fetched translations, 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(await screen.findByRole('option', { name: 'KJV' })).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 after confirmation', async () => {
seedProject();
window.confirm.mockReturnValue(true);
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();
});
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 studies/i }));
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
});
test('a reading plan can be started from Home', async () => {
seedProject(); // the plan card shows once there's at least one project
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /set goal/i }));
await user.click(screen.getByRole('button', { name: /start plan/i }));
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN'));
});
});