Study - New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports); the chunk stage is now "Chunks" - Home cards: passages shown as ranges, tags capped with "+N more" Reader - Side-by-side translations and a Compare tab per verse - Typeface, line spacing, verse-number settings; text and column scale with the screen - Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix - Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help Flow and safety - Toasts with Undo replace most confirm()/alert() - Back/Forward move between pages - Server refuses saves over a newer copy (409); keep-mine / use-other banner; opening a study no longer re-stamps it - Latest chapter request wins when flipping quickly Offline and data - Save books, plan days or a whole BSB/NET for offline - Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late - Settings: download/restore a full backup Accessibility - Sheets trap focus and close on Escape, skip link, focus ring, reduced motion, theme contrast raised to 4.5:1 (with a test) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1442 lines
70 KiB
React
1442 lines
70 KiB
React
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||
import App from './App';
|
||
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared mock data
|
||
// ---------------------------------------------------------------------------
|
||
const mockChapterData = {
|
||
chapter: {
|
||
content: [
|
||
{ type: 'verse', number: 1, content: ['Paul, a servant of God.'] },
|
||
{ type: 'verse', number: 2, content: ['In hope of eternal life.'] },
|
||
{ type: 'verse', number: 3, content: ['Grace and peace from God.'] },
|
||
],
|
||
},
|
||
};
|
||
|
||
// Mirrors the OpenScriptures Strong's Greek dictionary format loaded from jsdelivr.
|
||
const mockGreekDict = {
|
||
G4102: {
|
||
lemma: 'πίστις',
|
||
translit: 'pistis',
|
||
kjv_def: 'faith, belief',
|
||
strongs_def: 'persuasion, i.e. credence; moral conviction',
|
||
},
|
||
};
|
||
|
||
function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) {
|
||
return vi.fn((url) => {
|
||
if (url.includes('available_translations')) {
|
||
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB', 'KJV']) });
|
||
}
|
||
if (url.includes('bible.helloao.org')) {
|
||
return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) });
|
||
}
|
||
if (url.includes('strongs-greek-dictionary') && greekData !== null) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
text: () => Promise.resolve(`var strongsGreekDictionary = ${JSON.stringify(greekData)};`),
|
||
});
|
||
}
|
||
return Promise.resolve({ ok: false });
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test lifecycle
|
||
// ---------------------------------------------------------------------------
|
||
beforeEach(() => {
|
||
localStorage.clear();
|
||
resetLexiconCaches();
|
||
switchStorageUser(null); // a signed-in test leaves the storage namespace switched
|
||
vi.stubGlobal('fetch', buildFetchMock());
|
||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// App checks /api/auth/me on mount before deciding whether to show the app or
|
||
// a login gate. In tests the mock fetch reports the server as unreachable, so
|
||
// it falls back to local-only mode — but that still takes a tick to resolve.
|
||
async function renderApp() {
|
||
render(<App />);
|
||
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||
}
|
||
|
||
// 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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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(<App />);
|
||
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);
|
||
});
|
||
});
|