Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3c266fa66 | |||
| d1861ec5d9 | |||
| c0ceb68faa | |||
| 9a3ed73d20 | |||
| 0f08721b48 | |||
| 603c3bcbf2 | |||
| bb92095ba4 | |||
| 6c4658e244 | |||
| c41916b3ce | |||
| 6fb3bfbd82 | |||
| 3e84ea37be | |||
| e59bc25ed0 | |||
| 65039d44a5 | |||
| 5d0cc2934f | |||
| 6f1a311ba9 | |||
| f95d5670ae | |||
| faf9ba9e2e | |||
| 91b01c3117 | |||
| b40f03846d | |||
| 33107cf7af | |||
| 7bfbe826a0 | |||
| caa781d039 | |||
| 587eedd192 | |||
| e3b14fb25b | |||
| 7eadb8500b | |||
| be62be0aa6 | |||
| 0b4bb46866 | |||
| 7117f70e4b | |||
| b6a277e16c | |||
| d5c9761584 | |||
| 1ae39118c9 | |||
| 337006c72d | |||
| 945ff15a4f |
@@ -1,6 +1,11 @@
|
||||
node_modules/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
dist/
|
||||
coverage/
|
||||
*.local
|
||||
.vite.pid
|
||||
.vite.log
|
||||
.api.pid
|
||||
.api.log
|
||||
|
||||
Executable
+92
@@ -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"
|
||||
@@ -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
|
||||
Generated
+1753
-18
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -15,10 +15,12 @@
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"docx": "^9.7.0",
|
||||
"dompurify": "^3.4.9",
|
||||
"express": "^4.19.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
@@ -29,6 +31,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"csv-parse": "^6.2.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.35",
|
||||
"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
@@ -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}`);
|
||||
+2193
-145
File diff suppressed because it is too large
Load Diff
+31
-27
@@ -16,15 +16,15 @@ const mockChapterData = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockGreekDefinition = [
|
||||
{
|
||||
topic: 'G4102',
|
||||
lexeme: 'πίστις',
|
||||
transliteration: 'pistis',
|
||||
short_definition: 'faith, belief',
|
||||
definition: '<p>Part(s) of speech: Noun</p><p>Faith or belief.</p>',
|
||||
// Mirrors the OpenScriptures Strong's Greek dictionary format loaded from jsdelivr.
|
||||
const mockGreekDict = {
|
||||
G4102: {
|
||||
lemma: 'πίστις',
|
||||
translit: 'pistis',
|
||||
kjv_def: 'faith, belief',
|
||||
strongs_def: 'persuasion, i.e. credence; moral conviction',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) {
|
||||
return vi.fn((url) => {
|
||||
@@ -34,8 +34,11 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
|
||||
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) });
|
||||
if (url.includes('strongs-greek-dictionary') && greekData !== null) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(`var strongsGreekDictionary = ${JSON.stringify(greekData)};`),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false });
|
||||
});
|
||||
@@ -45,10 +48,11 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
|
||||
// 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();
|
||||
vi.stubGlobal('fetch', buildFetchMock());
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -182,7 +186,7 @@ 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/);
|
||||
await screen.findByText(/1[-–]3/);
|
||||
});
|
||||
|
||||
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 }));
|
||||
await screen.findByText(/chunk editor/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', () => {
|
||||
test('adds a Greek word entry form', async () => {
|
||||
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 () => {
|
||||
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDefinition }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
|
||||
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDict }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up greek/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, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
|
||||
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: {} }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
|
||||
await screen.findByDisplayValue('No definition found.');
|
||||
});
|
||||
|
||||
@@ -326,8 +330,8 @@ describe('Greek word lookup', () => {
|
||||
return Promise.reject(new Error('Network error'));
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
|
||||
await screen.findByDisplayValue('Lookup failed.');
|
||||
});
|
||||
|
||||
@@ -335,7 +339,7 @@ describe('Greek word lookup', () => {
|
||||
const fetchSpy = buildFetchMock();
|
||||
await goToStudyAndAddGreekWord(fetchSpy);
|
||||
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(() => {
|
||||
expect(fetchSpy.mock.calls.length).toBe(callCountBefore);
|
||||
});
|
||||
@@ -343,10 +347,10 @@ describe('Greek word lookup', () => {
|
||||
|
||||
test('removes a Greek word entry', async () => {
|
||||
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 }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText(/G4102, 4102/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/G4102, H7225, 4102/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1,18 @@
|
||||
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
@@ -263,9 +263,16 @@ describe('migrateChunk', () => {
|
||||
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: [] };
|
||||
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', () => {
|
||||
|
||||
@@ -16,6 +16,9 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
environmentOptions: {
|
||||
jsdom: { url: 'http://localhost:3000/' },
|
||||
},
|
||||
globals: true,
|
||||
setupFiles: './src/test-setup.js',
|
||||
coverage: {
|
||||
|
||||
Reference in New Issue
Block a user