실시간 동기화 (rsync 쌍): 맞출 것 · 지울지 묻는 목록이 옵션의 거르기를 따름 · --delete-excluded 는 지우기 포함 쌍에서 따름 (검토 대기)
관리자 지시 (10/20) 2번 중 28 의 장면 (옵션 28개) 을 실시간 동기화로 돌려 찾음 (버그 수정): - 로컬 쌍의 diff 는 거르기를 전혀 따르지 않아, rsync 가 보내지 않는 것을 "맞출 것" 으로 늘 세고, 받는 쪽의 거른 것 (예: --exclude=*.log 의 keep.log) 까지 지울지 물었다 (rsync 는 지우지 않음). - 원격 쌍 (_diffV) · 로컬 쌍 모두: 받는 쪽에만 있는 폴더에 거른 것이 들어 있으면 "− 폴더" 한 줄로 셌다 - 묻는 창에 폴더가 통째로 지워지는 것처럼 보이고, 맞춘 뒤 (폴더는 남으므로) 맞출 것에 계속 남았다. 이제 rsync · _mirrorV 처럼 그 안의 거르지 않은 것만 (_gone · _hasExcluded 를 둘이 같이 씀). - 로컬 쌍의 rsync 인수가 --delete-excluded 를 늘 뺐다 (97 의 지우기 빼기에 걸림) - 묻는 창에는 지운다고 보이고 실제로는 남았다. 내 판단: 쌍이 "지우기 포함" 일 때만 남김 (원격 쌍 · Rsync 화면과 같게, 지우기 전 확인 (42) 은 그대로). 지우기 없는 쌍은 그대로 뺌. 시험: live_sync_scene_test (로컬, 실제 rsync) · webdav_test (WebDAV 쌍) - 결과 트리 = 실제 rsync, 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 맞출 것 없음. 고치기 전 로컬 실패 · WebDAV 실패 (− old · − old2 · − onlydst) 확인. live_sync_args_test 에 --delete-excluded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>next6
parent
10954a632b
commit
954703c4f0
|
|
@ -154,6 +154,46 @@ class LiveSync extends ChangeNotifier {
|
|||
/// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
|
||||
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none;
|
||||
|
||||
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
|
||||
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
|
||||
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
|
||||
/// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다
|
||||
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit) async {
|
||||
if (out.length >= limit) return;
|
||||
if (filter.excluded(r, isDir: isDir) && !filter.deleteExcluded) return;
|
||||
if (!isDir || filter.isEmpty || filter.deleteExcluded || !await _hasExcluded(path, r, filter)) {
|
||||
out.add('− $r');
|
||||
return;
|
||||
}
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(path);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final e in items) {
|
||||
final name = vBasename(e.path);
|
||||
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
await _gone(e.path, '$r/$name', e.isDir, filter, out, limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// 폴더 [dir] (상대 경로 [rel]) 안에 거른 것이 하나라도 있는지
|
||||
static Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter) async {
|
||||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||||
try {
|
||||
items = await vList(dir);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async {
|
||||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter);
|
||||
final out = <String>[];
|
||||
|
|
@ -170,8 +210,11 @@ class LiveSync extends ChangeNotifier {
|
|||
if (out.length >= limit) return;
|
||||
final name = p.basename(e.path);
|
||||
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 10/20 (15, 28 의 장면으로 찾음): rsync 쌍은 옵션의 거르기를 따른다 - 예전에는 rsync 가 보내지 않는 것을
|
||||
// 맞출 것으로 늘 세고, 지우지 않는 것 (받는 쪽의 거른 것) 을 지울지 물었다
|
||||
if (filter.excluded(r, isDir: e is Directory)) continue;
|
||||
names.add(name);
|
||||
final t = p.join(dst, name);
|
||||
if (e is Directory) {
|
||||
await walk(e.path, t, r);
|
||||
|
|
@ -189,7 +232,8 @@ class LiveSync extends ChangeNotifier {
|
|||
if (x.delete && await Directory(dst).exists()) {
|
||||
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
|
||||
final name = p.basename(e.path);
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
|
||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -238,10 +282,7 @@ class LiveSync extends ChangeNotifier {
|
|||
for (final t in there.entries) {
|
||||
final name = t.key;
|
||||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
// 거른 것은 지우지 않는다 (--delete-excluded 면 지움). 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만 (아래는 폴더 하나로 셈)
|
||||
if (filter.excluded(r, isDir: t.value.isDir) && !filter.deleteExcluded) continue;
|
||||
out.add('− $r');
|
||||
await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -494,6 +535,9 @@ class LiveSync extends ChangeNotifier {
|
|||
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
|
||||
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
|
||||
if (x.delete) '--delete',
|
||||
// 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창
|
||||
// (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름)
|
||||
if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
|
||||
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
|
||||
'${toCygwinPath(x.source, windows: windows)}/',
|
||||
'${toCygwinPath(x.target, windows: windows)}/',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@ void main() {
|
|||
expect(on.where((o) => o.startsWith('--del')), ['--delete']);
|
||||
});
|
||||
|
||||
test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () {
|
||||
final s = AppSettings()..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
|
||||
final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false);
|
||||
expect(off.where((o) => o.startsWith('--del')), isEmpty);
|
||||
final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false);
|
||||
expect(on.where((o) => o.startsWith('--del')), ['--delete', '--delete-excluded']);
|
||||
expect(on, contains('--exclude=*.tmp'));
|
||||
});
|
||||
|
||||
test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () {
|
||||
final s = AppSettings()..robocopyOptions = '/MIR /PURGE /R:2';
|
||||
final off = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), s).map((o) => o.toUpperCase()).toList();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
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/sync_preview.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/rsync_scenes.dart';
|
||||
|
||||
/// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync,
|
||||
/// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음)
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
test('실시간 동기화 (로컬 rsync 쌍) 결과 = 실제 rsync 결과 · 지울지 묻는 목록 = rsync 가 지우는 것 (28 의 장면 · 옵션 28개)', () async {
|
||||
final rsync = sceneRsync;
|
||||
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_livescene_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'), got = p.join(tmp.path, 'live');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
// 앱에 든 rsync 를 그대로 (시험 환경에는 앱 폴더 (path_provider) 가 없어 '직접 지정' 으로)
|
||||
c.settings
|
||||
..rsyncSource = 'custom'
|
||||
..rsyncPath = rsync;
|
||||
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);
|
||||
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
|
||||
c.settings.rsyncOptions = opts;
|
||||
final pair = LiveSyncPair(src, got, 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>[];
|
||||
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)));
|
||||
}
|
||||
|
|
@ -318,4 +318,45 @@ void main() {
|
|||
}
|
||||
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)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue