import 'dart:io'; 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/llm_service.dart'; 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/chat_warmup_provider.dart'; class _NoopStorage implements StorageAdapter { _NoopStorage(this.dir); final Directory dir; @override Future supportDir() async => dir; @override Future rangeGet(Uri url, int from) => throw UnimplementedError(); } /// quickCheck 만 사용하는 controller 테스트에서는 download 경로가 필요 없다. /// `meta_kv` 를 직접 세팅해 quickCheck 가 ready/missing 등으로 분기되게 만든다. Future _setupLifecycle({ required MetaDao meta, required Directory tmp, required bool readyOnDisk, }) async { final lc = ModelLifecycle( meta: meta, config: ModelConfig( url: Uri.parse('https://example/model.bin'), expectedSha256: 'x', filename: 'warmup_test.bin', ), storage: _NoopStorage(tmp), ); await meta.put(AiMetaKeys.optIn, 'true'); if (readyOnDisk) { final path = '${tmp.path}/warmup_test.bin'; File(path).writeAsStringSync('payload'); await meta.put(AiMetaKeys.modelPath, path); await meta.put(AiMetaKeys.modelSha, 'any'); } return lc; } void main() { late AppDatabase db; late MetaDao meta; late Directory tmp; setUp(() async { db = AppDatabase(NativeDatabase.memory()); meta = MetaDao(db); tmp = await Directory.systemTemp.createTemp('warmup_test_'); }); tearDown(() async { await db.close(); if (tmp.existsSync()) await tmp.delete(recursive: true); }); test('AC1/AC3/AC4: happy path emits Idle → Loading → Ready', () async { final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true); final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 30); final controller = ChatWarmupController(llm: mock, lifecycle: lc); final seen = []; controller.addListener(seen.add, fireImmediately: false); await controller.start(); expect(seen.first, isA()); expect(seen.last, isA()); expect(mock.isLoaded, true); expect(mock.loadCount, 1); }); test('AC11 / UX R4: fast path skips Loading when already loaded', () async { final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true); final mock = MockLlmService(); await mock.load(); // pre-loaded final controller = ChatWarmupController(llm: mock, lifecycle: lc); final seen = []; controller.addListener(seen.add, fireImmediately: false); await controller.start(); expect(seen, hasLength(1)); expect(seen.single, isA()); // 추가 native init 호출 없음 (사전 mock.load() 1 회만 — fast path 가 // _doLoad 를 다시 호출하지 않음을 검증). expect(mock.loadCount, 1); }); test('AC2: quickCheck != ready → Unavailable, load not called', () async { // readyOnDisk: false → meta_kv 의 modelPath 가 없음 → missing. final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: false); final mock = MockLlmService(); final controller = ChatWarmupController(llm: mock, lifecycle: lc); await controller.start(); expect(controller.state, isA()); expect(mock.loadCount, 0); expect(mock.isLoaded, false); }); test('AC5: FileSystemException → Failed(fileMissing)', () async { final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true); final mock = MockLlmService() ..loadThrows = const FileSystemException('model file missing', '/x'); final controller = ChatWarmupController(llm: mock, lifecycle: lc); await controller.start(); expect(controller.state, isA()); final failed = controller.state as ChatWarmupFailed; expect(failed.kind, ChatWarmupFailureKind.fileMissing); expect(failed.message, 'AI 모델 파일을 찾을 수 없어요.'); // AC12: message 에 명령형 문구 금지. expect(failed.message, isNot(contains('다시 시도'))); }); test('AC5: generic error → Failed(runtime)', () async { final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true); final mock = MockLlmService()..loadThrows = StateError('boom'); final controller = ChatWarmupController(llm: mock, lifecycle: lc); await controller.start(); final failed = controller.state as ChatWarmupFailed; expect(failed.kind, ChatWarmupFailureKind.runtime); expect(failed.message, 'AI 를 시작하지 못했어요.'); expect(failed.message, isNot(contains('다시 시도'))); }); test('retry: Failed → retry() → Loading → Ready', () async { final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true); final mock = MockLlmService()..loadThrows = StateError('first fails'); final controller = ChatWarmupController(llm: mock, lifecycle: lc); await controller.start(); expect(controller.state, isA()); // 두 번째 시도는 성공시킴. mock.loadThrows = null; final seen = []; controller.addListener(seen.add, fireImmediately: false); await controller.retry(); expect(seen.map((s) => s.runtimeType).toList(), [ ChatWarmupIdle, ChatWarmupLoading, ChatWarmupReady, ]); expect(mock.loadCount, 2); }); test('AC6: dispose 도중 state 변경 시도 무시 (race 안전)', () async { final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true); final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 50); final controller = ChatWarmupController(llm: mock, lifecycle: lc); // listener 로 마지막 상태 추적 (StateNotifier.state 는 dispose 후 throw). final seen = []; controller.addListener(seen.add, fireImmediately: false); final f = controller.start(); // quickCheck 완료 + Loading 진입까지 진행한 다음 dispose. await Future.delayed(const Duration(milliseconds: 10)); controller.dispose(); await f; // throw 하지 않아야 함. // dispose 후 load() 완료가 _safeSet(Ready) 를 시도해도 막혀야 한다. expect(seen.last, isA()); expect( seen.whereType(), isEmpty, reason: 'dispose 이후 Ready 로 전이되면 안 됨', ); }); test('AC7: concurrent load shares future (loadCount = 1)', () async { final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true); final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 30); // 두 caller (warmup controller + 가상 userTurn) 가 동시에 load. final future1 = mock.load(); final future2 = mock.load(); await Future.wait([future1, future2]); expect(mock.loadCount, 1); expect(mock.isLoaded, true); // lc 는 본 케이스에서는 사용 안 함 (gemma 서비스 가드 검증). expect(lc, isNotNull); }); test('재진입 가드: Loading 상태에서 start 재호출은 no-op', () async { final lc = await _setupLifecycle(meta: meta, tmp: tmp, readyOnDisk: true); final mock = MockLlmService()..loadDelay = const Duration(milliseconds: 50); final controller = ChatWarmupController(llm: mock, lifecycle: lc); final first = controller.start(); // 첫 호출이 Loading 으로 들어간 직후 두 번째 start 호출. await Future.delayed(const Duration(milliseconds: 5)); final second = controller.start(); // no-op await Future.wait([first, second]); expect(mock.loadCount, 1); expect(controller.state, isA()); }); }