Add test infrastructure and initial test suite

Sets up Vitest + React Testing Library with jsdom, exports utility functions
for unit testing, and adds 78 tests covering pure functions and component
integration flows (chapter loading, chunk management, navigation, Greek word
lookup).

https://claude.ai/code/session_01C4T3bYHR2svLq7JVcxKJ6Y
This commit is contained in:
Claude
2026-05-27 11:04:20 +00:00
parent 9a78e2a65f
commit 3210132a21
7 changed files with 3339 additions and 52 deletions
+2588 -40
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -6,7 +6,10 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"docx": "^9.7.0", "docx": "^9.7.0",
@@ -14,10 +17,16 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"jsdom": "^29.1.1",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"vite": "^5.4.1" "vite": "^5.4.1",
"vitest": "^4.1.7"
} }
} }
+10 -10
View File
@@ -41,10 +41,10 @@ const bookOptions = [
{ name: 'Revelation', abbrev: 'REV' }, { name: 'Revelation', abbrev: 'REV' },
]; ];
const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`; export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
function buildExportHtml(project) { export function buildExportHtml(project) {
const style = ` const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; } body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
.page { max-width: 900px; margin: auto; } .page { max-width: 900px; margin: auto; }
@@ -122,7 +122,7 @@ function buildExportHtml(project) {
</html>`; </html>`;
} }
function wordTableHtml(rows) { export function wordTableHtml(rows) {
return ` return `
<table> <table>
<thead> <thead>
@@ -141,7 +141,7 @@ function wordTableHtml(rows) {
`; `;
} }
function extractPartOfSpeech(definitionHtml) { export function extractPartOfSpeech(definitionHtml) {
const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || ''); const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || '');
if (match && match[1]) { if (match && match[1]) {
return match[1].trim(); return match[1].trim();
@@ -149,7 +149,7 @@ function extractPartOfSpeech(definitionHtml) {
return ''; return '';
} }
function htmlToPlainText(html = '') { export function htmlToPlainText(html = '') {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
const lines = []; const lines = [];
@@ -168,7 +168,7 @@ function htmlToPlainText(html = '') {
return lines.join('').replace(/\n{2,}/g, '\n').trim(); return lines.join('').replace(/\n{2,}/g, '\n').trim();
} }
function createParagraphsFromText(text) { export function createParagraphsFromText(text) {
return text return text
.split(/\n+/) .split(/\n+/)
.map((line) => line.trim()) .map((line) => line.trim())
@@ -176,7 +176,7 @@ function createParagraphsFromText(text) {
.map((line) => new Paragraph({ children: [new TextRun({ text: line })] })); .map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
} }
function renderVerseContent(content) { export function renderVerseContent(content) {
if (typeof content === 'string') { if (typeof content === 'string') {
return content; return content;
} }
@@ -198,7 +198,7 @@ function renderVerseContent(content) {
return ''; return '';
} }
function parseBibleChapter(data) { export function parseBibleChapter(data) {
if (data?.verses && Array.isArray(data.verses)) { if (data?.verses && Array.isArray(data.verses)) {
return data.verses.map((verse) => ({ return data.verses.map((verse) => ({
number: verse.number, number: verse.number,
@@ -904,7 +904,7 @@ const App = () => {
<span className="text-sm font-medium text-slate-600">Chapter verses</span> <span className="text-sm font-medium text-slate-600">Chapter verses</span>
<span className="text-xs text-slate-500">Click a verse, then shift-click an end verse.</span> <span className="text-xs text-slate-500">Click a verse, then shift-click an end verse.</span>
</div> </div>
<div className="max-h-[520px] overflow-y-auto rounded-3xl border border-slate-200 bg-white p-4 scrollbar-thin"> <div data-testid="verse-list" className="max-h-[520px] overflow-y-auto rounded-3xl border border-slate-200 bg-white p-4 scrollbar-thin">
{project.verses.map((verse) => { {project.verses.map((verse) => {
const inRange = rangeStart !== null && (verse.number >= Math.min(rangeStart, rangeEnd) && verse.number <= Math.max(rangeStart, rangeEnd)); const inRange = rangeStart !== null && (verse.number >= Math.min(rangeStart, rangeEnd) && verse.number <= Math.max(rangeStart, rangeEnd));
const inChunk = project.chunks.some((chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse); const inChunk = project.chunks.some((chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse);
+329
View File
@@ -0,0 +1,329 @@
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
import App from './App';
// ---------------------------------------------------------------------------
// Shared mock data
// ---------------------------------------------------------------------------
const mockChapterData = {
chapter: {
content: [
{ type: 'verse', number: 1, content: ['Paul, a servant of God.'] },
{ type: 'verse', number: 2, content: ['In hope of eternal life.'] },
{ type: 'verse', number: 3, content: ['Grace and peace from God.'] },
],
},
};
const mockGreekDefinition = [
{
topic: 'G4102',
lexeme: 'πίστις',
transliteration: 'pistis',
short_definition: 'faith, belief',
definition: '<p>Part(s) of speech: Noun</p><p>Faith or belief.</p>',
},
];
function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) {
return vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB', 'KJV']) });
}
if (url.includes('bible.helloao.org')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) });
}
if (url.includes('bolls.life') && greekData !== null) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(greekData) });
}
return Promise.resolve({ ok: false });
});
}
// ---------------------------------------------------------------------------
// Test lifecycle
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.stubGlobal('fetch', buildFetchMock());
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn() });
vi.spyOn(window, 'confirm').mockReturnValue(false);
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function loadChapter() {
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
return user;
}
function getVerseList() {
return screen.getByTestId('verse-list');
}
function clickVerse(textFragment) {
fireEvent.click(
within(getVerseList()).getByRole('button', { name: new RegExp(textFragment) }),
);
}
function shiftClickVerse(textFragment) {
fireEvent.click(
within(getVerseList()).getByRole('button', { name: new RegExp(textFragment) }),
{ shiftKey: true },
);
}
function addChunk(startFragment, endFragment) {
clickVerse(startFragment);
shiftClickVerse(endFragment);
}
function findChunkCounter(n, total) {
return screen.findByText((_, el) => el?.textContent === `Chunk ${n} of ${total}`);
}
// ---------------------------------------------------------------------------
// Initial render
// ---------------------------------------------------------------------------
describe('Initial render', () => {
test('shows the project setup form', () => {
render(<App />);
expect(screen.getByText('Project Setup')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
});
test('shows translation, book, and chapter inputs', () => {
render(<App />);
expect(screen.getByText('Translation')).toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument();
expect(screen.getByText('Chapter')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Loading a chapter
// ---------------------------------------------------------------------------
describe('Loading a chapter', () => {
test('transitions to the chunk setup view on success', async () => {
await loadChapter();
expect(screen.getByText(/Scripture & Chunks/i)).toBeInTheDocument();
});
test('renders verse text after loading', async () => {
await loadChapter();
expect(screen.getByText(/Paul, a servant of God/i)).toBeInTheDocument();
});
test('shows "0 created" initially', async () => {
await loadChapter();
expect(screen.getByText('0 created')).toBeInTheDocument();
});
test('shows an error message when the API returns a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB']) });
}
return Promise.resolve({ ok: false });
}));
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/unable to load chapter/i);
});
test('shows an error message when chapter data contains no verses', async () => {
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/invalid bible data/i);
});
});
// ---------------------------------------------------------------------------
// Chunk management
// ---------------------------------------------------------------------------
describe('Chunk management', () => {
test('creates a chunk by clicking a verse range', async () => {
await loadChapter();
addChunk('Paul, a servant', 'Grace and peace');
await screen.findByText(/1-3/);
});
test('chunk count increments after each addition', async () => {
await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
addChunk('In hope of eternal', 'Grace and peace');
await screen.findByText('2 created');
});
test('prevents adding a duplicate chunk range', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText(/That chunk already exists/i);
});
test('deletes a chunk', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
await screen.findByText('0 created');
});
test('"Begin Studying" is disabled when no chunks exist', async () => {
await loadChapter();
expect(screen.getByRole('button', { name: /begin studying/i })).toBeDisabled();
});
test('"Begin Studying" is enabled once a chunk exists', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /begin studying/i })).not.toBeDisabled();
});
});
// ---------------------------------------------------------------------------
// Study view navigation
// ---------------------------------------------------------------------------
async function goToStudyWith3Chunks() {
await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
addChunk('In hope of eternal', 'In hope of eternal');
addChunk('Grace and peace', 'Grace and peace');
await screen.findByText('3 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i);
// addChunk always selects the newest chunk; navigate back to chunk 1
// so navigation tests start from a known position.
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
}
describe('Study view navigation', () => {
test('transitions to study view when "Begin Studying" is clicked', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i);
});
test('"Previous Chunk" is disabled on the first chunk', async () => {
await goToStudyWith3Chunks();
expect(screen.getByRole('button', { name: /previous chunk/i })).toBeDisabled();
});
test('"Next Chunk" advances to the second chunk', async () => {
await goToStudyWith3Chunks();
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
});
test('"Next Chunk" is disabled on the last chunk', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(3, 3)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next chunk/i })).toBeDisabled();
});
test('"Previous Chunk" returns to prior chunk after advancing', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Greek word lookup
// ---------------------------------------------------------------------------
async function goToStudyAndAddGreekWord(fetchMock) {
if (fetchMock) vi.stubGlobal('fetch', fetchMock);
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i);
fireEvent.click(screen.getByRole('button', { name: /add greek word/i }));
await screen.findByPlaceholderText(/G4102 or faith/i);
}
describe('Greek word lookup', () => {
test('adds a Greek word entry form', async () => {
await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102 or faith/i)).toBeInTheDocument();
});
test('populates fields after a successful lookup', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDefinition }));
fireEvent.change(screen.getByPlaceholderText(/G4102 or faith/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
await screen.findByDisplayValue('πίστις');
expect(screen.getByDisplayValue('pistis')).toBeInTheDocument();
expect(screen.getByDisplayValue('faith, belief')).toBeInTheDocument();
});
test('shows "No definition found." when the API returns an empty array', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: [] }));
fireEvent.change(screen.getByPlaceholderText(/G4102 or faith/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
await screen.findByDisplayValue('No definition found.');
});
test('shows "Lookup failed." on network error', async () => {
await goToStudyAndAddGreekWord(
vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB']) });
}
if (url.includes('bible.helloao.org')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockChapterData) });
}
return Promise.reject(new Error('Network error'));
}),
);
fireEvent.change(screen.getByPlaceholderText(/G4102 or faith/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
await screen.findByDisplayValue('Lookup failed.');
});
test('lookup is skipped when query is empty', async () => {
const fetchSpy = buildFetchMock();
await goToStudyAndAddGreekWord(fetchSpy);
const callCountBefore = fetchSpy.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
await waitFor(() => {
expect(fetchSpy.mock.calls.length).toBe(callCountBefore);
});
});
test('removes a Greek word entry', async () => {
await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102 or faith/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => {
expect(screen.queryByPlaceholderText(/G4102 or faith/i)).not.toBeInTheDocument();
});
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+390
View File
@@ -0,0 +1,390 @@
import { describe, test, expect } from 'vitest';
import {
storageKey,
extractPartOfSpeech,
htmlToPlainText,
renderVerseContent,
parseBibleChapter,
wordTableHtml,
buildExportHtml,
createParagraphsFromText,
} 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>');
});
});
// ---------------------------------------------------------------------------
// buildExportHtml
// ---------------------------------------------------------------------------
const baseProject = {
title: 'Titus 1 Study',
translation: 'BSB',
book: 'Titus',
chapter: '1',
verses: [
{ number: 1, text: 'Paul, a servant of God.' },
{ number: 2, text: 'In hope of eternal life.' },
],
chunks: [
{
id: 'chunk-1',
startVerse: 1,
endVerse: 2,
notes: 'Key observations.',
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>',
},
],
},
],
};
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 study notes', () => {
expect(buildExportHtml(baseProject)).toContain('Key observations.');
});
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,
chunks: [{ ...baseProject.chunks[0], 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 notes." when chunk notes are empty', () => {
const project = { ...baseProject, chunks: [{ ...baseProject.chunks[0], notes: '' }] };
expect(buildExportHtml(project)).toContain('No notes.');
});
test('shows "No Greek word notes." when chunk has no Greek words', () => {
const project = { ...baseProject, chunks: [{ ...baseProject.chunks[0], greekWords: [] }] };
expect(buildExportHtml(project)).toContain('No Greek word notes.');
});
test('handles a project with no chunks', () => {
const project = { ...baseProject, 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,
verses: [
{ number: 1, text: 'Verse one text.' },
{ number: 2, text: 'Verse two text.' },
{ number: 3, text: 'Verse three text.' },
],
chunks: [{ ...baseProject.chunks[0], 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.');
});
});
// ---------------------------------------------------------------------------
// 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);
});
});
+10
View File
@@ -3,4 +3,14 @@ import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test-setup.js',
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.{js,jsx}'],
},
},
}); });