only-bible-app v3.7.2+24

#kotlin#android#ios

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);
      },
    );
  });
}