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
fe24dd6 1
import "dart:io";
fe24dd6 2
fe24dd6 3
import "package:async_redux/async_redux.dart";
c2e460a 4
import "package:flutter/material.dart";
fe24dd6 5
import "package:flutter_test/flutter_test.dart";
0228ae8 6
import "package:go_router/go_router.dart";
fe24dd6 7
import "package:path_provider_platform_interface/path_provider_platform_interface.dart";
8b87a9b 8
import "package:only_bible_app/app.dart";
fe24dd6 9
import "package:only_bible_app/env.dart";
fe24dd6 10
import "package:only_bible_app/gen/bible.gen.dart";
fe24dd6 11
import "package:only_bible_app/store/actions_navigation.dart";
fe24dd6 12
import "package:only_bible_app/store/actions_state.dart";
8b87a9b 13
import "package:only_bible_app/store/app_navigator.dart";
fe24dd6 14
import "package:only_bible_app/store/app_persistor.dart";
fe24dd6 15
import "package:only_bible_app/store/app_state.dart";
0228ae8 16
import "package:only_bible_app/theme.dart";
fe24dd6 17
import "package:only_bible_app/utils.dart";
fe24dd6 18
fe24dd6 19
/// A tiny 3-book Bible used to exercise book/chapter boundary logic without
fe24dd6 20
/// depending on the real bundled assets:
fe24dd6 21
///   Book 0 "Genesis":    chapter 0 (2 verses), chapter 1 (1 verse)
fe24dd6 22
///   Book 1 "Judges":     chapter 0 (1 verse)                        <- single-chapter book
fe24dd6 23
///   Book 2 "Revelation": chapter 0 (1 verse), chapter 1 (1 verse)   <- last book
fe24dd6 24
Bible buildTestBible() {
fe24dd6 25
  final builder = BibleObjectBuilder(
fe24dd6 26
    name: "test_bible",
fe24dd6 27
    languageCode: "en",
fe24dd6 28
    languageEnglish: "English",
fe24dd6 29
    languageNative: "English",
fe24dd6 30
    voiceName: "en-US",
fe24dd6 31
    oldTestamentTitle: "Old Testament",
fe24dd6 32
    newTestamentTitle: "New Testament",
fe24dd6 33
    bibleSelectTitle: "Select Bible",
fe24dd6 34
    books: [
fe24dd6 35
      BookObjectBuilder(
fe24dd6 36
        index: 0,
fe24dd6 37
        name: "Genesis",
fe24dd6 38
        chapters: [
fe24dd6 39
          ChapterObjectBuilder(
fe24dd6 40
            index: 0,
fe24dd6 41
            book: 0,
fe24dd6 42
            verses: [
0228ae8 43
              VerseObjectBuilder(
0228ae8 44
                index: 0,
0228ae8 45
                book: 0,
0228ae8 46
                chapter: 0,
0228ae8 47
                text: "In the beginning...",
0228ae8 48
              ),
0228ae8 49
              VerseObjectBuilder(
0228ae8 50
                index: 1,
0228ae8 51
                book: 0,
0228ae8 52
                chapter: 0,
0228ae8 53
                text: "And the earth...",
0228ae8 54
              ),
fe24dd6 55
            ],
fe24dd6 56
          ),
fe24dd6 57
          ChapterObjectBuilder(
fe24dd6 58
            index: 1,
fe24dd6 59
            book: 0,
fe24dd6 60
            verses: [
0228ae8 61
              VerseObjectBuilder(
0228ae8 62
                index: 0,
0228ae8 63
                book: 0,
0228ae8 64
                chapter: 1,
0228ae8 65
                text: "Chapter two verse one",
0228ae8 66
              ),
fe24dd6 67
            ],
fe24dd6 68
          ),
fe24dd6 69
        ],
fe24dd6 70
      ),
fe24dd6 71
      BookObjectBuilder(
fe24dd6 72
        index: 1,
fe24dd6 73
        name: "Judges",
fe24dd6 74
        chapters: [
fe24dd6 75
          ChapterObjectBuilder(
fe24dd6 76
            index: 0,
fe24dd6 77
            book: 1,
fe24dd6 78
            verses: [
0228ae8 79
              VerseObjectBuilder(
0228ae8 80
                index: 0,
0228ae8 81
                book: 1,
0228ae8 82
                chapter: 0,
0228ae8 83
                text: "Judges chapter one verse one",
0228ae8 84
              ),
fe24dd6 85
            ],
fe24dd6 86
          ),
fe24dd6 87
        ],
fe24dd6 88
      ),
fe24dd6 89
      BookObjectBuilder(
fe24dd6 90
        index: 2,
fe24dd6 91
        name: "Revelation",
fe24dd6 92
        chapters: [
fe24dd6 93
          ChapterObjectBuilder(
fe24dd6 94
            index: 0,
fe24dd6 95
            book: 2,
0228ae8 96
            verses: [
0228ae8 97
              VerseObjectBuilder(
0228ae8 98
                index: 0,
0228ae8 99
                book: 2,
0228ae8 100
                chapter: 0,
0228ae8 101
                text: "Rev 1:1",
0228ae8 102
              ),
0228ae8 103
            ],
fe24dd6 104
          ),
fe24dd6 105
          ChapterObjectBuilder(
fe24dd6 106
            index: 1,
fe24dd6 107
            book: 2,
0228ae8 108
            verses: [
0228ae8 109
              VerseObjectBuilder(
0228ae8 110
                index: 0,
0228ae8 111
                book: 2,
0228ae8 112
                chapter: 1,
0228ae8 113
                text: "Rev 2:1",
0228ae8 114
              ),
0228ae8 115
            ],
fe24dd6 116
          ),
fe24dd6 117
        ],
fe24dd6 118
      ),
fe24dd6 119
    ],
fe24dd6 120
  );
fe24dd6 121
  return Bible(builder.toBytes());
fe24dd6 122
}
fe24dd6 123
8b87a9b 124
/// Pumps the real [App] widget - not a bare test [GoRouter] - and returns its
8b87a9b 125
/// router. Navigation actions dispatch fine against a bare router too, but a
8b87a9b 126
/// bare router never attaches App._syncCurrentChapter's routerDelegate
8b87a9b 127
/// listener, which is exactly the listener that had a bug (silently reading
8b87a9b 128
/// the wrong location for push()/pushReplacement() and clobbering
8b87a9b 129
/// savedBook/savedChapter back to a stale value). Testing against a bare
8b87a9b 130
/// router would give false confidence by never exercising it at all.
8b87a9b 131
Future<GoRouter> pumpRealApp(WidgetTester tester, Store<AppState> store) async {
8b87a9b 132
  await tester.pumpWidget(
8b87a9b 133
    App(globalNavigatorKey: GlobalKey<NavigatorState>(), store: store),
0228ae8 134
  );
8b87a9b 135
  await tester.pumpAndSettle();
8b87a9b 136
  return tester.element(find.byType(MaterialApp)).router;
0228ae8 137
}
0228ae8 138
fe24dd6 139
class _FakePathProviderPlatform extends PathProviderPlatform {
fe24dd6 140
  _FakePathProviderPlatform(this.path);
fe24dd6 141
  final String path;
fe24dd6 142
fe24dd6 143
  @override
fe24dd6 144
  Future<String?> getApplicationDocumentsPath() async => path;
fe24dd6 145
}
fe24dd6 146
fe24dd6 147
void main() {
fe24dd6 148
  TestWidgetsFlutterBinding.ensureInitialized();
fe24dd6 149
fe24dd6 150
  group("clampBookChapter", () {
fe24dd6 151
    test("keeps in-range values unchanged", () {
fe24dd6 152
      final bible = buildTestBible();
fe24dd6 153
      expect(clampBookChapter(bible, 1, 0), (1, 0));
fe24dd6 154
    });
fe24dd6 155
fe24dd6 156
    test("clamps a negative book to 0", () {
fe24dd6 157
      final bible = buildTestBible();
fe24dd6 158
      expect(clampBookChapter(bible, -5, 0), (0, 0));
fe24dd6 159
    });
fe24dd6 160
fe24dd6 161
    test("clamps a too-large book to the last book", () {
fe24dd6 162
      final bible = buildTestBible();
fe24dd6 163
      expect(clampBookChapter(bible, 99, 0), (2, 0));
fe24dd6 164
    });
fe24dd6 165
fe24dd6 166
    test("clamps a negative chapter to 0", () {
fe24dd6 167
      final bible = buildTestBible();
fe24dd6 168
      expect(clampBookChapter(bible, 0, -1), (0, 0));
fe24dd6 169
    });
fe24dd6 170
fe24dd6 171
    test("clamps a too-large chapter to that book's last chapter", () {
fe24dd6 172
      final bible = buildTestBible();
fe24dd6 173
      expect(clampBookChapter(bible, 0, 99), (0, 1));
fe24dd6 174
      expect(clampBookChapter(bible, 1, 99), (1, 0));
fe24dd6 175
    });
fe24dd6 176
  });
fe24dd6 177
fe24dd6 178
  group("safe bible accessors", () {
0228ae8 179
    test(
0228ae8 180
      "bookAt returns null instead of throwing for an out-of-range index",
0228ae8 181
      () {
0228ae8 182
        final bible = buildTestBible();
0228ae8 183
        expect(bible.bookAt(-1), isNull);
0228ae8 184
        expect(bible.bookAt(99), isNull);
0228ae8 185
        expect(bible.bookAt(1)?.name, "Judges");
0228ae8 186
      },
0228ae8 187
    );
0228ae8 188
0228ae8 189
    test(
0228ae8 190
      "chapterAt returns null instead of throwing for an out-of-range index",
0228ae8 191
      () {
0228ae8 192
        final genesis = buildTestBible().bookAt(0)!;
0228ae8 193
        expect(genesis.chapterAt(-1), isNull);
0228ae8 194
        expect(genesis.chapterAt(99), isNull);
0228ae8 195
        expect(genesis.chapterAt(1)?.index, 1);
0228ae8 196
      },
0228ae8 197
    );
0228ae8 198
0228ae8 199
    test(
0228ae8 200
      "verseAt returns null instead of throwing for an out-of-range index",
0228ae8 201
      () {
0228ae8 202
        final chapter = buildTestBible().bookAt(0)!.chapterAt(0)!;
0228ae8 203
        expect(chapter.verseAt(-1), isNull);
0228ae8 204
        expect(chapter.verseAt(99), isNull);
0228ae8 205
        expect(chapter.verseAt(1)?.text, "And the earth...");
0228ae8 206
      },
0228ae8 207
    );
fe24dd6 208
  });
fe24dd6 209
fe24dd6 210
  group("Book.shortName", () {
fe24dd6 211
    // shortName() only reads its `name` argument, so any Book instance works
fe24dd6 212
    // as the extension method receiver.
fe24dd6 213
    late Book anyBook;
fe24dd6 214
    setUpAll(() => anyBook = buildTestBible().bookAt(0)!);
fe24dd6 215
fe24dd6 216
    test("disambiguates Judges vs Jude", () {
fe24dd6 217
      expect(anyBook.shortName("Judges"), "Jdg");
fe24dd6 218
      expect(anyBook.shortName("Jude"), "Jud");
0228ae8 219
      expect(
0228ae8 220
        anyBook.shortName("Judges"),
0228ae8 221
        isNot(equals(anyBook.shortName("Jude"))),
0228ae8 222
      );
fe24dd6 223
    });
fe24dd6 224
fe24dd6 225
    test("disambiguates Philemon vs Philippians", () {
fe24dd6 226
      expect(anyBook.shortName("Philemon"), "Phm");
fe24dd6 227
      expect(anyBook.shortName("Philippians"), "Php");
0228ae8 228
      expect(
0228ae8 229
        anyBook.shortName("Philemon"),
0228ae8 230
        isNot(equals(anyBook.shortName("Philippians"))),
0228ae8 231
      );
fe24dd6 232
    });
fe24dd6 233
fe24dd6 234
    test("does not throw for 3-letter book names", () {
fe24dd6 235
      expect(() => anyBook.shortName("Job"), returnsNormally);
fe24dd6 236
      expect(anyBook.shortName("Job"), "Job");
fe24dd6 237
    });
fe24dd6 238
fe24dd6 239
    test("keeps numbered-book abbreviations distinct", () {
fe24dd6 240
      expect(anyBook.shortName("1 Samuel"), "1Sa");
fe24dd6 241
      expect(anyBook.shortName("2 Samuel"), "2Sa");
0228ae8 242
      expect(
0228ae8 243
        anyBook.shortName("1 Timothy"),
0228ae8 244
        isNot(equals(anyBook.shortName("1 Thessalonians"))),
0228ae8 245
      );
fe24dd6 246
    });
fe24dd6 247
  });
fe24dd6 248
fe24dd6 249
  group("AppState.fromJson", () {
fe24dd6 250
    test("round-trips through toJson", () {
fe24dd6 251
      final bible = buildTestBible();
fe24dd6 252
      final state = AppState(
fe24dd6 253
        bible: bible,
fe24dd6 254
        savedBook: 1,
fe24dd6 255
        savedChapter: 0,
fe24dd6 256
        fontSize: 20,
c2e460a 257
        themeMode: ThemeMode.dark,
fe24dd6 258
        highlightHistory: [
0228ae8 259
          HighlightHistoryEntry(
0228ae8 260
            book: 0,
0228ae8 261
            chapter: 0,
0228ae8 262
            verseIndex: 0,
0228ae8 263
            colorIndex: 1,
0228ae8 264
            timestamp: DateTime.utc(2026, 1, 1),
0228ae8 265
          ),
fe24dd6 266
        ],
fe24dd6 267
      );
fe24dd6 268
      final restored = AppState.fromJson(state.toJson(), bible);
fe24dd6 269
      expect(restored.savedBook, 1);
fe24dd6 270
      expect(restored.savedChapter, 0);
fe24dd6 271
      expect(restored.fontSize, 20);
c2e460a 272
      expect(restored.themeMode, ThemeMode.dark);
fe24dd6 273
      expect(restored.highlightHistory.single.book, 0);
fe24dd6 274
    });
fe24dd6 275
0228ae8 276
    test(
0228ae8 277
      "clamps an out-of-range persisted savedBook/savedChapter instead of crashing",
0228ae8 278
      () {
0228ae8 279
        final bible = buildTestBible();
0228ae8 280
        final state = AppState.fromJson({
0228ae8 281
          "savedBook": 999,
0228ae8 282
          "savedChapter": 999,
0228ae8 283
        }, bible);
0228ae8 284
        expect(state.savedBook, 2);
0228ae8 285
        expect(state.savedChapter, 1);
0228ae8 286
      },
0228ae8 287
    );
fe24dd6 288
fe24dd6 289
    test("defaults missing fields", () {
fe24dd6 290
      final bible = buildTestBible();
fe24dd6 291
      final state = AppState.fromJson({}, bible);
fe24dd6 292
      expect(state.savedBook, 0);
fe24dd6 293
      expect(state.savedChapter, 0);
c2e460a 294
      expect(state.themeMode, ThemeMode.system);
fe24dd6 295
      expect(state.engTitles, false);
fe24dd6 296
    });
fe24dd6 297
  });
fe24dd6 298
fe24dd6 299
  group("SelectVerseAction", () {
0228ae8 300
    test(
0228ae8 301
      "deselecting a verse only removes the matching book+chapter+index",
0228ae8 302
      () async {
0228ae8 303
        final bible = buildTestBible();
0228ae8 304
        // Same verse index (0) in two different chapters of the same book -
0228ae8 305
        // exactly the collision the deselect-path bug missed.
0228ae8 306
        final verseInChapter0 = bible.bookAt(0)!.chapterAt(0)!.verseAt(0)!;
0228ae8 307
        final verseInChapter1 = bible.bookAt(0)!.chapterAt(1)!.verseAt(0)!;
0228ae8 308
0228ae8 309
        final store = Store<AppState>(initialState: AppState(bible: bible));
0228ae8 310
        await store.dispatchAndWait(SelectVerseAction(verseInChapter0));
0228ae8 311
        await store.dispatchAndWait(SelectVerseAction(verseInChapter1));
0228ae8 312
        expect(store.state.selectedVerses.length, 2);
0228ae8 313
0228ae8 314
        await store.dispatchAndWait(SelectVerseAction(verseInChapter0));
0228ae8 315
        expect(store.state.selectedVerses, [verseInChapter1]);
0228ae8 316
      },
0228ae8 317
    );
fe24dd6 318
  });
fe24dd6 319
fe24dd6 320
  group("SetHighlightAction", () {
fe24dd6 321
    test("keeps only the last 500 history entries", () async {
fe24dd6 322
      final bible = buildTestBible();
fe24dd6 323
      final verse = bible.bookAt(0)!.chapterAt(0)!.verseAt(0)!;
fe24dd6 324
      final existingHistory = List.generate(
fe24dd6 325
        500,
0228ae8 326
        (i) => HighlightHistoryEntry(
0228ae8 327
          book: 0,
0228ae8 328
          chapter: 0,
0228ae8 329
          verseIndex: 1,
0228ae8 330
          colorIndex: 0,
0228ae8 331
          timestamp: DateTime.utc(2026, 1, 1),
0228ae8 332
        ),
0228ae8 333
      );
0228ae8 334
      final store = Store<AppState>(
0228ae8 335
        initialState: AppState(bible: bible, highlightHistory: existingHistory),
fe24dd6 336
      );
fe24dd6 337
      await store.dispatchAndWait(SetHighlightAction([verse], 2));
fe24dd6 338
      expect(store.state.highlightHistory.length, 500);
fe24dd6 339
      expect(store.state.highlights["0:0:0"], 2);
fe24dd6 340
    });
fe24dd6 341
  });
fe24dd6 342
64b2ff0 343
  // RemoveHighlightAction's "removes the highlight and clears the selection"
64b2ff0 344
  // behavior is covered by test/verse_selection_integration_test.dart, which
64b2ff0 345
  // exercises it (and the tap -> action wiring around it) through real UI
64b2ff0 346
  // interaction rather than a bare dispatch.
fe24dd6 347
fe24dd6 348
  group("adjacentChapter", () {
fe24dd6 349
    test("moves to the next chapter within the same book", () {
fe24dd6 350
      final bible = buildTestBible();
fe24dd6 351
      expect(adjacentChapter(bible, 0, 0, forward: true), (0, 1));
fe24dd6 352
    });
fe24dd6 353
fe24dd6 354
    test("crosses into the next book after the last chapter", () {
fe24dd6 355
      final bible = buildTestBible();
fe24dd6 356
      expect(adjacentChapter(bible, 0, 1, forward: true), (1, 0));
fe24dd6 357
    });
fe24dd6 358
fe24dd6 359
    test("returns null after the very last chapter of the last book", () {
fe24dd6 360
      final bible = buildTestBible();
fe24dd6 361
      expect(adjacentChapter(bible, 2, 1, forward: true), isNull);
fe24dd6 362
    });
fe24dd6 363
fe24dd6 364
    test("moves to the previous chapter within the same book", () {
fe24dd6 365
      final bible = buildTestBible();
fe24dd6 366
      expect(adjacentChapter(bible, 0, 1, forward: false), (0, 0));
fe24dd6 367
    });
fe24dd6 368
fe24dd6 369
    test("crosses back into the previous book's last chapter", () {
fe24dd6 370
      final bible = buildTestBible();
fe24dd6 371
      expect(adjacentChapter(bible, 1, 0, forward: false), (0, 1));
fe24dd6 372
    });
fe24dd6 373
fe24dd6 374
    test("returns null before the very first chapter", () {
fe24dd6 375
      final bible = buildTestBible();
fe24dd6 376
      expect(adjacentChapter(bible, 0, 0, forward: false), isNull);
fe24dd6 377
    });
fe24dd6 378
  });
fe24dd6 379
64b2ff0 380
  // UpdateChapterAction is a trivial 2-line reducer, and its effect is
64b2ff0 381
  // already exercised end-to-end by the GoToChapterAction/NextChapterAction/
64b2ff0 382
  // etc. integration tests below - a standalone dispatch-and-assert test for
64b2ff0 383
  // it added no signal beyond what those already cover.
fe24dd6 384
fe24dd6 385
  group("SyncCurrentChapterAction", () {
fe24dd6 386
    test("is a no-op when book/chapter already match", () async {
64b2ff0 387
      // Unlike the "updates state when they differ" half of this reducer -
64b2ff0 388
      // now covered end-to-end by the integration tests below - this checks
64b2ff0 389
      // object identity is preserved (avoids an unnecessary rebuild), which
64b2ff0 390
      // isn't something a UI-driven test can observe.
fe24dd6 391
      final bible = buildTestBible();
0228ae8 392
      final store = Store<AppState>(
0228ae8 393
        initialState: AppState(bible: bible, savedBook: 1, savedChapter: 0),
0228ae8 394
      );
fe24dd6 395
      final stateBefore = store.state;
fe24dd6 396
      await store.dispatchAndWait(SyncCurrentChapterAction(1, 0));
fe24dd6 397
      expect(identical(store.state, stateBefore), isTrue);
fe24dd6 398
    });
fe24dd6 399
  });
fe24dd6 400
0228ae8 401
  // These used to be untestable here: they all call stopAudioPlayback(),
0228ae8 402
  // which touched flutter_soloud's FFI bindings and dlsym'd a native symbol
0228ae8 403
  // only present once the plugin's native library was loaded by a real app
0228ae8 404
  // process, crashing in this headless VM regardless of the logic under
0228ae8 405
  // test. Switching to audioplayers (a plain platform-channel plugin, lazily
0228ae8 406
  // constructed) removed that: stopAudioPlayback() is a no-op whenever
0228ae8 407
  // playback was never started, which is always true here.
8b87a9b 408
  //
8b87a9b 409
  // All of these mount the real App widget via pumpRealApp rather than a
8b87a9b 410
  // bare test GoRouter, specifically so App._syncCurrentChapter's
8b87a9b 411
  // routerDelegate listener is genuinely exercised - see the regression test
8b87a9b 412
  // below for why that distinction mattered.
0228ae8 413
  group("GoToChapterAction", () {
8b87a9b 414
    testWidgets("updates savedBook/savedChapter and pushes the route", (
8b87a9b 415
      tester,
8b87a9b 416
    ) async {
0228ae8 417
      final bible = buildTestBible();
0228ae8 418
      final store = Store<AppState>(
0228ae8 419
        initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
0228ae8 420
      );
8b87a9b 421
      final router = await pumpRealApp(tester, store);
0228ae8 422
      await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
8b87a9b 423
      await tester.pumpAndSettle();
0228ae8 424
      expect(store.state.savedBook, 2);
0228ae8 425
      expect(store.state.savedChapter, 1);
0228ae8 426
    });
8b87a9b 427
8b87a9b 428
    testWidgets(
8b87a9b 429
      "savedBook/savedChapter stay put on a second read after navigating "
8b87a9b 430
      "(regression: App._syncCurrentChapter read the wrong location for "
8b87a9b 431
      "push(), clobbering them back to the pre-navigation chapter - visible "
8b87a9b 432
      "as the book/chapter selector reopening on the old chapter)",
8b87a9b 433
      (tester) async {
8b87a9b 434
        final bible = buildTestBible();
8b87a9b 435
        final store = Store<AppState>(
8b87a9b 436
          initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
8b87a9b 437
        );
8b87a9b 438
        final router = await pumpRealApp(tester, store);
8b87a9b 439
8b87a9b 440
        await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
8b87a9b 441
        await tester.pumpAndSettle();
8b87a9b 442
8b87a9b 443
        // Simulate "reopening the selector": read state again after the
8b87a9b 444
        // dust settles, the same way BookSelectSheet.initState() would.
8b87a9b 445
        expect(store.state.savedBook, 2);
8b87a9b 446
        expect(store.state.savedChapter, 1);
8b87a9b 447
      },
8b87a9b 448
    );
0228ae8 449
  });
0228ae8 450
0228ae8 451
  group("NextChapterAction", () {
8b87a9b 452
    testWidgets("advances to the next chapter and updates saved state", (
8b87a9b 453
      tester,
8b87a9b 454
    ) async {
0228ae8 455
      final bible = buildTestBible();
0228ae8 456
      final store = Store<AppState>(
0228ae8 457
        initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
0228ae8 458
      );
8b87a9b 459
      final router = await pumpRealApp(tester, store);
0228ae8 460
      await store.dispatchAndWait(NextChapterAction(router, bible, 0, 0));
8b87a9b 461
      await tester.pumpAndSettle();
0228ae8 462
      expect(store.state.savedBook, 0);
0228ae8 463
      expect(store.state.savedChapter, 1);
0228ae8 464
    });
0228ae8 465
8b87a9b 466
    testWidgets("is a no-op after the very last chapter of the last book", (
8b87a9b 467
      tester,
8b87a9b 468
    ) async {
0228ae8 469
      final bible = buildTestBible();
0228ae8 470
      final store = Store<AppState>(
0228ae8 471
        initialState: AppState(bible: bible, savedBook: 2, savedChapter: 1),
0228ae8 472
      );
8b87a9b 473
      final router = await pumpRealApp(tester, store);
0228ae8 474
      await store.dispatchAndWait(NextChapterAction(router, bible, 2, 1));
8b87a9b 475
      await tester.pumpAndSettle();
0228ae8 476
      expect(store.state.savedBook, 2);
0228ae8 477
      expect(store.state.savedChapter, 1);
0228ae8 478
    });
8b87a9b 479
8b87a9b 480
    testWidgets(
8b87a9b 481
      "still lands correctly when swiping right after a prior book/chapter-picker "
8b87a9b 482
      "push (pushReplacement on a multi-entry stack also gets wrapped in an "
8b87a9b 483
      "ImperativeRouteMatch, hitting the same drilling logic as push())",
8b87a9b 484
      (tester) async {
8b87a9b 485
        final bible = buildTestBible();
8b87a9b 486
        final store = Store<AppState>(
8b87a9b 487
          initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
8b87a9b 488
        );
8b87a9b 489
        final router = await pumpRealApp(tester, store);
8b87a9b 490
8b87a9b 491
        // Push to Revelation 1 the way the book/chapter picker does.
8b87a9b 492
        await store.dispatchAndWait(GoToChapterAction(router, 2, 0));
8b87a9b 493
        await tester.pumpAndSettle();
8b87a9b 494
        expect(store.state.savedBook, 2);
8b87a9b 495
        expect(store.state.savedChapter, 0);
8b87a9b 496
8b87a9b 497
        // Swipe to Revelation 2 - a pushReplacement on top of the still
8b87a9b 498
        // 2-entry stack left by the push above.
8b87a9b 499
        await store.dispatchAndWait(NextChapterAction(router, bible, 2, 0));
8b87a9b 500
        await tester.pumpAndSettle();
8b87a9b 501
        expect(store.state.savedBook, 2);
8b87a9b 502
        expect(store.state.savedChapter, 1);
8b87a9b 503
      },
8b87a9b 504
    );
0228ae8 505
  });
0228ae8 506
0228ae8 507
  group("PreviousChapterAction", () {
8b87a9b 508
    testWidgets("moves to the previous chapter and updates saved state", (
8b87a9b 509
      tester,
8b87a9b 510
    ) async {
0228ae8 511
      final bible = buildTestBible();
0228ae8 512
      final store = Store<AppState>(
0228ae8 513
        initialState: AppState(bible: bible, savedBook: 1, savedChapter: 0),
0228ae8 514
      );
8b87a9b 515
      final router = await pumpRealApp(tester, store);
0228ae8 516
      await store.dispatchAndWait(PreviousChapterAction(router, bible, 1, 0));
8b87a9b 517
      await tester.pumpAndSettle();
0228ae8 518
      expect(store.state.savedBook, 0);
0228ae8 519
      expect(store.state.savedChapter, 1);
fe24dd6 520
    });
fe24dd6 521
  });
fe24dd6 522
0228ae8 523
  group("PopChapterAction", () {
0228ae8 524
    testWidgets("restores the pre-push savedBook/savedChapter synchronously, "
0228ae8 525
        "without waiting on the router's pop-transition listener", (
0228ae8 526
      tester,
0228ae8 527
    ) async {
0228ae8 528
      final bible = buildTestBible();
0228ae8 529
      final store = Store<AppState>(
0228ae8 530
        initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
0228ae8 531
      );
8b87a9b 532
      final router = await pumpRealApp(tester, store);
0228ae8 533
0228ae8 534
      await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
0228ae8 535
      await tester.pumpAndSettle();
0228ae8 536
      expect(store.state.savedBook, 2);
0228ae8 537
      expect(store.state.savedChapter, 1);
0228ae8 538
0228ae8 539
      await store.dispatchAndWait(PopChapterAction(router));
0228ae8 540
      expect(store.state.savedBook, 0);
0228ae8 541
      expect(store.state.savedChapter, 0);
0228ae8 542
    });
0228ae8 543
  });
0228ae8 544
1b816be 545
  group("UpdateCurrentBibleAction", () {
1b816be 546
    testWidgets(
1b816be 547
      "switching translation after navigating to another chapter doesn't leave a stale "
1b816be 548
      "chapter poppable underneath (regression: switching Bible used to revert to it)",
1b816be 549
      (tester) async {
1b816be 550
        final bible = buildTestBible();
1b816be 551
        final store = Store<AppState>(
1b816be 552
          initialState: AppState(bible: bible, savedBook: 0, savedChapter: 0),
1b816be 553
        );
8b87a9b 554
        final router = await pumpRealApp(tester, store);
1b816be 555
1b816be 556
        // Navigate to another chapter the way the book/chapter picker does
1b816be 557
        // (push, not pushReplacement) - this is what used to leave a stale
1b816be 558
        // entry behind after a bible switch.
1b816be 559
        await store.dispatchAndWait(GoToChapterAction(router, 2, 1));
1b816be 560
        await tester.pumpAndSettle();
1b816be 561
        expect(store.state.savedBook, 2);
1b816be 562
        expect(store.state.savedChapter, 1);
1b816be 563
1b816be 564
        await store.dispatchAndWait(
1b816be 565
          UpdateCurrentBibleAction(
1b816be 566
            router,
1b816be 567
            "other_bible",
1b816be 568
            store.state.savedBook,
1b816be 569
            store.state.savedChapter,
1b816be 570
            loadBibleFn: (_) async => buildTestBible(),
1b816be 571
          ),
1b816be 572
        );
1b816be 573
        await tester.pumpAndSettle();
1b816be 574
        expect(store.state.savedBook, 2);
1b816be 575
        expect(store.state.savedChapter, 1);
1b816be 576
1b816be 577
        // The pre-switch chapter must not still be reachable via "back" -
1b816be 578
        // otherwise the next edge-swipe-right treats it as pop-to-previous
1b816be 579
        // instead of previous-chapter, landing back on the stale position.
1b816be 580
        expect(router.canPop(), isFalse);
1b816be 581
        expect(router.routerDelegate.currentConfiguration.matches.length, 1);
1b816be 582
      },
1b816be 583
    );
1b816be 584
  });
1b816be 585
1b816be 586
  group("buildShareContent", () {
1b816be 587
    test(
1b816be 588
      "sorts verses by index for both the title and the text, regardless of selection order",
1b816be 589
      () {
1b816be 590
        final bible = buildTestBible();
1b816be 591
        final chapter = bible.bookAt(0)!.chapterAt(0)!;
1b816be 592
        final verse0 = chapter.verseAt(0)!; // "In the beginning..."
1b816be 593
        final verse1 = chapter.verseAt(1)!; // "And the earth..."
1b816be 594
1b816be 595
        // Selected out of order: verse 2 (index 1) tapped before verse 1
1b816be 596
        // (index 0) - the exact repro for the reported bug.
1b816be 597
        final (title, text) = buildShareContent(
1b816be 598
          [verse1, verse0],
1b816be 599
          "Genesis",
1b816be 600
          bible,
1b816be 601
        );
1b816be 602
1b816be 603
        expect(title, "Genesis 1:1,2 English");
1b816be 604
        expect(text, "In the beginning...\nAnd the earth...");
1b816be 605
      },
1b816be 606
    );
1b816be 607
  });
1b816be 608
0228ae8 609
  group("theme", () {
0228ae8 610
    test(
0228ae8 611
      "dark theme labelMedium (verse numbers) uses the dark error accent, matching the light theme's dedicated red",
0228ae8 612
      () {
0228ae8 613
        expect(
0228ae8 614
          AppTheme.dark.textTheme.labelMedium!.color,
0228ae8 615
          darkColorScheme.error,
0228ae8 616
        );
0228ae8 617
      },
0228ae8 618
    );
0228ae8 619
  });
0228ae8 620
fe24dd6 621
  group("Env", () {
fe24dd6 622
    test("obfuscated secrets decode back to non-empty strings", () {
225ad3e 623
      expect(Env.errorReportSecret, isNotEmpty);
fe24dd6 624
      expect(Env.ttsSubscriptionKey, isNotEmpty);
fe24dd6 625
    });
fe24dd6 626
  });
fe24dd6 627
fe24dd6 628
  group("AppPersistor", () {
fe24dd6 629
    late Directory tempDir;
fe24dd6 630
fe24dd6 631
    setUp(() async {
fe24dd6 632
      tempDir = await Directory.systemTemp.createTemp("app_persistor_test");
fe24dd6 633
      PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
fe24dd6 634
    });
fe24dd6 635
fe24dd6 636
    tearDown(() async {
fe24dd6 637
      await tempDir.delete(recursive: true);
fe24dd6 638
    });
fe24dd6 639
0228ae8 640
    test(
0228ae8 641
      "persistDifference writes atomically, leaving no leftover temp file",
0228ae8 642
      () async {
0228ae8 643
        final bible = buildTestBible();
0228ae8 644
        final persistor = AppPersistor();
0228ae8 645
        final state = AppState(
0228ae8 646
          bible: bible,
0228ae8 647
          savedBook: 1,
0228ae8 648
          savedChapter: 0,
0228ae8 649
          themeMode: ThemeMode.dark,
0228ae8 650
        );
0228ae8 651
0228ae8 652
        await persistor.persistDifference(
0228ae8 653
          lastPersistedState: null,
0228ae8 654
          newState: state,
0228ae8 655
        );
0228ae8 656
0228ae8 657
        final json = await persistor.readJson();
0228ae8 658
        expect(json, isNotNull);
0228ae8 659
        expect(json!["savedBook"], 1);
0228ae8 660
        expect(json["themeMode"], "dark");
0228ae8 661
        expect(
0228ae8 662
          await File("${tempDir.path}/app_state.json.tmp").exists(),
0228ae8 663
          isFalse,
0228ae8 664
        );
0228ae8 665
      },
0228ae8 666
    );
0228ae8 667
0228ae8 668
    test(
0228ae8 669
      "readJson returns null for corrupt JSON instead of throwing",
0228ae8 670
      () async {
0228ae8 671
        final persistor = AppPersistor();
0228ae8 672
        await File(
0228ae8 673
          "${tempDir.path}/app_state.json",
0228ae8 674
        ).writeAsString("{not valid json");
0228ae8 675
        expect(await persistor.readJson(), isNull);
0228ae8 676
      },
0228ae8 677
    );
fe24dd6 678
  });
fe24dd6 679
}