JJ_MKVMaker/test/monitor_test.dart

358 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<File>(), 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.copyTasks = [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.copyMethodFolder = '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<void>.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.liveSyncPairs.single;
expect([pair.source, pair.target], [src, p.join(dst, 'src')]);
// 다시 복사 목록으로 (안의 것 → 대상)
final back = await center.fromLiveSync(pair);
expect(c.settings.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.copyMethodFolder = '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.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<void>.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()
..runInBackground = true
..liveSyncOnStart = 'ask';
final r = AppSettings.fromJson(s.toJson());
expect([r.runInBackground, r.liveSyncOnStart], [true, 'ask']);
expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).liveSyncOnStart, 'auto');
// 사용자 결정 (10/8): Android 백그라운드로 실행은 기본 켜짐. 사용자가 끈 값 (새 형식) 은 그대로
expect(AppSettings.fromJson({}).runInBackground, isTrue);
expect(AppSettings.fromJson({'runInBackground': false, 'backgroundV2': true}).runInBackground, isFalse);
final w = AppSettings()..backgroundRun = false;
if (Platform.isWindows) {
expect(w.closeAction, 'quit');
w.backgroundRun = true;
expect([w.closeAction, w.backgroundRun], ['background', true]);
} else {
expect(w.runInBackground, 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
..liveSyncPairs = [pa, pb]
..liveSyncOnStart = 'off';
final live = LiveSync(c)..start(hold: c.settings.liveSyncOnStart != 'auto');
// 맞추기 (rsync 프로세스) 가 끝난 뒤 닫고 지운다 - 도는 중에 지우면 "다른 프로세스가 사용 중" (전체 시험이 무거울 때)
addTearDown(() async {
for (var i = 0; i < 200 && live.anyRunning; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
live.dispose();
});
// 모아서 3초 뒤 맞춘다 - 바쁠 때는 늦으므로 정해진 4초가 아니라 될 때까지 (최대 30초)
Future<void> until(bool Function() done) async {
for (var i = 0; i < 120 && !done(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
expect(live.watching, isEmpty);
await Future<void>.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.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.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
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')]
..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.copyTasks.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('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.copyTasks = [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.copySkipNewer, isFalse);
});
}