import { render, screen, fireEvent, within, waitFor } from '@testing-library/react';
import { StrictMode } from 'react';
import { vi, test, expect } from 'vitest';
const noop = () => {};
const ctx = {
goHome: noop,
readerBookAbbrev: 'JHN', setReaderBookAbbrev: noop,
readerTranslation: 'ESV', setReaderTranslation: noop,
readerTheme: 'paper', setReaderTheme: noop, readerCommentary: 'matthew-henry', setReaderCommentary: noop,
readerLayout: 'flow', setReaderLayout: noop,
readerChapter: 3, setReaderChapter: noop,
readerVerses: [{ number: 1, text: 'Now there was a man of the Pharisees.' }, { number: 2, text: 'This man came to Jesus by night.' }],
readerTotalChapters: 21, readerLoading: false, readerError: '',
readerInterlinear: null, readerSelectedVerse: null, setReaderSelectedVerse: vi.fn(),
readerFontSize: 1, setReaderFontSize: noop, readerWide: false, setReaderWide: noop, readerBookmarks: {},
readerCrossRefs: null, setReaderCrossRefs: noop, readerCrossRefsLoading: false,
readerShowCrossRefs: false, setReaderShowCrossRefs: noop,
readerSearch: '', setReaderSearch: noop, setReaderSearchActive: noop,
readerSearchScope: 'chapter', setReaderSearchScope: noop,
readerAudioState: { status: 'idle' }, audioProgress: { currentTime: 0, duration: 0 },
bibleIndexStatusFor: () => 'idle', audioNarrator: 'david', setAudioNarrator: noop,
_bibleIndexCacheRef: { current: {} },
readerGoToPreviousChapter: noop, readerGoToNextChapter: noop, jumpToReaderVerse: noop,
toggleReaderBookmark: noop, cycleBookmarkColor: noop, loadReaderCrossRefs: noop, loadBibleIndex: noop,
handlePlayReaderAudio: noop, handleToggleReaderAudioPause: noop, handleStopBookAudio: noop,
handleSeekAudio: noop, handleSeekAudioTo: noop,
readerInkByPage: {}, updateReaderPageInk: noop, readerTextHighlights: [], addTextHighlight: noop, removeTextHighlight: noop,
planProgress: [], toggleDay: vi.fn(), toggleChapterRead: vi.fn(), readingActivity: [],
readerPlan: null, setReaderPlan: vi.fn(), openPlanReading: vi.fn(), studyReadingPlanDay: vi.fn(), studyBuildBusy: false,
setReaderBookAbbrev: vi.fn(), setReaderChapter: vi.fn(),
};
vi.mock('../context/AppContext.js', () => ({ useApp: () => ctx }));
import ReaderPage from './ReaderPage.jsx';
test('renders the chapter, chrome and opens the tool sheets', () => {
render();
expect(screen.getByText('John 3 · ESV')).toBeTruthy();
expect(screen.getByText(/man of the Pharisees/)).toBeTruthy();
fireEvent.click(screen.getByText('John 3 · ESV'));
expect(screen.getByText('Go to')).toBeTruthy();
fireEvent.click(screen.getByLabelText('Close'));
fireEvent.click(screen.getByLabelText('Reading settings'));
expect(screen.getByText('Text size')).toBeTruthy();
fireEvent.click(screen.getByLabelText('Close'));
fireEvent.click(screen.getByLabelText('Search'));
expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy();
});
test('the compare button next to Aa opens the comparison sheet, and choosing a Bible turns it on', () => {
ctx.setReaderParallel = vi.fn();
render();
const compare = screen.getByLabelText('Compare translations');
expect(compare.nextElementSibling).toBe(screen.getByLabelText('Reading settings')); // sits right beside Aa
fireEvent.click(compare);
expect(screen.getByText(/how different its wording is/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'NET' }));
expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET');
});
test('a cross-reference under a verse opens the side panel with that passage, without leaving the chapter', async () => {
const saved = { ...ctx };
Object.assign(ctx, {
readerShowCrossRefs: true, readerSelectedVerse: 1,
readerCrossRefs: { 1: [{ book: 'ROM', chapter: 5, verse: 8, endVerse: 9 }] },
jumpToReaderVerse: vi.fn(),
});
vi.stubGlobal('fetch', vi.fn((url) => Promise.resolve({
ok: true,
json: () => Promise.resolve(String(url).includes('/api/bible/')
? { verses: [{ number: 7, text: 'Seven.' }, { number: 8, text: 'But God shows His love for us.' }, { number: 9, text: 'Much more shall we be saved.' }, { number: 10, text: 'Ten.' }] }
: { chapter: { content: [] } }),
})));
render(); // dev builds run effects twice; the preview must survive that
const chip = within(screen.getByRole('article')).getByRole('button', { name: 'Romans 5:8-9' });
fireEvent.click(chip);
const card = await screen.findByRole('region', { name: /cross-reference romans 5:8-9/i });
await waitFor(() => expect(within(card).getByText(/But God shows His love/)).toBeTruthy());
expect(within(card).getByText(/Much more shall we be saved/)).toBeTruthy();
expect(within(card).queryByText('Seven.')).toBeNull(); // only the verses in the reference
expect(ctx.setReaderSelectedVerse).not.toHaveBeenCalledWith(null); // the click didn't toggle the verse off
fireEvent.click(within(card).getByRole('button', { name: /open chapter/i }));
expect(ctx.jumpToReaderVerse).toHaveBeenCalledWith('ROM', 5, 8); // only now does it leave the chapter
fireEvent.click(within(card).getByRole('button', { name: /close cross-reference/i }));
expect(screen.queryByRole('region', { name: /cross-reference romans/i })).toBeNull();
Object.assign(ctx, saved);
vi.unstubAllGlobals();
});
test('the verse panel has one-tap highlight colours for the whole verse', () => {
const saved = { ...ctx };
Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [] });
render();
const row = screen.getByRole('group', { name: /highlight this verse/i });
fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i }));
expect(ctx.addTextHighlight).toHaveBeenCalledWith(expect.objectContaining({
book: 'JHN', chapter: 3, verse: 2, startOffset: 0, endOffset: 'This man came to Jesus by night.'.length, color: '#bbf7d0', text: 'This man came to Jesus by night.',
}));
Object.assign(ctx, saved);
});
test('tapping a verse colour again takes it off, and Clear removes them all (with Undo)', () => {
const saved = { ...ctx };
const text = 'This man came to Jesus by night.';
const hl = (id, color, start = 0, end = text.length) => ({ id, book: 'JHN', chapter: 3, verse: 2, startOffset: start, endOffset: end, color });
Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [hl('a', '#bbf7d0'), hl('b', '#fef08a', 5, 8)] });
render();
const row = screen.getByRole('group', { name: /highlight this verse/i });
expect(within(row).getByRole('button', { name: /highlight verse green/i })).toHaveAttribute('aria-pressed', 'true');
expect(within(row).getByRole('button', { name: /highlight verse yellow/i })).toHaveAttribute('aria-pressed', 'false'); // a part-verse highlight isn't "the verse"
fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i }));
expect(ctx.removeTextHighlight).toHaveBeenCalledWith('a');
expect(ctx.addTextHighlight).not.toHaveBeenCalled();
fireEvent.click(within(row).getByRole('button', { name: /^clear$/i }));
expect(ctx.removeTextHighlight).toHaveBeenCalledWith('b');
Object.assign(ctx, saved);
});
test('a Highlights button sits in the top bar and opens the highlights; Bookmarks opens the bookmarks', () => {
const saved = { ...ctx };
Object.assign(ctx, {
readerTextHighlights: [{ id: 'h1', book: 'JHN', chapter: 3, verse: 2, startOffset: 0, endOffset: 4, color: '#bbf7d0', text: 'This' }],
readerBookmarks: { 'JHN-3-1': '#fef08a' },
});
render();
const highlights = screen.getByLabelText('Highlights');
expect(highlights.nextElementSibling).toBe(screen.getByLabelText('Bookmarks')); // beside the bookmarks button
fireEvent.click(highlights);
expect(screen.getByRole('tab', { name: /highlights \(1\)/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText(/“This”/)).toBeTruthy();
fireEvent.click(screen.getByLabelText('Close'));
fireEvent.click(screen.getByLabelText('Bookmarks'));
expect(screen.getByRole('tab', { name: /bookmarks \(1\)/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('John 3:1')).toBeTruthy();
Object.assign(ctx, saved);
});
test('the H key opens the highlights', () => {
render();
fireEvent.keyDown(window, { key: 'h' });
expect(screen.getByRole('dialog', { name: /bookmarks & highlights/i })).toBeTruthy();
});
test('the side panel has a drag handle to resize it, and no handle when no verse is open', () => {
const saved = { ...ctx };
render();
expect(screen.queryByRole('separator', { name: /resize side panel/i })).toBeNull();
Object.assign(ctx, { readerSelectedVerse: 1 });
render();
const handle = screen.getByRole('separator', { name: /resize side panel/i });
expect(handle).toHaveAttribute('aria-orientation', 'vertical');
expect(handle).toHaveAttribute('tabindex', '0'); // reachable by keyboard (arrow keys resize)
Object.assign(ctx, saved);
});
test('tapping a verse selects it', () => {
render();
fireEvent.click(screen.getByText(/This man came to Jesus/));
expect(ctx.setReaderSelectedVerse).toHaveBeenCalledWith(2);
});
// --- reading plan integration -------------------------------------------------
import { getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js';
const schedule = getPlanSchedule('gospels-30');
const dayIndex = findDayIndexForRef(schedule, 'JHN', 3);
const refs = schedule[dayIndex];
const keys = refs.map((r) => `${r.book}.${r.chapter}`);
const position = keys.indexOf('JHN.3') + 1;
const isLast = position === refs.length;
const withPlan = (over = {}) => {
ctx.planProgress = [{ id: 'p1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [], chaptersRead: {}, completedAt: null, translation: 'ESV', ...over }];
ctx.readerPlan = { progressId: 'p1', dayIndex };
[ctx.setReaderPlan, ctx.toggleChapterRead, ctx.toggleDay, ctx.setReaderChapter, ctx.setReaderBookAbbrev].forEach((f) => f.mockClear());
ctx.setReaderTranslation = vi.fn();
};
const stripButton = () => screen.getAllByText(new RegExp(`Day ${dayIndex + 1} *$`))[0];
test('in a plan: strip shows the day, a dot per chapter, and the plan translation', () => {
withPlan();
render();
expect(screen.getAllByLabelText(/Go to chapter/)).toHaveLength(refs.length);
expect(screen.getByText(`${position} of ${refs.length}`)).toBeTruthy();
});
test('end-of-day card ticks the current chapter and moves on', () => {
// every other chapter of the day already read, so finishing this one completes the day
withPlan({ chaptersRead: { [dayIndex]: keys.filter((k) => k !== 'JHN.3') } });
render();
fireEvent.click(screen.getByText(isLast ? /Mark read · Day/ : /^Next: /));
expect(ctx.toggleChapterRead).toHaveBeenCalledWith('p1', dayIndex, 'JHN.3', keys, 30, true);
if (!isLast) expect(ctx.setReaderChapter).toHaveBeenCalledWith(refs[position].chapter);
});
test('"mark read" on the last chapter completes the whole day even if earlier chapters were skipped', () => {
const last = refs.at(-1);
expect(refs.length).toBeGreaterThan(1);
withPlan(); // nothing read yet
ctx.readerBookAbbrev = last.book;
ctx.readerChapter = last.chapter;
try {
render();
fireEvent.click(screen.getByText(/Mark read · Day/));
expect(ctx.toggleDay).toHaveBeenCalledWith('p1', dayIndex, 30);
} finally {
ctx.readerBookAbbrev = 'JHN';
ctx.readerChapter = 3;
}
});
test('plan sheet lists the day, changes the plan translation, and leaves the plan', () => {
withPlan();
render();
fireEvent.click(stripButton());
expect(screen.getByText('Mark the whole day read')).toBeTruthy();
fireEvent.change(screen.getByDisplayValue(/^ESV/), { target: { value: 'NLT' } });
expect(ctx.setReaderTranslation).toHaveBeenCalledWith('NLT');
fireEvent.click(screen.getByText(/Leave plan/));
expect(ctx.setReaderPlan).toHaveBeenCalledWith(null);
});
test('outside a plan but on a plan chapter: offers to open the plan', () => {
withPlan();
ctx.readerPlan = null;
render();
fireEvent.click(screen.getByText('Open plan'));
expect(ctx.setReaderPlan).toHaveBeenCalledWith({ progressId: 'p1', dayIndex });
});