[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:
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user