Files
study-bible-project/src/utils.test.js
T
nmemmert 37cfcd55a0 Add study templates, exports, breadcrumb, whole-Bible search, bookmarks UX, and share links
- Study templates: richer multi-line guiding questions in the OIA placeholders
- PDF/print export: reuses buildExportHtml in a new tab + window.print()
- Markdown export: new buildMarkdownExport() with matching tests
- Breadcrumb: current chunk's passage reference shown in the Study page header
- Reader bookmarks: SVG icons instead of ambiguous emoji, always visible
  (not hover-only, so it works on touch devices), plus a Bookmarks panel
  that lists all saved verses across every book and jumps + scrolls to them
- Whole-Bible search: no server-side search endpoint exists, so this fetches
  the full translation once (~7MB) and searches an in-memory flat verse
  index client-side, with results linking back into the reader
- Read-only share links: per-project share token, a public unauthenticated
  /api/share/:token endpoint, and a ?share=TOKEN view that bypasses the auth
  gate entirely and renders the export HTML in a script-sandboxed iframe

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 10:12:00 -04:00

640 lines
21 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, test, expect } from 'vitest';
import {
storageKey,
extractPartOfSpeech,
htmlToPlainText,
renderVerseContent,
parseBibleChapter,
wordTableHtml,
buildExportHtml,
buildMarkdownExport,
buildClaudePrompt,
createParagraphsFromText,
migrateChunk,
migrateProject,
} from './App.jsx';
// ---------------------------------------------------------------------------
// storageKey
// ---------------------------------------------------------------------------
describe('storageKey', () => {
test('formats key with translation, book, and chapter', () => {
expect(storageKey('BSB', 'TIT', '1')).toBe('bible-study-BSB-TIT-1');
});
test('handles different values', () => {
expect(storageKey('KJV', 'MAT', '5')).toBe('bible-study-KJV-MAT-5');
});
});
// ---------------------------------------------------------------------------
// extractPartOfSpeech
// ---------------------------------------------------------------------------
describe('extractPartOfSpeech', () => {
test('extracts single-word part of speech', () => {
expect(extractPartOfSpeech('<p>Part(s) of speech: Noun</p>')).toBe('Noun');
});
test('extracts multi-word part of speech', () => {
expect(extractPartOfSpeech('<p>Part(s) of speech: Verb, Intransitive</p>')).toBe('Verb, Intransitive');
});
test('is case-insensitive on the label', () => {
expect(extractPartOfSpeech('<p>PART(S) OF SPEECH: Adjective</p>')).toBe('Adjective');
});
test('returns empty string when label is absent', () => {
expect(extractPartOfSpeech('<p>Some other text</p>')).toBe('');
});
test('returns empty string for empty input', () => {
expect(extractPartOfSpeech('')).toBe('');
});
test('returns empty string for null', () => {
expect(extractPartOfSpeech(null)).toBe('');
});
test('returns empty string for undefined', () => {
expect(extractPartOfSpeech(undefined)).toBe('');
});
});
// ---------------------------------------------------------------------------
// htmlToPlainText
// ---------------------------------------------------------------------------
describe('htmlToPlainText', () => {
test('extracts text from a paragraph', () => {
expect(htmlToPlainText('<p>Hello world</p>')).toBe('Hello world');
});
test('returns empty string for empty input', () => {
expect(htmlToPlainText('')).toBe('');
});
test('strips script tags leaving safe text intact (XSS regression)', () => {
const result = htmlToPlainText('<script>alert("xss")</script>Safe text');
expect(result).not.toContain('<script>');
expect(result).toContain('Safe text');
});
test('extracts text from nested elements', () => {
const result = htmlToPlainText('<div><p>Line one</p><p>Line two</p></div>');
expect(result).toContain('Line one');
expect(result).toContain('Line two');
});
test('collapses multiple consecutive newlines into one', () => {
const result = htmlToPlainText('<p>A</p><p></p><p></p><p>B</p>');
expect(result).not.toMatch(/\n{2,}/);
});
test('trims leading and trailing whitespace', () => {
const result = htmlToPlainText(' <p> text </p> ');
expect(result).toBe(result.trim());
});
});
// ---------------------------------------------------------------------------
// renderVerseContent
// ---------------------------------------------------------------------------
describe('renderVerseContent', () => {
test('returns a string unchanged', () => {
expect(renderVerseContent('hello world')).toBe('hello world');
});
test('joins an array of strings', () => {
expect(renderVerseContent(['hello', ' ', 'world'])).toBe('hello world');
});
test('recursively renders mixed-type arrays', () => {
expect(renderVerseContent(['a', { lineBreak: true }, 'b'])).toBe('a\nb');
});
test('returns newline for lineBreak object', () => {
expect(renderVerseContent({ lineBreak: true })).toBe('\n');
});
test('returns newline for type:line_break object', () => {
expect(renderVerseContent({ type: 'line_break' })).toBe('\n');
});
test('renders nested content arrays', () => {
expect(renderVerseContent({ content: ['hello', ' ', 'world'] })).toBe('hello world');
});
test('returns empty string for an empty object', () => {
expect(renderVerseContent({})).toBe('');
});
test('returns empty string for an object with unknown type and no content', () => {
expect(renderVerseContent({ type: 'unknown' })).toBe('');
});
test('returns empty string for null', () => {
expect(renderVerseContent(null)).toBe('');
});
test('returns empty string for undefined', () => {
expect(renderVerseContent(undefined)).toBe('');
});
test('returns empty string for a number', () => {
expect(renderVerseContent(42)).toBe('');
});
});
// ---------------------------------------------------------------------------
// parseBibleChapter
// ---------------------------------------------------------------------------
describe('parseBibleChapter', () => {
test('parses format A (verses array)', () => {
const data = {
verses: [
{ number: 1, text: 'In the beginning.' },
{ number: 2, text: 'The earth was formless.' },
],
};
expect(parseBibleChapter(data)).toEqual([
{ number: 1, text: 'In the beginning.' },
{ number: 2, text: 'The earth was formless.' },
]);
});
test('parses format B (chapter.content array)', () => {
const data = {
chapter: {
content: [
{ type: 'heading', content: ['Genesis'] },
{ type: 'verse', number: 1, content: ['In the beginning.'] },
{ type: 'verse', number: 2, content: ['The earth was formless.'] },
],
},
};
const result = parseBibleChapter(data);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ number: 1, text: 'In the beginning.' });
expect(result[1]).toEqual({ number: 2, text: 'The earth was formless.' });
});
test('filters out non-verse items in format B', () => {
const data = {
chapter: {
content: [
{ type: 'heading', content: ['Title'] },
{ type: 'verse', number: 1, content: ['Verse text.'] },
{ type: 'note', content: ['A footnote.'] },
],
},
};
const result = parseBibleChapter(data);
expect(result).toHaveLength(1);
expect(result[0].number).toBe(1);
});
test('normalises excessive whitespace in verse text', () => {
const data = {
chapter: {
content: [{ type: 'verse', number: 1, content: [' Extra spaces '] }],
},
};
expect(parseBibleChapter(data)[0].text).toBe('Extra spaces');
});
test('returns empty array for null', () => {
expect(parseBibleChapter(null)).toEqual([]);
});
test('returns empty array for undefined', () => {
expect(parseBibleChapter(undefined)).toEqual([]);
});
test('returns empty array for empty object', () => {
expect(parseBibleChapter({})).toEqual([]);
});
test('returns empty array when chapter.content is missing', () => {
expect(parseBibleChapter({ chapter: {} })).toEqual([]);
});
test('returns empty array when chapter.content is empty', () => {
expect(parseBibleChapter({ chapter: { content: [] } })).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// wordTableHtml
// ---------------------------------------------------------------------------
describe('wordTableHtml', () => {
test('generates a table with all header columns', () => {
const html = wordTableHtml('');
expect(html).toContain('<table>');
expect(html).toContain("Strong's");
expect(html).toContain('Greek');
expect(html).toContain('Transliteration');
expect(html).toContain('Part of Speech');
expect(html).toContain('Short Definition');
});
test('injects provided rows into tbody', () => {
const rows = '<tr><td>G4102</td><td>πίστις</td></tr>';
const html = wordTableHtml(rows);
expect(html).toContain('G4102');
expect(html).toContain('πίστις');
});
test('produces valid table structure', () => {
const html = wordTableHtml('');
expect(html).toContain('<thead>');
expect(html).toContain('<tbody>');
expect(html).toContain('</table>');
});
});
// ---------------------------------------------------------------------------
// migrateChunk
// ---------------------------------------------------------------------------
describe('migrateChunk', () => {
test('converts notes → observation and adds OIA/crossRef fields', () => {
const old = { id: 'c1', startVerse: 1, endVerse: 2, notes: 'My notes.', greekWords: [] };
const result = migrateChunk(old);
expect(result.observation).toBe('My notes.');
expect(result.interpretation).toBe('');
expect(result.application).toBe('');
expect(result.crossReferences).toEqual([]);
});
test('preserves existing fields and backfills new ones when chunk already has observation field', () => {
const modern = { id: 'c1', startVerse: 1, endVerse: 1, observation: 'Already migrated.', interpretation: '', application: '', crossReferences: [], greekWords: [] };
const result = migrateChunk(modern);
expect(result).toMatchObject(modern);
expect(result.tags).toEqual([]);
expect(result.spilloverEndVerse).toBeNull();
expect(result.generalNotes).toBe('');
expect(result.episodeNumber).toBe('');
expect(result.episodeTitle).toBe('');
expect(result.finalScript).toBe('');
});
test('handles missing notes gracefully', () => {
const chunk = { id: 'c1', startVerse: 1, endVerse: 1, greekWords: [] };
expect(migrateChunk(chunk).observation).toBe('');
});
});
// ---------------------------------------------------------------------------
// migrateProject
// ---------------------------------------------------------------------------
describe('migrateProject', () => {
test('wraps old flat project into chapters array', () => {
const old = {
title: 'Test',
translation: 'BSB',
book: 'Titus',
bookAbbrev: 'TIT',
chapter: '1',
verses: [{ number: 1, text: 'Hello.' }],
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, notes: 'Note.', greekWords: [] }],
};
const result = migrateProject(old);
expect(Array.isArray(result.chapters)).toBe(true);
expect(result.chapters).toHaveLength(1);
expect(result.chapters[0].book).toBe('Titus');
expect(result.chapters[0].chunks[0].observation).toBe('Note.');
});
test('preserves chapters array if already new format', () => {
const modern = {
id: 'p1',
title: 'Test',
translation: 'BSB',
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1',
verses: [], chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation: 'x', interpretation: '', application: '', crossReferences: [], greekWords: [] }],
}],
};
const result = migrateProject(modern);
expect(result.chapters).toHaveLength(1);
expect(result.chapters[0].chunks[0].observation).toBe('x');
});
test('returns null for null input', () => {
expect(migrateProject(null)).toBeNull();
});
});
// ---------------------------------------------------------------------------
// buildExportHtml
// ---------------------------------------------------------------------------
const baseChunk = {
id: 'chunk-1',
startVerse: 1,
endVerse: 2,
observation: 'Key observations.',
interpretation: 'Theological meaning.',
application: 'Live it out.',
crossReferences: ['John 1:1'],
greekWords: [
{
strongNumber: 'G1401',
lexeme: 'δοῦλος',
transliteration: 'doulos',
partOfSpeech: 'Noun',
shortDefinition: 'a slave',
definitionHtml: '<p>Part(s) of speech: Noun</p><p>A slave or servant.</p>',
},
],
};
const baseProject = {
id: 'test-project-1',
title: 'Titus 1 Study',
translation: 'BSB',
lastEdited: 1700000000000,
selectedChunkId: 'chunk-1',
chapters: [
{
book: 'Titus',
bookAbbrev: 'TIT',
chapter: '1',
verses: [
{ number: 1, text: 'Paul, a servant of God.' },
{ number: 2, text: 'In hope of eternal life.' },
],
chunks: [baseChunk],
},
],
};
describe('buildExportHtml', () => {
test('produces a valid HTML document', () => {
const html = buildExportHtml(baseProject);
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('<html lang="en">');
expect(html).toContain('</html>');
});
test('includes the project title', () => {
expect(buildExportHtml(baseProject)).toContain('Titus 1 Study');
});
test('includes translation, book, and chapter heading', () => {
const html = buildExportHtml(baseProject);
expect(html).toContain('BSB');
expect(html).toContain('Titus');
expect(html).toContain('Titus 1');
});
test('includes verse text for the chunk range', () => {
const html = buildExportHtml(baseProject);
expect(html).toContain('Paul, a servant of God.');
expect(html).toContain('In hope of eternal life.');
});
test('includes OIA notes', () => {
const html = buildExportHtml(baseProject);
expect(html).toContain('Key observations.');
expect(html).toContain('Theological meaning.');
expect(html).toContain('Live it out.');
});
test('includes cross-references', () => {
expect(buildExportHtml(baseProject)).toContain('John 1:1');
});
test('includes Greek word data', () => {
const html = buildExportHtml(baseProject);
expect(html).toContain('G1401');
expect(html).toContain('δοῦλος');
expect(html).toContain('doulos');
expect(html).toContain('a slave');
});
test('formats a single-verse reference without a range dash', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, startVerse: 1, endVerse: 1 }] }],
};
const html = buildExportHtml(project);
expect(html).toContain('Titus 1:1');
expect(html).not.toContain('Titus 1:1-1');
});
test('formats a multi-verse reference with a range dash', () => {
expect(buildExportHtml(baseProject)).toContain('Titus 1:1-2');
});
test('shows "No observation." when chunk observation is empty', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, observation: '', interpretation: '', application: '' }] }],
};
expect(buildExportHtml(project)).toContain('No observation.');
});
test('shows "No Greek word notes." when chunk has no Greek words', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, greekWords: [] }] }],
};
expect(buildExportHtml(project)).toContain('No Greek word notes.');
});
test('handles a project with no chunks', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [] }],
};
const html = buildExportHtml(project);
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('Titus 1 Study');
});
test('only includes verses that fall within the chunk range', () => {
const project = {
...baseProject,
chapters: [{
...baseProject.chapters[0],
verses: [
{ number: 1, text: 'Verse one text.' },
{ number: 2, text: 'Verse two text.' },
{ number: 3, text: 'Verse three text.' },
],
chunks: [{ ...baseChunk, startVerse: 2, endVerse: 2, greekWords: [] }],
}],
};
const html = buildExportHtml(project);
expect(html).toContain('Verse two text.');
expect(html).not.toContain('Verse one text.');
expect(html).not.toContain('Verse three text.');
});
});
// ---------------------------------------------------------------------------
// buildMarkdownExport
// ---------------------------------------------------------------------------
describe('buildMarkdownExport', () => {
test('includes the project title and translation as Markdown headers', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('# Titus 1 Study');
expect(md).toContain('*BSB*');
});
test('includes a chapter heading and chunk reference', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('## Titus 1');
expect(md).toContain('### Titus 1:1-2');
});
test('includes verse text and OIA notes', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('Paul, a servant of God.');
expect(md).toContain('Key observations.');
expect(md).toContain('Theological meaning.');
expect(md).toContain('Live it out.');
});
test('includes cross-references and Greek word data', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('John 1:1');
expect(md).toContain('G1401');
expect(md).toContain('δοῦλος');
});
test('shows placeholder text when observation is empty', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, observation: '', interpretation: '', application: '' }] }],
};
expect(buildMarkdownExport(project)).toContain('_No observation._');
});
});
// ---------------------------------------------------------------------------
// createParagraphsFromText
// ---------------------------------------------------------------------------
describe('createParagraphsFromText', () => {
test('returns one paragraph per non-empty line', () => {
const result = createParagraphsFromText('Line one\nLine two\nLine three');
expect(result).toHaveLength(3);
});
test('filters out empty lines', () => {
const result = createParagraphsFromText('Line one\n\n\nLine two');
expect(result).toHaveLength(2);
});
test('returns an empty array for blank input', () => {
expect(createParagraphsFromText('')).toHaveLength(0);
expect(createParagraphsFromText('\n\n\n')).toHaveLength(0);
});
test('trims whitespace from each line', () => {
const result = createParagraphsFromText(' hello \n world ');
expect(result).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// buildClaudePrompt
// ---------------------------------------------------------------------------
describe('buildClaudePrompt', () => {
test('includes project title, translation, and passage', () => {
const prompt = buildClaudePrompt(baseProject);
expect(prompt).toContain('Titus 1 Study');
expect(prompt).toContain('BSB');
expect(prompt).toContain('Titus 1');
});
test('includes scripture verse text for the chunk range', () => {
const prompt = buildClaudePrompt(baseProject);
expect(prompt).toContain('Paul, a servant of God.');
expect(prompt).toContain('In hope of eternal life.');
});
test('includes OIA notes', () => {
const prompt = buildClaudePrompt(baseProject);
expect(prompt).toContain('Key observations.');
expect(prompt).toContain('Theological meaning.');
expect(prompt).toContain('Live it out.');
});
test('includes cross-references', () => {
expect(buildClaudePrompt(baseProject)).toContain('John 1:1');
});
test('includes Greek word data', () => {
const prompt = buildClaudePrompt(baseProject);
expect(prompt).toContain('G1401');
expect(prompt).toContain('δοῦλος');
expect(prompt).toContain('doulos');
expect(prompt).toContain('a slave');
});
test('numbers each chunk sequentially', () => {
const multiChunkProject = {
...baseProject,
chapters: [{
...baseProject.chapters[0],
chunks: [
{ ...baseChunk, id: 'c1', startVerse: 1, endVerse: 1 },
{ ...baseChunk, id: 'c2', startVerse: 2, endVerse: 2, observation: 'Second chunk notes.', greekWords: [] },
],
}],
};
const prompt = buildClaudePrompt(multiChunkProject);
expect(prompt).toContain('CHUNK 1');
expect(prompt).toContain('CHUNK 2');
});
test('formats a single-verse reference without a dash', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, startVerse: 1, endVerse: 1 }] }],
};
const prompt = buildClaudePrompt(project);
expect(prompt).toContain('Titus 1:1');
expect(prompt).not.toContain('Titus 1:11');
});
test('formats a multi-verse reference with an en-dash', () => {
const prompt = buildClaudePrompt(baseProject);
expect(prompt).toContain('Titus 1:12');
});
test('shows "No observation." when chunk observation is empty', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, observation: '', interpretation: '', application: '' }] }],
};
expect(buildClaudePrompt(project)).toContain('No observation.');
});
test('shows "None." when chunk has no Greek words', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, greekWords: [] }] }],
};
expect(buildClaudePrompt(project)).toContain('None.');
});
test('only includes verses within the chunk range', () => {
const project = {
...baseProject,
chapters: [{
...baseProject.chapters[0],
verses: [
{ number: 1, text: 'Verse one.' },
{ number: 2, text: 'Verse two.' },
{ number: 3, text: 'Verse three.' },
],
chunks: [{ ...baseChunk, startVerse: 2, endVerse: 2, greekWords: [] }],
}],
};
const prompt = buildClaudePrompt(project);
expect(prompt).toContain('Verse two.');
expect(prompt).not.toContain('Verse one.');
expect(prompt).not.toContain('Verse three.');
});
});