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
| 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 | } |