import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/live_sync.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/webdav.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:path/path.dart' as p; import 'support/dav_server.dart'; import 'support/rsync_scenes.dart'; /// 관리자 지시 (10/20): H · S · P · R 과 고치개 ,s · ,r 을 rsync 와 똑같이 따른다 - 기준은 실제 rsync 를 돌린 (-n 아님) 결과 트리. /// 장면 · 옵션마다 ① 미리 보기 (previewSync 로 예측한 트리) ② 원격 맞추기 (WebDAV 시험 서버) ③ 실시간 동기화 (로컬 rsync 쌍: /// 묻는 목록 = rsync 가 지운 것 · 맞춘 뒤 맞출 것 없음) ④ 실시간 동기화 (WebDAV 쌍) 가 모두 실제 rsync 와 같아야 한다. /// 하나라도 다르면 그 규칙은 미리 볼 수 없는 것 (unsupported) 으로 남겨야 한다. /// 보내는 쪽 (양쪽에 같은 이름이 많게 - H · S 는 보내기, P · R 은 지우기에 걸리게) const _src = [ 'a.txt', 'b.log', 'keep.log', 'both.txt', 'both.log', 'shared/x.txt', 'shared/y.log', 'build/x.o', 'build/sub/y.o', 'sub/c.txt', 'sub/deep/d.txt', 'sub/deep/e.log', 'tmp/t.txt', '한글 폴더/가.txt', 'docs/r.md', 'z.txt', 'keep/k.txt', ]; /// 받는 쪽 (같은 이름은 크기가 달라 늘 바뀜 · 받는 쪽에만 있는 것은 지우기 대상) const _dst = [ 'a.txt', 'keep.log', 'both.txt', 'both.log', 'shared/x.txt', 'shared/onlyd.log', 'shared/onlyd.txt', 'build/old.o', 'old/keep.log', 'old/gone.txt', 'old2/deep/x/keep.log', 'old2/deep/x/g.txt', 'old2/e.txt', 'tmp/old.txt', 'extra.txt', 'extra.log', 'sub/deep/z.txt', '한글 폴더/나.txt', 'onlydst/a.bak', // 28 확인 (10/20): 원본에도 있는 폴더 안의, 받는 쪽에만 있는 파일 · 하위 폴더 ("P keep/***" 가 안쪽까지 지키는지) 'keep/extra.txt', 'keep/sub/deep.txt', ]; /// 규칙 장면 (지우기 포함이 많게 - 지우기가 데이터 쪽 위험) const phsrOptions = [ '-a --delete --filter="H *.log"', '-a --delete --filter="-,s *.log"', '-a --delete --filter="P *.log"', '-a --delete --filter="-,r *.log"', '-a --delete --filter="P old/"', '-a --delete --filter="R *.log" --exclude=*.log', '-a --delete --exclude=*.log --filter="R keep.log"', '-a --delete --filter="R keep.log" --exclude=*.log', '-a --delete --exclude=*.log --delete-excluded --filter="P keep.log"', '-a --delete --filter="P keep.log" --exclude=*.log --delete-excluded', '-a --delete --filter="S a.txt" --filter="H *.txt"', '-a --delete --filter="H *.txt" --filter="S a.txt"', '-a --delete --filter="H build/"', '-a --delete --filter="P build/"', '-a --delete --filter="-,sr *.log"', '-a --delete --filter="-,sr *.log" --delete-excluded', '-a --delete --filter="hide *.log"', '-a --delete --filter="protect /old2/"', '-a --delete --filter="risk *.txt" --filter="P *"', '-a --delete --filter="P *" --filter="R *.txt"', '-a --delete -f"H deep/" -f"P z.txt"', '-a --delete --filter="H tmp/" --filter="P tmp/old.txt"', '-a --delete --filter="+,r old/***" --exclude=old/', '-a --delete --filter="H 한글*/"', '-a --delete --filter="S *.log" --exclude=*.log', '-a --delete --filter="+,s *.log" --exclude=*.log', '-a --delete --filter="show shared/" --filter="hide shared/*"', '-a --delete --filter="H *.log" --delete-excluded', '-a --delete --filter="P shared/onlyd.*" --filter="H shared/"', '-a --filter="H *.log"', '-a --filter="P *.log" --exclude=*.txt', // 28 확인 (10/20): "이름/***" 이 안쪽 (받는 쪽에만 있는 하위 폴더) 까지 '-a --delete --filter="R keep/extra.txt" --filter="P keep/***"', '-a --delete --filter="P keep/***"', '-a --delete --exclude=keep/***', '-a --delete --filter="H keep/***"', ]; void main() { TestWidgetsFlutterBinding.ensureInitialized(); final rsync = sceneRsync; late Directory tmp, remote; late TestDavServer server; const id = 'nas'; DavServer dav() => DavServer(id: id, name: '시험', url: server.url, user: 'user', password: 'pass'); setUp(() async { // 위젯 시험 바인딩은 HttpClient 를 가짜 (늘 400) 로 바꾼다 - 시험 WebDAV 서버 (127.0.0.1) 에 실제로 묻는다 HttpOverrides.global = null; tmp = Directory.systemTemp.createTempSync('jj_phsr_'); remote = Directory(p.join(tmp.path, 'remote'))..createSync(); server = TestDavServer(remote); await server.start(); DavRegistry.configure([dav()]); }); tearDown(() async { DavRegistry.configure([]); await server.stop(); tmp.deleteSync(recursive: true); }); test('이 규칙들은 이제 미리 볼 수 있다 (unsupported 없음) · 그 밖의 고치개 · merge 는 그대로 알림', () { for (final o in phsrOptions) { expect(RsyncFilter.parse(o).unsupported, isEmpty, reason: o); } for (final o in ['--filter="-,! a"', '--filter="-,p a"', '--filter="-,x a"', '--filter="H,r a"', '--filter=". rules"', '--filter="merge x"', '--filter="!"']) { expect(RsyncFilter.parse(o).unsupported, isNotEmpty, reason: o); } // 쪽: H 는 보내기에만 (받는 쪽을 지키지 않음) · P 는 지우기에만 (보내기는 그대로) final h = RsyncFilter.parse('--filter="H *.log"'); expect((h.excluded('a.log', isDir: false), h.protects('a.log', isDir: false)), (true, false)); final pr = RsyncFilter.parse('--filter="P *.log"'); expect((pr.excluded('a.log', isDir: false), pr.protects('a.log', isDir: false)), (false, true)); // --delete-excluded: 쪽을 적지 않은 규칙은 보내기에만 · ,sr 로 적은 것은 양쪽 final de = RsyncFilter.parse('--exclude=*.log --filter="-,sr *.bak" --delete-excluded'); expect((de.excluded('a.log', isDir: false), de.protects('a.log', isDir: false)), (true, false)); expect((de.excluded('a.bak', isDir: false), de.protects('a.bak', isDir: false)), (true, true)); }); for (final (i, opts) in phsrOptions.indexed) { test('${i + 1}: $opts - 미리 보기 · 원격 맞추기 · 실시간 동기화 (로컬 · 원격) = 실제 rsync', () async { if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'); writeScene(src, want, srcFiles: _src, dstFiles: _dst); final before = treeOf(want); final w = await realRsyncTree(rsync, src, want, opts); final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; final delete = optionsDelete(opts); final filter = RsyncFilter.parse(opts); final why = []; // ① 미리 보기: 받는 쪽 트리 + 미리 보기 줄 → 예측한 트리 final pv = p.join(tmp.path, 'pv'); writeScene(src, pv, srcFiles: _src, dstFiles: _dst); final items = await previewSync(src, pv, toRight: true, delete: delete, filter: filter); final guess = Map.of(before); for (final x in items) { if (x.action == PreviewAction.delete) { guess.removeWhere((k, _) => k == x.rel || k.startsWith('${x.rel}/')); } } for (final x in items) { if (x.action == PreviewAction.add || x.action == PreviewAction.update) { guess[x.rel] = x.isDir ? '/' : File(p.joinAll([src, ...x.rel.split('/')])).readAsStringSync(); } } for (final d in treeDiff(w, guess)) { why.add('미리 보기: $d'); } // ② 원격 맞추기 (WebDAV) final got = p.join(remote.path, 'm'); writeScene(src, got, srcFiles: _src, dstFiles: _dst); await FileOps().mirror(src, 'dav://$id/m', delete: delete, filter: filter); for (final d in treeDiff(w, treeOf(got))) { why.add('원격 맞추기: $d'); } // ③ · ④ 실시간 동기화 (새 쌍 - 지울 것이 있으면 묻고, 확인하면 맞춤) final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..copy.rsyncSource = 'custom' ..copy.rsyncPath = rsync ..copy.rsyncOptions = opts ..remote.webdavServers = [dav()]; for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) { final dir = local ? target : p.join(remote.path, 'l'); writeScene(src, dir, srcFiles: _src, dstFiles: _dst); final pair = LiveSyncPair(src, target, method: 'rsync', delete: delete); c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final k = LiveSync.keyOf(pair); await live.syncNow(pair); if (live.status[k]!.$2.contains('실패')) why.add('$label 상태: ${live.status[k]!.$2}'); if (live.noAutoDelete[k] != null) why.add('$label: 미리 볼 수 없다고 함 ${live.noAutoDelete[k]}'); final asked = live.toDelete[k] ?? const []; for (final g in gone) { if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('$label: rsync 는 지우는데 묻지 않음: $g'); } for (final a in asked) { if (!gone.contains(a)) why.add('$label: rsync 는 지우지 않는데 물음: $a'); } if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true); for (final d in treeDiff(w, treeOf(dir))) { why.add('$label: $d'); } if ((live.pending[k] ?? const []).isNotEmpty) why.add('$label: 맞춘 뒤에도 맞출 것 ${live.pending[k]}'); live.dispose(); } expect(why, isEmpty, reason: '$opts\n ${why.join('\n ')}'); }, timeout: const Timeout(Duration(minutes: 2))); } }