JJ_MKVMaker/test/live_sync_scene_test.dart

345 lines
19 KiB
Dart
Raw Permalink 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: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';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 관리자 지시 (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 = <String>[];
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 = <String>[];
await live.syncNow(pair);
if (live.status[k]!.$2.contains('실패')) why.add('상태: ${live.status[k]!.$2}');
final asked = live.toDelete[k] ?? const <String>[];
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 <String>[];
final wantExcl = {
'-a --delete --exclude=*.bak --delete-excluded': {'onlydst'},
'-a --delete --exclude=*.log --delete-excluded': {'keep.log', 'old', 'old2'},
}[opts] ?? const <String>{};
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(keepOptionsTogether('거른 것 2개도 지워집니다 (--delete-excluded)')), findsOneWidget);
// 순서: 그냥 지울 것 → 묶음 제목 → 거른 것
final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(keepOptionsTogether(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 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<void>.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);
});
});
}