33 Commits

Author SHA1 Message Date
Claude b3c266fa66 Add a read-only Bible reader page (BSB)
Adds a "Read Bible" button on the home screen that opens a simple
chapter browser using the existing helloao BSB API, with book/chapter
pickers and prev/next navigation, separate from study projects.

https://claude.ai/code/session_013GXQxSaTF6zeBsSYTeF3fN
2026-06-12 03:38:01 +00:00
nmemmert d1861ec5d9 Fix broken test suite and untrack runtime artifacts
- Bridge jsdom localStorage/sessionStorage over Node 22+'s experimental
  globals that vitest's jsdom environment doesn't override
- Update Greek lookup tests to mock the OpenScriptures Strong's
  dictionary instead of the retired bolls.life path
- Update migrateChunk no-op test for backfilled fields
- Match en-dash verse range labels in chunk creation test
- Untrack .api.log/.api.pid and gitignore them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:40:44 -04:00
nmemmert c0ceb68faa Avoid side effect in state updater for audio chapter advance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:28:12 -04:00
nmemmert 9a3ed73d20 Fix BSB audio player not advancing past first chapter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:22:23 -04:00
nmemmert 0f08721b48 Add BSB full-book audio player card to home page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:15:28 -04:00
nmemmert 603c3bcbf2 Fix commentary JSON parse error for chapters without coverage 2026-06-11 14:45:47 -04:00
nmemmert bb92095ba4 Add word-by-word Greek/Hebrew interlinear under Scripture panel
Adds a collapsible Interlinear section beneath the Scripture text
(stacked and split layouts) showing each original-language word with
its BSB gloss, transliteration, parsing, and Strong's number on hover.
Data is generated offline from the Berean Standard Bible translation
tables via scripts/build-interlinear.mjs into per-book JSON files.
2026-06-11 14:29:24 -04:00
nmemmert 6c4658e244 Apply brand kit colors and fonts to DOCX export
Use antique gold accent, rich black text, Cormorant Garamond
headings, and Lora body text. Remove fill backgrounds, keeping
only the gold left-border scripture callout and section divider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:18:05 -04:00
nmemmert c41916b3ce Improve DOCX export styling and include missing fields
- Add background notes, tags, and final script to the export
- Use shaded section labels, accent color, and a callout-style scripture box
- Switch accent color from indigo to teal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:14:15 -04:00
nmemmert 6fb3bfbd82 Suggest existing tags when tagging chunks
Shows quick-add chips and a datalist of tags already used elsewhere
in this project or across all projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:04:30 -04:00
nmemmert 3e84ea37be Add DOCX upload to populate Final Script from a document
Uses mammoth to extract raw text from an uploaded .docx file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:59:31 -04:00
nmemmert e59bc25ed0 Add chunk tagging, persisted Split View prefs, sticky tab bar, and Strong's hover badges
- Tag chunks with topics; aggregate into project index and add a Home page tag filter
- Persist studyLayout/activeStudyTab in localStorage
- Make the Split View tab bar sticky while scrolling
- Show a hoverable Strong's number badge with gloss on each word study card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:51:23 -04:00
nmemmert 65039d44a5 Show expandable definitions in word study suggestion list
Lets users preview a word's full Strong's definition before
deciding whether to add it.
2026-06-11 08:37:45 -04:00
nmemmert 5d0cc2934f Add 2-pane split study layout with tabbed reference panels
Scripture stays pinned in a sticky left column while Notes,
Cross-Refs, Word Study, Commentary, and Script become tabs in
the right column. Toggle via "Split View" / "Stacked View".
2026-06-11 08:33:52 -04:00
nmemmert 6f1a311ba9 Add commentary panel and smart cross-reference suggestions with hover preview
Adds a Commentary section (HelloAO commentaries) and a "Suggest from
passage" cross-reference feature using the open-cross-ref dataset, with
hover popups that show the referenced verse text.
2026-06-11 08:27:32 -04:00
nmemmert f95d5670ae Stop tracking SQLite database files
Local DB state shouldn't be versioned; only code, migrations, and schema belong in git.
2026-06-10 11:01:32 -04:00
nmemmert faf9ba9e2e Make episode info, general notes, and final script per-chunk fields
Each chunk is unique, so these fields no longer share a single project-level value.
2026-06-10 10:56:54 -04:00
nmemmert 91b01c3117 Revert restart.sh systemd auto-restart, keep dev setup.sh workflow 2026-06-10 09:35:28 -04:00
nmemmert b40f03846d fix install 2026-06-10 09:29:54 -04:00
nmemmert 33107cf7af install fix 2026-06-10 09:23:36 -04:00
nmemmert 7bfbe826a0 BIG UPGRADE 2026-06-10 09:12:47 -04:00
nmemmert caa781d039 Fix missing verses and add cross-chapter chunk spanning 2026-06-01 15:56:14 -04:00
nmemmert 587eedd192 Add typed and bulk chunk range entry 2026-06-01 15:47:54 -04:00
nmemmert e3b14fb25b Merge branch 'claude/greek-suggest-cross-device-sync' 2026-06-01 15:38:16 -04:00
nmemmert 7eadb8500b Add OT books and separate Greek/Hebrew lookup 2026-06-01 15:38:11 -04:00
nmemmert be62be0aa6 Merge pull request #15 from nmemmert/claude/greek-suggest-cross-device-sync
Fix sync: consistent lastEdited timestamp + persistent error + retry …
2026-05-28 15:00:41 -04:00
nmemmert 0b4bb46866 Merge pull request #14 from nmemmert/claude/greek-suggest-cross-device-sync
Add English word field to Greek word cards
2026-05-28 13:33:18 -04:00
nmemmert 7117f70e4b Merge pull request #13 from nmemmert/claude/greek-suggest-cross-device-sync
Use macula-greek English glosses in suggest modal instead of KJV kjv_def
2026-05-28 13:19:36 -04:00
nmemmert b6a277e16c Merge pull request #12 from nmemmert/claude/greek-suggest-cross-device-sync
Clean up English word display in suggest modal
2026-05-28 13:05:20 -04:00
nmemmert d5c9761584 Merge pull request #11 from nmemmert/claude/greek-suggest-cross-device-sync
Fix suggest modal: portal to body + English word first
2026-05-28 12:59:43 -04:00
nmemmert 1ae39118c9 Merge pull request #10 from nmemmert/claude/greek-suggest-cross-device-sync
Replace auto-add with word picker modal for Greek suggest
2026-05-28 12:55:26 -04:00
nmemmert 337006c72d Merge pull request #9 from nmemmert/claude/greek-suggest-cross-device-sync
Fix Greek suggest: use local macula-greek concordance instead of brok…
2026-05-28 12:47:15 -04:00
nmemmert 945ff15a4f Merge pull request #8 from nmemmert/claude/greek-suggest-cross-device-sync
Add passage-aware Greek word suggest and cross-device stale-project sync
2026-05-28 12:27:01 -04:00
77 changed files with 4320 additions and 220 deletions
+5
View File
@@ -1,6 +1,11 @@
node_modules/ node_modules/
*.db
*.db-wal
*.db-shm
dist/ dist/
coverage/ coverage/
*.local *.local
.vite.pid .vite.pid
.vite.log .vite.log
.api.pid
.api.log
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Install Bible Study App as a systemd service on Rocky Linux (or any systemd distro).
#
# Usage:
# sudo ./deploy/install.sh [INSTALL_DIR]
#
# Defaults to /opt/study-app. Run from the project repo root.
set -euo pipefail
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (e.g. with sudo)." >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL_DIR="${1:-/opt/study-app}"
SERVICE_USER="study-app"
SERVICE_NAME="study-app"
echo "=== Installing Bible Study App to $INSTALL_DIR ==="
# ── Node check ────────────────────────────────────────────────────────────────
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: Node.js is not installed."
echo "On Rocky Linux, install via NodeSource, e.g.:"
echo " curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -"
echo " sudo dnf install -y nodejs"
exit 1
fi
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_VERSION" -lt 18 ]; then
echo "ERROR: Node.js v18+ required (found $(node -v))."
exit 1
fi
echo "Node.js $(node -v) found."
# build tools needed for better-sqlite3 native module
if ! command -v gcc >/dev/null 2>&1 || ! command -v make >/dev/null 2>&1; then
echo "Installing build tools (Development Tools group + python3)..."
dnf groupinstall -y "Development Tools"
dnf install -y python3
fi
# ── Create service user ─────────────────────────────────────────────────────
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
echo "Creating service user '$SERVICE_USER'..."
useradd --system --home-dir "$INSTALL_DIR" --shell /sbin/nologin "$SERVICE_USER"
fi
# ── Copy app files ────────────────────────────────────────────────────────────
echo "Copying application files to $INSTALL_DIR..."
mkdir -p "$INSTALL_DIR"
rsync -a --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'dist' \
--exclude '*.pid' \
--exclude '*.log' \
"$ROOT_DIR"/ "$INSTALL_DIR"/
cd "$INSTALL_DIR"
# ── Install dependencies & build ─────────────────────────────────────────────
echo "Installing dependencies (this can take a while for better-sqlite3)..."
npm ci || npm install
echo "Building production frontend..."
npx vite build
echo "Removing dev dependencies..."
npm prune --omit=dev
# ── Permissions ──────────────────────────────────────────────────────────────
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# ── systemd unit ──────────────────────────────────────────────────────────────
echo "Installing systemd unit..."
sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#" \
"$ROOT_DIR/deploy/study-app.service" > "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
echo ""
echo "=== Done ==="
echo "Service status: systemctl status $SERVICE_NAME"
echo "Logs: journalctl -u $SERVICE_NAME -f"
echo "App listens on: http://0.0.0.0:\${PORT:-3001}"
echo ""
echo "If you have a firewall enabled, allow the port, e.g.:"
echo " sudo firewall-cmd --add-port=3001/tcp --permanent && sudo firewall-cmd --reload"
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Bible Study App
After=network.target
[Service]
Type=simple
# Edit these to match your deployment
User=study-app
Group=study-app
WorkingDirectory=/opt/study-app
Environment=NODE_ENV=production
Environment=PORT=3001
ExecStart=/usr/bin/node server/index.js
Restart=on-failure
RestartSec=5
# Hardening (relax if it causes issues)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/study-app
[Install]
WantedBy=multi-user.target
+1753 -18
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -15,10 +15,12 @@
"coverage": "vitest run --coverage" "coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^9.4.3", "better-sqlite3": "^12.10.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"docx": "^9.7.0", "docx": "^9.7.0",
"dompurify": "^3.4.9",
"express": "^4.19.2", "express": "^4.19.2",
"mammoth": "^1.12.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
@@ -29,6 +31,7 @@
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^4.1.7", "@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"csv-parse": "^6.2.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+96
View File
@@ -0,0 +1,96 @@
// One-time/offline data prep: convert the BSB Translation Tables (bereanbible.com/bsb_tables.tsv)
// into per-book JSON files of word-by-word interlinear data for /public/interlinear/.
//
// Usage: node scripts/build-interlinear.mjs /path/to/bsb_tables.tsv
import fs from 'fs';
import path from 'path';
import { parse } from 'csv-parse/sync';
const BOOK_ABBREV = {
Genesis: 'GEN', Exodus: 'EXO', Leviticus: 'LEV', Numbers: 'NUM', Deuteronomy: 'DEU',
Joshua: 'JOS', Judges: 'JDG', Ruth: 'RUT', '1 Samuel': '1SA', '2 Samuel': '2SA',
'1 Kings': '1KI', '2 Kings': '2KI', '1 Chronicles': '1CH', '2 Chronicles': '2CH',
Ezra: 'EZR', Nehemiah: 'NEH', Esther: 'EST', Job: 'JOB', Psalm: 'PSA', Proverbs: 'PRO',
Ecclesiastes: 'ECC', 'Song of Solomon': 'SNG', Isaiah: 'ISA', Jeremiah: 'JER',
Lamentations: 'LAM', Ezekiel: 'EZK', Daniel: 'DAN', Hosea: 'HOS', Joel: 'JOL',
Amos: 'AMO', Obadiah: 'OBA', Jonah: 'JON', Micah: 'MIC', Nahum: 'NAM', Habakkuk: 'HAB',
Zephaniah: 'ZEP', Haggai: 'HAG', Zechariah: 'ZEC', Malachi: 'MAL',
Matthew: 'MAT', Mark: 'MRK', Luke: 'LUK', John: 'JHN', Acts: 'ACT', Romans: 'ROM',
'1 Corinthians': '1CO', '2 Corinthians': '2CO', Galatians: 'GAL', Ephesians: 'EPH',
Philippians: 'PHP', Colossians: 'COL', '1 Thessalonians': '1TH', '2 Thessalonians': '2TH',
'1 Timothy': '1TI', '2 Timothy': '2TI', Titus: 'TIT', Philemon: 'PHM', Hebrews: 'HEB',
James: 'JAS', '1 Peter': '1PE', '2 Peter': '2PE', '1 John': '1JN', '2 John': '2JN',
'3 John': '3JN', Jude: 'JUD', Revelation: 'REV',
};
const inputPath = process.argv[2];
if (!inputPath) {
console.error('Usage: node scripts/build-interlinear.mjs /path/to/bsb_tables.tsv');
process.exit(1);
}
const outDir = path.resolve('public/interlinear');
fs.mkdirSync(outDir, { recursive: true });
const raw = fs.readFileSync(inputPath, 'utf-8');
const records = parse(raw, { delimiter: '\t', columns: false, relax_column_count: true });
const header = records[0];
const idx = Object.fromEntries(header.map((h, i) => [h.trim(), i]));
// books[ABBREV][chapter][verse] = [{ o, t, p, s, g }]
const books = {};
let curVerseId = '';
for (let i = 1; i < records.length; i++) {
const row = records[i];
const verseId = row[idx.VerseId];
if (verseId) curVerseId = verseId;
if (!curVerseId) continue;
const lang = row[idx.Language];
const original = (lang === 'Hebrew' || lang === 'Aramaic')
? row[idx['WLC / Nestle Base TR RP WH NE NA SBL']]
: row[idx['WLC / Nestle Base TR RP WH NE NA SBL']];
if (!original || !original.trim()) continue;
const strongs = row[idx['Str Heb']] || row[idx['Str Grk']] || '';
const gloss = (row[idx['BSB version']] || '').replace(/\s+/g, ' ').trim();
const translit = (row[idx.Translit] || '').trim();
const parsing = (row[idx.Parsing] || '').trim();
const sortKey = (lang === 'Hebrew' || lang === 'Aramaic')
? row[idx['Heb Sort']]
: row[idx['Greek Sort']];
const m = curVerseId.match(/^(.*) (\d+):(\d+)$/);
if (!m) continue;
const [, bookName, chapter, verse] = m;
const abbrev = BOOK_ABBREV[bookName];
if (!abbrev) continue;
books[abbrev] ??= {};
books[abbrev][chapter] ??= {};
books[abbrev][chapter][verse] ??= [];
books[abbrev][chapter][verse].push({
sort: Number(sortKey) || 0,
o: original.trim(),
t: translit,
p: parsing,
s: strongs ? `${(lang === 'Hebrew' || lang === 'Aramaic') ? 'H' : 'G'}${strongs}` : '',
g: gloss,
});
}
let fileCount = 0;
for (const [abbrev, chapters] of Object.entries(books)) {
for (const chapter of Object.values(chapters)) {
for (const verse of Object.values(chapter)) {
verse.sort((a, b) => a.sort - b.sort);
for (const w of verse) delete w.sort;
}
}
fs.writeFileSync(path.join(outDir, `${abbrev}.json`), JSON.stringify(chapters));
fileCount++;
}
console.log(`Wrote ${fileCount} book files to ${outDir}`);
+2219 -171
View File
File diff suppressed because it is too large Load Diff
+31 -27
View File
@@ -16,15 +16,15 @@ const mockChapterData = {
}, },
}; };
const mockGreekDefinition = [ // Mirrors the OpenScriptures Strong's Greek dictionary format loaded from jsdelivr.
{ const mockGreekDict = {
topic: 'G4102', G4102: {
lexeme: 'πίστις', lemma: 'πίστις',
transliteration: 'pistis', translit: 'pistis',
short_definition: 'faith, belief', kjv_def: 'faith, belief',
definition: '<p>Part(s) of speech: Noun</p><p>Faith or belief.</p>', strongs_def: 'persuasion, i.e. credence; moral conviction',
}, },
]; };
function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) { function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) {
return vi.fn((url) => { return vi.fn((url) => {
@@ -34,8 +34,11 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
if (url.includes('bible.helloao.org')) { if (url.includes('bible.helloao.org')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) }); return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) });
} }
if (url.includes('bolls.life') && greekData !== null) { if (url.includes('strongs-greek-dictionary') && greekData !== null) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(greekData) }); return Promise.resolve({
ok: true,
text: () => Promise.resolve(`var strongsGreekDictionary = ${JSON.stringify(greekData)};`),
});
} }
return Promise.resolve({ ok: false }); return Promise.resolve({ ok: false });
}); });
@@ -45,10 +48,11 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
// Test lifecycle // Test lifecycle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('fetch', buildFetchMock());
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn() });
vi.spyOn(window, 'confirm').mockReturnValue(false);
localStorage.clear(); localStorage.clear();
vi.stubGlobal('fetch', buildFetchMock());
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(window, 'confirm').mockReturnValue(false);
}); });
afterEach(() => { afterEach(() => {
@@ -182,7 +186,7 @@ describe('Chunk management', () => {
test('creates a chunk by clicking a verse range', async () => { test('creates a chunk by clicking a verse range', async () => {
await loadChapter(); await loadChapter();
addChunk('Paul, a servant', 'Grace and peace'); addChunk('Paul, a servant', 'Grace and peace');
await screen.findByText(/1-3/); await screen.findByText(/1[-]3/);
}); });
test('chunk count increments after each addition', async () => { test('chunk count increments after each addition', async () => {
@@ -289,28 +293,28 @@ async function goToStudyAndAddGreekWord(fetchMock) {
fireEvent.click(screen.getByRole('button', { name: /begin studying/i })); fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i); await screen.findByText(/chunk editor/i);
fireEvent.click(screen.getByRole('button', { name: /add greek word/i })); fireEvent.click(screen.getByRole('button', { name: /add greek word/i }));
await screen.findByPlaceholderText(/G4102, 4102/i); await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
} }
describe('Greek word lookup', () => { describe('Greek word lookup', () => {
test('adds a Greek word entry form', async () => { test('adds a Greek word entry form', async () => {
await goToStudyAndAddGreekWord(); await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102, 4102/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
}); });
test('populates fields after a successful lookup', async () => { test('populates fields after a successful lookup', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDefinition })); await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDict }));
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } }); fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i })); fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('πίστις'); await screen.findByDisplayValue('πίστις');
expect(screen.getByDisplayValue('pistis')).toBeInTheDocument(); expect(screen.getByDisplayValue('pistis')).toBeInTheDocument();
expect(screen.getByDisplayValue('faith, belief')).toBeInTheDocument(); expect(screen.getByDisplayValue('faith, belief')).toBeInTheDocument();
}); });
test('shows "No definition found." when the API returns an empty array', async () => { test('shows "No definition found." when the API returns an empty array', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: [] })); await goToStudyAndAddGreekWord(buildFetchMock({ greekData: {} }));
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } }); fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i })); fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('No definition found.'); await screen.findByDisplayValue('No definition found.');
}); });
@@ -326,8 +330,8 @@ describe('Greek word lookup', () => {
return Promise.reject(new Error('Network error')); return Promise.reject(new Error('Network error'));
}), }),
); );
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } }); fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i })); fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('Lookup failed.'); await screen.findByDisplayValue('Lookup failed.');
}); });
@@ -335,7 +339,7 @@ describe('Greek word lookup', () => {
const fetchSpy = buildFetchMock(); const fetchSpy = buildFetchMock();
await goToStudyAndAddGreekWord(fetchSpy); await goToStudyAndAddGreekWord(fetchSpy);
const callCountBefore = fetchSpy.mock.calls.length; const callCountBefore = fetchSpy.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: /look up/i })); fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await waitFor(() => { await waitFor(() => {
expect(fetchSpy.mock.calls.length).toBe(callCountBefore); expect(fetchSpy.mock.calls.length).toBe(callCountBefore);
}); });
@@ -343,10 +347,10 @@ describe('Greek word lookup', () => {
test('removes a Greek word entry', async () => { test('removes a Greek word entry', async () => {
await goToStudyAndAddGreekWord(); await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102, 4102/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i })); fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByPlaceholderText(/G4102, 4102/i)).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText(/G4102, H7225, 4102/i)).not.toBeInTheDocument();
}); });
}); });
}); });
+17
View File
@@ -1 +1,18 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
// Node 22+ defines experimental localStorage/sessionStorage globals that are
// undefined unless --localstorage-file is passed. Vitest's jsdom environment
// skips copying window keys that already exist on the Node global, so jsdom's
// storage objects get shadowed. Bridge them from the raw jsdom instance.
const jsdomWindow = globalThis.jsdom?.window;
if (jsdomWindow) {
for (const key of ['localStorage', 'sessionStorage']) {
if (typeof globalThis[key] === 'undefined' && jsdomWindow[key]) {
Object.defineProperty(globalThis, key, {
value: jsdomWindow[key],
writable: true,
configurable: true,
});
}
}
}
+9 -2
View File
@@ -263,9 +263,16 @@ describe('migrateChunk', () => {
expect(result.crossReferences).toEqual([]); expect(result.crossReferences).toEqual([]);
}); });
test('is a no-op when chunk already has observation field', () => { 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 modern = { id: 'c1', startVerse: 1, endVerse: 1, observation: 'Already migrated.', interpretation: '', application: '', crossReferences: [], greekWords: [] };
expect(migrateChunk(modern)).toBe(modern); 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', () => { test('handles missing notes gracefully', () => {
+3
View File
@@ -16,6 +16,9 @@ export default defineConfig({
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',
environmentOptions: {
jsdom: { url: 'http://localhost:3000/' },
},
globals: true, globals: true,
setupFiles: './src/test-setup.js', setupFiles: './src/test-setup.js',
coverage: { coverage: {