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.
137 lines
5.0 KiB
Dart
137 lines
5.0 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pj_supermarket_cloud/common/index.dart';
|
|
import 'package:pj_supermarket_cloud/common/utils/sync/face_sync_plan.dart';
|
|
import 'package:pj_supermarket_cloud/common/utils/sync/face_image_download_queue.dart';
|
|
import 'package:pj_supermarket_cloud/common/services/database/type_converter/face_timestamp_converter.dart';
|
|
|
|
ServiceFaceSyncRecord record(String id, {DateTime? version, String? name}) =>
|
|
ServiceFaceSyncRecord(
|
|
id: id,
|
|
sampleId: id,
|
|
userId: '20',
|
|
deptId: '10',
|
|
faceImg: 'photo',
|
|
sourceUpdatedAt: version,
|
|
username: name);
|
|
|
|
FaceEntity local(String id, {DateTime? version, String? deptName}) =>
|
|
FaceEntity(
|
|
sampleId: id,
|
|
userId: '20',
|
|
userName: '',
|
|
deptId: '10',
|
|
deptName: deptName,
|
|
imagePath: 'photo',
|
|
path: '/$id.jpg',
|
|
faceModel: [1],
|
|
updateTime: version);
|
|
|
|
void main() {
|
|
test('重复同步与空部门名直接跳过,缺失版本只需校验一次照片', () {
|
|
final remote = [record('1'), record('2', version: DateTime(2026))];
|
|
final first = FaceSyncPlan.build(
|
|
remote: remote,
|
|
local: [local('1'), local('2')],
|
|
existingPaths: {'/1.jpg', '/2.jpg'});
|
|
expect(first.changes.map((x) => x.kind),
|
|
[FaceSyncChangeKind.unchanged, FaceSyncChangeKind.image]);
|
|
final second = FaceSyncPlan.build(
|
|
remote: remote,
|
|
local: [local('1'), local('2', version: DateTime(2026))],
|
|
existingPaths: {'/1.jpg', '/2.jpg'});
|
|
expect(second.changes.every((x) => x.kind == FaceSyncChangeKind.unchanged),
|
|
isTrue);
|
|
});
|
|
|
|
test('新增、照片版本变化和文件缺失只处理对应样本', () {
|
|
final plan = FaceSyncPlan.build(
|
|
remote: [record('1'), record('2', version: DateTime(2026)), record('3')],
|
|
local: [local('1'), local('2', version: DateTime(2025))],
|
|
existingPaths: {'/2.jpg'},
|
|
);
|
|
expect(
|
|
plan.changes.every((x) => x.kind == FaceSyncChangeKind.image), isTrue);
|
|
});
|
|
|
|
test('空部门权威清单可删除最后一张照片,并保留调试导入的外部门样本', () {
|
|
final foreign = local('2')..deptId = '99';
|
|
final plan = FaceSyncPlan.build(
|
|
remote: [],
|
|
local: [local('1'), foreign],
|
|
existingPaths: {},
|
|
scopeDeptIds: {'10', '11'});
|
|
expect(plan.orphans.map((x) => x.sampleId), ['1']);
|
|
});
|
|
|
|
test('冲突编号被保护,本地和远端重复清单会拒绝执行', () {
|
|
final plan = FaceSyncPlan.build(
|
|
remote: [],
|
|
local: [local('1'), local('2')],
|
|
existingPaths: {},
|
|
protectedSampleIds: {'1'});
|
|
expect(plan.orphans.map((x) => x.sampleId), ['2']);
|
|
expect(
|
|
() => FaceSyncPlan.build(
|
|
remote: [record('1'), record('1')], local: [], existingPaths: {}),
|
|
throwsA(isA<FaceSyncSnapshotException>()));
|
|
expect(
|
|
() => FaceSyncPlan.build(
|
|
remote: [], local: [local('1'), local('1')], existingPaths: {}),
|
|
throwsA(isA<FaceSyncSnapshotException>()));
|
|
});
|
|
|
|
test('时间版本保留微秒和时区,旧秒级格式仍可读取', () {
|
|
final converter = FaceTimestampConverter();
|
|
final version = DateTime.parse('2026-10-22T08:00:01.123456Z');
|
|
expect(converter.decode(converter.encode(version)), version);
|
|
expect(converter.decode('2026-10-22 08:00:01'),
|
|
DateTime(2026, 10, 22, 8, 0, 1));
|
|
final plan = FaceSyncPlan.build(
|
|
remote: [record('1', version: version)],
|
|
local: [local('1', version: version.toLocal())],
|
|
existingPaths: {'/1.jpg'});
|
|
expect(plan.changes.single.kind, FaceSyncChangeKind.unchanged);
|
|
});
|
|
|
|
test('消费未完成的预下载会等待同一任务,预取窗口始终有界', () async {
|
|
final tasks = <String, Completer<File>>{};
|
|
final calls = <String>[];
|
|
final discarded = <String>[];
|
|
final queue = FaceImageDownloadQueue(
|
|
records: List.generate(30, (i) => record('${i + 1}')),
|
|
download: (face) {
|
|
calls.add(face.sampleId);
|
|
return (tasks[face.sampleId] = Completer<File>()).future;
|
|
},
|
|
discard: (file) async => discarded.add(file.path),
|
|
);
|
|
expect(calls, ['1', '2', '3']);
|
|
final take = queue.take('1');
|
|
expect(calls.length, 3);
|
|
tasks['1']!.complete(File('/1'));
|
|
expect((await take)!.path, '/1');
|
|
expect(calls, ['1', '2', '3', '4']);
|
|
final closing = queue.dispose();
|
|
for (final id in ['2', '3', '4']) {
|
|
tasks[id]!.complete(File('/$id'));
|
|
}
|
|
await closing;
|
|
expect(calls.length, 4);
|
|
expect(discarded.toSet(), {'/2', '/3', '/4'});
|
|
expect(queue.consumedCount, 1);
|
|
});
|
|
|
|
test('下载异常被消费方接管,不产生未处理异步异常', () async {
|
|
final queue = FaceImageDownloadQueue(
|
|
records: [record('1')],
|
|
download: (_) async => throw const SocketException('offline'),
|
|
discard: (_) async {});
|
|
expect(await queue.take('1'), isNull);
|
|
await queue.dispose();
|
|
expect(queue.downloadCount, 1);
|
|
});
|
|
}
|