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.
481 lines
17 KiB
Dart
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);
|
|
});
|
|
}
|