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()); } 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('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(); 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(); 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(); 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(); 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(); 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(); 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(); 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')); }); }); // --------------------------------------------------------------------------- // Project header (ProjectHeader + 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 → Markdown downloads a file', async () => { const user = await loadChapter(); await user.click(screen.getByRole('button', { name: /export/i })); await user.click(screen.getByRole('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(); await user.click(screen.getByRole('button', { name: /share/i })); await user.click(screen.getByRole('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('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(); 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(); 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)).toHaveClass('bg-slate-900'); }); test('"Back to Chunks" 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 screen.findByText(/chunk editor/i); await user.click(screen.getByRole('button', { name: /back to chunks/i })); expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900'); }); });