ac37794d43
Review follow-ups on 0fa23e0 (Task 2), applied as one commit:
- Path-precise dupPaths (#2, user-visible): DuplicateMatch gains
sourcePath (local match fills it from the matched pick; unified
server branch from picks[hit.index].path). Both pages' duplicate
dialogs derive dupPaths from duplicates' sourcePath instead of
basename matching, so same-basename siblings (different dir/bytes)
are no longer mis-flagged — "跳过重复" no longer drops the
non-duplicate sibling. Stale basename-derivation comment removed.
- Single shared stat (#3): files are stat'd once in
checkDuplicatesUnified (_statPicks) and the picks feed both the
local half (extracted _matchLocalHistory; public checkDuplicates
folded — it had no remaining callers) and the server request.
Halves stat IO for large batches (was ~2x).
- Union test (#1): new test/features/uploads/enqueue_test.dart pins
that a file hit by BOTH local and server history yields ONE
DuplicateMatch with the local uploadedAt (server's echoed time does
not override). Merge dedup is now keyed by sourcePath
(putIfAbsent, local first).
Tests: +1 enqueue union test (real-async chain inside one runAsync
window; sync file/dir setup — fake-async body must not await real
IO), +1 album_detail same-basename sibling test (skip-duplicates
enqueues exactly the non-hit sibling, distinguished by bytes since
sourceFilePath becomes the preparer's staging copy).
Gates: flutter analyze clean; full flutter test 429/429 green.
Co-Authored-By: Claude Code <noreply@anthropic.com>
156 lines
5.0 KiB
Dart
156 lines
5.0 KiB
Dart
/// checkDuplicatesUnified 单元测试(Task 2 评审 #1/#3)。
|
||
///
|
||
/// 页级测试覆盖弹窗三态;本文件钉住合并语义:同一文件被本机 TaskStore
|
||
/// 与服务器历史同时命中 → 单条结果(不重复呈现)且本地 uploadedAt 优先。
|
||
/// 经 Consumer 捕获 WidgetRef 驱动真实函数——drift/文件 stat 均真实异步,
|
||
/// 整体跑在 runAsync(真实事件循环)窗口内(同页级测试的 real-io 模式;
|
||
/// fake-async 区内不做真实异步 IO)。
|
||
library;
|
||
|
||
import 'dart:io';
|
||
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter/widgets.dart' show SizedBox;
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:photo_live/core/api_client.dart';
|
||
import 'package:photo_live/core/session_store.dart';
|
||
import 'package:photo_live/features/uploads/enqueue.dart';
|
||
import 'package:photo_live/features/uploads/task_state.dart';
|
||
import 'package:photo_live/features/uploads/task_store.dart';
|
||
|
||
import '../../helpers/fakes.dart';
|
||
|
||
/// JPEG 魔数最小字节(同页级测试)。
|
||
const List<int> kJpgBytes = <int>[
|
||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||
];
|
||
|
||
void main() {
|
||
testWidgets('本机∪服务器:同文件双命中 → 单条结果 + sourcePath + 本地时间优先',
|
||
(tester) async {
|
||
final session = FakeSessionStore()
|
||
..session = Session(
|
||
token: 'tok-1',
|
||
expiresAt: DateTime.utc(2099, 1, 1),
|
||
account: 'alice',
|
||
);
|
||
final store = TaskStore.memory();
|
||
final tmp = Directory.systemTemp.createTempSync('enqueue-test-');
|
||
addTearDown(() async {
|
||
await store.close();
|
||
try {
|
||
tmp.deleteSync(recursive: true);
|
||
} on Object {
|
||
// Windows 句柄残留容忍(同页级测试)。
|
||
}
|
||
});
|
||
|
||
// 层 2 命中源:服务器预检命中 pick1(index 0;uploadedAt 早于本地)。
|
||
final adapter = FakeAdapter(
|
||
(o) => (
|
||
200,
|
||
{
|
||
'duplicates': [
|
||
{
|
||
'index': 0,
|
||
'bytes': kJpgBytes.length,
|
||
'uploadedAt': '2020-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
),
|
||
);
|
||
final client = ApiClient(
|
||
sessionStore: session,
|
||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||
..httpClientAdapter = adapter,
|
||
);
|
||
|
||
late WidgetRef capturedRef;
|
||
await tester.pumpWidget(
|
||
ProviderScope(
|
||
overrides: [
|
||
...pageTestOverrides(
|
||
session: session,
|
||
taskStore: store,
|
||
client: client,
|
||
),
|
||
],
|
||
child: Consumer(
|
||
builder: (context, ref, _) {
|
||
capturedRef = ref;
|
||
return const SizedBox();
|
||
},
|
||
),
|
||
),
|
||
);
|
||
|
||
// 本批照片(同步写盘;pick2 字节不同 = 两层的非命中)。
|
||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||
final pick2 = File('${tmp.path}/pick2.jpg')
|
||
..writeAsBytesSync([...kJpgBytes, 0x00]);
|
||
|
||
// 层 1 命中源 + 统一查重:真实异步链整体在真实事件循环内完成。
|
||
final seededAt = DateTime.utc(2026, 9, 20, 12);
|
||
late final DateTime seedCreatedAt;
|
||
late List<DuplicateMatch>? result;
|
||
await tester.runAsync(() async {
|
||
final seed = await store.create(
|
||
UploadTaskDraft(
|
||
id: 'task-seed',
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
clientTaskId: 'ct-seed',
|
||
sourceFilePath: '${tmp.path}/seed.jpg',
|
||
filename: 'pick1.jpg',
|
||
bytes: kJpgBytes.length,
|
||
contentType: 'image/jpeg',
|
||
),
|
||
now: seededAt,
|
||
);
|
||
for (final state in [
|
||
UploadTaskState.queued,
|
||
UploadTaskState.uploading,
|
||
UploadTaskState.uploadedConfirming,
|
||
UploadTaskState.completed,
|
||
]) {
|
||
await store.updateState(seed.id, state);
|
||
}
|
||
seedCreatedAt = seed.createdAt;
|
||
|
||
result = await checkDuplicatesUnified(
|
||
capturedRef,
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePaths: [pick1.path, pick2.path],
|
||
);
|
||
});
|
||
|
||
expect(result, isNotNull, reason: '双命中至少一条');
|
||
final matches = result!;
|
||
expect(
|
||
matches, hasLength(1), reason: '同一文件本地+服务器双命中合并为单条(不重复呈现)');
|
||
expect(
|
||
matches.single.sourcePath,
|
||
pick1.path,
|
||
reason: '重复项携带完整源路径(路径精确派生 dupPaths 的依据)',
|
||
);
|
||
expect(
|
||
matches.single.uploadedAt,
|
||
seedCreatedAt,
|
||
reason: '本地历史时间优先(服务器回显 2020 不覆盖本地 2026-09-20)',
|
||
);
|
||
expect(
|
||
adapter.requests,
|
||
hasLength(1),
|
||
reason: '服务器预检发起(≤500 项单请求)',
|
||
);
|
||
expect(
|
||
(adapter.requests.single.body as Map<String, dynamic>)['items'],
|
||
hasLength(2),
|
||
reason: '两张均送预检(含本地已命中的那张——union 语义不做短路)',
|
||
);
|
||
});
|
||
}
|