import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import App from '../App'; import { vi, describe, test, expect } from 'vitest'; import { installAppLifecycle, buildFetchMock, renderApp, loadChapter, addChunk, findChunkCounter } from './appHarness.jsx'; installAppLifecycle(); // --------------------------------------------------------------------------- // 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); // each page is lazy-loaded, so its app bar may take a moment to replace the loading skeleton await u.click(await screen.findByRole('button', { name: home })); await dashboard(); }); test.each(['Chunks', 'Study', 'Outline', 'Script', 'Finish'])('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 })); await u.click(await screen.findByRole('tab', { name: 'Preferences' })); 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); }); });