ChatScreen 마운트 시 백그라운드 native init 으로 첫 send 시점에 native load 지연을 안 보이게 한다. 12개 AC + UX-Reviewer 의 6개 권고 모두 코드 반영. 핵심 변경: - `chat_warmup_provider.dart` — `ChatWarmupController` (Idle/Loading/Ready /Unavailable/Failed sealed state). fast path (`llm.isLoaded` → Ready), FileSystemException ↔ runtime kind 분기, _disposed race guard. - `model_lifecycle.dart` — `quickCheck()`: 2.4GB SHA-256 hashing 없이 meta_kv + 파일 존재만 보고 ready 추정 (R4 UX 권고). - `gemma_llm_service.dart` + `llm_service.dart` — `_loadingFuture` 동시 호출 가드. 두 caller 가 동시에 load() 해도 native init 은 1 회만. - `chat_screen.dart` — initState postFrameCallback 에서 warmup.start(). warmup 상태에 따라 hintText / spinner / 실패 banner 분기. AC coverage (12개): - AC1~AC8: ChatWarmupController unit (chat_warmup_test.dart 8 tests). - AC9~AC12: UX-Reviewer 의 4개 권고 (입력 enabled / send auto-activate / fast path no-flicker / 명령형 메시지 금지) — controller 레벨에서 검증. 테스트: 167 passed (1 pre-existing skip). `flutter analyze` clean. Refs #311 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
7.9 KiB
Dart
217 lines
7.9 KiB
Dart
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<Directory> supportDir() async => dir;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> rangeGet(Uri url, int from) =>
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
/// quickCheck 만 사용하는 controller 테스트에서는 download 경로가 필요 없다.
|
|
/// `meta_kv` 를 직접 세팅해 quickCheck 가 ready/missing 등으로 분기되게 만든다.
|
|
Future<ModelLifecycle> _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 = <ChatWarmupState>[];
|
|
controller.addListener(seen.add, fireImmediately: false);
|
|
|
|
await controller.start();
|
|
|
|
expect(seen.first, isA<ChatWarmupLoading>());
|
|
expect(seen.last, isA<ChatWarmupReady>());
|
|
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 = <ChatWarmupState>[];
|
|
controller.addListener(seen.add, fireImmediately: false);
|
|
|
|
await controller.start();
|
|
|
|
expect(seen, hasLength(1));
|
|
expect(seen.single, isA<ChatWarmupReady>());
|
|
// 추가 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<ChatWarmupUnavailable>());
|
|
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<ChatWarmupFailed>());
|
|
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<ChatWarmupFailed>());
|
|
|
|
// 두 번째 시도는 성공시킴.
|
|
mock.loadThrows = null;
|
|
final seen = <ChatWarmupState>[];
|
|
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 = <ChatWarmupState>[];
|
|
controller.addListener(seen.add, fireImmediately: false);
|
|
|
|
final f = controller.start();
|
|
// quickCheck 완료 + Loading 진입까지 진행한 다음 dispose.
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
controller.dispose();
|
|
await f; // throw 하지 않아야 함.
|
|
// dispose 후 load() 완료가 _safeSet(Ready) 를 시도해도 막혀야 한다.
|
|
expect(seen.last, isA<ChatWarmupLoading>());
|
|
expect(
|
|
seen.whereType<ChatWarmupReady>(),
|
|
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<void>.delayed(const Duration(milliseconds: 5));
|
|
final second = controller.start(); // no-op
|
|
|
|
await Future.wait([first, second]);
|
|
expect(mock.loadCount, 1);
|
|
expect(controller.state, isA<ChatWarmupReady>());
|
|
});
|
|
}
|