import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:drift/native.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'; class _FakeStorage implements StorageAdapter { _FakeStorage(this.dir); final Directory dir; Future Function(Uri url, int from)? handler; @override Future supportDir() async => dir; @override Future rangeGet(Uri url, int from) { final h = handler; if (h == null) { throw StateError('no handler'); } return h(url, from); } } http.StreamedResponse _streamed( List bytes, { int status = 200, }) { return http.StreamedResponse( Stream.value(bytes), status, contentLength: bytes.length, ); } void main() { late AppDatabase db; late MetaDao meta; late Directory tmp; late _FakeStorage storage; const url = 'https://example/model.bin'; setUp(() async { db = AppDatabase(NativeDatabase.memory()); meta = MetaDao(db); tmp = await Directory.systemTemp.createTemp('lifecycle_test_'); storage = _FakeStorage(tmp); }); tearDown(() async { await db.close(); if (tmp.existsSync()) await tmp.delete(recursive: true); }); test('checkAvailability missing when opt_in false', () async { final lc = ModelLifecycle( meta: meta, config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'), storage: storage, ); expect(await lc.checkAvailability(), ModelAvailability.missing); }); test('download writes file, sets meta keys, completes with ready', () async { final payload = utf8.encode('hello world fake model'); final expected = sha256.convert(payload).toString(); final lc = ModelLifecycle( meta: meta, config: ModelConfig( url: Uri.parse(url), expectedSha256: expected, filename: 'gemma_test.bin', ), storage: storage, ); storage.handler = (_, from) async => _streamed(payload); await meta.put(AiMetaKeys.optIn, 'true'); final progresses = await lc.download().toList(); expect(progresses.last.state, DownloadState.completed); expect(progresses.last.bytesReceived, payload.length); expect(await meta.find(AiMetaKeys.downloadState), 'completed'); final p = await meta.find(AiMetaKeys.modelPath); expect(p, isNotNull); expect(File(p!).existsSync(), true); expect(await meta.find(AiMetaKeys.modelSha), expected); expect(await lc.checkAvailability(), ModelAvailability.ready); }); test('SHA mismatch deletes file and emits failed', () async { final payload = utf8.encode('payload'); final lc = ModelLifecycle( meta: meta, config: ModelConfig( url: Uri.parse(url), expectedSha256: 'deadbeef', filename: 'gemma_bad.bin', ), storage: storage, ); storage.handler = (_, from) async => _streamed(payload); await meta.put(AiMetaKeys.optIn, 'true'); final progresses = await lc.download().toList(); expect(progresses.last.state, DownloadState.failed); expect(progresses.last.errorMessage, contains('sha')); final pathStr = '${tmp.path}/gemma_bad.bin'; expect(File(pathStr).existsSync(), false); }); test('network error → paused, file preserved for resume', () async { final lc = ModelLifecycle( meta: meta, config: ModelConfig( url: Uri.parse(url), expectedSha256: 'x', filename: 'gemma_net.bin', ), storage: storage, ); storage.handler = (_, from) async => throw const SocketException('down'); await meta.put(AiMetaKeys.optIn, 'true'); final progresses = await lc.download().toList(); expect(progresses.last.state, DownloadState.failed); expect(await meta.find(AiMetaKeys.downloadState), 'paused'); }); test('purge deletes file + clears meta keys (idempotent)', () async { final payload = utf8.encode('xx'); final expected = sha256.convert(payload).toString(); final lc = ModelLifecycle( meta: meta, config: ModelConfig( url: Uri.parse(url), expectedSha256: expected, filename: 'gemma_purge.bin', ), storage: storage, ); storage.handler = (_, from) async => _streamed(payload); await meta.put(AiMetaKeys.optIn, 'true'); await lc.download().toList(); final freed = await lc.purge(); expect(freed, payload.length); expect(await meta.find(AiMetaKeys.modelPath), isNull); expect(await meta.find(AiMetaKeys.modelSha), isNull); expect(await meta.find(AiMetaKeys.downloadState), isNull); // Idempotent — second purge returns 0 without throwing. expect(await lc.purge(), 0); }); test('checkAvailability detects download in progress', () async { final lc = ModelLifecycle( meta: meta, config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'), storage: storage, ); await meta.put(AiMetaKeys.optIn, 'true'); await meta.put(AiMetaKeys.downloadState, 'paused'); expect(await lc.checkAvailability(), ModelAvailability.downloading); }); test('quickCheck ready when meta_kv complete + file exists (no SHA)', () async { const file = 'gemma_quick.bin'; final lc = ModelLifecycle( meta: meta, // 일부러 expectedSha 와 다르게 — quickCheck 는 SHA 비교 X. config: ModelConfig( url: Uri.parse(url), expectedSha256: 'unused_by_quickcheck', filename: file, ), storage: storage, ); await meta.put(AiMetaKeys.optIn, 'true'); final path = '${tmp.path}/$file'; File(path).writeAsStringSync('payload'); await meta.put(AiMetaKeys.modelPath, path); await meta.put(AiMetaKeys.modelSha, 'whatever'); expect(await lc.quickCheck(), ModelAvailability.ready); }); test('quickCheck missing when modelPath not set', () async { final lc = ModelLifecycle( meta: meta, config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'), storage: storage, ); await meta.put(AiMetaKeys.optIn, 'true'); expect(await lc.quickCheck(), ModelAvailability.missing); }); test('quickCheck missing when file deleted from disk', () async { const file = 'gemma_gone.bin'; final lc = ModelLifecycle( meta: meta, config: ModelConfig( url: Uri.parse(url), expectedSha256: 'x', filename: file, ), storage: storage, ); await meta.put(AiMetaKeys.optIn, 'true'); await meta.put(AiMetaKeys.modelPath, '${tmp.path}/$file'); await meta.put(AiMetaKeys.modelSha, 'sha'); // 파일 자체는 만들지 않음. expect(await lc.quickCheck(), ModelAvailability.missing); }); test('quickCheck downloading when state in progress', () async { final lc = ModelLifecycle( meta: meta, config: ModelConfig(url: Uri.parse(url), expectedSha256: 'x'), storage: storage, ); await meta.put(AiMetaKeys.optIn, 'true'); await meta.put(AiMetaKeys.downloadState, 'downloading'); expect(await lc.quickCheck(), ModelAvailability.downloading); }); test('checkAvailability returns corrupt when file SHA mismatches', () async { const file = 'gemma_corrupt.bin'; final lc = ModelLifecycle( meta: meta, config: ModelConfig( url: Uri.parse(url), expectedSha256: 'wrong', filename: file, ), storage: storage, ); await meta.put(AiMetaKeys.optIn, 'true'); final path = '${tmp.path}/$file'; File(path).writeAsStringSync('payload'); await meta.put(AiMetaKeys.modelPath, path); await meta.put(AiMetaKeys.modelSha, 'expected_but_actual_will_differ'); expect(await lc.checkAvailability(), ModelAvailability.corrupt); }); }