main
dart 169 lines 4.95 KB
Raw
1 import 'package:flutter_test/flutter_test.dart';
2 import 'package:apple_mlx/apple_mlx.dart';
3
4 void main() {
5 group('AppleMlxSpeechManager', () {
6 test('initializes with default configuration', () {
7 final manager = AppleMlxSpeechManager();
8 expect(manager.isInitialized, isFalse);
9 expect(manager.config.defaultVoice, equals('alba'));
10 expect(
11 manager.config.sttModelId,
12 equals(AppleMlxModelIds.parakeetTdt06b),
13 );
14 expect(manager.config.ttsModelId, equals(AppleMlxModelIds.pocketTts));
15 });
16
17 test('supports custom model configuration', () {
18 final config = AppleMlxConfig(
19 sttModelId: AppleMlxModelIds.whisperSmall,
20 ttsModelId: AppleMlxModelIds.pocketTts,
21 defaultVoice: 'marius',
22 );
23 final manager = AppleMlxSpeechManager(config: config);
24 expect(manager.config.defaultVoice, equals('marius'));
25 expect(manager.config.sttModelId, equals(AppleMlxModelIds.whisperSmall));
26 });
27 });
28
29 group('AppleMlxLlmManager', () {
30 test('instantiates with uninitialized state', () {
31 final llm = AppleMlxLlmManager();
32 expect(llm.isModelLoaded, isFalse);
33 expect(llm.isServerRunning, isFalse);
34 expect(llm.serverInfo, isNull);
35 expect(llm.modelHandle, isNull);
36 });
37
38 test('exposes popular MLX model identifiers', () {
39 expect(AppleMlxModelIds.gemma4E2b, isNotEmpty);
40 expect(AppleMlxModelIds.gemma22b, isNotEmpty);
41 expect(AppleMlxModelIds.llama321b, isNotEmpty);
42 expect(AppleMlxModelIds.qwen2505b, isNotEmpty);
43 });
44
45 test('cleans up a loaded model when server startup fails', () async {
46 final runtime = _FakeRuntime(failServerStart: true);
47 final llm = AppleMlxLlmManager(runtime: runtime);
48
49 await expectLater(
50 llm.loadAndStartServer(modelPath: '/models/test'),
51 throwsStateError,
52 );
53
54 expect(runtime.loadCount, 1);
55 expect(runtime.unloadCount, 1);
56 expect(llm.isModelLoaded, isFalse);
57 expect(llm.isServerRunning, isFalse);
58 });
59
60 test('emits one terminal delta when SSE includes DONE', () async {
61 final client = _FakeClient([
62 const MlxSseEvent(
63 data: {
64 'choices': [
65 {
66 'delta': {'content': 'answer', 'reasoning_content': 'thought'},
67 },
68 ],
69 },
70 ),
71 const MlxSseEvent(done: true),
72 ]);
73 final llm = AppleMlxLlmManager(
74 runtime: _FakeRuntime(),
75 clientFactory: (_) => client,
76 );
77 await llm.loadAndStartServer(modelPath: '/models/test');
78
79 final deltas = await llm
80 .generateStream(
81 messages: const [
82 {'role': 'user', 'content': 'hello'},
83 ],
84 )
85 .toList();
86
87 expect(deltas, hasLength(2));
88 expect(deltas.first.content, 'answer');
89 expect(deltas.first.reasoningContent, 'thought');
90 expect(deltas.last.isDone, isTrue);
91 expect(deltas.where((delta) => delta.isDone), hasLength(1));
92 });
93
94 test('emits a terminal delta when SSE closes without DONE', () async {
95 final client = _FakeClient(const []);
96 final llm = AppleMlxLlmManager(
97 runtime: _FakeRuntime(),
98 clientFactory: (_) => client,
99 );
100 await llm.loadAndStartServer(modelPath: '/models/test');
101
102 final deltas = await llm
103 .generateStream(
104 messages: const [
105 {'role': 'user', 'content': 'hello'},
106 ],
107 )
108 .toList();
109
110 expect(deltas, hasLength(1));
111 expect(deltas.single.isDone, isTrue);
112 });
113 });
114
115 test('native LLM inference is enabled for Apple platforms', () {
116 expect(AppleMlxCapabilities.nativeLlmInferenceAvailable, isTrue);
117 expect(
118 AppleMlxCapabilities.nativeLlmUnavailableReason,
119 contains('Apple platforms'),
120 );
121 });
122 }
123
124 class _FakeRuntime extends LibMlxRuntime {
125 _FakeRuntime({this.failServerStart = false});
126
127 final bool failServerStart;
128 int loadCount = 0;
129 int unloadCount = 0;
130
131 @override
132 Future<MlxModelHandle> loadModel(MlxModelConfig config) async {
133 loadCount++;
134 return MlxModelHandle(value: loadCount, modelId: config.modelId);
135 }
136
137 @override
138 Future<MlxServerInfo> startServer(
139 MlxModelHandle handle, {
140 MlxServerConfig config = const MlxServerConfig(),
141 }) async {
142 if (failServerStart) throw StateError('server failed');
143 return MlxServerInfo(
144 host: '127.0.0.1',
145 port: 12345,
146 baseUrl: 'http://127.0.0.1:12345',
147 modelId: config.modelId,
148 status: 'running',
149 );
150 }
151
152 @override
153 Future<void> stopServer(MlxModelHandle handle) async {}
154
155 @override
156 Future<void> unloadModel(MlxModelHandle handle) async {
157 unloadCount++;
158 }
159 }
160
161 class _FakeClient extends LibMlxOpenAiClient {
162 _FakeClient(this.events) : super(baseUri: Uri.parse('http://localhost'));
163
164 final List<MlxSseEvent> events;
165
166 @override
167 Stream<MlxSseEvent> chatCompletionsStream(Map<String, Object?> request) =>
168 Stream.fromIterable(events);
169 }