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/playlist.dart' show PlaylistMode; import 'package:jj_mkvmaker/core/vfs.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 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'support/rsync_scenes.dart'; void main() { late Directory tmp, remote, local; late TestDavServer server; const id = 'nas'; setUp(() async { tmp = Directory.systemTemp.createTempSync('jj_dav_'); remote = Directory(p.join(tmp.path, 'remote'))..createSync(); local = Directory(p.join(tmp.path, 'local'))..createSync(); server = TestDavServer(remote); await server.start(); DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]); }); tearDown(() async { DavRegistry.configure([]); await server.stop(); tmp.deleteSync(recursive: true); }); File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel)) ..parent.createSync(recursive: true) ..writeAsStringSync(text); File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel)) ..parent.createSync(recursive: true) ..writeAsStringSync(text); test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () { expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b'); expect(DavPath.parse('dav://nas').full, 'dav://nas/'); expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더'); expect(vDirname('dav://nas/a/b'), 'dav://nas/a'); expect(vDirname('dav://nas/a'), 'dav://nas/'); expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv'); expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue); expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue); expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse); expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse); expect(samePath('dav://nas/a', r'C:\a'), isFalse); }); test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async { rf('영상/a.mkv', 'aaaa'); rf('영상/sub/b.txt', 'b'); final top = await listEntries('dav://$id/'); expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]); final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name); expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]); final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더'); expect(made, 'dav://$id/영상/새 폴더'); expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue); final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv'); expect(renamed, 'dav://$id/영상/c.mkv'); expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa'); await FileOps().delete(['dav://$id/영상/sub']); expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse); // 잘못된 비밀번호 DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]); expect(() => listEntries('dav://$id/'), throwsA(isA().having((e) => e.status, 'status', 401))); }); test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async { lf('up/x.txt', 'xx'); lf('up/deep/y.bin', 'yyy'); var bytes = 0; final ops = FileOps(onBytes: (n) => bytes += n); expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']); expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy'); expect(bytes, 5); // 같은 이름이면 "이름 (2)" expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']); // WebDAV → 로컬 final down = Directory(p.join(local.path, 'down'))..createSync(); await ops.copy(['dav://$id/up'], down.path); expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx'); // 같은 서버 안 이동 (서버가 MOVE) await FileOps.makeFolder('dav://$id/', 'box'); await ops.move(['dav://$id/up (2)'], 'dav://$id/box'); expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue); expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse); expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty); // 로컬 → WebDAV 이동 (복사 후 원본 지움) lf('mv/m.txt', 'm'); await ops.move([p.join(local.path, 'mv')], 'dav://$id/box'); expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm'); expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse); }); test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async { lf('src/a.txt', 'a'); lf('src/sub/b.txt', 'b'); final ops = FileOps(); expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2); expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b'); // 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것) expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0); // 원본에서 내용 (크기) 이 바뀐 것만 lf('src/a.txt', 'aaa'); expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1); expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa'); // 지우기 포함: 대상에만 있는 것 rf('dst/extra.txt', 'e'); await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true); expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse); // -u: 대상이 더 새것이면 덮어쓰지 않음 rf('dst/a.txt', 'remote newer!!'); final old = DateTime.now().subtract(const Duration(days: 1)); File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old); await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true); expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!'); // 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음) final back = p.join(local.path, 'back'); expect(await ops.mirror('dav://$id/dst', back), 2); expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b'); expect(await ops.mirror('dav://$id/dst', back), 0); }); test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async { rf('p/e1/e2/.keep', ''); File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync(); rf('p/f/z.txt', 'z'); expect(await vCountFiles('dav://$id/p'), 1); expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1 expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue); expect(await DavRegistry.client(id).quota(), (1000000, 500)); final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache')); expect(File(got).readAsStringSync(), 'z'); expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로')); }); test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async { lf('live/a.txt', 'a'); lf('live/sub/b.txt', 'bb'); rf('mirror/old.txt', 'o'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); c.settings.liveSyncPairs = [pair]; final live = LiveSync(c); expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt'])); await live.syncNow(pair); expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb'); expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse); expect(live.pending[LiveSync.keyOf(pair)], isEmpty); live.dispose(); }); test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async { rf('영상/1화.mkv', 'v1'); rf('영상/2화.mkv', 'v2'); rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); rf('영상/메모.txt', 't'); final s = davStream('dav://$id/영상/1화.mkv'); expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}'); expect(s.headers['Authorization'], startsWith('Basic ')); // 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다 expect(vPlayable('dav://$id/영상/1화.mkv'), s.url); expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass'))); expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv'); // 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청) final http = HttpClient(); final req = await http.getUrl(Uri.parse(s.url)); s.headers.forEach(req.headers.set); final res = await req.close(); expect(await res.transform(const SystemEncoding().decoder).join(), 'v1'); http.close(); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.playlistMode = PlaylistMode.folder; final before = server.requests.where((r) => r.startsWith('GET')).length; final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']); expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']); expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음 final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv'); expect(subs.map(p.basename), ['1화.ko.srt']); expect(File(subs.single).readAsStringSync(), contains('안녕')); }); test('M1 (관리자 P0): 원본 파일 · 받는 쪽 같은 이름 폴더 - 지우기 없으면 폴더를 지우지 않고 건너뜀, 지우기면 지우고 파일로 · 미리 보기도 같음', () async { lf('src/x', 'file'); lf('src/ok.txt', 'ok'); rf('dst/x/keep.txt', 'k'); rf('dst/x/sub/deep.txt', 'd'); final src = p.join(local.path, 'src'); const dst = 'dav://$id/dst'; // 지우기 없음: 미리 보기 "건너뜀" · 지울 것 없음 → 실제로도 폴더 그대로 var pv = await previewSync(src, dst, toRight: true); expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']); expect(pv.where((x) => x.action == PreviewAction.delete), isEmpty); final ops = FileOps(); await ops.mirror(src, dst); expect(File(p.join(remote.path, 'dst', 'x', 'keep.txt')).readAsStringSync(), 'k', reason: '예전에는 이 폴더를 통째로 지웠다'); expect(File(p.join(remote.path, 'dst', 'x', 'sub', 'deep.txt')).existsSync(), isTrue); expect(File(p.join(remote.path, 'dst', 'ok.txt')).readAsStringSync(), 'ok', reason: '다른 것은 그대로 맞춤'); expect(ops.dirConflicts.map(vBasename), ['x']); // 지우기 포함: 미리 보기에 "폴더 · 안의 3개를 지우고 파일로" → 실제로 폴더가 파일로 pv = await previewSync(src, dst, toRight: true, delete: true); final del = pv.singleWhere((x) => x.action == PreviewAction.delete); expect((del.rel, del.isDir, del.replacedByFile, del.inner), ('x', true, true, 3)); final ops2 = FileOps(); await ops2.mirror(src, dst, delete: true); expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file'); expect(ops2.dirConflicts, isEmpty); }); /// 관리자 지시 (10/19): 원격 맞추기 (_mirrorV) 가 rsync 옵션의 거르기를 미리 보기와 같게 따르는지 - /// 미리 보기의 "새로 · 바뀜" 은 실제로 생기고, "지워짐" 은 실제로 없어지고, 그 밖의 받는 쪽 것은 그대로 Future checkSame(String src, String dst, String Function(String rel) real, String options, {required bool delete}) async { final filter = RsyncFilter.parse(options); final before = {}; void scan(Directory d, String rel) { for (final e in d.listSync()) { final r = rel.isEmpty ? p.basename(e.path) : '$rel/${p.basename(e.path)}'; before.add(r); if (e is Directory) scan(e, r); } } final root = Directory(real('')); if (root.existsSync()) scan(root, ''); final pv = await previewSync(src, dst, toRight: true, delete: delete, filter: filter); await FileOps().mirror(src, dst, delete: delete, filter: filter); for (final x in pv) { final there = FileSystemEntity.typeSync(real(x.rel)) != FileSystemEntityType.notFound; switch (x.action) { case PreviewAction.add || PreviewAction.update: expect(there, isTrue, reason: '미리 보기 + / ~ ${x.rel} → 실제로 있음 ($options)'); case PreviewAction.delete when !x.replacedByFile: expect(there, isFalse, reason: '미리 보기 − ${x.rel} → 실제로 지워짐 ($options)'); default: } } final shownDeleted = {for (final x in pv) if (x.action == PreviewAction.delete) x.rel}; for (final r in before) { final gone = FileSystemEntity.typeSync(real(r)) == FileSystemEntityType.notFound; final underShown = shownDeleted.any((d) => r == d || r.startsWith('$d/')); if (gone) expect(underShown, isTrue, reason: '실제로 지워진 $r 는 미리 보기 지울 목록에 ($options)'); } } test('관리자 지시 (10/19): WebDAV 맞추기가 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따르고 미리 보기와 같음', () async { void fresh() { for (final d in [Directory(p.join(local.path, 'src')), Directory(p.join(remote.path, 'dst'))]) { if (d.existsSync()) d.deleteSync(recursive: true); } lf('src/a.txt', 'a'); lf('src/skip.tmp', 's'); lf('src/cache/c.bin', 'c'); lf('src/keep/x.txt', 'x'); lf('src/keep/y.log', 'y'); lf('src/한글 폴더/가.txt', '가'); rf('dst/only.tmp', 'o'); // 받는 쪽에만 있고 거른 것 rf('dst/old.txt', 'old'); // 받는 쪽에만 있음 rf('dst/cache/remote.bin', 'r'); // 거른 폴더 rf('dst/gone/g.tmp', 'g'); // 받는 쪽에만 있는 폴더 안의 거른 것 rf('dst/gone/g.txt', 'g'); } final src = p.join(local.path, 'src'); const dst = 'dav://$id/dst'; String real(String rel) => p.joinAll([remote.path, 'dst', ...rel.split('/').where((s) => s.isNotEmpty)]); for (final (opts, delete) in [ ('-av --exclude=*.tmp --exclude=cache/', true), ('-av --exclude=*.tmp --exclude=cache/ --delete-excluded', true), ('-av --include=keep/ --include=keep/*.txt --exclude=keep/* --exclude=*.tmp', true), ('-av --filter="- *.log" --filter="- /cache"', true), ('-av --exclude=*.tmp', false), ]) { fresh(); await checkSame(src, dst, real, opts, delete: delete); } // 하나를 자세히: 거른 것은 보내지 않고 받는 쪽의 거른 것은 지키며, 거른 것이 든 폴더는 남기고 안의 것만 지움 fresh(); await FileOps().mirror(src, dst, delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/')); expect(File(real('a.txt')).existsSync(), isTrue); expect(File(real('skip.tmp')).existsSync(), isFalse, reason: '거른 것은 보내지 않음'); expect(File(real('cache/c.bin')).existsSync(), isFalse, reason: '거른 폴더 안으로 내려가지 않음'); expect(File(real('only.tmp')).readAsStringSync(), 'o', reason: '받는 쪽의 거른 것은 지키기'); expect(File(real('cache/remote.bin')).existsSync(), isTrue); expect(File(real('old.txt')).existsSync(), isFalse, reason: '거르지 않은 것은 --delete 로 지움'); expect(File(real('gone/g.tmp')).existsSync(), isTrue, reason: '거른 것이 든 폴더는 남기고'); expect(File(real('gone/g.txt')).existsSync(), isFalse, reason: '그 안의 거르지 않은 것만 지움'); expect(File(real('한글 폴더/가.txt')).readAsStringSync(), '가'); // --delete-excluded: 받는 쪽의 거른 것도 지움 fresh(); await FileOps().mirror(src, dst, delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/ --delete-excluded')); expect(File(real('only.tmp')).existsSync(), isFalse); expect(Directory(real('cache')).existsSync(), isFalse); expect(Directory(real('gone')).existsSync(), isFalse); }); test('관리자 지시 (10/20): WebDAV 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); final src = p.join(local.path, 'scene_src'), want = p.join(local.path, 'scene_rsync'), got = p.join(remote.path, 'scene'); final bad = []; for (final opts in sceneOptions) { writeScene(src, want); final w = await realRsyncTree(rsync, src, want, opts); writeScene(src, got); await FileOps().mirror(src, 'dav://$id/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts)); final d = treeDiff(w, treeOf(got)); if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}'); } expect(bad, isEmpty, reason: bad.join('\n')); }, timeout: const Timeout(Duration(minutes: 5))); }