diff --git a/lib/core/messages/library_messages.dart b/lib/core/messages/library_messages.dart index 602e441c1..6b5acebef 100644 --- a/lib/core/messages/library_messages.dart +++ b/lib/core/messages/library_messages.dart @@ -86,6 +86,9 @@ abstract class LibraryMessages { static const String updateInstallerLaunchError = 'שגיאה בהפעלת מתקין העדכון'; + static const String updateDiskSpaceError = + 'אין מספיק מקום פנוי בדיסק לעדכון הספרייה'; + static const String deltaApplyFailed = 'החלת עדכון הדלתא נכשלה'; static const String deltaResultMismatch = @@ -99,4 +102,20 @@ abstract class LibraryMessages { static String fullLibraryDownloadRequired(String reason, String size) => '$reason — נדרשת הורדה מלאה ($size)'; + + /// בחירת מסלול כשהחלת הדלתא צפויה להימשך זמן רב (issue #1211). + static String heavyDeltaRouteChoice({ + required String reason, + required String deltaDownloadSize, + required String deltaApplySize, + required String fullDownloadSize, + }) => + '$reason.\n' + 'עדכון דלתא: הורדה קטנה ($deltaDownloadSize), אך פריסה של ' + '$deltaApplySize והחלה ארוכה — עשרות דקות ומעלה. מתאים לרשת איטית.\n' + 'הורדה מלאה: הורדה גדולה ($fullDownloadSize) והחלה מהירה — דקות.'; + + /// נלווה להודעת שלב ההחלה כשמסלול הדלתא כבד ואין הורדה מלאה חלופית. + static String applyStageWithHeavyDeltaNotice(String stageMessage) => + '$stageMessage — ההחלה עשויה להימשך זמן רב'; } diff --git a/lib/library/view/library_browser.dart b/lib/library/view/library_browser.dart index 617ec0725..b62cf2eae 100644 --- a/lib/library/view/library_browser.dart +++ b/lib/library/view/library_browser.dart @@ -89,6 +89,7 @@ String libraryUpdateButtonTooltip(LibraryUpdateState state) => LibraryUpdateStatus.error => 'שגיאה בעדכון - לחץ לנסות שוב', LibraryUpdateStatus.disconnected => '${state.message} - לחץ לנסות שוב', LibraryUpdateStatus.needsFullConfirmation => state.message, + LibraryUpdateStatus.needsRouteChoice => state.message, LibraryUpdateStatus.blocked => state.message, _ when state.isBusy => state.message, _ => 'עדכון ספרייה', diff --git a/lib/library_update/bloc/library_update_bloc.dart b/lib/library_update/bloc/library_update_bloc.dart index bcd61dde6..5836bca43 100644 --- a/lib/library_update/bloc/library_update_bloc.dart +++ b/lib/library_update/bloc/library_update_bloc.dart @@ -62,6 +62,10 @@ class LibraryUpdateBloc extends Bloc { // לפני await של פתיחת ה-DB מחדש. כך Cancel/Reset לא יכולים לנצח את ה-state. bool _fullDownloadDbReplaced = false; + // הריצה הנוכחית היא דלתא כבדה שאין לה חלופה מלאה — הודעות שלב ההחלה + // נושאות אזהרה שההחלה ארוכה. + bool _heavyDeltaNotice = false; + // שינוי נלווים שריצה מבוטלת לא יכלה לדווח כי ריצה חדשה כבר busy — // הריצה החדשה תדווח אותו, אחרת הריענון/אינדוקס אובדים. bool _unreportedAssetsChange = false; @@ -80,6 +84,7 @@ class LibraryUpdateBloc extends Bloc { super(const LibraryUpdateState()) { on(_onStart); on(_onConfirmFull); + on(_onConfirmHeavyDelta); on(_onDeclineFull); on(_onCancel); on(_onReset); @@ -151,6 +156,7 @@ class LibraryUpdateBloc extends Bloc { } final opId = ++_operationId; + _heavyDeltaNotice = false; _resetProgressThrottle(); emit( const LibraryUpdateState( @@ -188,6 +194,24 @@ class LibraryUpdateBloc extends Bloc { ), ); case LibraryUpdatePlanKind.delta: + // דלתא כבדה (issue #1211): ההחלה עשויה להימשך שעה. כשיש הורדה מלאה + // חלופית הבחירה היא של המשתמש; בלעדיה רק מזהירים וממשיכים. + if (plan.isHeavyDelta && plan.fullDbAsset != null) { + emit( + LibraryUpdateState( + status: LibraryUpdateStatus.needsRouteChoice, + message: LibraryMessages.heavyDeltaRouteChoice( + reason: plan.heavyDeltaReason!, + deltaDownloadSize: _formatSize(plan.totalDownloadSize), + deltaApplySize: _formatSize(plan.deltaUncompressedBytes), + fullDownloadSize: _formatSize(plan.fullDbAsset!.size), + ), + plan: plan, + ), + ); + return; + } + _heavyDeltaNotice = plan.isHeavyDelta; await _runDelta(plan, emit, opId); case LibraryUpdatePlanKind.fullDownload: emit( @@ -325,7 +349,11 @@ class LibraryUpdateBloc extends Bloc { emit( LibraryUpdateState( status: LibraryUpdateStatus.error, - message: 'שגיאה בהחלת העדכון', + // חוסר מקום אינו כשל החלה: הוא נבדק לפני ההורדה, ולכן גם אינו מציע + // הורדה מלאה — היא דורשת עוד יותר מקום. + message: applyError is LibraryUpdateDiskSpaceException + ? LibraryMessages.updateDiskSpaceError + : 'שגיאה בהחלת העדכון', hasUpdate: partial?.hasDatabaseChanges ?? false, changedBookIds: partial?.changedBookIds ?? const {}, requiresFullIndexRefresh: requiresFullIndexRefresh, @@ -349,18 +377,26 @@ class LibraryUpdateBloc extends Bloc { Emitter emit, ) async { if (state.isBusy) return; // הורדה כבר רצה — מתעלמים מאישור כפול. - final plan = state.plan; + // בבחירת מסלול, ה-plan שב-state הוא תוכנית הדלתא — ההורדה המלאה היא + // ה-fallback שלה. חייב להיכנס ל-state, אחרת אין reconcile של האינדקס. + final plan = state.status == LibraryUpdateStatus.needsRouteChoice + ? state.plan?.toFullDownloadFallback( + reason: state.plan?.heavyDeltaReason, + ) + : state.plan; if (plan == null || plan.kind != LibraryUpdatePlanKind.fullDownload) { emit(const LibraryUpdateState()); return; } final opId = ++_operationId; + _heavyDeltaNotice = false; _fullDownloadDbReplaced = false; _resetProgressThrottle(); emit( state.copyWith( status: LibraryUpdateStatus.downloading, message: 'מוריד ספרייה מלאה', + plan: plan, ), ); try { @@ -390,7 +426,9 @@ class LibraryUpdateBloc extends Bloc { emit( LibraryUpdateState( status: LibraryUpdateStatus.error, - message: 'שגיאה בהורדה המלאה', + message: e is LibraryUpdateDiskSpaceException + ? LibraryMessages.updateDiskSpaceError + : 'שגיאה בהורדה המלאה', // fallback אחרי דלתא חלקית: ההורדה המלאה נכשלה, אבל הצעדים שכבר // נכתבו ל-DB עדיין דורשים ריענון ספרייה ואינדקס. hasUpdate: dbReplaced || state.hasUpdate, @@ -407,6 +445,28 @@ class LibraryUpdateBloc extends Bloc { } } + Future _onConfirmHeavyDelta( + ConfirmHeavyDelta event, + Emitter emit, + ) async { + if (state.isBusy) return; + final plan = state.plan; + if (plan == null || plan.kind != LibraryUpdatePlanKind.delta) { + emit(const LibraryUpdateState()); + return; + } + final opId = ++_operationId; + _heavyDeltaNotice = plan.isHeavyDelta; + _resetProgressThrottle(); + emit( + state.copyWith( + status: LibraryUpdateStatus.downloading, + message: 'מוריד עדכון ספרייה', + ), + ); + await _runDelta(plan, emit, opId); + } + /// מוודא שהקבצים הנלווים (תלמוד, קטלוגים, מילון) קיימים ומעודכנים, בסוף /// כל בדיקת/החלת עדכון. best-effort — כשל לא הופך את העדכון לשגיאה. /// מחזיר האם תוכן הספרייה השתנה (ראה [CompanionAssetsService.verifyAndUpdate]). @@ -512,7 +572,9 @@ class LibraryUpdateBloc extends Bloc { case LibraryUpdateStatus.refreshing: return false; case LibraryUpdateStatus.applying: - return state.plan?.kind == LibraryUpdatePlanKind.fullDownload; + // בדלתא, applying לפני הכתיבה הוא אימות ה-patch הפרוס — עדיין ניתן לבטל. + return state.plan?.kind == LibraryUpdatePlanKind.fullDownload || + !_deltaWriteStarted; default: return true; } @@ -523,6 +585,7 @@ class LibraryUpdateBloc extends Bloc { if (_fullDownloadDbReplaced && _pendingCompleted == null) return; _operationId++; _pendingCompleted = null; + _heavyDeltaNotice = false; emit(const LibraryUpdateState()); } @@ -542,7 +605,12 @@ class LibraryUpdateBloc extends Bloc { 'מוריד עדכון ספרייה' '${p.totalSteps > 1 ? ' (${p.stepIndex + 1}/${p.totalSteps})' : ''}', LibraryUpdatePhase.verifying => 'מאמת קובץ עדכון', - LibraryUpdatePhase.applying => _applyStageMessage(p.stage), + LibraryUpdatePhase.applying => + _heavyDeltaNotice + ? LibraryMessages.applyStageWithHeavyDeltaNotice( + _applyStageMessage(p.stage), + ) + : _applyStageMessage(p.stage), LibraryUpdatePhase.refreshing => 'מרענן ספרייה', LibraryUpdatePhase.done => 'מסיים', }; diff --git a/lib/library_update/bloc/library_update_event.dart b/lib/library_update/bloc/library_update_event.dart index 6653d88ef..40f7fb6ad 100644 --- a/lib/library_update/bloc/library_update_event.dart +++ b/lib/library_update/bloc/library_update_event.dart @@ -17,6 +17,11 @@ class ConfirmFullDownload extends LibraryUpdateEvent { const ConfirmFullDownload(); } +/// המשתמש בחר במסלול הדלתא למרות שהחלתו צפויה להימשך זמן רב. +class ConfirmHeavyDelta extends LibraryUpdateEvent { + const ConfirmHeavyDelta(); +} + /// המשתמש בחר לדחות הורדה מלאה ולהישאר עם הגרסה הנוכחית. class DeclineFullDownload extends LibraryUpdateEvent { const DeclineFullDownload(); diff --git a/lib/library_update/bloc/library_update_state.dart b/lib/library_update/bloc/library_update_state.dart index cca2a37eb..2e0a46932 100644 --- a/lib/library_update/bloc/library_update_state.dart +++ b/lib/library_update/bloc/library_update_state.dart @@ -22,6 +22,9 @@ enum LibraryUpdateStatus { /// נדרש אישור משתמש להורדה מלאה גדולה. needsFullConfirmation, + /// מסלול הדלתא זמין אך החלתו ארוכה מאוד — המשתמש בוחר בינו לבין הורדה מלאה. + needsRouteChoice, + /// מצב חסום שדורש פעולה ידנית. blocked, @@ -48,7 +51,8 @@ class LibraryUpdateState extends Equatable { /// יחס התקדמות (0..1) בתוך שלב אימות ה-hash; null בשאר שלבי ה-apply. final double? applyProgress; - /// התוכנית שנבחרה — זמינה במצב [LibraryUpdateStatus.needsFullConfirmation]. + /// התוכנית שנבחרה — זמינה במצבי [LibraryUpdateStatus.needsFullConfirmation] + /// ו-[LibraryUpdateStatus.needsRouteChoice]. final LibraryUpdatePlan? plan; /// מזהי ספרים (seforim.db) שתוכנם השתנה בעדכון דלתא — לרענון אינדקס החיפוש. diff --git a/lib/library_update/library_update_work_status.dart b/lib/library_update/library_update_work_status.dart index 874c0bbfe..f665eff14 100644 --- a/lib/library_update/library_update_work_status.dart +++ b/lib/library_update/library_update_work_status.dart @@ -1,3 +1,4 @@ +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/foundation.dart'; import 'package:otzaria/library_update/bloc/library_update_bloc.dart'; import 'package:otzaria/work_status/work_status_item.dart'; @@ -17,7 +18,32 @@ const kCheckFailureAutoDismiss = Duration(seconds: 8); WorkStatusItem? libraryUpdateWorkStatusItem( LibraryUpdateState state, { required VoidCallback onRetry, + required VoidCallback onChooseDelta, + required VoidCallback onChooseFullDownload, }) { + // שתי אפשרויות שקולות — אין המלצה: הבחירה תלויה במהירות הרשת של המשתמש. + if (state.status == LibraryUpdateStatus.needsRouteChoice) { + return WorkStatusItem( + id: kLibraryUpdateWorkStatusId, + title: 'עדכון ספרייה', + message: state.message, + detail: 'בחר כיצד לעדכן', + kind: WorkStatusKind.awaitingInput, + actions: [ + WorkStatusAction( + label: 'עדכון דלתא', + icon: FluentIcons.arrow_download_24_regular, + onPressed: onChooseDelta, + ), + WorkStatusAction( + label: 'הורדה מלאה', + icon: FluentIcons.database_24_regular, + onPressed: onChooseFullDownload, + ), + ], + ); + } + if (state.isBusy && state.status != LibraryUpdateStatus.checking) { return WorkStatusItem( id: kLibraryUpdateWorkStatusId, diff --git a/lib/library_update/repository/library_update_repository.dart b/lib/library_update/repository/library_update_repository.dart index 916a7d3f6..4a8c4358b 100644 --- a/lib/library_update/repository/library_update_repository.dart +++ b/lib/library_update/repository/library_update_repository.dart @@ -15,6 +15,7 @@ import 'package:otzaria/data/sqlite/sqlite3_api.dart' as sqlite3; import 'package:seforim_library_updater/seforim_library_updater.dart'; import '../services/library_runtime_refresh_service.dart'; +import '../services/streaming_patch_downloader.dart'; /// שלבי תהליך העדכון — לתצוגת הודעות למשתמש. enum LibraryUpdatePhase { @@ -37,7 +38,8 @@ class LibraryUpdateProgress { /// תת-שלב גולמי בתוך ה-apply (מ-`PatchApplier.onStage`), לתצוגה מפורטת. final String? stage; - /// יחס התקדמות (0..1) בתוך שלב אימות ה-hash הארוך; null בשאר שלבי ה-apply. + /// יחס התקדמות (0..1) בתוך שלבי ה-apply הארוכים (שורות ב-upserts/deletes, + /// בתים באימות ה-hash); null כשאין מדידה לשלב. final double? applyProgress; const LibraryUpdateProgress({ @@ -61,7 +63,8 @@ typedef FullDbReplacedCallback = void Function(); typedef FullDbExtractor = Future Function(String archivePath, String outputPath); -/// אין מספיק מקום פנוי בדיסק להורדה המלאה — נבדק לפני תחילת ההורדה. +/// אין מספיק מקום פנוי בדיסק לעדכון (הורדה מלאה או צעד דלתא) — נבדק לפני +/// תחילת ההורדה. class LibraryUpdateDiskSpaceException implements Exception { final String message; const LibraryUpdateDiskSpaceException(this.message); @@ -188,6 +191,18 @@ class LibraryUpdateRepository implements LibraryUpdateService { fullDbExtractor = fullDbExtractor ?? _defaultFullDbExtractor, diskSpaceProvider = diskSpaceProvider ?? getDiskSpaceInfo; + /// גודל הקובץ, או null כשהוא חסר/ריק — ה-planner מתעלם מגודל לא ידוע. + static int? _fileSizeOrNull(String path) { + try { + final file = File(path); + if (!file.existsSync()) return null; + final size = file.lengthSync(); + return size > 0 ? size : null; + } catch (_) { + return null; + } + } + static Future _defaultFullDbExtractor( String archivePath, String outputPath, @@ -205,9 +220,11 @@ class LibraryUpdateRepository implements LibraryUpdateService { Future checkForUpdate({ required bool allowPrerelease, }) async { - final local = versionReader.read(dbPathProvider()); + final dbPath = dbPathProvider(); + final local = versionReader.read(dbPath); final result = await discovery.discover(allowPrerelease: allowPrerelease); return planner.plan( + localDbSizeBytes: _fileSizeOrNull(dbPath), localVersion: local.dbVersion, localSchemaVersion: local.schemaVersion, hasLocalVersionMeta: local.hasVersionMeta, @@ -252,6 +269,9 @@ class LibraryUpdateRepository implements LibraryUpdateService { var result = const LibraryDeltaApplyResult(); final steps = plan.deltaSteps; + // לפני בדיקת המקום: patch של תוכנית אחרת שנשאר בקאש תופס גיגה-בייטים + // שבלעדיהם הבדיקה תיכשל, והניקוי שבסוף לא היה מגיע לעולם. + _deleteStalePatchFiles(cacheDir, steps); try { for (var i = 0; i < steps.length; i++) { final step = steps[i]; @@ -261,6 +281,34 @@ class LibraryUpdateRepository implements LibraryUpdateService { throw StateError('חסר URL להורדת ${patchFile.file}'); } + final reusablePatchPath = downloader is StreamingPatchDownloader + ? await (downloader as StreamingPatchDownloader) + .findReusableExtracted( + patchFile: patchFile, + destDir: cacheDir, + isCancelled: isCancelled, + onVerifyProgress: (done, total) => onProgress?.call( + LibraryUpdateProgress( + phase: LibraryUpdatePhase.verifying, + stepIndex: i, + totalSteps: steps.length, + applyProgress: total > 0 + ? (done / total).clamp(0.0, 1.0) + : null, + ), + ), + ) + : null; + + // נבדק לכל צעד בנפרד: ה-patch נמחק בסיום, ולכן שיא הצרכן + // הוא צעד בודד. + await _ensureDiskSpaceForDeltaStep( + cacheDir: cacheDir, + patchFile: patchFile, + dbDir: p.dirname(dbPath), + reusableExtracted: reusablePatchPath != null, + ); + onProgress?.call( LibraryUpdateProgress( phase: LibraryUpdatePhase.downloading, @@ -268,21 +316,34 @@ class LibraryUpdateRepository implements LibraryUpdateService { totalSteps: steps.length, ), ); - final patchPath = await downloader.downloadAndExtract( - patchFile: patchFile, - downloadUrl: url, - destDir: cacheDir, - isCancelled: isCancelled, - onProgress: (downloaded, total) => onProgress?.call( - LibraryUpdateProgress( - phase: LibraryUpdatePhase.downloading, - stepIndex: i, - totalSteps: steps.length, - bytesDownloaded: downloaded, - bytesTotal: total, - ), - ), - ); + final patchPath = + reusablePatchPath ?? + await downloader.downloadAndExtract( + patchFile: patchFile, + downloadUrl: url, + destDir: cacheDir, + isCancelled: isCancelled, + onProgress: (downloaded, total) => onProgress?.call( + LibraryUpdateProgress( + phase: LibraryUpdatePhase.downloading, + stepIndex: i, + totalSteps: steps.length, + bytesDownloaded: downloaded, + bytesTotal: total, + ), + ), + // אימות patch פרוס של כמה GB נמשך עשרות שניות — בלי מד הוא נראה קפוא. + onVerifyProgress: (done, total) => onProgress?.call( + LibraryUpdateProgress( + phase: LibraryUpdatePhase.verifying, + stepIndex: i, + totalSteps: steps.length, + applyProgress: total > 0 + ? (done / total).clamp(0.0, 1.0) + : null, + ), + ), + ); try { // ביטול בדיוק אחרי החילוץ ולפני ההחלה — עוצרים לפני שנוגעים ב-DB. @@ -294,18 +355,35 @@ class LibraryUpdateRepository implements LibraryUpdateService { totalSteps: steps.length, ), ); + // מד השורות מדווח בלי שם שלב; ה-onStage האחרון הוא השלב שבו הוא נמדד + // ('upserts' או 'deletes'), וה-BLoC גוזר ממנו את ההודעה. + String? currentStage; final stepResult = await _applyStepInQueue( dbPath: dbPath, patchPath: patchPath, step: step, verifyTotalBytesHint: verifyTotalHint, verifyTableBytesHint: verifyTableBytes, - onStage: (stage) => onProgress?.call( + onStage: (stage) { + currentStage = stage; + onProgress?.call( + LibraryUpdateProgress( + phase: LibraryUpdatePhase.applying, + stepIndex: i, + totalSteps: steps.length, + stage: stage, + ), + ); + }, + onApplyProgress: (rowsDone, rowsTotal) => onProgress?.call( LibraryUpdateProgress( phase: LibraryUpdatePhase.applying, stepIndex: i, totalSteps: steps.length, - stage: stage, + stage: currentStage, + applyProgress: rowsTotal > 0 + ? (rowsDone / rowsTotal).clamp(0.0, 1.0) + : null, ), ), onVerifyProgress: (done, total) { @@ -467,6 +545,93 @@ class LibraryUpdateRepository implements LibraryUpdateService { } } + /// מוחק patch-ים מחולצים של תוכניות אחרות שנשארו בקאש מריצה שנקטעה; + /// הקבצים של התוכנית הנוכחית נשמרים לשימוש חוזר. + void _deleteStalePatchFiles(Directory cacheDir, List steps) { + final planFiles = { + for (final step in steps) + for (final patch in step.manifest.patchFiles) + extractedPatchFileName(patch.file), + }; + try { + if (!cacheDir.existsSync()) return; + for (final entity in cacheDir.listSync()) { + if (entity is! File) continue; + final name = p.basename(entity.path); + if (!name.startsWith('patch-') || !name.endsWith('.db')) continue; + if (planFiles.contains(name)) continue; + _deleteQuietly(entity.path); + } + } catch (_) {} + } + + /// זורק [LibraryUpdateDiskSpaceException] אם אין מקום ל-patch של הצעד: + /// בקאש — הדחוס והמחולץ; ליד ה-DB — ה-WAL של טרנזקציית ההחלה היחידה. + Future _ensureDiskSpaceForDeltaStep({ + required Directory cacheDir, + required PatchFileEntry patchFile, + required String dbDir, + required bool reusableExtracted, + }) async { + if (!cacheDir.existsSync()) cacheDir.createSync(recursive: true); + int cacheNeeded = 0; + if (!reusableExtracted) { + final partial = File(p.join(cacheDir.path, patchFile.file)); + final resumed = _resumablePatchBytes(partial, patchFile); + cacheNeeded = + (patchFile.size - resumed).clamp(0, patchFile.size) + + patchFile.uncompressedSize; + } + // כל דף שה-patch נוגע בו נכתב ל-WAL פעם אחת — נפח ה-patch הוא האומדן. + final walNeeded = patchFile.uncompressedSize; + + final cacheInfo = await diskSpaceProvider(cacheDir.path); + final dbInfo = await diskSpaceProvider(dbDir); + String gb(int bytes) => (bytes / (1 << 30)).toStringAsFixed(1); + + final sameVolume = + cacheInfo.volumeId != null && cacheInfo.volumeId == dbInfo.volumeId; + if (sameVolume) { + final needed = cacheNeeded + walNeeded; + if (cacheInfo.freeBytes >= 0 && cacheInfo.freeBytes < needed) { + throw LibraryUpdateDiskSpaceException( + 'אין מספיק מקום פנוי בכונן: נדרש ~${gb(needed)}GB להורדת עדכון ' + 'הדלתא ולהחלתו, פנוי ${gb(cacheInfo.freeBytes)}GB', + ); + } + return; + } + if (cacheInfo.freeBytes >= 0 && cacheInfo.freeBytes < cacheNeeded) { + throw LibraryUpdateDiskSpaceException( + 'אין מספיק מקום פנוי להורדת עדכון הדלתא: נדרש ~${gb(cacheNeeded)}GB, ' + 'פנוי ${gb(cacheInfo.freeBytes)}GB', + ); + } + if (dbInfo.freeBytes >= 0 && dbInfo.freeBytes < walNeeded) { + throw LibraryUpdateDiskSpaceException( + 'אין מספיק מקום פנוי להחלת עדכון הדלתא: נדרש ~${gb(walNeeded)}GB ' + 'ליד הספרייה, פנוי ${gb(dbInfo.freeBytes)}GB', + ); + } + } + + int _resumablePatchBytes(File partial, PatchFileEntry patchFile) { + try { + if (!partial.existsSync()) return 0; + final length = partial.lengthSync(); + final sidecar = File(PatchDownloader.resumeSidecarPath(partial.path)); + if (!sidecar.existsSync()) return 0; + final lines = sidecar.readAsStringSync().split('\n'); + if (lines.first != patchFile.sha256) return 0; + if (length == patchFile.size) return length; + if (length <= 0 || length > patchFile.size || lines.length < 2) return 0; + final etag = lines[1].trim(); + return etag.isNotEmpty && !etag.startsWith('W/') ? length : 0; + } catch (_) { + return 0; + } + } + /// מבצע הורדה מלאה: מוריד את `seforim.db.zst`, מחלץ בזרימה ליד ה-DB, /// מאמת (quick_check + גרסה), ומחליף אטומית את ה-DB הישן. /// @@ -487,6 +652,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { p.join(await dataRootProvider(), 'library_update_cache'), ); if (!cacheDir.existsSync()) cacheDir.createSync(recursive: true); + _deleteStalePatchFiles(cacheDir, const []); final archivePath = p.join(cacheDir.path, 'seforim.db.zst'); final sidecarPath = PatchDownloader.resumeSidecarPath(archivePath); // מחולץ ליד ה-DB (אותו filesystem) כדי שה-rename יהיה אטומי. @@ -722,6 +888,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { Map? verifyTableBytesHint, void Function(String stage)? onStage, void Function(int done, int total)? onVerifyProgress, + void Function(int rowsDone, int rowsTotal)? onApplyProgress, }) { return DatabaseLibraryProvider.operationQueue.enqueue(() async { // WAL מאפשר לקוראים להמשיך לקרוא את ה-snapshot שלפני העדכון בזמן @@ -751,6 +918,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { verifyTableBytesHint: verifyTableBytesHint, onStage: onStage, onVerifyProgress: onVerifyProgress, + onApplyProgress: onApplyProgress, ); recovery.finishSuccess(dbPath); return booksTouched; @@ -818,14 +986,18 @@ class LibraryUpdateRepository implements LibraryUpdateService { Map? verifyTableBytesHint, void Function(String stage)? onStage, void Function(int done, int total)? onVerifyProgress, + void Function(int rowsDone, int rowsTotal)? onApplyProgress, }) async { final port = ReceivePort(); final sub = port.listen((msg) { - // String=שם תת-שלב (onStage); record=(bytesHashed, total) של האימות. + // String=שם תת-שלב (onStage); (int,int)=בתים באימות ה-hash; + // ('apply',int,int)=שורות שהוחלו ב-upserts/deletes. if (msg is String) { onStage?.call(msg); } else if (msg is (int, int)) { onVerifyProgress?.call(msg.$1, msg.$2); + } else if (msg is (String, int, int) && msg.$1 == _applyProgressTag) { + onApplyProgress?.call(msg.$2, msg.$3); } }); try { @@ -874,6 +1046,8 @@ class LibraryUpdateRepository implements LibraryUpdateService { checkForeignKeys: false, onStage: (stage) => sendPort.send(stage), onVerifyProgress: (done, total) => sendPort.send((done, total)), + onApplyProgress: (rowsDone, rowsTotal) => + sendPort.send((_applyProgressTag, rowsDone, rowsTotal)), ), ); } @@ -927,6 +1101,9 @@ class LibraryUpdateRepository implements LibraryUpdateService { ); } + /// מבדיל את הודעות התקדמות ה-apply מהודעות אימות ה-hash באותו SendPort. + static const String _applyProgressTag = 'apply'; + // static מאותה סיבה כמו [_applyPatchInIsolate] — מונע לכידת `this`. static Future _verifyFullDbInIsolate( String newDbPath, diff --git a/lib/library_update/services/streaming_patch_downloader.dart b/lib/library_update/services/streaming_patch_downloader.dart index 829debbef..d65395ae9 100644 --- a/lib/library_update/services/streaming_patch_downloader.dart +++ b/lib/library_update/services/streaming_patch_downloader.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:isolate'; +import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import 'package:seforim_library_updater/seforim_library_updater.dart'; @@ -11,6 +12,12 @@ import 'package:otzaria/utils/file/zstd_stream_extractor.dart'; typedef StreamingZstdExtractor = Future Function(String archivePath, String outputPath); +/// שם הקובץ המחולץ מארכיון patch — זהה למימוש הבסיסי: הסרת סיומת `.zst`. +String extractedPatchFileName(String archiveFileName) => + archiveFileName.endsWith('.zst') + ? archiveFileName.substring(0, archiveFileName.length - 4) + : '$archiveFileName.db'; + /// [PatchDownloader] שמוריד ומחלץ קובצי patch **בזרימה לדיסק**, במקום דרך /// הזיכרון. /// @@ -35,22 +42,61 @@ class StreamingPatchDownloader extends PatchDownloader { final StreamingZstdExtractor _extractor; + /// מחזיר נתיב של patch מחולץ רק לאחר אימות גודל ו-sha256. + Future findReusableExtracted({ + required PatchFileEntry patchFile, + required Directory destDir, + void Function(int bytesDone, int bytesTotal)? onVerifyProgress, + bool Function()? isCancelled, + }) async { + final extractedPath = p.join( + destDir.path, + extractedPatchFileName(patchFile.file), + ); + final reusable = await _reuseExtracted( + patchFile, + extractedPath, + onVerifyProgress: onVerifyProgress, + isCancelled: isCancelled, + ); + if (!reusable) return null; + final compressedPath = p.join(destDir.path, patchFile.file); + _deleteQuietly(compressedPath); + _deleteQuietly(PatchDownloader.resumeSidecarPath(compressedPath)); + return extractedPath; + } + @override Future downloadAndExtract({ required PatchFileEntry patchFile, required String downloadUrl, required Directory destDir, void Function(int downloaded, int? total)? onProgress, + void Function(int bytesDone, int bytesTotal)? onVerifyProgress, bool Function()? isCancelled, }) async { if (!destDir.existsSync()) destDir.createSync(recursive: true); final compressedPath = p.join(destDir.path, patchFile.file); - // שם הקובץ המחולץ — זהה למימוש הבסיסי: הסרת סיומת .zst - final extractedName = patchFile.file.endsWith('.zst') - ? patchFile.file.substring(0, patchFile.file.length - 4) - : '${patchFile.file}.db'; - final extractedPath = p.join(destDir.path, extractedName); + final extractedPath = p.join( + destDir.path, + extractedPatchFileName(patchFile.file), + ); + + // patch מחולץ ומאומת שנשאר מריצה שנקטעה באמצע ה-apply — שימוש חוזר בו + // חוסך הורדה של מאות MB וחילוץ של כמה GB. + final reused = await _reuseExtracted( + patchFile, + extractedPath, + onVerifyProgress: onVerifyProgress, + isCancelled: isCancelled, + ); + if (reused) { + onProgress?.call(patchFile.size, patchFile.size); + _deleteQuietly(compressedPath); + _deleteQuietly(PatchDownloader.resumeSidecarPath(compressedPath)); + return extractedPath; + } try { // ה-sha256 של הדחוס הוא זהות יציבה של הנכס — מאפשר המשך הורדה שנקטעה. @@ -83,7 +129,11 @@ class StreamingPatchDownloader extends PatchDownloader { ); } - final actualHash = await Isolate.run(() => _sha256OfFile(extractedPath)); + final actualHash = await _sha256OfFile( + extractedPath, + onProgress: onVerifyProgress, + isCancelled: isCancelled, + ); if (actualHash != patchFile.uncompressedSha256.toLowerCase()) { throw const PatchDownloadException('sha256 של הקובץ המחולץ אינו תואם'); } @@ -104,6 +154,28 @@ class StreamingPatchDownloader extends PatchDownloader { } } + /// האם [extractedPath] הוא בדיוק ה-patch המצופה. אינו תואם — נמחק. + /// ביטול באמצע האימות משאיר את הקובץ — ייבדק שוב בריצה הבאה. + static Future _reuseExtracted( + PatchFileEntry patchFile, + String extractedPath, { + void Function(int bytesDone, int bytesTotal)? onVerifyProgress, + bool Function()? isCancelled, + }) async { + final extracted = File(extractedPath); + if (!extracted.existsSync()) return false; + if (extracted.lengthSync() == patchFile.uncompressedSize) { + final hash = await _sha256OfFile( + extractedPath, + onProgress: onVerifyProgress, + isCancelled: isCancelled, + ); + if (hash == patchFile.uncompressedSha256.toLowerCase()) return true; + } + _deleteQuietly(extractedPath); + return false; + } + static void _deleteQuietly(String path) { try { final file = File(path); @@ -112,8 +184,71 @@ class StreamingPatchDownloader extends PatchDownloader { } } +/// כל כמה בייטים ה-isolate מדווח התקדמות ובודק אם התבקש ביטול. +const int _kVerifyReportEvery = 8 << 20; + /// sha256 של קובץ בזרימה — רץ ב-isolate כדי לא לחסום את ה-UI על קבצים גדולים. -Future _sha256OfFile(String path) async { - final digest = await sha256.bind(File(path).openRead()).first; - return digest.toString(); +/// [onProgress] מקבל (בייטים שנקראו, גודל הקובץ); ביטול זורק +/// [PatchDownloadCancelled] בתוך שניות גם על קובץ של כמה GB. +Future _sha256OfFile( + String path, { + void Function(int bytesDone, int bytesTotal)? onProgress, + bool Function()? isCancelled, +}) async { + final total = File(path).lengthSync(); + onProgress?.call(0, total); + final progressPort = ReceivePort(); + SendPort? cancelPort; + final sub = progressPort.listen((msg) { + if (msg is SendPort) { + cancelPort = msg; + return; + } + final done = msg as int; + onProgress?.call(done, total); + if (isCancelled != null && isCancelled()) cancelPort?.send(null); + }); + // הסגור נשלח ל-isolate — מותר לו להחזיק SendPort בלבד, לא את ה-ReceivePort. + final progressSink = progressPort.sendPort; + try { + final hash = await Isolate.run(() => _hashWorker(path, progressSink)); + // קובץ קטן מסתיים לפני דיווח הביניים הראשון — הביטול נבדק גם בסיום. + if (hash == null || (isCancelled != null && isCancelled())) { + throw const PatchDownloadCancelled(); + } + onProgress?.call(total, total); + return hash; + } finally { + await sub.cancel(); + progressPort.close(); + } +} + +/// גוף ה-isolate: מחשב sha256, מדווח כל [_kVerifyReportEvery] בייטים, ועוצר +/// (מחזיר null) כשמגיעה הודעת ביטול על ה-port שהוא שולח בתחילה. +Future _hashWorker(String path, SendPort progress) async { + final cancelPort = ReceivePort(); + var cancelled = false; + cancelPort.listen((_) => cancelled = true); + progress.send(cancelPort.sendPort); + try { + final digestSink = AccumulatorSink(); + final input = sha256.startChunkedConversion(digestSink); + var done = 0; + var sinceReport = 0; + await for (final chunk in File(path).openRead()) { + if (cancelled) return null; + input.add(chunk); + done += chunk.length; + sinceReport += chunk.length; + if (sinceReport >= _kVerifyReportEvery) { + sinceReport = 0; + progress.send(done); + } + } + input.close(); + return digestSink.events.single.toString(); + } finally { + cancelPort.close(); + } } diff --git a/lib/navigation/view/main_window_screen.dart b/lib/navigation/view/main_window_screen.dart index da70285f2..84e93d583 100644 --- a/lib/navigation/view/main_window_screen.dart +++ b/lib/navigation/view/main_window_screen.dart @@ -2728,6 +2728,13 @@ class MainWindowScreenState extends State onRetry: () => context.read().add( const StartLibraryUpdate(), ), + onChooseDelta: () => context.read().add( + const ConfirmHeavyDelta(), + ), + onChooseFullDownload: () => + context.read().add( + const ConfirmFullDownload(), + ), ); if (item == null) { cubit.remove(kLibraryUpdateWorkStatusId); diff --git a/lib/work_status/work_status_item.dart b/lib/work_status/work_status_item.dart index 69b425a2e..d680bb3c8 100644 --- a/lib/work_status/work_status_item.dart +++ b/lib/work_status/work_status_item.dart @@ -1,7 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -enum WorkStatusKind { running, failed, cancelled } +/// [awaitingInput] — אין עבודה רצה, הפריט מחכה להחלטת המשתמש (בלי טבעת התקדמות). +enum WorkStatusKind { running, failed, cancelled, awaitingInput } /// לחצן פעולה בשורת הפעולות של פריט חיווי; [emphasized] מציג אותו כלחוץ /// (tonal) — למצב פעיל של פעולת toggle. diff --git a/lib/work_status/work_status_overlay.dart b/lib/work_status/work_status_overlay.dart index ff7aa1225..0fb420bf6 100644 --- a/lib/work_status/work_status_overlay.dart +++ b/lib/work_status/work_status_overlay.dart @@ -133,6 +133,12 @@ class _PrimaryItemRow extends StatelessWidget { size: 44, color: colorScheme.error, ) + : item.kind == WorkStatusKind.awaitingInput + ? Icon( + FluentIcons.question_circle_24_regular, + size: 44, + color: colorScheme.primary, + ) : Stack( alignment: Alignment.center, children: [ @@ -253,6 +259,12 @@ class _SecondaryItemRow extends StatelessWidget { size: 18, color: colorScheme.error, ) + : item.kind == WorkStatusKind.awaitingInput + ? Icon( + FluentIcons.question_circle_24_regular, + size: 18, + color: colorScheme.primary, + ) : CircularProgressIndicator( value: progress, strokeWidth: 3, diff --git a/pubspec.yaml b/pubspec.yaml index cce749718..cd85c9ccb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,7 +84,7 @@ dependencies: seforim_library_updater: git: url: https://github.com/Otzaria/otzaria_library_updater - ref: main + ref: 30bf3ff8d6d278f8ba782161e231d219217a3f0a window_manager: ^0.5.2 screen_retriever: ^0.2.2 flutter_spinbox: ^0.13.1 diff --git a/test/library_update/library_update_bloc_test.dart b/test/library_update/library_update_bloc_test.dart index 7e44d56a8..f9aafb921 100644 --- a/test/library_update/library_update_bloc_test.dart +++ b/test/library_update/library_update_bloc_test.dart @@ -61,6 +61,24 @@ class _FakeService implements LibraryUpdateService { } } +/// הורדה מלאה שנעצרת על חוסר מקום — לבדיקת מיפוי השגיאה ב-BLoC. +class _DiskFullOnFullDownloadService extends _FakeService { + _DiskFullOnFullDownloadService(super.plan); + + @override + Future applyFullDownload( + LibraryUpdatePlan plan, { + LibraryUpdateProgressCallback? onProgress, + FullDbReplacedCallback? onDbReplaced, + bool Function()? isCancelled, + }) async { + fullCalled = true; + throw const LibraryUpdateDiskSpaceException( + 'אין מספיק מקום פנוי בכונן: נדרש ~7.5GB, פנוי 2.0GB', + ); + } +} + class _FullVerifyingService extends _FakeService { _FullVerifyingService(super.plan); @@ -270,6 +288,37 @@ class _VerifyThenCommitService implements LibraryUpdateService { }) async {} } +/// מדווח התקדמות שורות בתוך שלב ה-upserts — מד ההחלה של issue #1211. +class _UpsertProgressService extends _FakeService { + _UpsertProgressService(super.plan); + + @override + Future applyDeltaPlan( + LibraryUpdatePlan plan, { + LibraryUpdateProgressCallback? onProgress, + bool Function()? isCancelled, + }) async { + applyCalled = true; + onProgress?.call( + const LibraryUpdateProgress( + phase: LibraryUpdatePhase.applying, + stage: 'upserts', + ), + ); + for (final fraction in [0.25, 1.0]) { + onProgress?.call( + LibraryUpdateProgress( + phase: LibraryUpdatePhase.applying, + stage: 'upserts', + applyProgress: fraction, + ), + ); + } + await Future.delayed(const Duration(milliseconds: 10)); + return const LibraryDeltaApplyResult(appliedSteps: 1); + } +} + /// שירות שבו applyDeltaPlan נחסם עד שמשחררים את ה-gate — לבדיקת race של ביטול. class _GatedService implements LibraryUpdateService { final LibraryUpdatePlan plan; @@ -428,6 +477,24 @@ void main() { ), fullDbReleaseTag: 'v3', ); + final heavyDeltaWithFullPlan = LibraryUpdatePlan.delta( + localVersion: 23, + targetVersion: 27, + steps: const [], + fullDbAsset: const ReleaseAsset( + name: 'seforim.db.zst', + downloadUrl: 'https://x', + size: 1500000000, + ), + fullDbReleaseTag: 'v27', + heavyDeltaReason: 'מסלול הדלתא פורס 3.0GB לעומת DB מקומי בגודל 5.5GB', + ); + final heavyDeltaWithoutFullPlan = LibraryUpdatePlan.delta( + localVersion: 23, + targetVersion: 27, + steps: const [], + heavyDeltaReason: 'מסלול הדלתא פורס 3.0GB לעומת DB מקומי בגודל 5.5GB', + ); final blockedPlan = LibraryUpdatePlan.blocked( localVersion: 1, targetVersion: 3, @@ -517,6 +584,144 @@ void main() { ], ); + blocTest( + 'דלתא כבדה עם DB מלא זמין → needsRouteChoice, בלי להריץ apply', + build: () => _bloc(_FakeService(heavyDeltaWithFullPlan)), + act: (b) => b.add(const StartLibraryUpdate()), + verify: (b) => + expect((b.repository as _FakeService).applyCalled, isFalse), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + LibraryUpdateStatus.checking, + ), + isA() + .having( + (s) => s.status, + 'status', + LibraryUpdateStatus.needsRouteChoice, + ) + .having((s) => s.plan, 'plan', heavyDeltaWithFullPlan) + .having((s) => s.message, 'message', contains('עדכון דלתא')) + .having((s) => s.message, 'message', contains('הורדה מלאה')), + ], + ); + + blocTest( + 'ConfirmHeavyDelta → מריץ את מסלול הדלתא שנבחר', + build: () => _bloc(_FakeService(heavyDeltaWithFullPlan)), + seed: () => LibraryUpdateState( + status: LibraryUpdateStatus.needsRouteChoice, + plan: heavyDeltaWithFullPlan, + ), + act: (b) => b.add(const ConfirmHeavyDelta()), + verify: (b) { + final service = b.repository as _FakeService; + expect(service.applyCalled, isTrue); + expect(service.fullCalled, isFalse); + }, + expect: () => [ + isA().having( + (s) => s.status, + 'status', + LibraryUpdateStatus.downloading, + ), + isA() + .having((s) => s.status, 'status', LibraryUpdateStatus.completed) + .having((s) => s.hasUpdate, 'hasUpdate', true), + ], + ); + + blocTest( + 'ConfirmFullDownload מבחירת מסלול → עובר לתוכנית ההורדה המלאה', + build: () => _bloc(_FakeService(heavyDeltaWithFullPlan)), + seed: () => LibraryUpdateState( + status: LibraryUpdateStatus.needsRouteChoice, + plan: heavyDeltaWithFullPlan, + ), + act: (b) => b.add(const ConfirmFullDownload()), + verify: (b) { + final service = b.repository as _FakeService; + expect(service.fullCalled, isTrue); + expect(service.applyCalled, isFalse); + }, + expect: () => [ + isA() + .having((s) => s.status, 'status', LibraryUpdateStatus.downloading) + .having( + (s) => s.plan?.kind, + 'plan.kind', + LibraryUpdatePlanKind.fullDownload, + ), + isA() + .having((s) => s.status, 'status', LibraryUpdateStatus.completed) + .having((s) => s.isFullDownloadPlan, 'isFullDownloadPlan', true), + ], + ); + + blocTest( + 'דלתא כבדה בלי DB מלא → רצה אוטומטית, אין ממה לבחור', + build: () => _bloc(_FakeService(heavyDeltaWithoutFullPlan)), + act: (b) => b.add(const StartLibraryUpdate()), + verify: (b) => expect((b.repository as _FakeService).applyCalled, isTrue), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + LibraryUpdateStatus.checking, + ), + isA() + .having((s) => s.status, 'status', LibraryUpdateStatus.completed) + .having((s) => s.hasUpdate, 'hasUpdate', true), + ], + ); + + test('דלתא כבדה בלי חלופה — הודעת ההחלה נושאת את האזהרה', () async { + final bloc = _bloc(_UpsertProgressService(heavyDeltaWithoutFullPlan)); + final seen = []; + final sub = bloc.stream.listen(seen.add); + + bloc.add(const StartLibraryUpdate()); + await Future.delayed(const Duration(milliseconds: 60)); + + expect( + seen.where( + (s) => + s.status == LibraryUpdateStatus.applying && + s.message == + LibraryMessages.applyStageWithHeavyDeltaNotice( + 'מוסיף ומעדכן רשומות', + ), + ), + isNotEmpty, + ); + await sub.cancel(); + await bloc.close(); + }); + + test('התקדמות שורות ב-upserts מגיעה ל-applyProgress', () async { + // שעון קופץ — ויסות ההתקדמות (200ms) חוסם אירועים באותו שלב. + var tick = DateTime(2026); + final bloc = _bloc( + _UpsertProgressService(deltaPlan), + now: () => tick = tick.add(const Duration(milliseconds: 250)), + ); + final seen = []; + final sub = bloc.stream.listen(seen.add); + + bloc.add(const StartLibraryUpdate()); + await Future.delayed(const Duration(milliseconds: 60)); + + final applying = seen + .where((s) => s.status == LibraryUpdateStatus.applying) + .toList(); + expect(applying.map((s) => s.applyProgress), [null, 0.25, 1.0]); + expect(applying.last.message, 'מוסיף ומעדכן רשומות'); + await sub.cancel(); + await bloc.close(); + }); + blocTest( 'plan fullDownload → needsFullConfirmation עם plan', build: () => _bloc(_FakeService(fullPlan)), @@ -795,6 +1000,31 @@ void main() { expect(bloc.state.status, LibraryUpdateStatus.checking); }); + blocTest( + 'חוסר מקום בהורדה מלאה → אותה שגיאת מקום כמו בדלתא', + build: () => _bloc(_DiskFullOnFullDownloadService(fullPlan)), + seed: () => LibraryUpdateState( + status: LibraryUpdateStatus.needsFullConfirmation, + plan: fullPlan, + ), + act: (b) => b.add(const ConfirmFullDownload()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + LibraryUpdateStatus.downloading, + ), + isA() + .having((s) => s.status, 'status', LibraryUpdateStatus.error) + .having( + (s) => s.message, + 'message', + LibraryMessages.updateDiskSpaceError, + ) + .having((s) => s.errorMessage, 'errorMessage', contains('7.5GB')), + ], + ); + blocTest( 'ConfirmFullDownload → מבצע הורדה מלאה ומסיים עם hasUpdate', build: () => _bloc(_FakeService(fullPlan)), @@ -1449,6 +1679,34 @@ void main() { ], ); + blocTest( + 'חוסר מקום בדלתא → שגיאת מקום, ולא הצעת הורדה מלאה', + build: () => _bloc( + _FakeService( + deltaWithFallbackPlan, + applyError: const LibraryUpdateDiskSpaceException( + 'אין מספיק מקום פנוי בכונן: נדרש ~3.0GB, פנוי 1.0GB', + ), + ), + ), + act: (b) => b.add(const StartLibraryUpdate()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + LibraryUpdateStatus.checking, + ), + isA() + .having((s) => s.status, 'status', LibraryUpdateStatus.error) + .having( + (s) => s.message, + 'message', + LibraryMessages.updateDiskSpaceError, + ) + .having((s) => s.errorMessage, 'errorMessage', contains('3.0GB')), + ], + ); + blocTest( 'כשל hash אחרי apply אינו מוצג בטעות כסטיית DB מקומי', build: () => _bloc( diff --git a/test/library_update/library_update_repository_test.dart b/test/library_update/library_update_repository_test.dart index a40ed4f69..d77a7f9c8 100644 --- a/test/library_update/library_update_repository_test.dart +++ b/test/library_update/library_update_repository_test.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:otzaria/core/app_paths.dart'; @@ -15,6 +16,7 @@ import 'package:otzaria/data/data_providers/database_library_provider.dart'; import 'package:otzaria/data/data_providers/sqlite_data_provider.dart'; import 'package:otzaria/library_update/repository/library_update_repository.dart'; import 'package:otzaria/library_update/services/library_runtime_refresh_service.dart'; +import 'package:otzaria/library_update/services/streaming_patch_downloader.dart'; import 'package:seforim_library_updater/seforim_library_updater.dart'; import 'package:otzaria/settings/engine/settings_repository.dart'; import 'package:otzaria/utils/file/disk_free_space.dart'; @@ -442,6 +444,301 @@ void main() { throwsA(isNot(isA())), ); }); + + test('patch מחולץ ישן נמחק לפני בדיקת המקום', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final cacheDir = Directory(p.join(tmp.path, 'library_update_cache')) + ..createSync(recursive: true); + final stale = File(p.join(cacheDir.path, 'patch-v9-v10.db')) + ..writeAsBytesSync(List.filled(2000, 0)); + final repository = repo( + (_) async => DiskSpaceInfo( + volumeId: 'C:\\', + freeBytes: stale.existsSync() ? oneGb : 100 * oneGb, + ), + dbPath, + ); + + await expectLater( + repository.applyFullDownload(plan()), + throwsA(isNot(isA())), + ); + expect(stale.existsSync(), isFalse); + }); + }); + + group('applyDeltaPlan: בדיקת מקום פנוי לפני הורדת הצעד', () { + const oneGb = 1 << 30; + + LibraryUpdateRepository repo( + Future Function(String dirPath) diskSpaceProvider, + String dbPath, { + PatchDownloader? downloader, + }) { + return LibraryUpdateRepository( + discovery: _unusedDiscovery(), + downloader: + downloader ?? + PatchDownloader( + httpClient: MockClient.streaming( + (request, bodyStream) async => + throw Exception('download-started'), + ), + decompress: (b) async => b, + ), + refreshService: _NoopRefreshService(), + dbPathProvider: () => dbPath, + dataRootProvider: () async => tmp.path, + nowTimestamp: () => '2026-09-07T00:00:00Z', + diskSpaceProvider: diskSpaceProvider, + ); + } + + test('אותו volume בלי מקום לדחוס+מחולץ+WAL — נכשל לפני ההורדה', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final repository = repo( + (_) async => + const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 2 * oneGb), + dbPath, + ); + + await expectLater( + repository.applyDeltaPlan( + _deltaPlan(size: oneGb, uncompressedSize: 2 * oneGb), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('אין מספיק מקום פנוי'), + ), + ), + ); + }); + + test('volumes נפרדים: אין מקום ל-WAL ליד ה-DB', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final repository = repo( + (dirPath) async => dirPath.contains('library_update_cache') + ? const DiskSpaceInfo(volumeId: 'D:\\', freeBytes: 100 * oneGb) + : const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: oneGb), + dbPath, + ); + + await expectLater( + repository.applyDeltaPlan( + _deltaPlan(size: oneGb, uncompressedSize: 2 * oneGb), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('להחלת'), + ), + ), + ); + }); + + test('patch מחולץ קיים אינו נספר כמקום שיידרש להורדה', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final cacheDir = Directory(p.join(tmp.path, 'library_update_cache')) + ..createSync(recursive: true); + final contents = List.filled(2000, 0); + File(p.join(cacheDir.path, 'patch.db')).writeAsBytesSync(contents); + final repository = repo( + (_) async => const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 2500), + dbPath, + downloader: StreamingPatchDownloader( + httpClient: MockClient.streaming( + (request, bodyStream) async => throw Exception('אסור להוריד'), + ), + extractor: (archive, output) async => fail('אסור לחלץ'), + ), + ); + + // בלי השימוש החוזר היו נדרשים 5000 בייטים ובדיקת המקום הייתה חוסמת. + await expectLater( + repository.applyDeltaPlan( + _deltaPlan( + size: 1000, + uncompressedSize: 2000, + uncompressedSha256: sha256.convert(contents).toString(), + ), + ), + throwsA(isNot(isA())), + ); + }); + + test( + 'patch מחולץ בגודל נכון אך hash שגוי אינו עוקף את בדיקת המקום', + () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final cacheDir = Directory(p.join(tmp.path, 'library_update_cache')) + ..createSync(recursive: true); + final extracted = File(p.join(cacheDir.path, 'patch.db')) + ..writeAsBytesSync(List.filled(2000, 0)); + final repository = repo( + (_) async => const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 2500), + dbPath, + downloader: StreamingPatchDownloader( + httpClient: MockClient.streaming( + (request, bodyStream) async => throw Exception('אסור להוריד'), + ), + ), + ); + + await expectLater( + repository.applyDeltaPlan( + _deltaPlan( + size: 1000, + uncompressedSize: 2000, + uncompressedSha256: sha256 + .convert(List.filled(2000, 1)) + .toString(), + ), + ), + throwsA(isA()), + ); + expect(extracted.existsSync(), isFalse); + }, + ); + + test('partial דחוס בלי sidecar אינו מנוכה מדרישת המקום', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final cacheDir = Directory(p.join(tmp.path, 'library_update_cache')) + ..createSync(recursive: true); + File(p.join(cacheDir.path, 'patch.db.zst')).writeAsBytesSync( + List.filled(400, 0), + ); + final repository = repo( + (_) async => const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 4800), + dbPath, + ); + + await expectLater( + repository.applyDeltaPlan( + _deltaPlan(size: 1000, uncompressedSize: 2000), + ), + throwsA(isA()), + ); + }); + + test( + 'partial דחוס עם sidecar מטוקן שגוי אינו מנוכה מדרישת המקום', + () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final cacheDir = Directory(p.join(tmp.path, 'library_update_cache')) + ..createSync(recursive: true); + final partial = File(p.join(cacheDir.path, 'patch.db.zst')) + ..writeAsBytesSync(List.filled(400, 0)); + File(PatchDownloader.resumeSidecarPath(partial.path)).writeAsStringSync( + 'wrong-token\n"etag-v1"', + ); + final repository = repo( + (_) async => const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 4800), + dbPath, + ); + + await expectLater( + repository.applyDeltaPlan( + _deltaPlan(size: 1000, uncompressedSize: 2000), + ), + throwsA(isA()), + ); + }, + ); + + test('partial דחוס בלי ETag חזק אינו מנוכה מדרישת המקום', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final cacheDir = Directory(p.join(tmp.path, 'library_update_cache')) + ..createSync(recursive: true); + final partial = File(p.join(cacheDir.path, 'patch.db.zst')) + ..writeAsBytesSync(List.filled(400, 0)); + File(PatchDownloader.resumeSidecarPath(partial.path)).writeAsStringSync( + 'aa\nW/"weak-etag"', + ); + final repository = repo( + (_) async => const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 4800), + dbPath, + ); + + await expectLater( + repository.applyDeltaPlan( + _deltaPlan(size: 1000, uncompressedSize: 2000), + ), + throwsA(isA()), + ); + }); + + test('partial דחוס עם token ו-ETag חזקים מנוכה מדרישת המקום', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final cacheDir = Directory(p.join(tmp.path, 'library_update_cache')) + ..createSync(recursive: true); + final partial = File(p.join(cacheDir.path, 'patch.db.zst')) + ..writeAsBytesSync(List.filled(400, 0)); + File(PatchDownloader.resumeSidecarPath(partial.path)).writeAsStringSync( + 'aa\n"etag-v1"', + ); + final repository = repo( + (_) async => const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 4800), + dbPath, + ); + + await expectLater( + repository.applyDeltaPlan( + _deltaPlan(size: 1000, uncompressedSize: 2000), + ), + throwsA(isNot(isA())), + ); + }); + + test('patch של תוכנית אחרת נמחק גם כשהריצה נעצרת על חוסר מקום', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final cacheDir = Directory(p.join(tmp.path, 'library_update_cache')) + ..createSync(recursive: true); + final stale = File(p.join(cacheDir.path, 'patch-v9-v10.db')) + ..writeAsBytesSync(List.filled(2000, 0)); + final repository = repo( + (_) async => const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 10), + dbPath, + ); + + // בלי הניקוי המוקדם, השריד היה תופס את המקום שחסר וחוסם כל ניסיון הבא. + await expectLater( + repository.applyDeltaPlan( + _deltaPlan(size: 1000, uncompressedSize: 2000), + ), + throwsA(isA()), + ); + expect(stale.existsSync(), isFalse); + }); + + test('מקום פנוי מספיק — הבדיקה עוברת וההורדה מתחילה', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final repository = repo( + (_) async => + const DiskSpaceInfo(volumeId: 'C:\\', freeBytes: 100 * oneGb), + dbPath, + ); + + await expectLater( + repository.applyDeltaPlan( + _deltaPlan(size: oneGb, uncompressedSize: 2 * oneGb), + ), + throwsA(isNot(isA())), + ); + }); }); test( @@ -556,6 +853,10 @@ void main() { toVersion: 2, sourceName: 'new', ); + // שריד מריצה קודמת שנקטעה — חייב להתנקות בסיום תוכנית מוצלחת. + final stale = File( + p.join(tmp.path, 'library_update_cache', 'patch-v9-v10.db'), + )..createSync(recursive: true); final refresh = _NoopRefreshService(); final repository = LibraryUpdateRepository( discovery: _unusedDiscovery(), @@ -582,10 +883,85 @@ void main() { expect(result.requiresFullIndexRefresh, isTrue); expect(refresh.called, isTrue); expect(const LocalDbVersionReader().read(dbPath).dbVersion, 2); + expect(stale.existsSync(), isFalse); + }, + timeout: const Timeout(Duration(seconds: 30)), + ); + + test( + 'applyDeltaPlan מדווח התקדמות שורות בשלב ה-upserts', + () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeSchema4SourceDb(dbPath, version: 1, sourceName: 'old'); + final expectedPath = p.join(tmp.path, 'expected.db'); + _writeSchema4SourceDb(expectedPath, version: 2, sourceName: 'new'); + final patchPath = p.join(tmp.path, 'patch-1-2.db'); + _writeSourcePatch( + patchPath, + fromVersion: 1, + toVersion: 2, + sourceName: 'new', + ); + final repository = LibraryUpdateRepository( + discovery: _unusedDiscovery(), + downloader: _PatchMapDownloader({'patch-1-2.db': patchPath}), + refreshService: _NoopRefreshService(), + dbPathProvider: () => dbPath, + dataRootProvider: () async => tmp.path, + nowTimestamp: () => '2026-09-01T00:00:00Z', + ); + + final progress = []; + await repository.applyDeltaPlan( + _schema4DeltaPlan([ + _schema4Edge( + fromVersion: 1, + toVersion: 2, + patchName: 'patch-1-2.db', + toHash: _logicalHash(expectedPath), + ), + ]), + onProgress: progress.add, + ); + + final rowProgress = progress + .where( + (e) => + e.phase == LibraryUpdatePhase.applying && + e.stage == 'upserts' && + e.applyProgress != null, + ) + .toList(); + expect( + rowProgress, + isNotEmpty, + reason: 'בלי מד שורות המשתמש רואה ספינר בלתי-מוגדר לאורך כל ההחלה', + ); + expect(rowProgress.every((e) => e.applyProgress! >= 0), isTrue); + expect(rowProgress.last.applyProgress, lessThanOrEqualTo(1.0)); }, timeout: const Timeout(Duration(seconds: 30)), ); + test('checkForUpdate מעביר ל-planner את גודל ה-DB המקומי', () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeDb(dbPath, version: 1, marker: 'old'); + final planner = _RecordingPlanner(); + final repository = LibraryUpdateRepository( + discovery: _unusedDiscovery(), + planner: planner, + downloader: _PatchMapDownloader(const {}), + refreshService: _NoopRefreshService(), + dbPathProvider: () => dbPath, + dataRootProvider: () async => tmp.path, + nowTimestamp: () => '2026-09-01T00:00:00Z', + ); + + await repository.checkForUpdate(allowPrerelease: false); + + expect(planner.seenLocalDbSizeBytes, File(dbPath).lengthSync()); + }); + test( 'כשל בצעד דלתא מאוחר מדווח את הצעדים שכבר נכתבו ומרענן runtime', () async { @@ -1069,6 +1445,35 @@ String _journalMode(String dbPath) { } } +/// לוכד את גודל ה-DB המקומי שהריפוזיטורי מעביר ל-planner. +class _RecordingPlanner extends LibraryUpdatePlanner { + int? seenLocalDbSizeBytes; + + @override + LibraryUpdatePlan plan({ + required int localVersion, + required int? localSchemaVersion, + required bool hasLocalVersionMeta, + required int latestVersion, + required List edges, + ReleaseAsset? latestFullDbAsset, + String? latestReleaseTag, + int? localDbSizeBytes, + }) { + seenLocalDbSizeBytes = localDbSizeBytes; + return super.plan( + localVersion: localVersion, + localSchemaVersion: localSchemaVersion, + hasLocalVersionMeta: hasLocalVersionMeta, + latestVersion: latestVersion, + edges: edges, + latestFullDbAsset: latestFullDbAsset, + latestReleaseTag: latestReleaseTag, + localDbSizeBytes: localDbSizeBytes, + ); + } +} + LibraryUpdateDiscovery _unusedDiscovery() { return LibraryUpdateDiscovery( client: GithubLibraryReleaseClient( @@ -1238,6 +1643,7 @@ class _LocalPatchDownloader extends PatchDownloader { required String downloadUrl, required Directory destDir, void Function(int downloaded, int? total)? onProgress, + void Function(int bytesDone, int bytesTotal)? onVerifyProgress, bool Function()? isCancelled, }) async => patchPath; } @@ -1253,6 +1659,7 @@ class _PatchMapDownloader extends PatchDownloader { required String downloadUrl, required Directory destDir, void Function(int downloaded, int? total)? onProgress, + void Function(int bytesDone, int bytesTotal)? onVerifyProgress, bool Function()? isCancelled, }) async => patchPaths[patchFile.file]!; } @@ -1394,7 +1801,12 @@ String? _readSourceName(String dbPath) { } } -LibraryUpdatePlan _deltaPlan() { +LibraryUpdatePlan _deltaPlan({ + String file = 'patch.db.zst', + int size = 1, + int uncompressedSize = 1, + String uncompressedSha256 = 'bb', +}) { final manifest = DeltaManifest.fromJson({ 'fromVersion': 1, 'toVersion': 2, @@ -1404,12 +1816,12 @@ LibraryUpdatePlan _deltaPlan() { 'toContentHash': 'cafef00d', 'patchFiles': [ { - 'file': 'patch.db.zst', + 'file': file, 'compression': 'zstd', 'sha256': 'aa', - 'size': 1, - 'uncompressedSha256': 'bb', - 'uncompressedSize': 1, + 'size': size, + 'uncompressedSha256': uncompressedSha256, + 'uncompressedSize': uncompressedSize, }, ], }); @@ -1419,7 +1831,7 @@ LibraryUpdatePlan _deltaPlan() { steps: [ PatchEdge( manifest: manifest, - patchFileUrls: const {'patch.db.zst': 'https://x/patch.db.zst'}, + patchFileUrls: {file: 'https://x/$file'}, manifestUrl: 'https://x/manifest.json', ), ], diff --git a/test/library_update/library_update_work_status_test.dart b/test/library_update/library_update_work_status_test.dart index 711deb551..5304200aa 100644 --- a/test/library_update/library_update_work_status_test.dart +++ b/test/library_update/library_update_work_status_test.dart @@ -5,8 +5,17 @@ import 'package:otzaria/library_update/library_update_work_status.dart'; import 'package:otzaria/work_status/work_status_item.dart'; void main() { - WorkStatusItem? item(LibraryUpdateState state, {VoidCallback? onRetry}) => - libraryUpdateWorkStatusItem(state, onRetry: onRetry ?? () {}); + WorkStatusItem? item( + LibraryUpdateState state, { + VoidCallback? onRetry, + VoidCallback? onChooseDelta, + VoidCallback? onChooseFullDownload, + }) => libraryUpdateWorkStatusItem( + state, + onRetry: onRetry ?? () {}, + onChooseDelta: onChooseDelta ?? () {}, + onChooseFullDownload: onChooseFullDownload ?? () {}, + ); group('libraryUpdateWorkStatusItem', () { test('מנותק אינו יוצר פריט חיווי כלל — זו הרגרסיה שהתלוננו עליה', () { @@ -167,6 +176,39 @@ void main() { } }); + test('בחירת מסלול מציגה את שתי האפשרויות כשוות ערך', () { + var delta = 0; + var full = 0; + final result = item( + const LibraryUpdateState( + status: LibraryUpdateStatus.needsRouteChoice, + message: 'עדכון דלתא: ... הורדה מלאה: ...', + ), + onChooseDelta: () => delta++, + onChooseFullDownload: () => full++, + )!; + + expect(result.actions, hasLength(2)); + expect(result.actions.map((a) => a.label), ['עדכון דלתא', 'הורדה מלאה']); + expect( + result.kind, + WorkStatusKind.awaitingInput, + reason: 'טבעת 0% נראית כמו עבודה שנתקעה — כאן אין עבודה, יש שאלה', + ); + expect(result.progress, isNull); + expect( + result.actions.every((a) => !a.emphasized), + isTrue, + reason: 'אין המלצה — הבחירה תלויה במהירות הרשת של המשתמש', + ); + expect(result.message, 'עדכון דלתא: ... הורדה מלאה: ...'); + + result.actions[0].onPressed(); + result.actions[1].onPressed(); + expect(delta, 1); + expect(full, 1); + }); + test('התקדמות ההורדה מחושבת מהבתים, ונחתכת לטווח חוקי', () { expect( item( diff --git a/test/library_update/streaming_patch_downloader_test.dart b/test/library_update/streaming_patch_downloader_test.dart index bc7c32fce..6961220f8 100644 --- a/test/library_update/streaming_patch_downloader_test.dart +++ b/test/library_update/streaming_patch_downloader_test.dart @@ -52,6 +52,94 @@ void main() { ); } + // כל הגעה לרשת בבדיקות השימוש-החוזר היא כשל הבדיקה עצמה. + StreamingPatchDownloader buildNoNetwork() { + extractorCalls = 0; + return StreamingPatchDownloader( + httpClient: MockClient.streaming( + (request, bodyStream) async => throw StateError('אסור להוריד'), + ), + extractor: reversingExtractor, + ); + } + + test('מחולץ מאומת שנשאר בקאש → שימוש חוזר בלי הורדה', () async { + final extractedPath = p.join(tmp.path, 'patch-v1-v2.db'); + File(extractedPath).writeAsBytesSync(uncompressed, flush: true); + final leftoverArchive = File(p.join(tmp.path, 'patch-v1-v2.db.zst')) + ..writeAsBytesSync(compressed, flush: true); + final progress = <(int, int?)>[]; + + final path = await buildNoNetwork().downloadAndExtract( + patchFile: entry(), + downloadUrl: 'https://x/patch-v1-v2.db.zst', + destDir: tmp, + onProgress: (d, t) => progress.add((d, t)), + ); + + expect(path, extractedPath); + expect(extractorCalls, 0); + expect(progress.last, (compressed.length, compressed.length)); + expect(leftoverArchive.existsSync(), isFalse); + }); + + test('אימות המחולץ מדווח התקדמות בבייטים, מ-0 ועד גודל הקובץ', () async { + final extractedPath = p.join(tmp.path, 'patch-v1-v2.db'); + File(extractedPath).writeAsBytesSync(uncompressed, flush: true); + final verify = <(int, int)>[]; + + await buildNoNetwork().downloadAndExtract( + patchFile: entry(), + downloadUrl: 'https://x/patch-v1-v2.db.zst', + destDir: tmp, + onVerifyProgress: (d, t) => verify.add((d, t)), + ); + + expect(verify.first, (0, uncompressed.length)); + expect(verify.last, (uncompressed.length, uncompressed.length)); + expect(verify.every((e) => e.$2 == uncompressed.length), isTrue); + }); + + test( + 'ביטול באמצע אימות המחולץ → PatchDownloadCancelled, הקובץ נשאר', + () async { + final extractedPath = p.join(tmp.path, 'patch-v1-v2.db'); + File(extractedPath).writeAsBytesSync(uncompressed, flush: true); + var cancelled = false; + + await expectLater( + buildNoNetwork().downloadAndExtract( + patchFile: entry(), + downloadUrl: 'https://x/patch-v1-v2.db.zst', + destDir: tmp, + onVerifyProgress: (d, t) => cancelled = true, + isCancelled: () => cancelled, + ), + throwsA(isA()), + ); + // הקובץ תקין — נמחק רק כשהאימות נכשל, לא כשהופסק. + expect(File(extractedPath).existsSync(), isTrue); + }, + ); + + test('מחולץ בגודל תואם אך hash שגוי → נמחק ומורידים מחדש', () async { + final extractedPath = p.join(tmp.path, 'patch-v1-v2.db'); + File(extractedPath).writeAsBytesSync( + Uint8List(uncompressed.length), // אותו גודל, תוכן אחר + flush: true, + ); + + final path = await build().downloadAndExtract( + patchFile: entry(), + downloadUrl: 'https://x/patch-v1-v2.db.zst', + destDir: tmp, + ); + + expect(path, extractedPath); + expect(extractorCalls, 1); + expect(File(path).readAsBytesSync(), uncompressed); + }); + test('הורדה לדיסק + חילוץ זורם → .db מאומת, הדחוס נמחק', () async { final path = await build().downloadAndExtract( patchFile: entry(), diff --git a/test/widgets/work_status_overlay_test.dart b/test/widgets/work_status_overlay_test.dart index a193f1319..1d1c310c5 100644 --- a/test/widgets/work_status_overlay_test.dart +++ b/test/widgets/work_status_overlay_test.dart @@ -47,7 +47,9 @@ void main() { cubit.close(); }); - testWidgets('מעגל אחוזים כלפי מטה כדי לא להציג 100% טרם סיום', (tester) async { + testWidgets('מעגל אחוזים כלפי מטה כדי לא להציג 100% טרם סיום', ( + tester, + ) async { final cubit = WorkStatusCubit(); cubit.upsert( const WorkStatusItem( @@ -217,6 +219,33 @@ void main() { cubit.close(); }); + testWidgets('פריט הממתין להחלטה מוצג עם אייקון שאלה ולא עם טבעת', ( + tester, + ) async { + final cubit = WorkStatusCubit(); + cubit.upsert( + const WorkStatusItem( + id: 'library_update', + title: 'עדכון ספרייה', + message: 'עדכון דלתא או הורדה מלאה', + detail: 'בחר כיצד לעדכן', + kind: WorkStatusKind.awaitingInput, + ), + ); + + await tester.pumpWidget(_wrap(const WorkStatusOverlay(), cubit)); + await tester.pump(); + + expect(find.text('בחר כיצד לעדכן'), findsOneWidget); + expect( + find.byIcon(FluentIcons.question_circle_24_regular), + findsOneWidget, + ); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('0%'), findsNothing); + cubit.close(); + }); + testWidgets('לחיצה על פריט כושל מפעילה ניסיון חוזר', (tester) async { final cubit = WorkStatusCubit(); var retried = false;