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
230 lines
7.4 KiB
Dart
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');
|
|
});
|
|
}
|