1 // Copyright 2026 Splitfire AB (Onde Inference). All rights reserved.
2 // SPDX-License-Identifier: MIT OR Apache-2.0
3
4 import 'package:flutter_test/flutter_test.dart';
5 import 'package:onde_inference/onde_inference.dart';
6 import 'package:onde_inference/src/frb_generated.dart/frb_generated.dart'
7 as frb
8 show RustLibApi;
9
10 class _FakeOndeChatEngine implements OndeChatEngine {
11 @override
12 dynamic noSuchMethod(Invocation invocation) =>
13 throw UnimplementedError('Unexpected OndeChatEngine member: $invocation');
14 }
15
16 class _MockRustLibApi implements frb.RustLibApi {
17 static GgufModelConfig _qwen3(
18 String variant,
19 String filename,
20 String memory,
21 ) => GgufModelConfig(
22 modelId: 'bartowski/Qwen_Qwen3-$variant-GGUF',
23 files: [filename],
24 displayName: 'Qwen 3 $variant',
25 approxMemory: memory,
26 );
27
28 static const _qwen2515b = GgufModelConfig(
29 modelId: 'bartowski/Qwen2.5-1.5B-Instruct-GGUF',
30 files: ['Qwen2.5-1.5B-Instruct-Q4_K_M.gguf'],
31 displayName: 'Qwen 2.5 1.5B',
32 approxMemory: '~941 MB',
33 );
34
35 static const _qwen253b = GgufModelConfig(
36 modelId: 'bartowski/Qwen2.5-3B-Instruct-GGUF',
37 files: ['Qwen2.5-3B-Instruct-Q4_K_M.gguf'],
38 displayName: 'Qwen 2.5 3B',
39 approxMemory: '~1.93 GB',
40 );
41
42 static const _qwen25Coder15b = GgufModelConfig(
43 modelId: 'bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF',
44 files: ['Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf'],
45 displayName: 'Qwen 2.5 Coder 1.5B',
46 approxMemory: '~941 MB',
47 );
48
49 static const _qwen25Coder3b = GgufModelConfig(
50 modelId: 'bartowski/Qwen2.5-Coder-3B-Instruct-GGUF',
51 files: ['Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf'],
52 displayName: 'Qwen 2.5 Coder 3B',
53 approxMemory: '~1.93 GB',
54 );
55
56 static final _defaultSampling = SamplingConfig(
57 temperature: 0.7,
58 topP: 0.95,
59 maxTokens: BigInt.from(512),
60 );
61
62 static final _deterministicSampling = SamplingConfig(
63 temperature: 0.0,
64 maxTokens: BigInt.from(512),
65 );
66
67 static final _mobileSampling = SamplingConfig(
68 temperature: 0.7,
69 topP: 0.95,
70 maxTokens: BigInt.from(128),
71 );
72
73 @override
74 GgufModelConfig crateApiDefaultModelConfig() => _qwen25Coder3b;
75
76 @override
77 SamplingConfig crateApiDefaultSamplingConfig() => _defaultSampling;
78
79 @override
80 SamplingConfig crateApiDeterministicSamplingConfig() =>
81 _deterministicSampling;
82
83 @override
84 SamplingConfig crateApiMobileSamplingConfig() => _mobileSampling;
85
86 @override
87 OndeChatEngine crateApiOndeChatEngineNew() => _FakeOndeChatEngine();
88
89 @override
90 GgufModelConfig crateApiQwen2515BConfig() => _qwen2515b;
91
92 @override
93 GgufModelConfig crateApiQwen253BConfig() => _qwen253b;
94
95 @override
96 GgufModelConfig crateApiQwen25Coder15BConfig() => _qwen25Coder15b;
97
98 @override
99 GgufModelConfig crateApiQwen25Coder3BConfig() => _qwen25Coder3b;
100
101 @override
102 GgufModelConfig crateApiQwen306BConfig() =>
103 _qwen3('0.6B', 'Qwen_Qwen3-0.6B-Q4_K_M.gguf', '~0.5 GB');
104
105 @override
106 GgufModelConfig crateApiQwen317BConfig() =>
107 _qwen3('1.7B', 'Qwen_Qwen3-1.7B-Q4_K_M.gguf', '~1.3 GB');
108
109 @override
110 GgufModelConfig crateApiQwen34BConfig() =>
111 _qwen3('4B', 'Qwen_Qwen3-4B-Q4_K_M.gguf', '~2.5 GB');
112
113 @override
114 GgufModelConfig crateApiQwen38BConfig() =>
115 _qwen3('8B', 'Qwen_Qwen3-8B-Q4_K_M.gguf', '~5 GB');
116
117 @override
118 GgufModelConfig crateApiQwen314BConfig() =>
119 _qwen3('14B', 'Qwen_Qwen3-14B-Q4_K_M.gguf', '~8.4 GB');
120
121 @override
122 GgufModelConfig crateApiQwen332BConfig() =>
123 _qwen3('32B', 'Qwen_Qwen3-32B-Q4_K_M.gguf', '~19.8 GB');
124
125 @override
126 GgufModelConfig crateApiQwen34BInstruct2507Config() => _qwen3(
127 '4B-Instruct-2507',
128 'Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
129 '~2.5 GB',
130 );
131
132 @override
133 GgufModelConfig crateApiQwen34BThinking2507Config() => _qwen3(
134 '4B-Thinking-2507',
135 'Qwen_Qwen3-4B-Thinking-2507-Q4_K_M.gguf',
136 '~2.5 GB',
137 );
138
139 @override
140 GgufModelConfig crateApiQwen330BA3BInstruct2507Config() => _qwen3(
141 '30B-A3B-Instruct-2507',
142 'Qwen_Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf',
143 '~18.6 GB',
144 );
145
146 @override
147 dynamic noSuchMethod(Invocation invocation) =>
148 throw UnimplementedError('Unexpected RustLibApi member: $invocation');
149 }
150
151 void main() {
152 setUpAll(() {
153 RustLib.initMock(api: _MockRustLibApi());
154 });
155
156 tearDownAll(() {
157 RustLib.dispose();
158 });
159 group('OndeException', () {
160 test('toString includes error info', () {
161 const e = OndeException(OndeError_NoModelLoaded());
162 expect(e.toString(), contains('OndeException'));
163 });
164
165 test('equality', () {
166 const a = OndeException(OndeError_NoModelLoaded());
167 const b = OndeException(OndeError_NoModelLoaded());
168 const c = OndeException(OndeError_Cancelled());
169 expect(a, equals(b));
170 expect(a, isNot(equals(c)));
171 });
172 });
173
174 group('ChatRole', () {
175 test('name values', () {
176 expect(ChatRole.system.name, 'system');
177 expect(ChatRole.user.name, 'user');
178 expect(ChatRole.assistant.name, 'assistant');
179 });
180 });
181
182 group('ChatMessage', () {
183 test('factory constructors set correct roles', () {
184 expect(ChatMessageX.system('Hi').role, ChatRole.system);
185 expect(ChatMessageX.user('Hi').role, ChatRole.user);
186 expect(ChatMessageX.assistant('Hi').role, ChatRole.assistant);
187 });
188
189 test('equality', () {
190 final a = ChatMessageX.user('Hello');
191 final b = ChatMessageX.user('Hello');
192 final c = ChatMessageX.user('World');
193 expect(a, equals(b));
194 expect(a, isNot(equals(c)));
195 });
196 });
197
198 group('SamplingConfig', () {
199 test('defaultConfig preset', () {
200 final s = SamplingConfigX.defaultConfig();
201 expect(s.temperature, 0.7);
202 expect(s.topP, 0.95);
203 expect(s.maxTokens, BigInt.from(512));
204 });
205
206 test('deterministic preset', () {
207 final s = SamplingConfigX.deterministic();
208 expect(s.temperature, 0.0);
209 expect(s.maxTokens, BigInt.from(512));
210 expect(s.topP, isNull);
211 });
212
213 test('mobile preset', () {
214 final s = SamplingConfigX.mobile();
215 expect(s.temperature, 0.7);
216 expect(s.topP, 0.95);
217 expect(s.maxTokens, BigInt.from(128));
218 });
219
220 test('copyWith overrides single field', () {
221 final base = SamplingConfigX.defaultConfig();
222 final copy = base.copyWith(temperature: 0.3);
223 expect(copy.temperature, 0.3);
224 expect(copy.topP, base.topP);
225 expect(copy.maxTokens, base.maxTokens);
226 });
227
228 test('equality', () {
229 expect(
230 SamplingConfigX.deterministic(),
231 equals(SamplingConfigX.deterministic()),
232 );
233 expect(
234 SamplingConfigX.deterministic(),
235 isNot(equals(SamplingConfigX.mobile())),
236 );
237 });
238 });
239
240 group('GgufModelConfig', () {
241 test('factory functions return non-empty configs', () {
242 final config1_5b = OndeInference.qwen2515bConfig();
243 expect(config1_5b.modelId, contains('1.5B'));
244 expect(config1_5b.files, isNotEmpty);
245 expect(config1_5b.displayName, isNotEmpty);
246 expect(config1_5b.approxMemory, isNotEmpty);
247
248 final config3b = OndeInference.qwen253bConfig();
249 expect(config3b.modelId, contains('3B'));
250 expect(config3b.files, isNotEmpty);
251
252 final configCoder1_5b = OndeInference.qwen25Coder15bConfig();
253 expect(configCoder1_5b.modelId, contains('Coder'));
254
255 final configCoder3b = OndeInference.qwen25Coder3bConfig();
256 expect(configCoder3b.modelId, contains('Coder'));
257
258 final defaultConfig = OndeInference.defaultModelConfig();
259 expect(defaultConfig.modelId, isNotEmpty);
260
261 final qwen3Configs = [
262 OndeInference.qwen306bConfig(),
263 OndeInference.qwen317bConfig(),
264 OndeInference.qwen34bConfig(),
265 OndeInference.qwen38bConfig(),
266 OndeInference.qwen314bConfig(),
267 OndeInference.qwen332bConfig(),
268 OndeInference.qwen34bInstruct2507Config(),
269 OndeInference.qwen34bThinking2507Config(),
270 OndeInference.qwen330bA3bInstruct2507Config(),
271 ];
272 expect(qwen3Configs, everyElement(isA<GgufModelConfig>()));
273 expect(
274 qwen3Configs,
275 everyElement(
276 predicate<GgufModelConfig>(
277 (config) => config.modelId.contains('Qwen3'),
278 ),
279 ),
280 );
281 });
282
283 test('equality', () {
284 expect(
285 OndeInference.qwen2515bConfig(),
286 equals(OndeInference.qwen2515bConfig()),
287 );
288 expect(
289 OndeInference.qwen2515bConfig(),
290 isNot(equals(OndeInference.qwen253bConfig())),
291 );
292 });
293 });
294
295 group('EngineStatus', () {
296 test('isReady only true for ready', () {
297 expect(EngineStatus.ready.isReady, isTrue);
298 expect(EngineStatus.unloaded.isReady, isFalse);
299 expect(EngineStatus.loading.isReady, isFalse);
300 expect(EngineStatus.generating.isReady, isFalse);
301 expect(EngineStatus.error.isReady, isFalse);
302 });
303
304 test('name values', () {
305 expect(EngineStatus.unloaded.name, 'unloaded');
306 expect(EngineStatus.loading.name, 'loading');
307 expect(EngineStatus.ready.name, 'ready');
308 expect(EngineStatus.generating.name, 'generating');
309 expect(EngineStatus.error.name, 'error');
310 });
311 });
312
313 group('EngineInfo', () {
314 test('equality', () {
315 final a = EngineInfo(
316 status: EngineStatus.unloaded,
317 historyLength: BigInt.zero,
318 );
319 final b = EngineInfo(
320 status: EngineStatus.unloaded,
321 historyLength: BigInt.zero,
322 );
323 final c = EngineInfo(
324 status: EngineStatus.ready,
325 modelName: 'Qwen 2.5 3B',
326 approxMemory: '~1.93 GB',
327 historyLength: BigInt.from(3),
328 );
329 expect(a, equals(b));
330 expect(a, isNot(equals(c)));
331 });
332 });
333
334 group('StreamChunk', () {
335 test('equality', () {
336 const a = StreamChunk(delta: 'hello', done: false);
337 const b = StreamChunk(delta: 'hello', done: false);
338 const c = StreamChunk(delta: 'bye', done: true, finishReason: 'stop');
339 expect(a, equals(b));
340 expect(a, isNot(equals(c)));
341 });
342 });
343
344 group('InferenceResult', () {
345 test('equality', () {
346 const a = InferenceResult(
347 text: 'Hi there!',
348 durationSecs: 1.5,
349 durationDisplay: '1.5s',
350 finishReason: 'stop',
351 toolCalls: [],
352 );
353 const b = InferenceResult(
354 text: 'Hi there!',
355 durationSecs: 1.5,
356 durationDisplay: '1.5s',
357 finishReason: 'stop',
358 toolCalls: [],
359 );
360 expect(a, equals(b));
361 });
362 });
363
364 group('RustLib', () {
365 test('instance is accessible before init', () {
366 // instance is always non-null; initialization requires the native
367 // library to be compiled first.
368 expect(RustLib.instance, isNotNull);
369 });
370 });
371
372 group('OndeInference helpers', () {
373 test('config factories return valid configs', () {
374 expect(OndeInference.defaultModelConfig().modelId, isNotEmpty);
375 expect(OndeInference.qwen2515bConfig().modelId, contains('1.5B'));
376 expect(OndeInference.qwen253bConfig().modelId, contains('3B'));
377 expect(OndeInference.qwen25Coder15bConfig().modelId, contains('Coder'));
378 expect(OndeInference.qwen25Coder3bConfig().modelId, contains('Coder'));
379 });
380
381 test('sampling factories return correct presets', () {
382 expect(OndeInference.defaultSamplingConfig().temperature, 0.7);
383 expect(OndeInference.deterministicSamplingConfig().temperature, 0.0);
384 expect(OndeInference.mobileSamplingConfig().maxTokens, BigInt.from(128));
385 });
386 });
387
388 group('OndeChatEngine', () {
389 test('constructor succeeds when mock API is initialized', () {
390 expect(OndeChatEngine(), isA<OndeChatEngine>());
391 });
392 });
393 }