Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
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:
1 parent
b1bc1c8ea4
commit
31abee0695
31 files changed
+1911
-2460
No files matched your search
+153
-40
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user