Skip to content

Commit d4c217c

Browse files
test(ai): add fixture images and comprehensive test updates for new APIs
- Add 4 missing test fixture images (image_full.png, image_thumb_100w_q10.jpg, image2 variants) - clip_preprocessing_test: 22 tests for config-driven preprocessing with dynamic sizes - clip_tokenizer_test: 22 tests for config-driven tokenizer, attention mask, BPE validation, EOS truncation - ai_inference_engine_test: 44 tests for dynamic shapes, session reuse, format consistency, config methods - ai_image_moderation_test: 28 tests for dynamic embeddings, dimension validation, pHash auto-block - image_grid_ai_test: 7 tests for pending, blur, highRisk tap-to-reveal - Plus ai_state, clip_similarity, image_block_service test updates Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <[email protected]>
1 parent daa2ff6 commit d4c217c

14 files changed

Lines changed: 1165 additions & 271 deletions

test/ai_image_moderation_test.dart

Lines changed: 263 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,64 +4,132 @@ import 'dart:typed_data';
44
import 'package:PiliPlus/utils/ai_image_moderation_service.dart';
55
import 'package:PiliPlus/utils/ai_image_state.dart';
66
import 'package:PiliPlus/utils/ai_inference_engine.dart';
7+
import 'package:PiliPlus/utils/clip_tokenizer_config.dart';
8+
import 'package:PiliPlus/utils/image_block_service.dart';
79
import 'package:PiliPlus/utils/path_utils.dart';
810
import 'package:PiliPlus/utils/storage.dart';
911
import 'package:PiliPlus/utils/storage_pref.dart';
1012
import 'package:flutter/foundation.dart';
1113
import 'package:flutter_test/flutter_test.dart';
14+
import 'package:image/image.dart' as img;
15+
import 'package:path_provider/path_provider.dart';
16+
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
17+
18+
/// Fake [PathProviderPlatform] for test environments where no platform
19+
/// channel implementation is available.
20+
class _FakePathProviderPlatform extends PathProviderPlatform {
21+
final String basePath;
22+
_FakePathProviderPlatform(this.basePath);
23+
24+
@override
25+
Future<String?> getApplicationDocumentsPath() async => basePath;
26+
27+
@override
28+
Future<String?> getApplicationSupportPath() async => basePath;
29+
30+
@override
31+
Future<String?> getTemporaryPath() async => basePath;
32+
33+
@override
34+
Future<String?> getLibraryPath() async => basePath;
35+
36+
@override
37+
Future<String?> getApplicationCachePath() async => basePath;
38+
}
1239

1340
/// Stub [InferenceSession] for tests that require a full evaluation pipeline.
1441
class _MockInferenceSession implements InferenceSession {
1542
bool shouldThrow = false;
16-
final Float32List _unitVector;
1743

18-
_MockInferenceSession({this.shouldThrow = false})
19-
: _unitVector = _makeUnitVector();
44+
/// Dimension of the embeddings this mock returns.
45+
final int dim;
2046

21-
static Float32List _makeUnitVector() {
22-
final v = Float32List(512);
23-
v[0] = 1.0; // unit-norm: only first component is non-zero
24-
return v;
25-
}
47+
/// Optional factory to produce custom vision results per test case.
48+
/// If null, [runVision] returns a vector that classifies as [AiImageState.blocked]
49+
/// (first text-embedding segment → highest similarity with MALICIOUS prompt).
50+
final Float32List Function()? visionResultFactory;
51+
52+
_MockInferenceSession({
53+
this.shouldThrow = false,
54+
this.dim = 384,
55+
this.visionResultFactory,
56+
});
2657

2758
@override
28-
Future<Float32List> runVision(Float32List input) async {
59+
Future<Float32List> runVision(
60+
Float32List input, {
61+
required List<int> shape,
62+
}) async {
2963
if (shouldThrow) throw Exception('mock inference error');
30-
return Float32List.fromList(_unitVector);
64+
if (visionResultFactory != null) return visionResultFactory!();
65+
66+
// Default: return first dim entries of Pref.aiTextEmbeddings.
67+
// After L2-normalization this has highest cosine-sim with textEmbeds[0]
68+
// (MALICIOUS), so classify() returns AiImageState.blocked.
69+
final embeds = Pref.aiTextEmbeddings;
70+
if (embeds.length >= dim * 3) {
71+
return Float32List(dim)..setRange(0, dim, embeds.sublist(0, dim));
72+
}
73+
return Float32List(dim);
3174
}
3275

3376
@override
34-
Future<Float32List> runText(List<int> tokenIds) async {
35-
return Float32List(512);
77+
Future<Float32List> runText(TokenizedText tokens) async {
78+
if (shouldThrow) throw Exception('mock inference error');
79+
return Float32List(dim);
3680
}
3781

3882
@override
3983
void dispose() {}
4084
}
4185

86+
/// Default text embedding dimension used in tests (384 = 1152 / 3).
87+
const _defaultDim = 384;
88+
const _defaultEmbeddingLength = _defaultDim * 3; // 1152
89+
90+
/// Create a minimal valid PNG image as [Uint8List] for pre-populating the
91+
/// image cache in full-pipeline tests.
92+
Uint8List _createTestImage({int size = 64}) {
93+
final image = img.Image(width: size, height: size);
94+
// Fill with neutral grey pixels
95+
for (int y = 0; y < size; y++) {
96+
for (int x = 0; x < size; x++) {
97+
image.setPixelRgba(x, y, 128, 128, 128, 255);
98+
}
99+
}
100+
return Uint8List.fromList(img.encodePng(image));
101+
}
102+
42103
void main() {
43104
TestWidgetsFlutterBinding.ensureInitialized();
44105

45106
late Directory tempDir;
107+
late Uint8List _testImageBytes;
46108

47109
setUpAll(() async {
48110
tempDir = await Directory.systemTemp.createTemp(
49111
'pili_ai_moderation_test_',
50112
);
51113
debugSetAppSupportDirPath(tempDir.path);
114+
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
52115
await GStorage.init();
116+
_testImageBytes = _createTestImage();
53117
});
54118

55119
setUp(() {
56120
// Reset to known defaults before each test
57121
AiImageModerationService.invalidateCache();
58122
AiImageModerationService.dispose();
123+
AiImageModerationService.mockImageBytes = null;
124+
AiImageModerationService.setMockSession(null);
125+
AiImageModerationService.onAutoBlock = null;
59126
Pref.enableAiImageModeration = true;
60127
Pref.enableImageBlock = true;
61128
Pref.aiModelDownloaded = true;
62-
Pref.aiTextEmbeddings = List.filled(1536, 0.1);
129+
Pref.aiTextEmbeddings = List.filled(_defaultEmbeddingLength, 0.1);
63130
Pref.aiModelFormat = 'tflite';
64131
Pref.aiAutoBlocklist = false;
132+
Pref.imageBlockHashList = [];
65133
});
66134

67135
tearDownAll(() async {
@@ -107,15 +175,49 @@ void main() {
107175
});
108176

109177
test(
110-
'embeddings shorter than 1536 returns normal (fail-open)',
178+
'embeddings not divisible by 3 returns normal (fail-open)',
111179
() async {
112-
Pref.aiTextEmbeddings = List.filled(1000, 0.1);
180+
Pref.aiTextEmbeddings = List.filled(100, 0.1);
113181
final result = await AiImageModerationService.evaluateImage(
114182
'https://example.com/test.jpg',
115183
);
116184
expect(result, equals(AiImageState.normal));
117185
},
118186
);
187+
188+
test(
189+
'384-dim embeddings accepted without error',
190+
() async {
191+
Pref.aiTextEmbeddings = List.generate(1152, (i) => i.toDouble());
192+
// Downloads will fail in test env, so result is normal (fail-open)
193+
final result = await AiImageModerationService.evaluateImage(
194+
'https://example.com/384dim.jpg',
195+
);
196+
expect(result, equals(AiImageState.normal));
197+
},
198+
);
199+
200+
test(
201+
'512-dim embeddings (backward compat) accepted without error',
202+
() async {
203+
Pref.aiTextEmbeddings = List.generate(1536, (i) => i.toDouble());
204+
final result = await AiImageModerationService.evaluateImage(
205+
'https://example.com/512dim.jpg',
206+
);
207+
expect(result, equals(AiImageState.normal));
208+
},
209+
);
210+
211+
test(
212+
'768-dim embeddings accepted without error',
213+
() async {
214+
Pref.aiTextEmbeddings = List.generate(2304, (i) => i.toDouble());
215+
final result = await AiImageModerationService.evaluateImage(
216+
'https://example.com/768dim.jpg',
217+
);
218+
expect(result, equals(AiImageState.normal));
219+
},
220+
);
119221
});
120222

121223
// ── Cache behaviour ─────────────────────────────────────────────────
@@ -137,7 +239,7 @@ void main() {
137239

138240
test('getCachedResult returns null after invalidateCache', () {
139241
const url = 'https://example.com/to_invalidate.jpg';
140-
AiImageModerationService.setCachedResult(url, AiImageState.lowRes);
242+
AiImageModerationService.setCachedResult(url, AiImageState.highRisk);
141243
AiImageModerationService.invalidateCache();
142244
final result = AiImageModerationService.getCachedResult(url);
143245
expect(result, isNull);
@@ -152,17 +254,21 @@ void main() {
152254
expect(result, equals(AiImageState.blocked));
153255
});
154256

155-
test('URL normalization matches between setCachedResult and getCachedResult',
156-
() {
157-
const cleanUrl = 'https://example.com/photo.jpg';
158-
const formattedUrl =
159-
'https://example.com/photo.jpg@100w_100h.webp';
160-
AiImageModerationService.setCachedResult(cleanUrl, AiImageState.blocked);
161-
final result1 = AiImageModerationService.getCachedResult(formattedUrl);
162-
expect(result1, equals(AiImageState.blocked));
163-
final result2 = AiImageModerationService.getCachedResult(cleanUrl);
164-
expect(result2, equals(AiImageState.blocked));
165-
});
257+
test(
258+
'URL normalization matches between setCachedResult and getCachedResult',
259+
() {
260+
const cleanUrl = 'https://example.com/photo.jpg';
261+
const formattedUrl = 'https://example.com/photo.jpg@100w_100h.webp';
262+
AiImageModerationService.setCachedResult(
263+
cleanUrl,
264+
AiImageState.blocked,
265+
);
266+
final result1 = AiImageModerationService.getCachedResult(formattedUrl);
267+
expect(result1, equals(AiImageState.blocked));
268+
final result2 = AiImageModerationService.getCachedResult(cleanUrl);
269+
expect(result2, equals(AiImageState.blocked));
270+
},
271+
);
166272
});
167273

168274
// ── URL normalization ───────────────────────────────────────────────
@@ -190,8 +296,7 @@ void main() {
190296
});
191297

192298
test('strips at @ when @ appears before ?', () {
193-
const url =
194-
'https://i0.hdslb.com/bfs/album/[email protected]?q=1';
299+
const url = 'https://i0.hdslb.com/bfs/album/[email protected]?q=1';
195300
expect(
196301
AiImageModerationService.normalizeUrl(url),
197302
equals('https://i0.hdslb.com/bfs/album/abc.jpg'),
@@ -314,4 +419,133 @@ void main() {
314419
AiImageModerationService.setMockSession(null);
315420
});
316421
});
422+
423+
// ── Full pipeline (mock session + mock image bytes) ──────────────────
424+
425+
group('full pipeline with mocks', () {
426+
const _testUrl = 'https://example.com/full_pipeline_test.jpg';
427+
428+
setUp(() {
429+
AiImageModerationService.setMockSession(_MockInferenceSession());
430+
AiImageModerationService.mockImageBytes = Uint8List.fromList(
431+
_testImageBytes,
432+
);
433+
AiImageModerationService.onAutoBlock = null;
434+
});
435+
436+
test(
437+
'image/text dim mismatch returns normal (fail-open)',
438+
() async {
439+
// 384-dim embeddings but mock returns 512-dim vision embedding
440+
Pref.aiTextEmbeddings = List.generate(1152, (i) => i.toDouble());
441+
final mismatchSession = _MockInferenceSession(
442+
dim: 512,
443+
visionResultFactory: () => Float32List(512),
444+
);
445+
AiImageModerationService.setMockSession(mismatchSession);
446+
447+
final result = await AiImageModerationService.evaluateImage(_testUrl);
448+
expect(result, equals(AiImageState.normal));
449+
},
450+
);
451+
452+
test(
453+
'auto-block ON → onAutoBlock fires with source ai_auto',
454+
() async {
455+
Pref.aiAutoBlocklist = true;
456+
Pref.aiTextEmbeddings = List.generate(1152, (i) => i.toDouble());
457+
458+
String? capturedUrl;
459+
String? capturedSource;
460+
AiImageModerationService.onAutoBlock = (url, source) {
461+
capturedUrl = url;
462+
capturedSource = source;
463+
};
464+
465+
final result = await AiImageModerationService.evaluateImage(_testUrl);
466+
expect(result, equals(AiImageState.blocked));
467+
expect(capturedUrl, contains('full_pipeline_test'));
468+
expect(capturedSource, equals('ai_auto'));
469+
},
470+
);
471+
472+
test(
473+
'auto-block fires only once per URL (dedup via result cache)',
474+
() async {
475+
Pref.aiAutoBlocklist = true;
476+
Pref.aiTextEmbeddings = List.generate(1152, (i) => i.toDouble());
477+
478+
int callCount = 0;
479+
AiImageModerationService.onAutoBlock = (_, __) {
480+
callCount++;
481+
};
482+
483+
// First call → fires auto-block
484+
await AiImageModerationService.evaluateImage(_testUrl);
485+
expect(
486+
callCount,
487+
equals(1),
488+
reason: 'first evaluation should fire auto-block',
489+
);
490+
491+
// Result cache hit → second call returns cached, no re-fire
492+
await AiImageModerationService.evaluateImage(_testUrl);
493+
expect(
494+
callCount,
495+
equals(1),
496+
reason: 'cached result should not fire auto-block again',
497+
);
498+
},
499+
);
500+
501+
test(
502+
'highRisk does NOT fire onAutoBlock',
503+
() async {
504+
Pref.aiAutoBlocklist = true;
505+
Pref.aiTextEmbeddings = List.generate(1152, (i) => i.toDouble());
506+
507+
bool called = false;
508+
AiImageModerationService.onAutoBlock = (_, __) {
509+
called = true;
510+
};
511+
512+
// Return the middle text-embedding segment → classifies as highRisk
513+
const dim = 384;
514+
final highRiskSession = _MockInferenceSession(
515+
visionResultFactory: () => Float32List(dim)
516+
..setRange(
517+
0,
518+
dim,
519+
Pref.aiTextEmbeddings.sublist(dim, 2 * dim),
520+
),
521+
);
522+
AiImageModerationService.setMockSession(highRiskSession);
523+
524+
final result = await AiImageModerationService.evaluateImage(_testUrl);
525+
expect(result, equals(AiImageState.highRisk));
526+
expect(
527+
called,
528+
isFalse,
529+
reason: 'highRisk should not trigger auto-block',
530+
);
531+
},
532+
);
533+
534+
test(
535+
'auto-block OFF does not fire onAutoBlock',
536+
() async {
537+
// aiAutoBlocklist is false by default from setUp
538+
Pref.aiTextEmbeddings = List.generate(1152, (i) => i.toDouble());
539+
540+
bool called = false;
541+
AiImageModerationService.onAutoBlock = (_, __) {
542+
called = true;
543+
};
544+
545+
final result = await AiImageModerationService.evaluateImage(_testUrl);
546+
expect(result, equals(AiImageState.blocked));
547+
expect(called, isFalse, reason: 'no auto-block when setting is OFF');
548+
},
549+
);
550+
});
317551
}

0 commit comments

Comments
 (0)