fix(app): path-precise duplicate matching; shared file stat; union test

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>
This commit is contained in:
浅念
2026-09-28 20:22:37 +08:00
parent 0fa23e015e
commit ac37794d43
5 changed files with 289 additions and 65 deletions
@@ -599,6 +599,66 @@ void main() {
expect(find.text('可能已上传过'), findsNothing);
expect(find.text('Added 1 photo(s) to the upload queue'), findsOneWidget);
});
testWidgets('同 basename 兄弟文件:仅命中那张被标记/跳过(评审 #2 路径精确)',
(tester) async {
// 批内两个同名文件(不同目录、不同字节):仅 dirA 那张服务器命中。
// basename 派生会把两张都误标为重复(「跳过重复」丢非重复项);
// 路径精确派生只标记命中那张。
final adapter = detailAdapter(
duplicateCheck: (
200,
{
'duplicates': [
{
'index': 0,
'bytes': kJpgBytes.length,
'uploadedAt': '2026-09-25T08:00:00Z',
},
],
},
),
);
// 同步建目录:fake-async 区内不做真实异步 IO(同 enqueue_test 教训)。
final dirA = Directory('${tmp.path}/a')..createSync();
final dirB = Directory('${tmp.path}/b')..createSync();
final hit = File('${dirA.path}/IMG_SAME.jpg')
..writeAsBytesSync(kJpgBytes);
final other = File('${dirB.path}/IMG_SAME.jpg')
..writeAsBytesSync([...kJpgBytes, 0x00]);
galleryPaths.addAll([hit.path, other.path]);
await pumpDetailForUpload(tester, adapter: adapter);
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
await tester.runAsync(() async {
(button.evaluate().single.widget as OutlinedButton).onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('可能已上传过'), findsOneWidget);
expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget,
reason: '仅命中那张计为重复(同名兄弟不误伤)');
expect(find.text('跳过重复,上传 1 张'), findsOneWidget);
// 「跳过重复」:仅未命中的同名兄弟入队,命中那张被跳过。
await tester.tap(find.text('跳过重复,上传 1 张'));
await tester.pump(const Duration(milliseconds: 100));
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(find.textContaining('1 张已入队,1 张重复已跳过'), findsOneWidget);
final tasks = await taskStore.listByAccount('alice');
expect(tasks, hasLength(1), reason: '命中那张被跳过,仅同名兄弟入队');
expect(
tasks.single.bytes,
kJpgBytes.length + 1,
reason: '入队的是未命中那张(字节不同可区分;sourceFilePath 已是'
'准备器复制路径,原始选图路径不再保留);命中那张(同 basename)'
'被精确跳过',
);
});
});
}