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

+109
View File
@@ -0,0 +1,109 @@
import { renderHook, act } from '@testing-library/react';
import { useState } from 'react';
import { vi, test, expect, beforeEach } from 'vitest';
import { blankChunk, headingStarts, mergeChunks, uncoveredRuns, useChunkBuilder } from './useChunkBuilder.js';
const c = (s, e) => ({ startVerse: s, endVerse: e });
test('uncoveredRuns lists the verse runs not yet in any chunk', () => {
expect(uncoveredRuns([c(1, 3), c(6, 7)], 1, 10)).toEqual([[4, 5], [8, 10]]);
expect(uncoveredRuns([], 5, 8)).toEqual([[5, 8]]);
expect(uncoveredRuns([c(1, 10)], 1, 10)).toEqual([]);
});
test('headingStarts finds the verse each section heading precedes', () => {
const data = { chapter: { content: [
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'verse', number: 2 },
{ type: 'heading' }, { type: 'line_break' }, { type: 'verse', number: 22 },
] } };
expect(headingStarts(data)).toEqual([1, 22]);
});
test('mergeChunks keeps the first id and combines notes, words, refs and tags', () => {
const a = { ...blankChunk(1, 3), observation: 'A', tags: ['x'], crossReferences: ['John 1:1'], greekWords: [{ id: 'w1' }] };
const b = { ...blankChunk(4, 6), observation: 'B', tags: ['x', 'y'], crossReferences: ['John 1:1', 'Rom 3:23'], greekWords: [{ id: 'w2' }], episodeTitle: 'Ep' };
const m = mergeChunks(a, b);
expect([m.id, m.startVerse, m.endVerse]).toEqual([a.id, 1, 6]);
expect(m.observation).toBe('A\n\nB');
expect(m.tags).toEqual(['x', 'y']);
expect(m.crossReferences).toEqual(['John 1:1', 'Rom 3:23']);
expect(m.greekWords.map((w) => w.id)).toEqual(['w1', 'w2']);
expect(m.episodeTitle).toBe('Ep');
});
// --- hook operations against a small in-memory project -----------------------
const verses = Array.from({ length: 12 }, (_, i) => ({ number: i + 1, text: `verse ${i + 1}` }));
const makeProject = (chunks = []) => ({
id: 'p', title: 'T', translation: 'BSB', selectedChunkId: null,
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses, chunks }],
});
function setup(initialChunks) {
return renderHook(() => {
const [project, setProject] = useState(makeProject(initialChunks));
const updateProject = (fn) => setProject((cur) => fn(cur));
const allChunks = project.chapters.flatMap((ch) => ch.chunks);
const builder = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage: () => {} });
return { project, builder };
});
}
const ranges = (h) => h.result.current.project.chapters[0].chunks.map((k) => [k.startVerse, k.endVerse]);
beforeEach(() => { vi.restoreAllMocks(); });
test('splitting between uncovered verses builds chunks one after another', () => {
const h = setup();
act(() => h.result.current.builder.splitAfterVerse(4));
act(() => h.result.current.builder.splitAfterVerse(9));
expect(ranges(h)).toEqual([[1, 4], [5, 9]]);
});
test('splitting inside a chunk keeps the notes on the first part', () => {
const original = { ...blankChunk(1, 8), observation: 'keep me' };
const h = setup([original]);
act(() => h.result.current.builder.splitAfterVerse(3));
const [first, second] = h.result.current.project.chapters[0].chunks;
expect([first.id, first.startVerse, first.endVerse, first.observation]).toEqual([original.id, 1, 3, 'keep me']);
expect([second.startVerse, second.endVerse, second.observation]).toEqual([4, 8, '']);
});
test('splitting where a boundary already exists changes nothing', () => {
const h = setup([blankChunk(1, 4), blankChunk(5, 8)]);
act(() => h.result.current.builder.splitAfterVerse(4));
expect(ranges(h)).toEqual([[1, 4], [5, 8]]);
});
test('joining merges a chunk with the next one and re-selects the survivor', () => {
const a = { ...blankChunk(1, 4), observation: 'one' };
const b = { ...blankChunk(5, 8), observation: 'two' };
const h = setup([a, b]);
act(() => h.result.current.builder.joinWithNext(a.id));
const [only] = h.result.current.project.chapters[0].chunks;
expect([only.id, only.startVerse, only.endVerse, only.observation]).toEqual([a.id, 1, 8, 'one\n\ntwo']);
});
test('joining is refused when the chunks are not adjacent', () => {
const a = blankChunk(1, 3);
const h = setup([a, blankChunk(6, 8)]);
act(() => h.result.current.builder.joinWithNext(a.id));
expect(ranges(h)).toEqual([[1, 3], [6, 8]]);
});
test('suggest from headings fills only the uncovered verses, one chunk per section', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ chapter: { content: [
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'heading' }, { type: 'verse', number: 5 }, { type: 'heading' }, { type: 'verse', number: 9 },
] } }),
})));
const h = setup([blankChunk(5, 6)]); // verses 5-6 already done
await act(async () => { await h.result.current.builder.suggestChunks(); });
expect(ranges(h).sort((x, y) => x[0] - y[0])).toEqual([[1, 4], [5, 6], [7, 8], [9, 12]]);
});
test('suggest falls back to even chunks when the chapter has no headings', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ chapter: { content: [{ type: 'verse', number: 1 }] } }) })));
const h = setup();
await act(async () => { await h.result.current.builder.suggestChunks(); });
expect(ranges(h)).toEqual([[1, 8], [9, 12]]);
});