next9: + filter_phsr (c593440: H · S · P · R · ,s · ,r 을 rsync 와 같게 따름) - 시험판 982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
review_d2dc267
RyuJongJin 2026-10-20 14:16:35 +09:00
commit 0ef2c2c33b
11 changed files with 305 additions and 66 deletions

View File

@ -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);

View File

@ -214,23 +214,20 @@ class LiveSync extends ChangeNotifier {
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit,
[List<String>? 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<void> 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<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
/// 폴더 [dir] (상대 경로 [rel]) 안에 받는 쪽에서 지키는 것 ([sender] 면 보내는 쪽에서 거른 것) 이 하나라도 있는지
static Future<bool> _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;
}

View File

@ -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;
}

View File

@ -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<RsyncFilterRule> 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<String>? 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('/***')) {

View File

@ -135,7 +135,7 @@ Future<List<PreviewItem>> 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<List<PreviewItem>> 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<List<PreviewItem>> previewSync(String src, String dst,
return out;
}
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 걸러지는 것이 있는지 (거른 폴더 안으로는 들어가지 않는다)
/// 177: 받는 쪽 폴더 [dir] (상대 경로 [rel]) 안 어딘가에 받는 쪽에서 지키는 것이 있는지 (지키는 폴더 안으로는 들어가지 않는다)
Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
List<({String path, bool isDir, int size, DateTime modified})> items;
try {
@ -245,7 +241,7 @@ Future<bool> _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;

View File

@ -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<FilledButton>(find.widgetWithText(FilledButton, '실행'));
expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음');
await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));

View File

@ -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);

View File

@ -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);
});
}

186
test/rsync_phsr_test.dart Normal file
View File

@ -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 = <String>[];
// ① 미리 보기: 받는 쪽 트리 + 미리 보기 줄 → 예측한 트리
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 <String>[];
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)));
}
}

View File

@ -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]);

View File

@ -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<String> srcFiles = sceneSrc, List<String>? 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);