diff --git a/integration_test/windows_lsync_p0_shot_test.dart b/integration_test/windows_lsync_p0_shot_test.dart index a790ba1..1ed8d93 100644 --- a/integration_test/windows_lsync_p0_shot_test.dart +++ b/integration_test/windows_lsync_p0_shot_test.dart @@ -9,6 +9,7 @@ import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/i18n_controller.dart'; import 'package:jj_mkvmaker/app/live_sync.dart'; import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/sync_tools.dart' show toCygwinPath; import 'package:jj_mkvmaker/l10n/tr.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; @@ -19,7 +20,7 @@ import 'package:path/path.dart' as p; import 'support/temp_dir.dart'; -/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 ("H a.txt") 이 있는 지우기 포함 쌍 - 모니터링 카드의 빨간 줄 +/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 (merge 파일 - 안에 "H a.txt") 이 있는 지우기 포함 쌍 - 모니터링 카드의 빨간 줄 /// "자동으로 지우지 않습니다" · [지금 지우기 (확인)] 창 (FilterLimitNote · 체크) 을 412 · 960, ko · en · ja 로 찍는다. /// 시험 폴더 안에서만 (창은 [나중에] - 지우지 않음). /// 실행: flutter test integration_test\windows_lsync_p0_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG) @@ -43,11 +44,12 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('$dir/$rel'); } + File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..rsyncSource = 'custom' ..rsyncPath = rsync - ..rsyncOptions = '-a --filter="H a.txt"' + ..rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"' ..liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)]; final live = LiveSync(c)..start(); addTearDown(live.dispose); diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index cdb01d8..843cd08 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -214,23 +214,20 @@ class LiveSync extends ChangeNotifier { static Future _gone(String path, String r, bool isDir, RsyncFilter filter, List out, int limit, [List? excluded]) async { if (out.length >= limit) return; - if (filter.excluded(r, isDir: isDir)) { - if (!filter.deleteExcluded) return; - out.add('− $r'); - excluded?.add(r); - return; + // 관리자 지시 (10/20): 받는 쪽 규칙 (- · P · R · ,r - --delete-excluded 면 쪽을 적지 않은 규칙은 빠짐) 에 걸린 것만 지킨다 + if (filter.protects(r, isDir: isDir)) return; + Future mark() async { + // --delete-excluded 때문에 지워지는 것: 보내는 쪽에서 거른 것 · 그런 것이 든 폴더 + if (excluded != null && + filter.deleteExcluded && + (filter.excluded(r, isDir: isDir) || (isDir && await _hasExcluded(path, r, filter, sender: true)))) { + excluded.add(r); + } } - if (!isDir || filter.isEmpty) { - out.add('− $r'); - return; - } - if (filter.deleteExcluded) { - out.add('− $r'); - if (excluded != null && await _hasExcluded(path, r, filter)) excluded.add(r); - return; - } - if (!await _hasExcluded(path, r, filter)) { + + if (!isDir || !filter.protectsAny || !await _hasExcluded(path, r, filter)) { out.add('− $r'); + await mark(); return; } List<({String path, bool isDir, int size, DateTime modified})> items; @@ -246,8 +243,8 @@ class LiveSync extends ChangeNotifier { } } - /// 폴더 [dir] (상대 경로 [rel]) 안에 거른 것이 하나라도 있는지 - static Future _hasExcluded(String dir, String rel, RsyncFilter filter) async { + /// 폴더 [dir] (상대 경로 [rel]) 안에 받는 쪽에서 지키는 것 ([sender] 면 보내는 쪽에서 거른 것) 이 하나라도 있는지 + static Future _hasExcluded(String dir, String rel, RsyncFilter filter, {bool sender = false}) async { List<({String path, bool isDir, int size, DateTime modified})> items; try { items = await vList(dir); @@ -256,8 +253,8 @@ class LiveSync extends ChangeNotifier { } for (final e in items) { final r = '$rel/${vBasename(e.path)}'; - if (filter.excluded(r, isDir: e.isDir)) return true; - if (e.isDir && await _hasExcluded(e.path, r, filter)) return true; + if (sender ? filter.excluded(r, isDir: e.isDir) : filter.protects(r, isDir: e.isDir)) return true; + if (e.isDir && await _hasExcluded(e.path, r, filter, sender: sender)) return true; } return false; } diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 06e8115..b5ee4ab 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -935,7 +935,7 @@ class FileOps { } for (final x in items) { final r = '$rel/${vBasename(x.path)}'; - if (filter.excluded(r, isDir: x.isDir)) return true; + if (filter.protects(r, isDir: x.isDir)) return true; if (x.isDir && await hasExcluded(x.path, r)) return true; } return false; @@ -947,7 +947,7 @@ class FileOps { final name = vBasename(x.path); if (name.endsWith('.jjsync') || isAppTempName(name)) continue; final r = '$rel/$name'; - if (filter.excluded(r, isDir: x.isDir)) continue; + if (filter.protects(r, isDir: x.isDir)) continue; if (x.isDir && await hasExcluded(x.path, r)) { await deleteInside(x.path, r); } else { @@ -1011,11 +1011,9 @@ class FileOps { if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; _check(); final r = rel.isEmpty ? name : '$rel/$name'; - if (filter.excluded(r, isDir: t.value.isDir)) { - if (filter.deleteExcluded) await vDelete(t.value.path); - continue; - } - if (t.value.isDir && !filter.isEmpty && !filter.deleteExcluded && await hasExcluded(t.value.path, r)) { + // 받는 쪽 규칙에 걸린 것만 지킨다 (관리자 지시 10/20: 보내는 쪽에서만 거른 H · -,s · --delete-excluded 의 그냥 규칙은 지움) + if (filter.protects(r, isDir: t.value.isDir)) continue; + if (t.value.isDir && filter.protectsAny && await hasExcluded(t.value.path, r)) { await deleteInside(t.value.path, r); continue; } diff --git a/lib/core/rsync_filter.dart b/lib/core/rsync_filter.dart index 684632e..724c332 100644 --- a/lib/core/rsync_filter.dart +++ b/lib/core/rsync_filter.dart @@ -15,6 +15,10 @@ import 'sync_tools.dart' show splitOptions; /// - `이름/***` 은 그 폴더와 안의 모든 것. /// - 거른 폴더 안으로는 내려가지 않는다. 받는 쪽의 거른 것은 지우지 않는다 (--delete-excluded 면 지운다). /// - rsync 처럼 바이트 단위: `?` · `[…]` 는 UTF-8 한 바이트 (그래서 `?.txt` 는 "가.txt" 에 맞지 않는다). +/// - 관리자 지시 (10/20): 규칙마다 걸리는 쪽 - 그냥 `+` · `-` 는 양쪽, `H` (hide) = `-,s` · `S` (show) = `+,s` 는 보내는 쪽 +/// (보낼 것 고르기) 에만, `P` (protect) = `-,r` · `R` (risk) = `+,r` 은 받는 쪽 (지울 것 고르기) 에만. 쪽마다 그 쪽에 걸리는 +/// 규칙만 위에서부터 보고 처음 맞는 것이 이긴다. --delete-excluded 면 쪽을 적지 않은 규칙은 보내는 쪽에만 (rsync 와 같음). +/// 보내는 쪽에서 거른 것 (보내지 않음) 은 받는 쪽에서 지울 것이 되고, 받는 쪽 규칙에 걸린 것만 지킨다. class RsyncFilter { final List rules; @@ -30,6 +34,13 @@ class RsyncFilter { bool get isEmpty => rules.isEmpty && !deleteExcluded; + /// 받는 쪽에서 지키는 규칙이 하나라도 있는지 (없으면 받는 쪽에만 있는 것은 모두 지운다 - 폴더를 뒤질 필요 없음) + bool get protectsAny => rules.any((r) => !r.include && _onReceiver(r)); + + bool _onSender(RsyncFilterRule r) => r.side != RuleSide.receiver; + bool _onReceiver(RsyncFilterRule r) => + r.side == RuleSide.receiver || r.side == RuleSide.bothExplicit || (r.side == RuleSide.both && !deleteExcluded); + /// [options] (rsync 옵션 칸의 글) 에서 규칙을 읽는다. [readLines] 는 --exclude-from 파일 읽기 (시험에서 바꿈) factory RsyncFilter.parse(String options, {List? Function(String path)? readLines}) { final read = readLines ?? _readLines; @@ -63,15 +74,34 @@ class RsyncFilter { } void filterRule(String r) { - // 15 검토 F1: 고치개 (",s" 보내는 쪽만 · ",r" 받는 쪽만 · ",!" 반대 · ",p" 등) 가 붙은 규칙은 뜻이 달라 - // (예: "-,s a.txt" 는 받는 쪽 a.txt 를 지키지 않아 --delete 로 지워진다) 미리 볼 수 없다 - 적용하지 않고 알린다. - // H · S · P · R · merge 도 같다 - final m = RegExp(r'^(\+|-|include|exclude)\s+(.+)$').firstMatch(r.trim()); + // 관리자 지시 (10/20): H · S · P · R 과 고치개 ",s" (보내는 쪽만) · ",r" (받는 쪽만) 를 rsync 와 같게 따른다 + // (예: "-,s a.txt" · "H a.txt" 는 보내지 않고, 받는 쪽 a.txt 는 지키지 않아 --delete 로 지워진다 - 미리 보기에도 그렇게). + // 그 밖의 고치개 (",!" 반대 · ",p" · ",x" · ",e" 등) · merge · "!" 는 미리 볼 수 없다 - 적용하지 않고 알린다 + final m = RegExp(r'^(\+|-|include|exclude|H|S|P|R|hide|show|protect|risk)(?:,([a-zA-Z!/]*))?\s+(.+)$').firstMatch(r.trim()); if (m == null) { unsupported.add('--filter=$r'); return; } - rules.add(RsyncFilterRule(m.group(2)!, include: m.group(1) == '+' || m.group(1) == 'include')); + final name = m.group(1)!, mods = m.group(2) ?? ''; + final short = const {'include': '+', 'exclude': '-', 'hide': 'H', 'show': 'S', 'protect': 'P', 'risk': 'R'}[name] ?? name; + // 쪽 (s · r) 말고 다른 고치개는 미리 볼 수 없다. H · S · P · R 에는 쪽 고치개도 받지 않는다 (이미 쪽이 정해짐) + if (mods.contains(RegExp(r'[^sr]')) || ('HSPR'.contains(short) && mods.isNotEmpty)) { + unsupported.add('--filter=$r'); + return; + } + final include = short == '+' || short == 'S' || short == 'R'; + final side = switch (short) { + 'H' || 'S' => RuleSide.sender, + 'P' || 'R' => RuleSide.receiver, + _ => mods.contains('s') && !mods.contains('r') + ? RuleSide.sender + : mods.contains('r') && !mods.contains('s') + ? RuleSide.receiver + : mods.isEmpty + ? RuleSide.both + : RuleSide.bothExplicit, + }; + rules.add(RsyncFilterRule(m.group(3)!, include: include, side: side)); } for (var i = 0; i < t.length; i++) { @@ -132,22 +162,35 @@ class RsyncFilter { } } - /// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 걸러지는지 + /// 옮기는 맨 위 기준 상대 경로 [rel] ("/" 구분) 이 보내는 쪽에서 걸러지는지 (보내지 않음 · 그 폴더 안으로 내려가지 않음) bool excluded(String rel, {required bool isDir}) { for (final r in rules) { - if (r.matches(rel, isDir: isDir)) return !r.include; + if (_onSender(r) && r.matches(rel, isDir: isDir)) return !r.include; + } + return false; + } + + /// 받는 쪽의 [rel] 을 지우기 (--delete) 에서 지키는지 - 받는 쪽에 걸리는 규칙 가운데 처음 맞는 것이 빼기 (- · P) 면 지킨다. + /// 보내는 쪽에만 거른 것 (H · -,s · --delete-excluded 의 그냥 규칙) 은 지키지 않는다 + bool protects(String rel, {required bool isDir}) { + for (final r in rules) { + if (_onReceiver(r) && r.matches(rel, isDir: isDir)) return !r.include; } return false; } } +/// 규칙이 걸리는 쪽. [both] = 쪽을 적지 않은 규칙 (--delete-excluded 면 보내는 쪽만), [bothExplicit] = ",sr" 로 양쪽을 적음 +enum RuleSide { both, bothExplicit, sender, receiver } + class RsyncFilterRule { final String pattern; final bool include; + final RuleSide side; late final bool _dirOnly; late final RegExp _re; - RsyncFilterRule(this.pattern, {required this.include}) { + RsyncFilterRule(this.pattern, {required this.include, this.side = RuleSide.both}) { var p = pattern; var all = false; // "이름/***" = 그 폴더와 안의 모든 것 if (p.endsWith('/***')) { diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index 881695a..3c88955 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -135,7 +135,7 @@ Future> previewSync(String src, String dst, final name = vBasename(x.path); if (isAppTempName(name)) continue; final r = '$rel/$name'; - if (filter.excluded(r, isDir: x.isDir)) continue; + if (filter.protects(r, isDir: x.isDir)) continue; if (x.isDir && await _hasExcluded(x.path, r, filter)) { await deleteInside(x.path, r); } else { @@ -211,16 +211,12 @@ Future> previewSync(String src, String dst, // 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.part 등) 도 --delete 면 지워진다 - 지울 목록에 (지우지 않으면 셀 것 없음) if (names.contains(name) || isAppTempName(name) || (isPartialFile(name) && !delete)) continue; final r = rel.isEmpty ? name : '$rel/$name'; - if (filter.excluded(r, isDir: e.isDir)) { - // 거른 것은 rsync 가 건드리지 않는다 (--delete-excluded 와 --delete 면 지운다) - if (delete && filter.deleteExcluded) { - out.add(PreviewItem(r, PreviewAction.delete, toRight: toRight, isDir: e.isDir, size: e.size)); - } - continue; - } - // 177 (28 확인): 받는 쪽에만 있는 폴더 안에 거른 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고 + // 받는 쪽 규칙 (- · P · R · ,r) 에 걸린 것은 rsync 가 지우지 않는다. 관리자 지시 (10/20): 보내는 쪽에서만 거른 것 + // (H · -,s · --delete-excluded 의 그냥 규칙) 은 보내지 않았으므로 --delete 면 지운다. 지우지 않을 때는 거른 것을 세지 않음 (예전과 같음) + if (filter.protects(r, isDir: e.isDir) || (!delete && filter.excluded(r, isDir: e.isDir))) continue; + // 177 (28 확인): 받는 쪽에만 있는 폴더 안에 지키는 것이 있으면 rsync 는 그것을 지키느라 폴더를 남기고 // 거르지 않은 것만 지운다 - 폴더 통째로 "지워짐" 이 아니라 안의 것만 - if (delete && e.isDir && !filter.isEmpty && !filter.deleteExcluded && await _hasExcluded(e.path, r, filter)) { + if (delete && e.isDir && filter.protectsAny && await _hasExcluded(e.path, r, filter)) { await deleteInside(e.path, r); continue; } @@ -233,7 +229,7 @@ Future> previewSync(String src, String dst, return out; } -/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다) +/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 받는 쪽에서 지키는 것이 있는지 (지키는 폴더 안으로는 들어가지 않는다) Future _hasExcluded(String dir, String rel, RsyncFilter filter) async { List<({String path, bool isDir, int size, DateTime modified})> items; try { @@ -245,7 +241,7 @@ Future _hasExcluded(String dir, String rel, RsyncFilter filter) async { final name = vBasename(x.path); if (isPartialFile(name) || isAppTempName(name)) continue; final r = '$rel/$name'; - if (filter.excluded(r, isDir: x.isDir)) return true; + if (filter.protects(r, isDir: x.isDir)) return true; if (x.isDir && await _hasExcluded(x.path, r, filter)) return true; } return false; diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index cd14d23..7704d52 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -228,11 +228,12 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('o'); bool? r; - final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="H a.txt"'); + // 관리자 지시 (10/20): H 는 이제 미리 볼 수 있다 - 아직 따르지 못하는 merge 규칙으로 + final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge rules.txt"'); await host(tester, (ctx) async => r = await confirmDeletingRun(ctx, t)); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200); expect(find.textContaining('미리 볼 수 없습니다'), findsOneWidget); - expect(find.textContaining('H a.txt'), findsWidgets); + expect(find.textContaining('merge rules.txt'), findsWidgets); FilledButton run() => tester.widget(find.widgetWithText(FilledButton, '실행')); expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음'); await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)')); diff --git a/test/live_sync_scene_test.dart b/test/live_sync_scene_test.dart index 37e3b87..d94d63b 100644 --- a/test/live_sync_scene_test.dart +++ b/test/live_sync_scene_test.dart @@ -224,11 +224,19 @@ void main() { tearDown(() => tmp.deleteSync(recursive: true)); bool there(String rel) => File(p.join(dst, rel)).existsSync(); - for (final opts in ['-a --filter="-,s a.txt"', '-a --filter="H a.txt"', '-a -f"H a.txt"']) { + // 관리자 지시 (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 재현 $opts (${confirmed ? '확인한 쌍' : '새 쌍'}): 묻는 목록 밖의 a.txt 가 지워지지 않음', () async { + 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(); @@ -268,7 +276,7 @@ void main() { } } - test('P (지키기) 규칙: 경고만 · 지킨 것을 끝없이 묻지 않음 · 저절로 지우지 않음', () async { + 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); @@ -276,12 +284,16 @@ void main() { final live = LiveSync(c); addTearDown(live.dispose); final k = LiveSync.keyOf(pair); - for (var i = 0; i < 3; i++) { + 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.noAutoDelete[k], ['--filter=P old.log']); + expect(live.toDelete[k], isNull, reason: '다시 묻지 않음 ($i)'); + expect(live.pending[k], isEmpty); } - expect(there('old.log') && there('extra.txt'), isTrue); }); test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async { @@ -299,7 +311,7 @@ void main() { testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async { if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); - c.settings.rsyncOptions = '-a --filter="H a.txt"'; + 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); diff --git a/test/rsync_filter_test.dart b/test/rsync_filter_test.dart index c70eb7a..8aafa2e 100644 --- a/test/rsync_filter_test.dart +++ b/test/rsync_filter_test.dart @@ -85,9 +85,11 @@ void main() { expect(ex('--exclude=**/deep/**', 'a/deep/x'), isTrue); expect(ex('--exclude=**/deep/**', 'deep', dir: true), isFalse); }); - test('15 검토 F1 · F3: 고치개 (,s · ,r · ,! · ,p) · H · S · P · R · merge 와 exclude-from 의 ! · 머리 줄은 적용하지 않고 "미리 볼 수 없음"', () { - for (final o in ['--filter="-,s a.txt"', '--filter="+,r a.txt"', '--filter="-,! a.txt"', '--filter="-,p a.txt"', '--filter="H a.txt"', - '--filter="P a.txt"', '--filter="S a.txt"', '--filter="R a.txt"', '--filter=". rules"', '--filter=": .rsync-filter"']) { + test('관리자 지시 (10/20): 고치개 ,s · ,r 과 H · S · P · R 은 따른다 (rsync_phsr_test 가 실제 rsync 와 견줌) · 15 검토 F1 · F3: 그 밖의 고치개 (,! · ,p) · merge 와 exclude-from 의 ! · 머리 줄은 적용하지 않고 "미리 볼 수 없음"', () { + for (final o in ['--filter="-,s a.txt"', '--filter="+,r a.txt"', '--filter="H a.txt"', '--filter="P a.txt"', '--filter="S a.txt"', '--filter="R a.txt"']) { + expect(RsyncFilter.parse(o).unsupported, isEmpty, reason: o); + } + for (final o in ['--filter="-,! a.txt"', '--filter="-,p a.txt"', '--filter=". rules"', '--filter=": .rsync-filter"']) { final f = RsyncFilter.parse(o); expect(f.unsupported, isNotEmpty, reason: o); expect(f.excluded('a.txt', isDir: false), isFalse, reason: '$o 는 지키지 않음 (미리 보기가 더 적게 지우게 되지 않게)'); @@ -214,14 +216,15 @@ void main() { } for (final opts in ['--filter="-,s a.txt"', '--filter="H a.txt"']) { - test('15 재현: $opts + --delete - 실제 rsync 는 받는 쪽 a.txt 를 지움 → 미리 보기는 그 규칙을 "미리 볼 수 없음" 으로 알림', () async { + test('15 재현: $opts + --delete - 실제 rsync 는 받는 쪽 a.txt 를 지움 → 미리 보기도 지움으로 (관리자 지시 10/20: 이제 이 규칙을 따름)', () async { if (!Platform.isWindows || !File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음'); File(p.join(dst, 'a.txt')).writeAsStringSync('받는 쪽'); + expect((await preview(opts))['a.txt'], 'delete', reason: '보내는 쪽에서만 빼므로 받는 쪽 a.txt 는 지킴이 없다'); final r = await Process.run(rsync, ['-r', '-8', '--delete', '-i', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(dst)}/'], stdoutEncoding: utf8, stderrEncoding: utf8); expect(r.exitCode, 0, reason: '${r.stderr}'); expect(File(p.join(dst, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움 (보내는 쪽만 빼기 · 받는 쪽은 지키지 않음)'); - expect(RsyncFilter.parse(opts).unsupported, isNotEmpty, reason: '미리 보기는 이 규칙을 따르지 못한다고 알려야 함'); + expect(RsyncFilter.parse(opts).unsupported, isEmpty); }); } diff --git a/test/rsync_phsr_test.dart b/test/rsync_phsr_test.dart new file mode 100644 index 0000000..43a1003 --- /dev/null +++ b/test/rsync_phsr_test.dart @@ -0,0 +1,186 @@ +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/rsync_filter.dart'; +import 'package:jj_mkvmaker/core/sync_preview.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 'support/rsync_scenes.dart'; + +/// 관리자 지시 (10/20): H · S · P · R 과 고치개 ,s · ,r 을 rsync 와 똑같이 따른다 - 기준은 실제 rsync 를 돌린 (-n 아님) 결과 트리. +/// 장면 · 옵션마다 ① 미리 보기 (previewSync 로 예측한 트리) ② 원격 맞추기 (WebDAV 시험 서버) ③ 실시간 동기화 (로컬 rsync 쌍: +/// 묻는 목록 = rsync 가 지운 것 · 맞춘 뒤 맞출 것 없음) ④ 실시간 동기화 (WebDAV 쌍) 가 모두 실제 rsync 와 같아야 한다. +/// 하나라도 다르면 그 규칙은 미리 볼 수 없는 것 (unsupported) 으로 남겨야 한다. + +/// 보내는 쪽 (양쪽에 같은 이름이 많게 - H · S 는 보내기, P · R 은 지우기에 걸리게) +const _src = [ + 'a.txt', 'b.log', 'keep.log', 'both.txt', 'both.log', 'shared/x.txt', 'shared/y.log', 'build/x.o', 'build/sub/y.o', + 'sub/c.txt', 'sub/deep/d.txt', 'sub/deep/e.log', 'tmp/t.txt', '한글 폴더/가.txt', 'docs/r.md', 'z.txt', +]; + +/// 받는 쪽 (같은 이름은 크기가 달라 늘 바뀜 · 받는 쪽에만 있는 것은 지우기 대상) +const _dst = [ + 'a.txt', 'keep.log', 'both.txt', 'both.log', 'shared/x.txt', 'shared/onlyd.log', 'shared/onlyd.txt', 'build/old.o', + 'old/keep.log', 'old/gone.txt', 'old2/deep/x/keep.log', 'old2/deep/x/g.txt', 'old2/e.txt', 'tmp/old.txt', 'extra.txt', + 'extra.log', 'sub/deep/z.txt', '한글 폴더/나.txt', 'onlydst/a.bak', +]; + +/// 규칙 장면 (지우기 포함이 많게 - 지우기가 데이터 쪽 위험) +const phsrOptions = [ + '-a --delete --filter="H *.log"', + '-a --delete --filter="-,s *.log"', + '-a --delete --filter="P *.log"', + '-a --delete --filter="-,r *.log"', + '-a --delete --filter="P old/"', + '-a --delete --filter="R *.log" --exclude=*.log', + '-a --delete --exclude=*.log --filter="R keep.log"', + '-a --delete --filter="R keep.log" --exclude=*.log', + '-a --delete --exclude=*.log --delete-excluded --filter="P keep.log"', + '-a --delete --filter="P keep.log" --exclude=*.log --delete-excluded', + '-a --delete --filter="S a.txt" --filter="H *.txt"', + '-a --delete --filter="H *.txt" --filter="S a.txt"', + '-a --delete --filter="H build/"', + '-a --delete --filter="P build/"', + '-a --delete --filter="-,sr *.log"', + '-a --delete --filter="-,sr *.log" --delete-excluded', + '-a --delete --filter="hide *.log"', + '-a --delete --filter="protect /old2/"', + '-a --delete --filter="risk *.txt" --filter="P *"', + '-a --delete --filter="P *" --filter="R *.txt"', + '-a --delete -f"H deep/" -f"P z.txt"', + '-a --delete --filter="H tmp/" --filter="P tmp/old.txt"', + '-a --delete --filter="+,r old/***" --exclude=old/', + '-a --delete --filter="H 한글*/"', + '-a --delete --filter="S *.log" --exclude=*.log', + '-a --delete --filter="+,s *.log" --exclude=*.log', + '-a --delete --filter="show shared/" --filter="hide shared/*"', + '-a --delete --filter="H *.log" --delete-excluded', + '-a --delete --filter="P shared/onlyd.*" --filter="H shared/"', + '-a --filter="H *.log"', + '-a --filter="P *.log" --exclude=*.txt', +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final rsync = sceneRsync; + late Directory tmp, remote; + late TestDavServer server; + const id = 'nas'; + DavServer dav() => DavServer(id: id, name: '시험', url: server.url, user: 'user', password: 'pass'); + + setUp(() async { + // 위젯 시험 바인딩은 HttpClient 를 가짜 (늘 400) 로 바꾼다 - 시험 WebDAV 서버 (127.0.0.1) 에 실제로 묻는다 + HttpOverrides.global = null; + tmp = Directory.systemTemp.createTempSync('jj_phsr_'); + remote = Directory(p.join(tmp.path, 'remote'))..createSync(); + server = TestDavServer(remote); + await server.start(); + DavRegistry.configure([dav()]); + }); + tearDown(() async { + DavRegistry.configure([]); + await server.stop(); + tmp.deleteSync(recursive: true); + }); + + test('이 규칙들은 이제 미리 볼 수 있다 (unsupported 없음) · 그 밖의 고치개 · merge 는 그대로 알림', () { + for (final o in phsrOptions) { + expect(RsyncFilter.parse(o).unsupported, isEmpty, reason: o); + } + for (final o in ['--filter="-,! a"', '--filter="-,p a"', '--filter="-,x a"', '--filter="H,r a"', '--filter=". rules"', '--filter="merge x"', '--filter="!"']) { + expect(RsyncFilter.parse(o).unsupported, isNotEmpty, reason: o); + } + // 쪽: H 는 보내기에만 (받는 쪽을 지키지 않음) · P 는 지우기에만 (보내기는 그대로) + final h = RsyncFilter.parse('--filter="H *.log"'); + expect((h.excluded('a.log', isDir: false), h.protects('a.log', isDir: false)), (true, false)); + final pr = RsyncFilter.parse('--filter="P *.log"'); + expect((pr.excluded('a.log', isDir: false), pr.protects('a.log', isDir: false)), (false, true)); + // --delete-excluded: 쪽을 적지 않은 규칙은 보내기에만 · ,sr 로 적은 것은 양쪽 + final de = RsyncFilter.parse('--exclude=*.log --filter="-,sr *.bak" --delete-excluded'); + expect((de.excluded('a.log', isDir: false), de.protects('a.log', isDir: false)), (true, false)); + expect((de.excluded('a.bak', isDir: false), de.protects('a.bak', isDir: false)), (true, true)); + }); + + for (final (i, opts) in phsrOptions.indexed) { + test('${i + 1}: $opts - 미리 보기 · 원격 맞추기 · 실시간 동기화 (로컬 · 원격) = 실제 rsync', () async { + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); + final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'); + writeScene(src, want, srcFiles: _src, dstFiles: _dst); + final before = treeOf(want); + final w = await realRsyncTree(rsync, src, want, opts); + final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; + final delete = optionsDelete(opts); + final filter = RsyncFilter.parse(opts); + final why = []; + + // ① 미리 보기: 받는 쪽 트리 + 미리 보기 줄 → 예측한 트리 + final pv = p.join(tmp.path, 'pv'); + writeScene(src, pv, srcFiles: _src, dstFiles: _dst); + final items = await previewSync(src, pv, toRight: true, delete: delete, filter: filter); + final guess = Map.of(before); + for (final x in items) { + if (x.action == PreviewAction.delete) { + guess.removeWhere((k, _) => k == x.rel || k.startsWith('${x.rel}/')); + } + } + for (final x in items) { + if (x.action == PreviewAction.add || x.action == PreviewAction.update) { + guess[x.rel] = x.isDir ? '/' : File(p.joinAll([src, ...x.rel.split('/')])).readAsStringSync(); + } + } + for (final d in treeDiff(w, guess)) { + why.add('미리 보기: $d'); + } + + // ② 원격 맞추기 (WebDAV) + final got = p.join(remote.path, 'm'); + writeScene(src, got, srcFiles: _src, dstFiles: _dst); + await FileOps().mirror(src, 'dav://$id/m', delete: delete, filter: filter); + for (final d in treeDiff(w, treeOf(got))) { + why.add('원격 맞추기: $d'); + } + + // ③ · ④ 실시간 동기화 (새 쌍 - 지울 것이 있으면 묻고, 확인하면 맞춤) + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..rsyncSource = 'custom' + ..rsyncPath = rsync + ..rsyncOptions = opts + ..webdavServers = [dav()]; + for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) { + final dir = local ? target : p.join(remote.path, 'l'); + writeScene(src, dir, srcFiles: _src, dstFiles: _dst); + final pair = LiveSyncPair(src, target, method: 'rsync', delete: delete); + c.settings.liveSyncPairs = [pair]; + final live = LiveSync(c); + final k = LiveSync.keyOf(pair); + await live.syncNow(pair); + if (live.status[k]!.$2.contains('실패')) why.add('$label 상태: ${live.status[k]!.$2}'); + if (live.noAutoDelete[k] != null) why.add('$label: 미리 볼 수 없다고 함 ${live.noAutoDelete[k]}'); + final asked = live.toDelete[k] ?? const []; + for (final g in gone) { + if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('$label: rsync 는 지우는데 묻지 않음: $g'); + } + for (final a in asked) { + if (!gone.contains(a)) why.add('$label: rsync 는 지우지 않는데 물음: $a'); + } + if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); + for (final d in treeDiff(w, treeOf(dir))) { + why.add('$label: $d'); + } + if ((live.pending[k] ?? const []).isNotEmpty) why.add('$label: 맞춘 뒤에도 맞출 것 ${live.pending[k]}'); + live.dispose(); + } + expect(why, isEmpty, reason: '$opts\n ${why.join('\n ')}'); + }, timeout: const Timeout(Duration(minutes: 2))); + } +} diff --git a/test/smb_test.dart b/test/smb_test.dart index 7d8dfdf..2366f69 100644 --- a/test/smb_test.dart +++ b/test/smb_test.dart @@ -299,7 +299,7 @@ void main() { if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}'); } expect(bad, isEmpty, reason: bad.join('\n')); - }); + }, timeout: const Timeout(Duration(minutes: 5))); // 실제 rsync 28번 - 여러 시험을 함께 돌리면 기본 30초를 넘을 수 있음 test('관리자 지시 (10/20): SMB 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async { SmbRegistry.configure(const [server]); diff --git a/test/support/rsync_scenes.dart b/test/support/rsync_scenes.dart index a29c053..ed76bb7 100644 --- a/test/support/rsync_scenes.dart +++ b/test/support/rsync_scenes.dart @@ -60,8 +60,9 @@ String? get sceneRsync { } /// 장면을 만든다 (있던 것은 지우고). 보내는 쪽 글은 "s:경로", 받는 쪽은 "d:경로" -void writeScene(String src, String dst) { - for (final (dir, list, tag) in [(src, sceneSrc, 's'), (dst, sceneDst, 'd')]) { +/// [srcFiles] · [dstFiles] 로 다른 장면도 (받는 쪽 글은 "dd:경로" 로 - 양쪽에 같은 이름이 있으면 크기가 달라 늘 바뀜으로 보이게) +void writeScene(String src, String dst, {List srcFiles = sceneSrc, List? dstFiles}) { + for (final (dir, list, tag) in [(src, srcFiles, 's'), (dst, dstFiles ?? sceneDst, dstFiles == null ? 'd' : 'dd')]) { final d = Directory(dir); if (d.existsSync()) d.deleteSync(recursive: true); d.createSync(recursive: true);