import { describe, expect, test } from 'vitest'; import { addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent, STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH, } from './outline.js'; const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children }); const ids = (tree) => flatten(tree).map((f) => f.node.id); // I. a (with A. a1, B. a2) II. b const sample = () => [n('a', 'a', [n('a1'), n('a2')]), n('b')]; describe('labels', () => { test('roman numerals, letters and numbers by level', () => { expect([1, 4, 9, 14, 19].map(toRoman)).toEqual(['I', 'IV', 'IX', 'XIV', 'XIX']); expect(labelFor(0, 1)).toBe('II.'); expect(labelFor(1, 0)).toBe('A.'); expect(labelFor(1, 26)).toBe('AA.'); expect(labelFor(2, 2)).toBe('3.'); expect(labelFor(3, 1)).toBe('b.'); expect(labelFor(4, 0)).toBe('(1)'); }); test('flatten gives reading order with depth and labels', () => { expect(flatten(sample()).map((f) => `${f.label}${f.node.id}@${f.depth}`)).toEqual(['I.a@0', 'A.a1@1', 'B.a2@1', 'II.b@0']); }); }); describe('editing', () => { test('add after, add child, add root', () => { expect(ids(addAfter(sample(), 'a1', n('x')))).toEqual(['a', 'a1', 'x', 'a2', 'b']); expect(ids(addChild(sample(), 'b', n('x')))).toEqual(['a', 'a1', 'a2', 'b', 'x']); expect(ids(addRoot(sample(), n('x')))).toEqual(['a', 'a1', 'a2', 'b', 'x']); expect(flatten(addChild(sample(), 'b', n('x'))).at(-1).depth).toBe(1); }); test('update and remove, including a node deep in the tree', () => { expect(getNode(updateNode(sample(), 'a2', { text: 'new' }), 'a2').text).toBe('new'); expect(ids(removeNode(sample(), 'a1'))).toEqual(['a', 'a2', 'b']); expect(ids(removeNode(sample(), 'a'))).toEqual(['b']); // takes its sub-points with it }); test('indent makes it a sub-point of the one above; the first one cannot', () => { const t = indent(sample(), 'b'); expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['a@0', 'a1@1', 'a2@1', 'b@1']); expect(indent(sample(), 'a')).toEqual(sample()); expect(indent(sample(), 'a1')).toEqual(sample()); }); test('indent carries a point\'s own sub-points with it', () => { const t = indent([n('p'), n('q', 'q', [n('q1')])], 'q'); expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['p@0', 'q@1', 'q1@2']); }); test('indent stops at the deepest level', () => { // a chain: top > s1 > s2 > ... pushing every new point in as far as it will go let tree = [n('top')]; for (let i = 1; i <= MAX_DEPTH + 2; i += 1) { tree = addRoot(tree, n(`s${i}`)); for (let k = 0; k < MAX_DEPTH + 2; k += 1) tree = indent(tree, `s${i}`); } const deepest = Math.max(...flatten(tree).map((f) => f.depth)); expect(deepest).toBe(MAX_DEPTH - 1); }); test('outdent moves a sub-point to sit just after its parent', () => { const t = outdent(sample(), 'a1'); expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['a@0', 'a2@1', 'a1@0', 'b@0']); expect(outdent(sample(), 'a')).toEqual(sample()); // already at the top }); test('move swaps with a sibling and stops at the ends', () => { expect(ids(moveNode(sample(), 'b', -1))).toEqual(['b', 'a', 'a1', 'a2']); expect(ids(moveNode(sample(), 'a2', -1))).toEqual(['a', 'a2', 'a1', 'b']); expect(moveNode(sample(), 'a', -1)).toEqual(sample()); expect(moveNode(sample(), 'b', 1)).toEqual(sample()); }); test('edits never change the tree they are given', () => { const t = sample(); const copy = JSON.stringify(t); indent(t, 'b'); outdent(t, 'a1'); removeNode(t, 'a'); moveNode(t, 'b', -1); updateNode(t, 'a', { text: 'z' }); expect(JSON.stringify(t)).toBe(copy); }); }); describe('reading it back', () => { test('plain text and markdown carry labels, indentation and passages', () => { const t = [{ ...n('a', 'Creation'), ref: 'Gen 1:1-2', children: [n('a1', 'Light')] }, n('b', '')]; expect(outlineToText(t)).toBe('I. Creation (Gen 1:1-2)\n A. Light\nII. (untitled)'); expect(outlineToMarkdown(t)).toBe('- **I.** Creation (Gen 1:1-2)\n - **A.** Light\n- **II.** (untitled)'); expect(countNodes(t)).toBe(3); }); }); describe('seeding from chunks', () => { const project = { chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }, { number: 3, text: 'c' }], chunks: [ { id: 'c1', startVerse: 1, endVerse: 2, observation: 'Paul greets.\nMore.', episodeTitle: '' }, { id: 'c2', startVerse: 3, endVerse: 3, observation: '', episodeTitle: 'Hope' }, ] }], }; test('one main point per chunk, linked to it and named from its notes or title', () => { const t = seedFromChunks(project); expect(t.map((x) => [x.text, x.ref, x.chunkId])).toEqual([['Paul greets.', 'Titus 1:1–2', 'c1'], ['Hope', 'Titus 1:3', 'c2']]); }); test('keeps what is there and only adds chunks that are not yet in the outline', () => { const first = seedFromChunks(project); const edited = updateNode(first, first[0].id, { text: 'My own wording' }); const again = seedFromChunks(project, edited); expect(again).toHaveLength(2); expect(again[0].text).toBe('My own wording'); expect(seedFromChunks(project, [newNode('Extra')]).map((x) => x.chunkId)).toEqual([null, 'c1', 'c2']); }); }); describe('putting a deleted point back', () => { test('locate + insertAt restores a point, with its sub-points, exactly where it was', () => { const tree = sample(); const where = locate(tree, 'a1'); const gone = getNode(tree, 'a1'); expect(where).toEqual({ parentId: 'a', index: 0 }); expect(ids(insertAt(removeNode(tree, 'a1'), where.parentId, where.index, gone))).toEqual(ids(tree)); const top = locate(tree, 'a'); expect(ids(insertAt(removeNode(tree, 'a'), top.parentId, top.index, getNode(tree, 'a')))).toEqual(ids(tree)); }); test('if the parent is gone it goes to the top level instead of vanishing', () => { expect(ids(insertAt([n('b')], 'missing', 0, n('x')))).toEqual(['x', 'b']); }); }); describe('richer points', () => { const rich = [{ ...n('a', 'Creation'), ref: 'Gen 1', kind: 'illustration', minutes: 5, note: 'Tell the story.\n\nKeep it short.', scripture: { ref: 'Genesis 1:1', text: 'In the beginning', translation: 'BSB' }, attachments: [{ id: 'x', type: 'xref', label: 'John 1:1' }, { id: 'w', type: 'word', label: 'bara (H1254)', detail: 'to create' }], children: [{ ...n('b', 'Light'), minutes: 3 }], }]; test('text and markdown show the kind, minutes, note, verse and attachments', () => { expect(outlineToText(rich)).toBe([ 'I. [Illustration] Creation (Gen 1) · 5 min', ' Tell the story.', ' Keep it short.', ' “In the beginning” — Genesis 1:1', ' See: John 1:1', ' Word: bara (H1254) — to create', ' A. Light · 3 min', ].join('\n')); expect(outlineToMarkdown(rich)).toContain('- **I.** [Illustration] Creation (Gen 1) · 5 min\n *Tell the story.*'); }); test('totals the minutes, and says nothing when there are none', () => { expect(totalMinutes(rich)).toBe(8); expect(timeSummary(rich)).toBe('About 8 minutes'); expect(timeSummary([n('z')])).toBe(''); }); test('new points start plain, and older ones without the new fields still work', () => { expect(newNode('x')).toMatchObject({ note: '', kind: '', minutes: null, scripture: null, attachments: [] }); expect(detailLines(n('old'))).toEqual([]); }); }); describe('an outline for each chunk', () => { const project = { translation: 'BSB', outline: [n('whole')], chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'One.' }, { number: 2, text: 'Two.' }, { number: 3, text: 'Three.' }], chunks: [ { id: 'c1', startVerse: 1, endVerse: 2, observation: '', outline: [n('mine')] }, { id: 'c2', startVerse: 3, endVerse: 3, observation: '' }, ] }], }; test('reads and replaces the outline of the whole study or of one chunk', () => { expect(ids(outlineFor(project, STUDY))).toEqual(['whole']); expect(ids(outlineFor(project, 'c1'))).toEqual(['mine']); expect(outlineFor(project, 'c2')).toEqual([]); const next = withOutline(project, 'c2', [n('new')]); expect(ids(outlineFor(next, 'c2'))).toEqual(['new']); expect(ids(outlineFor(next, 'c1'))).toEqual(['mine']); expect(ids(outlineFor(next, STUDY))).toEqual(['whole']); expect(outlineFor(project, 'c2')).toEqual([]); // the original is untouched expect(ids(outlineFor(withOutline(project, STUDY, []), STUDY))).toEqual([]); }); test('seeding from verses adds a point per verse with its words, and never twice', () => { const t = seedFromVerses(project, 'c1', []); expect(t.map((x) => [x.ref, x.scripture.text])).toEqual([['Titus 1:1', 'One.'], ['Titus 1:2', 'Two.']]); expect(seedFromVerses(project, 'c1', t)).toHaveLength(2); expect(seedFromVerses(project, 'c2', [])).toHaveLength(1); }); });