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
@@ -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]]);
|
||||
});
|
||||
Reference in new issue
Block a user