diff --git a/integration_test/background_interaction_test.dart b/integration_test/background_interaction_test.dart index 4ba061c..be34779 100644 --- a/integration_test/background_interaction_test.dart +++ b/integration_test/background_interaction_test.dart @@ -9,6 +9,7 @@ import 'package:app4training/data/languages.dart'; import 'package:app4training/l10n/generated/app_localizations_de.dart'; import 'package:app4training/main.dart'; import 'package:app4training/routes/view_page.dart'; +import 'package:file/memory.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -32,21 +33,17 @@ void main() async { completer.complete(data); }); await Workmanager().initialize(backgroundTask); - await Workmanager().registerOneOffTask( - "task-identifier", - "testTask", - initialDelay: const Duration(seconds: 2), - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - packageInfoProvider.overrideWithValue(packageInfo), - ], - child: const App4Training(), - ), - ); + await Workmanager().registerOneOffTask("task-identifier", "testTask", + initialDelay: const Duration(seconds: 2)); + + final fileSystem = MemoryFileSystem(); + await tester.pumpWidget(ProviderScope(overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + packageInfoProvider.overrideWithValue(packageInfo), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider + .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), + ], child: const App4Training())); expect(find.text('Loading'), findsOneWidget); // Wait for the background isolate to finish @@ -70,24 +67,13 @@ void main() async { }); var fileSystem = await createTestFileSystem(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - packageInfoProvider.overrideWithValue(packageInfo), - fileSystemProvider.overrideWith((ref) => fileSystem), - // We need to mock DownloadAssetsController because we can't use a memory - // file system to test it (it uses dart:io, not the file package) - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: createMockDownloadAssetsController(), - ), - ), - ], - child: const App4Training(), - ), - ); + await tester.pumpWidget(ProviderScope(overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + packageInfoProvider.overrideWithValue(packageInfo), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider + .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), + ], child: const App4Training())); expect(find.text('Loading'), findsOneWidget); await tester.pumpAndSettle(); @@ -101,11 +87,8 @@ void main() async { await tester.pumpAndSettle(); await Workmanager().initialize(backgroundTask); - await Workmanager().registerOneOffTask( - "task-identifier", - "testTask", - initialDelay: const Duration(seconds: 2), - ); + await Workmanager().registerOneOffTask("task-identifier", "testTask", + initialDelay: const Duration(seconds: 2)); // Wait for the background isolate to finish final msg = await completer.future.timeout(const Duration(seconds: 10)); diff --git a/lib/background/background_task.dart b/lib/background/background_task.dart index 187b504..d9bed1c 100644 --- a/lib/background/background_task.dart +++ b/lib/background/background_task.dart @@ -3,8 +3,11 @@ import 'dart:ui'; import 'package:app4training/background/background_test.dart'; import 'package:app4training/data/globals.dart'; +import 'package:app4training/data/language_downloader.dart'; import 'package:app4training/data/languages.dart'; import 'package:app4training/data/updates.dart'; +import 'package:dio/dio.dart'; +import 'package:file/local.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; @@ -52,9 +55,18 @@ void backgroundTask() { Future backgroundMain() async { await writeLog("Background task is starting..."); final prefs = await SharedPreferences.getInstance(); + final appDocsDir = await getApplicationDocumentsDirectory(); + final languageDownloader = LanguageDownloaderImpl( + root: appDocsDir.path, + dio: Dio(), + fileSystem: const LocalFileSystem(), + ); final ref = ProviderContainer( - overrides: [sharedPrefsProvider.overrideWithValue(prefs)], + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + languageDownloaderProvider.overrideWithValue(languageDownloader), + ], ); await backgroundCheck(ref); @@ -73,11 +85,8 @@ Future backgroundTestMain() async { overrides: [ sharedPrefsProvider.overrideWithValue(prefs), fileSystemProvider.overrideWith((ref) => fileSystem), - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: createMockDownloadAssetsController(), - ), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), ), ], ); diff --git a/lib/background/background_test.dart b/lib/background/background_test.dart index 069424a..6276b6d 100644 --- a/lib/background/background_test.dart +++ b/lib/background/background_test.dart @@ -1,24 +1,54 @@ -import 'package:download_assets/download_assets.dart'; +import 'package:app4training/data/globals.dart'; +import 'package:app4training/data/language_downloader.dart'; +import 'package:file/file.dart'; import 'package:file/memory.dart'; -import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; -/* This are utility functions for the integration test. +/* These are utility functions for the integration test. Unfortunately it seems not be easily possible to move this into the test/ folder and include it from there in our background_task.dart, so that's why it is in our "normal" code directory */ -class MockDownloadAssetsController extends Mock - implements DownloadAssetsController {} - -// Simulate that German files are already downloaded -MockDownloadAssetsController createMockDownloadAssetsController() { - final mock = MockDownloadAssetsController(); - when(() => mock.init(assetDir: any(named: 'assetDir'))) - .thenAnswer((_) async {}); - when(mock.clearAssets).thenAnswer((_) async {}); - when(() => mock.assetsDir).thenReturn('assets-de'); - when(() => mock.assetsDirAlreadyExists()).thenAnswer((_) async => true); - return mock; +/// A test double for [LanguageDownloader] backed by a [FileSystem]. +/// [download] records calls in [downloadCalls] but does not write files — +/// pre-seed the file system if a test needs a language to appear downloaded. +class FakeLanguageDownloader implements LanguageDownloader { + final FileSystem fileSystem; + final String root; + final bool throwOnDownload; + int downloadCalls = 0; + int deleteCalls = 0; + + FakeLanguageDownloader({ + required this.fileSystem, + this.root = '', + this.throwOnDownload = false, + }); + + @override + String pathFor(String langCode) => + p.join(root, Globals.getAssetsDir(langCode)); + + @override + Future isDownloaded(String langCode) => + fileSystem.directory(pathFor(langCode)).exists(); + + @override + Future download(String langCode) async { + downloadCalls += 1; + if (throwOnDownload) { + throw Exception('Simulated download failure'); + } + } + + @override + Future delete(String langCode) async { + deleteCalls += 1; + final dir = fileSystem.directory(pathFor(langCode)); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } } // Simulate a file system where German is downloaded with one worksheet diff --git a/lib/data/globals.dart b/lib/data/globals.dart index c03d966..519865b 100644 --- a/lib/data/globals.dart +++ b/lib/data/globals.dart @@ -1,3 +1,4 @@ +import 'package:app4training/data/language_downloader.dart'; import 'package:app4training/l10n/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; final sharedPrefsProvider = MustOverrideProvider(); final packageInfoProvider = MustOverrideProvider(); +final languageDownloaderProvider = MustOverrideProvider(); /// ignore: non_constant_identifier_names Provider MustOverrideProvider() { diff --git a/lib/data/language_downloader.dart b/lib/data/language_downloader.dart new file mode 100644 index 0000000..fd66ec6 --- /dev/null +++ b/lib/data/language_downloader.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:app4training/data/globals.dart'; +import 'package:dio/dio.dart'; +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +abstract interface class LanguageDownloader { + String pathFor(String langCode); + Future isDownloaded(String langCode); + Future download(String langCode); + Future delete(String langCode); +} + +class LanguageDownloaderImpl implements LanguageDownloader { + final String _root; + final Dio _dio; + final FileSystem _fileSystem; + Completer? _inFlight; + + LanguageDownloaderImpl({ + required String root, + required Dio dio, + required FileSystem fileSystem, + }) : _root = root, + _dio = dio, + _fileSystem = fileSystem; + + @override + String pathFor(String langCode) => + p.join(_root, Globals.getAssetsDir(langCode)); + + @override + Future isDownloaded(String langCode) => + _fileSystem.directory(pathFor(langCode)).exists(); + + @override + Future download(String langCode) async { + // Serialize: wait for any in-flight download to finish + while (_inFlight != null) { + await _inFlight!.future; + } + final completer = Completer(); + _inFlight = completer; + + final dest = pathFor(langCode); + final staging = '$dest.staging'; + final old = '$dest.old'; + + try { + // Crash recovery: remove leftover staging dir + final stagingDir = _fileSystem.directory(staging); + if (await stagingDir.exists()) { + await stagingDir.delete(recursive: true); + } + + // Download both zips concurrently + final results = await Future.wait([ + _dio.get>( + Globals.getRemoteUrlHtml(langCode), + options: Options(responseType: ResponseType.bytes), + ), + _dio.get>( + Globals.getRemoteUrlPdf(langCode), + options: Options(responseType: ResponseType.bytes), + ), + ]); + + // Extract both zips into staging + for (final response in results) { + final bytes = Uint8List.fromList(response.data!); + final archive = ZipDecoder().decodeBytes(bytes); + for (final file in archive) { + final filePath = p.join(staging, file.name); + if (file.isFile) { + final outFile = _fileSystem.file(filePath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(file.content as List); + } else { + await _fileSystem.directory(filePath).create(recursive: true); + } + } + } + + // Atomic swap + final destDir = _fileSystem.directory(dest); + final oldDir = _fileSystem.directory(old); + + if (await destDir.exists()) { + await destDir.rename(old); + } + await _fileSystem.directory(staging).rename(dest); + + // Best-effort cleanup of old + if (await oldDir.exists()) { + try { + await oldDir.delete(recursive: true); + } catch (_) {} + } + } catch (e) { + // Clean up staging on failure + final stagingDir = _fileSystem.directory(staging); + if (await stagingDir.exists()) { + await stagingDir.delete(recursive: true); + } + rethrow; + } finally { + _inFlight = null; + completer.complete(); + } + } + + @override + Future delete(String langCode) async { + final dir = _fileSystem.directory(pathFor(langCode)); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } +} diff --git a/lib/data/languages.dart b/lib/data/languages.dart index 2946718..c076e51 100644 --- a/lib/data/languages.dart +++ b/lib/data/languages.dart @@ -1,7 +1,6 @@ import 'dart:collection'; import 'dart:convert'; import 'package:app4training/data/exceptions.dart'; -import 'package:download_assets/download_assets.dart'; import 'package:file/local.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -25,8 +24,7 @@ final imageContentProvider = Provider.family((ref, res) { final String path = ref.watch(languageProvider(res.langCode)).path; if (path == '') { debugPrint( - "Error: Can't load image ${res.name} in language ${res.langCode}", - ); + "Error: Can't load image ${res.name} in language ${res.langCode}"); return ''; } final fileSystem = ref.watch(fileSystemProvider); @@ -45,10 +43,8 @@ final imageContentProvider = Provider.family((ref, res) { /// throws [PageNotFoundException]: Hm, errorneous link? /// throws [LanguageCorruptedException]: oops, /// hope this goes away by deleting + re-downloading the language -final pageContentProvider = FutureProvider.family(( - ref, - page, -) async { +final pageContentProvider = + FutureProvider.family((ref, page) async { final fileSystem = ref.watch(fileSystemProvider); final lang = ref.watch(languageProvider(page.langCode)); if (!lang.downloaded) { @@ -64,32 +60,25 @@ final pageContentProvider = FutureProvider.family(( debugPrint("Fetching content of '${page.name}/${page.langCode}'..."); try { - String content = - await fileSystem - .file(join(lang.path, pageDetails.fileName)) - .readAsString(); + String content = await fileSystem + .file(join(lang.path, pageDetails.fileName)) + .readAsString(); // Load images directly into the HTML: // Replace with - return content.replaceAllMapped(RegExp(r'src="files/([^.]+.png)"'), ( - match, - ) { + return content.replaceAllMapped(RegExp(r'src="files/([^.]+.png)"'), + (match) { if (!lang.images.containsKey(match.group(1))) { debugPrint( - 'Warning: image ${match.group(1)} missing (in ${pageDetails.fileName})', - ); + 'Warning: image ${match.group(1)} missing (in ${pageDetails.fileName})'); return match.group(0)!; } - String imageData = ref.watch( - imageContentProvider((name: match.group(1)!, langCode: page.langCode)), - ); + String imageData = ref.watch(imageContentProvider( + (name: match.group(1)!, langCode: page.langCode))); return 'src="data:image/png;base64,$imageData"'; }); } on FileSystemException catch (e) { throw LanguageCorruptedException( - page.langCode, - 'Error while reading from local storage.', - e, - ); + page.langCode, 'Error while reading from local storage.', e); } }, retry: null); @@ -98,8 +87,8 @@ final pageContentProvider = FutureProvider.family(( /// ref.watch(languageProvider('en').notifier) -> get English LanguageController final languageProvider = NotifierProvider.family((arg) { - return LanguageController(languageCode: arg); - }); + return LanguageController(); +}); /// How many languages do we have available offline? final countDownloadedLanguagesProvider = Provider((ref) { @@ -114,27 +103,9 @@ final countDownloadedLanguagesProvider = Provider((ref) { class LanguageController extends Notifier { @protected - String languageCode; - - /// You must call [await _initController()] before accessing [_controller] - final DownloadAssetsController _controller; - bool _isInitialized = false; - - /// We use dependency injection (optional parameters [assetsController]) - /// so that we can test the class well - LanguageController({ - this.languageCode = '', - DownloadAssetsController? assetsController, - }) : _controller = assetsController ?? DownloadAssetsController(); - - /// Make sure that our [_controller] is initialized - Future _initController() async { - if (!_isInitialized) { - assert(languageCode != ''); - await _controller.init(assetDir: Globals.getAssetsDir(languageCode)); - _isInitialized = true; - } - } + String languageCode = ''; + + LanguageController(); @override Language build() { @@ -143,25 +114,12 @@ class LanguageController extends Notifier { // through the constructor. languageCode = ref.$arg as String; return Language( - '', - const {}, - const [], - const {}, - '', - 0, - DateTime.utc(2023), - ); + '', const {}, const [], const {}, '', 0, DateTime.utc(2023)); } /// Download this language and make it available. - /// If [force] is true, delete the existing structure and reload everything. /// Returns whether everything went well - Future download({bool force = false}) async { - assert(languageCode != ''); - if (force) { - await deleteResources(); - } - assert(!state.downloaded); + Future download() async { if (!await _download()) return false; return await _load(); } @@ -169,7 +127,6 @@ class LanguageController extends Notifier { /// Is this language downloaded to the device? If yes, load it into memory. /// Returns true when the language is now available, false if not Future init() async { - assert(languageCode != ''); return await _load(); } @@ -177,30 +134,20 @@ class LanguageController extends Notifier { /// load any details into memory. /// Returns true when the language is now available, false if not Future lazyInit() async { - await _initController(); - String path = join( - _controller.assetsDir!, - Globals.getResourcesDir(languageCode), - ); + final downloader = ref.read(languageDownloaderProvider); + String path = + join(downloader.pathFor(languageCode), Globals.getResourcesDir(languageCode)); final stat = await ref .watch(fileSystemProvider) .stat(join(path, 'structure', 'contents.json')); bool downloaded = (stat.type != FileSystemEntityType.notFound); debugPrint( - "QuickInit trying to load '$languageCode', downloaded: $downloaded", - ); + "QuickInit trying to load '$languageCode', downloaded: $downloaded"); if (downloaded) { DateTime timestamp = stat.modified.toUtc(); // Always store UTC internally state = Language( - languageCode, - const {}, - const [], - const {}, - path, - 0, - timestamp, - ); + languageCode, const {}, const [], const {}, path, 0, timestamp); return true; } return false; @@ -210,39 +157,33 @@ class LanguageController extends Notifier { /// Returns whether everything went well and the language is available now. /// This method shouldn't throw Future _load() async { - await _initController(); + final downloader = ref.read(languageDownloaderProvider); final fileSystem = ref.watch(fileSystemProvider); try { // Now we store the full path to the language String path = join( - _controller.assetsDir!, - Globals.getResourcesDir(languageCode), - ); + downloader.pathFor(languageCode), Globals.getResourcesDir(languageCode)); debugPrint("Path: $path"); - bool downloaded = await _controller.assetsDirAlreadyExists(); + bool downloaded = await downloader.isDownloaded(languageCode); debugPrint("Trying to load '$languageCode', downloaded: $downloaded"); if (!downloaded) return false; // Store the size of the downloaded files (HTML + PDF) int sizeInKB = await _calculateMemoryUsage( - fileSystem.directory(_controller.assetsDir!), - ); + fileSystem.directory(downloader.pathFor(languageCode))); // Get the timestamp: When were our contents stored on the device? - FileStat stat = await fileSystem.stat( - join(path, 'structure', 'contents.json'), - ); + FileStat stat = + await fileSystem.stat(join(path, 'structure', 'contents.json')); DateTime timestamp = stat.modified.toUtc(); // Always store UTC internally // Read structure/contents.json as our source of truth: // Which pages are available, what is the order in the menu - var structure = jsonDecode( - fileSystem - .file(join(path, 'structure', 'contents.json')) - .readAsStringSync(), - ); + var structure = jsonDecode(fileSystem + .file(join(path, 'structure', 'contents.json')) + .readAsStringSync()); final Map pages = {}; final List pageIndex = []; @@ -251,15 +192,11 @@ class LanguageController extends Notifier { // Go through existing PDF files var pdfPath = join( - _controller.assetsDir!, - Globals.getPdfDir(languageCode), - ); + downloader.pathFor(languageCode), Globals.getPdfDir(languageCode)); var pdfDir = fileSystem.directory(pdfPath); if (await pdfDir.exists()) { - await for (var file in pdfDir.list( - recursive: false, - followLinks: false, - )) { + await for (var file + in pdfDir.list(recursive: false, followLinks: false)) { if (file is File) { pdfFiles.add(file.basename); } else { @@ -277,13 +214,8 @@ class LanguageController extends Notifier { pdfName = join(pdfPath, element['pdf']); pdfFiles.remove(element['pdf']); } - pages[element['page']] = Page( - element['page'], - element['title'], - element['filename'], - element['version'], - pdfName, - ); + pages[element['page']] = Page(element['page'], element['title'], + element['filename'], element['version'], pdfName); } // Consistency checking... @@ -295,10 +227,8 @@ class LanguageController extends Notifier { // Register available images var filesDir = fileSystem.directory(join(path, 'files')); if (await filesDir.exists()) { - await for (var file in filesDir.list( - recursive: false, - followLinks: false, - )) { + await for (var file + in filesDir.list(recursive: false, followLinks: false)) { if (file is File) { images[file.basename] = Image(file.basename); } else { @@ -307,67 +237,34 @@ class LanguageController extends Notifier { } } state = Language( - languageCode, - pages, - pageIndex, - images, - path, - sizeInKB, - timestamp, - ); + languageCode, pages, pageIndex, images, path, sizeInKB, timestamp); return true; } catch (e) { String msg = 'Error initializing data structure: $e'; debugPrint(msg); // Delete the whole folder - await _controller.clearAssets(); - state = Language( - '', - const {}, - const [], - const {}, - '', - 0, - DateTime.utc(2023), - ); + await downloader.delete(languageCode); + state = + Language('', const {}, const [], const {}, '', 0, DateTime.utc(2023)); return false; } } /// Delete this language from the device. Future deleteResources() async { - await _initController(); - await _controller.clearAssets(); - state = Language( - '', - const {}, - const [], - const {}, - '', - 0, - DateTime.utc(2023), - ); + await ref.read(languageDownloaderProvider).delete(languageCode); + state = + Language('', const {}, const [], const {}, '', 0, DateTime.utc(2023)); } - /// Download all files for one language via DownloadAssetsController + /// Download all files for one language via [LanguageDownloader] /// Returns whether we were successful. Shouldn't throw Future _download() async { - await _initController(); debugPrint("Starting to download language '$languageCode' ..."); - try { - // assetUrls takes an array, but we can't specify both URLs in one call: - // DownloadAssets throws when both files have the same name (main.zip) :-/ - await _controller.startDownload( - assetsUrls: [AssetUrl(url: Globals.getRemoteUrlHtml(languageCode))], - ); - await _controller.startDownload( - assetsUrls: [AssetUrl(url: Globals.getRemoteUrlPdf(languageCode))], - ); + await ref.read(languageDownloaderProvider).download(languageCode); } catch (e) { debugPrint("Error while downloading language '$languageCode': $e"); - // delete the empty folder left behind by startDownload() - await _controller.clearAssets(); return false; } debugPrint("Downloading language '$languageCode' finished."); @@ -391,9 +288,7 @@ class LanguageController extends Notifier { /// error handling if a page we expect to be there can't be loaded because /// a HTML file is missing... Future _checkConsistency( - Directory dir, - final Map pages, - ) async { + Directory dir, final Map pages) async { Set files = {}; await for (var file in dir.list(recursive: false, followLinks: false)) { if (file is File) { @@ -403,8 +298,7 @@ class LanguageController extends Notifier { pages.forEach((key, page) { if (!files.remove(page.fileName)) { debugPrint( - "Warning: Structure mentions ${page.fileName} but the file is missing", - ); + "Warning: Structure mentions ${page.fileName} but the file is missing"); } }); if (files.isNotEmpty) debugPrint("Warning: Found orphaned files $files"); @@ -467,15 +361,8 @@ class Language { /// When were the files downloaded on our device? file system attribute, UTC final DateTime downloadTimestamp; - const Language( - this.languageCode, - this.pages, - this.pageIndex, - this.images, - this.path, - this.sizeInKB, - this.downloadTimestamp, - ); + const Language(this.languageCode, this.pages, this.pageIndex, this.images, + this.path, this.sizeInKB, this.downloadTimestamp); /// Returns an list with all the worksheet titles in the menu. /// The list is ordered as identifier -> translated title diff --git a/lib/main.dart b/lib/main.dart index 32e7b23..18f2c23 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,13 @@ +import 'package:app4training/data/language_downloader.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; +import 'package:dio/dio.dart'; +import 'package:file/local.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:app4training/routes/routes.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'data/app_language.dart'; import 'data/globals.dart'; @@ -127,13 +131,20 @@ void main() async { _installHtmlTableSemanticsFilter(); final prefs = await SharedPreferences.getInstance(); final packageInfo = await PackageInfo.fromPlatform(); + final appDocsDir = await getApplicationDocumentsDirectory(); + final languageDownloader = LanguageDownloaderImpl( + root: appDocsDir.path, + dio: Dio(), + fileSystem: const LocalFileSystem(), + ); // Run initialization for our background task TODO enable in version 0.9 // await Workmanager().initialize(backgroundTask, isInDebugMode: false); runApp(ProviderScope(overrides: [ sharedPrefsProvider.overrideWithValue(prefs), - packageInfoProvider.overrideWithValue(packageInfo) + packageInfoProvider.overrideWithValue(packageInfo), + languageDownloaderProvider.overrideWithValue(languageDownloader), ], child: const App4Training())); } diff --git a/lib/widgets/update_language_button.dart b/lib/widgets/update_language_button.dart index 5b33176..bc4a3fa 100644 --- a/lib/widgets/update_language_button.dart +++ b/lib/widgets/update_language_button.dart @@ -36,7 +36,7 @@ class _UpdateLanguageButtonState extends ConsumerState { // Get l10n now as we can't access context after async gap later AppLocalizations l10n = context.l10n; - bool success = await lang.download(force: true); + bool success = await lang.download(); ref.watch(scaffoldMessengerProvider).showSnackBar(SnackBar( content: Text(success @@ -92,7 +92,7 @@ class _UpdateAllLanguagesButtonState ref.read(languageProvider(languageCode)).downloaded) { if (await ref .read(languageProvider(languageCode).notifier) - .download(force: true)) { + .download()) { countUpdates++; lastLanguage = languageCode; } else { diff --git a/pubspec.lock b/pubspec.lock index c7f8131..a795c2e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "0.13.11" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff @@ -185,14 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" - download_assets: - dependency: "direct main" - description: - name: download_assets - sha256: ecc0e9f644f1c57e5ba8c723b05e69ace7fd6177f34eebcadc496b7d410e6c4d - url: "https://pub.dev" - source: hosted - version: "4.0.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a6991fe..50dc978 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,10 +34,10 @@ dependencies: flutter_html: ^3.0.0 flutter_html_table: ^3.0.0 path_provider: ^2.0.11 - download_assets: 4.0.0 # the Content-Length check breaks GitHub archive URLs in 4.1.0 path: ^1.8.2 http: ^1.0.0 dio: ^5.0.0 + archive: ^4.0.0 flutter_localizations: sdk: flutter intl: any diff --git a/test/background_task_test.dart b/test/background_task_test.dart index 31bac12..06b40d0 100644 --- a/test/background_task_test.dart +++ b/test/background_task_test.dart @@ -1,7 +1,9 @@ import 'package:app4training/background/background_task.dart'; +import 'package:app4training/background/background_test.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; import 'package:app4training/data/updates.dart'; +import 'package:file/memory.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -15,15 +17,14 @@ void main() { test('Test background check: no updates available', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - var fakeController = FakeDownloadAssetsController(); + final fileSystem = MemoryFileSystem(); final ref = ProviderContainer(overrides: [ sharedPrefsProvider.overrideWithValue(prefs), - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: fakeController,),), - languageStatusProvider.overrideWith2((languageCode) => TestLanguageStatus()) + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider + .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), + languageStatusProvider.overrideWith2((langCode) => TestLanguageStatus()) ]); await backgroundCheck(ref); expect(ref.read(updatesAvailableProvider), false); @@ -33,15 +34,13 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); var fileSystem = await createBasicFileSystem(['de', 'en']); - var fakeController = FakeDownloadAssetsController(); final ref = ProviderContainer(overrides: [ sharedPrefsProvider.overrideWithValue(prefs), httpClientProvider.overrideWith((ref) => mockCheckResponse({'de': 2})), fileSystemProvider.overrideWith((ref) => fileSystem), - languageProvider.overrideWith2( - (languageCode) => LanguageController(assetsController: fakeController), - ), + languageDownloaderProvider + .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), ]); expect(ref.read(sharedPrefsProvider).getBool('updatesAvailable-de'), null); diff --git a/test/download_language_button_test.dart b/test/download_language_button_test.dart index 4f60b3d..d6c357d 100644 --- a/test/download_language_button_test.dart +++ b/test/download_language_button_test.dart @@ -1,8 +1,10 @@ +import 'package:app4training/background/background_test.dart'; import 'package:app4training/data/app_language.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; import 'package:app4training/widgets/download_language_button.dart'; +import 'package:file/memory.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,44 +15,31 @@ import 'languages_test.dart'; class TestDownloadLanguageButton extends ConsumerWidget { final String languageCode; final bool highlight; - - const TestDownloadLanguageButton( - this.languageCode, { - this.highlight = false, - super.key, - }); + const TestDownloadLanguageButton(this.languageCode, + {this.highlight = false, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: Scaffold( - body: DownloadLanguageButton(languageCode, highlight: highlight), - ), - ); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: Scaffold( + body: DownloadLanguageButton(languageCode, highlight: highlight))); } } void main() { testWidgets('Test DownloadLanguageButton', (WidgetTester tester) async { - final ref = ProviderContainer( - overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(downloadedLanguages: []), - ), - ], - ); - - await tester.pumpWidget( - UncontrolledProviderScope( - container: ref, - child: const TestDownloadLanguageButton('en'), - ), - ); + final ref = ProviderContainer(overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider + .overrideWith2((langCode) => TestLanguageController(downloadedLanguages: [])), + ]); + + await tester.pumpWidget(UncontrolledProviderScope( + container: ref, child: const TestDownloadLanguageButton('en'))); expect(find.byIcon(Icons.download), findsOneWidget); expect(find.byType(Container), findsNothing); // should not be highlighted @@ -64,27 +53,20 @@ void main() { }); testWidgets('Test DownloadAllLanguagesButton', (WidgetTester tester) async { - final ref = ProviderContainer( - overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2( - (languageCodeL) => TestLanguageController(downloadedLanguages: []), - ), - ], - ); - - await tester.pumpWidget( - UncontrolledProviderScope( + final ref = ProviderContainer(overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider + .overrideWith2((langCode) => TestLanguageController(downloadedLanguages: [])) + ]); + + await tester.pumpWidget(UncontrolledProviderScope( container: ref, child: MaterialApp( - locale: ref.read(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: const Scaffold(body: DownloadAllLanguagesButton()), - ), - ), - ); + locale: ref.read(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: const Scaffold(body: DownloadAllLanguagesButton())))); expect(ref.read(languageProvider('ar')).downloaded, false); @@ -99,24 +81,17 @@ void main() { expect(find.text('34 Sprachen heruntergeladen'), findsOneWidget); }); - testWidgets('Test highlighted DownloadLanguageButton', ( - WidgetTester tester, - ) async { - final ref = ProviderContainer( - overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(downloadedLanguages: []), - ), - ], - ); - - await tester.pumpWidget( - UncontrolledProviderScope( + testWidgets('Test highlighted DownloadLanguageButton', + (WidgetTester tester) async { + final ref = ProviderContainer(overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider + .overrideWith2((langCode) => TestLanguageController(downloadedLanguages: [])), + ]); + + await tester.pumpWidget(UncontrolledProviderScope( container: ref, - child: const TestDownloadLanguageButton('en', highlight: true), - ), - ); + child: const TestDownloadLanguageButton('en', highlight: true))); expect(find.byIcon(Icons.download), findsOneWidget); // Test the highlighting @@ -131,30 +106,21 @@ void main() { }); testWidgets('Test failing download', (WidgetTester tester) async { - var throwingController = ThrowingDownloadAssetsController(); - final ref = ProviderContainer( - overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: throwingController, - ), - ), - ], - ); - - await tester.pumpWidget( - UncontrolledProviderScope( - container: ref, - child: const TestDownloadLanguageButton('en'), - ), - ); + final fileSystem = MemoryFileSystem(); + final fakeDownloader = FakeLanguageDownloader( + fileSystem: fileSystem, throwOnDownload: true); + final ref = ProviderContainer(overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ]); + + await tester.pumpWidget(UncontrolledProviderScope( + container: ref, child: const TestDownloadLanguageButton('en'))); await tester.tap(find.byType(DownloadLanguageButton)); await tester.pump(); - expect(throwingController.startDownloadCalls, 1); - expect(throwingController.clearAssetsCalled, true); + expect(fakeDownloader.downloadCalls, 1); expect(ref.read(languageProvider('en')).downloaded, false); // Snackbar visible? expect(find.textContaining('Download fehlgeschlagen'), findsOneWidget); diff --git a/test/language_downloader_test.dart b/test/language_downloader_test.dart new file mode 100644 index 0000000..b2d06f0 --- /dev/null +++ b/test/language_downloader_test.dart @@ -0,0 +1,255 @@ +import 'dart:typed_data'; + +import 'package:app4training/data/globals.dart'; +import 'package:app4training/data/language_downloader.dart'; +import 'package:archive/archive.dart'; +import 'package:dio/dio.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDio extends Mock implements Dio {} + +/// Create a zip archive in memory with the given files (path -> content). +Uint8List createTestZip(Map files) { + final archive = Archive(); + for (final entry in files.entries) { + final data = Uint8List.fromList(entry.value.codeUnits); + archive.addFile(ArchiveFile(entry.key, data.length, data)); + } + return Uint8List.fromList(ZipEncoder().encode(archive)); +} + +/// Helper to set up a mock Dio response for a URL returning zip bytes. +void mockDioGet(MockDio dio, String url, Uint8List zipBytes) { + when(() => dio.get>( + url, + options: any(named: 'options'), + )).thenAnswer((_) async => Response( + data: zipBytes.toList(), + statusCode: 200, + requestOptions: RequestOptions(path: url), + )); +} + +void main() { + late MemoryFileSystem fs; + late MockDio dio; + late LanguageDownloaderImpl downloader; + const root = '/app-docs'; + + setUp(() { + fs = MemoryFileSystem(); + dio = MockDio(); + downloader = LanguageDownloaderImpl(root: root, dio: dio, fileSystem: fs); + }); + + test('pathFor returns deterministic path', () { + expect(downloader.pathFor('de'), '/app-docs/assets-de'); + expect(downloader.pathFor('en'), '/app-docs/assets-en'); + }); + + test('Happy path: both zips download and extract', () async { + final htmlZip = createTestZip({ + '${Globals.getResourcesDir('de')}/structure/contents.json': + '{"worksheets":[]}', + '${Globals.getResourcesDir('de')}/index.html': '

Hello

', + }); + final pdfZip = createTestZip({ + '${Globals.getPdfDir('de')}/test.pdf': 'pdf-content', + }); + + mockDioGet(dio, Globals.getRemoteUrlHtml('de'), htmlZip); + mockDioGet(dio, Globals.getRemoteUrlPdf('de'), pdfZip); + + await downloader.download('de'); + + expect(await downloader.isDownloaded('de'), true); + final contentsJson = fs.file( + '/app-docs/assets-de/${Globals.getResourcesDir('de')}/structure/contents.json'); + expect(await contentsJson.exists(), true); + expect(await contentsJson.readAsString(), '{"worksheets":[]}'); + + final indexHtml = fs.file( + '/app-docs/assets-de/${Globals.getResourcesDir('de')}/index.html'); + expect(await indexHtml.exists(), true); + + final pdfFile = fs.file( + '/app-docs/assets-de/${Globals.getPdfDir('de')}/test.pdf'); + expect(await pdfFile.exists(), true); + + // No staging dir left behind + expect(await fs.directory('/app-docs/assets-de.staging').exists(), false); + }); + + test('Network failure: nothing remains at pathFor and no staging leftover', + () async { + // HTML download succeeds but PDF download throws + final htmlZip = createTestZip({ + '${Globals.getResourcesDir('fr')}/index.html': '

Bonjour

', + }); + mockDioGet(dio, Globals.getRemoteUrlHtml('fr'), htmlZip); + when(() => dio.get>( + Globals.getRemoteUrlPdf('fr'), + options: any(named: 'options'), + )).thenThrow(DioException( + requestOptions: RequestOptions(path: Globals.getRemoteUrlPdf('fr')), + )); + + await expectLater(downloader.download('fr'), throwsA(isA())); + + expect(await downloader.isDownloaded('fr'), false); + expect(await fs.directory('/app-docs/assets-fr.staging').exists(), false); + }); + + test('Corrupted zip: cleanup same as network failure', () async { + // A zip header (PK\x03\x04) followed by garbage triggers a real decode error + final corruptedZip = Uint8List.fromList([ + 0x50, 0x4B, 0x03, 0x04, // local file header signature + 0xFF, 0xFF, 0xFF, 0xFF, // garbage version/flags + 0xFF, 0xFF, 0xFF, 0xFF, // more garbage + 0xFF, 0xFF, 0xFF, 0xFF, + ]); + mockDioGet(dio, Globals.getRemoteUrlHtml('es'), corruptedZip); + mockDioGet(dio, Globals.getRemoteUrlPdf('es'), corruptedZip); + + await expectLater(downloader.download('es'), throwsA(anything)); + + expect(await downloader.isDownloaded('es'), false); + expect(await fs.directory('/app-docs/assets-es.staging').exists(), false); + }); + + test('Atomic update: failing download preserves prior data', () async { + // Seed existing data + final existingDir = fs.directory('/app-docs/assets-it'); + await existingDir.create(recursive: true); + await fs.file('/app-docs/assets-it/existing.txt').writeAsString('precious'); + + // HTML download succeeds but PDF throws — simulates network failure mid-flight + final htmlZip = createTestZip({ + '${Globals.getResourcesDir('it')}/file.html': '

ciao

', + }); + mockDioGet(dio, Globals.getRemoteUrlHtml('it'), htmlZip); + when(() => dio.get>( + Globals.getRemoteUrlPdf('it'), + options: any(named: 'options'), + )).thenThrow(DioException( + requestOptions: RequestOptions(path: Globals.getRemoteUrlPdf('it')), + )); + + await expectLater(downloader.download('it'), throwsA(isA())); + + // Prior data is intact + expect(await downloader.isDownloaded('it'), true); + expect( + await fs.file('/app-docs/assets-it/existing.txt').readAsString(), + 'precious'); + }); + + test('Concurrent calls are serialized', () async { + final callOrder = []; + + final htmlZip1 = createTestZip({ + '${Globals.getResourcesDir('de')}/file.txt': 'de-content', + }); + final pdfZip1 = createTestZip({ + '${Globals.getPdfDir('de')}/file.pdf': 'de-pdf', + }); + final htmlZip2 = createTestZip({ + '${Globals.getResourcesDir('fr')}/file.txt': 'fr-content', + }); + final pdfZip2 = createTestZip({ + '${Globals.getPdfDir('fr')}/file.pdf': 'fr-pdf', + }); + + // Track call ordering via dio mocks + when(() => dio.get>( + Globals.getRemoteUrlHtml('de'), + options: any(named: 'options'), + )).thenAnswer((_) async { + callOrder.add('de-html-start'); + return Response( + data: htmlZip1.toList(), + statusCode: 200, + requestOptions: RequestOptions(), + ); + }); + when(() => dio.get>( + Globals.getRemoteUrlPdf('de'), + options: any(named: 'options'), + )).thenAnswer((_) async { + callOrder.add('de-pdf-start'); + return Response( + data: pdfZip1.toList(), + statusCode: 200, + requestOptions: RequestOptions(), + ); + }); + when(() => dio.get>( + Globals.getRemoteUrlHtml('fr'), + options: any(named: 'options'), + )).thenAnswer((_) async { + callOrder.add('fr-html-start'); + return Response( + data: htmlZip2.toList(), + statusCode: 200, + requestOptions: RequestOptions(), + ); + }); + when(() => dio.get>( + Globals.getRemoteUrlPdf('fr'), + options: any(named: 'options'), + )).thenAnswer((_) async { + callOrder.add('fr-pdf-start'); + return Response( + data: pdfZip2.toList(), + statusCode: 200, + requestOptions: RequestOptions(), + ); + }); + + // Fire both without awaiting + final f1 = downloader.download('de'); + final f2 = downloader.download('fr'); + await Future.wait([f1, f2]); + + // de downloads must all complete before fr starts + final deLastIndex = callOrder.lastIndexOf('de-pdf-start'); + final frFirstIndex = callOrder.indexOf('fr-html-start'); + expect(deLastIndex, lessThan(frFirstIndex)); + }); + + test('Crash recovery: pre-seeded staging dir is wiped by next download', + () async { + // Simulate crashed prior run leaving a staging dir + await fs + .directory('/app-docs/assets-de.staging/leftover') + .create(recursive: true); + await fs + .file('/app-docs/assets-de.staging/leftover/junk.txt') + .writeAsString('crash-leftover'); + + final htmlZip = createTestZip({ + '${Globals.getResourcesDir('de')}/fresh.html': '

Fresh

', + }); + final pdfZip = createTestZip({ + '${Globals.getPdfDir('de')}/fresh.pdf': 'fresh-pdf', + }); + + mockDioGet(dio, Globals.getRemoteUrlHtml('de'), htmlZip); + mockDioGet(dio, Globals.getRemoteUrlPdf('de'), pdfZip); + + await downloader.download('de'); + + // Old staging leftover is gone + expect( + await fs.file('/app-docs/assets-de/leftover/junk.txt').exists(), false); + // Fresh content is there + expect( + await fs + .file( + '/app-docs/assets-de/${Globals.getResourcesDir('de')}/fresh.html') + .exists(), + true); + }); +} diff --git a/test/languages_test.dart b/test/languages_test.dart index 2a3cdf4..632d5d8 100644 --- a/test/languages_test.dart +++ b/test/languages_test.dart @@ -3,8 +3,6 @@ import 'dart:io'; import 'package:app4training/background/background_test.dart'; import 'package:app4training/data/exceptions.dart'; import 'package:app4training/data/globals.dart'; -import 'package:dio/dio.dart'; -import 'package:download_assets/download_assets.dart'; import 'package:file/chroot.dart'; import 'package:file/local.dart'; import 'package:file/memory.dart'; @@ -12,82 +10,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod/misc.dart' show ProviderException; import 'package:flutter_test/flutter_test.dart'; import 'package:app4training/data/languages.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as path; - // ignore: implementation_imports, invalid_use_of_internal_member import 'package:riverpod/src/framework.dart' show $RefArg; -class MockDownloadAssetsController extends Mock - implements DownloadAssetsController {} - -class FakeDownloadAssetsController extends Fake - implements DownloadAssetsController { - late String _assetDir; - bool initCalled = false; - bool clearAssetsCalled = false; - int startDownloadCalls = 0; - - // TODO use this class to test the startDownload() functionality - @override - Future init({ - String assetDir = 'assets', - bool useFullDirectoryPath = false, - }) async { - _assetDir = assetDir; - initCalled = true; - return; - } - - @override - String? get assetsDir => _assetDir; - - @override - Future assetsDirAlreadyExists() async { - return false; - } - - @override - Future clearAssets() async { - clearAssetsCalled = true; - } - - @override - Future startDownload({ - required List assetsUrls, - bool? checkSize, - List uncompressDelegates = const [UnzipDelegate()], - Function(double p1)? onProgress, - Function()? onStartUnziping, - Function()? onCancel, - Function()? onDone, - Map? requestQueryParams, - Map requestExtraHeaders = const {}, - }) async { - // TODO: implement startDownload - startDownloadCalls += 1; - return; - } -} - -class ThrowingDownloadAssetsController extends FakeDownloadAssetsController { - @override - Future startDownload({ - required List assetsUrls, - bool? checkSize, - List uncompressDelegates = const [UnzipDelegate()], - Function(double p1)? onProgress, - Function()? onStartUnziping, - Function()? onCancel, - Function()? onDone, - Map? requestQueryParams, - Map requestExtraHeaders = const {}, - }) async { - startDownloadCalls += 1; - throw DioException(requestOptions: RequestOptions()); - } -} - /// For testing the LanguageController: Simulate essential behavior /// without needing access to device file system etc. /// @@ -98,16 +24,15 @@ class TestLanguageController extends LanguageController { final int _languageSize; // size in KB final Map _pages; // map of pages that are available final bool _initReturns; - - TestLanguageController({ - List? downloadedLanguages, - int languageSize = 0, - Map pages = const {}, - initReturns = false, - }) : _downloadedLanguages = downloadedLanguages, - _languageSize = languageSize, - _pages = pages, - _initReturns = initReturns; + TestLanguageController( + {List? downloadedLanguages, + int languageSize = 0, + Map pages = const {}, + initReturns = false}) + : _downloadedLanguages = downloadedLanguages, + _languageSize = languageSize, + _pages = pages, + _initReturns = initReturns; @override Language build() { @@ -119,42 +44,21 @@ class TestLanguageController extends LanguageController { if (_downloadedLanguages != null) { downloaded = _downloadedLanguages.contains(languageCode); } - return Language( - downloaded ? languageCode : '', - _pages, - const [], - const {}, - '', - _languageSize, - DateTime.utc(2023), - ); + return Language(downloaded ? languageCode : '', _pages, const [], const {}, + '', _languageSize, DateTime.utc(2023)); } @override - Future download({bool force = false}) async { - state = Language( - languageCode, - _pages, - const [], - const {}, - '', - _languageSize, - DateTime.now().toUtc(), - ); + Future download() async { + state = Language(languageCode, _pages, const [], const {}, '', + _languageSize, DateTime.now().toUtc()); return true; } @override Future deleteResources() async { - state = Language( - '', - const {}, - const [], - const {}, - '', - 0, - DateTime.utc(2023), - ); + state = + Language('', const {}, const [], const {}, '', 0, DateTime.utc(2023)); } @override @@ -168,8 +72,7 @@ class TestLanguageController extends LanguageController { /// Only structure/contents.json is existing (with dummy contents) /// But that's enough for Languages.lazyInit() Future createBasicFileSystem( - List downloadedLangs, -) async { + List downloadedLangs) async { var fileSystem = MemoryFileSystem(); for (final lang in downloadedLangs) { await fileSystem @@ -184,101 +87,71 @@ Future createBasicFileSystem( void main() { test('Test init() when no files are there', () async { - var fakeController = FakeDownloadAssetsController(); - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: fakeController, - ), - ), - fileSystemProvider.overrideWith((ref) => MemoryFileSystem()), - ], - ); + final fileSystem = MemoryFileSystem(); + final fakeDownloader = FakeLanguageDownloader(fileSystem: fileSystem); + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ]); final frTest = ref.read(languageProvider('fr').notifier); expect(await frTest.init(), false); expect(frTest.state.downloaded, false); // init() shouldn't start a download - expect(fakeController.startDownloadCalls, 0); + expect(fakeDownloader.downloadCalls, 0); }); test('Test lazyInit() when no files are there', () async { - var fakeController = FakeDownloadAssetsController(); - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: fakeController, - ), - ), - fileSystemProvider.overrideWith((ref) => MemoryFileSystem()), - ], - ); + final fileSystem = MemoryFileSystem(); + final fakeDownloader = FakeLanguageDownloader(fileSystem: fileSystem); + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ]); final frTest = ref.read(languageProvider('fr').notifier); expect(await frTest.lazyInit(), false); expect(frTest.state.downloaded, false); }); test('Test that download() starts the download', () async { - var fakeController = FakeDownloadAssetsController(); - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: fakeController, - ), - ), - fileSystemProvider.overrideWith((ref) => MemoryFileSystem()), - ], - ); + final fileSystem = MemoryFileSystem(); + final fakeDownloader = FakeLanguageDownloader(fileSystem: fileSystem); + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ]); final frTest = ref.read(languageProvider('fr').notifier); - // as we're mocking, the language won't be available + // as we're faking, the language won't be available after download expect(await frTest.download(), false); // Verify that download got started - expect(fakeController.initCalled, true); - expect(fakeController.startDownloadCalls, 2); - expect(fakeController.clearAssetsCalled, false); + expect(fakeDownloader.downloadCalls, 1); + expect(fakeDownloader.deleteCalls, 0); }); test('Test failing download', () async { - var throwingController = ThrowingDownloadAssetsController(); - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: throwingController, - ), - ), - fileSystemProvider.overrideWith((ref) => MemoryFileSystem()), - ], - ); + final fileSystem = MemoryFileSystem(); + final fakeDownloader = + FakeLanguageDownloader(fileSystem: fileSystem, throwOnDownload: true); + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ]); final frTest = ref.read(languageProvider('fr').notifier); expect(await frTest.download(), false); // download shouldn't throw (if it would the test would fail) - expect(throwingController.startDownloadCalls, 1); - expect(throwingController.clearAssetsCalled, true); + expect(fakeDownloader.downloadCalls, 1); }); group('Test correct behavior after downloading', () { group('Test error handling of incorrect files / structure', () { test('Test error handling when no files can be found at all', () async { - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: createMockDownloadAssetsController(), - ), - ), - fileSystemProvider.overrideWith((ref) => MemoryFileSystem()), - ], - ); + final fileSystem = MemoryFileSystem(); + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem)), + ]); final deTest = ref.read(languageProvider('de').notifier); expect(await deTest.init(), false); @@ -290,22 +163,15 @@ void main() { await fileSystem .directory('assets-de/html-de-main/structure') .create(recursive: true); - var contentsJson = fileSystem.file( - 'assets-de/html-de-main/structure/contents.json', - ); + var contentsJson = + fileSystem.file('assets-de/html-de-main/structure/contents.json'); await contentsJson.writeAsString('invalid'); - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: createMockDownloadAssetsController(), - ), - ), - fileSystemProvider.overrideWith((ref) => fileSystem), - ], - ); + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem)), + ]); final deTest = ref.read(languageProvider('de').notifier); expect(await deTest.init(), false); expect(deTest.state.downloaded, false); @@ -319,91 +185,59 @@ void main() { .directory('assets-de/html-de-main/structure') .create(recursive: true); var readFileSystem = ChrootFileSystem( - const LocalFileSystem(), - path.canonicalize('test/'), - ); + const LocalFileSystem(), path.canonicalize('test/')); String jsonPath = 'assets-de/html-de-main/structure/contents.json'; var contentsJson = fileSystem.file(jsonPath); - await contentsJson.writeAsString( - await readFileSystem.file(jsonPath).readAsString(), - ); - - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: createMockDownloadAssetsController(), - ), - ), - fileSystemProvider.overrideWith((ref) => fileSystem), - ], - ); + await contentsJson + .writeAsString(await readFileSystem.file(jsonPath).readAsString()); + + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem)), + ]); // init() should work (even if expected HTML files are missing) final deTest = ref.read(languageProvider('de').notifier); expect(await deTest.init(), true); expect(deTest.state.downloaded, true); - expect( - deTest.state.downloadTimestamp.compareTo(DateTime(2023)), - greaterThan(0), - ); + expect(deTest.state.downloadTimestamp.compareTo(DateTime(2023)), + greaterThan(0)); }); }); test('Test lazyInit() when language is available', () async { // We construct a file system in memory with structure/contents.json final fileSystem = await createBasicFileSystem(['de']); - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: createMockDownloadAssetsController(), - ), - ), - fileSystemProvider.overrideWith((ref) => fileSystem), - ], - ); + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem)), + ]); expect(await ref.read(languageProvider('de').notifier).lazyInit(), true); final deStatus = ref.read(languageProvider('de')); expect(deStatus.downloaded, true); expect(deStatus.path, equals('assets-de/html-de-main')); expect( - deStatus.downloadTimestamp.compareTo(DateTime(2023)), - greaterThan(0), - ); + deStatus.downloadTimestamp.compareTo(DateTime(2023)), greaterThan(0)); }); test('Test everything with real content from test/assets-de/', () async { - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: createMockDownloadAssetsController(), - ), - ), - fileSystemProvider.overrideWith( - (ref) => ChrootFileSystem( - const LocalFileSystem(), - path.canonicalize('test/'), - ), - ), - ], - ); + final fileSystem = ChrootFileSystem( + const LocalFileSystem(), path.canonicalize('test/')); + final ref = ProviderContainer(overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider + .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), + ]); final deTest = ref.read(languageProvider('de').notifier); expect(await deTest.init(), true); // Loads Gottes_Geschichte_(fünf_Finger).html - String content = await ref.read( - pageContentProvider(( - name: "God's_Story_(five_fingers)", - langCode: 'de', - )).future, - ); + String content = await ref.read(pageContentProvider( + (name: "God's_Story_(five_fingers)", langCode: 'de')).future); expect(content, startsWith('

Gottes Geschichte')); // The link of this image should have been replaced with image content @@ -412,22 +246,19 @@ void main() { // This should still be there as the image file is missing expect(content, contains('src="files/Hand_5.png"')); // PDF should be available - expect( - deTest.state.pages['Forgiving_Step_by_Step']?.pdfPath, - equals('assets-de/pdf-de-main/Schritte_der_Vergebung.pdf'), - ); + expect(deTest.state.pages['Forgiving_Step_by_Step']?.pdfPath, + equals('assets-de/pdf-de-main/Schritte_der_Vergebung.pdf')); // This PDF is missing expect(deTest.state.pages['MissingTest']?.pdfPath, isNull); // Test Languages.getPageTitles() expect( - deTest.state.getPageTitles().values, - orderedEquals(const [ - 'Gottes Geschichte (fünf Finger)', - 'Schritte der Vergebung', - 'MissingTest', - ]), - ); + deTest.state.getPageTitles().values, + orderedEquals(const [ + 'Gottes Geschichte (fünf Finger)', + 'Schritte der Vergebung', + 'MissingTest' + ])); expect(deTest.state.sizeInKB, 147); expect(deTest.state.path, equals('assets-de/html-de-main')); @@ -437,22 +268,18 @@ void main() { ref.read(pageContentProvider((name: 'Invalid', langCode: 'de'))); await Future.delayed(const Duration(milliseconds: 500)); - final missingResult = ref.read( - pageContentProvider((name: 'MissingTest', langCode: 'de')), - ); + final missingResult = + ref.read(pageContentProvider((name: 'MissingTest', langCode: 'de'))); expect(missingResult.hasError, true); // In Riverpod v3, errors are wrapped in ProviderException var error = missingResult.error; if (error is ProviderException) error = error.exception; expect(error, isA()); - expect( - (error as LanguageCorruptedException).exception, - isA(), - ); + expect((error as LanguageCorruptedException).exception, + isA()); - final invalidResult = ref.read( - pageContentProvider((name: 'Invalid', langCode: 'de')), - ); + final invalidResult = + ref.read(pageContentProvider((name: 'Invalid', langCode: 'de'))); expect(invalidResult.hasError, true); error = invalidResult.error; if (error is ProviderException) error = error.exception; @@ -461,24 +288,18 @@ void main() { }); test('Test diskUsageProvider', () { - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(languageSize: 42), - ), - ], - ); + final ref = ProviderContainer(overrides: [ + languageProvider + .overrideWith2((langCode) => TestLanguageController(languageSize: 42)), + ]); expect(ref.read(diskUsageProvider), countAvailableLanguages * 42); }); test('Test countDownloadedLanguagesProvider', () { - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(downloadedLanguages: ['de', 'fr', 'en']), - ), - ], - ); + final ref = ProviderContainer(overrides: [ + languageProvider.overrideWith2((langCode) => + TestLanguageController(downloadedLanguages: ['de', 'fr', 'en'])), + ]); expect(ref.read(countDownloadedLanguagesProvider), 3); }); } diff --git a/test/routes_test.dart b/test/routes_test.dart index 3bb3f03..5ae0a73 100644 --- a/test/routes_test.dart +++ b/test/routes_test.dart @@ -1,11 +1,13 @@ import 'dart:async'; +import 'package:app4training/background/background_test.dart'; import 'package:app4training/data/languages.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; import 'package:app4training/routes/error_page.dart'; import 'package:app4training/routes/home_page.dart'; import 'package:app4training/routes/onboarding/download_languages_page.dart'; import 'package:app4training/routes/onboarding/welcome_page.dart'; +import 'package:file/memory.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -36,7 +38,6 @@ class TestObserver extends NavigatorObserver { class TestApp extends StatelessWidget { final TestObserver observer; - const TestApp(this.observer, {super.key}); @override @@ -56,12 +57,9 @@ void main() { final prefs = await SharedPreferences.getInstance(); final TestObserver observer = TestObserver(); - await tester.pumpWidget( - ProviderScope( + await tester.pumpWidget(ProviderScope( overrides: [sharedPrefsProvider.overrideWithValue(prefs)], - child: TestApp(observer), - ), - ); + child: TestApp(observer))); // Test that onboarding process get's started on first app usage expect(find.byType(TestApp), findsOneWidget); @@ -70,24 +68,18 @@ void main() { expect(find.byType(WelcomePage), findsOneWidget); // Test second onboarding step - unawaited( - Navigator.of( - tester.element(find.byType(WelcomePage)), - ).pushReplacementNamed('/onboarding/2'), - ); + unawaited(Navigator.of(tester.element(find.byType(WelcomePage))) + .pushReplacementNamed('/onboarding/2')); await tester.pumpAndSettle(); expect(find.byType(DownloadLanguagesPage), findsOneWidget); // Go back again - unawaited( - Navigator.of( - tester.element(find.byType(DownloadLanguagesPage)), - ).pushReplacementNamed('/onboarding/1'), - ); + unawaited(Navigator.of(tester.element(find.byType(DownloadLanguagesPage))) + .pushReplacementNamed('/onboarding/1')); await tester.pumpAndSettle(); expect(find.byType(WelcomePage), findsOneWidget); - /* TODO for version 0.9 +/* TODO for version 0.9 // Test third onboarding step unawaited(Navigator.of(tester.element(find.byType(WelcomePage))) .pushReplacementNamed('/onboarding/3')); @@ -97,14 +89,13 @@ void main() { // Test that routes are handled expect( - observer.replacedRoutes, - orderedEquals([ - '/onboarding/1', - '/onboarding/2', - '/onboarding/1', - // '/onboarding/3' - ]), - ); + observer.replacedRoutes, + orderedEquals([ + '/onboarding/1', + '/onboarding/2', + '/onboarding/1', +// '/onboarding/3' + ])); }); testWidgets('Test normal startup', (WidgetTester tester) async { @@ -112,45 +103,32 @@ void main() { final prefs = await SharedPreferences.getInstance(); final TestObserver observer = TestObserver(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(initReturns: true), - ), - sharedPrefsProvider.overrideWithValue(prefs), - ], - child: TestApp(observer), - ), - ); + await tester.pumpWidget(ProviderScope(overrides: [ + languageProvider + .overrideWith2((langCode) => TestLanguageController(initReturns: true)), + sharedPrefsProvider.overrideWithValue(prefs) + ], child: TestApp(observer))); // Test initial route / expect(find.byType(TestApp), findsOneWidget); expect(find.byType(StartupPage), findsOneWidget); // Test home page - unawaited( - Navigator.of(tester.element(find.byType(StartupPage))).pushNamed('/home'), - ); + unawaited(Navigator.of(tester.element(find.byType(StartupPage))) + .pushNamed('/home')); await tester.pumpAndSettle(); expect(find.byType(HomePage), findsOneWidget); // Test settings page - unawaited( - Navigator.of( - tester.element(find.byType(HomePage)), - ).pushNamed('/settings'), - ); + unawaited(Navigator.of(tester.element(find.byType(HomePage))) + .pushNamed('/settings')); await tester.pumpAndSettle(); expect(find.byType(SettingsPage), findsOneWidget); // Test viewing the forgiveness page in English const String viewRoute = '/view/Forgiving_Step_by_Step/en'; - unawaited( - Navigator.of( - tester.element(find.byType(SettingsPage)), - ).pushNamed(viewRoute), - ); + unawaited(Navigator.of(tester.element(find.byType(SettingsPage))) + .pushNamed(viewRoute)); await tester.pumpAndSettle(); expect(find.byType(ViewPage), findsOneWidget); ViewPage viewPage = @@ -160,40 +138,34 @@ void main() { // Test that routes are handled expect( - observer.routes, - orderedEquals(['/', '/home', '/settings', viewRoute]), - ); + observer.routes, orderedEquals(['/', '/home', '/settings', viewRoute])); }); testWidgets('Test some edge cases', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({'appLanguage': 'system'}); final prefs = await SharedPreferences.getInstance(); + final fileSystem = MemoryFileSystem(); - await tester.pumpWidget( - ProviderScope( - overrides: [sharedPrefsProvider.overrideWithValue(prefs)], - child: TestApp(TestObserver()), - ), - ); + await tester.pumpWidget(ProviderScope(overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider + .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), + ], child: TestApp(TestObserver()))); // All of the following errorneous routes should result in showing /home - unawaited( - Navigator.of(tester.element(find.byType(StartupPage))).pushNamed('/view'), - ); + unawaited(Navigator.of(tester.element(find.byType(StartupPage))) + .pushNamed('/view')); await tester.pumpAndSettle(); expect(find.byType(HomePage), findsOneWidget); - unawaited( - Navigator.of(tester.element(find.byType(HomePage))).pushNamed('/view//'), - ); + unawaited(Navigator.of(tester.element(find.byType(HomePage))) + .pushNamed('/view//')); await tester.pumpAndSettle(); expect(find.byType(HomePage), findsOneWidget); - unawaited( - Navigator.of( - tester.element(find.byType(HomePage)), - ).pushNamed('/view/Forgiving_Step_by_Step/'), - ); + unawaited(Navigator.of(tester.element(find.byType(HomePage))) + .pushNamed('/view/Forgiving_Step_by_Step/')); await tester.pumpAndSettle(); expect(find.byType(HomePage), findsOneWidget); }); @@ -201,18 +173,16 @@ void main() { testWidgets('Test unknown route', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({'appLanguage': 'system'}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget( - ProviderScope( - overrides: [sharedPrefsProvider.overrideWithValue(prefs)], - child: TestApp(TestObserver()), - ), - ); - - unawaited( - Navigator.of( - tester.element(find.byType(StartupPage)), - ).pushNamed('/unknown'), - ); + final fileSystem = MemoryFileSystem(); + await tester.pumpWidget(ProviderScope(overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider + .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), + ], child: TestApp(TestObserver()))); + + unawaited(Navigator.of(tester.element(find.byType(StartupPage))) + .pushNamed('/unknown')); await tester.pumpAndSettle(); expect(find.byType(ErrorPage), findsOneWidget); expect(find.textContaining('Unknown route /unknown'), findsOneWidget); diff --git a/test/update_language_button_test.dart b/test/update_language_button_test.dart index 9eef234..1d6c4db 100644 --- a/test/update_language_button_test.dart +++ b/test/update_language_button_test.dart @@ -1,9 +1,11 @@ +import 'package:app4training/background/background_test.dart'; import 'package:app4training/data/app_language.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; import 'package:app4training/data/updates.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; import 'package:app4training/widgets/update_language_button.dart'; +import 'package:file/memory.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,12 +23,11 @@ class TestUpdateLanguageButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: Scaffold(body: UpdateLanguageButton(languageCode)), - ); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: Scaffold(body: UpdateLanguageButton(languageCode))); } } @@ -36,12 +37,11 @@ class TestUpdateAllLanguagesButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.read(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: const Scaffold(body: UpdateAllLanguagesButton()), - ); + locale: ref.read(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: const Scaffold(body: UpdateAllLanguagesButton())); } } @@ -49,23 +49,15 @@ void main() { testWidgets('Test UpdateLanguageButton', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(), - ), - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - sharedPrefsProvider.overrideWith((ref) => prefs), - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - ], - ); - - await tester.pumpWidget( - UncontrolledProviderScope( - container: ref, - child: const TestUpdateLanguageButton('en'), - ), - ); + final ref = ProviderContainer(overrides: [ + languageProvider.overrideWith2((langCode) => TestLanguageController()), + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()) + ]); + + await tester.pumpWidget(UncontrolledProviderScope( + container: ref, child: const TestUpdateLanguageButton('en'))); expect(find.byIcon(Icons.refresh), findsOneWidget); expect(ref.read(languageProvider('en')).downloaded, true); @@ -86,84 +78,63 @@ void main() { expect(secondTimestamp.compareTo(firstTimestamp), greaterThan(0)); expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); // Snackbar visible? - expect( - find.text('Englisch (en) ist nun auf dem aktuellsten Stand'), - findsOneWidget, - ); + expect(find.text('Englisch (en) ist nun auf dem aktuellsten Stand'), + findsOneWidget); }); testWidgets('Test when download fails', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - var throwingController = ThrowingDownloadAssetsController(); - final ref = ProviderContainer( - overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2( - (languageCode) => LanguageController( - languageCode: languageCode, - assetsController: throwingController, - ), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - ], - ); - - await tester.pumpWidget( - UncontrolledProviderScope( - container: ref, - child: const TestUpdateLanguageButton('en'), - ), - ); + final fileSystem = MemoryFileSystem(); + final fakeDownloader = FakeLanguageDownloader( + fileSystem: fileSystem, throwOnDownload: true); + final ref = ProviderContainer(overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()) + ]); + + await tester.pumpWidget(UncontrolledProviderScope( + container: ref, child: const TestUpdateLanguageButton('en'))); await ref.read(languageStatusProvider('en').notifier).check(); expect(ref.read(languageStatusProvider('en')).updatesAvailable, true); await tester.tap(find.byType(UpdateLanguageButton)); await tester.pump(); - expect(throwingController.startDownloadCalls, 1); - expect(throwingController.clearAssetsCalled, true); + expect(fakeDownloader.downloadCalls, 1); // Snackbar visible? expect( - find.textContaining('Aktualisierung fehlgeschlagen.'), - findsOneWidget, - ); + find.textContaining('Aktualisierung fehlgeschlagen.'), findsOneWidget); expect(ref.read(languageProvider('en')).downloaded, false); - expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); + // A failed download must not clear updates flag: existing data is intact + // (atomic staging-and-swap inside LanguageDownloader) + expect(ref.read(languageStatusProvider('en')).updatesAvailable, true); }); - testWidgets('Test UpdateAllLanguagesButton: Updating 3 languages', ( - WidgetTester tester, - ) async { + testWidgets('Test UpdateAllLanguagesButton: Updating 3 languages', + (WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - ], - ); + final ref = ProviderContainer(overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()) + ]); expect(ref.read(updatesAvailableProvider), false); // Simulate that there are updates for these three languages available for (String languageCode in ['de', 'en', 'fr']) { expect( - await ref.read(languageStatusProvider(languageCode).notifier).check(), - 2, - ); + await ref.read(languageStatusProvider(languageCode).notifier).check(), + 2); } expect(ref.read(updatesAvailableProvider), true); - await tester.pumpWidget( - UncontrolledProviderScope( - container: ref, - child: const TestUpdateAllLanguagesButton(), - ), - ); + await tester.pumpWidget(UncontrolledProviderScope( + container: ref, child: const TestUpdateAllLanguagesButton())); expect(find.byIcon(Icons.refresh), findsOneWidget); await tester.tap(find.byType(UpdateAllLanguagesButton)); @@ -171,17 +142,14 @@ void main() { expect(ref.read(updatesAvailableProvider), false); expect(find.byIcon(Icons.refresh), findsNothing); + expect(ref.read(languageProvider('ar')).downloadTimestamp, + equals(DateTime.utc(2023))); expect( - ref.read(languageProvider('ar')).downloadTimestamp, - equals(DateTime.utc(2023)), - ); - expect( - ref - .read(languageProvider('de')) - .downloadTimestamp - .compareTo(DateTime.utc(2023)), - greaterThan(0), - ); + ref + .read(languageProvider('de')) + .downloadTimestamp + .compareTo(DateTime.utc(2023)), + greaterThan(0)); expect(ref.read(languageStatusProvider('fr')).updatesAvailable, false); await tester.pumpAndSettle(); @@ -189,50 +157,36 @@ void main() { expect(find.text('3 Sprachen aktualisiert'), findsOneWidget); }); - testWidgets('Test UpdateAllLanguagesButton: Updating one language', ( - WidgetTester tester, - ) async { + testWidgets('Test UpdateAllLanguagesButton: Updating one language', + (WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - ], - ); - expect( - ref.read(languageProvider('de')).downloadTimestamp, - equals(DateTime.utc(2023)), - ); + final ref = ProviderContainer(overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()) + ]); + expect(ref.read(languageProvider('de')).downloadTimestamp, + equals(DateTime.utc(2023))); await ref.read(languageStatusProvider('de').notifier).check(); - await tester.pumpWidget( - UncontrolledProviderScope( - container: ref, - child: const TestUpdateAllLanguagesButton(), - ), - ); + await tester.pumpWidget(UncontrolledProviderScope( + container: ref, child: const TestUpdateAllLanguagesButton())); expect(find.byIcon(Icons.refresh), findsOneWidget); await tester.tap(find.byType(UpdateAllLanguagesButton)); await tester.pump(); expect( - ref - .read(languageProvider('de')) - .downloadTimestamp - .compareTo(DateTime.utc(2023)), - greaterThan(0), - ); + ref + .read(languageProvider('de')) + .downloadTimestamp + .compareTo(DateTime.utc(2023)), + greaterThan(0)); // Snackbar visible? - expect( - find.text('Deutsch (de) ist nun auf dem aktuellsten Stand'), - findsOneWidget, - ); + expect(find.text('Deutsch (de) ist nun auf dem aktuellsten Stand'), + findsOneWidget); }); // TODO Test correct handling / snackbar message when updates fail diff --git a/test/updates_test.dart b/test/updates_test.dart index ea88e03..4def356 100644 --- a/test/updates_test.dart +++ b/test/updates_test.dart @@ -10,7 +10,6 @@ import 'package:app4training/data/updates.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; - // ignore: implementation_imports, invalid_use_of_internal_member import 'package:riverpod/src/framework.dart' show $RefArg; @@ -32,32 +31,24 @@ int countCheckCalled = 0; class TestLanguageStatus extends LanguageStatusNotifier { int _checkReturnValue; final List _langWithUpdates; - - TestLanguageStatus({ - int checkReturnValue = 0, - List langWithUpdates = const [], - }) : _checkReturnValue = checkReturnValue, - _langWithUpdates = langWithUpdates; + TestLanguageStatus( + {int checkReturnValue = 0, List langWithUpdates = const []}) + : _checkReturnValue = checkReturnValue, + _langWithUpdates = langWithUpdates; @override LanguageStatus build() { final arg = ref.$arg as String; return LanguageStatus( - _langWithUpdates.contains(arg), - DateTime.utc(2023), - DateTime.utc(2023), - ); + _langWithUpdates.contains(arg), DateTime.utc(2023), DateTime.utc(2023)); } @override Future check() async { countCheckCalled++; if (_checkReturnValue >= 0) { - state = LanguageStatus( - _checkReturnValue > 0, - state.downloadTimestamp, - DateTime.now().toUtc(), - ); + state = LanguageStatus(_checkReturnValue > 0, state.downloadTimestamp, + DateTime.now().toUtc()); } return _checkReturnValue; } @@ -104,10 +95,8 @@ Client mockCheckResponse(Map languageUpdateMap) { // Get our language code from the URL that looks something like this: // https://api.github.com/repos/4training/html-de/commits?since=... final String languageCode = request.url.pathSegments - .firstWhere( - (element) => element.startsWith('html-'), - orElse: () => 'html-xyz', - ) + .firstWhere((element) => element.startsWith('html-'), + orElse: () => 'html-xyz') .substring(5); int countUpdates = languageUpdateMap[languageCode] ?? 0; return fakeResponseNUpdates(countUpdates); @@ -125,42 +114,30 @@ void main() { }); testWidgets('Test getting localized messages', (WidgetTester tester) async { - await tester.pumpWidget( - const ProviderScope( + await tester.pumpWidget(const ProviderScope( child: MaterialApp( - locale: Locale('de'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(), - ), - ), - ); + locale: Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold()))); BuildContext context = tester.element(find.byType(Scaffold)); expect( - CheckFrequency.getLocalized(context, CheckFrequency.never), - "niemals", - ); + CheckFrequency.getLocalized(context, CheckFrequency.never), "niemals"); }); test('Test TestLanguageStatusNotifier class', () async { var testLanguageStatus = TestLanguageStatus(); - var ref = ProviderContainer( - overrides: [ - languageStatusProvider.overrideWith2( - (languageCode) => testLanguageStatus, - ), - ], - ); + var ref = ProviderContainer(overrides: [ + languageStatusProvider.overrideWith2((langCode) => testLanguageStatus) + ]); var deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.updatesAvailable, false); expect(deStatus.lastCheckedTimestamp, equals(deStatus.downloadTimestamp)); expect(await ref.read(languageStatusProvider('de').notifier).check(), 0); deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.updatesAvailable, false); - expect( - deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0), - ); + expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0)); testLanguageStatus.setCheckReturnValue(2); await Future.delayed(const Duration(milliseconds: 1)); @@ -170,9 +147,8 @@ void main() { expect(deStatus2.downloadTimestamp, equals(deStatus.downloadTimestamp)); // last checked timestamp should be even newer expect( - deStatus2.lastCheckedTimestamp.compareTo(deStatus.lastCheckedTimestamp), - greaterThan(0), - ); + deStatus2.lastCheckedTimestamp.compareTo(deStatus.lastCheckedTimestamp), + greaterThan(0)); testLanguageStatus.setCheckReturnValue(-1); await Future.delayed(const Duration(milliseconds: 1)); @@ -182,9 +158,7 @@ void main() { expect(deStatus3.downloadTimestamp, equals(deStatus.downloadTimestamp)); // last checked timestamp should be the same expect( - deStatus3.lastCheckedTimestamp, - equals(deStatus2.lastCheckedTimestamp), - ); + deStatus3.lastCheckedTimestamp, equals(deStatus2.lastCheckedTimestamp)); testLanguageStatus.setCheckReturnValue(0); await Future.delayed(const Duration(milliseconds: 1)); @@ -194,40 +168,30 @@ void main() { expect(deStatus4.downloadTimestamp, equals(deStatus.downloadTimestamp)); // last checked timestamp should be newer expect( - deStatus4.lastCheckedTimestamp.compareTo(deStatus3.lastCheckedTimestamp), - greaterThan(0), - ); + deStatus4.lastCheckedTimestamp + .compareTo(deStatus3.lastCheckedTimestamp), + greaterThan(0)); + }); + test('Test TestLanguageStatusNotifier constructor parameter checkReturnValue', + () async { + final ref = ProviderContainer(overrides: [ + languageStatusProvider + .overrideWith2((langCode) => TestLanguageStatus(checkReturnValue: 2)) + ]); + expect(ref.read(languageStatusProvider('de')).updatesAvailable, false); + expect(await ref.read(languageStatusProvider('de').notifier).check(), 2); + expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); + expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); + }); + test('Test TestLanguageStatusNotifier constructor parameter langsWithUpdates', + () async { + var ref = ProviderContainer(overrides: [ + languageStatusProvider + .overrideWith2((langCode) => TestLanguageStatus(langWithUpdates: ['de'])) + ]); + expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); + expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); }); - test( - 'Test TestLanguageStatusNotifier constructor parameter checkReturnValue', - () async { - final ref = ProviderContainer( - overrides: [ - languageStatusProvider.overrideWith2( - (languageCode) => TestLanguageStatus(checkReturnValue: 2), - ), - ], - ); - expect(ref.read(languageStatusProvider('de')).updatesAvailable, false); - expect(await ref.read(languageStatusProvider('de').notifier).check(), 2); - expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); - expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); - }, - ); - test( - 'Test TestLanguageStatusNotifier constructor parameter langsWithUpdates', - () async { - var ref = ProviderContainer( - overrides: [ - languageStatusProvider.overrideWith2( - (languageCode) => TestLanguageStatus(langWithUpdates: ['de']), - ), - ], - ); - expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); - expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); - }, - ); group('Test reading LanguageStatus from SharedPreferences', () { late ProviderContainer ref; @@ -236,15 +200,11 @@ void main() { setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); - ref = ProviderContainer( - overrides: [ - languageProvider.overrideWith2( - (languageCode) => - TestLanguageController(downloadedLanguages: ['de']), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - ], - ); + ref = ProviderContainer(overrides: [ + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: ['de'])), + sharedPrefsProvider.overrideWith((ref) => prefs) + ]); }); test('Nothing saved in SharedPreferences', () async { @@ -307,23 +267,16 @@ void main() { test('Test checking for updates: no updates', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - httpClientProvider.overrideWith( - (ref) => MockClient((request) async { - expect( - request.url.toString(), - equals(Globals.getCommitsSince('de', DateTime.utc(2023))), - ); + final ref = ProviderContainer(overrides: [ + httpClientProvider.overrideWith((ref) => MockClient((request) async { + expect(request.url.toString(), + equals(Globals.getCommitsSince('de', DateTime.utc(2023)))); return Response(json.encode([]), 200); - }), - ), - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(downloadedLanguages: ['de']), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - ], - ); + })), + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: ['de'])), + sharedPrefsProvider.overrideWith((ref) => prefs) + ]); LanguageStatus deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); expect(deStatus.lastCheckedTimestamp, equals(DateTime.utc(2023))); @@ -334,38 +287,28 @@ void main() { // The lastCheckedTimestamp should be updated to the current time now() deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); + expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0)); expect( - deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0), - ); - expect( - ref.read(lastCheckedProvider), - equals(deStatus.lastCheckedTimestamp), - ); + ref.read(lastCheckedProvider), equals(deStatus.lastCheckedTimestamp)); expect(deStatus.updatesAvailable, false); expect(prefs.getBool('updatesAvailable-de'), false); - expect( - prefs.getString('lastChecked-de'), - equals(deStatus.lastCheckedTimestamp.toIso8601String()), - ); + expect(prefs.getString('lastChecked-de'), + equals(deStatus.lastCheckedTimestamp.toIso8601String())); }); test('Test checking for updates: 2 updates', () async { SharedPreferences.setMockInitialValues({ 'lastChecked-de': '2023-02-02 00:00:00.000Z', - 'updatesAvailable-de': false, + 'updatesAvailable-de': false }); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - languageProvider.overrideWith2( - (languageCode) => - TestLanguageController(downloadedLanguages: ['de', 'en']), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - ], - ); + final ref = ProviderContainer(overrides: [ + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: ['de', 'en'])), + sharedPrefsProvider.overrideWith((ref) => prefs) + ]); final deStatusNotifier = ref.read(languageStatusProvider('de').notifier); expect(ref.read(lastCheckedProvider), equals(DateTime.utc(2023))); expect(await deStatusNotifier.check(), 2); @@ -373,31 +316,23 @@ void main() { // lastCheckedTimestamp should be updated to the current time now() LanguageStatus deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); - expect( - deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0), - ); + expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0)); expect(deStatus.updatesAvailable, true); expect(prefs.getBool('updatesAvailable-de'), true); - expect( - prefs.getString('lastChecked-de'), - equals(deStatus.lastCheckedTimestamp.toIso8601String()), - ); + expect(prefs.getString('lastChecked-de'), + equals(deStatus.lastCheckedTimestamp.toIso8601String())); // If we check for updates a second time, we should get the same results expect(await deStatusNotifier.check(), 2); deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); - expect( - deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0), - ); + expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0)); expect(deStatus.updatesAvailable, true); expect(prefs.getBool('updatesAvailable-de'), true); - expect( - prefs.getString('lastChecked-de'), - equals(deStatus.lastCheckedTimestamp.toIso8601String()), - ); + expect(prefs.getString('lastChecked-de'), + equals(deStatus.lastCheckedTimestamp.toIso8601String())); // As we didn't check for updates for English: expect(ref.read(lastCheckedProvider), equals(DateTime.utc(2023))); @@ -406,26 +341,20 @@ void main() { test('Test checking and updating', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - languageProvider.overrideWith2( - (languageCode) => - TestLanguageController(downloadedLanguages: ['de', 'en']), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - ], - ); + final ref = ProviderContainer(overrides: [ + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: ['de', 'en'])), + sharedPrefsProvider.overrideWith((ref) => prefs) + ]); expect(await ref.read(languageStatusProvider('de').notifier).check(), 2); // The lastCheckedTimestamp should be updated to the current time now() LanguageStatus deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); - expect( - deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0), - ); + expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0)); expect(deStatus.updatesAvailable, true); expect(ref.read(sharedPrefsProvider).getBool('updatesAvailable-de'), true); expect(ref.read(lastCheckedProvider), equals(DateTime.utc(2023))); @@ -438,13 +367,11 @@ void main() { final englishTimestamp = ref.read(languageStatusProvider('en')).lastCheckedTimestamp; expect( - ref.read(lastCheckedProvider), - equals(deStatus.lastCheckedTimestamp), - ); + ref.read(lastCheckedProvider), equals(deStatus.lastCheckedTimestamp)); // After downloading the resources there shouldn't be updates available await Future.delayed(const Duration(milliseconds: 1)); - await ref.read(languageProvider('de').notifier).download(force: true); + await ref.read(languageProvider('de').notifier).download(); deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.updatesAvailable, false); expect(deStatus.downloadTimestamp, equals(deStatus.lastCheckedTimestamp)); @@ -453,27 +380,20 @@ void main() { // lastCheckedProvider should have advanced a little bit again expect(ref.read(lastCheckedProvider), equals(englishTimestamp)); expect( - ref.read(lastCheckedProvider).compareTo(deStatus.lastCheckedTimestamp), - lessThan(0), - ); + ref.read(lastCheckedProvider).compareTo(deStatus.lastCheckedTimestamp), + lessThan(0)); }); test('Test correct behavior when checking for updates fails', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - httpClientProvider.overrideWith( - (ref) => MockClient((request) async { + final ref = ProviderContainer(overrides: [ + httpClientProvider.overrideWith((ref) => MockClient((request) async { throw ClientException; - }), - ), - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - ], - ); + })), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs) + ]); expect(await ref.read(languageStatusProvider('de').notifier).check(), -1); @@ -488,31 +408,22 @@ void main() { test('Test error handling when API query limit is exceeded', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - httpClientProvider.overrideWith( - (ref) => MockClient((request) async { + final ref = ProviderContainer(overrides: [ + httpClientProvider.overrideWith((ref) => MockClient((request) async { return Response( - json.encode({ - "message": - "API rate limit exceeded for 1.1.1.1. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", - "documentation_url": - "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting", - }), - 403, - ); - }), - ), - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - ], - ); - expect( - await ref.read(languageStatusProvider('de').notifier).check(), - apiRateLimitExceeded, - ); + json.encode({ + "message": + "API rate limit exceeded for 1.1.1.1. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", + "documentation_url": + "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting" + }), + 403); + })), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs) + ]); + expect(await ref.read(languageStatusProvider('de').notifier).check(), + apiRateLimitExceeded); final deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); @@ -524,15 +435,11 @@ void main() { test('Test updatesAvailableProvider', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer( - overrides: [ - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(), - ), - sharedPrefsProvider.overrideWith((ref) => prefs), - ], - ); + final ref = ProviderContainer(overrides: [ + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs) + ]); // No updates available LanguageStatus deStatus = ref.read(languageStatusProvider('de')); @@ -550,9 +457,8 @@ void main() { // Updating the German resources expect( - await ref.read(languageProvider('de').notifier).download(force: true), - true, - ); + await ref.read(languageProvider('de').notifier).download(), + true); // Now there should again be no updates available deStatus = ref.read(languageStatusProvider('de'));