next8: next7 + next6 (44a6c4e: 실시간 동기화 거르기 · --delete-excluded) - 시험판 980

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next8
RyuJongJin 2026-10-20 12:24:14 +09:00
commit a19d68e167
13 changed files with 503 additions and 15 deletions

View File

@ -466,6 +466,7 @@
"rsync 양쪽 (⇄)": "rsync both ways (⇄)", "rsync 양쪽 (⇄)": "rsync both ways (⇄)",
"rsync 에 고르기": "Pick for rsync", "rsync 에 고르기": "Pick for rsync",
"rsync 옵션": "rsync options", "rsync 옵션": "rsync options",
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "These are left out by the rsync options (--exclude etc.), but --delete-excluded deletes them from the target. To keep them, remove --delete-excluded from the options.",
"rsync 종료 코드 {0}": "rsync exit code {0}", "rsync 종료 코드 {0}": "rsync exit code {0}",
"settings.json 없음": "settings.json missing", "settings.json 없음": "settings.json missing",
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)", "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)",
@ -696,6 +697,7 @@
"개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect", "개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect",
"거꾸로": "Reverse", "거꾸로": "Reverse",
"거꾸로 된 범위: {0}": "Reversed range: {0}", "거꾸로 된 범위: {0}": "Reversed range: {0}",
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "{0} excluded items will also be deleted (--delete-excluded)",
"건너뛰기": "Skip", "건너뛰기": "Skip",
"건너뛴 링크 {0}개": "{0} links skipped", "건너뛴 링크 {0}개": "{0} links skipped",
"건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}", "건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}",

View File

@ -466,6 +466,7 @@
"rsync 양쪽 (⇄)": "rsync 双方向 (⇄)", "rsync 양쪽 (⇄)": "rsync 双方向 (⇄)",
"rsync 에 고르기": "rsync に選ぶ", "rsync 에 고르기": "rsync に選ぶ",
"rsync 옵션": "rsync オプション", "rsync 옵션": "rsync オプション",
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "rsync オプションで除外したもの (--exclude など) ですが、--delete-excluded があるため対象から削除します。残すにはオプションから --delete-excluded を外してください。",
"rsync 종료 코드 {0}": "rsync 終了コード {0}", "rsync 종료 코드 {0}": "rsync 終了コード {0}",
"settings.json 없음": "settings.json がありません", "settings.json 없음": "settings.json がありません",
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows・CPU・Vulkan)", "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows・CPU・Vulkan)",
@ -696,6 +697,7 @@
"개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません", "개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません",
"거꾸로": "逆順", "거꾸로": "逆順",
"거꾸로 된 범위: {0}": "逆順の範囲: {0}", "거꾸로 된 범위: {0}": "逆順の範囲: {0}",
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "除外したもの {0} 件も削除されます (--delete-excluded)",
"건너뛰기": "スキップ", "건너뛰기": "スキップ",
"건너뛴 링크 {0}개": "スキップしたリンク {0} 件", "건너뛴 링크 {0}개": "スキップしたリンク {0} 件",
"건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}", "건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}",

View File

@ -466,6 +466,7 @@
"rsync 양쪽 (⇄)": "rsync 双向 (⇄)", "rsync 양쪽 (⇄)": "rsync 双向 (⇄)",
"rsync 에 고르기": "选为 rsync", "rsync 에 고르기": "选为 rsync",
"rsync 옵션": "rsync 选项", "rsync 옵션": "rsync 选项",
"rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "这些是 rsync 选项中排除的项目 (--exclude 等),但因为有 --delete-excluded,会从目标中删除。若要保留,请从选项中去掉 --delete-excluded。",
"rsync 종료 코드 {0}": "rsync 退出代码 {0}", "rsync 종료 코드 {0}": "rsync 退出代码 {0}",
"settings.json 없음": "缺少 settings.json", "settings.json 없음": "缺少 settings.json",
"stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)", "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)",
@ -696,6 +697,7 @@
"개인 키의 암호가 맞지 않습니다": "私钥密码不正确", "개인 키의 암호가 맞지 않습니다": "私钥密码不正确",
"거꾸로": "倒序", "거꾸로": "倒序",
"거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}",
"거른 것 {0}개도 지워집니다 (--delete-excluded)": "已排除的 {0} 项也会被删除 (--delete-excluded)",
"건너뛰기": "跳过", "건너뛰기": "跳过",
"건너뛴 링크 {0}개": "跳过的链接 {0} 个", "건너뛴 링크 {0}개": "跳过的链接 {0} 个",
"건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}", "건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}",

View File

@ -0,0 +1,87 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
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/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:jj_mkvmaker/ui/monitor_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 관리자 3a 조건 (10/20): 실시간 동기화의 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것을 따로 묶은 모양을
/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다. 시험 폴더 안에서만 (확인 창은 [나중에] - 지우지 않음).
/// 실행: flutter test integration_test\windows_live_excl_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_live_excl');
final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('지울지 묻는 창 $lang ${w}x$h', (t) async {
if (!File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음');
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_liveexcl_shot_');
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
for (final (dir, rel) in [
(src, 'a.txt'),
(dst, 'only.txt'),
(dst, '오래된 메모.txt'),
(dst, 'skip.tmp'),
(dst, 'cache/x.tmp'),
(dst, 'build/obj/very_long_intermediate_file_name.tmp'),
]) {
File(p.join(dir, rel))
..createSync(recursive: true)
..writeAsStringSync(rel);
}
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await t.runAsync(() => live.syncNow(pair));
expect(live.toDeleteExcluded[LiveSync.keyOf(pair)], hasLength(3));
final navKey = GlobalKey<NavigatorState>();
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
));
unawaited(confirmLiveDeletes(navKey.currentContext!, live, pair));
for (var i = 0; i < 8; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump(const Duration(milliseconds: 60));
}
expect(t.takeException(), isNull, reason: '넘침');
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'live_excl_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
navKey.currentState!.pop();
await t.pump();
expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '[나중에] - 지우지 않음');
});
}
}
}

View File

@ -154,8 +154,66 @@ class LiveSync extends ChangeNotifier {
/// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이 /// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none; RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none;
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async { /// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter); /// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
/// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다.
/// [excluded] 가 있으면 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 거기에도 (확인 창에서 따로 묶음)
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;
}
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)) {
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, excluded);
}
}
/// 폴더 [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;
}
/// [excluded]: 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 모은다 ([_gone])
static Future<List<String>> diff(LiveSyncPair x,
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter, excluded: excluded);
final out = <String>[]; final out = <String>[];
Future<void> walk(String src, String dst, String rel) async { Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return; if (out.length >= limit) return;
@ -170,8 +228,11 @@ class LiveSync extends ChangeNotifier {
if (out.length >= limit) return; if (out.length >= limit) return;
final name = p.basename(e.path); final name = p.basename(e.path);
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다 if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다
names.add(name);
final r = rel.isEmpty ? name : '$rel/$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); final t = p.join(dst, name);
if (e is Directory) { if (e is Directory) {
await walk(e.path, t, r); await walk(e.path, t, r);
@ -189,7 +250,8 @@ class LiveSync extends ChangeNotifier {
if (x.delete && await Directory(dst).exists()) { if (x.delete && await Directory(dst).exists()) {
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
final name = p.basename(e.path); 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, excluded);
} }
} }
} }
@ -199,7 +261,8 @@ class LiveSync extends ChangeNotifier {
} }
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면 /// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async { static Future<List<String>> _diffV(LiveSyncPair x,
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
final out = <String>[]; final out = <String>[];
Future<void> walk(String src, String dst, String rel) async { Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return; if (out.length >= limit) return;
@ -238,10 +301,7 @@ class LiveSync extends ChangeNotifier {
for (final t in there.entries) { for (final t in there.entries) {
final name = t.key; final name = t.key;
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
final r = rel.isEmpty ? name : '$rel/$name'; await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit, excluded);
// 거른 것은 지우지 않는다 (--delete-excluded 면 지움). 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만 (아래는 폴더 하나로 셈)
if (filter.excluded(r, isDir: t.value.isDir) && !filter.deleteExcluded) continue;
out.add('− $r');
} }
} }
} }
@ -289,6 +349,10 @@ class LiveSync extends ChangeNotifier {
/// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42) /// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42)
final toDelete = <String, List<String>>{}; final toDelete = <String, List<String>>{};
/// [toDelete] 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더). 확인 창에서 따로 묶어 보인다
/// (관리자 3a 조건 10/20: 사용자가 알고 누르게)
final toDeleteExcluded = <String, List<String>>{};
/// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도 /// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도
final problems = <String, SourceUnreadableException>{}; final problems = <String, SourceUnreadableException>{};
@ -313,6 +377,7 @@ class LiveSync extends ChangeNotifier {
Future<void> decideDelete(LiveSyncPair x, {required bool delete}) async { Future<void> decideDelete(LiveSyncPair x, {required bool delete}) async {
final now = delete ? x.copyWith(deleteConfirmed: true) : x.copyWith(delete: false); final now = delete ? x.copyWith(deleteConfirmed: true) : x.copyWith(delete: false);
toDelete.remove(keyOf(x)); toDelete.remove(keyOf(x));
toDeleteExcluded.remove(keyOf(x));
await _setPair(x, now); await _setPair(x, now);
await syncNow(now); await syncNow(now);
} }
@ -414,15 +479,20 @@ class LiveSync extends ChangeNotifier {
try { try {
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete]) // 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
if (x.delete && !x.deleteConfirmed) { if (x.delete && !x.deleteConfirmed) {
final del = [for (final d in await diff(x, limit: 100000, filter: _filterOf(x))) if (d.startsWith('− ')) d.substring(2)]; final excl = <String>[];
final del = [
for (final d in await diff(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2)
];
if (del.isEmpty) { if (del.isEmpty) {
await _setPair(x, x.copyWith(deleteConfirmed: true)); await _setPair(x, x.copyWith(deleteConfirmed: true));
} else { } else {
toDelete[k] = del; toDelete[k] = del;
toDeleteExcluded[k] = excl;
x = x.copyWith(delete: false); x = x.copyWith(delete: false);
} }
} else { } else {
toDelete.remove(k); toDelete.remove(k);
toDeleteExcluded.remove(k);
} }
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다 // 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
final allowEmpty = _allowEmpty.remove(k); final allowEmpty = _allowEmpty.remove(k);
@ -494,6 +564,9 @@ class LiveSync extends ChangeNotifier {
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
if (x.delete) '--delete', 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 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
'${toCygwinPath(x.source, windows: windows)}/', '${toCygwinPath(x.source, windows: windows)}/',
'${toCygwinPath(x.target, windows: windows)}/', '${toCygwinPath(x.target, windows: windows)}/',

View File

@ -80,6 +80,10 @@ class MonitorPage extends StatelessWidget {
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async { Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[]; final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
if (list.isEmpty) return; if (list.isEmpty) return;
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
final excl = (live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const <String>[]).toSet();
final plain = [for (final n in list) if (!excl.contains(n)) n];
final filtered = [for (final n in list) if (excl.contains(n)) n];
final pick = await showDialog<bool>( final pick = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -93,8 +97,18 @@ Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPai
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: ListView(children: [ child: ListView(children: [
for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])), if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
if (filtered.isNotEmpty) ...[
if (plain.isNotEmpty) const SizedBox(height: 10),
Text(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length]),
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
Text(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4),
for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
],
]), ]),
), ),
]), ]),

View File

@ -14,6 +14,11 @@ import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'support/rsync_scenes.dart'; import 'support/rsync_scenes.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
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';
/// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) - /// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다 /// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다
@ -236,7 +241,7 @@ void main() {
}, skip: hasPy ? false : '시험용 파이썬 없음'); }, skip: hasPy ? false : '시험용 파이썬 없음');
test('관리자 지시 (10/20): FTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { test('관리자 지시 (10/20): FTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
final rsync = bundledRsync; final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
await start('plain'); await start('plain');
FtpRegistry.configure([srv(security: 'none')]); FtpRegistry.configure([srv(security: 'none')]);
@ -263,6 +268,45 @@ void main() {
expect(await range(7, null), '789'); expect(await range(7, null), '789');
}, skip: hasPy ? false : '시험용 파이썬 없음'); }, skip: hasPy ? false : '시험용 파이썬 없음');
test('관리자 지시 (10/20): FTP 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async {
await start('plain');
FtpRegistry.configure([srv(security: 'none')]);
Directory(p.join(root.path, '영상')).createSync();
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789');
File(p.join(root.path, '영상', '2화.mkv')).writeAsStringSync('abcdefghij');
File(p.join(root.path, '영상', '2화.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n둘째\n');
File(p.join(root.path, '영상', '메모.txt')).writeAsStringSync('t');
addTearDown(RemoteStreamBridge.close);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
final plan = await c.preparePlayback(['ftp://f1/영상/1화.mkv']);
expect(plan!.$1, ['ftp://f1/영상/1화.mkv', 'ftp://f1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)');
final bridge = await RemoteStreamBridge.get();
final u1 = bridge.url('ftp://f1/영상/1화.mkv'), u2 = bridge.url('ftp://f1/영상/2화.mkv');
expect(u1, startsWith('http://127.0.0.1:'));
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient();
try {
final req = await http.getUrl(Uri.parse(url));
if (range != null) req.headers.set('Range', range);
final res = await req.close();
return (res.statusCode, await res.transform(utf8.decoder).join());
} finally {
http.close(force: true);
}
}
expect(await get(u1), (200, '0123456789'));
expect(await get(u1, 'bytes=4-6'), (206, '456'), reason: '건너뛰기 - 그 자리부터');
expect(await get(u1, 'bytes=7-'), (206, '789'));
expect(await get(u2), (200, 'abcdefghij'), reason: '다음 화도 받지 않고 바로');
final subs = await c.externalSubtitlesFor('ftp://f1/영상/2화.mkv');
expect(subs.map(p.basename), ['2화.ko.srt']);
expect(File(subs.single).readAsStringSync(), contains('둘째'));
}, skip: hasPy ? false : '시험용 파이썬 없음');
test('FTPS (암시, 990 꼴)', () async { test('FTPS (암시, 990 꼴)', () async {
await start('implicit'); await start('implicit');
final c = FtpRemote(srv(security: 'implicit', cert: fp)); final c = FtpRemote(srv(security: 'implicit', cert: fp));

View File

@ -12,6 +12,15 @@ void main() {
expect(on.where((o) => o.startsWith('--del')), ['--delete']); 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 는 쌍이 지우기 포함일 때만', () { test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () {
final s = AppSettings()..robocopyOptions = '/MIR /PURGE /R:2'; 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(); final off = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), s).map((o) => o.toUpperCase()).toList();

View File

@ -0,0 +1,117 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
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/rsync_filter.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:jj_mkvmaker/ui/monitor_page.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');
}
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 확인 창에서 따로
final excl = live.toDeleteExcluded[k] ?? const <String>[];
final wantExcl = {
'-a --delete --exclude=*.bak --delete-excluded': {'onlydst'},
'-a --delete --exclude=*.log --delete-excluded': {'keep.log', 'old', 'old2'},
}[opts] ?? const <String>{};
if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl');
if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl');
if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl');
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)));
testWidgets('관리자 3a 조건 (10/20): 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것은 따로 묶어 빨갛게', (tester) async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final tmp = Directory.systemTemp.createTempSync('jj_liveexcl_');
addTearDown(() => tmp.deleteSync(recursive: true));
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
for (final (dir, rel) in [(src, 'a.txt'), (dst, 'only.txt'), (dst, 'skip.tmp'), (dst, 'cache/x.tmp')]) {
File(p.join(dir, rel))
..createSync(recursive: true)
..writeAsStringSync(rel);
}
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await tester.runAsync(() => live.syncNow(pair));
final k = LiveSync.keyOf(pair);
expect(live.toDelete[k], unorderedEquals(['only.txt', 'skip.tmp', 'cache']));
expect(live.toDeleteExcluded[k], unorderedEquals(['skip.tmp', 'cache']), reason: 'cache 는 거른 것이 든 폴더');
expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음');
late BuildContext ctx;
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {
ctx = x;
return const SizedBox();
})));
unawaited(confirmLiveDeletes(ctx, live, pair));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget);
expect(find.text('거른 것 2개도 지워집니다 (--delete-excluded)'), findsOneWidget);
// 순서: 그냥 지울 것 → 묶음 제목 → 거른 것
final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(n)).dy};
expect(y['only.txt']! < y['거른 것 2개도 지워집니다 (--delete-excluded)']!, isTrue);
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue);
expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue);
await tester.tap(find.text('나중에'));
await tester.pump();
});
}

View File

@ -211,7 +211,7 @@ void main() {
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('관리자 지시 (10/20): SFTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { test('관리자 지시 (10/20): SFTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
final rsync = bundledRsync; final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
await start(); await start();
SftpRegistry.configure([srv(fp: fingerprint)]); SftpRegistry.configure([srv(fp: fingerprint)]);

View File

@ -12,6 +12,13 @@ import 'package:path/path.dart' as p;
import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'support/rsync_scenes.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
import 'package:jj_mkvmaker/core/remote_stream.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';
/// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫) /// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫)
class _FakeSmb { class _FakeSmb {
@ -263,4 +270,78 @@ void main() {
expect(fake.files.containsKey(r'dst\old.txt'), isFalse); expect(fake.files.containsKey(r'dst\old.txt'), isFalse);
expect((fake.files.containsKey(r'dst\gone\g.tmp'), fake.files.containsKey(r'dst\gone\g.txt')), (true, false)); expect((fake.files.containsKey(r'dst\gone\g.tmp'), fake.files.containsKey(r'dst\gone\g.txt')), (true, false));
}); });
test('관리자 지시 (10/20): SMB 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개, 가짜 채널)', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
SmbRegistry.configure(const [server]);
final tmp = Directory.systemTemp.createTempSync('jj_smbscene_');
addTearDown(() => tmp.deleteSync(recursive: true));
final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'), seed = p.join(tmp.path, 'seed');
// 가짜 공유의 scene 폴더 → 트리 (treeOf 와 같은 모양)
Map<String, String> fakeTree() => {
for (final e in fake.files.entries)
if (e.key.startsWith(r'scene\'))
e.key.substring(6).replaceAll(r'\', '/'): e.value == null ? '/' : utf8.decode(e.value!),
};
final bad = <String>[];
for (final opts in sceneOptions) {
writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts);
writeScene(src, seed);
fake.files
..clear()
..['scene'] = null;
for (final e in treeOf(seed).entries) {
fake.files['scene\\${e.key.replaceAll('/', r'\')}'] = e.value == '/' ? null : utf8.encode(e.value);
}
await FileOps().mirror(src, 'smb://m1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
final d = treeDiff(w, fakeTree());
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
}
expect(bad, isEmpty, reason: bad.join('\n'));
});
test('관리자 지시 (10/20): SMB 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async {
SmbRegistry.configure(const [server]);
// 위젯 시험 바인딩은 HttpClient 를 가짜 (늘 400) 로 바꾼다 - 이 시험은 이 기기 안의 다리 (127.0.0.1) 에 실제로 묻는다
final saved = HttpOverrides.current;
HttpOverrides.global = null;
addTearDown(() => HttpOverrides.global = saved);
fake.files.addAll({
'영상': null,
'영상\\1화.mkv': utf8.encode('0123456789'),
'영상\\2화.mkv': utf8.encode('abcdefghij'),
'영상\\2화.ko.srt': utf8.encode('1\n00:00:01,000 --> 00:00:02,000\n둘째\n'),
'영상\\메모.txt': utf8.encode('t'),
});
addTearDown(RemoteStreamBridge.close);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
final plan = await c.preparePlayback(['smb://m1/영상/1화.mkv']);
expect(plan!.$1, ['smb://m1/영상/1화.mkv', 'smb://m1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)');
final bridge = await RemoteStreamBridge.get();
final u1 = bridge.url('smb://m1/영상/1화.mkv'), u2 = bridge.url('smb://m1/영상/2화.mkv');
expect(u1, startsWith('http://127.0.0.1:'));
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient();
try {
final req = await http.getUrl(Uri.parse(url));
if (range != null) req.headers.set('Range', range);
final res = await req.close();
return (res.statusCode, await res.transform(utf8.decoder).join());
} finally {
http.close(force: true);
}
}
expect(await get(u1), (200, '0123456789'));
expect(await get(u1, 'bytes=4-6'), (206, '456'), reason: '건너뛰기 - 그 자리부터');
expect(await get(u1, 'bytes=7-'), (206, '789'));
expect(await get(u2), (200, 'abcdefghij'), reason: '다음 화도 받지 않고 바로');
final subs = await c.externalSubtitlesFor('smb://m1/영상/2화.mkv');
expect(subs.map(p.basename), ['2화.ko.srt']);
expect(File(subs.single).readAsStringSync(), contains('둘째'));
});
} }

View File

@ -54,7 +54,7 @@ const sceneOptions = [
]; ];
/// 앱에 들어 있는 rsync (Windows). 없으면 null /// 앱에 들어 있는 rsync (Windows). 없으면 null
String? get bundledRsync { String? get sceneRsync {
final f = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe'); final f = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe');
return Platform.isWindows && File(f).existsSync() ? f : null; return Platform.isWindows && File(f).existsSync() ? f : null;
} }

View File

@ -16,6 +16,7 @@ import 'package:path/path.dart' as p;
import 'support/dav_server.dart'; import 'support/dav_server.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'support/rsync_scenes.dart';
void main() { void main() {
late Directory tmp, remote, local; late Directory tmp, remote, local;
@ -302,4 +303,60 @@ void main() {
expect(Directory(real('cache')).existsSync(), isFalse); expect(Directory(real('cache')).existsSync(), isFalse);
expect(Directory(real('gone')).existsSync(), isFalse); expect(Directory(real('gone')).existsSync(), isFalse);
}); });
test('관리자 지시 (10/20): WebDAV 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
final src = p.join(local.path, 'scene_src'), want = p.join(local.path, 'scene_rsync'), got = p.join(remote.path, 'scene');
final bad = <String>[];
for (final opts in sceneOptions) {
writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts);
writeScene(src, got);
await FileOps().mirror(src, 'dav://$id/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
final d = treeDiff(w, treeOf(got));
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
}
expect(bad, isEmpty, reason: bad.join('\n'));
}, timeout: const Timeout(Duration(minutes: 5)));
test('관리자 지시 (10/20): 실시간 동기화 (WebDAV rsync 쌍) 결과 = 실제 rsync 결과 · 지울지 묻는 목록 = rsync 가 지우는 것 (28 의 장면 · 옵션 28개)', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)');
final src = p.join(local.path, 'live_src'), want = p.join(local.path, 'live_rsync'), got = p.join(remote.path, 'live');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 지우기를 정하면 설정을 저장하며 서버 목록을 설정에서 다시 읽는다 - 시험 서버를 설정에도
c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')];
final bad = <String>[];
for (final opts in sceneOptions) {
writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts);
writeScene(src, got);
final before = treeOf(got);
// rsync 가 지운 것 (받는 쪽에 있다가 없어진 것)
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
c.settings.rsyncOptions = opts;
// 새 쌍 (지우기 확인 전) - 지울 것이 있으면 지우지 않고 맞춘 뒤 [toDelete] 로 묻는다
final pair = LiveSyncPair(src, 'dav://$id/live', method: 'rsync', delete: optionsDelete(opts));
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
final k = LiveSync.keyOf(pair);
final why = <String>[];
await live.syncNow(pair);
if (live.status[k]!.$2.contains('실패')) why.add('상태: ${live.status[k]!.$2}');
final asked = live.toDelete[k] ?? const <String>[];
// 묻는 목록 = rsync 가 지우는 것 (폴더는 한 줄로 - 그 안의 것은 그 폴더에 든 것으로)
for (final g in gone) {
if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('rsync 는 지우는데 묻지 않음: $g');
}
for (final a in asked) {
if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a');
}
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
why.addAll(treeDiff(w, treeOf(got)));
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
live.dispose();
if (why.isNotEmpty) bad.add('$opts\n ${why.join('\n ')}');
}
expect(bad, isEmpty, reason: bad.join('\n'));
}, timeout: const Timeout(Duration(minutes: 5)));
} }