273 lines
18 KiB
Dart
273 lines
18 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 --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);
|
|
}
|
|
});
|
|
|
|
test('관리자 지시 (10/20): Windows 는 드라이브 · UNC · /cygdrive/ 만 전체 경로 ("/" 는 MSYS 가 자기 폴더 기준으로 읽음), Android 는 / · 끝 문구', () {
|
|
expect(unreadableOptions('-a --exclude-from=/lists/l.txt', windows: true), hasLength(1));
|
|
expect(unreadableOptions('-a --exclude-from=/sdcard/l.txt', windows: false), isEmpty);
|
|
expect(unreadableOptions('-a --exclude-from=C:/l.txt', windows: false), hasLength(1), reason: 'Android 에서는 / 로 시작해야');
|
|
for (final o in [r'-a --exclude-from=\\nas\s\l.txt', '-a --exclude-from=//nas/s/l.txt', '-a --exclude-from=/cygdrive/c/l.txt']) {
|
|
expect(unreadableOptions(o, windows: true), isEmpty, reason: o);
|
|
}
|
|
expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: true), windows: true), r'C:\ 처럼 드라이브로 시작하는 전체 경로로');
|
|
expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: false), windows: false), '/ 로 시작하는 전체 경로로');
|
|
});
|
|
|
|
test('관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\\ · C:/ · UNC 는 rsync 가 읽는 꼴로 바꿔 넘긴다 (막지 않음)', () {
|
|
// UNC 는 \\서버\… 로 (28 · 15 확인 10/20: 목록 · 기록 파일은 //서버/… 를 열지 못함, exit 11)
|
|
expect(
|
|
rsyncFileOptionArgs(
|
|
[r'--exclude-from=C:\l\a.txt', '--include-from', 'C:/l/b.txt', r'--files-from=\\nas\s\f.txt', '--exclude-from=//nas/s/g.txt'],
|
|
windows: true),
|
|
['--exclude-from=/cygdrive/c/l/a.txt', '--include-from', '/cygdrive/c/l/b.txt', r'--files-from=\\nas\s\f.txt', r'--exclude-from=\\nas\s\g.txt']);
|
|
// 받는 쪽은 UNC 를 \\ 로 (//서버 는 change_dir 실패, exit 3) · 원본은 // 그대로 (toCygwinPath)
|
|
expect(rsyncDestPath(r'\\nas\s\d'), r'\\nas\s\d');
|
|
expect(rsyncDestPath('//nas/s/d'), r'\\nas\s\d');
|
|
expect(rsyncDestPath(r'C:\d'), '/cygdrive/c/d');
|
|
expect(rsyncDestPath(r'\\nas\s\d', windows: false), r'\\nas\s\d');
|
|
expect(toCygwinPath(r'\\nas\s\src'), '//nas/s/src');
|
|
final a = rsyncArgs(options: '-a', sources: [r'\\nas\s\src'], dest: r'\\nas\s\dst', contents: true);
|
|
expect(a.sublist(a.length - 2), ['//nas/s/src/', r'\\nas\s\dst/']);
|
|
expect(rsyncFileOptionArgs(['--filter=merge,- C:/r/m.txt', '--filter', '. C:/r/d.txt', '-f. C:/r/e.txt', '-avf', 'merge C:/r/g.txt'], windows: true),
|
|
['--filter=merge,- /cygdrive/c/r/m.txt', '--filter', '. /cygdrive/c/r/d.txt', '-f. /cygdrive/c/r/e.txt', '-avf', 'merge /cygdrive/c/r/g.txt']);
|
|
expect(rsyncFileOptionArgs(['--log-file=C:/logs/r.log', '--write-batch', r'D:\b\batch', '--only-write-batch=C:/b2', '--read-batch=C:/b3'], windows: true),
|
|
['--log-file=/cygdrive/c/logs/r.log', '--write-batch', '/cygdrive/d/b/batch', '--only-write-batch=/cygdrive/c/b2', '--read-batch=/cygdrive/c/b3']);
|
|
// 바꾸지 않는 것: 패턴 · 폴더마다 읽는 규칙 · 받는 쪽 기준 옵션 · 다른 값 받는 글자 · 이미 /cygdrive · Android
|
|
final keep = ['--exclude=C:/x', '--filter=: .rsync-filter', '--filter=- C:/x', '--backup-dir=C:/b', '-e', 'C:/ssh.exe', '--exclude-from=/cygdrive/c/l.txt'];
|
|
expect(rsyncFileOptionArgs(keep, windows: true), keep);
|
|
expect(rsyncFileOptionArgs([r'--exclude-from=C:\l.txt'], windows: false), [r'--exclude-from=C:\l.txt']);
|
|
});
|
|
|
|
test('관리자 지시 (10/20, 28 지적): 표준 입력 (-) 은 따로 된 문구 (하이픈이 겹치지 않게)', () {
|
|
for (final o in ['-a --files-from=-', '-a --exclude-from=-', '-a --include-from -']) {
|
|
final bad = optionProblems(o, windows: true);
|
|
expect(bad.map((x) => x.text), ['표준 입력 (-) 은 쓸 수 없습니다'], reason: o);
|
|
expect(optionProblemHint(bad, windows: true), '목록 파일의 전체 경로를 적어 주세요', 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('28 확인 (10/20): 목록 · 기록 파일을 C:/ · C:\\ 로 적어도 rsync 가 연다 (예전: failed to open exclude file, exit 11) - keep.txt 를 지킴', () async {
|
|
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
|
final lists = Directory(p.join(tmp.path, 'lists'))..createSync();
|
|
File(p.join(lists.path, 'ex.txt')).writeAsStringSync('keep.txt\n');
|
|
File(p.join(lists.path, 'rules.txt')).writeAsStringSync('- keep2.txt\n');
|
|
final slash = lists.path.replaceAll(r'\', '/');
|
|
for (final (name, opts) in [
|
|
('--exclude-from=C:/…', '-a --delete --exclude-from=$slash/ex.txt --exclude=keep2.txt'),
|
|
(r'--exclude-from=C:\…', '-a --delete --exclude-from="${lists.path}\\ex.txt" --exclude=keep2.txt'),
|
|
('--exclude-from C:/… (다음 낱말)', '-a --delete --exclude-from $slash/ex.txt --exclude=keep2.txt'),
|
|
('--filter="merge C:/…"', '-a --delete --exclude=keep.txt --filter="merge $slash/rules.txt"'),
|
|
('-f". C:/…"', '-a --delete --exclude=keep.txt -f". $slash/rules.txt"'),
|
|
]) {
|
|
final (s, d) = scene('s_$name'.replaceAll(RegExp(r'[^\w]'), '_'), 'd_$name'.replaceAll(RegExp(r'[^\w]'), '_'), ['a.txt'],
|
|
['keep.txt', 'keep2.txt', 'old.txt']);
|
|
expect(unreadableOptions(opts), isEmpty, reason: '$name: 막지 않음');
|
|
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: '$name: ${job.error}');
|
|
expect(treeOf(d).keys.toSet(), {'keep.txt', 'keep2.txt', 'a.txt'}, reason: '$name: 목록의 것을 지키고 old.txt 만 지움');
|
|
}
|
|
// --log-file=C:/… 은 그 자리에 기록을 남긴다 (예전에는 열지 못해 실패)
|
|
final (s, d) = scene('s_log', 'd_log', ['a.txt'], []);
|
|
final job = TransferJob(
|
|
sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a --log-file=$slash/r.log', rsyncExe: rsync, contents: true);
|
|
await job.run();
|
|
expect(job.error, isNull, reason: '${job.error}');
|
|
expect(File(p.join(lists.path, 'r.log')).existsSync(), isTrue);
|
|
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
|
|
|
|
test('28 · 15 확인 (10/20): 네트워크 공유 (UNC, 관리 공유 \\\\localhost\\C\$ 로) - 원본 · 받는 쪽 · 목록 파일 (\\\\ · //) 이 모두 된다', () async {
|
|
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
|
|
String unc(String local) => '\\\\localhost\\C\$${local.substring(2)}';
|
|
if (!Directory(unc(tmp.path)).existsSync()) return markTestSkipped('관리 공유 \\\\localhost\\C\$ 를 쓸 수 없음');
|
|
final list = File(p.join(tmp.path, 'ex.txt'))..writeAsStringSync('keep.txt\n');
|
|
for (final (name, listArg) in [('목록 \\\\', unc(list.path)), ('목록 //', unc(list.path).replaceAll(r'\', '/'))]) {
|
|
final (s, d) = scene('us_${name.length}', 'ud_${name.length}', ['a.txt'], ['keep.txt', 'old.txt']);
|
|
// 사용자가 고른 그대로의 UNC 경로 (원본 · 받는 쪽 모두)
|
|
final job = TransferJob(
|
|
sources: [unc(s)], dest: unc(d), move: false, method: CopyMethod.rsync, options: '-a --delete --exclude-from=$listArg', rsyncExe: rsync, contents: true);
|
|
await job.run();
|
|
expect(job.error, isNull, reason: '$name: ${job.error}');
|
|
expect(treeOf(d).keys.toSet(), {'keep.txt', 'a.txt'}, reason: name);
|
|
}
|
|
// 실시간 동기화 쌍의 받는 쪽도 UNC
|
|
final (s, d) = scene('ls', 'ld', ['a.txt'], ['old.txt']);
|
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
|
c.settings
|
|
..copy.rsyncSource = 'custom'
|
|
..copy.rsyncPath = rsync
|
|
..copy.rsyncOptions = '-a'
|
|
..copy.liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')];
|
|
final live = LiveSync(c);
|
|
addTearDown(live.dispose);
|
|
await live.syncNow(c.settings.copy.liveSyncPairs.single);
|
|
expect(live.status[LiveSync.keyOf(c.settings.copy.liveSyncPairs.single)]!.$2, isNot(contains('실패')));
|
|
expect(treeOf(d).keys.toSet(), {'a.txt'});
|
|
}, 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
|
|
..copy.rsyncSource = 'custom'
|
|
..copy.rsyncPath = rsync
|
|
..copy.rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"'
|
|
..copy.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.copy.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)');
|
|
});
|
|
}
|