import 'dart:async'; import 'package:flutter_gemma/flutter_gemma.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:life_helper/data/ai/gemma_llm_service.dart'; /// Unit tests for `collectFunctionCall` (fn-spec §D, 8 cases). /// /// `GemmaLlmService.load` / `.generateStructured` themselves require the /// flutter_gemma native runtime and are covered by AC-7 (on-device E2E), /// not by host tests. The pure stream-parsing helper is unit-testable in /// isolation because we can feed a synthetic `Stream`. void main() { const fn = 'emit_frame_candidates'; test('1. single FCR with expected name returns args', () async { final stream = Stream.fromIterable([ const FunctionCallResponse( name: fn, args: { 'candidates': [ {'text': 'a', 'level': 'L2'}, {'text': 'b', 'level': 'L2'}, {'text': 'c', 'level': 'L3'}, ], }, ), ]); final args = await collectFunctionCall(stream, fn); expect(args['candidates'], hasLength(3)); }); test('2. TextResponse before FCR is skipped', () async { final stream = Stream.fromIterable([ const TextResponse('hello'), const FunctionCallResponse(name: fn, args: {'candidates': []}), ]); final args = await collectFunctionCall(stream, fn); expect(args['candidates'], isEmpty); }); test('3. ThinkingResponse + TextResponse before empty-args FCR', () async { final stream = Stream.fromIterable([ const ThinkingResponse('reasoning...'), const TextResponse('preamble'), const FunctionCallResponse(name: fn, args: {}), ]); final args = await collectFunctionCall(stream, fn); expect(args, isEmpty); }); test('4. wrong function name throws FormatException', () async { final stream = Stream.fromIterable([ const FunctionCallResponse(name: 'wrong_name', args: {}), ]); expect( () => collectFunctionCall(stream, fn), throwsA( isA().having( (e) => e.message, 'message', contains('wrong_name'), ), ), ); }); test('5. text-only stream throws "no function call emitted"', () async { final stream = Stream.fromIterable([ const TextResponse('only text, no call'), ]); expect( () => collectFunctionCall(stream, fn), throwsA( isA().having( (e) => e.message, 'message', contains('no function call emitted'), ), ), ); }); test('6. stream error throws sanitized FormatException (no leak)', () async { final stream = Stream.error( Exception('SENSITIVE: user_prompt_leaked_in_error'), ); expect( () => collectFunctionCall(stream, fn), throwsA( isA().having( (e) => e.message, 'message', allOf( equals('stream error'), isNot(contains('SENSITIVE')), isNot(contains('user_prompt_leaked_in_error')), ), ), ), ); }); test('7. FCR with empty args map returns empty map (no throw)', () async { final stream = Stream.fromIterable([ const FunctionCallResponse(name: fn, args: {}), ]); final args = await collectFunctionCall(stream, fn); expect(args, isEmpty); }); test('8. empty stream throws "no function call emitted"', () async { final stream = const Stream.empty(); expect( () => collectFunctionCall(stream, fn), throwsA( isA().having( (e) => e.message, 'message', contains('no function call emitted'), ), ), ); }); group('ParallelFunctionCallResponse', () { test('first call with expected name returns its args', () async { final stream = Stream.fromIterable([ ParallelFunctionCallResponse(calls: [ const FunctionCallResponse(name: fn, args: {'x': 1}), const FunctionCallResponse(name: 'other', args: {'y': 2}), ]), ]); final args = await collectFunctionCall(stream, fn); expect(args['x'], 1); }); test('first call with wrong name throws', () async { final stream = Stream.fromIterable([ ParallelFunctionCallResponse(calls: [ const FunctionCallResponse(name: 'wrong_first', args: {}), ]), ]); expect( () => collectFunctionCall(stream, fn), throwsA(isA()), ); }); }); }