@@ -4,64 +4,132 @@ import 'dart:typed_data';
44import 'package:PiliPlus/utils/ai_image_moderation_service.dart' ;
55import 'package:PiliPlus/utils/ai_image_state.dart' ;
66import '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' ;
79import 'package:PiliPlus/utils/path_utils.dart' ;
810import 'package:PiliPlus/utils/storage.dart' ;
911import 'package:PiliPlus/utils/storage_pref.dart' ;
1012import 'package:flutter/foundation.dart' ;
1113import '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.
1441class _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+
42103void 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