// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups. import { render, screen, waitFor, within, fireEvent } 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'; import { resetSyncBases } from './utils/syncBase.js'; const chapterData = { chapter: { content: [ { type: 'verse', number: 1, content: ['In the beginning God created.'] }, { type: 'verse', number: 2, content: ['The earth was without form.'] }, ] }, }; const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); // A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call. function stubServer({ user = null, putStatus = 200 } = {}) { const calls = []; const state = { putStatus, outlineToken: null }; vi.stubGlobal('fetch', vi.fn((url, opts = {}) => { const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`; calls.push({ route, url, opts }); if (url.includes('bible.helloao.org')) return json(200, chapterData); if (!url.startsWith('/api/')) return Promise.resolve({ ok: false }); if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' }); if (route === 'GET /api/projects') return json(200, []); if (route === 'GET /api/reader/ink') return json(200, {}); if (route.endsWith('/outline-share')) { if (opts.method === 'POST') state.outlineToken = 'abc123'; if (opts.method === 'DELETE') state.outlineToken = null; return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null }); } if (route === 'GET /api/share-outline/abc123') { return json(200, { title: 'Titus', translation: 'BSB', outlines: [ { scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] }, { scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] }, ] }); } if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' }); if (route.startsWith('PUT /api/projects/')) { return state.putStatus === 409 ? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 }) : json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' }); } return json(404, { error: 'not mocked' }); })); return { calls, state }; } beforeEach(() => { localStorage.clear(); resetLexiconCaches(); resetSyncBases(); switchStorageUser(null); vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false }))); vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock'); vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); async function renderApp() { render(); await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); } 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)); } describe('Back button', () => { test('moves back through the pages you visited instead of leaving the app', async () => { const user = userEvent.setup(); await renderApp(); expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /reading plans/i })); expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument(); window.history.back(); expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument(); window.history.forward(); expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument(); }); }); const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox'); describe('Search palette (⌘K)', () => { test('a typed passage goes straight to the Bible reader', async () => { const user = userEvent.setup(); await renderApp(); await user.keyboard('{Control>}k{/Control}'); await user.type(await paletteBox(), 'john 3:16'); expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument(); await user.keyboard('{Enter}'); expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument(); expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument(); }); test('finds a note you wrote and opens that study', async () => { seedProject({ observation: 'Grace abounds to all.' }); const user = userEvent.setup(); await renderApp(); await user.keyboard('{Control>}k{/Control}'); await user.type(await paletteBox(), 'abounds'); const hit = await screen.findByRole('option', { name: /Titus 1:1/ }); await user.click(hit); expect(await screen.findByText(/Your notes/i)).toBeInTheDocument(); }); test('Escape closes it', async () => { const user = userEvent.setup(); await renderApp(); await user.keyboard('{Control>}k{/Control}'); await paletteBox(); await user.keyboard('{Escape}'); expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument(); }); test('? lists the keyboard shortcuts', async () => { const user = userEvent.setup(); await renderApp(); await user.keyboard('?'); expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument(); }); }); describe('Undo', () => { test('deleting a project, then Undo, keeps its notes', async () => { seedProject(); const user = userEvent.setup(); await renderApp(); await user.click(screen.getByRole('button', { name: /^delete$/i })); expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i); await user.click(screen.getByRole('button', { name: /undo/i })); expect(await screen.findByText('Titus Notes')).toBeInTheDocument(); expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.'); }); }); describe('Sync conflict', () => { const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false }; async function startStudy(u) { await renderApp(); await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]); await u.click(await screen.findByRole('button', { name: /load chapter/i })); await screen.findByText(/Scripture & Chunks/i); } test('a save the server refuses shows the choice instead of overwriting', async () => { const server = stubServer({ user, putStatus: 409 }); const u = userEvent.setup(); await startStudy(u); const banner = await screen.findByRole('alert', {}, { timeout: 4000 }); expect(banner).toHaveTextContent(/changed on another device/i); expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument(); expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument(); // "Keep this version" sends the save again, this time as an overwrite server.state.putStatus = 200; await u.click(within(banner).getByRole('button', { name: /keep this version/i })); await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true)); await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument()); }, 15000); test('opening a study without editing it does not touch the server', async () => { seedProject(); const server = stubServer({ user }); const u = userEvent.setup(); await renderApp(); // signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0); await u.click(await screen.findByRole('button', { name: /read bible/i })); expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0); }); }); describe('Outline stage', () => { async function openOutline() { // local-only mode (the account server is unreachable), with chapter text available vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false })))); const user = userEvent.setup(); await renderApp(); await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]); await user.click(await screen.findByRole('button', { name: /load chapter/i })); await screen.findByText(/Scripture & Chunks/i); // one chunk covering verses 1-2 const verses = within(screen.getByTestId('verse-list')); fireEvent.click(verses.getByRole('button', { name: /In the beginning/ })); fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true }); await screen.findByText('1 created'); await user.click(screen.getByRole('button', { name: /^outline$/i })); await screen.findByRole('heading', { name: 'Outline' }); return user; } test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => { const user = await openOutline(); expect(screen.getByText(/no outline yet/i)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /start from my chunks/i })); const list = await screen.findByRole('list', { name: 'Outline' }); expect(within(list).getAllByRole('textbox')).toHaveLength(1); expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument(); }); test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => { const user = await openOutline(); await user.click(screen.getByRole('button', { name: /start blank/i })); const first = await screen.findByRole('textbox', { name: 'Point I.' }); await user.type(first, 'Creation{Enter}'); const second = await screen.findByRole('textbox', { name: 'Point II.' }); await user.type(second, 'Light{Tab}'); expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light'); expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation'); }); test('deleting a point can be undone', async () => { const user = await openOutline(); await user.click(screen.getByRole('button', { name: /start blank/i })); await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me'); await user.click(screen.getByRole('button', { name: /delete i\./i })); expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: /undo/i })); expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me'); }); }); describe('Outline: per chunk, details, print and share', () => { async function openOutline() { vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false })))); const user = userEvent.setup(); await renderApp(); await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]); await user.click(await screen.findByRole('button', { name: /load chapter/i })); await screen.findByText(/Scripture & Chunks/i); const verses = within(screen.getByTestId('verse-list')); fireEvent.click(verses.getByRole('button', { name: /In the beginning/ })); fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true }); await screen.findByText('1 created'); await user.click(screen.getByRole('button', { name: /^outline$/i })); await screen.findByRole('heading', { name: 'Outline' }); return user; } test('each chunk has its own outline, separate from the whole study\'s', async () => { const user = await openOutline(); const picker = screen.getByRole('combobox', { name: 'Outline for' }); await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /start from the verses/i })); const list = await screen.findByRole('list', { name: 'Outline' }); expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study'); expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count }); test('a point can be given a type and minutes, and the total shows', async () => { const user = await openOutline(); await user.click(screen.getByRole('button', { name: /start blank/i })); await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story'); await user.click(screen.getByRole('button', { name: /details for i\./i })); await user.selectOptions(screen.getByLabelText('Type'), 'illustration'); await user.type(screen.getByLabelText('Minutes'), '5'); expect(await screen.findByText('About 5 minutes')).toBeInTheDocument(); await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly'); await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line expect(await screen.findByText('Tell it slowly')).toBeInTheDocument(); expect(screen.getByText('Illustration')).toBeInTheDocument(); }); test('"Add verse text" puts the passage\'s words into the point', async () => { const user = await openOutline(); await user.click(screen.getByRole('button', { name: /start blank/i })); await user.click(await screen.findByRole('button', { name: /details for i\./i })); await user.type(screen.getByLabelText('Passage'), 'Titus 1:1'); await user.click(screen.getByRole('button', { name: /add verse text/i })); expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument(); await user.clear(screen.getByLabelText('Passage')); await user.type(screen.getByLabelText('Passage'), 'nonsense'); await user.click(screen.getByRole('button', { name: /replace verse text/i })); expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i); }); test('Share opens a sheet; with no account it offers the text, which is copied', async () => { const user = await openOutline(); await user.click(screen.getByRole('button', { name: /start from my chunks/i })); await screen.findByRole('list', { name: 'Outline' }); await user.click(screen.getByRole('button', { name: /^share$/i })); const sheet = await screen.findByRole('dialog', { name: /share this outline/i }); expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument(); await user.click(within(sheet).getByRole('button', { name: /share as text/i })); expect(await screen.findByText(/outline copied/i)).toBeInTheDocument(); }); test('Print opens a page of just the outline', async () => { const user = await openOutline(); await user.click(screen.getByRole('button', { name: /start from my chunks/i })); await screen.findByRole('list', { name: 'Outline' }); const doc = { write: vi.fn(), close: vi.fn() }; vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() }); await user.click(screen.getByRole('button', { name: /print \/ pdf/i })); expect(doc.write.mock.calls[0][0]).toContain('class="outline"'); }); }); describe('Read-only outline link', () => { const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false }; test('create the link, see it, and turn it off', async () => { const server = stubServer({ user }); const u = userEvent.setup(); await renderApp(); await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]); await u.click(await screen.findByRole('button', { name: /load chapter/i })); await screen.findByText(/Scripture & Chunks/i); const verses = within(screen.getByTestId('verse-list')); fireEvent.click(verses.getByRole('button', { name: /In the beginning/ })); fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true }); await screen.findByText('1 created'); await u.click(screen.getByRole('button', { name: /^outline$/i })); await u.click(await screen.findByRole('button', { name: /start from my chunks/i })); await u.click(await screen.findByRole('button', { name: /^share$/i })); const sheet = await screen.findByRole('dialog', { name: /share this outline/i }); await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i })); const box = await within(sheet).findByLabelText('Outline link'); expect(box.value).toMatch(/\?outline=abc123$/); expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true); await u.click(within(sheet).getByRole('button', { name: /turn off link/i })); expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument(); }, 15000); }); describe('Opening a read-only outline link', () => { afterEach(() => window.history.replaceState({}, '', '/')); test('shows the outline with no sign-in and a way to switch between outlines', async () => { stubServer({ user: null }); window.history.replaceState({}, '', '/?outline=abc123'); render(); const frame = await screen.findByTitle('Shared outline'); expect(frame.getAttribute('srcdoc')).toContain('Appoint elders'); expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument(); await userEvent.setup().selectOptions(screen.getByRole('combobox', { name: 'Outline' }), 'c1'); expect(screen.getByTitle('Shared outline').getAttribute('srcdoc')).toContain('Greeting'); expect(screen.getByTitle('Shared outline').getAttribute('srcdoc')).toContain('Titus — Titus 1:1–4'); }); test('a turned-off or wrong link says so', async () => { stubServer({ user: null }); window.history.replaceState({}, '', '/?outline=nope'); render(); expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument(); }); }); describe('Study cards on Home', () => { test('a long run of chapters and many tags stay short', async () => { const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', '); const tags = ['A', 'B', 'C', 'D', 'E', 'F']; localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }])); await renderApp(); expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument(); expect(screen.getByText('+3 more')).toBeInTheDocument(); expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter }); }); describe('Window title', () => { test('names the page you are on, including the chapter in the reader', async () => { const user = userEvent.setup(); await renderApp(); expect(document.title).toBe('My Studies · Bible Study'); await user.click(screen.getByRole('button', { name: /reading plans/i })); await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study')); await user.click(screen.getByRole('button', { name: /read bible/i })); await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/)); }); });