import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; 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/rsync_filter.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/sync_tools.dart' show splitOptions, toCygwinPath; 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:jj_mkvmaker/ui/monitor_page.dart'; import 'package:path/path.dart' as p; import 'support/rsync_scenes.dart'; /// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync, /// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음) void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('실시간 동기화 (로컬 rsync 쌍) 결과 = 실제 rsync 결과 · 지울지 묻는 목록 = rsync 가 지우는 것 (28 의 장면 · 옵션 28개)', () async { final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); final tmp = Directory.systemTemp.createTempSync('jj_livescene_'); addTearDown(() => tmp.deleteSync(recursive: true)); final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'), got = p.join(tmp.path, 'live'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); // 앱에 든 rsync 를 그대로 (시험 환경에는 앱 폴더 (path_provider) 가 없어 '직접 지정' 으로) c.settings ..rsyncSource = 'custom' ..rsyncPath = rsync; final bad = []; for (final opts in sceneOptions) { writeScene(src, want); final w = await realRsyncTree(rsync, src, want, opts); writeScene(src, got); final before = treeOf(got); final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; c.settings.rsyncOptions = opts; final pair = LiveSyncPair(src, got, method: 'rsync', delete: optionsDelete(opts)); c.settings.liveSyncPairs = [pair]; final live = LiveSync(c); final k = LiveSync.keyOf(pair); final why = []; await live.syncNow(pair); if (live.status[k]!.$2.contains('실패')) why.add('상태: ${live.status[k]!.$2}'); final asked = live.toDelete[k] ?? const []; for (final g in gone) { if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('rsync 는 지우는데 묻지 않음: $g'); } for (final a in asked) { if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a'); } // 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 확인 창에서 따로 final excl = live.toDeleteExcluded[k] ?? const []; final wantExcl = { '-a --delete --exclude=*.bak --delete-excluded': {'onlydst'}, '-a --delete --exclude=*.log --delete-excluded': {'keep.log', 'old', 'old2'}, }[opts] ?? const {}; if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl'); if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl'); if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl'); if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); why.addAll(treeDiff(w, treeOf(got))); if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}'); live.dispose(); if (why.isNotEmpty) bad.add('$opts\n ${why.join('\n ')}'); } expect(bad, isEmpty, reason: bad.join('\n')); }, timeout: const Timeout(Duration(minutes: 5))); testWidgets('관리자 3a 조건 (10/20): 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것은 따로 묶어 빨갛게', (tester) async { final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); final tmp = Directory.systemTemp.createTempSync('jj_liveexcl_'); addTearDown(() => tmp.deleteSync(recursive: true)); final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd'); for (final (dir, rel) in [(src, 'a.txt'), (dst, 'only.txt'), (dst, 'skip.tmp'), (dst, 'cache/x.tmp')]) { File(p.join(dir, rel)) ..createSync(recursive: true) ..writeAsStringSync(rel); } final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..rsyncSource = 'custom' ..rsyncPath = rsync ..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); c.settings.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await tester.runAsync(() => live.syncNow(pair)); final k = LiveSync.keyOf(pair); expect(live.toDelete[k], unorderedEquals(['only.txt', 'skip.tmp', 'cache'])); expect(live.toDeleteExcluded[k], unorderedEquals(['skip.tmp', 'cache']), reason: 'cache 는 거른 것이 든 폴더'); expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음'); late BuildContext ctx; await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) { ctx = x; return const SizedBox(); }))); unawaited(confirmLiveDeletes(ctx, live, pair)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget); expect(find.text('거른 것 2개도 지워집니다 (--delete-excluded)'), findsOneWidget); // 순서: 그냥 지울 것 → 묶음 제목 → 거른 것 final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(n)).dy}; expect(y['only.txt']! < y['거른 것 2개도 지워집니다 (--delete-excluded)']!, isTrue); expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue); expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue); await tester.tap(find.text('나중에')); await tester.pump(); }); test('관리자 3a 판단 (10/20): 확인한 쌍도 rsync 옵션이 바뀌어 지울 것이 늘면 다시 묻고, 늘지 않으면 묻지 않고 기억', () async { final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); final tmp = Directory.systemTemp.createTempSync('jj_reask_'); addTearDown(() => tmp.deleteSync(recursive: true)); final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd'); for (final (dir, rel) in [(src, 'a.txt'), (dst, 'only.tmp'), (dst, 'keep/x.tmp')]) { File(p.join(dir, rel)) ..createSync(recursive: true) ..writeAsStringSync(rel); } final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..rsyncSource = 'custom' ..rsyncPath = rsync ..rsyncOptions = '-av --exclude=*.tmp'; // 이미 이 옵션으로 확인한 쌍 c.settings.liveSyncPairs = [ LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'), ]; final live = LiveSync(c); addTearDown(live.dispose); LiveSyncPair pair() => c.settings.liveSyncPairs.single; final k = LiveSync.keyOf(pair()); await live.syncNow(pair()); expect(live.toDelete[k], isNull); expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue); // ① 지울 것이 늘지 않는 바꿈 (-h 더하기) → 묻지 않고 지금 옵션을 기억 c.settings.rsyncOptions = '-avh --exclude=*.tmp'; await live.syncNow(pair()); expect(live.toDelete[k], isNull); expect(pair().confirmedOptions, '-avh --exclude=*.tmp'); // ② --delete-excluded 를 더함 → 거른 것이 새로 지워지므로 다시 묻고, 확인 전에는 지우지 않음 c.settings.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded'; await live.syncNow(pair()); expect(live.toDelete[k], unorderedEquals(['only.tmp', 'keep'])); expect(live.reasked, contains(k)); expect(live.status[k]!.$2, contains('확인 필요')); expect(File(p.join(dst, 'only.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음'); expect(pair().deleteConfirmed, isTrue, reason: '쌍의 확인은 그대로 - 바뀐 옵션만 다시 묻는다'); // 확인하면 지우고 그 옵션을 기억 await live.decideDelete(pair(), delete: true); expect(File(p.join(dst, 'only.tmp')).existsSync(), isFalse); expect(Directory(p.join(dst, 'keep')).existsSync(), isFalse); expect(pair().confirmedOptions, '-avh --exclude=*.tmp --delete-excluded'); expect(live.reasked, isNot(contains(k))); // ③ 거르기를 빼면 (--exclude 를 지움) 지울 것은 줄거나 같다 → 묻지 않음 c.settings.rsyncOptions = '-avh'; await live.syncNow(pair()); expect(live.toDelete[k], isNull); expect(pair().confirmedOptions, '-avh'); // ④ 예전에 확인한 쌍 (기억한 옵션 없음): --delete-excluded 로 새로 지워질 것이 있으면 묻는다 · 없으면 기억만 File(p.join(dst, 'new.tmp')).writeAsStringSync('n'); c.settings ..rsyncOptions = '-av --exclude=*.tmp --delete-excluded' ..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})]; expect(pair().confirmedOptions, isNull); await live.syncNow(pair()); expect(live.toDelete[k], ['new.tmp']); expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue); c.settings ..rsyncOptions = '-av --exclude=*.tmp' ..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})]; await live.syncNow(pair()); expect(live.toDelete[k], isNull); expect(pair().confirmedOptions, '-av --exclude=*.tmp'); expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue, reason: '거른 것 - 지우지 않음'); // 설정 파일: 기억한 옵션이 저장 · 읽기에서 남는다 final j = pair().toJson(); expect(j['confirmedOptions'], '-av --exclude=*.tmp'); expect(LiveSyncPair.fromJson(j).confirmedOptions, '-av --exclude=*.tmp'); expect(LiveSyncPair.fromJson({...j, 'confirmedOptions': 3}).confirmedOptions, isNull, reason: '틀린 값은 없는 것으로'); }); // P0 (관리자 3a 10/20, 28 재현): 미리 볼 수 없는 거르기 규칙이 있는 지우기 포함 쌍 group('P0: 미리 볼 수 없는 거르기 규칙 - 자동은 보내기만 · 지우기는 확인 뒤 한 번', () { late Directory tmp; late String src, dst; late AppController c; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_p0_'); src = p.join(tmp.path, 's'); dst = p.join(tmp.path, 'd'); for (final (dir, rel, s) in [ (src, 'a.txt', 's:a'), (src, 'b.txt', 's:b'), (src, 'c.log', 's:c'), (dst, 'a.txt', 'd:a'), (dst, 'old.log', 'd:old'), (dst, 'extra.txt', 'd:extra'), ]) { File(p.join(dir, rel)) ..createSync(recursive: true) ..writeAsStringSync(s); } c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..rsyncSource = 'custom' ..rsyncPath = sceneRsync ?? ''; }); tearDown(() => tmp.deleteSync(recursive: true)); bool there(String rel) => File(p.join(dst, rel)).existsSync(); // 관리자 지시 (10/20): -,s · H 는 이제 미리 볼 수 있다 (rsync_phsr_test) - 28 의 재현은 아직 따르지 못하는 merge 규칙 (그 파일 안에 // "H a.txt" · "-,s a.txt") 으로 한다. 실제 rsync 는 그 규칙을 따라 a.txt 를 지우지만 앱은 merge 를 읽지 않는다 for (final (label, rule, form) in [ ('merge "-,s a.txt"', '-,s a.txt', '-a --filter="merge {f}"'), ('merge "H a.txt"', 'H a.txt', '-a --filter="merge {f}"'), ('-f"merge" "H a.txt"', 'H a.txt', '-a -f"merge {f}"'), ]) { for (final confirmed in [false, true]) { test('28 재현 $label (${confirmed ? '확인한 쌍' : '새 쌍'}): 묻는 목록 밖의 a.txt 가 지워지지 않음', () async { final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); final rules = File(p.join(tmp.path, 'rules.txt'))..writeAsStringSync('$rule\n'); final opts = form.replaceAll('{f}', toCygwinPath(rules.path)); // 먼저 실제 rsync 가 이 규칙 + --delete 로 받는 쪽 a.txt 를 지우는 것을 확인 (위험이 실제로 있음) final probe = p.join(tmp.path, 'probe'); Directory(probe).createSync(); for (final f in Directory(dst).listSync()) { File(f.path).copySync(p.join(probe, p.basename(f.path))); } final r = await Process.run(rsync, ['-8', '--delete', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(probe)}/']); expect(r.exitCode, 0, reason: '${r.stderr}'); expect(File(p.join(probe, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움'); c.settings.rsyncOptions = opts; final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: confirmed, confirmedOptions: confirmed ? opts : null); c.settings.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); final k = LiveSync.keyOf(pair); for (var i = 0; i < 2; i++) { await live.syncNow(c.settings.liveSyncPairs.single); expect(live.status[k]!.$2, isNot(contains('실패'))); expect([there('a.txt'), there('old.log'), there('extra.txt')], [true, true, true], reason: '자동 맞추기는 지우지 않음 ($i)'); expect(there('b.txt') && there('c.log'), isTrue, reason: '보내기는 함'); expect(live.noAutoDelete[k], isNotEmpty); expect(live.toDelete[k], isNull, reason: '묻지도 않음 (목록이 실제와 다름)'); expect(live.pending[k]!.where((d) => d.startsWith('− ')), isEmpty, reason: '− 줄이 늘 남지 않음'); } // [지금 지우기 (확인)] - 창에 보일 목록은 미리 본 것 (a.txt 는 없음 - 그래서 빨간 경고와 체크) final (list, _) = await live.previewDeletes(c.settings.liveSyncPairs.single); expect(list, unorderedEquals(['old.log', 'extra.txt'])); await live.deleteNow(c.settings.liveSyncPairs.single); expect([there('old.log'), there('extra.txt')], [false, false]); // 다음 자동 맞추기는 다시 보내기만 File(p.join(dst, 'extra2.txt')).writeAsStringSync('x'); await live.syncNow(c.settings.liveSyncPairs.single); expect(there('extra2.txt'), isTrue); expect(live.noAutoDelete[k], isNotEmpty); }); } } test('관리자 지시 (10/20): P (지키기) 는 이제 따른다 - 묻는 목록에 지킨 것 없음 · 확인 뒤 남음 · 다시 묻지 않음', () async { if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); c.settings.rsyncOptions = '-a --filter="P old.log"'; final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); c.settings.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); final k = LiveSync.keyOf(pair); await live.syncNow(pair); expect(live.noAutoDelete[k], isNull); expect(live.toDelete[k], ['extra.txt'], reason: 'old.log 는 지킴'); await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); expect((there('old.log'), there('extra.txt')), (true, false)); for (var i = 0; i < 2; i++) { await live.syncNow(c.settings.liveSyncPairs.single); expect(live.toDelete[k], isNull, reason: '다시 묻지 않음 ($i)'); expect(live.pending[k], isEmpty); } }); test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async { if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); c.settings.rsyncOptions = '-a --exclude=*.log'; final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); c.settings.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await live.syncNow(pair); final k = LiveSync.keyOf(pair); expect(live.noAutoDelete[k], isNull); expect(live.toDelete[k], ['extra.txt']); }); testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async { if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로 final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true); c.settings.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); late BuildContext ctx; await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) { ctx = x; return const SizedBox(); }))); await tester.runAsync(() async { unawaited(confirmLiveDeleteNow(ctx, live, pair)); await Future.delayed(const Duration(milliseconds: 500)); }); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.textContaining('미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다'), findsOneWidget); expect(find.text('대상에서 2개를 지울까요?'), findsOneWidget); expect(find.text('지우지 않기 (지우기 끄기)'), findsNothing); FilledButton go() => tester.widget(find.widgetWithText(FilledButton, '2개 지우고 맞추기')); expect(go().onPressed, isNull, reason: '체크 전에는 꺼짐'); await tester.tap(find.byType(Checkbox)); await tester.pump(); expect(go().onPressed, isNotNull); await tester.tap(find.text('나중에')); await tester.pump(); expect(there('old.log') && there('extra.txt') && there('a.txt'), isTrue); }); }); }