From 9e40b485a91614a767c4d0de6eda7cafafa46061 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:29:14 +0900 Subject: [PATCH] =?UTF-8?q?rc=20=ED=9A=8C=EA=B7=80=20=EA=B3=A0=EC=B9=A8:?= =?UTF-8?q?=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?(UNC)=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=80=20\\=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EB=B0=9B=EB=8A=94=20=EC=AA=BD=20UNC=20=EB=8F=84=20?= =?UTF-8?q?\\=20=C2=B7=20=EB=A7=89=ED=9E=88=EB=A9=B4=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20[=E2=96=B6=20=EC=8B=A4=ED=96=89]=20?= =?UTF-8?q?=EB=8F=84=20=EB=81=94=20=C2=B7=20=ED=91=9C=EC=A4=80=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20(-)=20=EB=AC=B8=EA=B5=AC=20(=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/20, 28 이 f43f067 에서 확인 - 지운 것은 없음): 1. 회귀: ed459a0 이 목록 · 기록 · 배치 파일의 \\localhost\C$\… 를 //localhost/C$/… 로 바꿔 넘겨 rsync 가 "failed to open exclude file" (exit 11) 로 멈췄다 (985 는 \\ 그대로라 됨). 15 가 앱이 실제로 띄우는 방식 (응답 파일 · MSYS=noglob, rsync 3.5.1) 으로 자리마다 확인 - 28 의 측정과 같음: 원본 = // 만 됨 (\\ 는 exit 23), 받는 쪽 = \\ 만 됨 (// 는 change_dir 실패 exit 3), 목록 · 기록 파일 = \\ 만 됨 (// 는 exit 11 · 기록이 안 생김). - rsyncFileOptionArgs: UNC 는 \\ 로 (//서버 로 적은 것도 \\ 로 바꿈), 드라이브는 /cygdrive/… 그대로. - 받는 쪽: 새 rsyncDestPath - UNC 면 \\서버\… (rsyncArgs 의 대상 · 실시간 동기화 rsyncArgsFor 의 받는 쪽). 원본은 toCygwinPath (//) 그대로. ed459a0 전부터 Windows 네트워크 공유가 받는 쪽인 rsync 는 실패했다 (관리자 판단: 데이터 문제는 아니지만 사용자가 겪는 실패라 rc 에). 2. 모니터링 복사 · rsync 카드: 옵션이 막혀 빨간 안내가 보이면 [▶ 실행] 도 끈다 (확인 창과 같게 - 누르면 실패로 끝났다). 3. --files-from=- · --exclude-from=- (표준 입력) 은 따로 된 문구 "표준 입력 (-) 은 쓸 수 없습니다" · 끝 문구 "목록 파일의 전체 경로를 적어 주세요" (예전에는 "적어 주세요: - - C:\ …" 로 하이픈이 겹침). 시험: rsync_msys_args_test (실제 rsync - 관리 공유 \\localhost\C$ 로 원본 · 받는 쪽 UNC 와 목록 파일 \\ · // 를 복사 작업으로, 받는 쪽 UNC 실시간 동기화 쌍 · 바꿔 넘기기 단위 · 표준 입력 문구) · monitor_test (막히면 [실행] 꺼짐 · 고치면 켜짐). 전체 시험 1142 통과 · 1 실패 (explorer_test "여러 개 표시 → 삭제 · 새 폴더 · 이름 변경" - 이번 고침과 상관없음, 혼자 두 번 통과 · ed459a0 의 전체 시험에서도 통과 - 부하 흔들림) · analyze 문제 없음 · l10n 빠진 번역 0. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 ++ assets/l10n/ja.json | 2 ++ assets/l10n/zh-Hans.json | 2 ++ lib/app/live_sync.dart | 2 +- lib/core/sync_tools.dart | 27 ++++++++++++++--- lib/ui/monitor_page.dart | 3 +- test/monitor_test.dart | 20 +++++++++++++ test/rsync_msys_args_test.dart | 53 ++++++++++++++++++++++++++++++++-- 8 files changed, 103 insertions(+), 8 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 9a70406..b84387f 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1245,6 +1245,7 @@ "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", "목록 보기": "View list", "목록 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the list file: {0}", + "목록 파일의 전체 경로를 적어 주세요": "write the full path of the list file", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "If JJ_MKVMaker is not in the list, use [Allow another app] to pick JJ_MKVMaker.exe and check \"Private\". Turn sharing off and on again afterwards.", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here", "목록에서 빼기": "Remove from list", @@ -2731,6 +2732,7 @@ "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "You can edit display names right here. Pressing a video row three times moves it to the first button's folder", "표시 지우기": "Clear marks", "표준": "Standard", + "표준 입력 (-) 은 쓸 수 없습니다": "Standard input (-) cannot be used", "푸는 중": "Extracting", "푸는 중: {0}": "Extracting: {0}", "풀 수 없는 이름 {0}개": "{0} invalid names", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 4c6a2ae..68d1cff 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1245,6 +1245,7 @@ "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", "목록 보기": "リストを見る", "목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}", + "목록 파일의 전체 경로를 적어 주세요": "リストファイルのフルパスを書いてください", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます", "목록에서 빼기": "リストから外す", @@ -2731,6 +2732,7 @@ "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します", "표시 지우기": "マークを解除", "표준": "標準", + "표준 입력 (-) 은 쓸 수 없습니다": "標準入力 (-) は使えません", "푸는 중": "展開中", "푸는 중: {0}": "展開中: {0}", "풀 수 없는 이름 {0}개": "展開できない名前 {0} 件", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 1e254a9..299f513 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1245,6 +1245,7 @@ "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", "목록 보기": "查看列表", "목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}", + "목록 파일의 전체 경로를 적어 주세요": "请填写列表文件的完整路径", "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情", "목록에서 빼기": "从列表中移除", @@ -2731,6 +2732,7 @@ "표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹", "표시 지우기": "清除标记", "표준": "标准", + "표준 입력 (-) 은 쓸 수 없습니다": "不能使用标准输入 (-)", "푸는 중": "正在解压", "푸는 중: {0}": "正在解压: {0}", "풀 수 없는 이름 {0}개": "{0} 个无法解压的名称", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index fd45501..c34dec4 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -661,7 +661,7 @@ class LiveSync extends ChangeNotifier { if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded', ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 '${toCygwinPath(x.source, windows: windows)}/', - '${toCygwinPath(x.target, windows: windows)}/', + '${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20) ]; /// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97 diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 2dd099e..ee291d0 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -203,7 +203,7 @@ const _rsyncLongWithValue = { List unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text]; /// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적) -enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath } +enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote, relativePath, stdin } /// [unreadableOptions] 의 한 줄과 그 종류 class OptionProblem { @@ -230,6 +230,7 @@ String optionProblemHint(Iterable problems, {bool? windows}) { if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), // 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 / if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'), + if (kinds.contains(OptionProblemKind.stdin)) tr('목록 파일의 전체 경로를 적어 주세요'), ].join(' · '); } @@ -280,7 +281,12 @@ List optionProblems(String options, {bool? windows}) { // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 for (final f in listFileValues(parsed.words)) { - if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); + // 관리자 지시 (10/20, 28 지적): "-" (표준 입력) 는 따로 - 앱은 rsync 에 표준 입력을 주지 않는다 + if (f == '-') { + bad.add(OptionProblem(OptionProblemKind.stdin, tr('표준 입력 (-) 은 쓸 수 없습니다'), f)); + } else if (!_absolutePath(f, windows: windows)) { + bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); + } } // 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 - 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 찾을 수 없다. // --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로 @@ -347,7 +353,13 @@ List rsyncFileOptionArgs(List words, {required bool windows}) { const opts = { '--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch', }; - String conv(String v) => RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\)').hasMatch(v) ? toCygwinPath(v) : v; + // 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로, + // 드라이브는 /cygdrive/… 로 + String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v) + ? toCygwinPath(v) + : RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v) + ? v.replaceAll('/', '\\') + : v; String rule(String r) { final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r); return m == null ? r : '${m[1]}${conv(m[2]!)}'; @@ -399,6 +411,13 @@ List rsyncFileOptionArgs(List words, {required bool windows}) { /// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). /// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. +/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다 +/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로 +String rsyncDestPath(String path, {bool windows = true}) { + if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\'); + return toCygwinPath(path, windows: windows); +} + String toCygwinPath(String path, {bool windows = true}) { if (!windows) return path; final s = path.replaceAll('\\', '/'); @@ -436,7 +455,7 @@ List rsyncArgs({ ...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다 // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', - '${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/', + '${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/', ]; } diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index f264efb..b68c8b1 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -698,7 +698,8 @@ class _CopyCardState extends State<_CopyCard> { TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소'))) else FilledButton.icon( - onPressed: () => _runPressed(t), + // 관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내가 보이면) 확인 창처럼 [실행] 도 끈다 - 누르면 실패로 끝났다 + onPressed: _method == 'rsync' && optionProblems(_options.text).isNotEmpty ? null : () => _runPressed(t), icon: const Icon(Icons.play_arrow, size: 18), label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')), ), diff --git a/test/monitor_test.dart b/test/monitor_test.dart index f1385b1..881372d 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -329,6 +329,26 @@ void main() { expect(find.text('복사 · rsync 로 이동'), findsOneWidget); }); + testWidgets('관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내) 카드의 [▶ 실행] 도 꺼진다', (tester) async { + final tmp = Directory.systemTemp.createTempSync('jj_monbad_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')]; + tester.view.physicalSize = const Size(1500, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c))); + await tester.pump(); + FilledButton run() => tester.widget(find.ancestor(of: find.text('실행'), matching: find.byWidgetPredicate((w) => w is FilledButton))); + expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget); + expect(run().onPressed, isNull, reason: '막히면 꺼짐'); + // 옵션을 고치면 다시 켜진다 + await tester.enterText(find.widgetWithText(TextField, '옵션'), '-av'); + await tester.pump(); + expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsNothing); + expect(run().onPressed, isNotNull); + }); + testWidgets('233: 이동 카드의 저장 창은 "이 이동을" · 229 설정은 Rsync 탭에도 있다', (tester) async { final tmp = Directory.systemTemp.createTempSync('jj_mon233_'); addTearDown(() => tmp.deleteSync(recursive: true)); diff --git a/test/rsync_msys_args_test.dart b/test/rsync_msys_args_test.dart index a29b067..0da64dd 100644 --- a/test/rsync_msys_args_test.dart +++ b/test/rsync_msys_args_test.dart @@ -86,8 +86,20 @@ void main() { }); test('관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\\ · C:/ · UNC 는 rsync 가 읽는 꼴로 바꿔 넘긴다 (막지 않음)', () { - expect(rsyncFileOptionArgs([r'--exclude-from=C:\l\a.txt', '--include-from', 'C:/l/b.txt', r'--files-from=\\nas\s\f.txt'], windows: true), - ['--exclude-from=/cygdrive/c/l/a.txt', '--include-from', '/cygdrive/c/l/b.txt', '--files-from=//nas/s/f.txt']); + // UNC 는 \\서버\… 로 (28 · 15 확인 10/20: 목록 · 기록 파일은 //서버/… 를 열지 못함, exit 11) + expect( + rsyncFileOptionArgs( + [r'--exclude-from=C:\l\a.txt', '--include-from', 'C:/l/b.txt', r'--files-from=\\nas\s\f.txt', '--exclude-from=//nas/s/g.txt'], + windows: true), + ['--exclude-from=/cygdrive/c/l/a.txt', '--include-from', '/cygdrive/c/l/b.txt', r'--files-from=\\nas\s\f.txt', r'--exclude-from=\\nas\s\g.txt']); + // 받는 쪽은 UNC 를 \\ 로 (//서버 는 change_dir 실패, exit 3) · 원본은 // 그대로 (toCygwinPath) + expect(rsyncDestPath(r'\\nas\s\d'), r'\\nas\s\d'); + expect(rsyncDestPath('//nas/s/d'), r'\\nas\s\d'); + expect(rsyncDestPath(r'C:\d'), '/cygdrive/c/d'); + expect(rsyncDestPath(r'\\nas\s\d', windows: false), r'\\nas\s\d'); + expect(toCygwinPath(r'\\nas\s\src'), '//nas/s/src'); + final a = rsyncArgs(options: '-a', sources: [r'\\nas\s\src'], dest: r'\\nas\s\dst', contents: true); + expect(a.sublist(a.length - 2), ['//nas/s/src/', r'\\nas\s\dst/']); expect(rsyncFileOptionArgs(['--filter=merge,- C:/r/m.txt', '--filter', '. C:/r/d.txt', '-f. C:/r/e.txt', '-avf', 'merge C:/r/g.txt'], windows: true), ['--filter=merge,- /cygdrive/c/r/m.txt', '--filter', '. /cygdrive/c/r/d.txt', '-f. /cygdrive/c/r/e.txt', '-avf', 'merge /cygdrive/c/r/g.txt']); expect(rsyncFileOptionArgs(['--log-file=C:/logs/r.log', '--write-batch', r'D:\b\batch', '--only-write-batch=C:/b2', '--read-batch=C:/b3'], windows: true), @@ -98,6 +110,14 @@ void main() { expect(rsyncFileOptionArgs([r'--exclude-from=C:\l.txt'], windows: false), [r'--exclude-from=C:\l.txt']); }); + test('관리자 지시 (10/20, 28 지적): 표준 입력 (-) 은 따로 된 문구 (하이픈이 겹치지 않게)', () { + for (final o in ['-a --files-from=-', '-a --exclude-from=-', '-a --include-from -']) { + final bad = optionProblems(o, windows: true); + expect(bad.map((x) => x.text), ['표준 입력 (-) 은 쓸 수 없습니다'], reason: o); + expect(optionProblemHint(bad, windows: true), '목록 파일의 전체 경로를 적어 주세요', reason: o); + } + }); + group('실제 rsync', () { final rsync = sceneRsync; late Directory tmp; @@ -187,6 +207,35 @@ void main() { expect(File(p.join(lists.path, 'r.log')).existsSync(), isTrue); }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); + test('28 · 15 확인 (10/20): 네트워크 공유 (UNC, 관리 공유 \\\\localhost\\C\$ 로) - 원본 · 받는 쪽 · 목록 파일 (\\\\ · //) 이 모두 된다', () async { + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); + String unc(String local) => '\\\\localhost\\C\$${local.substring(2)}'; + if (!Directory(unc(tmp.path)).existsSync()) return markTestSkipped('관리 공유 \\\\localhost\\C\$ 를 쓸 수 없음'); + final list = File(p.join(tmp.path, 'ex.txt'))..writeAsStringSync('keep.txt\n'); + for (final (name, listArg) in [('목록 \\\\', unc(list.path)), ('목록 //', unc(list.path).replaceAll(r'\', '/'))]) { + final (s, d) = scene('us_${name.length}', 'ud_${name.length}', ['a.txt'], ['keep.txt', 'old.txt']); + // 사용자가 고른 그대로의 UNC 경로 (원본 · 받는 쪽 모두) + final job = TransferJob( + sources: [unc(s)], dest: unc(d), move: false, method: CopyMethod.rsync, options: '-a --delete --exclude-from=$listArg', rsyncExe: rsync, contents: true); + await job.run(); + expect(job.error, isNull, reason: '$name: ${job.error}'); + expect(treeOf(d).keys.toSet(), {'keep.txt', 'a.txt'}, reason: name); + } + // 실시간 동기화 쌍의 받는 쪽도 UNC + final (s, d) = scene('ls', 'ld', ['a.txt'], ['old.txt']); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..rsyncSource = 'custom' + ..rsyncPath = rsync + ..rsyncOptions = '-a' + ..liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')]; + final live = LiveSync(c); + addTearDown(live.dispose); + await live.syncNow(c.settings.liveSyncPairs.single); + expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.single)]!.$2, isNot(contains('실패'))); + expect(treeOf(d).keys.toSet(), {'a.txt'}); + }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); + test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async { if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']);