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/copy_center.dart'; import 'package:jj_mkvmaker/core/file_ops.dart' show transferProblem; import 'package:jj_mkvmaker/app/live_sync.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/cron_window.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:jj_mkvmaker/ui/copy_sync_settings.dart' show CopySyncSettings; import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:path/path.dart' as p; void main() { group('cron 일정', () { // 2026-10-05 은 월요일 final mon10 = DateTime(2026, 10, 5, 10, 30), mon20 = DateTime(2026, 10, 5, 20), sat10 = DateTime(2026, 10, 10, 10); test('읽기 · 시간 창 (시 · 요일 · 범위 · 간격 · 이름)', () { final e = CronExpr.parse('0 9-17 * * 1-5'); expect([e.activeAt(mon10), e.activeAt(mon20), e.activeAt(sat10)], [true, false, false]); expect(CronExpr.parse('0 */2 * * SAT,SUN').activeAt(sat10), isTrue); expect(CronExpr.parse('0 * * * 7').activeAt(DateTime(2026, 10, 11, 3)), isTrue); // 7 = 일요일 expect(CronExpr.parse('0 10 5 10 *').activeAt(mon10), isTrue); // 10월 5일 10시 expect(() => CronExpr.parse('0 25 * * *'), throwsFormatException); expect(() => CronExpr.parse('0 9 * *'), throwsFormatException); expect(CronExpr.tryParse('x'), isNull); }); test('계속 · 다음 시작 · 끝나는 때', () { expect(scheduleActive(const [], sat10), isTrue); const work = ['0 9-17 * * 1-5']; expect(scheduleNextStart(work, mon20), DateTime(2026, 10, 6, 9)); expect(scheduleNextStart(work, mon10), isNull); expect(scheduleEnd(work, mon10), DateTime(2026, 10, 5, 18)); expect(scheduleNextStart(work, sat10), DateTime(2026, 10, 12, 9)); // 다음 월요일 }); test('격자 ↔ cron (같은 시간대 요일끼리 한 줄)', () { final cells = {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h, 6 * 24 + 22, 6 * 24 + 23}; final lines = cronFromGrid(cells); expect(lines, ['0 9-17 * * 1-5', '0 22-23 * * 6']); expect(gridFromCron(lines), cells); expect(cronFromGrid({for (var i = 0; i < 168; i++) i}), ['0 * * * *']); expect(gridFromCron(['0 10 5 10 *']), isEmpty); // 일 · 월을 정한 줄은 격자로 못 그림 }); }); group('복사 기억 · lsync', () { late Directory tmp; late AppController c; late String src, dst; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_mon_'); src = p.join(tmp.path, 'src'); dst = p.join(tmp.path, 'dst'); File(p.join(src, 'a.txt')) ..createSync(recursive: true) ..writeAsStringSync('a'); File(p.join(src, 'sub', 'b.txt')) ..createSync(recursive: true) ..writeAsStringSync('b'); Directory(dst).createSync(); c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); }); tearDown(() => tmp.deleteSync(recursive: true)); test('205: 폴더 안의 것을 바로 위 폴더로 이동 (Rsync 화면 · 원본/ → 대상/) 은 막지 않고 옮긴다', () async { final up = p.dirname(src); // 폴더째 옮기기는 그대로 "이미 이 폴더에 있습니다", 안의 것은 된다 expect(transferProblem([src], up, move: true), contains('이미 이 폴더에')); expect(transferProblem([src], up, move: true, contents: true), isNull); final center = CopyCenter(c); final t = await center.remember([src], up, contents: true, method: 'builtin', move: true); final job = await center.start(t); expect(job, isNotNull, reason: '예전에는 여기서 null (조용히 시작하지 않음)'); await job!.done; expect(job.error, isNull); expect(File(p.join(up, 'sub', 'b.txt')).readAsStringSync(), 'b'); expect(Directory(src).listSync(recursive: true).whereType(), isEmpty, reason: '원본의 파일은 옮겨짐'); }); test('219: 파일이 섞인 이동 작업을 lsync 로 옮기면 남는 작업 (파일) 은 이동 · 원본 정리 · 한 번에 를 그대로', () async { final single = File(p.join(tmp.path, 'single.txt'))..writeAsStringSync('s'); final center = CopyCenter(c); final t = CopyTask(id: '9', sources: [src, single.path], dest: dst, move: true, method: 'rsync', prune: 'all', once: true); await c.updateSettings((x) => x.copy.tasks = [t]); expect(await center.toLiveSync(center.tasks.single), 1); final rest = center.tasks.single; expect(rest.sources, [single.path]); expect([rest.move, rest.prune, rest.once, rest.method], [true, 'all', true, 'rsync']); }); test('같은 복사는 기억한 옵션 · 실행 결과 기록 · lsync 로 이동 · 다시 복사 목록으로', () async { c.settings.copy.methodFolder = 'builtin'; // rsync 실행은 sync_tools_test (시험 환경에서는 path_provider 없음) final center = CopyCenter(c); final t = await center.remember([src], dst); await center.update(t.copyWith(options: '-a --custom', bandwidthKBps: 77)); final again = await center.remember([src], dst); expect([again.id, again.options, again.bandwidthKBps], [t.id, '-a --custom', 77]); expect(center.tasks, hasLength(1)); final job = await center.start(center.tasks.single); await job!.done; await Future.delayed(const Duration(milliseconds: 50)); expect(File(p.join(dst, 'src', 'sub', 'b.txt')).readAsStringSync(), 'b'); expect(center.tasks.single.lastResult, 'done'); expect(center.tasks.single.lastFiles, 2); // lsync 로: 원본 → 대상\이름 expect(await center.toLiveSync(center.tasks.single), 1); expect(center.tasks, isEmpty); final pair = c.settings.copy.liveSyncPairs.single; expect([pair.source, pair.target], [src, p.join(dst, 'src')]); // 다시 복사 목록으로 (안의 것 → 대상) final back = await center.fromLiveSync(pair); expect(c.settings.copy.liveSyncPairs, isEmpty); expect([back.contents, back.dest], [true, p.join(dst, 'src')]); expect(back.method, Platform.isWindows ? 'rsync' : 'builtin'); // rsync 로 옮겨 온다 (쓸 수 있으면) File(p.join(src, 'new.txt')).writeAsStringSync('n'); // (시험 환경에는 rsync 가 없어 현재 방식으로 실행 - rsync 실행은 sync_tools_test) final j2 = await center.start(back.copyWith(method: 'builtin')); await j2!.done; expect(File(p.join(dst, 'src', 'new.txt')).readAsStringSync(), 'n'); // 안의 것 모드: 이름 폴더가 또 생기지 않음 expect(Directory(p.join(dst, 'src', 'src')).existsSync(), isFalse); }); test('이동 뒤 원본 정리 (prune) 저장 · 읽기', () { final t = CopyTask(id: '1', sources: const ['/a'], dest: '/b', move: true, contents: true, method: 'rsync', prune: 'all'); final back = CopyTask.fromJson(t.toJson()); expect([back.move, back.prune], [true, 'all']); expect(CopyTask.fromJson({'id': '2', 'dest': '/b', 'prune': 'x'}).prune, ''); expect(t.copyWith(prune: 'keep').prune, 'keep'); }); test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async { c.settings.copy.methodFolder = 'rsync'; final center = CopyCenter(c); final t = await center.remember([src], src); // 자기 안으로 expect(await center.start(t), isNull); expect([center.tasks.single.lastResult, center.tasks.single.lastMessage], ['failed', contains('자기 자신 안으로')]); final t2 = await center.remember([p.join(tmp.path, 'gone')], dst); expect(await center.start(t2), isNull); expect(center.tasks.firstWhere((x) => x.id == t2.id).lastMessage, contains('원본이 없습니다')); expect(center.jobs, isEmpty); }); test('lsync: 맞출 것 자동으로 세기 · 일정 밖이면 맞추지 않음', () async { final target = p.join(dst, 'mirror'); // 일정: 지금이 아닌 시간 (지금 시 + 2) final off = (DateTime.now().hour + 2) % 24; final pair = LiveSyncPair(src, target, schedule: ['0 $off * * *'], delete: true); expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt'])); expect(LiveSync.activeNow(pair), isFalse); c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c)..start(); // 모아서 3초 뒤 (컴퓨터가 바쁘면 늦을 수 있어 정해진 4초가 아니라 될 때까지 - 최대 20초) for (var i = 0; i < 80 && (live.pending[LiveSync.keyOf(pair)]?.length ?? 0) < 2; i++) { await Future.delayed(const Duration(milliseconds: 250)); } expect(live.pending[LiveSync.keyOf(pair)], hasLength(2)); expect(Directory(target).existsSync(), isFalse); // 일정 밖: 맞추지 않음 // 지금 맞추기는 늘 된다 await live.syncNow(pair); expect(live.pending[LiveSync.keyOf(pair)], isEmpty); File(p.join(target, 'extra.txt')).writeAsStringSync('x'); expect(await LiveSync.diff(pair), ['− extra.txt']); live.dispose(); }); test('78 보충 b: lsync 는 일괄 이름 바꾸기의 임시 이름 (~jjren_ · ~jjback_) 을 맞출 것으로 세지 않고, rsync 인수에도 제외', () async { final target = p.join(dst, 'mirror2'); final pair = LiveSyncPair(src, target, delete: true); File(p.join(src, '~jjren_abc_0')).writeAsStringSync('x'); Directory(target).createSync(); File(p.join(target, '~jjback_abc_1')).writeAsStringSync('x'); final pending = await LiveSync.diff(pair); expect(pending.where((x) => x.contains('~jj')), isEmpty, reason: '$pending'); final args = LiveSync.rsyncArgsFor(pair, c.settings, windows: true); expect(args, containsAll(['--exclude=~jjren_*', '--exclude=~jjback_*'])); expect(LiveSync.robocopyArgsFor(pair, c.settings), containsAll(['/XF', '~jjren_*', '~jjback_*'])); }); }); group('백그라운드 · 다시 켤 때 동기화', () { test('설정 저장 · 읽기, Windows 는 종료 동작과 같은 값', () { final s = AppSettings() ..run.inBackground = true ..copy.liveSyncOnStart = 'ask'; final r = AppSettings.fromJson(s.toJson()); expect([r.run.inBackground, r.copy.liveSyncOnStart], [true, 'ask']); expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).copy.liveSyncOnStart, 'auto'); // 사용자 결정 (10/8): Android 백그라운드로 실행은 기본 켜짐. 사용자가 끈 값 (새 형식) 은 그대로 expect(AppSettings.fromJson({}).run.inBackground, isTrue); expect(AppSettings.fromJson({'runInBackground': false, 'backgroundV2': true}).run.inBackground, isFalse); final w = AppSettings()..run.backgroundRun = false; if (Platform.isWindows) { expect(w.run.closeAction, 'quit'); w.run.backgroundRun = true; expect([w.run.closeAction, w.run.backgroundRun], ['background', true]); } else { expect(w.run.inBackground, isFalse); } }); test('멈춘 채로 시작 · 고른 것만 시작 · 모두 시작', () async { final tmp = Directory.systemTemp.createTempSync('jj_hold_'); addTearDown(() => tmp.deleteSync(recursive: true)); final a = Directory(p.join(tmp.path, 'a'))..createSync(); final b = Directory(p.join(tmp.path, 'b'))..createSync(); File(p.join(a.path, '1.txt')).writeAsStringSync('1'); File(p.join(b.path, '2.txt')).writeAsStringSync('2'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pa = LiveSyncPair(a.path, p.join(tmp.path, 'ta')), pb = LiveSyncPair(b.path, p.join(tmp.path, 'tb')); c.settings ..copy.liveSyncPairs = [pa, pb] ..copy.liveSyncOnStart = 'off'; final live = LiveSync(c)..start(hold: c.settings.copy.liveSyncOnStart != 'auto'); // 맞추기 (rsync 프로세스) 가 끝난 뒤 닫고 지운다 - 도는 중에 지우면 "다른 프로세스가 사용 중" (전체 시험이 무거울 때) addTearDown(() async { for (var i = 0; i < 200 && live.anyRunning; i++) { await Future.delayed(const Duration(milliseconds: 100)); } live.dispose(); }); // 모아서 3초 뒤 맞춘다 - 바쁠 때는 늦으므로 정해진 4초가 아니라 될 때까지 (최대 30초) Future until(bool Function() done) async { for (var i = 0; i < 120 && !done(); i++) { await Future.delayed(const Duration(milliseconds: 250)); } } expect(live.watching, isEmpty); await Future.delayed(const Duration(seconds: 4)); expect(Directory(pa.target).existsSync(), isFalse); // 시작 안 함 live.runOnly([pa]); expect(live.watching.map((x) => x.source), [a.path]); expect(live.isPaused(pb), isTrue); await until(() => File(p.join(pa.target, '1.txt')).existsSync() && !live.anyRunning); expect(File(p.join(pa.target, '1.txt')).existsSync(), isTrue); expect(Directory(pb.target).existsSync(), isFalse); live.resumeAll(); await until(() => File(p.join(pb.target, '2.txt')).existsSync() && !live.anyRunning); expect(File(p.join(pb.target, '2.txt')).existsSync(), isTrue); live.pause(pa); expect(live.watching.map((x) => x.source), [b.path]); }); test('244: 맞출 것이 없어도 대상에만 있는 것이 있으면 "같습니다" 가 아니라 그 수를 센다', () async { final tmp = Directory.systemTemp.createTempSync('jj_only_'); addTearDown(() => tmp.deleteSync(recursive: true)); final a = Directory(p.join(tmp.path, 'a'))..createSync(); final t = Directory(p.join(tmp.path, 't'))..createSync(); final now = DateTime.now(); for (final d in [a, t]) { (File(p.join(d.path, 'same.txt'))..writeAsStringSync('s')).setLastModifiedSync(now); } File(p.join(t.path, 'only1.txt')).writeAsStringSync('1'); Directory(p.join(t.path, 'onlydir')).createSync(); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(a.path, t.path, method: 'builtin'); c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await live.refreshPending(pair); expect(live.pending[LiveSync.keyOf(pair)], isEmpty); expect(live.onlyTarget[LiveSync.keyOf(pair)], 2); File(p.join(t.path, 'only1.txt')).deleteSync(); Directory(p.join(t.path, 'onlydir')).deleteSync(); await live.refreshPending(pair); expect(live.onlyTarget[LiveSync.keyOf(pair)], 0, reason: '정말 같으면 0 - "같습니다"'); }); test('dispose 뒤에 맞추기가 끝나도 알리지 않는다 (앱을 끄는 중 - used after disposed 오류 없음)', () async { final tmp = Directory.systemTemp.createTempSync('jj_disp_'); addTearDown(() => tmp.deleteSync(recursive: true)); final a = Directory(p.join(tmp.path, 'a'))..createSync(); File(p.join(a.path, '1.txt')).writeAsStringSync('1'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(a.path, p.join(tmp.path, 't'), method: 'builtin'); c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final running = live.syncNow(pair); live.dispose(); await running; // 예전에는 여기서 notifyListeners 가 FlutterError expect(File(p.join(tmp.path, 't', '1.txt')).existsSync(), isTrue, reason: '맞추기 자체는 끝까지'); }); }); testWidgets('모니터링 화면: 복사 목록 · 옵션 고치기 → 현재 유지 / 반영 후 실행, lsync 탭 · 일정', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_monui_'); addTearDown(() => tmp.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')] ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c))); await tester.pump(); expect(find.text('아직 실행 안 함'), findsOneWidget); expect(find.text('실행'), findsOneWidget); expect(find.text('lsync 로 이동'), findsOneWidget); // 속도 제한을 고치면 [저장] → 묻기 → 현재 유지 (실행 안 함) await tester.enterText(find.widgetWithText(TextField, '속도 제한'), '500'); await tester.pump(); await tester.tap(find.text('저장')); await tester.pumpAndSettle(); expect(find.text('옵션을 저장했습니다'), findsOneWidget); expect(find.text('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?'), findsOneWidget); expect(find.text('설정 반영 후 실행'), findsOneWidget); await tester.tap(find.text('현재 유지')); await tester.pumpAndSettle(); expect(c.settings.copy.tasks.single.bandwidthKBps, 500); expect(CopyCenter.of(c).jobs, isEmpty); // lsync 탭: 일정 요약 · 맞추기 버튼 await tester.tap(find.text('lsync (실시간 동기화)')); await tester.pumpAndSettle(); expect(find.textContaining('0 9-17 * * 1-5'), findsOneWidget); expect(find.text('지금 맞추기'), findsOneWidget); expect(find.text('복사 · rsync 로 이동'), findsOneWidget); }); testWidgets('관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내) 카드의 [▶ 실행] 도 꺼진다', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_monbad_'); addTearDown(() => tmp.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c))); await tester.pump(); FilledButton run() => tester.widget(find.ancestor(of: find.text('실행'), matching: find.byWidgetPredicate((w) => w is FilledButton))); expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget); expect(run().onPressed, isNull, reason: '막히면 꺼짐'); // 옵션을 고치면 다시 켜진다 await tester.enterText(find.widgetWithText(TextField, '옵션'), '-av'); await tester.pump(); expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsNothing); expect(run().onPressed, isNotNull); }); testWidgets('233: 이동 카드의 저장 창은 "이 이동을" · 229 설정은 Rsync 탭에도 있다', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_mon233_'); addTearDown(() => tmp.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), move: true, method: 'builtin')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c))); await tester.pump(); await tester.enterText(find.widgetWithText(TextField, '속도 제한'), '500'); await tester.pump(); await tester.tap(find.text('저장')); await tester.pumpAndSettle(); expect(find.text('이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?'), findsOneWidget); await tester.tap(find.text('현재 유지')); await tester.pumpAndSettle(); // Rsync 탭의 설정에도 같은 스위치 await tester.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: CopySyncSettings(c: c, rsync: true))))); await tester.pump(); expect(find.text('받는 쪽이 더 새것이면 건너뛰기'), findsOneWidget); await tester.tap(find.text('받는 쪽이 더 새것이면 건너뛰기')); await tester.pump(); expect(c.settings.copy.skipNewer, isFalse); }); }