next9: + filter_phsr (c593440: H · S · P · R · ,s · ,r 을 rsync 와 같게 따름) - 시험판 982
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>review_d2dc267
commit
0ef2c2c33b
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('/***')) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue