- 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>
180 lines
9.1 KiB
JavaScript
180 lines
9.1 KiB
JavaScript
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);
|
||
});
|
||
});
|