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/app/transfer_job.dart'; import 'package:jj_mkvmaker/core/rsync_launch.dart'; import 'package:jj_mkvmaker/core/sync_tools.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/rsync_scenes.dart' show sceneRsync, treeOf; /// P0 (관리자 지시 10/20, 28 재현 - 데이터): Windows 의 rsync (MSYS2 런타임) 가 명령 줄을 다시 읽어 ' 를 짝짓고 (거르기가 사라져 /// 지킬 파일이 지워짐 · ' 경로가 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼치던 것 - 응답 파일 + MSYS=noglob 으로 그대로 넘긴다. /// 앱이 rsync 를 띄우는 두 길 (복사 · Rsync 화면 · 모니터링 · 최종 정리 = TransferJob, 실시간 동기화 = LiveSync) 을 실제 rsync 로. void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('도우미: Windows 는 응답 파일 · MSYS=noglob, 그 밖은 그대로 · 큰따옴표가 든 인수는 막음', () async { final plain = await prepareRsyncLaunch(['-a', "it's"], windows: false); expect(plain.args, ['-a', "it's"]); expect(plain.environment, isNull); final w = await prepareRsyncLaunch(['-a', "--exclude=it's.txt", 'a b'], windows: true); addTearDown(w.dispose); expect(w.args, ['@args.txt']); expect(w.environment!['MSYS'], contains('noglob')); expect(File(p.join(w.workingDirectory!, 'args.txt')).readAsStringSync(), '"-a" "--exclude=it\'s.txt" "a b"'); await expectLater(prepareRsyncLaunch(['--exclude=a"b'], windows: true), throwsA(isA())); // 옵션 칸에서도 미리 빨갛게 (Windows 만) expect(unreadableOptions('-a --exclude=\'a"b\'', windows: true), hasLength(1)); expect(unreadableOptions('-a --exclude=\'a"b\'', windows: false), isEmpty); expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty); }); test('관리자 판단 (10/20): 목록 파일의 상대 경로는 막는다 (rsync 와 미리 보기가 다른 작업 폴더 기준으로 읽음) - 전체 경로 · 폴더마다 읽는 규칙은 그대로', () { for (final o in [ '-a --exclude-from=list.txt', '-a --exclude-from list.txt', '-a --include-from=./a.txt', '-a --files-from=sub/files.txt', '-a --filter=". rules.txt"', '-a --filter="merge,- rules.txt"', '-a --filter ". w.txt"', '-a -f". y.txt"', '-avf ". z.txt"', '-a --exclude-from=~/list.txt', ]) { expect(unreadableOptions(o, windows: true), [contains('목록 파일은 전체 경로로 적어 주세요')], reason: o); } for (final o in [ r'-a --exclude-from=C:\lists\l.txt', '-a --exclude-from=C:/lists/l.txt', '-a --include-from=/cygdrive/c/l.txt', r'-a --files-from=\\nas\share\l.txt', '-a --exclude-from /sdcard/l.txt', '-a --filter=". /cygdrive/c/rules.txt"', r'-a -f". C:\r.txt"', '-a --filter=": .rsync-filter"', '-a --filter="dir-merge .x"', '-a -F', '-a --exclude=list.txt', // 받는 쪽 기준이라 그대로 (28 확인) '-a --backup-dir=old --partial-dir=.part --temp-dir=tmp --compare-dest=../base', r'-a --log-file=C:\logs\r.log --write-batch=/cygdrive/c/b/batch', ]) { expect(unreadableOptions(o, windows: true), isEmpty, reason: o); } // 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 전체 경로만 (상대면 응답 파일 임시 폴더에 생겼다가 함께 지워짐) for (final o in ['-a --log-file=r.log', '-a --log-file r.log', '-a --write-batch=b', '-a --only-write-batch b', '-a --read-batch=./b']) { expect(unreadableOptions(o, windows: true), [contains('기록 · 배치 파일은 전체 경로로 적어 주세요')], reason: o); } }); group('실제 rsync', () { final rsync = sceneRsync; late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_msysargs_')); tearDown(() => tmp.deleteSync(recursive: true)); /// 원본 · 대상 폴더를 만들고 파일을 넣는다 (내용 = 쪽:이름) (String, String) scene(String sn, String dn, List sf, List df) { final s = Directory(p.join(tmp.path, sn))..createSync(recursive: true); final d = Directory(p.join(tmp.path, dn))..createSync(recursive: true); for (final f in sf) { File(p.join(s.path, f)) ..parent.createSync(recursive: true) ..writeAsStringSync('s:$f'); } for (final f in df) { File(p.join(d.path, f)) ..parent.createSync(recursive: true) ..writeAsStringSync('d:$f'); } return (s.path, d.path); } // (이름, 옵션, 원본 폴더, 대상 폴더, 원본 파일, 대상 파일, 남아야 할 것 (대상), 없어야 할 것 (대상)) final cases = <(String, String, String, String, List, List, List, List)>[ ("28 재현: ' 두 개의 --exclude", '-a --delete --exclude="it\'s.txt" --exclude="Bob\'s.txt"', 's', 'd', ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt'], ["it's.txt", "Bob's.txt", 'a.txt'], ['old.txt']), ("' 하나의 --exclude", "-a --delete --exclude=\"it's.txt\"", 's', 'd', ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], ['old.txt']), ("' 가 든 원본 경로", '-a --delete', "s'1", 'd', ["a'b.txt"], ['old.txt'], ["a'b.txt"], ['old.txt']), ("' 가 든 원본 · 대상 경로", '-a --delete', "s'1", "d'2", ['a.txt', "c'd/e'f.txt"], ['old.txt'], ['a.txt', "c'd/e'f.txt"], ['old.txt']), ("' 경로 + ' --exclude", "-a --delete --exclude=\"it's.txt\"", "s'1", "d'2", ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], ['old.txt']), ('따로 쓴 * (작업 폴더에 맞는 파일이 있어도)', '-a --delete --exclude *.txt', 's', 'd', ['a.txt', 'n.log'], ['keep.txt', 'old.log'], ['keep.txt', 'n.log'], ['old.log', 'a.txt']), ('따로 쓴 ? · [ ] · { }', '-a --delete --exclude b?.txt --exclude [x]* --exclude {q,r}.txt', 's', 'd', ['a.txt'], ['b9.txt', 'xk.txt', '{q,r}.txt', 'q.txt', 'old.txt'], ['b9.txt', 'xk.txt', '{q,r}.txt', 'a.txt'], ['q.txt', 'old.txt']), ('[ ] · { } · 빈칸 · 한글이 든 경로', '-a --delete', '원본 [1] {a}', "받는 쪽 [2] {b}'s", ['가 나.txt'], ['old.txt'], ['가 나.txt'], ['old.txt']), ]; for (final (name, opts, sn, dn, sf, df, keep, gone) in cases) { test('복사 작업 (TransferJob): $name', () async { if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); final (s, d) = scene(sn, dn, sf, df); // 작업 폴더에 글로브에 맞을 파일을 둔다 (예전에는 따로 쓴 * 가 이것들로 펼쳐졌다) final cwd = Directory.current; final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true); await job.run(); expect(job.error, isNull, reason: '${job.error}'); final t = treeOf(d); for (final k in keep) { expect(t.containsKey(k), isTrue, reason: '$name: $k 가 있어야 함 - $t'); } for (final g in gone) { expect(t.containsKey(g), isFalse, reason: '$name: $g 는 없어야 함 - $t'); } expect(Directory.current.path, cwd.path); }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); } test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async { if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..rsyncSource = 'custom' ..rsyncPath = rsync ..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"' ..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')]; final live = LiveSync(c); addTearDown(live.dispose); final pair = c.settings.liveSyncPairs.single; await live.syncNow(pair); expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); final t = treeOf(d); expect(t.keys.toSet(), {"it's.txt", "Bob's.txt", 'a.txt'}, reason: '거르기를 지키고 old.txt 만 지움'); }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); test('응답 파일은 끝나면 지운다', () async { if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); final (s, d) = scene('s', 'd', ['a.txt'], []); final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a', rsyncExe: rsync, contents: true); await job.run(); expect(job.error, isNull); // 이 작업의 응답 파일 (이 시험의 경로가 든 것) 이 남지 않았는지 - 함께 도는 다른 시험의 것은 세지 않는다 final mine = [ for (final e in Directory.systemTemp.listSync()) if (p.basename(e.path).startsWith('jj_rsargs_') && File(p.join(e.path, 'args.txt')).existsSync()) if (File(p.join(e.path, 'args.txt')).readAsStringSync().contains(toCygwinPath(s))) e.path, ]; expect(mine, isEmpty); }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); }); }