JJ_MKVMaker/test/webdav_test.dart

392 lines
22 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: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/core/file_ops.dart';
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/core/webdav.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/dav_server.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'support/rsync_scenes.dart';
void main() {
late Directory tmp, remote, local;
late TestDavServer server;
const id = 'nas';
setUp(() async {
tmp = Directory.systemTemp.createTempSync('jj_dav_');
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
local = Directory(p.join(tmp.path, 'local'))..createSync();
server = TestDavServer(remote);
await server.start();
DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]);
});
tearDown(() async {
DavRegistry.configure([]);
await server.stop();
tmp.deleteSync(recursive: true);
});
File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(text);
File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(text);
test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () {
expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b');
expect(DavPath.parse('dav://nas').full, 'dav://nas/');
expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더');
expect(vDirname('dav://nas/a/b'), 'dav://nas/a');
expect(vDirname('dav://nas/a'), 'dav://nas/');
expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv');
expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue);
expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue);
expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse);
expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse);
expect(samePath('dav://nas/a', r'C:\a'), isFalse);
});
test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async {
rf('영상/a.mkv', 'aaaa');
rf('영상/sub/b.txt', 'b');
final top = await listEntries('dav://$id/');
expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]);
final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name);
expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]);
final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더');
expect(made, 'dav://$id/영상/새 폴더');
expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue);
final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv');
expect(renamed, 'dav://$id/영상/c.mkv');
expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa');
await FileOps().delete(['dav://$id/영상/sub']);
expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse);
// 잘못된 비밀번호
DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]);
expect(() => listEntries('dav://$id/'), throwsA(isA<DavException>().having((e) => e.status, 'status', 401)));
});
test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async {
lf('up/x.txt', 'xx');
lf('up/deep/y.bin', 'yyy');
var bytes = 0;
final ops = FileOps(onBytes: (n) => bytes += n);
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']);
expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy');
expect(bytes, 5);
// 같은 이름이면 "이름 (2)"
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']);
// WebDAV → 로컬
final down = Directory(p.join(local.path, 'down'))..createSync();
await ops.copy(['dav://$id/up'], down.path);
expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx');
// 같은 서버 안 이동 (서버가 MOVE)
await FileOps.makeFolder('dav://$id/', 'box');
await ops.move(['dav://$id/up (2)'], 'dav://$id/box');
expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue);
expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse);
expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty);
// 로컬 → WebDAV 이동 (복사 후 원본 지움)
lf('mv/m.txt', 'm');
await ops.move([p.join(local.path, 'mv')], 'dav://$id/box');
expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm');
expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse);
});
test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async {
lf('src/a.txt', 'a');
lf('src/sub/b.txt', 'b');
final ops = FileOps();
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2);
expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b');
// 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것)
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0);
// 원본에서 내용 (크기) 이 바뀐 것만
lf('src/a.txt', 'aaa');
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1);
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa');
// 지우기 포함: 대상에만 있는 것
rf('dst/extra.txt', 'e');
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true);
expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse);
// -u: 대상이 더 새것이면 덮어쓰지 않음
rf('dst/a.txt', 'remote newer!!');
final old = DateTime.now().subtract(const Duration(days: 1));
File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old);
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true);
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!');
// 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음)
final back = p.join(local.path, 'back');
expect(await ops.mirror('dav://$id/dst', back), 2);
expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b');
expect(await ops.mirror('dav://$id/dst', back), 0);
});
test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async {
rf('p/e1/e2/.keep', '');
File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync();
rf('p/f/z.txt', 'z');
expect(await vCountFiles('dav://$id/p'), 1);
expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1
expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue);
expect(await DavRegistry.client(id).quota(), (1000000, 500));
final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache'));
expect(File(got).readAsStringSync(), 'z');
expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로'));
});
test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async {
lf('live/a.txt', 'a');
lf('live/sub/b.txt', 'bb');
rf('mirror/old.txt', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 예전에 확인한 쌍은 처음 맞출 때 지금 옵션을 기억하며 설정을 저장한다 (서버 목록도 설정에서 다시 읽음) - 시험 서버를 설정에도
c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')];
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');
expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse);
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
live.dispose();
});
test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async {
rf('영상/1화.mkv', 'v1');
rf('영상/2화.mkv', 'v2');
rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
rf('영상/메모.txt', 't');
final s = davStream('dav://$id/영상/1화.mkv');
expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}');
expect(s.headers['Authorization'], startsWith('Basic '));
// 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다
expect(vPlayable('dav://$id/영상/1화.mkv'), s.url);
expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass')));
expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv');
// 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청)
final http = HttpClient();
final req = await http.getUrl(Uri.parse(s.url));
s.headers.forEach(req.headers.set);
final res = await req.close();
expect(await res.transform(const SystemEncoding().decoder).join(), 'v1');
http.close();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
final before = server.requests.where((r) => r.startsWith('GET')).length;
final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']);
expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']);
expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음
final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv');
expect(subs.map(p.basename), ['1화.ko.srt']);
expect(File(subs.single).readAsStringSync(), contains('안녕'));
});
test('M1 (관리자 P0): 원본 파일 · 받는 쪽 같은 이름 폴더 - 지우기 없으면 폴더를 지우지 않고 건너뜀, 지우기면 지우고 파일로 · 미리 보기도 같음', () async {
lf('src/x', 'file');
lf('src/ok.txt', 'ok');
rf('dst/x/keep.txt', 'k');
rf('dst/x/sub/deep.txt', 'd');
final src = p.join(local.path, 'src');
const dst = 'dav://$id/dst';
// 지우기 없음: 미리 보기 "건너뜀" · 지울 것 없음 → 실제로도 폴더 그대로
var pv = await previewSync(src, dst, toRight: true);
expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']);
expect(pv.where((x) => x.action == PreviewAction.delete), isEmpty);
final ops = FileOps();
await ops.mirror(src, dst);
expect(File(p.join(remote.path, 'dst', 'x', 'keep.txt')).readAsStringSync(), 'k', reason: '예전에는 이 폴더를 통째로 지웠다');
expect(File(p.join(remote.path, 'dst', 'x', 'sub', 'deep.txt')).existsSync(), isTrue);
expect(File(p.join(remote.path, 'dst', 'ok.txt')).readAsStringSync(), 'ok', reason: '다른 것은 그대로 맞춤');
expect(ops.dirConflicts.map(vBasename), ['x']);
// 지우기 포함: 미리 보기에 "폴더 · 안의 3개를 지우고 파일로" → 실제로 폴더가 파일로
pv = await previewSync(src, dst, toRight: true, delete: true);
final del = pv.singleWhere((x) => x.action == PreviewAction.delete);
expect((del.rel, del.isDir, del.replacedByFile, del.inner), ('x', true, true, 3));
final ops2 = FileOps();
await ops2.mirror(src, dst, delete: true);
expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file');
expect(ops2.dirConflicts, isEmpty);
});
/// 관리자 지시 (10/19): 원격 맞추기 (_mirrorV) 가 rsync 옵션의 거르기를 미리 보기와 같게 따르는지 -
/// 미리 보기의 "새로 · 바뀜" 은 실제로 생기고, "지워짐" 은 실제로 없어지고, 그 밖의 받는 쪽 것은 그대로
Future<void> checkSame(String src, String dst, String Function(String rel) real, String options, {required bool delete}) async {
final filter = RsyncFilter.parse(options);
final before = <String>{};
void scan(Directory d, String rel) {
for (final e in d.listSync()) {
final r = rel.isEmpty ? p.basename(e.path) : '$rel/${p.basename(e.path)}';
before.add(r);
if (e is Directory) scan(e, r);
}
}
final root = Directory(real(''));
if (root.existsSync()) scan(root, '');
final pv = await previewSync(src, dst, toRight: true, delete: delete, filter: filter);
await FileOps().mirror(src, dst, delete: delete, filter: filter);
for (final x in pv) {
final there = FileSystemEntity.typeSync(real(x.rel)) != FileSystemEntityType.notFound;
switch (x.action) {
case PreviewAction.add || PreviewAction.update:
expect(there, isTrue, reason: '미리 보기 + / ~ ${x.rel} → 실제로 있음 ($options)');
case PreviewAction.delete when !x.replacedByFile:
expect(there, isFalse, reason: '미리 보기 − ${x.rel} → 실제로 지워짐 ($options)');
default:
}
}
final shownDeleted = {for (final x in pv) if (x.action == PreviewAction.delete) x.rel};
for (final r in before) {
final gone = FileSystemEntity.typeSync(real(r)) == FileSystemEntityType.notFound;
final underShown = shownDeleted.any((d) => r == d || r.startsWith('$d/'));
if (gone) expect(underShown, isTrue, reason: '실제로 지워진 $r 는 미리 보기 지울 목록에 ($options)');
}
}
test('관리자 지시 (10/19): WebDAV 맞추기가 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따르고 미리 보기와 같음', () async {
void fresh() {
for (final d in [Directory(p.join(local.path, 'src')), Directory(p.join(remote.path, 'dst'))]) {
if (d.existsSync()) d.deleteSync(recursive: true);
}
lf('src/a.txt', 'a');
lf('src/skip.tmp', 's');
lf('src/cache/c.bin', 'c');
lf('src/keep/x.txt', 'x');
lf('src/keep/y.log', 'y');
lf('src/한글 폴더/가.txt', '가');
rf('dst/only.tmp', 'o'); // 받는 쪽에만 있고 거른 것
rf('dst/old.txt', 'old'); // 받는 쪽에만 있음
rf('dst/cache/remote.bin', 'r'); // 거른 폴더
rf('dst/gone/g.tmp', 'g'); // 받는 쪽에만 있는 폴더 안의 거른 것
rf('dst/gone/g.txt', 'g');
}
final src = p.join(local.path, 'src');
const dst = 'dav://$id/dst';
String real(String rel) => p.joinAll([remote.path, 'dst', ...rel.split('/').where((s) => s.isNotEmpty)]);
for (final (opts, delete) in [
('-av --exclude=*.tmp --exclude=cache/', true),
('-av --exclude=*.tmp --exclude=cache/ --delete-excluded', true),
('-av --include=keep/ --include=keep/*.txt --exclude=keep/* --exclude=*.tmp', true),
('-av --filter="- *.log" --filter="- /cache"', true),
('-av --exclude=*.tmp', false),
]) {
fresh();
await checkSame(src, dst, real, opts, delete: delete);
}
// 하나를 자세히: 거른 것은 보내지 않고 받는 쪽의 거른 것은 지키며, 거른 것이 든 폴더는 남기고 안의 것만 지움
fresh();
await FileOps().mirror(src, dst, delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/'));
expect(File(real('a.txt')).existsSync(), isTrue);
expect(File(real('skip.tmp')).existsSync(), isFalse, reason: '거른 것은 보내지 않음');
expect(File(real('cache/c.bin')).existsSync(), isFalse, reason: '거른 폴더 안으로 내려가지 않음');
expect(File(real('only.tmp')).readAsStringSync(), 'o', reason: '받는 쪽의 거른 것은 지키기');
expect(File(real('cache/remote.bin')).existsSync(), isTrue);
expect(File(real('old.txt')).existsSync(), isFalse, reason: '거르지 않은 것은 --delete 로 지움');
expect(File(real('gone/g.tmp')).existsSync(), isTrue, reason: '거른 것이 든 폴더는 남기고');
expect(File(real('gone/g.txt')).existsSync(), isFalse, reason: '그 안의 거르지 않은 것만 지움');
expect(File(real('한글 폴더/가.txt')).readAsStringSync(), '가');
// --delete-excluded: 받는 쪽의 거른 것도 지움
fresh();
await FileOps().mirror(src, dst, delete: true, filter: RsyncFilter.parse('--exclude=*.tmp --exclude=cache/ --delete-excluded'));
expect(File(real('only.tmp')).existsSync(), isFalse);
expect(Directory(real('cache')).existsSync(), isFalse);
expect(Directory(real('gone')).existsSync(), isFalse);
});
test('관리자 지시 (10/20): WebDAV 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
final src = p.join(local.path, 'scene_src'), want = p.join(local.path, 'scene_rsync'), got = p.join(remote.path, 'scene');
final bad = <String>[];
for (final opts in sceneOptions) {
writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts);
writeScene(src, got);
await FileOps().mirror(src, 'dav://$id/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
final d = treeDiff(w, treeOf(got));
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
}
expect(bad, isEmpty, reason: bad.join('\n'));
}, timeout: const Timeout(Duration(minutes: 5)));
test('관리자 지시 (10/20): 실시간 동기화 (WebDAV rsync 쌍) 결과 = 실제 rsync 결과 · 지울지 묻는 목록 = rsync 가 지우는 것 (28 의 장면 · 옵션 28개)', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
final src = p.join(local.path, 'live_src'), want = p.join(local.path, 'live_rsync'), got = p.join(remote.path, 'live');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 지우기를 정하면 설정을 저장하며 서버 목록을 설정에서 다시 읽는다 - 시험 서버를 설정에도
c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')];
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);
// rsync 가 지운 것 (받는 쪽에 있다가 없어진 것)
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
c.settings.rsyncOptions = opts;
// 새 쌍 (지우기 확인 전) - 지울 것이 있으면 지우지 않고 맞춘 뒤 [toDelete] 로 묻는다
final pair = LiveSyncPair(src, 'dav://$id/live', 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>[];
// 묻는 목록 = rsync 가 지우는 것 (폴더는 한 줄로 - 그 안의 것은 그 폴더에 든 것으로)
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');
}
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)));
test('관리자 3a 판단 (10/20): 원격 (WebDAV) 쌍도 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다', () async {
lf('rq/a.txt', 'a');
rf('rqd/only.tmp', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]
..rsyncOptions = '-av --exclude=*.tmp'
..liveSyncPairs = [
LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd',
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);
c.settings.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
await live.syncNow(pair());
expect(live.toDelete[k], ['only.tmp']);
expect(live.reasked, contains(k));
expect(File(p.join(remote.path, 'rqd', 'only.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음');
await live.decideDelete(pair(), delete: true);
expect(File(p.join(remote.path, 'rqd', 'only.tmp')).existsSync(), isFalse);
expect(pair().confirmedOptions, '-av --exclude=*.tmp --delete-excluded');
});
}