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