Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s

Studies now move through a stage bar in the reader's themes. Outline splits
chunks by clicking between verses (join merges notes) and suggests chunks from
section headings. Study is three panes: outline, passage with an Observe /
Interpret / Apply accordion, and commentary / word study / cross-refs / people
& places for the chunk; original words are added by clicking them in the
interlinear. Script drafts an outline from notes; Export holds files and the
share link. Home, Import and the Pronunciation guide are restyled. Study
projects are BSB only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:13:46 -04:00
1 parent b1bc1c8ea4
commit 31abee0695
31 files changed
+1911 -2460

No files matched your search

+153 -40
View File
@@ -135,17 +135,18 @@ describe('Initial render', () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(await screen.findByText('Project Setup')).toBeInTheDocument();
expect(await screen.findByText('New study')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
});
test('setup form shows translation, book, and chapter inputs', async () => {
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.getByText('Translation')).toBeInTheDocument();
expect(screen.queryByText('Translation')).not.toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument();
expect(screen.getByText('Chapter')).toBeInTheDocument();
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
});
});
@@ -200,7 +201,7 @@ describe('Chunk management', () => {
test('creates a chunk by clicking a verse range', async () => {
await loadChapter();
addChunk('Paul, a servant', 'Grace and peace');
await screen.findByText(/1[-–]3/);
expect((await screen.findAllByText(/1[-–]3/)).length).toBeGreaterThan(0);
});
test('chunk count increments after each addition', async () => {
@@ -250,7 +251,7 @@ async function goToStudyWith3Chunks() {
addChunk('Grace and peace', 'Grace and peace');
await screen.findByText('3 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i);
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 }));
@@ -264,7 +265,7 @@ describe('Study view navigation', () => {
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i);
await findChunkCounter(1, 1);
});
test('"Previous Chunk" is disabled on the first chunk', async () => {
@@ -305,8 +306,9 @@ async function goToStudyAndAddGreekWord(fetchMock) {
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i);
fireEvent.click(screen.getByRole('button', { name: /add greek word/i }));
await 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);
}
@@ -507,12 +509,13 @@ describe('Admin page', () => {
// Import page (state is local to ImportPage)
// ---------------------------------------------------------------------------
describe('Import page', () => {
test('opens from Home, lists fetched translations, and goes back', async () => {
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(await screen.findByRole('option', { name: 'KJV' })).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();
});
@@ -625,15 +628,17 @@ describe('Home page', () => {
});
// ---------------------------------------------------------------------------
// Project header (ProjectHeader + useShareLink) and the shared read-only view
// 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 → Markdown downloads a file', async () => {
test('Export stage → Markdown downloads a file', async () => {
const user = await loadChapter();
await user.click(screen.getByRole('button', { name: /export/i }));
await user.click(screen.getByRole('button', { name: /export markdown/i }));
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();
});
@@ -647,8 +652,10 @@ describe('Project header and sharing', () => {
});
vi.stubGlobal('fetch', fetchMock);
const user = await loadChapter();
await user.click(screen.getByRole('button', { name: /share/i }));
await user.click(screen.getByRole('button', { name: /enable sharing/i }));
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();
});
@@ -675,6 +682,7 @@ 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);
});
@@ -689,6 +697,7 @@ describe('Study editor', () => {
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' });
@@ -700,6 +709,7 @@ describe('Study editor', () => {
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' });
@@ -722,49 +732,152 @@ describe('Chunk builder', () => {
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
const user = await loadChapter();
expect(await addSecondChapter(user)).toHaveClass('bg-slate-900');
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
});
test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => {
test('the Outline 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 screen.findByText(/chunk editor/i);
await user.click(screen.getByRole('button', { name: /back to chunks/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900');
await findChunkCounter(1, 1);
await user.click(screen.getByRole('button', { name: /^outline$/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
});
});
// ---------------------------------------------------------------------------
// Header redesign: Tools menu + one-dropdown-at-a-time
// Stage bar (Outline / Study / Script / Export)
// ---------------------------------------------------------------------------
describe('Header menus', () => {
test('Tools is disabled until a chunk exists, then opens the Pronunciation Guide', async () => {
const user = await loadChapter();
expect(screen.getByRole('button', { name: /tools/i })).toBeDisabled();
describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => {
await loadChapter();
for (const name of [/^study$/i, /^script$/i, /^export$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled();
}
expect(screen.getByRole('button', { name: /^outline$/i })).not.toBeDisabled();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /tools/i }));
await user.click(screen.getByRole('button', { name: /pronunciation guide/i }));
expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
});
test('opening one dropdown closes the other; Escape and outside clicks close it', async () => {
test('Script stage opens the Pronunciation Guide, and back returns to Script', async () => {
const user = await loadChapter();
await user.click(screen.getByRole('button', { name: /export/i }));
expect(screen.getByRole('button', { name: /export markdown/i })).toBeInTheDocument();
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();
});
await user.click(screen.getByRole('button', { name: /share/i }));
expect(screen.queryByRole('button', { name: /export markdown/i })).not.toBeInTheDocument();
expect(screen.getByText(/read-only share link/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();
});
});
await user.keyboard('{Escape}');
expect(screen.queryByText(/read-only share link/i)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /export/i }));
await user.click(screen.getByText(/scripture & chunks/i));
expect(screen.queryByRole('button', { name: /export markdown/i })).not.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();
});
});