Files
pj_supermarket_cloud/test/face_sync_pipeline_test.dart
T
dzc 7c20242ad7 perf: optimize face library startup and differential sync
Stream and validate native feature loading with fast decoding and bounded SDK batches.
Reconcile authoritative department snapshots with rollback-safe metadata and file updates.
Bound downloads, isolate duplicate identifiers, and defer unchanged invalid photo versions.
Remove unused legacy face APIs and document device measurements.

Validation: 42 Flutter tests in an isolated source snapshot; native unit and device checks;
all 5,537 registered features verified byte-for-byte, plus real SDK photo searches and metadata checks.
2026-10-22 16:35:52 +08:00

481 lines
17 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' as dio;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:sqflite/sqflite.dart' as sqlite;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
class _TestApp extends AppControl {
@override
void onClose() {}
}
class _Adapter implements dio.HttpClientAdapter {
_Adapter(this.respond);
final Object Function(dio.RequestOptions) respond;
@override
Future<dio.ResponseBody> fetch(dio.RequestOptions options,
Stream<Uint8List>? requestStream, Future<void>? cancelFuture) async =>
dio.ResponseBody.fromString(
jsonEncode({'code': 0, 'data': respond(options)}), 200,
headers: {
'content-type': ['application/json']
});
@override
void close({bool force = false}) {}
}
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
late Directory temp;
late HttpServer server;
late AppDatabase database;
late String facePath;
late String imageUrl;
var imageRequests = 0;
var extractionCalls = 0;
var upserts = 0;
var metadataCalls = 0;
var qualityReports = 0;
var v2Requests = 0;
var failUpsertOnce = false;
var failMetadataOnce = false;
var invalidSnapshot = false;
var changingAllSnapshot = false;
var rejectQualityReports = false;
var rejectFacePhoto = false;
final memory = <String, Map<String, dynamic>>{};
final remote = <Map<String, dynamic>>[];
final allRemote = <Map<String, dynamic>>[];
var photoBytes = <int>[10, 20, 30];
final version = DateTime.parse('2026-10-22T01:02:03.123456');
Map<String, dynamic> record(String sample,
{String? user, DateTime? updated}) =>
{
'id': sample,
'sampleId': sample,
'userId': user ?? '20',
'username': 'Test',
'deptId': '10',
'deptName': null,
'faceImg': imageUrl,
'sourceUpdatedAt': (updated ?? version).toIso8601String(),
};
Future<FaceEntity> seed(String sample,
{String dept = '10', bool quality = false}) async {
final file = File('$facePath/$sample.jpg');
await file.writeAsBytes([1, 2, 3]);
final face = FaceEntity(
sampleId: sample,
userId: '20',
userName: 'Test',
deptId: dept,
imagePath: imageUrl,
path: file.path,
faceModel: [1, 2, 3],
updateTime: version,
qualityStatus: quality ? 'detected' : 'pending',
qualityCheckedAt: quality ? version : null,
qualityScore: quality ? 0.8 : null);
face.id = await database.getFaceDao.save(face);
memory[sample] = {
'userId': '20',
'username': 'Test',
'feature': [1, 2, 3]
};
return face;
}
Future<FaceSyncSummary> sync({bool all = false}) => SyncFaceTool.sync(
mode: all
? FaceSyncMode.debugAllSnapshot
: FaceSyncMode.departmentSnapshot);
setUpAll(() async {
HttpOverrides.global = null;
temp = await Directory.systemTemp.createTemp('pj-face-sync-test-');
for (final channel in [
'plugins.flutter.io/path_provider',
'external_path'
]) {
binding.defaultBinaryMessenger.setMockMethodCallHandler(
MethodChannel(channel), (_) async => temp.path);
}
sqfliteFfiInit();
sqlite.databaseFactory = databaseFactoryFfiNoIsolate;
database = await $FloorAppDatabase.inMemoryDatabaseBuilder().build();
final app = _TestApp()..dataBase = database;
app.isNetwork.value = true;
Get.put<AppControl>(app);
await database.getSettingDao.save(SettingEntity(
sysJson: jsonEncode(
(SysSetting(sysType: SysType.CANTEEN, serviceId: '900')
..merchantDeptId = '10')
.toJson()),
userJson: jsonEncode(UserSetting(username: '', password: '').toJson()),
));
facePath = (await AppFileTool.getFacePath())!;
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
imageUrl = 'http://127.0.0.1:${server.port}/photo.jpg';
server.listen((request) async {
imageRequests++;
request.response.add(photoBytes);
await request.response.close();
});
HttpManage().dio.httpClientAdapter = _Adapter((options) {
if (options.path.contains('getDescendantList')) {
return [
{'deptId': '10'},
{'deptId': '11'}
];
}
if (options.path.endsWith('sync-snapshot')) {
return {
'records': remote,
'nextAfterId': remote.isEmpty ? '0' : remote.last['id'],
'hasMore': invalidSnapshot,
'snapshotMaxId': '999'
};
}
if (options.path.contains('v2/find-face')) {
v2Requests++;
return changingAllSnapshot && v2Requests > 1 ? [] : allRemote;
}
if (options.path.contains('lease')) {
return {'granted': true, 'remainingSeconds': 0};
}
if (options.path.contains('quality')) {
qualityReports++;
if (rejectQualityReports) throw StateError('Injected network failure');
return {
'updatedSampleIds': [],
'ignoredStaleSampleIds': [],
'alreadyAbsentSampleIds': [],
'ignoredNotApprovedSampleIds': ['1']
};
}
throw StateError('Unexpected API in test: ${options.path}');
});
binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('local/face_discern'),
(call) async {
final args = Map<String, dynamic>.from(call.arguments as Map);
switch (call.method) {
case 'extractFaceFeature':
extractionCalls++;
if (rejectFacePhoto) {
return {
'featureData': [],
'qualityStatus': 'failed',
'cacheableFailure': true
};
}
return {
'featureData': [4, 5, 6],
'qualityStatus': 'pending'
};
case 'applyFaceDelta':
final id = args['sampleId'].toString();
if (args['operation'] == 'delete') {
memory.remove(id);
} else {
upserts++;
memory[id] = {
'userId': args['userId'],
'username': args['username'],
'feature': List<int>.from(args['featureData'] as List)
};
if (failUpsertOnce) {
failUpsertOnce = false;
return {
'success': false,
'code': -99,
'message': 'Injected failure'
};
}
}
return {'success': true, 'code': 0};
case 'updateFaceMetadata':
metadataCalls++;
if (failMetadataOnce) {
failMetadataOnce = false;
return false;
}
for (final item in args['items'] as List) {
memory[item['sampleId'].toString()]!
..['userId'] = item['userId']
..['username'] = item['username'];
}
return true;
case 'removeFaces':
for (final id in args['sampleIds'] as List) {
memory.remove(id.toString());
}
return {'success': true, 'failedSampleIds': []};
}
throw StateError('Unexpected native method: ${call.method}');
},
);
});
setUp(() async {
await database.database
.execute('DROP TRIGGER IF EXISTS reject_face_delete');
await database.database
.execute('DROP TRIGGER IF EXISTS reject_face_metadata');
await database.getFaceDao.delAll();
memory.clear();
remote.clear();
allRemote.clear();
imageRequests = extractionCalls =
upserts = metadataCalls = qualityReports = v2Requests = 0;
failUpsertOnce =
failMetadataOnce = invalidSnapshot = changingAllSnapshot = false;
rejectQualityReports = false;
rejectFacePhoto = false;
photoBytes = [10, 20, 30];
for (final file in await Directory(facePath).list().toList()) {
await file.delete(recursive: true);
}
});
tearDownAll(() async {
await AppSettingTool.removeSettingCache();
await server.close(force: true);
await database.close();
Get.delete<AppControl>(force: true);
await temp.delete(recursive: true);
});
test('连续两次同步不下载、不提取、不写 SDK,特征和精确时间保持不变', () async {
await seed('1');
remote.add(record('1'));
expect((await sync()).skipped, 1);
expect((await sync()).skipped, 1);
expect(
[imageRequests, extractionCalls, upserts, metadataCalls], [0, 0, 0, 0]);
final row = await database.getFaceDao.findBySampleId('1');
expect(row!.faceModel, [1, 2, 3]);
expect(row.updateTime, version);
});
test('照片变化只下载提取一次;下一次同步直接跳过', () async {
final old = await seed('1');
remote.add(record('1', updated: version.add(const Duration(seconds: 1))));
expect((await sync()).updated, 1);
expect((await sync()).skipped, 1);
expect([imageRequests, extractionCalls, upserts], [1, 1, 1]);
expect(await File(old.path!).exists(), isFalse);
expect(
(await database.getFaceDao.findBySampleId('1'))!.faceModel, [4, 5, 6]);
});
test('相同照片字节只更新版本,复用已有特征、质量和正式文件', () async {
final old = await seed('1');
photoBytes = [1, 2, 3];
remote.add(
record('1', updated: version.add(const Duration(microseconds: 1))));
expect((await sync()).updated, 1);
expect((await sync()).skipped, 1);
expect([imageRequests, extractionCalls, upserts], [1, 0, 0]);
expect((await database.getFaceDao.findBySampleId('1'))!.path, old.path);
});
test('人员绑定变化同时更新数据库和内存,不重新提取特征', () async {
await seed('1');
remote.add(record('1', user: '21'));
expect((await sync()).updated, 1);
expect((await sync()).skipped, 1);
expect((await database.getFaceDao.findBySampleId('1'))!.userId, '21');
expect(memory['1']!['userId'], '21');
expect(memory['1']!['feature'], [1, 2, 3]);
expect(
[imageRequests, extractionCalls, upserts, metadataCalls], [0, 0, 0, 1]);
});
test('内存档案提交失败时还原数据库,重试能够成功', () async {
await seed('1');
remote.add(record('1', user: '21'));
failMetadataOnce = true;
expect((await sync()).failed, 1);
expect((await database.getFaceDao.findBySampleId('1'))!.userId, '20');
expect(memory['1']!['userId'], '20');
expect((await sync()).updated, 1);
});
test('部门变更能命中调试导入的范围外旧行,不重复新增', () async {
final old = await seed('1', dept: '99');
remote.add(record('1'));
final result = await sync();
expect(result.updated, 1);
expect(result.added, 0);
final row = (await database.getFaceDao.findBySampleId('1'))!;
expect(row.id, old.id);
expect(row.deptId, '10');
expect(imageRequests, 0);
});
test('批量档案更新中第二条 SQL 失败,第一条也会回滚', () async {
await seed('1');
await seed('2');
remote.addAll([record('1', user: '21'), record('2', user: '21')]);
await database.database.execute(
"CREATE TRIGGER reject_face_metadata BEFORE UPDATE ON face WHEN NEW.sampleId = '2' AND NEW.userId = '21' BEGIN SELECT RAISE(ABORT, 'injected'); END");
expect((await sync()).failed, 2);
expect((await database.getFaceDao.findBySampleId('1'))!.userId, '20');
expect((await database.getFaceDao.findBySampleId('2'))!.userId, '20');
expect(metadataCalls, 0);
});
test('大目录分块核对能恢复中断图片,所有未变化样本都跳过', () async {
for (var i = 1; i <= 40; i++) {
final row = await seed('$i');
remote.add(record('$i'));
if (i == 1) {
await File(row.path!).rename('${row.path}.pending-delete');
}
}
expect((await sync()).skipped, 40);
expect([imageRequests, extractionCalls, upserts], [0, 0, 0]);
expect(await File('$facePath/1.jpg').exists(), isTrue);
});
test('同一版本坏图不重复下载提取,版本更新后立即重试', () async {
await seed('1');
remote.add(record('1', updated: DateTime(2027)));
rejectFacePhoto = true;
expect((await sync()).failed, 1);
expect((await sync()).failed, 1);
expect([imageRequests, extractionCalls, upserts], [1, 1, 0]);
expect(
(await database.getFaceDao.findBySampleId('1'))!.faceModel, [1, 2, 3]);
rejectFacePhoto = false;
remote[0] = record('1', updated: DateTime(2028));
expect((await sync()).updated, 1);
expect([imageRequests, extractionCalls, upserts], [2, 2, 1]);
});
test('特征提交失败时恢复旧照片、特征和数据库', () async {
final old = await seed('1');
remote.add(record('1', updated: DateTime(2027)));
failUpsertOnce = true;
expect((await sync()).failed, 1);
final row = (await database.getFaceDao.findBySampleId('1'))!;
expect(row.path, old.path);
expect(row.faceModel, [1, 2, 3]);
expect(await File(old.path!).readAsBytes(), [1, 2, 3]);
expect(memory['1']!['feature'], [1, 2, 3]);
expect((await sync()).updated, 1);
});
test('空部门清单删除最后一张照片,外部门照片保留', () async {
final old = await seed('1', dept: '11');
await seed('2', dept: '99');
expect((await sync()).deleted, 1);
expect(await database.getFaceDao.findBySampleId('1'), isNull);
expect(await database.getFaceDao.findBySampleId('2'), isNotNull);
expect(await File(old.path!).exists(), isFalse);
expect(memory.containsKey('1'), isFalse);
});
test('SQLite 删除失败会恢复原生特征和文件,并准确计入失败数', () async {
final old = await seed('1');
await database.database.execute(
"CREATE TRIGGER reject_face_delete BEFORE DELETE ON face BEGIN SELECT RAISE(ABORT, 'injected'); END");
final result = await sync();
expect(result.deleted, 0);
expect(result.failed, 1);
expect(await database.getFaceDao.findBySampleId('1'), isNotNull);
expect(await File(old.path!).exists(), isTrue);
expect(memory['1']!['feature'], [1, 2, 3]);
});
test('分页中断时不执行任何人脸更新或孤儿删除', () async {
final old = await seed('1');
invalidSnapshot = true;
await expectLater(sync(), throwsA(isA<FaceSyncSnapshotException>()));
expect(await database.getFaceDao.findBySampleId('1'), isNotNull);
expect(await File(old.path!).exists(), isTrue);
expect(imageRequests, 0);
});
test('全量冲突编号隔离并保留本地绑定,正常编号继续处理', () async {
await seed('1');
allRemote.addAll([
{
'id': '101',
'autoCode': '1',
'userId': '20',
'faceImg': imageUrl,
'deptId': '10'
},
{
'id': '102',
'autoCode': '01',
'userId': '21',
'faceImg': imageUrl,
'deptId': '10'
},
]);
final result = await sync(all: true);
expect(result.failed, 1);
expect(result.deleted, 0);
expect((await database.getFaceDao.findBySampleId('1'))!.userId, '20');
expect([imageRequests, upserts], [0, 0]);
});
test('V2 删档前复核发现清单变化,整轮中止且不做本地变更', () async {
final old = await seed('1');
allRemote.add({
'id': '102',
'autoCode': '2',
'userId': '20',
'faceImg': imageUrl,
'deptId': '10'
});
changingAllSnapshot = true;
await expectLater(
sync(all: true), throwsA(isA<FaceSyncSnapshotException>()));
expect(await File(old.path!).exists(), isTrue);
expect(await database.getFaceDao.findBySampleId('1'), isNotNull);
expect([imageRequests, upserts], [0, 0]);
});
test('上次中断遗留的待删图片恢复后直接跳过,无需下载', () async {
final old = await seed('1');
await File(old.path!).rename('${old.path}.pending-delete');
remote.add(record('1'));
expect((await sync()).skipped, 1);
expect(await File(old.path!).exists(), isTrue);
expect(imageRequests, 0);
});
test('质量批次请求失败留待下次,不拆成多批请求拖慢本次同步', () async {
for (var i = 1; i <= 40; i++) {
await seed('$i', quality: true);
remote.add(record('$i'));
}
rejectQualityReports = true;
expect((await sync()).qualityReportNote, isNotNull);
expect(qualityReports, 1);
expect(
(await database.getFaceDao.getUnreportedQualityProfiles()).length, 40);
});
test('服务端明确拒收的历史质量记录落确认标记,不在下轮重复上报', () async {
await seed('1', quality: true);
remote.add(record('1'));
expect((await sync()).qualityReportNote, isNull);
expect((await sync()).qualityReportNote, isNull);
expect(qualityReports, 1);
expect(await database.getFaceDao.getUnreportedQualityProfiles(), isEmpty);
});
}