Files
study-bible-project/src/utils/outline.test.js
T
nmemmertandClaude Sonnet 5.5 820886e3ab Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top);
  chunk outlines can start from the chunk's verses, each carrying its words
- Points can have a type (illustration, application, quote, question,
  transition), minutes (with a running total), a longer note, verse text
  fetched from a passage, and cross-references / word studies attached from
  the chunk
- Share (system share sheet, or copy), Print / PDF, Copy as text, Word and
  Markdown downloads for any one outline
- All of it included in the Markdown, HTML/PDF and Word study exports,
  with each chunk's outline under that chunk

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:28 -04:00

180 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});