From ed459a02294bc1a72f87f898a9fd1b5b406c58c5 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:24:35 +0900 Subject: [PATCH] =?UTF-8?q?rc:=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=ED=8C=8C=EC=9D=BC=EC=9D=98=20C:/=20=C2=B7=20C:\=20?= =?UTF-8?q?=C2=B7=20UNC=20=EB=A5=BC=20rsync=20=EA=B0=80=20=EC=97=AC?= =?UTF-8?q?=EB=8A=94=20=EA=BC=B4=EB=A1=9C=20=EB=84=98=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?Windows=20=EB=8A=94=20=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A7=8C=20=C2=B7=20=EB=A7=89=ED=9E=90=20?= =?UTF-8?q?=EB=95=8C=EB=8A=94=20=EC=9D=B4=EC=9C=A0=20=ED=95=9C=20=EC=A4=84?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20---delete=20=EB=AC=B8=EA=B5=AC=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/20, 28 이 985 에서 확인): 1. --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · merge · --log-file · --write-batch · --read-batch · --only-write-batch 의 값이 C:/… 이면 앱은 통과시키는데 rsync (MSYS2) 가 "failed to open exclude file" (exit 11) 로 실패했다 (지운 것은 없음). 관리자 판단: 막지 말고 바르게 넘긴다 - rsyncFileOptionArgs 가 그 값의 C:\ · C:/ · UNC 를 toCygwinPath 로 (rsyncArgs · 실시간 동기화 rsyncArgsFor). 패턴 · ": 이름" · 받는 쪽 기준 옵션 (--backup-dir 등) · -e 의 값은 그대로. 2. Windows 는 드라이브 (C:\ · C:/) · UNC · /cygdrive/ 만 전체 경로 - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 막는다. 끝 문구: Windows "C:\ 처럼 드라이브로 시작하는 전체 경로로", Android "/ 로 시작하는 전체 경로로". 3. [실행] 이 막힐 때 (알아볼 수 없음 · 상대 경로 등) 는 "미리 볼 수 없습니다" 줄과 [알고 실행합니다] 체크를 숨기고 막히는 이유 한 줄만 (Rsync 화면 확인 창 · 지우기 포함 실행 확인 창). 4. "원본 파일 지우기 (--remove-source-files)" 가 412 ko · ja 에서 옵션 중간에 갈리던 것: rc 에 든 dfed3fa (keepOptionsTogether) 로 이미 고쳐짐 - 캡처 shots_rsync_narrow (ko · ja · en 412) 로 확인. 5. "---delete" 의 끝 문구는 "옵션은 - 나 -- 로 시작합니다". 시험: rsync_msys_args_test (실제 rsync - C:/ · C:\ · 다음 낱말 · merge · -f. 로 적은 목록의 keep.txt 를 지킴, --log-file=C:/… 에 기록이 생김 · 바꿔 넘기기 단위 · Windows 의 / 막기 · 끝 문구) · dismiss_test (막힐 때 미리 볼 수 없음 · 체크 숨김) · option_paste_test (---delete 문구). 전체 시험 1140개 통과 (17 건너뜀) · analyze 문제 없음 · l10n 빠진 번역 0. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 3 +- assets/l10n/ja.json | 3 +- assets/l10n/zh-Hans.json | 3 +- lib/app/live_sync.dart | 7 ++- lib/core/sync_tools.dart | 80 ++++++++++++++++++++++++++++++---- lib/ui/explorer_page.dart | 3 +- lib/ui/sync_preview_view.dart | 3 +- test/dismiss_test.dart | 20 +++++++++ test/option_paste_test.dart | 2 +- test/rsync_msys_args_test.dart | 55 ++++++++++++++++++++++- 10 files changed, 161 insertions(+), 18 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 8078b98..9a70406 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -73,6 +73,7 @@ "- 감지한 언어": "- detected", "-1 은 아무렇게나": "-1 = random", ".{0} 재생 프로그램 (exe)": ".{0} player (exe)", + "/ 로 시작하는 전체 경로로": "use a full path starting with /", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)", "0 으로": "Reset to 0", "1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image", @@ -153,6 +154,7 @@ "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)", "Android: Tor 화면이 열려 있는 동안": "Android: while the Tor screen is open", "BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}", + "C:\\ 처럼 드라이브로 시작하는 전체 경로로": "use a full path starting with a drive, like C:\\", "Chrome (내려받기)": "Chrome (download)", "Chrome (내장)": "Chrome (built-in)", "Chrome 에서 가져오기": "Import from Chrome", @@ -1127,7 +1129,6 @@ "뒤로": "Back", "뒤로 보내고 계속": "Keep running in background", "뒤에서 계속": "Continue in background", - "드라이브 (C:) 나 / 로 시작하는 경로로": "Use a path that starts with a drive (C:) or /", "드라이브 경로": "Drive path", "드라이브의 맨 위 폴더는 지울 수 없습니다": "The top folder of a drive can't be deleted", "등록": "Register", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ccb57d8..4c6a2ae 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -73,6 +73,7 @@ "- 감지한 언어": "- 検出した言語", "-1 은 아무렇게나": "-1 はランダム", ".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)", + "/ 로 시작하는 전체 경로로": "/ で始まるフルパスで", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)", "0 으로": "0 に戻す", "1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中", @@ -153,6 +154,7 @@ "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)", "Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面を開いている間", "BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}", + "C:\\ 처럼 드라이브로 시작하는 전체 경로로": "C:\\ のようにドライブで始まるフルパスで", "Chrome (내려받기)": "Chrome(ダウンロード)", "Chrome (내장)": "Chrome(内蔵)", "Chrome 에서 가져오기": "Chrome から取り込む", @@ -1127,7 +1129,6 @@ "뒤로": "戻る", "뒤로 보내고 계속": "裏で続ける", "뒤에서 계속": "バックグラウンドで続ける", - "드라이브 (C:) 나 / 로 시작하는 경로로": "ドライブ (C:) か / で始まるパスで", "드라이브 경로": "ドライブのパス", "드라이브의 맨 위 폴더는 지울 수 없습니다": "ドライブの最上位フォルダーは削除できません", "등록": "登録", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index faec53e..1e254a9 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -73,6 +73,7 @@ "- 감지한 언어": "- 检测到的语言", "-1 은 아무렇게나": "-1 为随机", ".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)", + "/ 로 시작하는 전체 경로로": "请使用以 / 开头的完整路径", "0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)", "0 으로": "归零", "1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化", @@ -153,6 +154,7 @@ "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)", "Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面打开期间", "BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}", + "C:\\ 처럼 드라이브로 시작하는 전체 경로로": "请使用以驱动器开头的完整路径,如 C:\\", "Chrome (내려받기)": "Chrome(下载)", "Chrome (내장)": "Chrome(内置)", "Chrome 에서 가져오기": "从 Chrome 导入", @@ -1127,7 +1129,6 @@ "뒤로": "后退", "뒤로 보내고 계속": "转到后台继续", "뒤에서 계속": "在后台继续", - "드라이브 (C:) 나 / 로 시작하는 경로로": "请使用以盘符 (C:) 或 / 开头的路径", "드라이브 경로": "驱动器路径", "드라이브의 맨 위 폴더는 지울 수 없습니다": "无法删除驱动器的根文件夹", "등록": "注册", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 301427c..fd45501 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -648,8 +648,11 @@ class LiveSync extends ChangeNotifier { /// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97) static List rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [ // 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다 - ...splitOptions(s.rsyncOptions) - .where((o) => o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)), + // 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs) + ...rsyncFileOptionArgs([ + for (final o in splitOptions(s.rsyncOptions)) + if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o, + ], windows: windows), if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', if (x.delete) '--delete', diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index bacd2ae..2dd099e 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -219,7 +219,8 @@ class OptionProblem { } /// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다) -String optionProblemHint(Iterable problems) { +String optionProblemHint(Iterable problems, {bool? windows}) { + windows ??= Platform.isWindows; final kinds = {for (final x in problems) x.kind}; return [ if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'), @@ -227,7 +228,8 @@ String optionProblemHint(Iterable problems) { if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'), if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'), if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'), - if (kinds.contains(OptionProblemKind.relativePath)) tr('드라이브 (C:) 나 / 로 시작하는 경로로'), + // 관리자 지시 (10/20): Windows 는 드라이브로 시작하는 전체 경로만 ("/" 는 rsync 가 자기 설치 폴더 기준으로 읽음), Android 는 / + if (kinds.contains(OptionProblemKind.relativePath)) windows ? tr('C:\\ 처럼 드라이브로 시작하는 전체 경로로') : tr('/ 로 시작하는 전체 경로로'), ].join(' · '); } @@ -256,8 +258,8 @@ List optionProblems(String options, {bool? windows}) { continue; } if (t.startsWith('---')) { - // 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - bad.add(OptionProblem(OptionProblemKind.odd, t, t)); + // 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다 - 끝 문구는 "옵션은 - 나 -- 로 시작합니다" + bad.add(OptionProblem(OptionProblemKind.path, t, t)); } else if (t.startsWith('--')) { value = !t.contains('=') && _rsyncLongWithValue.contains(t); last = t; @@ -278,12 +280,12 @@ List optionProblems(String options, {bool? windows}) { // 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 // 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다 for (final f in listFileValues(parsed.words)) { - if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('목록 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); + 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 류는 받는 쪽 기준이라 그대로 for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) { - if (!_absolutePath(f)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); + if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f)); } return bad; } @@ -332,8 +334,68 @@ List listFileValues(List words) { return out; } -/// 전체 경로 (C:\ · C:/ · \\서버 · / 로 시작 - /cygdrive/c/… 와 Android 경로 포함) -bool _absolutePath(String f) => RegExp(r'^(?:[A-Za-z]:[\\/]|[\\/])').hasMatch(f); +/// 전체 경로. Windows: 드라이브 (C:\ · C:/) · UNC (\\서버 · //서버) · /cygdrive/… - 그 밖의 / 는 MSYS 가 자기 설치 폴더 기준으로 읽어 뺀다. +/// Android: / 로 시작 +bool _absolutePath(String f, {required bool windows}) => + windows ? RegExp(r'^(?:[A-Za-z]:[\\/]|\\\\|//|/cygdrive/)').hasMatch(f) : f.startsWith('/'); + +/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다 +/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음). +/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch +List rsyncFileOptionArgs(List words, {required bool windows}) { + if (!windows) return words; + 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; + String rule(String r) { + final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r); + return m == null ? r : '${m[1]}${conv(m[2]!)}'; + } + + final out = []; + for (var i = 0; i < words.length; i++) { + final w = words[i]; + final next = i + 1 < words.length ? words[i + 1] : null; + final eq = w.indexOf('='); + if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) { + out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}'); + } else if (opts.contains(w) && next != null) { + out + ..add(w) + ..add(conv(next)); + i++; + } else if (w.startsWith('--filter=')) { + out.add('--filter=${rule(w.substring(9))}'); + } else if (w == '--filter' && next != null) { + out + ..add(w) + ..add(rule(next)); + i++; + } else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) { + // 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록") + final k = w.indexOf(RegExp('[BefMT@]'), 1); + if (k >= 0 && w[k] == 'f' && k < w.length - 1) { + out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}'); + } else if (k >= 0 && w[k] == 'f' && next != null) { + out + ..add(w) + ..add(rule(next)); + i++; + } else { + out.add(w); + // 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀 + if (k == w.length - 1 && next != null) { + out.add(next); + i++; + } + } + } else { + out.add(w); + } + } + return out; +} /// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x). /// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다. @@ -364,7 +426,7 @@ List rsyncArgs({ bool contents = false, }) { // 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다 - final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; + final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows); return [ ...opts, if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index d4d87ad..1376e42 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3513,7 +3513,8 @@ class _ExplorerPageState extends State { // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) const SizedBox(height: 8), if (badOptions().isNotEmpty) OptionProblemNote(badOptions()), - if (filterLimits().isNotEmpty) + // 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김) + if (badOptions().isEmpty && filterLimits().isNotEmpty) FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)), SyncPreviewView( // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index e6508a8..6b3f687 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -258,7 +258,8 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM const SizedBox(height: 8), // 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐) if (bad.isNotEmpty) OptionProblemNote(bad), - if (filter.unsupported.isNotEmpty) + // 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김) + if (bad.isEmpty && filter.unsupported.isNotEmpty) FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)), SyncPreviewView( compute: () async => [ diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index 4fbe65d..7fc1807 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -219,6 +219,26 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 - "미리 볼 수 없습니다" · [알고 실행합니다] 는 숨김', (tester) async { + final src = p.join(tmp.path, 'bs'), dst = p.join(tmp.path, 'bd'); + File(p.join(src, 'a.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('a'); + Directory(dst).createSync(recursive: true); + bool? r; + // 미리 볼 수 없는 규칙 (merge) + 알아볼 수 없는 낱말 (delete) - 실행은 막힘 + final t = CopyTask(id: '9', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt" delete'); + 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('미리 볼 수 없습니다'), findsNothing); + expect(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'), findsNothing); + expect(tester.widget(find.widgetWithText(FilledButton, '실행')).onPressed, isNull); + await tester.tap(find.text('취소').last); + await tester.pumpAndSettle(); + expect(r, isFalse); + }); + testWidgets('177 · 15 검토 F2: 미리 볼 수 없는 거르기 규칙 + --delete 면 빨간 경고 · 체크해야 [실행] 이 켜진다', (tester) async { final src = p.join(tmp.path, 'fs'), dst = p.join(tmp.path, 'fd'); File(p.join(src, 'a.txt')) diff --git a/test/option_paste_test.dart b/test/option_paste_test.dart index 00271cf..5bf888b 100644 --- a/test/option_paste_test.dart +++ b/test/option_paste_test.dart @@ -117,7 +117,7 @@ void main() { expect(hint('-a --exclude="a.tmp'), '따옴표를 닫아 주세요'); expect(hint('-a delete'), '옵션은 - 나 -- 로 시작합니다'); expect(hint('-a --exclude'), '값을 적어 주세요 (예: --exclude=*.tmp)'); - expect(hint('-a ---delete'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'); + expect(hint('-a ---delete'), '옵션은 - 나 -- 로 시작합니다', reason: '관리자 지시 (10/20)'); expect(hint('-a \u00ABx\u00BB'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'); expect(hint('-a delete --exclude'), '옵션은 - 나 -- 로 시작합니다 · 값을 적어 주세요 (예: --exclude=*.tmp)'); expect(optionProblemMessage(optionProblems('-a delete')), '옵션을 알아볼 수 없습니다: 경로로 읽힙니다: delete - 옵션은 - 나 -- 로 시작합니다'); diff --git a/test/rsync_msys_args_test.dart b/test/rsync_msys_args_test.dart index 45a42a2..a29b067 100644 --- a/test/rsync_msys_args_test.dart +++ b/test/rsync_msys_args_test.dart @@ -56,7 +56,6 @@ void main() { '-a --exclude-from=C:/lists/l.txt', '-a --include-from=/cygdrive/c/l.txt', r'-a --files-from=\\nas\share\l.txt', - '-a --exclude-from /sdcard/l.txt', '-a --filter=". /cygdrive/c/rules.txt"', r'-a -f". C:\r.txt"', '-a --filter=": .rsync-filter"', @@ -75,6 +74,30 @@ void main() { } }); + test('관리자 지시 (10/20): Windows 는 드라이브 · UNC · /cygdrive/ 만 전체 경로 ("/" 는 MSYS 가 자기 폴더 기준으로 읽음), Android 는 / · 끝 문구', () { + expect(unreadableOptions('-a --exclude-from=/lists/l.txt', windows: true), hasLength(1)); + expect(unreadableOptions('-a --exclude-from=/sdcard/l.txt', windows: false), isEmpty); + expect(unreadableOptions('-a --exclude-from=C:/l.txt', windows: false), hasLength(1), reason: 'Android 에서는 / 로 시작해야'); + for (final o in [r'-a --exclude-from=\\nas\s\l.txt', '-a --exclude-from=//nas/s/l.txt', '-a --exclude-from=/cygdrive/c/l.txt']) { + expect(unreadableOptions(o, windows: true), isEmpty, reason: o); + } + expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: true), windows: true), r'C:\ 처럼 드라이브로 시작하는 전체 경로로'); + expect(optionProblemHint(optionProblems('-a --exclude-from=l.txt', windows: false), windows: false), '/ 로 시작하는 전체 경로로'); + }); + + 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']); + 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), + ['--log-file=/cygdrive/c/logs/r.log', '--write-batch', '/cygdrive/d/b/batch', '--only-write-batch=/cygdrive/c/b2', '--read-batch=/cygdrive/c/b3']); + // 바꾸지 않는 것: 패턴 · 폴더마다 읽는 규칙 · 받는 쪽 기준 옵션 · 다른 값 받는 글자 · 이미 /cygdrive · Android + final keep = ['--exclude=C:/x', '--filter=: .rsync-filter', '--filter=- C:/x', '--backup-dir=C:/b', '-e', 'C:/ssh.exe', '--exclude-from=/cygdrive/c/l.txt']; + expect(rsyncFileOptionArgs(keep, windows: true), keep); + expect(rsyncFileOptionArgs([r'--exclude-from=C:\l.txt'], windows: false), [r'--exclude-from=C:\l.txt']); + }); + group('실제 rsync', () { final rsync = sceneRsync; late Directory tmp; @@ -134,6 +157,36 @@ void main() { }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); } + test('28 확인 (10/20): 목록 · 기록 파일을 C:/ · C:\\ 로 적어도 rsync 가 연다 (예전: failed to open exclude file, exit 11) - keep.txt 를 지킴', () async { + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); + final lists = Directory(p.join(tmp.path, 'lists'))..createSync(); + File(p.join(lists.path, 'ex.txt')).writeAsStringSync('keep.txt\n'); + File(p.join(lists.path, 'rules.txt')).writeAsStringSync('- keep2.txt\n'); + final slash = lists.path.replaceAll(r'\', '/'); + for (final (name, opts) in [ + ('--exclude-from=C:/…', '-a --delete --exclude-from=$slash/ex.txt --exclude=keep2.txt'), + (r'--exclude-from=C:\…', '-a --delete --exclude-from="${lists.path}\\ex.txt" --exclude=keep2.txt'), + ('--exclude-from C:/… (다음 낱말)', '-a --delete --exclude-from $slash/ex.txt --exclude=keep2.txt'), + ('--filter="merge C:/…"', '-a --delete --exclude=keep.txt --filter="merge $slash/rules.txt"'), + ('-f". C:/…"', '-a --delete --exclude=keep.txt -f". $slash/rules.txt"'), + ]) { + final (s, d) = scene('s_$name'.replaceAll(RegExp(r'[^\w]'), '_'), 'd_$name'.replaceAll(RegExp(r'[^\w]'), '_'), ['a.txt'], + ['keep.txt', 'keep2.txt', 'old.txt']); + expect(unreadableOptions(opts), isEmpty, reason: '$name: 막지 않음'); + final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true); + await job.run(); + expect(job.error, isNull, reason: '$name: ${job.error}'); + expect(treeOf(d).keys.toSet(), {'keep.txt', 'keep2.txt', 'a.txt'}, reason: '$name: 목록의 것을 지키고 old.txt 만 지움'); + } + // --log-file=C:/… 은 그 자리에 기록을 남긴다 (예전에는 열지 못해 실패) + final (s, d) = scene('s_log', 'd_log', ['a.txt'], []); + final job = TransferJob( + sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a --log-file=$slash/r.log', rsyncExe: rsync, contents: true); + await job.run(); + expect(job.error, isNull, reason: '${job.error}'); + expect(File(p.join(lists.path, 'r.log')).existsSync(), isTrue); + }, 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']);