[03-Developer] #260 in-app tool calling (Gemma 4 multi-turn)
ADR-0005 in-process tool runtime — 6 tools (catalog 2 + tracker 2 + habit 2), ToolDispatcher with JSON-schema validation + modal ConfirmGate for destructive ops, multi-turn LlmChatSession abstraction wired to flutter_gemma 0.16.5 (ToolChoice.auto), ChatSessionController with MAX_TURNS=4 safety + 8-turn history hint, ChatScreen entry behind AI opt-in. R3/R7/R8 enforced inside handlers. 41 new tests (envelope, catalog/tracker/habit tools, dispatcher, controller loop) — 151 total passing. Refs #260
This commit is contained in:
48
app/test/ai/tools/_tool_test_helpers.dart
Normal file
48
app/test/ai/tools/_tool_test_helpers.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:life_helper/ai/tools/tool_definition.dart';
|
||||
import 'package:life_helper/core/constants.dart';
|
||||
import 'package:life_helper/core/time.dart';
|
||||
import 'package:life_helper/data/catalog/catalog_repository.dart';
|
||||
import 'package:life_helper/data/db/app_database.dart';
|
||||
import 'package:life_helper/data/db/daos/habit_dao.dart';
|
||||
import 'package:life_helper/data/db/daos/tracker_dao.dart';
|
||||
import 'package:life_helper/data/seed/seed_importer.dart';
|
||||
import 'package:life_helper/domain/models/frame_pattern.dart';
|
||||
|
||||
import '../../data/seed/test_seeds.dart';
|
||||
|
||||
/// Tool tests share a tiny in-memory bootstrap. Returns the assembled
|
||||
/// [ToolDeps] plus the underlying [AppDatabase] so callers can close it
|
||||
/// in tearDown.
|
||||
Future<({AppDatabase db, ToolDeps deps})> bootstrapToolDeps() async {
|
||||
final db = AppDatabase.memory();
|
||||
// default user (seed importer doesn't insert users — bootstrap does).
|
||||
await db.into(db.users).insert(UsersCompanion.insert(
|
||||
id: kLocalDefaultUserId,
|
||||
displayName: const drift.Value('Test'),
|
||||
createdAt: nowKst().toIso8601String(),
|
||||
));
|
||||
await SeedImporter(db, loadAsset: testStubLoader).importIfNeeded();
|
||||
final patterns = await db.select(db.framePatterns).get();
|
||||
final framePatterns = patterns
|
||||
.map((r) => FramePatternModel(
|
||||
id: r.id,
|
||||
domain: r.domain,
|
||||
avoidanceKeyword: r.avoidanceKeyword,
|
||||
l0Example: r.l0Example,
|
||||
l1SimpleReplace: r.l1SimpleReplace,
|
||||
l2Suggestion: r.l2Suggestion,
|
||||
l3Identity: r.l3Identity,
|
||||
))
|
||||
.toList();
|
||||
return (
|
||||
db: db,
|
||||
deps: ToolDeps(
|
||||
habitDao: HabitDao(db),
|
||||
trackerDao: TrackerDao(db),
|
||||
catalog: CatalogRepository(db),
|
||||
framePatterns: framePatterns,
|
||||
userId: kLocalDefaultUserId,
|
||||
),
|
||||
);
|
||||
}
|
||||
82
app/test/ai/tools/catalog_tools_test.dart
Normal file
82
app/test/ai/tools/catalog_tools_test.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/catalog_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('search_catalog', () {
|
||||
test('전체 검색 (인자 없음)', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await searchCatalogTool.handler({}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], greaterThan(0));
|
||||
// stub 3 items: protocol + break + diet
|
||||
expect(data['count'], 3);
|
||||
final items = data['items'] as List;
|
||||
expect(items.first.containsKey('id'), true);
|
||||
expect(items.first.containsKey('summary'), true);
|
||||
});
|
||||
|
||||
test('카테고리 필터', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await searchCatalogTool
|
||||
.handler({'category': 'breakHabit'}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final items = ((r as ToolOk).data['items'] as List);
|
||||
expect(items.length, 1);
|
||||
expect((items.first as Map)['category'], 'breakHabit');
|
||||
});
|
||||
|
||||
test('잘못된 카테고리 → validation 에러', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await searchCatalogTool
|
||||
.handler({'category': 'no_such'}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('limit 범위 검증', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await searchCatalogTool.handler({'limit': 99}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
});
|
||||
|
||||
group('query_protocol', () {
|
||||
test('정상 조회 → kind 분기', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await queryProtocolTool
|
||||
.handler({'id': 'morning_sunlight'}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['kind'], 'protocol');
|
||||
expect(data['what'], isNotNull);
|
||||
});
|
||||
|
||||
test('break 항목 → kind=break', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r =
|
||||
await queryProtocolTool.handler({'id': 'alcohol'}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
expect((r as ToolOk).data['kind'], 'break');
|
||||
});
|
||||
|
||||
test('미존재 id → not_found', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r =
|
||||
await queryProtocolTool.handler({'id': 'no_such'}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
});
|
||||
}
|
||||
130
app/test/ai/tools/habit_tools_test.dart
Normal file
130
app/test/ai/tools/habit_tools_test.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/habit_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
import 'package:life_helper/core/constants.dart';
|
||||
import 'package:life_helper/core/time.dart';
|
||||
import 'package:life_helper/data/db/daos/habit_dao.dart';
|
||||
import 'package:life_helper/domain/models/habit.dart';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('add_habit', () {
|
||||
test('정상 build → ToolOk', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '아침에 햇빛 보기',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['type'], 'build');
|
||||
expect(data['frame_level'], 'L2');
|
||||
expect(data['habit_id'], isNotEmpty);
|
||||
});
|
||||
|
||||
test('정상 break → type=break', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'alcohol',
|
||||
'frame_level': 'L3',
|
||||
'framed_text': '맑은 정신을 즐긴다',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
expect((r as ToolOk).data['type'], 'break');
|
||||
});
|
||||
|
||||
test('L0 프레임 → validation 거부', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L0',
|
||||
'framed_text': '게으름 피우지 마',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('미존재 protocol_id → not_found', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'no_such',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '뭐든',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
|
||||
test('R7 회피 키워드 → r7_avoidance', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
// 시드 framePatterns 에 "술 끊기" avoidance keyword 존재.
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'alcohol',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '술 끊기 해야지',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'r7_avoidance');
|
||||
});
|
||||
|
||||
test('R3 quota (build 3개) 초과', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
// 3개 사전 삽입.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await ctx.deps.habitDao.insertWithVariants(HabitDraft(
|
||||
userId: kLocalDefaultUserId,
|
||||
type: HabitType.build,
|
||||
title: 'pre_$i',
|
||||
protocolId: 'morning_sunlight',
|
||||
frameLevel: FrameLevel.l2,
|
||||
frameFramedText: 'pre$i',
|
||||
startedAt: dateOnly(nowKst()),
|
||||
));
|
||||
}
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '4번째 시도',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'r3_quota');
|
||||
});
|
||||
});
|
||||
|
||||
group('list_active_habits', () {
|
||||
test('0개일 때', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await listActiveHabitsTool.handler({}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], 0);
|
||||
expect(data['build_quota_remaining'], 3);
|
||||
expect(data['break_quota_remaining'], 1);
|
||||
});
|
||||
|
||||
test('add_habit 후 1개', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final r = await listActiveHabitsTool.handler({}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], 1);
|
||||
expect(data['build_count'], 1);
|
||||
expect(data['build_quota_remaining'], 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
114
app/test/ai/tools/tool_dispatcher_test.dart
Normal file
114
app/test/ai/tools/tool_dispatcher_test.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
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 '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('ToolDispatcher', () {
|
||||
test('unknown tool 이름', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'no_such',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'unknown_tool');
|
||||
});
|
||||
|
||||
test('validation: required 없음', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'query_protocol',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('validation: 타입 불일치', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'query_protocol',
|
||||
rawArgs: const {'id': 123},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('destructive + null confirmContext → ToolCancelled', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'add_habit',
|
||||
rawArgs: const {
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolCancelled>());
|
||||
});
|
||||
|
||||
test('read-only normal 경로', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'search_catalog',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolOk>());
|
||||
});
|
||||
|
||||
test('handler 예외 → ToolErr(handler_error)', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final throwing = ToolDefinition(
|
||||
name: 'always_throws',
|
||||
description: 'test',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'required': [],
|
||||
},
|
||||
handler: (_, _) async => throw StateError('boom'),
|
||||
);
|
||||
final dispatcher = ToolDispatcher(
|
||||
registry: ToolRegistry([throwing]),
|
||||
);
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'always_throws',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'handler_error');
|
||||
});
|
||||
});
|
||||
}
|
||||
66
app/test/ai/tools/tool_envelope_test.dart
Normal file
66
app/test/ai/tools/tool_envelope_test.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
|
||||
void main() {
|
||||
group('ToolResult', () {
|
||||
test('ToolOk JSON 형태', () {
|
||||
const r = ToolOk({'a': 1});
|
||||
expect(r.toJson(), {
|
||||
'status': 'ok',
|
||||
'data': {'a': 1}
|
||||
});
|
||||
});
|
||||
|
||||
test('ToolErr JSON 형태', () {
|
||||
const r = ToolErr('validation', '잘못된 인자');
|
||||
expect(r.toJson(), {
|
||||
'status': 'error',
|
||||
'code': 'validation',
|
||||
'reason': '잘못된 인자',
|
||||
});
|
||||
});
|
||||
|
||||
test('ToolCancelled JSON 형태', () {
|
||||
const r = ToolCancelled();
|
||||
expect(r.toJson(), {
|
||||
'status': 'cancelled',
|
||||
'reason': 'user did not confirm',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('encodeToolResult 2KB cap', () {
|
||||
test('payload 작으면 그대로', () {
|
||||
const r = ToolOk({'k': 'v'});
|
||||
final s = encodeToolResult(r);
|
||||
expect(jsonDecode(s), {
|
||||
'status': 'ok',
|
||||
'data': {'k': 'v'}
|
||||
});
|
||||
});
|
||||
|
||||
test('payload 2KB 초과 시 truncation hint 로 대체', () {
|
||||
final big = ToolOk({'blob': 'x' * 5000});
|
||||
final s = encodeToolResult(big);
|
||||
expect(s.length, lessThan(500));
|
||||
final decoded = jsonDecode(s) as Map<String, dynamic>;
|
||||
expect(decoded['status'], 'ok');
|
||||
final data = decoded['data'] as Map<String, dynamic>;
|
||||
expect(data['_truncated'], true);
|
||||
expect(data['_hint'], contains('query_protocol'));
|
||||
});
|
||||
|
||||
test('error/cancelled 는 작아서 그대로', () {
|
||||
expect(
|
||||
encodeToolResult(const ToolErr('e', 'r')).length,
|
||||
lessThan(100),
|
||||
);
|
||||
expect(
|
||||
encodeToolResult(const ToolCancelled()).length,
|
||||
lessThan(100),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
116
app/test/ai/tools/tracker_tools_test.dart
Normal file
116
app/test/ai/tools/tracker_tools_test.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/habit_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tracker_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('log_tracker_entry', () {
|
||||
test('정상 done 기록', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final addRes = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final habitId = (addRes as ToolOk).data['habit_id'] as String;
|
||||
|
||||
final r = await logTrackerEntryTool.handler({
|
||||
'habit_id': habitId,
|
||||
'value': 'done',
|
||||
'date': '2026-06-15',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['habit_id'], habitId);
|
||||
expect(data['value'], 'done');
|
||||
});
|
||||
|
||||
test('value 유효성', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await logTrackerEntryTool.handler({
|
||||
'habit_id': 'whatever',
|
||||
'value': 'maybe',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('미존재 habit_id', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await logTrackerEntryTool.handler({
|
||||
'habit_id': 'hb_no_such',
|
||||
'value': 'done',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
|
||||
test('같은 (habit_id, date) 중복 → duplicate', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final addRes = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final habitId = (addRes as ToolOk).data['habit_id'] as String;
|
||||
await logTrackerEntryTool.handler({
|
||||
'habit_id': habitId,
|
||||
'value': 'done',
|
||||
'date': '2026-06-15',
|
||||
}, ctx.deps);
|
||||
final r2 = await logTrackerEntryTool.handler({
|
||||
'habit_id': habitId,
|
||||
'value': 'blank',
|
||||
'date': '2026-06-15',
|
||||
}, ctx.deps);
|
||||
expect(r2, isA<ToolErr>());
|
||||
expect((r2 as ToolErr).code, 'duplicate');
|
||||
});
|
||||
|
||||
test('date 형식 오류', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await logTrackerEntryTool.handler({
|
||||
'habit_id': 'h',
|
||||
'value': 'done',
|
||||
'date': '2026/06/15',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
});
|
||||
|
||||
group('get_streak', () {
|
||||
test('습관 없음 → not_found', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r =
|
||||
await getStreakTool.handler({'habit_id': 'hb_no'}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
|
||||
test('정상 — 기록 없을 때 0 streak', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final addRes = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final habitId = (addRes as ToolOk).data['habit_id'] as String;
|
||||
final r =
|
||||
await getStreakTool.handler({'habit_id': habitId}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['current_streak'], 0);
|
||||
expect(data['tier'], isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
188
app/test/state/chat_session_controller_test.dart
Normal file
188
app/test/state/chat_session_controller_test.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.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 가 다음 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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user