From 81a2c169ff0cbc5ff486ca3783ded7f8665a6faa Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:02:48 +0900 Subject: [PATCH] =?UTF-8?q?modular=5Fsync=20F1:=20=EB=A7=9E=EC=B6=94?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EB=A1=9C=EC=BB=AC=20=C2=B7=20=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=20=EB=AA=A8=EB=91=90=20=EA=B0=99=EC=9D=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=ED=95=98=EB=82=98=20(anyMirror)=20-=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=EB=8F=84=20=EA=B1=B0=EB=A5=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=EC=9D=84=20=EB=94=B0=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileOps.mirror 가 늘 anyMirror 를 부름. localMirror (거르기를 무시 · 같은 이름 폴더 충돌 (M1) 을 모름) 를 지움 - anyMirror 의 같은 파일 판단: SameFile.forAppSync(remote: 경로에 원격이 있는지, move: strict) - "받는 쪽이 더 새것도 같음" 은 원격일 때만 (로컬끼리는 크기 + ±2초 그대로) - 약속 시험: mirror_one_impl (실제 폴더 - 거르기: 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않음 · 나머지는 --delete, 미리 보기와 같음 / 같은 이름 폴더: 지우기 없으면 건너뛰고 알림 · 있으면 파일로). same_file 의 원격 경우는 실제 WebDAV 시험 서버로 사용자가 보게 될 동작: 로컬끼리 맞추기 (현재 방식 · 실시간 동기화) 가 rsync 옵션의 거르기를 따름 - 미리 보기에서 빠진 것은 실제로도 보내지 않고 받는 쪽의 거른 것은 지우지 않음 (예전에는 미리 보기와 달리 다 보내고 다 지웠다). 받는 쪽의 같은 이름 폴더는 지우기 포함일 때만 바꿈 확인: analyze 0 · 단위 1484 ×2 · 통합 6/6 ×2 · base10 214장 0px · rsync_page · copy_sync_shot · lsync_p0_shot · live_excl_shot ×2 Co-Authored-By: Claude Opus 5.5 --- lib/core/file_ops.dart | 4 +- lib/core/files/any_transfer.dart | 3 +- lib/core/files/local_transfer.dart | 68 ------------------------ test/file_ops_modules_contract_test.dart | 2 +- test/mirror_one_impl_contract_test.dart | 49 +++++++++++++++++ test/same_file_contract_test.dart | 53 +++++++++++------- 6 files changed, 88 insertions(+), 91 deletions(-) create mode 100644 test/mirror_one_impl_contract_test.dart diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index dbada26..362fead 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -75,8 +75,8 @@ class FileOps extends TransferControl { {bool delete = false, bool update = false, bool allowEmptySource = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async { // 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (읽지 못하면 원본이 빈 것으로 보여 대상이 모두 지워졌다) if (delete) await ensureSourceForDelete(srcDir, dstDir, allowEmpty: allowEmptySource); - if (_anyDav([srcDir, dstDir])) return anyMirror(this, srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter); - return local.localMirror(this, srcDir, dstDir, delete: delete, update: update); + // F1 (modular_sync): 로컬 · 원격 모두 같은 구현 하나 - 예전 로컬 맞추기는 거르기 (filter) 를 무시하고 같은 이름 폴더 충돌 (M1) 도 몰랐다 + return anyMirror(this, srcDir, dstDir, delete: delete, update: update, strict: strict, filter: filter); } Future delete(List paths) => local.deletePaths(this, paths); diff --git a/lib/core/files/any_transfer.dart b/lib/core/files/any_transfer.dart index 75d6a9d..6b8368c 100644 --- a/lib/core/files/any_transfer.dart +++ b/lib/core/files/any_transfer.dart @@ -171,7 +171,8 @@ Future _deleteExcept(String dir, Set keep) async { Future anyMirror(TransferControl c, String srcDir, String dstDir, {bool delete = false, bool update = false, bool strict = false, RsyncFilter filter = RsyncFilter.none}) async { var n = 0; - final same = SameFile(uploadTime: !strict); + // 받는 쪽 시각이 올린 때인 곳은 원격이 낄 때만 (로컬끼리는 크기 + ±2초) - 미리 보기와 같은 판단 (F1 · F2) + final same = SameFile.forAppSync(remote: isDav(srcDir) || isDav(dstDir), move: strict); // 관리자 지시 (10/19): 거르기는 rsync 와 같게 (미리 보기 sync_preview 와 같은 규칙) - 거른 것은 보내지 않고 받는 쪽의 거른 것은 지우지 않는다 // (--delete-excluded 면 지운다). 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 폴더는 남기고 거르지 않은 것만 지운다 Future hasExcluded(String dir, String rel) async { diff --git a/lib/core/files/local_transfer.dart b/lib/core/files/local_transfer.dart index 35e35d0..069919d 100644 --- a/lib/core/files/local_transfer.dart +++ b/lib/core/files/local_transfer.dart @@ -6,7 +6,6 @@ import '../../l10n/tr.dart'; import '../vfs.dart'; import 'path_rules.dart'; import 'transfer_control.dart'; -import 'transfer_guard.dart'; import 'same_file.dart'; // 약속 (나누기 file_ops): 로컬 (같은 기기의 폴더 · 네트워크 공유) 끼리의 복사 · 이동 · 맞추기 · 지우기 · 크기. @@ -198,73 +197,6 @@ Future> localMove(TransferControl c, List sources, String d return made; } -/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다 (로컬끼리). -/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. -/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다. 지우기 전 원본 확인 (ensureSourceForDelete) 은 부르는 쪽이 한다. -Future localMirror(TransferControl c, String srcDir, String dstDir, {bool delete = false, bool update = false}) async { - var n = 0; - Future walk(String src, String dst) async { - c.check(); - await Directory(dst).create(recursive: true); - final names = {}; - // 원본 목록을 다 읽은 뒤에 맞춘다: 읽다 실패하면 (그 폴더에서) 아무것도 지우지 않고 멈춘다 - List items; - try { - items = await Directory(src).list(followLinks: false).toList(); - } catch (e) { - throw SourceUnreadableException(src, cause: e); - } - for (final e in items) { - final name = p.basename(e.path); - if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞추지 않는다 - names.add(name); - final target = p.join(dst, name); - if (e is Directory) { - await walk(e.path, target); - } else if (e is File) { - final st = await e.stat(); - final t = File(target); - final exists = await t.exists(); - // 같은 파일 판단은 SameFile 하나 (로컬끼리는 크기 + 바뀐 때 ±2초) - if (exists && const SameFile().same(size: st.size, modified: st.modified, targetSize: await t.length(), targetModified: await t.lastModified())) { - c.onSettled?.call(e.path, st.size, st.modified); - continue; - } - if (update && exists && SameFile.targetNewer(await t.lastModified(), st.modified)) continue; - final tmp = '$target.jjsync'; - final out = File(tmp).openWrite(); - try { - await for (final chunk in e.openRead()) { - c.check(); - out.add(chunk); - c.onBytes?.call(chunk.length); - await c.throttle(chunk.length); - } - } finally { - await out.close(); - } - await File(tmp).rename(target); - try { - await File(target).setLastModified(st.modified); - } catch (_) {} - n++; - c.onFileDone?.call(e.path); - c.onSettled?.call(e.path, st.size, st.modified); - } - } - if (delete) { - await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { - if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync') && !isAppTempName(p.basename(e.path))) { - await e.delete(recursive: true); - } - } - } - } - - await walk(srcDir, dstDir); - return n; -} - /// 지우기 (로컬 · WebDAV). 없는 것은 그냥 지나간다. 항목마다 취소를 본다 Future deletePaths(TransferControl c, List paths) async { for (final s in paths) { diff --git a/test/file_ops_modules_contract_test.dart b/test/file_ops_modules_contract_test.dart index ca9b76d..cb3f8db 100644 --- a/test/file_ops_modules_contract_test.dart +++ b/test/file_ops_modules_contract_test.dart @@ -112,7 +112,7 @@ void main() { expect(File(moved.single).readAsStringSync(), 'hello'); final m = p.join(tmp.path, 'm'); mk('m/old.txt'); - expect(await localMirror(TransferControl(), p.join(tmp.path, 'd'), m, delete: true), 2); + expect(await anyMirror(TransferControl(), p.join(tmp.path, 'd'), m, delete: true), 2, reason: 'F1: 로컬 맞추기도 anyMirror 하나'); expect(File(p.join(m, 'old.txt')).existsSync(), isFalse); final c = TransferControl()..cancel(); await expectLater(localCopy(c, [moved.single], p.join(tmp.path, 's')), throwsA(isA())); diff --git a/test/mirror_one_impl_contract_test.dart b/test/mirror_one_impl_contract_test.dart new file mode 100644 index 0000000..ab0303c --- /dev/null +++ b/test/mirror_one_impl_contract_test.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show FileOps; +import 'package:jj_mkvmaker/core/rsync_filter.dart'; +import 'package:jj_mkvmaker/core/sync_preview.dart'; +import 'package:path/path.dart' as p; + +/// modular_sync F1 약속: 맞추기는 로컬 · 원격 모두 같은 구현 하나 (anyMirror) - 예전 로컬 맞추기는 거르기 (filter) 를 무시하고 +/// 받는 쪽의 같은 이름 폴더 충돌도 몰랐다. 실제 시험 폴더 · 실제 맞추기, 실행 전 미리 보기와 같은 결과 +void main() { + late Directory tmp; + late String s, m; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_mirror1_'); + s = p.join(tmp.path, 's'); + m = p.join(tmp.path, 'm'); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + void mk(String path, [String text = 'x']) => File(path) + ..createSync(recursive: true) + ..writeAsStringSync(text); + bool there(String rel) => FileSystemEntity.typeSync(p.join(m, rel)) != FileSystemEntityType.notFound; + + test('로컬도 거르기를 따른다: 거른 것은 보내지 않고 · 받는 쪽의 거른 것은 지우지 않음 · 나머지는 --delete 로 지움 (미리 보기와 같음)', () async { + mk(p.join(s, 'keep.txt')); + mk(p.join(s, 'skip.tmp')); + mk(p.join(s, 'cache', 'x.txt')); + mk(p.join(m, 'old.tmp')); + mk(p.join(m, 'extra.txt')); + final filter = RsyncFilter.parse('--exclude=*.tmp --exclude=cache/'); + final preview = await previewSync(s, m, toRight: true, delete: true, filter: filter); + final adds = {for (final i in preview) if (i.action == PreviewAction.add) i.rel}; + final dels = {for (final i in preview) if (i.action == PreviewAction.delete) i.rel}; + expect([adds, dels], [{'keep.txt'}, {'extra.txt'}]); + await FileOps().mirror(s, m, delete: true, filter: filter); + expect([there('keep.txt'), there('skip.tmp'), there('cache'), there('old.tmp'), there('extra.txt')], [true, false, false, true, false]); + }); + + test('받는 쪽에 같은 이름의 폴더: 지우기 없으면 건너뛰고 알림 · 지우기 포함이면 폴더를 지우고 파일로', () async { + mk(p.join(s, 'clash'), 'file'); + mk(p.join(m, 'clash', 'inner.txt')); + final ops = FileOps(); + await ops.mirror(s, m); + expect([FileSystemEntity.isDirectorySync(p.join(m, 'clash')), ops.dirConflicts], [true, [p.join(m, 'clash')]]); + await FileOps().mirror(s, m, delete: true); + expect(File(p.join(m, 'clash')).readAsStringSync(), 'file'); + }); +} diff --git a/test/same_file_contract_test.dart b/test/same_file_contract_test.dart index 0cf06ea..a739d7e 100644 --- a/test/same_file_contract_test.dart +++ b/test/same_file_contract_test.dart @@ -2,11 +2,13 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/files/any_transfer.dart'; -import 'package:jj_mkvmaker/core/files/local_transfer.dart'; import 'package:jj_mkvmaker/core/files/same_file.dart'; import 'package:jj_mkvmaker/core/files/transfer_control.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:path/path.dart' as p; +import 'package:jj_mkvmaker/core/webdav.dart'; + +import 'support/dav_server.dart'; /// modular_sync F2 약속: "같은 파일" 판단은 SameFile 하나 - 실행 전 미리 보기와 실제 맞추기가 같은 결과를 낸다 (실제 파일 · 실제 맞추기) void main() { @@ -29,7 +31,9 @@ void main() { group('미리 보기 = 실제 맞추기 (받는 쪽 같은 크기 · 1분 더 새것)', () { late Directory tmp; late String s, m; - setUp(() { + late TestDavServer server; + // 받는 쪽: 로컬 m 과, 실제 WebDAV 시험 서버 (dav://nas/m) 에 같은 파일 + setUp(() async { tmp = Directory.systemTemp.createTempSync('jj_samefile_'); s = p.join(tmp.path, 's'); m = p.join(tmp.path, 'm'); @@ -37,33 +41,44 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('SRC') ..setLastModifiedSync(t0); - File(p.join(m, 'a.txt')) - ..createSync(recursive: true) - ..writeAsStringSync('DST') - ..setLastModifiedSync(t0.add(const Duration(minutes: 1))); + for (final dir in [m, p.join(tmp.path, 'remote', 'm')]) { + File(p.join(dir, 'a.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('DST') + ..setLastModifiedSync(t0.add(const Duration(minutes: 1))); + } + server = TestDavServer(Directory(p.join(tmp.path, 'remote'))); + await server.start(); + DavRegistry.configure([DavServer(id: 'nas', name: 'NAS', url: server.url, user: 'user', password: 'pass')]); }); - tearDown(() => tmp.deleteSync(recursive: true)); - String there() => File(p.join(m, 'a.txt')).readAsStringSync(); + tearDown(() async { + DavRegistry.configure([]); + await server.stop(); + tmp.deleteSync(recursive: true); + }); + String localThere() => File(p.join(m, 'a.txt')).readAsStringSync(); + String remoteThere() => File(p.join(tmp.path, 'remote', 'm', 'a.txt')).readAsStringSync(); + const remote = 'dav://nas/m'; test('원격이 낀 앱 맞추기: 미리 보기에 없고 · 실제로도 보내지 않음', () async { - final items = await previewSync(s, m, toRight: true, same: SameFile.forAppSync(remote: true, move: false)); + final items = await previewSync(s, remote, toRight: true, same: SameFile.forAppSync(remote: true, move: false)); expect(items, isEmpty); - expect(await anyMirror(TransferControl(), s, m), 0); - expect(there(), 'DST'); + expect(await anyMirror(TransferControl(), s, remote), 0); + expect(remoteThere(), 'DST'); }); - test('이동 (원본을 지움): 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄', () async { - final items = await previewSync(s, m, toRight: true, same: SameFile.forAppSync(remote: true, move: true)); + test('원격 이동 (원본을 지움): 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄', () async { + final items = await previewSync(s, remote, toRight: true, same: SameFile.forAppSync(remote: true, move: true)); expect([items.single.action, items.single.newer], [PreviewAction.update, true]); - expect(await anyMirror(TransferControl(), s, m, strict: true), 1); - expect(there(), 'SRC'); + expect(await anyMirror(TransferControl(), s, remote, strict: true), 1); + expect(remoteThere(), 'SRC'); }); - test('로컬끼리: 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄', () async { - final items = await previewSync(s, m, toRight: true); + test('로컬끼리: 미리 보기에 "바뀜 (더 새 것)" · 실제로도 보냄 (F1: 로컬도 같은 구현)', () async { + final items = await previewSync(s, m, toRight: true, same: SameFile.forAppSync(remote: false, move: false)); expect([items.single.action, items.single.newer], [PreviewAction.update, true]); - expect(await localMirror(TransferControl(), s, m), 1); - expect(there(), 'SRC'); + expect(await anyMirror(TransferControl(), s, m), 1); + expect(localThere(), 'SRC'); }); }); }