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]]); });