JJ_MKVMaker/test/rsync_msys_args_test.dart

171 lines
9.8 KiB
Dart

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<StateError>()));
// 옵션 칸에서도 미리 빨갛게 (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<String> sf, List<String> 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<String>, List<String>, List<String>, List<String>)>[
("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)');
});
}