Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
# Default: normalize line endings to LF on commit. Windows checkouts without
# core.autocrlf configured will no longer introduce CRLF into the repo.
* text=auto eol=lf

# Binary assets: never touch line endings
*.db binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.ico binary

# LFS-tracked source art
_tools/art_sources/*.jpg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.jpeg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.png filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion _tools/build_sqlite.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
from build_sqlite_schema import create_schema
from build_sqlite_loaders import (
populate_books, populate_chapters, populate_verses,
populate_book_intros, populate_people, populate_scholars,
populate_book_intros, populate_proof_text_guards, populate_people, populate_scholars,
populate_places, populate_map_stories, populate_ancient_borders,
populate_word_studies,
populate_synoptic, populate_topics, populate_debate_topics,
Expand DownExpand Up@@ -93,6 +93,7 @@ def main():

print(f" [OK] verses: {populate_verses(cur)} rows")
print(f" [OK] book_intros: {populate_book_intros(cur)} rows")
print(f" [OK] proof_text_guards: {populate_proof_text_guards(cur)} rows")
print(f" [OK] people: {populate_people(cur)} rows")
print(f" [OK] scholars: {populate_scholars(cur)} rows")
print(f" [OK] places: {populate_places(cur)} rows")
Expand Down
46 changes: 46 additions & 0 deletions _tools/build_sqlite_loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
AUDIT_DIR = ROOT / '_tools' / 'audit'
REFERENCE_MATRIX_PATH = AUDIT_DIR / 'reference_matrix.json'
TRANSLATIONS_DIR = ROOT / 'app' / 'assets' / 'translations'
PROOF_TEXT_GUARDS_PATH = META / 'proof-text-guards.json'

# Translations config — must match build_sqlite.py
AVAILABLE_TRANSLATIONS = {'kjv', 'asv'}
Expand DownExpand Up@@ -471,6 +472,51 @@ def populate_book_intros(cur):
return count


def populate_proof_text_guards(cur):
if not PROOF_TEXT_GUARDS_PATH.exists():
return 0
guards = _load_json(PROOF_TEXT_GUARDS_PATH)
count = 0
for guard in guards:
suggested = guard.get('suggested_chapter')
# Each guard must explicitly name the chapter to point the reader at. A
# self-reference is valid (the UX is often "read this verse in the full
# context of its own chapter"), but silently falling back to the guard's
# own location on a missing/malformed `suggested_chapter` masks
# data-quality bugs. Fail loudly instead.
if not isinstance(suggested, dict):
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} is missing "
f"`suggested_chapter` or it is not an object"
)
suggested_book_id = suggested.get('book_id')
suggested_chapter_num = suggested.get('chapter_num')
if not suggested_book_id or not suggested_chapter_num:
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} has incomplete "
f"`suggested_chapter` (book_id={suggested_book_id!r}, "
f"chapter_num={suggested_chapter_num!r}); both are required"
)
cur.execute(
'INSERT OR REPLACE INTO proof_text_guards '
'(ref, book_id, chapter_num, verse_num, common_misreading, '
'actual_context_summary, suggested_book_id, suggested_chapter_num) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
guard['ref'],
guard['book_id'],
guard['chapter_num'],
guard['verse_num'],
guard['common_misreading'],
guard['actual_context_summary'],
suggested_book_id,
suggested_chapter_num,
),
)

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker #3 — silent fallback hides malformed data

suggested.get('book_id', guard['book_id']),
suggested.get('chapter_num', guard['chapter_num']),

If suggested_chapter is missing or malformed in the source JSON, this falls back to the same verse the reader was just warned about — sending them back to re-read the out-of-context passage they were supposed to step out of.

Fix: Require suggested_chapter explicitly. Either raise on missing, or add a check in schema_validator.py that every proof-text-guard has a non-null suggested_chapter.book_id and suggested_chapter.chapter_num different from the guard's own location.

count += 1
return count


def populate_people(cur):
data = _load_json(META / 'people.json')
people = data.get('people', [])
Expand Down
13 changes: 13 additions & 0 deletions _tools/build_sqlite_schema.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,19 @@
at_a_glance TEXT
);

CREATE TABLE proof_text_guards (
ref TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id),
chapter_num INTEGER NOT NULL,
verse_num INTEGER NOT NULL,
common_misreading TEXT NOT NULL,
actual_context_summary TEXT NOT NULL,
suggested_book_id TEXT NOT NULL,
suggested_chapter_num INTEGER NOT NULL
);
CREATE INDEX idx_proof_text_guards_lookup
ON proof_text_guards(book_id, chapter_num, verse_num);

CREATE TABLE people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
24 changes: 24 additions & 0 deletions app/__tests__/components/guidedStudy/ConfidenceBadge.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ConfidenceBadge } from '@/components/guidedStudy/ConfidenceBadge';

describe('ConfidenceBadge', () => {
it('renders the human label for each confidence level', () => {
const { getByText, rerender } = renderWithProviders(<ConfidenceBadge level="consensus" />);
expect(getByText('Consensus')).toBeTruthy();

rerender(<ConfidenceBadge level="majority" />);
expect(getByText('Majority')).toBeTruthy();

rerender(<ConfidenceBadge level="debated" />);
expect(getByText('Debated')).toBeTruthy();

rerender(<ConfidenceBadge level="minority" />);
expect(getByText('Minority')).toBeTruthy();
});

it('renders nothing when level is undefined', () => {
const { toJSON } = renderWithProviders(<ConfidenceBadge />);
expect(toJSON()).toBeNull();
});
});
51 changes: 51 additions & 0 deletions app/__tests__/components/guidedStudy/ContextGuardBanner.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ContextGuardBanner } from '@/components/guidedStudy/ContextGuardBanner';

const guard = {
ref: 'jeremiah 29:11',
book_id: 'jeremiah',
chapter_num: 29,
verse_num: 11,
common_misreading: 'Personal promise of prosperity',
actual_context_summary: 'Letter to Judean exiles in Babylon',
suggested_book_id: 'jeremiah',
suggested_chapter_num: 29,
};

describe('ContextGuardBanner', () => {
it('renders nothing when no guard is passed', () => {
const { toJSON } = renderWithProviders(
<ContextGuardBanner guard={null} onReadContext={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders the common-misreading copy and action link', () => {
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(getByText('READ IN CONTEXT')).toBeTruthy();
expect(getByText('Personal promise of prosperity')).toBeTruthy();
expect(getByText('Open the surrounding chapter')).toBeTruthy();
});

it('fires onReadContext when the action row is tapped', () => {
const onReadContext = jest.fn();
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={onReadContext} />,
);
fireEvent.press(getByText('Open the surrounding chapter'));
expect(onReadContext).toHaveBeenCalledTimes(1);
});

it('hides itself after the dismiss button is tapped', () => {
const { queryByText, getByLabelText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(queryByText('READ IN CONTEXT')).toBeTruthy();
fireEvent.press(getByLabelText('Dismiss context note'));
expect(queryByText('READ IN CONTEXT')).toBeNull();
});
});
36 changes: 36 additions & 0 deletions app/__tests__/components/guidedStudy/HomeReviewDueCard.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { HomeReviewDueCard } from '@/components/guidedStudy/HomeReviewDueCard';

describe('HomeReviewDueCard', () => {
it('renders nothing when count is zero', () => {
const { toJSON } = renderWithProviders(
<HomeReviewDueCard count={0} onPress={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders singular copy when exactly 1 prompt is due', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={1} onPress={jest.fn()} />,
);
expect(getByText('1 prompt due today')).toBeTruthy();
});

it('renders pluralized copy for multiple prompts', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={7} onPress={jest.fn()} />,
);
expect(getByText('7 prompts due today')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<HomeReviewDueCard count={3} onPress={onPress} />,
);
fireEvent.press(getByLabelText('3 study review prompts due'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { PanelRecommendationRow } from '@/components/guidedStudy/PanelRecommendationRow';

describe('PanelRecommendationRow', () => {
const baseRec = {
key: 'ctx-1',
title: 'Historical Context',
subtitle: 'Ancient Near Eastern background',
panelType: 'ctx',
};

it('renders title and subtitle', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={jest.fn()} />,
);
expect(getByText('Historical Context')).toBeTruthy();
expect(getByText('Ancient Near Eastern background')).toBeTruthy();
});

it('renders a confidence badge when a level is provided', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow
recommendation={{ ...baseRec, confidence: 'debated' } as any}
onPress={jest.fn()}
/>,
);
expect(getByText('Debated')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Open Historical Context'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
25 changes: 25 additions & 0 deletions app/__tests__/components/guidedStudy/StudySessionCTA.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionCTA } from '@/components/guidedStudy/StudySessionCTA';

describe('StudySessionCTA', () => {
const estimate = { readMin: 3, guidedMin: 8, deepMin: 20 };

it('renders the estimate line with all three durations', () => {
const { getByText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={jest.fn()} />,
);
expect(getByText('Study this chapter')).toBeTruthy();
expect(getByText('3 min read · 8 min guided · 20 min deep')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Study this chapter'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionStepper } from '@/components/guidedStudy/StudySessionStepper';

describe('StudySessionStepper', () => {
it('renders all five canonical steps', () => {
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={jest.fn()} />,
);
['Scene', 'Observe', 'Explore', 'Synthesize', 'Review'].forEach((label) => {
expect(getByText(label)).toBeTruthy();
});
});

it('fires onSelect with the tapped step key', () => {
const onSelect = jest.fn();
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={onSelect} />,
);
fireEvent.press(getByText('Explore'));
expect(onSelect).toHaveBeenCalledWith('explore');
});
});
16 changes: 10 additions & 6 deletions app/__tests__/db/userDatabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@
const mockExecAsync = jest.fn().mockResolvedValue(undefined);
const mockGetAllAsync = jest.fn().mockResolvedValue([]);
const mockRunAsync = jest.fn().mockResolvedValue({ changes: 0 });
const mockWithTransactionAsync = jest.fn().mockImplementation(async (cb: () => Promise<void>) => cb());
const mockWithTransactionAsync = jest
.fn()
.mockImplementation(async (cb: () => Promise<void>) => cb());
const mockOpenDatabaseAsync = jest.fn();

jest.mock('expo-sqlite', () => ({
Expand DownExpand Up@@ -76,11 +78,12 @@ describe('userDatabase', () => {
it('skips already applied migrations', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate all migrations already applied
mockGetAllAsync.mockResolvedValueOnce(
Array.from({ length: 18 }, (_, i) => ({ version: i + 1 })),
Array.from({ length: MIGRATION_COUNT }, (_, i) => ({ version: i + 1 })),
);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// withTransactionAsync should NOT have been called since all migrations are applied
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
Expand All@@ -89,12 +92,13 @@ describe('userDatabase', () => {
it('runs pending migrations in order', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate only version 1 applied
mockGetAllAsync.mockResolvedValueOnce([{ version: 1 }]);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// Should run 17 remaining migrations (2 through 18)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(17);
// Should run the remaining migrations (2 through MIGRATION_COUNT)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(MIGRATION_COUNT - 1);
});

it('throws on migration failure', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Implement guided study session v1 by CraigBuckmaster · Pull Request #1580 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
# Default: normalize line endings to LF on commit. Windows checkouts without
# core.autocrlf configured will no longer introduce CRLF into the repo.
* text=auto eol=lf

# Binary assets: never touch line endings
*.db binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.ico binary

# LFS-tracked source art
_tools/art_sources/*.jpg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.jpeg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.png filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion _tools/build_sqlite.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
from build_sqlite_schema import create_schema
from build_sqlite_loaders import (
populate_books, populate_chapters, populate_verses,
populate_book_intros, populate_people, populate_scholars,
populate_book_intros, populate_proof_text_guards, populate_people, populate_scholars,
populate_places, populate_map_stories, populate_ancient_borders,
populate_word_studies,
populate_synoptic, populate_topics, populate_debate_topics,
Expand DownExpand Up@@ -93,6 +93,7 @@ def main():

print(f" [OK] verses: {populate_verses(cur)} rows")
print(f" [OK] book_intros: {populate_book_intros(cur)} rows")
print(f" [OK] proof_text_guards: {populate_proof_text_guards(cur)} rows")
print(f" [OK] people: {populate_people(cur)} rows")
print(f" [OK] scholars: {populate_scholars(cur)} rows")
print(f" [OK] places: {populate_places(cur)} rows")
Expand Down
46 changes: 46 additions & 0 deletions _tools/build_sqlite_loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
AUDIT_DIR = ROOT / '_tools' / 'audit'
REFERENCE_MATRIX_PATH = AUDIT_DIR / 'reference_matrix.json'
TRANSLATIONS_DIR = ROOT / 'app' / 'assets' / 'translations'
PROOF_TEXT_GUARDS_PATH = META / 'proof-text-guards.json'

# Translations config — must match build_sqlite.py
AVAILABLE_TRANSLATIONS = {'kjv', 'asv'}
Expand DownExpand Up@@ -471,6 +472,51 @@ def populate_book_intros(cur):
return count


def populate_proof_text_guards(cur):
if not PROOF_TEXT_GUARDS_PATH.exists():
return 0
guards = _load_json(PROOF_TEXT_GUARDS_PATH)
count = 0
for guard in guards:
suggested = guard.get('suggested_chapter')
# Each guard must explicitly name the chapter to point the reader at. A
# self-reference is valid (the UX is often "read this verse in the full
# context of its own chapter"), but silently falling back to the guard's
# own location on a missing/malformed `suggested_chapter` masks
# data-quality bugs. Fail loudly instead.
if not isinstance(suggested, dict):
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} is missing "
f"`suggested_chapter` or it is not an object"
)
suggested_book_id = suggested.get('book_id')
suggested_chapter_num = suggested.get('chapter_num')
if not suggested_book_id or not suggested_chapter_num:
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} has incomplete "
f"`suggested_chapter` (book_id={suggested_book_id!r}, "
f"chapter_num={suggested_chapter_num!r}); both are required"
)
cur.execute(
'INSERT OR REPLACE INTO proof_text_guards '
'(ref, book_id, chapter_num, verse_num, common_misreading, '
'actual_context_summary, suggested_book_id, suggested_chapter_num) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
guard['ref'],
guard['book_id'],
guard['chapter_num'],
guard['verse_num'],
guard['common_misreading'],
guard['actual_context_summary'],
suggested_book_id,
suggested_chapter_num,
),
)

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker #3 — silent fallback hides malformed data

suggested.get('book_id', guard['book_id']),
suggested.get('chapter_num', guard['chapter_num']),

If suggested_chapter is missing or malformed in the source JSON, this falls back to the same verse the reader was just warned about — sending them back to re-read the out-of-context passage they were supposed to step out of.

Fix: Require suggested_chapter explicitly. Either raise on missing, or add a check in schema_validator.py that every proof-text-guard has a non-null suggested_chapter.book_id and suggested_chapter.chapter_num different from the guard's own location.

count += 1
return count


def populate_people(cur):
data = _load_json(META / 'people.json')
people = data.get('people', [])
Expand Down
13 changes: 13 additions & 0 deletions _tools/build_sqlite_schema.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,19 @@
at_a_glance TEXT
);

CREATE TABLE proof_text_guards (
ref TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id),
chapter_num INTEGER NOT NULL,
verse_num INTEGER NOT NULL,
common_misreading TEXT NOT NULL,
actual_context_summary TEXT NOT NULL,
suggested_book_id TEXT NOT NULL,
suggested_chapter_num INTEGER NOT NULL
);
CREATE INDEX idx_proof_text_guards_lookup
ON proof_text_guards(book_id, chapter_num, verse_num);

CREATE TABLE people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
24 changes: 24 additions & 0 deletions app/__tests__/components/guidedStudy/ConfidenceBadge.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ConfidenceBadge } from '@/components/guidedStudy/ConfidenceBadge';

describe('ConfidenceBadge', () => {
it('renders the human label for each confidence level', () => {
const { getByText, rerender } = renderWithProviders(<ConfidenceBadge level="consensus" />);
expect(getByText('Consensus')).toBeTruthy();

rerender(<ConfidenceBadge level="majority" />);
expect(getByText('Majority')).toBeTruthy();

rerender(<ConfidenceBadge level="debated" />);
expect(getByText('Debated')).toBeTruthy();

rerender(<ConfidenceBadge level="minority" />);
expect(getByText('Minority')).toBeTruthy();
});

it('renders nothing when level is undefined', () => {
const { toJSON } = renderWithProviders(<ConfidenceBadge />);
expect(toJSON()).toBeNull();
});
});
51 changes: 51 additions & 0 deletions app/__tests__/components/guidedStudy/ContextGuardBanner.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ContextGuardBanner } from '@/components/guidedStudy/ContextGuardBanner';

const guard = {
ref: 'jeremiah 29:11',
book_id: 'jeremiah',
chapter_num: 29,
verse_num: 11,
common_misreading: 'Personal promise of prosperity',
actual_context_summary: 'Letter to Judean exiles in Babylon',
suggested_book_id: 'jeremiah',
suggested_chapter_num: 29,
};

describe('ContextGuardBanner', () => {
it('renders nothing when no guard is passed', () => {
const { toJSON } = renderWithProviders(
<ContextGuardBanner guard={null} onReadContext={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders the common-misreading copy and action link', () => {
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(getByText('READ IN CONTEXT')).toBeTruthy();
expect(getByText('Personal promise of prosperity')).toBeTruthy();
expect(getByText('Open the surrounding chapter')).toBeTruthy();
});

it('fires onReadContext when the action row is tapped', () => {
const onReadContext = jest.fn();
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={onReadContext} />,
);
fireEvent.press(getByText('Open the surrounding chapter'));
expect(onReadContext).toHaveBeenCalledTimes(1);
});

it('hides itself after the dismiss button is tapped', () => {
const { queryByText, getByLabelText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(queryByText('READ IN CONTEXT')).toBeTruthy();
fireEvent.press(getByLabelText('Dismiss context note'));
expect(queryByText('READ IN CONTEXT')).toBeNull();
});
});
36 changes: 36 additions & 0 deletions app/__tests__/components/guidedStudy/HomeReviewDueCard.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { HomeReviewDueCard } from '@/components/guidedStudy/HomeReviewDueCard';

describe('HomeReviewDueCard', () => {
it('renders nothing when count is zero', () => {
const { toJSON } = renderWithProviders(
<HomeReviewDueCard count={0} onPress={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders singular copy when exactly 1 prompt is due', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={1} onPress={jest.fn()} />,
);
expect(getByText('1 prompt due today')).toBeTruthy();
});

it('renders pluralized copy for multiple prompts', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={7} onPress={jest.fn()} />,
);
expect(getByText('7 prompts due today')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<HomeReviewDueCard count={3} onPress={onPress} />,
);
fireEvent.press(getByLabelText('3 study review prompts due'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { PanelRecommendationRow } from '@/components/guidedStudy/PanelRecommendationRow';

describe('PanelRecommendationRow', () => {
const baseRec = {
key: 'ctx-1',
title: 'Historical Context',
subtitle: 'Ancient Near Eastern background',
panelType: 'ctx',
};

it('renders title and subtitle', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={jest.fn()} />,
);
expect(getByText('Historical Context')).toBeTruthy();
expect(getByText('Ancient Near Eastern background')).toBeTruthy();
});

it('renders a confidence badge when a level is provided', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow
recommendation={{ ...baseRec, confidence: 'debated' } as any}
onPress={jest.fn()}
/>,
);
expect(getByText('Debated')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Open Historical Context'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
25 changes: 25 additions & 0 deletions app/__tests__/components/guidedStudy/StudySessionCTA.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionCTA } from '@/components/guidedStudy/StudySessionCTA';

describe('StudySessionCTA', () => {
const estimate = { readMin: 3, guidedMin: 8, deepMin: 20 };

it('renders the estimate line with all three durations', () => {
const { getByText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={jest.fn()} />,
);
expect(getByText('Study this chapter')).toBeTruthy();
expect(getByText('3 min read · 8 min guided · 20 min deep')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Study this chapter'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionStepper } from '@/components/guidedStudy/StudySessionStepper';

describe('StudySessionStepper', () => {
it('renders all five canonical steps', () => {
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={jest.fn()} />,
);
['Scene', 'Observe', 'Explore', 'Synthesize', 'Review'].forEach((label) => {
expect(getByText(label)).toBeTruthy();
});
});

it('fires onSelect with the tapped step key', () => {
const onSelect = jest.fn();
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={onSelect} />,
);
fireEvent.press(getByText('Explore'));
expect(onSelect).toHaveBeenCalledWith('explore');
});
});
16 changes: 10 additions & 6 deletions app/__tests__/db/userDatabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@
const mockExecAsync = jest.fn().mockResolvedValue(undefined);
const mockGetAllAsync = jest.fn().mockResolvedValue([]);
const mockRunAsync = jest.fn().mockResolvedValue({ changes: 0 });
const mockWithTransactionAsync = jest.fn().mockImplementation(async (cb: () => Promise<void>) => cb());
const mockWithTransactionAsync = jest
.fn()
.mockImplementation(async (cb: () => Promise<void>) => cb());
const mockOpenDatabaseAsync = jest.fn();

jest.mock('expo-sqlite', () => ({
Expand DownExpand Up@@ -76,11 +78,12 @@ describe('userDatabase', () => {
it('skips already applied migrations', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate all migrations already applied
mockGetAllAsync.mockResolvedValueOnce(
Array.from({ length: 18 }, (_, i) => ({ version: i + 1 })),
Array.from({ length: MIGRATION_COUNT }, (_, i) => ({ version: i + 1 })),
);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// withTransactionAsync should NOT have been called since all migrations are applied
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
Expand All@@ -89,12 +92,13 @@ describe('userDatabase', () => {
it('runs pending migrations in order', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate only version 1 applied
mockGetAllAsync.mockResolvedValueOnce([{ version: 1 }]);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// Should run 17 remaining migrations (2 through 18)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(17);
// Should run the remaining migrations (2 through MIGRATION_COUNT)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(MIGRATION_COUNT - 1);
});

it('throws on migration failure', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Implement guided study session v1 by CraigBuckmaster · Pull Request #1580 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
# Default: normalize line endings to LF on commit. Windows checkouts without
# core.autocrlf configured will no longer introduce CRLF into the repo.
* text=auto eol=lf

# Binary assets: never touch line endings
*.db binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.ico binary

# LFS-tracked source art
_tools/art_sources/*.jpg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.jpeg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.png filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion _tools/build_sqlite.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
from build_sqlite_schema import create_schema
from build_sqlite_loaders import (
populate_books, populate_chapters, populate_verses,
populate_book_intros, populate_people, populate_scholars,
populate_book_intros, populate_proof_text_guards, populate_people, populate_scholars,
populate_places, populate_map_stories, populate_ancient_borders,
populate_word_studies,
populate_synoptic, populate_topics, populate_debate_topics,
Expand DownExpand Up@@ -93,6 +93,7 @@ def main():

print(f" [OK] verses: {populate_verses(cur)} rows")
print(f" [OK] book_intros: {populate_book_intros(cur)} rows")
print(f" [OK] proof_text_guards: {populate_proof_text_guards(cur)} rows")
print(f" [OK] people: {populate_people(cur)} rows")
print(f" [OK] scholars: {populate_scholars(cur)} rows")
print(f" [OK] places: {populate_places(cur)} rows")
Expand Down
46 changes: 46 additions & 0 deletions _tools/build_sqlite_loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
AUDIT_DIR = ROOT / '_tools' / 'audit'
REFERENCE_MATRIX_PATH = AUDIT_DIR / 'reference_matrix.json'
TRANSLATIONS_DIR = ROOT / 'app' / 'assets' / 'translations'
PROOF_TEXT_GUARDS_PATH = META / 'proof-text-guards.json'

# Translations config — must match build_sqlite.py
AVAILABLE_TRANSLATIONS = {'kjv', 'asv'}
Expand DownExpand Up@@ -471,6 +472,51 @@ def populate_book_intros(cur):
return count


def populate_proof_text_guards(cur):
if not PROOF_TEXT_GUARDS_PATH.exists():
return 0
guards = _load_json(PROOF_TEXT_GUARDS_PATH)
count = 0
for guard in guards:
suggested = guard.get('suggested_chapter')
# Each guard must explicitly name the chapter to point the reader at. A
# self-reference is valid (the UX is often "read this verse in the full
# context of its own chapter"), but silently falling back to the guard's
# own location on a missing/malformed `suggested_chapter` masks
# data-quality bugs. Fail loudly instead.
if not isinstance(suggested, dict):
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} is missing "
f"`suggested_chapter` or it is not an object"
)
suggested_book_id = suggested.get('book_id')
suggested_chapter_num = suggested.get('chapter_num')
if not suggested_book_id or not suggested_chapter_num:
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} has incomplete "
f"`suggested_chapter` (book_id={suggested_book_id!r}, "
f"chapter_num={suggested_chapter_num!r}); both are required"
)
cur.execute(
'INSERT OR REPLACE INTO proof_text_guards '
'(ref, book_id, chapter_num, verse_num, common_misreading, '
'actual_context_summary, suggested_book_id, suggested_chapter_num) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
guard['ref'],
guard['book_id'],
guard['chapter_num'],
guard['verse_num'],
guard['common_misreading'],
guard['actual_context_summary'],
suggested_book_id,
suggested_chapter_num,
),
)

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker #3 — silent fallback hides malformed data

suggested.get('book_id', guard['book_id']),
suggested.get('chapter_num', guard['chapter_num']),

If suggested_chapter is missing or malformed in the source JSON, this falls back to the same verse the reader was just warned about — sending them back to re-read the out-of-context passage they were supposed to step out of.

Fix: Require suggested_chapter explicitly. Either raise on missing, or add a check in schema_validator.py that every proof-text-guard has a non-null suggested_chapter.book_id and suggested_chapter.chapter_num different from the guard's own location.

count += 1
return count


def populate_people(cur):
data = _load_json(META / 'people.json')
people = data.get('people', [])
Expand Down
13 changes: 13 additions & 0 deletions _tools/build_sqlite_schema.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,19 @@
at_a_glance TEXT
);

CREATE TABLE proof_text_guards (
ref TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id),
chapter_num INTEGER NOT NULL,
verse_num INTEGER NOT NULL,
common_misreading TEXT NOT NULL,
actual_context_summary TEXT NOT NULL,
suggested_book_id TEXT NOT NULL,
suggested_chapter_num INTEGER NOT NULL
);
CREATE INDEX idx_proof_text_guards_lookup
ON proof_text_guards(book_id, chapter_num, verse_num);

CREATE TABLE people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
24 changes: 24 additions & 0 deletions app/__tests__/components/guidedStudy/ConfidenceBadge.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ConfidenceBadge } from '@/components/guidedStudy/ConfidenceBadge';

describe('ConfidenceBadge', () => {
it('renders the human label for each confidence level', () => {
const { getByText, rerender } = renderWithProviders(<ConfidenceBadge level="consensus" />);
expect(getByText('Consensus')).toBeTruthy();

rerender(<ConfidenceBadge level="majority" />);
expect(getByText('Majority')).toBeTruthy();

rerender(<ConfidenceBadge level="debated" />);
expect(getByText('Debated')).toBeTruthy();

rerender(<ConfidenceBadge level="minority" />);
expect(getByText('Minority')).toBeTruthy();
});

it('renders nothing when level is undefined', () => {
const { toJSON } = renderWithProviders(<ConfidenceBadge />);
expect(toJSON()).toBeNull();
});
});
51 changes: 51 additions & 0 deletions app/__tests__/components/guidedStudy/ContextGuardBanner.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ContextGuardBanner } from '@/components/guidedStudy/ContextGuardBanner';

const guard = {
ref: 'jeremiah 29:11',
book_id: 'jeremiah',
chapter_num: 29,
verse_num: 11,
common_misreading: 'Personal promise of prosperity',
actual_context_summary: 'Letter to Judean exiles in Babylon',
suggested_book_id: 'jeremiah',
suggested_chapter_num: 29,
};

describe('ContextGuardBanner', () => {
it('renders nothing when no guard is passed', () => {
const { toJSON } = renderWithProviders(
<ContextGuardBanner guard={null} onReadContext={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders the common-misreading copy and action link', () => {
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(getByText('READ IN CONTEXT')).toBeTruthy();
expect(getByText('Personal promise of prosperity')).toBeTruthy();
expect(getByText('Open the surrounding chapter')).toBeTruthy();
});

it('fires onReadContext when the action row is tapped', () => {
const onReadContext = jest.fn();
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={onReadContext} />,
);
fireEvent.press(getByText('Open the surrounding chapter'));
expect(onReadContext).toHaveBeenCalledTimes(1);
});

it('hides itself after the dismiss button is tapped', () => {
const { queryByText, getByLabelText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(queryByText('READ IN CONTEXT')).toBeTruthy();
fireEvent.press(getByLabelText('Dismiss context note'));
expect(queryByText('READ IN CONTEXT')).toBeNull();
});
});
36 changes: 36 additions & 0 deletions app/__tests__/components/guidedStudy/HomeReviewDueCard.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { HomeReviewDueCard } from '@/components/guidedStudy/HomeReviewDueCard';

describe('HomeReviewDueCard', () => {
it('renders nothing when count is zero', () => {
const { toJSON } = renderWithProviders(
<HomeReviewDueCard count={0} onPress={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders singular copy when exactly 1 prompt is due', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={1} onPress={jest.fn()} />,
);
expect(getByText('1 prompt due today')).toBeTruthy();
});

it('renders pluralized copy for multiple prompts', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={7} onPress={jest.fn()} />,
);
expect(getByText('7 prompts due today')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<HomeReviewDueCard count={3} onPress={onPress} />,
);
fireEvent.press(getByLabelText('3 study review prompts due'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { PanelRecommendationRow } from '@/components/guidedStudy/PanelRecommendationRow';

describe('PanelRecommendationRow', () => {
const baseRec = {
key: 'ctx-1',
title: 'Historical Context',
subtitle: 'Ancient Near Eastern background',
panelType: 'ctx',
};

it('renders title and subtitle', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={jest.fn()} />,
);
expect(getByText('Historical Context')).toBeTruthy();
expect(getByText('Ancient Near Eastern background')).toBeTruthy();
});

it('renders a confidence badge when a level is provided', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow
recommendation={{ ...baseRec, confidence: 'debated' } as any}
onPress={jest.fn()}
/>,
);
expect(getByText('Debated')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Open Historical Context'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
25 changes: 25 additions & 0 deletions app/__tests__/components/guidedStudy/StudySessionCTA.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionCTA } from '@/components/guidedStudy/StudySessionCTA';

describe('StudySessionCTA', () => {
const estimate = { readMin: 3, guidedMin: 8, deepMin: 20 };

it('renders the estimate line with all three durations', () => {
const { getByText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={jest.fn()} />,
);
expect(getByText('Study this chapter')).toBeTruthy();
expect(getByText('3 min read · 8 min guided · 20 min deep')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Study this chapter'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionStepper } from '@/components/guidedStudy/StudySessionStepper';

describe('StudySessionStepper', () => {
it('renders all five canonical steps', () => {
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={jest.fn()} />,
);
['Scene', 'Observe', 'Explore', 'Synthesize', 'Review'].forEach((label) => {
expect(getByText(label)).toBeTruthy();
});
});

it('fires onSelect with the tapped step key', () => {
const onSelect = jest.fn();
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={onSelect} />,
);
fireEvent.press(getByText('Explore'));
expect(onSelect).toHaveBeenCalledWith('explore');
});
});
16 changes: 10 additions & 6 deletions app/__tests__/db/userDatabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@
const mockExecAsync = jest.fn().mockResolvedValue(undefined);
const mockGetAllAsync = jest.fn().mockResolvedValue([]);
const mockRunAsync = jest.fn().mockResolvedValue({ changes: 0 });
const mockWithTransactionAsync = jest.fn().mockImplementation(async (cb: () => Promise<void>) => cb());
const mockWithTransactionAsync = jest
.fn()
.mockImplementation(async (cb: () => Promise<void>) => cb());
const mockOpenDatabaseAsync = jest.fn();

jest.mock('expo-sqlite', () => ({
Expand DownExpand Up@@ -76,11 +78,12 @@ describe('userDatabase', () => {
it('skips already applied migrations', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate all migrations already applied
mockGetAllAsync.mockResolvedValueOnce(
Array.from({ length: 18 }, (_, i) => ({ version: i + 1 })),
Array.from({ length: MIGRATION_COUNT }, (_, i) => ({ version: i + 1 })),
);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// withTransactionAsync should NOT have been called since all migrations are applied
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
Expand All@@ -89,12 +92,13 @@ describe('userDatabase', () => {
it('runs pending migrations in order', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate only version 1 applied
mockGetAllAsync.mockResolvedValueOnce([{ version: 1 }]);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// Should run 17 remaining migrations (2 through 18)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(17);
// Should run the remaining migrations (2 through MIGRATION_COUNT)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(MIGRATION_COUNT - 1);
});

it('throws on migration failure', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Implement guided study session v1 by CraigBuckmaster · Pull Request #1580 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
# Default: normalize line endings to LF on commit. Windows checkouts without
# core.autocrlf configured will no longer introduce CRLF into the repo.
* text=auto eol=lf

# Binary assets: never touch line endings
*.db binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.ico binary

# LFS-tracked source art
_tools/art_sources/*.jpg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.jpeg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.png filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion _tools/build_sqlite.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
from build_sqlite_schema import create_schema
from build_sqlite_loaders import (
populate_books, populate_chapters, populate_verses,
populate_book_intros, populate_people, populate_scholars,
populate_book_intros, populate_proof_text_guards, populate_people, populate_scholars,
populate_places, populate_map_stories, populate_ancient_borders,
populate_word_studies,
populate_synoptic, populate_topics, populate_debate_topics,
Expand DownExpand Up@@ -93,6 +93,7 @@ def main():

print(f" [OK] verses: {populate_verses(cur)} rows")
print(f" [OK] book_intros: {populate_book_intros(cur)} rows")
print(f" [OK] proof_text_guards: {populate_proof_text_guards(cur)} rows")
print(f" [OK] people: {populate_people(cur)} rows")
print(f" [OK] scholars: {populate_scholars(cur)} rows")
print(f" [OK] places: {populate_places(cur)} rows")
Expand Down
46 changes: 46 additions & 0 deletions _tools/build_sqlite_loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
AUDIT_DIR = ROOT / '_tools' / 'audit'
REFERENCE_MATRIX_PATH = AUDIT_DIR / 'reference_matrix.json'
TRANSLATIONS_DIR = ROOT / 'app' / 'assets' / 'translations'
PROOF_TEXT_GUARDS_PATH = META / 'proof-text-guards.json'

# Translations config — must match build_sqlite.py
AVAILABLE_TRANSLATIONS = {'kjv', 'asv'}
Expand DownExpand Up@@ -471,6 +472,51 @@ def populate_book_intros(cur):
return count


def populate_proof_text_guards(cur):
if not PROOF_TEXT_GUARDS_PATH.exists():
return 0
guards = _load_json(PROOF_TEXT_GUARDS_PATH)
count = 0
for guard in guards:
suggested = guard.get('suggested_chapter')
# Each guard must explicitly name the chapter to point the reader at. A
# self-reference is valid (the UX is often "read this verse in the full
# context of its own chapter"), but silently falling back to the guard's
# own location on a missing/malformed `suggested_chapter` masks
# data-quality bugs. Fail loudly instead.
if not isinstance(suggested, dict):
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} is missing "
f"`suggested_chapter` or it is not an object"
)
suggested_book_id = suggested.get('book_id')
suggested_chapter_num = suggested.get('chapter_num')
if not suggested_book_id or not suggested_chapter_num:
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} has incomplete "
f"`suggested_chapter` (book_id={suggested_book_id!r}, "
f"chapter_num={suggested_chapter_num!r}); both are required"
)
cur.execute(
'INSERT OR REPLACE INTO proof_text_guards '
'(ref, book_id, chapter_num, verse_num, common_misreading, '
'actual_context_summary, suggested_book_id, suggested_chapter_num) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
guard['ref'],
guard['book_id'],
guard['chapter_num'],
guard['verse_num'],
guard['common_misreading'],
guard['actual_context_summary'],
suggested_book_id,
suggested_chapter_num,
),
)

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker #3 — silent fallback hides malformed data

suggested.get('book_id', guard['book_id']),
suggested.get('chapter_num', guard['chapter_num']),

If suggested_chapter is missing or malformed in the source JSON, this falls back to the same verse the reader was just warned about — sending them back to re-read the out-of-context passage they were supposed to step out of.

Fix: Require suggested_chapter explicitly. Either raise on missing, or add a check in schema_validator.py that every proof-text-guard has a non-null suggested_chapter.book_id and suggested_chapter.chapter_num different from the guard's own location.

count += 1
return count


def populate_people(cur):
data = _load_json(META / 'people.json')
people = data.get('people', [])
Expand Down
13 changes: 13 additions & 0 deletions _tools/build_sqlite_schema.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,19 @@
at_a_glance TEXT
);

CREATE TABLE proof_text_guards (
ref TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id),
chapter_num INTEGER NOT NULL,
verse_num INTEGER NOT NULL,
common_misreading TEXT NOT NULL,
actual_context_summary TEXT NOT NULL,
suggested_book_id TEXT NOT NULL,
suggested_chapter_num INTEGER NOT NULL
);
CREATE INDEX idx_proof_text_guards_lookup
ON proof_text_guards(book_id, chapter_num, verse_num);

CREATE TABLE people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
24 changes: 24 additions & 0 deletions app/__tests__/components/guidedStudy/ConfidenceBadge.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ConfidenceBadge } from '@/components/guidedStudy/ConfidenceBadge';

describe('ConfidenceBadge', () => {
it('renders the human label for each confidence level', () => {
const { getByText, rerender } = renderWithProviders(<ConfidenceBadge level="consensus" />);
expect(getByText('Consensus')).toBeTruthy();

rerender(<ConfidenceBadge level="majority" />);
expect(getByText('Majority')).toBeTruthy();

rerender(<ConfidenceBadge level="debated" />);
expect(getByText('Debated')).toBeTruthy();

rerender(<ConfidenceBadge level="minority" />);
expect(getByText('Minority')).toBeTruthy();
});

it('renders nothing when level is undefined', () => {
const { toJSON } = renderWithProviders(<ConfidenceBadge />);
expect(toJSON()).toBeNull();
});
});
51 changes: 51 additions & 0 deletions app/__tests__/components/guidedStudy/ContextGuardBanner.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ContextGuardBanner } from '@/components/guidedStudy/ContextGuardBanner';

const guard = {
ref: 'jeremiah 29:11',
book_id: 'jeremiah',
chapter_num: 29,
verse_num: 11,
common_misreading: 'Personal promise of prosperity',
actual_context_summary: 'Letter to Judean exiles in Babylon',
suggested_book_id: 'jeremiah',
suggested_chapter_num: 29,
};

describe('ContextGuardBanner', () => {
it('renders nothing when no guard is passed', () => {
const { toJSON } = renderWithProviders(
<ContextGuardBanner guard={null} onReadContext={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders the common-misreading copy and action link', () => {
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(getByText('READ IN CONTEXT')).toBeTruthy();
expect(getByText('Personal promise of prosperity')).toBeTruthy();
expect(getByText('Open the surrounding chapter')).toBeTruthy();
});

it('fires onReadContext when the action row is tapped', () => {
const onReadContext = jest.fn();
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={onReadContext} />,
);
fireEvent.press(getByText('Open the surrounding chapter'));
expect(onReadContext).toHaveBeenCalledTimes(1);
});

it('hides itself after the dismiss button is tapped', () => {
const { queryByText, getByLabelText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(queryByText('READ IN CONTEXT')).toBeTruthy();
fireEvent.press(getByLabelText('Dismiss context note'));
expect(queryByText('READ IN CONTEXT')).toBeNull();
});
});
36 changes: 36 additions & 0 deletions app/__tests__/components/guidedStudy/HomeReviewDueCard.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { HomeReviewDueCard } from '@/components/guidedStudy/HomeReviewDueCard';

describe('HomeReviewDueCard', () => {
it('renders nothing when count is zero', () => {
const { toJSON } = renderWithProviders(
<HomeReviewDueCard count={0} onPress={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders singular copy when exactly 1 prompt is due', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={1} onPress={jest.fn()} />,
);
expect(getByText('1 prompt due today')).toBeTruthy();
});

it('renders pluralized copy for multiple prompts', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={7} onPress={jest.fn()} />,
);
expect(getByText('7 prompts due today')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<HomeReviewDueCard count={3} onPress={onPress} />,
);
fireEvent.press(getByLabelText('3 study review prompts due'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { PanelRecommendationRow } from '@/components/guidedStudy/PanelRecommendationRow';

describe('PanelRecommendationRow', () => {
const baseRec = {
key: 'ctx-1',
title: 'Historical Context',
subtitle: 'Ancient Near Eastern background',
panelType: 'ctx',
};

it('renders title and subtitle', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={jest.fn()} />,
);
expect(getByText('Historical Context')).toBeTruthy();
expect(getByText('Ancient Near Eastern background')).toBeTruthy();
});

it('renders a confidence badge when a level is provided', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow
recommendation={{ ...baseRec, confidence: 'debated' } as any}
onPress={jest.fn()}
/>,
);
expect(getByText('Debated')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Open Historical Context'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
25 changes: 25 additions & 0 deletions app/__tests__/components/guidedStudy/StudySessionCTA.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionCTA } from '@/components/guidedStudy/StudySessionCTA';

describe('StudySessionCTA', () => {
const estimate = { readMin: 3, guidedMin: 8, deepMin: 20 };

it('renders the estimate line with all three durations', () => {
const { getByText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={jest.fn()} />,
);
expect(getByText('Study this chapter')).toBeTruthy();
expect(getByText('3 min read · 8 min guided · 20 min deep')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Study this chapter'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionStepper } from '@/components/guidedStudy/StudySessionStepper';

describe('StudySessionStepper', () => {
it('renders all five canonical steps', () => {
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={jest.fn()} />,
);
['Scene', 'Observe', 'Explore', 'Synthesize', 'Review'].forEach((label) => {
expect(getByText(label)).toBeTruthy();
});
});

it('fires onSelect with the tapped step key', () => {
const onSelect = jest.fn();
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={onSelect} />,
);
fireEvent.press(getByText('Explore'));
expect(onSelect).toHaveBeenCalledWith('explore');
});
});
16 changes: 10 additions & 6 deletions app/__tests__/db/userDatabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@
const mockExecAsync = jest.fn().mockResolvedValue(undefined);
const mockGetAllAsync = jest.fn().mockResolvedValue([]);
const mockRunAsync = jest.fn().mockResolvedValue({ changes: 0 });
const mockWithTransactionAsync = jest.fn().mockImplementation(async (cb: () => Promise<void>) => cb());
const mockWithTransactionAsync = jest
.fn()
.mockImplementation(async (cb: () => Promise<void>) => cb());
const mockOpenDatabaseAsync = jest.fn();

jest.mock('expo-sqlite', () => ({
Expand DownExpand Up@@ -76,11 +78,12 @@ describe('userDatabase', () => {
it('skips already applied migrations', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate all migrations already applied
mockGetAllAsync.mockResolvedValueOnce(
Array.from({ length: 18 }, (_, i) => ({ version: i + 1 })),
Array.from({ length: MIGRATION_COUNT }, (_, i) => ({ version: i + 1 })),
);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// withTransactionAsync should NOT have been called since all migrations are applied
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
Expand All@@ -89,12 +92,13 @@ describe('userDatabase', () => {
it('runs pending migrations in order', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate only version 1 applied
mockGetAllAsync.mockResolvedValueOnce([{ version: 1 }]);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// Should run 17 remaining migrations (2 through 18)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(17);
// Should run the remaining migrations (2 through MIGRATION_COUNT)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(MIGRATION_COUNT - 1);
});

it('throws on migration failure', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Implement guided study session v1 by CraigBuckmaster · Pull Request #1580 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
# Default: normalize line endings to LF on commit. Windows checkouts without
# core.autocrlf configured will no longer introduce CRLF into the repo.
* text=auto eol=lf

# Binary assets: never touch line endings
*.db binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.ico binary

# LFS-tracked source art
_tools/art_sources/*.jpg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.jpeg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.png filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion _tools/build_sqlite.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
from build_sqlite_schema import create_schema
from build_sqlite_loaders import (
populate_books, populate_chapters, populate_verses,
populate_book_intros, populate_people, populate_scholars,
populate_book_intros, populate_proof_text_guards, populate_people, populate_scholars,
populate_places, populate_map_stories, populate_ancient_borders,
populate_word_studies,
populate_synoptic, populate_topics, populate_debate_topics,
Expand DownExpand Up@@ -93,6 +93,7 @@ def main():

print(f" [OK] verses: {populate_verses(cur)} rows")
print(f" [OK] book_intros: {populate_book_intros(cur)} rows")
print(f" [OK] proof_text_guards: {populate_proof_text_guards(cur)} rows")
print(f" [OK] people: {populate_people(cur)} rows")
print(f" [OK] scholars: {populate_scholars(cur)} rows")
print(f" [OK] places: {populate_places(cur)} rows")
Expand Down
46 changes: 46 additions & 0 deletions _tools/build_sqlite_loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
AUDIT_DIR = ROOT / '_tools' / 'audit'
REFERENCE_MATRIX_PATH = AUDIT_DIR / 'reference_matrix.json'
TRANSLATIONS_DIR = ROOT / 'app' / 'assets' / 'translations'
PROOF_TEXT_GUARDS_PATH = META / 'proof-text-guards.json'

# Translations config — must match build_sqlite.py
AVAILABLE_TRANSLATIONS = {'kjv', 'asv'}
Expand DownExpand Up@@ -471,6 +472,51 @@ def populate_book_intros(cur):
return count


def populate_proof_text_guards(cur):
if not PROOF_TEXT_GUARDS_PATH.exists():
return 0
guards = _load_json(PROOF_TEXT_GUARDS_PATH)
count = 0
for guard in guards:
suggested = guard.get('suggested_chapter')
# Each guard must explicitly name the chapter to point the reader at. A
# self-reference is valid (the UX is often "read this verse in the full
# context of its own chapter"), but silently falling back to the guard's
# own location on a missing/malformed `suggested_chapter` masks
# data-quality bugs. Fail loudly instead.
if not isinstance(suggested, dict):
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} is missing "
f"`suggested_chapter` or it is not an object"
)
suggested_book_id = suggested.get('book_id')
suggested_chapter_num = suggested.get('chapter_num')
if not suggested_book_id or not suggested_chapter_num:
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} has incomplete "
f"`suggested_chapter` (book_id={suggested_book_id!r}, "
f"chapter_num={suggested_chapter_num!r}); both are required"
)
cur.execute(
'INSERT OR REPLACE INTO proof_text_guards '
'(ref, book_id, chapter_num, verse_num, common_misreading, '
'actual_context_summary, suggested_book_id, suggested_chapter_num) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
guard['ref'],
guard['book_id'],
guard['chapter_num'],
guard['verse_num'],
guard['common_misreading'],
guard['actual_context_summary'],
suggested_book_id,
suggested_chapter_num,
),
)

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker #3 — silent fallback hides malformed data

suggested.get('book_id', guard['book_id']),
suggested.get('chapter_num', guard['chapter_num']),

If suggested_chapter is missing or malformed in the source JSON, this falls back to the same verse the reader was just warned about — sending them back to re-read the out-of-context passage they were supposed to step out of.

Fix: Require suggested_chapter explicitly. Either raise on missing, or add a check in schema_validator.py that every proof-text-guard has a non-null suggested_chapter.book_id and suggested_chapter.chapter_num different from the guard's own location.

count += 1
return count


def populate_people(cur):
data = _load_json(META / 'people.json')
people = data.get('people', [])
Expand Down
13 changes: 13 additions & 0 deletions _tools/build_sqlite_schema.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,19 @@
at_a_glance TEXT
);

CREATE TABLE proof_text_guards (
ref TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id),
chapter_num INTEGER NOT NULL,
verse_num INTEGER NOT NULL,
common_misreading TEXT NOT NULL,
actual_context_summary TEXT NOT NULL,
suggested_book_id TEXT NOT NULL,
suggested_chapter_num INTEGER NOT NULL
);
CREATE INDEX idx_proof_text_guards_lookup
ON proof_text_guards(book_id, chapter_num, verse_num);

CREATE TABLE people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
24 changes: 24 additions & 0 deletions app/__tests__/components/guidedStudy/ConfidenceBadge.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ConfidenceBadge } from '@/components/guidedStudy/ConfidenceBadge';

describe('ConfidenceBadge', () => {
it('renders the human label for each confidence level', () => {
const { getByText, rerender } = renderWithProviders(<ConfidenceBadge level="consensus" />);
expect(getByText('Consensus')).toBeTruthy();

rerender(<ConfidenceBadge level="majority" />);
expect(getByText('Majority')).toBeTruthy();

rerender(<ConfidenceBadge level="debated" />);
expect(getByText('Debated')).toBeTruthy();

rerender(<ConfidenceBadge level="minority" />);
expect(getByText('Minority')).toBeTruthy();
});

it('renders nothing when level is undefined', () => {
const { toJSON } = renderWithProviders(<ConfidenceBadge />);
expect(toJSON()).toBeNull();
});
});
51 changes: 51 additions & 0 deletions app/__tests__/components/guidedStudy/ContextGuardBanner.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ContextGuardBanner } from '@/components/guidedStudy/ContextGuardBanner';

const guard = {
ref: 'jeremiah 29:11',
book_id: 'jeremiah',
chapter_num: 29,
verse_num: 11,
common_misreading: 'Personal promise of prosperity',
actual_context_summary: 'Letter to Judean exiles in Babylon',
suggested_book_id: 'jeremiah',
suggested_chapter_num: 29,
};

describe('ContextGuardBanner', () => {
it('renders nothing when no guard is passed', () => {
const { toJSON } = renderWithProviders(
<ContextGuardBanner guard={null} onReadContext={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders the common-misreading copy and action link', () => {
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(getByText('READ IN CONTEXT')).toBeTruthy();
expect(getByText('Personal promise of prosperity')).toBeTruthy();
expect(getByText('Open the surrounding chapter')).toBeTruthy();
});

it('fires onReadContext when the action row is tapped', () => {
const onReadContext = jest.fn();
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={onReadContext} />,
);
fireEvent.press(getByText('Open the surrounding chapter'));
expect(onReadContext).toHaveBeenCalledTimes(1);
});

it('hides itself after the dismiss button is tapped', () => {
const { queryByText, getByLabelText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(queryByText('READ IN CONTEXT')).toBeTruthy();
fireEvent.press(getByLabelText('Dismiss context note'));
expect(queryByText('READ IN CONTEXT')).toBeNull();
});
});
36 changes: 36 additions & 0 deletions app/__tests__/components/guidedStudy/HomeReviewDueCard.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { HomeReviewDueCard } from '@/components/guidedStudy/HomeReviewDueCard';

describe('HomeReviewDueCard', () => {
it('renders nothing when count is zero', () => {
const { toJSON } = renderWithProviders(
<HomeReviewDueCard count={0} onPress={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders singular copy when exactly 1 prompt is due', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={1} onPress={jest.fn()} />,
);
expect(getByText('1 prompt due today')).toBeTruthy();
});

it('renders pluralized copy for multiple prompts', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={7} onPress={jest.fn()} />,
);
expect(getByText('7 prompts due today')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<HomeReviewDueCard count={3} onPress={onPress} />,
);
fireEvent.press(getByLabelText('3 study review prompts due'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { PanelRecommendationRow } from '@/components/guidedStudy/PanelRecommendationRow';

describe('PanelRecommendationRow', () => {
const baseRec = {
key: 'ctx-1',
title: 'Historical Context',
subtitle: 'Ancient Near Eastern background',
panelType: 'ctx',
};

it('renders title and subtitle', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={jest.fn()} />,
);
expect(getByText('Historical Context')).toBeTruthy();
expect(getByText('Ancient Near Eastern background')).toBeTruthy();
});

it('renders a confidence badge when a level is provided', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow
recommendation={{ ...baseRec, confidence: 'debated' } as any}
onPress={jest.fn()}
/>,
);
expect(getByText('Debated')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Open Historical Context'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
25 changes: 25 additions & 0 deletions app/__tests__/components/guidedStudy/StudySessionCTA.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionCTA } from '@/components/guidedStudy/StudySessionCTA';

describe('StudySessionCTA', () => {
const estimate = { readMin: 3, guidedMin: 8, deepMin: 20 };

it('renders the estimate line with all three durations', () => {
const { getByText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={jest.fn()} />,
);
expect(getByText('Study this chapter')).toBeTruthy();
expect(getByText('3 min read · 8 min guided · 20 min deep')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Study this chapter'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionStepper } from '@/components/guidedStudy/StudySessionStepper';

describe('StudySessionStepper', () => {
it('renders all five canonical steps', () => {
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={jest.fn()} />,
);
['Scene', 'Observe', 'Explore', 'Synthesize', 'Review'].forEach((label) => {
expect(getByText(label)).toBeTruthy();
});
});

it('fires onSelect with the tapped step key', () => {
const onSelect = jest.fn();
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={onSelect} />,
);
fireEvent.press(getByText('Explore'));
expect(onSelect).toHaveBeenCalledWith('explore');
});
});
16 changes: 10 additions & 6 deletions app/__tests__/db/userDatabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@
const mockExecAsync = jest.fn().mockResolvedValue(undefined);
const mockGetAllAsync = jest.fn().mockResolvedValue([]);
const mockRunAsync = jest.fn().mockResolvedValue({ changes: 0 });
const mockWithTransactionAsync = jest.fn().mockImplementation(async (cb: () => Promise<void>) => cb());
const mockWithTransactionAsync = jest
.fn()
.mockImplementation(async (cb: () => Promise<void>) => cb());
const mockOpenDatabaseAsync = jest.fn();

jest.mock('expo-sqlite', () => ({
Expand DownExpand Up@@ -76,11 +78,12 @@ describe('userDatabase', () => {
it('skips already applied migrations', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate all migrations already applied
mockGetAllAsync.mockResolvedValueOnce(
Array.from({ length: 18 }, (_, i) => ({ version: i + 1 })),
Array.from({ length: MIGRATION_COUNT }, (_, i) => ({ version: i + 1 })),
);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// withTransactionAsync should NOT have been called since all migrations are applied
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
Expand All@@ -89,12 +92,13 @@ describe('userDatabase', () => {
it('runs pending migrations in order', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate only version 1 applied
mockGetAllAsync.mockResolvedValueOnce([{ version: 1 }]);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// Should run 17 remaining migrations (2 through 18)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(17);
// Should run the remaining migrations (2 through MIGRATION_COUNT)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(MIGRATION_COUNT - 1);
});

it('throws on migration failure', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Implement guided study session v1 by CraigBuckmaster · Pull Request #1580 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
# Default: normalize line endings to LF on commit. Windows checkouts without
# core.autocrlf configured will no longer introduce CRLF into the repo.
* text=auto eol=lf

# Binary assets: never touch line endings
*.db binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.ico binary

# LFS-tracked source art
_tools/art_sources/*.jpg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.jpeg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.png filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion _tools/build_sqlite.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
from build_sqlite_schema import create_schema
from build_sqlite_loaders import (
populate_books, populate_chapters, populate_verses,
populate_book_intros, populate_people, populate_scholars,
populate_book_intros, populate_proof_text_guards, populate_people, populate_scholars,
populate_places, populate_map_stories, populate_ancient_borders,
populate_word_studies,
populate_synoptic, populate_topics, populate_debate_topics,
Expand DownExpand Up@@ -93,6 +93,7 @@ def main():

print(f" [OK] verses: {populate_verses(cur)} rows")
print(f" [OK] book_intros: {populate_book_intros(cur)} rows")
print(f" [OK] proof_text_guards: {populate_proof_text_guards(cur)} rows")
print(f" [OK] people: {populate_people(cur)} rows")
print(f" [OK] scholars: {populate_scholars(cur)} rows")
print(f" [OK] places: {populate_places(cur)} rows")
Expand Down
46 changes: 46 additions & 0 deletions _tools/build_sqlite_loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
AUDIT_DIR = ROOT / '_tools' / 'audit'
REFERENCE_MATRIX_PATH = AUDIT_DIR / 'reference_matrix.json'
TRANSLATIONS_DIR = ROOT / 'app' / 'assets' / 'translations'
PROOF_TEXT_GUARDS_PATH = META / 'proof-text-guards.json'

# Translations config — must match build_sqlite.py
AVAILABLE_TRANSLATIONS = {'kjv', 'asv'}
Expand DownExpand Up@@ -471,6 +472,51 @@ def populate_book_intros(cur):
return count


def populate_proof_text_guards(cur):
if not PROOF_TEXT_GUARDS_PATH.exists():
return 0
guards = _load_json(PROOF_TEXT_GUARDS_PATH)
count = 0
for guard in guards:
suggested = guard.get('suggested_chapter')
# Each guard must explicitly name the chapter to point the reader at. A
# self-reference is valid (the UX is often "read this verse in the full
# context of its own chapter"), but silently falling back to the guard's
# own location on a missing/malformed `suggested_chapter` masks
# data-quality bugs. Fail loudly instead.
if not isinstance(suggested, dict):
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} is missing "
f"`suggested_chapter` or it is not an object"
)
suggested_book_id = suggested.get('book_id')
suggested_chapter_num = suggested.get('chapter_num')
if not suggested_book_id or not suggested_chapter_num:
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} has incomplete "
f"`suggested_chapter` (book_id={suggested_book_id!r}, "
f"chapter_num={suggested_chapter_num!r}); both are required"
)
cur.execute(
'INSERT OR REPLACE INTO proof_text_guards '
'(ref, book_id, chapter_num, verse_num, common_misreading, '
'actual_context_summary, suggested_book_id, suggested_chapter_num) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
guard['ref'],
guard['book_id'],
guard['chapter_num'],
guard['verse_num'],
guard['common_misreading'],
guard['actual_context_summary'],
suggested_book_id,
suggested_chapter_num,
),
)

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker #3 — silent fallback hides malformed data

suggested.get('book_id', guard['book_id']),
suggested.get('chapter_num', guard['chapter_num']),

If suggested_chapter is missing or malformed in the source JSON, this falls back to the same verse the reader was just warned about — sending them back to re-read the out-of-context passage they were supposed to step out of.

Fix: Require suggested_chapter explicitly. Either raise on missing, or add a check in schema_validator.py that every proof-text-guard has a non-null suggested_chapter.book_id and suggested_chapter.chapter_num different from the guard's own location.

count += 1
return count


def populate_people(cur):
data = _load_json(META / 'people.json')
people = data.get('people', [])
Expand Down
13 changes: 13 additions & 0 deletions _tools/build_sqlite_schema.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,19 @@
at_a_glance TEXT
);

CREATE TABLE proof_text_guards (
ref TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id),
chapter_num INTEGER NOT NULL,
verse_num INTEGER NOT NULL,
common_misreading TEXT NOT NULL,
actual_context_summary TEXT NOT NULL,
suggested_book_id TEXT NOT NULL,
suggested_chapter_num INTEGER NOT NULL
);
CREATE INDEX idx_proof_text_guards_lookup
ON proof_text_guards(book_id, chapter_num, verse_num);

CREATE TABLE people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
24 changes: 24 additions & 0 deletions app/__tests__/components/guidedStudy/ConfidenceBadge.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ConfidenceBadge } from '@/components/guidedStudy/ConfidenceBadge';

describe('ConfidenceBadge', () => {
it('renders the human label for each confidence level', () => {
const { getByText, rerender } = renderWithProviders(<ConfidenceBadge level="consensus" />);
expect(getByText('Consensus')).toBeTruthy();

rerender(<ConfidenceBadge level="majority" />);
expect(getByText('Majority')).toBeTruthy();

rerender(<ConfidenceBadge level="debated" />);
expect(getByText('Debated')).toBeTruthy();

rerender(<ConfidenceBadge level="minority" />);
expect(getByText('Minority')).toBeTruthy();
});

it('renders nothing when level is undefined', () => {
const { toJSON } = renderWithProviders(<ConfidenceBadge />);
expect(toJSON()).toBeNull();
});
});
51 changes: 51 additions & 0 deletions app/__tests__/components/guidedStudy/ContextGuardBanner.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ContextGuardBanner } from '@/components/guidedStudy/ContextGuardBanner';

const guard = {
ref: 'jeremiah 29:11',
book_id: 'jeremiah',
chapter_num: 29,
verse_num: 11,
common_misreading: 'Personal promise of prosperity',
actual_context_summary: 'Letter to Judean exiles in Babylon',
suggested_book_id: 'jeremiah',
suggested_chapter_num: 29,
};

describe('ContextGuardBanner', () => {
it('renders nothing when no guard is passed', () => {
const { toJSON } = renderWithProviders(
<ContextGuardBanner guard={null} onReadContext={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders the common-misreading copy and action link', () => {
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(getByText('READ IN CONTEXT')).toBeTruthy();
expect(getByText('Personal promise of prosperity')).toBeTruthy();
expect(getByText('Open the surrounding chapter')).toBeTruthy();
});

it('fires onReadContext when the action row is tapped', () => {
const onReadContext = jest.fn();
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={onReadContext} />,
);
fireEvent.press(getByText('Open the surrounding chapter'));
expect(onReadContext).toHaveBeenCalledTimes(1);
});

it('hides itself after the dismiss button is tapped', () => {
const { queryByText, getByLabelText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(queryByText('READ IN CONTEXT')).toBeTruthy();
fireEvent.press(getByLabelText('Dismiss context note'));
expect(queryByText('READ IN CONTEXT')).toBeNull();
});
});
36 changes: 36 additions & 0 deletions app/__tests__/components/guidedStudy/HomeReviewDueCard.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { HomeReviewDueCard } from '@/components/guidedStudy/HomeReviewDueCard';

describe('HomeReviewDueCard', () => {
it('renders nothing when count is zero', () => {
const { toJSON } = renderWithProviders(
<HomeReviewDueCard count={0} onPress={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders singular copy when exactly 1 prompt is due', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={1} onPress={jest.fn()} />,
);
expect(getByText('1 prompt due today')).toBeTruthy();
});

it('renders pluralized copy for multiple prompts', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={7} onPress={jest.fn()} />,
);
expect(getByText('7 prompts due today')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<HomeReviewDueCard count={3} onPress={onPress} />,
);
fireEvent.press(getByLabelText('3 study review prompts due'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { PanelRecommendationRow } from '@/components/guidedStudy/PanelRecommendationRow';

describe('PanelRecommendationRow', () => {
const baseRec = {
key: 'ctx-1',
title: 'Historical Context',
subtitle: 'Ancient Near Eastern background',
panelType: 'ctx',
};

it('renders title and subtitle', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={jest.fn()} />,
);
expect(getByText('Historical Context')).toBeTruthy();
expect(getByText('Ancient Near Eastern background')).toBeTruthy();
});

it('renders a confidence badge when a level is provided', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow
recommendation={{ ...baseRec, confidence: 'debated' } as any}
onPress={jest.fn()}
/>,
);
expect(getByText('Debated')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Open Historical Context'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
25 changes: 25 additions & 0 deletions app/__tests__/components/guidedStudy/StudySessionCTA.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionCTA } from '@/components/guidedStudy/StudySessionCTA';

describe('StudySessionCTA', () => {
const estimate = { readMin: 3, guidedMin: 8, deepMin: 20 };

it('renders the estimate line with all three durations', () => {
const { getByText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={jest.fn()} />,
);
expect(getByText('Study this chapter')).toBeTruthy();
expect(getByText('3 min read · 8 min guided · 20 min deep')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Study this chapter'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionStepper } from '@/components/guidedStudy/StudySessionStepper';

describe('StudySessionStepper', () => {
it('renders all five canonical steps', () => {
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={jest.fn()} />,
);
['Scene', 'Observe', 'Explore', 'Synthesize', 'Review'].forEach((label) => {
expect(getByText(label)).toBeTruthy();
});
});

it('fires onSelect with the tapped step key', () => {
const onSelect = jest.fn();
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={onSelect} />,
);
fireEvent.press(getByText('Explore'));
expect(onSelect).toHaveBeenCalledWith('explore');
});
});
16 changes: 10 additions & 6 deletions app/__tests__/db/userDatabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@
const mockExecAsync = jest.fn().mockResolvedValue(undefined);
const mockGetAllAsync = jest.fn().mockResolvedValue([]);
const mockRunAsync = jest.fn().mockResolvedValue({ changes: 0 });
const mockWithTransactionAsync = jest.fn().mockImplementation(async (cb: () => Promise<void>) => cb());
const mockWithTransactionAsync = jest
.fn()
.mockImplementation(async (cb: () => Promise<void>) => cb());
const mockOpenDatabaseAsync = jest.fn();

jest.mock('expo-sqlite', () => ({
Expand DownExpand Up@@ -76,11 +78,12 @@ describe('userDatabase', () => {
it('skips already applied migrations', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate all migrations already applied
mockGetAllAsync.mockResolvedValueOnce(
Array.from({ length: 18 }, (_, i) => ({ version: i + 1 })),
Array.from({ length: MIGRATION_COUNT }, (_, i) => ({ version: i + 1 })),
);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// withTransactionAsync should NOT have been called since all migrations are applied
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
Expand All@@ -89,12 +92,13 @@ describe('userDatabase', () => {
it('runs pending migrations in order', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate only version 1 applied
mockGetAllAsync.mockResolvedValueOnce([{ version: 1 }]);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// Should run 17 remaining migrations (2 through 18)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(17);
// Should run the remaining migrations (2 through MIGRATION_COUNT)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(MIGRATION_COUNT - 1);
});

it('throws on migration failure', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Implement guided study session v1 by CraigBuckmaster · Pull Request #1580 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
# Default: normalize line endings to LF on commit. Windows checkouts without
# core.autocrlf configured will no longer introduce CRLF into the repo.
* text=auto eol=lf

# Binary assets: never touch line endings
*.db binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.ico binary

# LFS-tracked source art
_tools/art_sources/*.jpg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.jpeg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.png filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion _tools/build_sqlite.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
from build_sqlite_schema import create_schema
from build_sqlite_loaders import (
populate_books, populate_chapters, populate_verses,
populate_book_intros, populate_people, populate_scholars,
populate_book_intros, populate_proof_text_guards, populate_people, populate_scholars,
populate_places, populate_map_stories, populate_ancient_borders,
populate_word_studies,
populate_synoptic, populate_topics, populate_debate_topics,
Expand DownExpand Up@@ -93,6 +93,7 @@ def main():

print(f" [OK] verses: {populate_verses(cur)} rows")
print(f" [OK] book_intros: {populate_book_intros(cur)} rows")
print(f" [OK] proof_text_guards: {populate_proof_text_guards(cur)} rows")
print(f" [OK] people: {populate_people(cur)} rows")
print(f" [OK] scholars: {populate_scholars(cur)} rows")
print(f" [OK] places: {populate_places(cur)} rows")
Expand Down
46 changes: 46 additions & 0 deletions _tools/build_sqlite_loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
AUDIT_DIR = ROOT / '_tools' / 'audit'
REFERENCE_MATRIX_PATH = AUDIT_DIR / 'reference_matrix.json'
TRANSLATIONS_DIR = ROOT / 'app' / 'assets' / 'translations'
PROOF_TEXT_GUARDS_PATH = META / 'proof-text-guards.json'

# Translations config — must match build_sqlite.py
AVAILABLE_TRANSLATIONS = {'kjv', 'asv'}
Expand DownExpand Up@@ -471,6 +472,51 @@ def populate_book_intros(cur):
return count


def populate_proof_text_guards(cur):
if not PROOF_TEXT_GUARDS_PATH.exists():
return 0
guards = _load_json(PROOF_TEXT_GUARDS_PATH)
count = 0
for guard in guards:
suggested = guard.get('suggested_chapter')
# Each guard must explicitly name the chapter to point the reader at. A
# self-reference is valid (the UX is often "read this verse in the full
# context of its own chapter"), but silently falling back to the guard's
# own location on a missing/malformed `suggested_chapter` masks
# data-quality bugs. Fail loudly instead.
if not isinstance(suggested, dict):
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} is missing "
f"`suggested_chapter` or it is not an object"
)
suggested_book_id = suggested.get('book_id')
suggested_chapter_num = suggested.get('chapter_num')
if not suggested_book_id or not suggested_chapter_num:
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} has incomplete "
f"`suggested_chapter` (book_id={suggested_book_id!r}, "
f"chapter_num={suggested_chapter_num!r}); both are required"
)
cur.execute(
'INSERT OR REPLACE INTO proof_text_guards '
'(ref, book_id, chapter_num, verse_num, common_misreading, '
'actual_context_summary, suggested_book_id, suggested_chapter_num) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
guard['ref'],
guard['book_id'],
guard['chapter_num'],
guard['verse_num'],
guard['common_misreading'],
guard['actual_context_summary'],
suggested_book_id,
suggested_chapter_num,
),
)

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker #3 — silent fallback hides malformed data

suggested.get('book_id', guard['book_id']),
suggested.get('chapter_num', guard['chapter_num']),

If suggested_chapter is missing or malformed in the source JSON, this falls back to the same verse the reader was just warned about — sending them back to re-read the out-of-context passage they were supposed to step out of.

Fix: Require suggested_chapter explicitly. Either raise on missing, or add a check in schema_validator.py that every proof-text-guard has a non-null suggested_chapter.book_id and suggested_chapter.chapter_num different from the guard's own location.

count += 1
return count


def populate_people(cur):
data = _load_json(META / 'people.json')
people = data.get('people', [])
Expand Down
13 changes: 13 additions & 0 deletions _tools/build_sqlite_schema.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,19 @@
at_a_glance TEXT
);

CREATE TABLE proof_text_guards (
ref TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id),
chapter_num INTEGER NOT NULL,
verse_num INTEGER NOT NULL,
common_misreading TEXT NOT NULL,
actual_context_summary TEXT NOT NULL,
suggested_book_id TEXT NOT NULL,
suggested_chapter_num INTEGER NOT NULL
);
CREATE INDEX idx_proof_text_guards_lookup
ON proof_text_guards(book_id, chapter_num, verse_num);

CREATE TABLE people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
24 changes: 24 additions & 0 deletions app/__tests__/components/guidedStudy/ConfidenceBadge.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ConfidenceBadge } from '@/components/guidedStudy/ConfidenceBadge';

describe('ConfidenceBadge', () => {
it('renders the human label for each confidence level', () => {
const { getByText, rerender } = renderWithProviders(<ConfidenceBadge level="consensus" />);
expect(getByText('Consensus')).toBeTruthy();

rerender(<ConfidenceBadge level="majority" />);
expect(getByText('Majority')).toBeTruthy();

rerender(<ConfidenceBadge level="debated" />);
expect(getByText('Debated')).toBeTruthy();

rerender(<ConfidenceBadge level="minority" />);
expect(getByText('Minority')).toBeTruthy();
});

it('renders nothing when level is undefined', () => {
const { toJSON } = renderWithProviders(<ConfidenceBadge />);
expect(toJSON()).toBeNull();
});
});
51 changes: 51 additions & 0 deletions app/__tests__/components/guidedStudy/ContextGuardBanner.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ContextGuardBanner } from '@/components/guidedStudy/ContextGuardBanner';

const guard = {
ref: 'jeremiah 29:11',
book_id: 'jeremiah',
chapter_num: 29,
verse_num: 11,
common_misreading: 'Personal promise of prosperity',
actual_context_summary: 'Letter to Judean exiles in Babylon',
suggested_book_id: 'jeremiah',
suggested_chapter_num: 29,
};

describe('ContextGuardBanner', () => {
it('renders nothing when no guard is passed', () => {
const { toJSON } = renderWithProviders(
<ContextGuardBanner guard={null} onReadContext={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders the common-misreading copy and action link', () => {
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(getByText('READ IN CONTEXT')).toBeTruthy();
expect(getByText('Personal promise of prosperity')).toBeTruthy();
expect(getByText('Open the surrounding chapter')).toBeTruthy();
});

it('fires onReadContext when the action row is tapped', () => {
const onReadContext = jest.fn();
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={onReadContext} />,
);
fireEvent.press(getByText('Open the surrounding chapter'));
expect(onReadContext).toHaveBeenCalledTimes(1);
});

it('hides itself after the dismiss button is tapped', () => {
const { queryByText, getByLabelText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(queryByText('READ IN CONTEXT')).toBeTruthy();
fireEvent.press(getByLabelText('Dismiss context note'));
expect(queryByText('READ IN CONTEXT')).toBeNull();
});
});
36 changes: 36 additions & 0 deletions app/__tests__/components/guidedStudy/HomeReviewDueCard.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { HomeReviewDueCard } from '@/components/guidedStudy/HomeReviewDueCard';

describe('HomeReviewDueCard', () => {
it('renders nothing when count is zero', () => {
const { toJSON } = renderWithProviders(
<HomeReviewDueCard count={0} onPress={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders singular copy when exactly 1 prompt is due', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={1} onPress={jest.fn()} />,
);
expect(getByText('1 prompt due today')).toBeTruthy();
});

it('renders pluralized copy for multiple prompts', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={7} onPress={jest.fn()} />,
);
expect(getByText('7 prompts due today')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<HomeReviewDueCard count={3} onPress={onPress} />,
);
fireEvent.press(getByLabelText('3 study review prompts due'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { PanelRecommendationRow } from '@/components/guidedStudy/PanelRecommendationRow';

describe('PanelRecommendationRow', () => {
const baseRec = {
key: 'ctx-1',
title: 'Historical Context',
subtitle: 'Ancient Near Eastern background',
panelType: 'ctx',
};

it('renders title and subtitle', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={jest.fn()} />,
);
expect(getByText('Historical Context')).toBeTruthy();
expect(getByText('Ancient Near Eastern background')).toBeTruthy();
});

it('renders a confidence badge when a level is provided', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow
recommendation={{ ...baseRec, confidence: 'debated' } as any}
onPress={jest.fn()}
/>,
);
expect(getByText('Debated')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Open Historical Context'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
25 changes: 25 additions & 0 deletions app/__tests__/components/guidedStudy/StudySessionCTA.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionCTA } from '@/components/guidedStudy/StudySessionCTA';

describe('StudySessionCTA', () => {
const estimate = { readMin: 3, guidedMin: 8, deepMin: 20 };

it('renders the estimate line with all three durations', () => {
const { getByText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={jest.fn()} />,
);
expect(getByText('Study this chapter')).toBeTruthy();
expect(getByText('3 min read · 8 min guided · 20 min deep')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Study this chapter'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionStepper } from '@/components/guidedStudy/StudySessionStepper';

describe('StudySessionStepper', () => {
it('renders all five canonical steps', () => {
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={jest.fn()} />,
);
['Scene', 'Observe', 'Explore', 'Synthesize', 'Review'].forEach((label) => {
expect(getByText(label)).toBeTruthy();
});
});

it('fires onSelect with the tapped step key', () => {
const onSelect = jest.fn();
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={onSelect} />,
);
fireEvent.press(getByText('Explore'));
expect(onSelect).toHaveBeenCalledWith('explore');
});
});
16 changes: 10 additions & 6 deletions app/__tests__/db/userDatabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@
const mockExecAsync = jest.fn().mockResolvedValue(undefined);
const mockGetAllAsync = jest.fn().mockResolvedValue([]);
const mockRunAsync = jest.fn().mockResolvedValue({ changes: 0 });
const mockWithTransactionAsync = jest.fn().mockImplementation(async (cb: () => Promise<void>) => cb());
const mockWithTransactionAsync = jest
.fn()
.mockImplementation(async (cb: () => Promise<void>) => cb());
const mockOpenDatabaseAsync = jest.fn();

jest.mock('expo-sqlite', () => ({
Expand DownExpand Up@@ -76,11 +78,12 @@ describe('userDatabase', () => {
it('skips already applied migrations', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate all migrations already applied
mockGetAllAsync.mockResolvedValueOnce(
Array.from({ length: 18 }, (_, i) => ({ version: i + 1 })),
Array.from({ length: MIGRATION_COUNT }, (_, i) => ({ version: i + 1 })),
);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// withTransactionAsync should NOT have been called since all migrations are applied
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
Expand All@@ -89,12 +92,13 @@ describe('userDatabase', () => {
it('runs pending migrations in order', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate only version 1 applied
mockGetAllAsync.mockResolvedValueOnce([{ version: 1 }]);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// Should run 17 remaining migrations (2 through 18)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(17);
// Should run the remaining migrations (2 through MIGRATION_COUNT)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(MIGRATION_COUNT - 1);
});

it('throws on migration failure', async () => {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Implement guided study session v1 by CraigBuckmaster · Pull Request #1580 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
# Default: normalize line endings to LF on commit. Windows checkouts without
# core.autocrlf configured will no longer introduce CRLF into the repo.
* text=auto eol=lf

# Binary assets: never touch line endings
*.db binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.ico binary

# LFS-tracked source art
_tools/art_sources/*.jpg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.jpeg filter=lfs diff=lfs merge=lfs -text
_tools/art_sources/*.png filter=lfs diff=lfs merge=lfs -text
3 changes: 2 additions & 1 deletion _tools/build_sqlite.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
from build_sqlite_schema import create_schema
from build_sqlite_loaders import (
populate_books, populate_chapters, populate_verses,
populate_book_intros, populate_people, populate_scholars,
populate_book_intros, populate_proof_text_guards, populate_people, populate_scholars,
populate_places, populate_map_stories, populate_ancient_borders,
populate_word_studies,
populate_synoptic, populate_topics, populate_debate_topics,
Expand DownExpand Up@@ -93,6 +93,7 @@ def main():

print(f" [OK] verses: {populate_verses(cur)} rows")
print(f" [OK] book_intros: {populate_book_intros(cur)} rows")
print(f" [OK] proof_text_guards: {populate_proof_text_guards(cur)} rows")
print(f" [OK] people: {populate_people(cur)} rows")
print(f" [OK] scholars: {populate_scholars(cur)} rows")
print(f" [OK] places: {populate_places(cur)} rows")
Expand Down
46 changes: 46 additions & 0 deletions _tools/build_sqlite_loaders.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
AUDIT_DIR = ROOT / '_tools' / 'audit'
REFERENCE_MATRIX_PATH = AUDIT_DIR / 'reference_matrix.json'
TRANSLATIONS_DIR = ROOT / 'app' / 'assets' / 'translations'
PROOF_TEXT_GUARDS_PATH = META / 'proof-text-guards.json'

# Translations config — must match build_sqlite.py
AVAILABLE_TRANSLATIONS = {'kjv', 'asv'}
Expand DownExpand Up@@ -471,6 +472,51 @@ def populate_book_intros(cur):
return count


def populate_proof_text_guards(cur):
if not PROOF_TEXT_GUARDS_PATH.exists():
return 0
guards = _load_json(PROOF_TEXT_GUARDS_PATH)
count = 0
for guard in guards:
suggested = guard.get('suggested_chapter')
# Each guard must explicitly name the chapter to point the reader at. A
# self-reference is valid (the UX is often "read this verse in the full
# context of its own chapter"), but silently falling back to the guard's
# own location on a missing/malformed `suggested_chapter` masks
# data-quality bugs. Fail loudly instead.
if not isinstance(suggested, dict):
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} is missing "
f"`suggested_chapter` or it is not an object"
)
suggested_book_id = suggested.get('book_id')
suggested_chapter_num = suggested.get('chapter_num')
if not suggested_book_id or not suggested_chapter_num:
raise ValueError(
f"proof_text_guards: guard {guard.get('ref')!r} has incomplete "
f"`suggested_chapter` (book_id={suggested_book_id!r}, "
f"chapter_num={suggested_chapter_num!r}); both are required"
)
cur.execute(
'INSERT OR REPLACE INTO proof_text_guards '
'(ref, book_id, chapter_num, verse_num, common_misreading, '
'actual_context_summary, suggested_book_id, suggested_chapter_num) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
guard['ref'],
guard['book_id'],
guard['chapter_num'],
guard['verse_num'],
guard['common_misreading'],
guard['actual_context_summary'],
suggested_book_id,
suggested_chapter_num,
),
)

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker #3 — silent fallback hides malformed data

suggested.get('book_id', guard['book_id']),
suggested.get('chapter_num', guard['chapter_num']),

If suggested_chapter is missing or malformed in the source JSON, this falls back to the same verse the reader was just warned about — sending them back to re-read the out-of-context passage they were supposed to step out of.

Fix: Require suggested_chapter explicitly. Either raise on missing, or add a check in schema_validator.py that every proof-text-guard has a non-null suggested_chapter.book_id and suggested_chapter.chapter_num different from the guard's own location.

count += 1
return count


def populate_people(cur):
data = _load_json(META / 'people.json')
people = data.get('people', [])
Expand Down
13 changes: 13 additions & 0 deletions _tools/build_sqlite_schema.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,6 +92,19 @@
at_a_glance TEXT
);

CREATE TABLE proof_text_guards (
ref TEXT PRIMARY KEY,
book_id TEXT NOT NULL REFERENCES books(id),
chapter_num INTEGER NOT NULL,
verse_num INTEGER NOT NULL,
common_misreading TEXT NOT NULL,
actual_context_summary TEXT NOT NULL,
suggested_book_id TEXT NOT NULL,
suggested_chapter_num INTEGER NOT NULL
);
CREATE INDEX idx_proof_text_guards_lookup
ON proof_text_guards(book_id, chapter_num, verse_num);

CREATE TABLE people (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
24 changes: 24 additions & 0 deletions app/__tests__/components/guidedStudy/ConfidenceBadge.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ConfidenceBadge } from '@/components/guidedStudy/ConfidenceBadge';

describe('ConfidenceBadge', () => {
it('renders the human label for each confidence level', () => {
const { getByText, rerender } = renderWithProviders(<ConfidenceBadge level="consensus" />);
expect(getByText('Consensus')).toBeTruthy();

rerender(<ConfidenceBadge level="majority" />);
expect(getByText('Majority')).toBeTruthy();

rerender(<ConfidenceBadge level="debated" />);
expect(getByText('Debated')).toBeTruthy();

rerender(<ConfidenceBadge level="minority" />);
expect(getByText('Minority')).toBeTruthy();
});

it('renders nothing when level is undefined', () => {
const { toJSON } = renderWithProviders(<ConfidenceBadge />);
expect(toJSON()).toBeNull();
});
});
51 changes: 51 additions & 0 deletions app/__tests__/components/guidedStudy/ContextGuardBanner.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { ContextGuardBanner } from '@/components/guidedStudy/ContextGuardBanner';

const guard = {
ref: 'jeremiah 29:11',
book_id: 'jeremiah',
chapter_num: 29,
verse_num: 11,
common_misreading: 'Personal promise of prosperity',
actual_context_summary: 'Letter to Judean exiles in Babylon',
suggested_book_id: 'jeremiah',
suggested_chapter_num: 29,
};

describe('ContextGuardBanner', () => {
it('renders nothing when no guard is passed', () => {
const { toJSON } = renderWithProviders(
<ContextGuardBanner guard={null} onReadContext={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders the common-misreading copy and action link', () => {
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(getByText('READ IN CONTEXT')).toBeTruthy();
expect(getByText('Personal promise of prosperity')).toBeTruthy();
expect(getByText('Open the surrounding chapter')).toBeTruthy();
});

it('fires onReadContext when the action row is tapped', () => {
const onReadContext = jest.fn();
const { getByText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={onReadContext} />,
);
fireEvent.press(getByText('Open the surrounding chapter'));
expect(onReadContext).toHaveBeenCalledTimes(1);
});

it('hides itself after the dismiss button is tapped', () => {
const { queryByText, getByLabelText } = renderWithProviders(
<ContextGuardBanner guard={guard} onReadContext={jest.fn()} />,
);
expect(queryByText('READ IN CONTEXT')).toBeTruthy();
fireEvent.press(getByLabelText('Dismiss context note'));
expect(queryByText('READ IN CONTEXT')).toBeNull();
});
});
36 changes: 36 additions & 0 deletions app/__tests__/components/guidedStudy/HomeReviewDueCard.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { HomeReviewDueCard } from '@/components/guidedStudy/HomeReviewDueCard';

describe('HomeReviewDueCard', () => {
it('renders nothing when count is zero', () => {
const { toJSON } = renderWithProviders(
<HomeReviewDueCard count={0} onPress={jest.fn()} />,
);
expect(toJSON()).toBeNull();
});

it('renders singular copy when exactly 1 prompt is due', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={1} onPress={jest.fn()} />,
);
expect(getByText('1 prompt due today')).toBeTruthy();
});

it('renders pluralized copy for multiple prompts', () => {
const { getByText } = renderWithProviders(
<HomeReviewDueCard count={7} onPress={jest.fn()} />,
);
expect(getByText('7 prompts due today')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<HomeReviewDueCard count={3} onPress={onPress} />,
);
fireEvent.press(getByLabelText('3 study review prompts due'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { PanelRecommendationRow } from '@/components/guidedStudy/PanelRecommendationRow';

describe('PanelRecommendationRow', () => {
const baseRec = {
key: 'ctx-1',
title: 'Historical Context',
subtitle: 'Ancient Near Eastern background',
panelType: 'ctx',
};

it('renders title and subtitle', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={jest.fn()} />,
);
expect(getByText('Historical Context')).toBeTruthy();
expect(getByText('Ancient Near Eastern background')).toBeTruthy();
});

it('renders a confidence badge when a level is provided', () => {
const { getByText } = renderWithProviders(
<PanelRecommendationRow
recommendation={{ ...baseRec, confidence: 'debated' } as any}
onPress={jest.fn()}
/>,
);
expect(getByText('Debated')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<PanelRecommendationRow recommendation={baseRec as any} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Open Historical Context'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
25 changes: 25 additions & 0 deletions app/__tests__/components/guidedStudy/StudySessionCTA.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionCTA } from '@/components/guidedStudy/StudySessionCTA';

describe('StudySessionCTA', () => {
const estimate = { readMin: 3, guidedMin: 8, deepMin: 20 };

it('renders the estimate line with all three durations', () => {
const { getByText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={jest.fn()} />,
);
expect(getByText('Study this chapter')).toBeTruthy();
expect(getByText('3 min read · 8 min guided · 20 min deep')).toBeTruthy();
});

it('fires onPress when tapped', () => {
const onPress = jest.fn();
const { getByLabelText } = renderWithProviders(
<StudySessionCTA estimate={estimate} onPress={onPress} />,
);
fireEvent.press(getByLabelText('Study this chapter'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { renderWithProviders } from '../../helpers/renderWithProviders';
import { StudySessionStepper } from '@/components/guidedStudy/StudySessionStepper';

describe('StudySessionStepper', () => {
it('renders all five canonical steps', () => {
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={jest.fn()} />,
);
['Scene', 'Observe', 'Explore', 'Synthesize', 'Review'].forEach((label) => {
expect(getByText(label)).toBeTruthy();
});
});

it('fires onSelect with the tapped step key', () => {
const onSelect = jest.fn();
const { getByText } = renderWithProviders(
<StudySessionStepper activeStep="scene" onSelect={onSelect} />,
);
fireEvent.press(getByText('Explore'));
expect(onSelect).toHaveBeenCalledWith('explore');
});
});
16 changes: 10 additions & 6 deletions app/__tests__/db/userDatabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@
const mockExecAsync = jest.fn().mockResolvedValue(undefined);
const mockGetAllAsync = jest.fn().mockResolvedValue([]);
const mockRunAsync = jest.fn().mockResolvedValue({ changes: 0 });
const mockWithTransactionAsync = jest.fn().mockImplementation(async (cb: () => Promise<void>) => cb());
const mockWithTransactionAsync = jest
.fn()
.mockImplementation(async (cb: () => Promise<void>) => cb());
const mockOpenDatabaseAsync = jest.fn();

jest.mock('expo-sqlite', () => ({
Expand DownExpand Up@@ -76,11 +78,12 @@ describe('userDatabase', () => {
it('skips already applied migrations', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate all migrations already applied
mockGetAllAsync.mockResolvedValueOnce(
Array.from({ length: 18 }, (_, i) => ({ version: i + 1 })),
Array.from({ length: MIGRATION_COUNT }, (_, i) => ({ version: i + 1 })),
);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// withTransactionAsync should NOT have been called since all migrations are applied
expect(mockWithTransactionAsync).not.toHaveBeenCalled();
Expand All@@ -89,12 +92,13 @@ describe('userDatabase', () => {
it('runs pending migrations in order', async () => {
jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } }));
jest.resetModules();
userDatabaseModule = require('@/db/userDatabase');
const { MIGRATION_COUNT } = userDatabaseModule;
// Simulate only version 1 applied
mockGetAllAsync.mockResolvedValueOnce([{ version: 1 }]);
userDatabaseModule = require('@/db/userDatabase');
await userDatabaseModule.initUserDatabase();
// Should run 17 remaining migrations (2 through 18)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(17);
// Should run the remaining migrations (2 through MIGRATION_COUNT)
expect(mockWithTransactionAsync).toHaveBeenCalledTimes(MIGRATION_COUNT - 1);
});

it('throws on migration failure', async () => {
Expand Down
Loading
Loading