import 'package:flutter_test/flutter_test.dart'; import 'package:life_helper/data/ai/llm_service.dart'; import 'package:life_helper/domain/ai/frame_candidate.dart'; import 'package:life_helper/domain/ai/suggest_frame.dart'; import 'package:life_helper/domain/models/frame_pattern.dart'; import 'package:life_helper/domain/models/habit.dart'; final _patterns = [ const FramePatternModel( id: 'fp_alcohol', domain: 'drink', avoidanceKeyword: '술 끊기', l0Example: '술 끊기', l2Suggestion: '무알콜', l3Identity: '맑은 정신', ), ]; const _input = SuggestFrameInput( rawText: '술 끊고 싶어', habitType: HabitType.breakHabit, ); void main() { test('happy path: returns up to 3 validated candidates', () async { final llm = MockLlmService(); await llm.load(); llm.enqueueResponse({ 'candidates': [ {'level': 'L2', 'framed_text': '저녁엔 무알콜 음료', 'confidence': 0.9}, {'level': 'L3', 'framed_text': '나는 맑은 정신의 사람이다'}, {'level': 'L2', 'framed_text': '주말엔 운동 우선'}, ], }); final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, ); expect(result, hasLength(3)); expect(result[0].level, FrameLevel.l2); }); test('L0/L1 candidates discarded by validateFrameLevel', () async { final llm = MockLlmService(); await llm.load(); llm.enqueueResponse({ 'candidates': [ {'level': 'L0', 'framed_text': '술 안 마시기'}, {'level': 'L1', 'framed_text': '음주 중단'}, {'level': 'L2', 'framed_text': '무알콜 마시기'}, ], }); final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, ); expect(result, hasLength(1)); expect(result.first.level, FrameLevel.l2); }); test('timeout → empty list (graceful)', () async { final llm = MockLlmService(); await llm.load(); llm.responseDelay = const Duration(milliseconds: 200); llm.enqueueResponse({ 'candidates': [ {'level': 'L2', 'framed_text': '무알콜'}, ], }); final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, timeout: const Duration(milliseconds: 50), ); expect(result, isEmpty); }); test('StateError (not loaded) → empty list (graceful)', () async { final llm = MockLlmService(); // not loaded final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, ); expect(result, isEmpty); }); test('malformed JSON → empty list (graceful)', () async { final llm = MockLlmService(); await llm.load(); llm.enqueueResponse({'foo': 'bar'}); // no candidates key final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, ); expect(result, isEmpty); }); test('empty rawText → llm not called', () async { final llm = MockLlmService(); await llm.load(); final result = await suggestFrame( const SuggestFrameInput( rawText: ' ', habitType: HabitType.breakHabit, ), llm: llm, framePatterns: _patterns, ); expect(result, isEmpty); expect(llm.callCount, 0); }); test('rawText > 200 chars → empty list, llm not called', () async { final llm = MockLlmService(); await llm.load(); final result = await suggestFrame( SuggestFrameInput( rawText: 'a' * 201, habitType: HabitType.breakHabit, ), llm: llm, framePatterns: _patterns, ); expect(result, isEmpty); expect(llm.callCount, 0); }); test('graceful: arbitrary throw is caught', () async { final llm = MockLlmService(); await llm.load(); llm.enqueueError(Exception('boom')); final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, ); expect(result, isEmpty); }); test('AC4: L2-only input shaped to L2 quota (2), no padding with L3', () async { final llm = MockLlmService(); await llm.load(); llm.enqueueResponse({ 'candidates': [ {'level': 'L2', 'framed_text': 'a'}, {'level': 'L2', 'framed_text': 'b'}, {'level': 'L2', 'framed_text': 'c'}, {'level': 'L2', 'framed_text': 'd'}, {'level': 'L2', 'framed_text': 'e'}, ], }); final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, ); expect(result, hasLength(2)); expect(result.every((c) => c.level == FrameLevel.l2), isTrue); }); test('AC4: L3-only input shaped to L3 quota (1)', () async { final llm = MockLlmService(); await llm.load(); llm.enqueueResponse({ 'candidates': [ {'level': 'L3', 'framed_text': '나는 운동인이다'}, {'level': 'L3', 'framed_text': '나는 건강한 사람이다'}, {'level': 'L3', 'framed_text': '나는 일찍 자는 사람이다'}, ], }); final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, ); expect(result, hasLength(1)); expect(result.single.level, FrameLevel.l3); }); test('AC4: mixed L2:3 + L3:2 input shaped to exactly L2:2 + L3:1', () async { final llm = MockLlmService(); await llm.load(); llm.enqueueResponse({ 'candidates': [ {'level': 'L2', 'framed_text': 'L2-A'}, {'level': 'L3', 'framed_text': '나는 A'}, {'level': 'L2', 'framed_text': 'L2-B'}, {'level': 'L3', 'framed_text': '나는 B'}, {'level': 'L2', 'framed_text': 'L2-C'}, ], }); final result = await suggestFrame( _input, llm: llm, framePatterns: _patterns, ); expect(result, hasLength(3)); expect(result.where((c) => c.level == FrameLevel.l2).length, 2); expect(result.where((c) => c.level == FrameLevel.l3).length, 1); // Order preserves original LLM-emitted ordering expect(result.map((c) => c.framedText).toList(), ['L2-A', '나는 A', 'L2-B']); }); }