Files
life-helper/app/test/state/chat_session_controller_test.dart
joungmin b9f5674f51 [03-Developer] #260 round 2: AC-9 + AC-10 보강
QA round 1 환송 노트 (#260 카테고리 63 환송) 의 두 결함 수정.

AC-9 — tool result 2KB 가드 runtime 연결
- chat_providers.dart userTurn: result.toJson() → encodeToolResult 통과
  후 jsonDecode 한 Map 을 LlmChatSession.sendToolResult 로 전달.
- encodeToolResult 가 더 이상 dead code 가 아니다. ADR-0005 / OQ-2
  의 2KB hard cap 이 실 경로에서 적용됨.
- 회귀: chat_session_controller_test.dart 신규 'AC-9 대용량 → cap'
  케이스 — 인위 huge_dump tool 로 _truncated:true + _hint 검증.

AC-10 — widget E2E 신규
- app/test/ui/chat_screen_test.dart 신규 (2 testWidgets):
  1) add_habit tool call → ConfirmDialog '수행' → habits +1 + 모델 마무리.
  2) ConfirmDialog '취소' → habits 무변화 + 'tool 취소됨' 라벨.
- ProviderScope overrides: appDatabaseProvider / llmServiceProvider /
  bootstrapProvider / toolDepsProvider.

회귀
- 신규 3 (cap 1 + widget 2) → 151 → 154 passed (1 skip)
- flutter analyze: clean

Refs #260
2026-06-15 10:54:53 +09:00

230 lines
7.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/ai/tools/tool_definition.dart';
import 'package:life_helper/ai/tools/tool_dispatcher.dart';
import 'package:life_helper/ai/tools/tool_envelope.dart';
import 'package:life_helper/ai/tools/tool_registry.dart';
import 'package:life_helper/data/ai/llm_service.dart';
import 'package:life_helper/state/chat_providers.dart';
import '../ai/tools/_tool_test_helpers.dart';
class _Harness {
final ChatSessionController controller;
final MockLlmService mock;
final dynamic db;
_Harness(this.controller, this.mock, this.db);
}
// ignore: library_private_types_in_public_api
Future<_Harness> makeHarness() async {
final ctx = await bootstrapToolDeps();
final mock = MockLlmService();
await mock.load();
final controller = ChatSessionController(
llm: mock,
dispatcher: ToolDispatcher(registry: ToolRegistry.defaults()),
deps: ctx.deps,
tools: ToolRegistry.defaults().all.toList(),
);
return _Harness(controller, mock, ctx.db);
}
/// Pumps an empty Material harness and returns a live mounted BuildContext
/// for read-only tool dispatch. The context becomes unmounted when the
/// widget is pumped away (used in the destructive-cancel test).
Future<BuildContext> mountContext(WidgetTester tester) async {
late BuildContext captured;
await tester.pumpWidget(MaterialApp(
home: Builder(builder: (ctx) {
captured = ctx;
return const SizedBox.shrink();
}),
));
return captured;
}
void main() {
testWidgets('자연어 응답만 — model 메시지로 종료', (tester) async {
final h = await makeHarness();
addTearDown(() {
h.controller.dispose();
h.db.close();
});
final ctx = await mountContext(tester);
h.mock.enqueueChatEvents([const LlmTextChunk('안녕!')]);
await h.controller.userTurn('hi', ctx);
expect(h.controller.state.isStreaming, false);
expect(h.controller.state.messages.length, 2);
expect(h.controller.state.messages.first, isA<UserChatMessage>());
expect(h.controller.state.messages.last, isA<ModelChatMessage>());
expect(
(h.controller.state.messages.last as ModelChatMessage).text,
'안녕!',
);
});
testWidgets('1 tool call + 응답 — 3 메시지', (tester) async {
final h = await makeHarness();
addTearDown(() {
h.controller.dispose();
h.db.close();
});
final ctx = await mountContext(tester);
h.mock.enqueueChatEvents([
const LlmFunctionCall('search_catalog', {}),
]);
h.mock.enqueueChatEvents([
const LlmTextChunk('카탈로그 결과를 확인했어요.'),
]);
await h.controller.userTurn('카탈로그 보여줘', ctx);
expect(h.controller.state.messages.length, 3);
expect(h.controller.state.messages[1], isA<ToolCallChatMessage>());
expect(
(h.controller.state.messages[1] as ToolCallChatMessage).result,
isA<ToolOk>(),
);
expect(h.controller.state.error, isNull);
});
testWidgets('destructive + unmounted context → ToolCancelled',
(tester) async {
final h = await makeHarness();
addTearDown(() {
h.controller.dispose();
h.db.close();
});
final ctx = await mountContext(tester);
// 컨텍스트를 강제로 unmount.
await tester.pumpWidget(const SizedBox.shrink());
expect(ctx.mounted, false);
h.mock.enqueueChatEvents([
const LlmFunctionCall('add_habit', {
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
}),
]);
h.mock.enqueueChatEvents([const LlmTextChunk('취소했어요.')]);
await h.controller.userTurn('습관 추가', ctx);
final toolMsg = h.controller.state.messages
.whereType<ToolCallChatMessage>()
.single;
expect(toolMsg.result, isA<ToolCancelled>());
});
testWidgets('MAX_TURNS 초과 → error 세팅', (tester) async {
final h = await makeHarness();
addTearDown(() {
h.controller.dispose();
h.db.close();
});
final ctx = await mountContext(tester);
for (var i = 0; i < kChatMaxTurns + 1; i++) {
h.mock.enqueueChatEvents([
const LlmFunctionCall('search_catalog', {}),
]);
}
await h.controller.userTurn('무한루프', ctx);
expect(h.controller.state.error, contains('루프'));
expect(h.controller.state.isStreaming, false);
});
testWidgets('빈 입력 무시', (tester) async {
final h = await makeHarness();
addTearDown(() {
h.controller.dispose();
h.db.close();
});
final ctx = await mountContext(tester);
await h.controller.userTurn(' ', ctx);
expect(h.controller.state.messages, isEmpty);
expect(h.mock.chatStartCount, 0);
});
testWidgets('clear() 가 메시지 초기화', (tester) async {
final h = await makeHarness();
addTearDown(() {
h.controller.dispose();
h.db.close();
});
final ctx = await mountContext(tester);
h.mock.enqueueChatEvents([const LlmTextChunk('hi')]);
await h.controller.userTurn('x', ctx);
expect(h.controller.state.messages, isNotEmpty);
h.controller.clear();
expect(h.controller.state.messages, isEmpty);
expect(h.controller.state.error, isNull);
});
testWidgets('대용량 tool result → 2KB cap 적용 (AC-9)', (tester) async {
// 인위적으로 큰 payload 를 돌려주는 fake tool 로 dispatcher 를 구성.
final hugePayload = {
'items': List.generate(200, (i) => {'id': 'p_$i' * 5, 'text': 'x' * 20}),
};
final hugeTool = ToolDefinition(
name: 'huge_dump',
description: 'test-only huge result',
parametersSchema: const {
'type': 'object',
'properties': {},
'required': [],
},
handler: (_, _) async => ToolOk(hugePayload),
);
final ctx2 = await bootstrapToolDeps();
addTearDown(() => ctx2.db.close());
final mock = MockLlmService();
await mock.load();
final controller = ChatSessionController(
llm: mock,
dispatcher: ToolDispatcher(registry: ToolRegistry([hugeTool])),
deps: ctx2.deps,
tools: [hugeTool],
);
addTearDown(controller.dispose);
final ctx = await mountContext(tester);
mock.enqueueChatEvents([const LlmFunctionCall('huge_dump', {})]);
mock.enqueueChatEvents([const LlmTextChunk('처리 완료.')]);
await controller.userTurn('덤프', ctx);
final submitted = mock.lastChat!.toolResults.first.$2;
expect(submitted['status'], 'ok');
// encodeToolResult 가 cap 적용 → _truncated 마커 + _hint 메시지 포함.
expect((submitted['data'] as Map)['_truncated'], true);
expect((submitted['data'] as Map)['_hint'], contains('query_protocol'));
});
testWidgets('tool result 가 다음 sendToolResult 로 전달', (tester) async {
final h = await makeHarness();
addTearDown(() {
h.controller.dispose();
h.db.close();
});
final ctx = await mountContext(tester);
h.mock.enqueueChatEvents([
const LlmFunctionCall('list_active_habits', {}),
]);
h.mock.enqueueChatEvents([
const LlmTextChunk('현재 습관 0개.'),
]);
await h.controller.userTurn('내 습관 알려줘', ctx);
final chat = h.mock.lastChat!;
expect(chat.userInputs, ['내 습관 알려줘']);
expect(chat.toolResults.length, 1);
expect(chat.toolResults.first.$1, 'list_active_habits');
final submitted = chat.toolResults.first.$2;
expect(submitted['status'], 'ok');
});
}