// AC2 regression: opt-in toggle triggers ModelDownloadController.start(), // which subscribes to ModelLifecycle.download() and emits DownloadProgress // states. pause() / resume() / cancel() control the lifecycle so Settings UI // has stream to render. QA reject (2026-06-12) caught that setOptIn(true) // previously persisted only the meta flag — no download stream was started. import 'dart:async'; import 'dart:io'; import 'package:drift/native.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:life_helper/data/ai/model_lifecycle.dart'; import 'package:life_helper/data/db/app_database.dart'; import 'package:life_helper/data/db/daos/meta_dao.dart'; import 'package:life_helper/state/ai_providers.dart'; import 'package:life_helper/state/providers.dart'; class _FakeStorage implements StorageAdapter { final Directory tempDir; final Future Function(Uri url, int from) handler; _FakeStorage(this.tempDir, this.handler); @override Future supportDir() async => tempDir; @override Future rangeGet(Uri url, int from) => handler(url, from); } http.StreamedResponse _okResponse(List bytes) { return http.StreamedResponse( Stream.value(bytes), 200, contentLength: bytes.length, ); } http.StreamedResponse _slowResponse(List bytes) { // Drip the bytes one chunk per tick so pause() has time to bite. late StreamController> ctrl; ctrl = StreamController>( onListen: () async { for (final b in bytes) { await Future.delayed(const Duration(milliseconds: 5)); if (ctrl.isClosed) return; ctrl.add([b]); } await ctrl.close(); }, ); return http.StreamedResponse( ctrl.stream, 200, contentLength: bytes.length, ); } void main() { test('AC2: opt-in triggers ModelDownloadController.start() and emits states', () async { final tempDir = await Directory.systemTemp.createTemp('ai-dl-'); addTearDown(() async { if (tempDir.existsSync()) await tempDir.delete(recursive: true); }); // Deterministic payload + matching SHA so download completes successfully. final payload = List.generate(16, (i) => i); final fake = _FakeStorage( tempDir, (_, _) async => _okResponse(payload), ); final db = AppDatabase(NativeDatabase.memory()); addTearDown(db.close); final container = ProviderContainer(overrides: [ appDatabaseProvider.overrideWithValue(db), modelLifecycleProvider.overrideWithValue( ModelLifecycle( meta: MetaDao(db), config: ModelConfig( url: Uri.parse('https://example.invalid/x.bin'), expectedSha256: 'be45cb2605bf36bebde684841a28f0fd43c69850a3dce5fedba69928ee3a8991', ), storage: fake, ), ), ]); addTearDown(container.dispose); // Capture emitted states. final states = []; container.listen( modelDownloadControllerProvider, (_, next) => states.add(next), ); // Act: opt-in via the public controller. This is the AC2 entry point. await container.read(aiSettingsControllerProvider).setOptIn(true); // Wait for stream to drain. await Future.delayed(const Duration(milliseconds: 50)); // Assert: at least one downloading + final completed state seen. expect(states.any((s) => s?.state == DownloadState.downloading), isTrue); expect(states.last?.state, DownloadState.completed); expect(states.last?.bytesReceived, payload.length); }); test('AC2: pause() halts emission, resume() picks up', () async { final tempDir = await Directory.systemTemp.createTemp('ai-dl-'); addTearDown(() async { if (tempDir.existsSync()) await tempDir.delete(recursive: true); }); final payload = List.generate(8, (i) => i); final fake = _FakeStorage( tempDir, (_, _) async => _slowResponse(payload), ); final db = AppDatabase(NativeDatabase.memory()); addTearDown(db.close); final container = ProviderContainer(overrides: [ appDatabaseProvider.overrideWithValue(db), modelLifecycleProvider.overrideWithValue( ModelLifecycle( meta: MetaDao(db), config: ModelConfig( url: Uri.parse('https://example.invalid/x.bin'), // Any sha — pause/resume tested before SHA verification reached. expectedSha256: 'PENDING', ), storage: fake, ), ), ]); addTearDown(container.dispose); final notifier = container.read(modelDownloadControllerProvider.notifier); // Wait deterministically for the first chunk-bearing state. final firstChunk = Completer(); final sub = container.listen( modelDownloadControllerProvider, (_, next) { if (next != null && next.state == DownloadState.downloading && next.bytesReceived > 0 && !firstChunk.isCompleted) { firstChunk.complete(); } }, ); notifier.start(); await firstChunk.future.timeout(const Duration(seconds: 2)); notifier.pause(); sub.close(); final pausedState = container.read(modelDownloadControllerProvider); expect(pausedState?.state, DownloadState.paused); expect(pausedState!.bytesReceived, greaterThan(0)); expect(pausedState.bytesReceived, lessThanOrEqualTo(payload.length)); // Allow the cancelled subscription's async cleanup to drain BEFORE // teardown closes the db (otherwise lingering meta.put races the close). await Future.delayed(const Duration(milliseconds: 150)); }); test('AC2: cancel() clears state to idle (null)', () { final db = AppDatabase(NativeDatabase.memory()); addTearDown(db.close); final tempDirPath = Directory.systemTemp.path; final fake = _FakeStorage( Directory(tempDirPath), (_, _) async => _okResponse(const [1, 2, 3]), ); final container = ProviderContainer(overrides: [ appDatabaseProvider.overrideWithValue(db), modelLifecycleProvider.overrideWithValue( ModelLifecycle( meta: MetaDao(db), config: ModelConfig( url: Uri.parse('https://example.invalid/x.bin'), expectedSha256: 'PENDING', ), storage: fake, ), ), ]); addTearDown(container.dispose); container.read(modelDownloadControllerProvider.notifier).cancel(); expect(container.read(modelDownloadControllerProvider), isNull); }); }