import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:life_helper/data/ai/llm_service.dart'; import 'package:life_helper/state/ai_providers.dart'; import 'package:life_helper/state/chat_providers.dart'; import 'package:life_helper/state/providers.dart'; import 'package:life_helper/ui/screens/chat_screen.dart'; import '../ai/tools/_tool_test_helpers.dart'; /// Widget E2E for #260 (AC-10). Verifies the full chat → tool call → /// ConfirmDialog → habit DB insert pipeline using a fully wired /// ChatSessionController, with a `MockLlmService` standing in for Gemma. void main() { testWidgets( 'add_habit tool call → ConfirmDialog 수행 → 활성 습관 +1', (tester) async { final ctx = await bootstrapToolDeps(); addTearDown(() => ctx.db.close()); final mock = MockLlmService(); await mock.load(); // Turn 1 (sendUser): LLM 이 add_habit 호출. mock.enqueueChatEvents([ const LlmFunctionCall('add_habit', { 'protocol_id': 'morning_sunlight', 'frame_level': 'L2', 'framed_text': '아침에 햇빛 보기', }), ]); // Turn 2 (sendToolResult): LLM 이 자연어로 마무리. mock.enqueueChatEvents([ const LlmTextChunk('아침 햇빛 습관을 추가했어요.'), ]); await tester.pumpWidget( ProviderScope( overrides: [ appDatabaseProvider.overrideWithValue(ctx.db), llmServiceProvider.overrideWithValue(mock), // bootstrapProvider 는 이미 ctx 에서 SeedImporter 가 끝났으므로 no-op. bootstrapProvider.overrideWith((ref) async {}), // toolDepsProvider 를 미리 resolve 된 형태로 주입. toolDepsProvider.overrideWith((ref) async => ctx.deps), ], child: const MaterialApp(home: ChatScreen()), ), ); await tester.pumpAndSettle(); // 활성 습관 0개에서 시작. var habits = await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId); expect(habits, isEmpty); // 사용자 입력 → 전송. await tester.enterText(find.byType(TextField), '아침 햇빛 추가해줘'); await tester.tap(find.byIcon(Icons.send)); await tester.pump(); // userTurn 시작 await tester.pump(const Duration(milliseconds: 50)); // mock stream // ConfirmDialog 가 떠야 한다. expect(find.text('이 작업을 수행할까요?'), findsOneWidget); expect(find.text('수행'), findsOneWidget); expect(find.text('취소'), findsOneWidget); // 수행 탭. await tester.tap(find.text('수행')); await tester.pumpAndSettle(); // 활성 습관 1개. habits = await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId); expect(habits, hasLength(1)); expect(habits.first.frameFramedText, '아침에 햇빛 보기'); // UI 에 모델 마무리 문구도 보인다. expect(find.text('아침 햇빛 습관을 추가했어요.'), findsOneWidget); }, ); testWidgets( 'ConfirmDialog 취소 → habit DB 변화 없음, ToolCancelled 메시지', (tester) async { final ctx = await bootstrapToolDeps(); addTearDown(() => ctx.db.close()); final mock = MockLlmService(); await mock.load(); mock.enqueueChatEvents([ const LlmFunctionCall('add_habit', { 'protocol_id': 'morning_sunlight', 'frame_level': 'L2', 'framed_text': '아침에 햇빛 보기', }), ]); mock.enqueueChatEvents([ const LlmTextChunk('알겠어요, 추가하지 않았어요.'), ]); await tester.pumpWidget( ProviderScope( overrides: [ appDatabaseProvider.overrideWithValue(ctx.db), llmServiceProvider.overrideWithValue(mock), bootstrapProvider.overrideWith((ref) async {}), toolDepsProvider.overrideWith((ref) async => ctx.deps), ], child: const MaterialApp(home: ChatScreen()), ), ); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), '추가'); await tester.tap(find.byIcon(Icons.send)); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('이 작업을 수행할까요?'), findsOneWidget); await tester.tap(find.text('취소')); await tester.pumpAndSettle(); final habits = await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId); expect(habits, isEmpty); // tool call 메시지 라벨이 '취소됨' 으로 표시. expect(find.textContaining('취소됨'), findsOneWidget); }, ); }