only-bible-app v3.7.2+24
git clone https://git.pyrossh.dev/only-bible-app
The only bible app you will ever need. No ads. No in-app purchases. No distractions.
test/app_logic_test.dart
import "dart:io";
import "package:async_redux/async_redux.dart";
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:go_router/go_router.dart";
import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
import "package:only_bible_app/app.dart";
import "package:only_bible_app/env.dart";
import "package:only_bible_app/gen/bible.gen.dart";
import "package:only_bible_app/store/actions_navigation.dart";
import "package:only_bible_app/store/actions_state.dart";
import "package:only_bible_app/store/app_navigator.dart";
import "package:only_bible_app/store/app_persistor.dart";
import "package:only_bible_app/store/app_state.dart";
import "package:only_bible_app/theme.dart";
import "package:only_bible_app/utils.dart";
/// A tiny 3-book Bible used to exercise book/chapter boundary logic without
/// depending on the real bundled assets:
/// Book 0 "Genesis": chapter 0 (2 verses), chapter 1 (1 verse)
/// Book 1 "Judges": chapter 0 (1 verse) <- single-chapter book
/// Book 2 "Revelation": chapter 0 (1 verse), chapter 1 (1 verse) <- last book
Bible buildTestBible() {
final builder = BibleObjectBuilder(
name: "test_bible",
languageCode: "en",
languageEnglish: "English",
languageNative: "English",
voiceName: "en-US",
oldTestamentTitle: "Old Testament",
newTestamentTitle: "New Testament",
bibleSelectTitle: "Select Bible",
books: [
BookObjectBuilder(
index: 0,
name: "Genesis",
chapters: [
ChapterObjectBuilder(
index: 0,
book: 0,
verses: [
VerseObjectBuilder(
index: 0,
book: 0,
chapter: 0,
text: "In the beginning...",
),
VerseObjectBuilder(
index: 1,
book: 0,
chapter: 0,
text: "And the earth...",
),
],
),
ChapterObjectBuilder(
index: 1,
book: 0,
verses: [
VerseObjectBuilder(
index: 0,
book: 0,
chapter: 1,
text: "Chapter two verse one",
),
],
),
],
),
BookObjectBuilder(
index: 1,
name: "Judges",
chapters: [
ChapterObjectBuilder(
index: 0,
book: 1,
verses: [
VerseObjectBuilder(
index: 0,
book: 1,
chapter: 0,
text: "Judges chapter one verse one",
),
],
),
],
),
BookObjectBuilder(
index: 2,
name: "Revelation",
chapters: [
ChapterObjectBuilder(
index: 0,
book: 2,
verses: [
VerseObjectBuilder(
index: 0,
book: 2,
chapter: 0,
text: "Rev 1:1",
),
],
),
ChapterObjectBuilder(
index: 1,
book: 2,
verses: [
VerseObjectBuilder(
index: 0,
book: 2,
chapter: 1,
text: "Rev 2:1",
),
],
),
],
),
],
);
return Bible(builder.toBytes());
}
/// Pumps the real [App] widget - not a bare test [GoRouter] - and returns its
/// router. Navigation actions dispatch fine against a bare router too, but a
/// bare router never attaches App._syncCurrentChapter's routerDelegate
/// listener, which is exactly the listener that had a bug (silently reading
/// the wrong location for push()/pushReplacement() and clobbering
/// savedBook/savedChapter back to a stale value). Testing against a bare
/// router would give false confidence by never exercising it at all.
Future<GoRouter> pumpRealApp(WidgetTester tester, Store<AppState> store) async {
await tester.pumpWidget(
App(globalNavigatorKey: GlobalKey<NavigatorState>(), store: store),
);
await tester.pumpAndSettle();
return tester.element(find.byType(MaterialApp)).router;
}
class _FakePathProviderPlatform extends PathProviderPlatform {
_FakePathProviderPlatform(this.path);
final String path;
Future<String?> getApplicationDocumentsPath() async => path;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group("clampBookChapter", () {
test("keeps in-range values unchanged", () {
final bible = buildTestBible();
expect(clampBookChapter(bible, 1, 0), (1, 0));
});
test("clamps a negative book to 0", () {
final bible = buildTestBible();
expect(clampBookChapter(bible, -5, 0), (0, 0));
});
test("clamps a too-large book to the last book", () {
final bible = buildTestBible();
expect(clampBookChapter(bible, 99, 0), (2, 0));
});
test("clamps a negative chapter to 0", () {
final bible = buildTestBible();
expect(clampBookChapter(bible, 0, -1), (0, 0));
});
test("clamps a too-large chapter to that book's last chapter", () {
final bible = buildTestBible();
expect(clampBookChapter(bible, 0, 99), (0, 1));
expect(clampBookChapter(bible, 1, 99), (1, 0));
});
});
group("safe bible accessors", () {
test(
"bookAt returns null instead of throwing for an out-of-range index",
() {
final bible = buildTestBible();
expect(bible.bookAt(-1), isNull);
expect(bible.bookAt(99), isNull);
expect(bible.bookAt(1)?.name, "Judges");
},
);
test(
"chapterAt returns null instead of throwing for an out-of-range index",
() {
final genesis = buildTestBible().bookAt(0)!;
expect(genesis.chapterAt(-1), isNull);
expect(genesis.chapterAt(99), isNull);
expect(genesis.chapterAt(1)?.index, 1);
},
);
test(
"verseAt returns null instead of throwing for an out-of-range index",
() {
final chapter = buildTestBible().bookAt(0)!.chapterAt(0)!;
expect(chapter.verseAt(-1), isNull);
expect(chapter.verseAt(99), isNull);
expect(chapter.verseAt(1)?.text, "And the earth...");
},
);
});
group("Book.shortName", () {
// shortName() only reads its `name` argument, so any Book instance works
// as the extension method receiver.
late Book anyBook;
setUpAll(() => anyBook = buildTestBible().bookAt(0)!);
test("disambiguates Judges vs Jude", () {
expect(anyBook.shortName("Judges"), "Jdg");
expect(anyBook.shortName("Jude"), "Jud");
expect(
anyBook.shortName("Judges"),
isNot(equals(anyBook.shortName("Jude"))),
);
});
test("disambiguates Philemon vs Philippians", () {
expect(anyBook.shortName("Philemon"), "Phm");
expect(anyBook.shortName("Philippians"), "Php");
expect(
anyBook.shortName("Philemon"),
isNot(equals(anyBook.shortName("Philippians"))),
);
});
test("does not throw for 3-letter book names", () {
expect(() => anyBook.shortName("Job"), returnsNormally);
expect(anyBook.shortName("Job"), "Job");
});
test("keeps numbered-book abbreviations distinct", () {
expect(anyBook.shortName("1 Samuel"), "1Sa");
expect(anyBook.shortName("2 Samuel"), "2Sa");
expect(
anyBook.shortName("1 Timothy"),
isNot(equals(anyBook.shortName("1 Thessalonians"))),
);
});
});
group("AppState.fromJson", () {
test("round-trips through toJson", () {
final bible = buildTestBible();
final state = AppState(
bible: bible,
savedBook: 1,
savedChapter: 0,
fontSize: 20,
themeMode: ThemeMode.dark,
highlightHistory: [
HighlightHistoryEntry(
book: 0,
chapter: 0,
verseIndex: 0,
colorIndex: 1,
timestamp: DateTime.utc(2026, 1, 1),
),
],
);
final restored = AppState.fromJson(state.toJson(), bible);
expect(restored.savedBook, 1);
expect(restored.savedChapter, 0);
expect(restored.fontSize, 20);
expect(restored.themeMode, ThemeMode.dark);
expect(restored.highlightHistory.single.book, 0);
});
test(
"clamps an out-of-range persisted savedBook/savedChapter instead of crashing",
() {
final bible = buildTestBible();
final state = AppState.fromJson({
"savedBook": 999,
"savedChapter": 999,
}, bible);
expect(state.savedBook, 2);
expect(state.savedChapter, 1);
},
);
test("defaults missing fields", () {
final bible = buildTestBible();
final state = AppState.fromJson({}, bible);
expect(state.savedBook, 0);
expect(state.savedChapter, 0);
expect(state.themeMode, ThemeMode.system);
expect(state.engTitles, false);
});
});
group("SelectVerseAction", () {
test(
"deselecting a verse only removes the matching book+chapter+index",
() async {
final bible = buildTestBible();
// Same verse index (0) in two different chapters of the same book -
// exactly the collision the deselect-path bug missed.
final verseInChapter0 = bible.bookAt(0)!.chapterAt(0)!.verseAt(0)!;
final verseInChapter1 = bible.bookAt(0)!.chapterAt(1)!.verseAt(0)!;
final store = Store<AppState>(initialState: AppState(bible: bible));
await store.dispatchAndWait(SelectVerseAction(verseInChapter0));
await store.dispatchAndWait(SelectVerseAction(verseInChapter1));
expect(store.state.selectedVerses.length, 2);
await store.dispatchAndWait(SelectVerseAction(verseInChapter0));
expect(store.state.selectedVerses, [verseInChapter1]);
},
);
});
group("SetHighlightAction", () {
test("keeps only the last 500 history entries", () async {
final bible = buildTestBible();
final verse = bible.bookAt(0)!.chapterAt(0)!.verseAt(0)!;
final existingHistory = List.generate(
500,
(i) => HighlightHistoryEntry(
book: 0,
chapter: 0,
verseIndex: 1,
colorIndex: 0,
timestamp: DateTime.utc(2026, 1, 1),
),
);
final store = Store<AppState>(
initialState: AppState(bible: bible, highlightHistory: existingHistory),
);
await store.dispatchAndWait(SetHighlightAction([verse], 2));
expect(store.state.highlightHistory.length, 500);
expect(store.state.highlights["0:0:0"], 2);
});
});
// RemoveHighlightAction's "removes the highlight and clears the selection"
// behavior is covered by test/verse_selection_integration_test.dart, which
// exercises it (and the tap -> action wiring around it) through real UI
// interaction rather than a bare dispatch.
group("adjacentChapter", () {
test("moves to the next chapter within the same book", () {
final bible = buildTestBible();
expect(adjacentChapter(bible, 0, 0, forward: true), (0, 1));
});
test("crosses into the next book after the last chapter", () {
final bible = buildTestBible();
expect(adjacentChapter(bible, 0, 1, forward: true), (1, 0));
});
test("returns null after the very last chapter of the last book", () {
final bible = buildTestBible();
expect(adjacentChapter(bible, 2, 1, forward: true), isNull);
});
test("moves to the previous chapter within the same book", () {
final bible = buildTestBible();
expect(adjacentChapter(bible, 0, 1, forward: false), (0, 0));
});
test("crosses back into the previous book's last chapter", () {
final bible = buildTestBible();
expect(adjacentChapter(bible, 1, 0, forward: false), (0, 1));
});
test("returns null before the very first chapter", () {
final bible = buildTestBible();
expect(adjacentChapter(bible, 0, 0, forward: false), isNull);
});
});
// UpdateChapterAction is a trivial 2-line reducer, and its effect is
// already exercised end-to-end by the GoToChapterAction/NextChapterAction/
// etc. integration tests below - a standalone dispatch-and-assert test for
// it added no signal beyond what those already cover.
group("SyncCurrentChapterAction", () {
test("is a no-op when book/chapter already match", () async {
// Unlike the "updates state when they differ" half of this reducer -
// now covered end-to-end by the integration tests below - this checks
// object identity is preserved (avoids an unnecessary rebuild), which
// isn't something a UI-driven test can observe.
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 1, savedChapter: 0),
);
final stateBefore = store.state;
await store.dispatchAndWait(SyncCurrentChapterAction(1, 0));
expect(identical(store.state, stateBefore), isTrue);
});
});
// These used to be untestable here: they all call stopAudioPlayback(),
// which touched flutter_soloud's FFI bindings and dlsym'd a native symbol
// only present once the plugin's native library was loaded by a real app
// process, crashing in this headless VM regardless of the logic under
// test. Switching to audioplayers (a plain platform-channel plugin, lazily
// constructed) removed that: stopAudioPlayback() is a no-op whenever
// playback was never started, which is always true here.
//
// All of these mount the real App widget via pumpRealApp rather than a
// bare test GoRouter, specifically so App._syncCurrentChapter's
// routerDelegate listener is genuinely exercised - see the regression test
// below for why that distinction mattered.
group("GoToChapterAction", () {
testWidgets("updates savedBook/savedChapter and pushes the route", (
tester,
) async {
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
);
final router = await pumpRealApp(tester, store);
await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
await tester.pumpAndSettle();
expect(store.state.savedBook, 2);
expect(store.state.savedChapter, 1);
});
testWidgets(
"savedBook/savedChapter stay put on a second read after navigating "
"(regression: App._syncCurrentChapter read the wrong location for "
"push(), clobbering them back to the pre-navigation chapter - visible "
"as the book/chapter selector reopening on the old chapter)",
(tester) async {
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
);
final router = await pumpRealApp(tester, store);
await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
await tester.pumpAndSettle();
// Simulate "reopening the selector": read state again after the
// dust settles, the same way BookSelectSheet.initState() would.
expect(store.state.savedBook, 2);
expect(store.state.savedChapter, 1);
},
);
});
group("NextChapterAction", () {
testWidgets("advances to the next chapter and updates saved state", (
tester,
) async {
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
);
final router = await pumpRealApp(tester, store);
await store.dispatchAndWait(NextChapterAction(router, bible, 0, 0));
await tester.pumpAndSettle();
expect(store.state.savedBook, 0);
expect(store.state.savedChapter, 1);
});
testWidgets("is a no-op after the very last chapter of the last book", (
tester,
) async {
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 2, savedChapter: 1),
);
final router = await pumpRealApp(tester, store);
await store.dispatchAndWait(NextChapterAction(router, bible, 2, 1));
await tester.pumpAndSettle();
expect(store.state.savedBook, 2);
expect(store.state.savedChapter, 1);
});
testWidgets(
"still lands correctly when swiping right after a prior book/chapter-picker "
"push (pushReplacement on a multi-entry stack also gets wrapped in an "
"ImperativeRouteMatch, hitting the same drilling logic as push())",
(tester) async {
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
);
final router = await pumpRealApp(tester, store);
// Push to Revelation 1 the way the book/chapter picker does.
await store.dispatchAndWait(GoToChapterAction(router, 2, 0));
await tester.pumpAndSettle();
expect(store.state.savedBook, 2);
expect(store.state.savedChapter, 0);
// Swipe to Revelation 2 - a pushReplacement on top of the still
// 2-entry stack left by the push above.
await store.dispatchAndWait(NextChapterAction(router, bible, 2, 0));
await tester.pumpAndSettle();
expect(store.state.savedBook, 2);
expect(store.state.savedChapter, 1);
},
);
});
group("PreviousChapterAction", () {
testWidgets("moves to the previous chapter and updates saved state", (
tester,
) async {
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 1, savedChapter: 0),
);
final router = await pumpRealApp(tester, store);
await store.dispatchAndWait(PreviousChapterAction(router, bible, 1, 0));
await tester.pumpAndSettle();
expect(store.state.savedBook, 0);
expect(store.state.savedChapter, 1);
});
});
group("PopChapterAction", () {
testWidgets("restores the pre-push savedBook/savedChapter synchronously, "
"without waiting on the router's pop-transition listener", (
tester,
) async {
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
);
final router = await pumpRealApp(tester, store);
await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
await tester.pumpAndSettle();
expect(store.state.savedBook, 2);
expect(store.state.savedChapter, 1);
await store.dispatchAndWait(PopChapterAction(router));
expect(store.state.savedBook, 0);
expect(store.state.savedChapter, 0);
});
});
group("UpdateCurrentBibleAction", () {
testWidgets(
"switching translation after navigating to another chapter doesn't leave a stale "
"chapter poppable underneath (regression: switching Bible used to revert to it)",
(tester) async {
final bible = buildTestBible();
final store = Store<AppState>(
initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
);
final router = await pumpRealApp(tester, store);
// Navigate to another chapter the way the book/chapter picker does
// (push, not pushReplacement) - this is what used to leave a stale
// entry behind after a bible switch.
await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
await tester.pumpAndSettle();
expect(store.state.savedBook, 2);
expect(store.state.savedChapter, 1);
await store.dispatchAndWait(
UpdateCurrentBibleAction(
router,
"other_bible",
store.state.savedBook,
store.state.savedChapter,
loadBibleFn: (_) async => buildTestBible(),
),
);
await tester.pumpAndSettle();
expect(store.state.savedBook, 2);
expect(store.state.savedChapter, 1);
// The pre-switch chapter must not still be reachable via "back" -
// otherwise the next edge-swipe-right treats it as pop-to-previous
// instead of previous-chapter, landing back on the stale position.
expect(router.canPop(), isFalse);
expect(router.routerDelegate.currentConfiguration.matches.length, 1);
},
);
});
group("buildShareContent", () {
test(
"sorts verses by index for both the title and the text, regardless of selection order",
() {
final bible = buildTestBible();
final chapter = bible.bookAt(0)!.chapterAt(0)!;
final verse0 = chapter.verseAt(0)!; // "In the beginning..."
final verse1 = chapter.verseAt(1)!; // "And the earth..."
// Selected out of order: verse 2 (index 1) tapped before verse 1
// (index 0) - the exact repro for the reported bug.
final (title, text) = buildShareContent(
[verse1, verse0],
"Genesis",
bible,
);
expect(title, "Genesis 1:1,2 English");
expect(text, "In the beginning...\nAnd the earth...");
},
);
});
group("theme", () {
test(
"dark theme labelMedium (verse numbers) uses the dark error accent, matching the light theme's dedicated red",
() {
expect(
AppTheme.dark.textTheme.labelMedium!.color,
darkColorScheme.error,
);
},
);
});
group("Env", () {
test("obfuscated secrets decode back to non-empty strings", () {
expect(Env.errorReportSecret, isNotEmpty);
expect(Env.ttsSubscriptionKey, isNotEmpty);
});
});
group("AppPersistor", () {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp("app_persistor_test");
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
});
tearDown(() async {
await tempDir.delete(recursive: true);
});
test(
"persistDifference writes atomically, leaving no leftover temp file",
() async {
final bible = buildTestBible();
final persistor = AppPersistor();
final state = AppState(
bible: bible,
savedBook: 1,
savedChapter: 0,
themeMode: ThemeMode.dark,
);
await persistor.persistDifference(
lastPersistedState: null,
newState: state,
);
final json = await persistor.readJson();
expect(json, isNotNull);
expect(json!["savedBook"], 1);
expect(json["themeMode"], "dark");
expect(
await File("${tempDir.path}/app_state.json.tmp").exists(),
isFalse,
);
},
);
test(
"readJson returns null for corrupt JSON instead of throwing",
() async {
final persistor = AppPersistor();
await File(
"${tempDir.path}/app_state.json",
).writeAsString("{not valid json");
expect(await persistor.readJson(), isNull);
},
);
});
}