diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 4cc8970..577c051 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1128,6 +1128,7 @@ "등록 중 일부가 실패했습니다.": "Some registrations failed.", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)", + "따옴표가 닫히지 않았습니다: {0}": "Unclosed quote: {0}", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)", "라오어": "Lao", "라이선스 원문 보기": "View full license text", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 199023d..c7c8c5f 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1128,6 +1128,7 @@ "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)", + "따옴표가 닫히지 않았습니다: {0}": "引用符が閉じていません: {0}", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "または、もう一方の端末のカメラで QR コードを読み取ってください (コード入り)", "라오어": "ラオ語", "라이선스 원문 보기": "ライセンス原文を見る", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index c8ef5fc..1a16d1a 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1128,6 +1128,7 @@ "등록 중 일부가 실패했습니다.": "部分注册失败。", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)", "따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)", + "따옴표가 닫히지 않았습니다: {0}": "引号未闭合: {0}", "또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)", "라오어": "老挝语", "라이선스 원문 보기": "查看许可证原文", diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index e1ef635..aa87a49 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -1,6 +1,7 @@ import 'package:path/path.dart' as p; import 'file_ops.dart' show appTempRsyncExcludes; +import '../l10n/tr.dart'; /// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화) /// @@ -36,57 +37,147 @@ const _dashLike = '\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D' const _curlyQuote = {'\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201C': '"', '\u201D': '"', '\u201E': '"'}; /// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]. -/// 관리자 지시 (10/20, 28 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 +/// 관리자 지시 (10/20, 28 · 15 확인): 웹 · 문서에서 붙여 넣은 글을 곧은 글자로 쓴 것과 같게 읽는다 - 예전에는 /// --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. -/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표 뒤가 빈칸 · 끝이면 닫는다. +/// - 굽은 따옴표 (‘ ’ “ ”) 는 낱말 맨 앞 · = 뒤 · 짧은 옵션 (-f) 뒤에서 열고, 같은 무리의 굽은 따옴표에서 닫는다 +/// (곧은 따옴표처럼 뒤에 글이 이어져도 - “a b”.txt → a b.txt). 작은따옴표 무리는 글자 사이 (it’s) 이면 닫지 않는다. /// 이름 안의 굽은 따옴표 (it’s.txt) 는 그대로 둔다. -/// - 낱말 맨 앞의 대시 (– — ‑ - 등): 둘이면 --, 하나 뒤에 소문자 낱말 (3자 이상, 예: –delete) 이면 -- (자동 고침이 -- 를 한 글자로 -/// 바꾼 것 - "-delete" 로 두면 rsync 가 -d -e … 짧은 옵션으로 읽는다, 내 판단), 그 밖은 -. 낱말 가운데의 대시는 그대로 (파일 이름). -List splitOptions(String s) { - final out = []; - final cur = StringBuffer(); +/// - 닫히지 않은 따옴표 (15 확인 F1): 예전에는 뒤의 옵션을 모두 삼켜 지키려던 --exclude 가 사라졌다 → 그 따옴표를 글자로 보고 +/// 나머지는 그대로 나누며, [unreadableOptions] 가 알려 실행을 막는다. +/// - 낱말 맨 앞의 대시 (– — ‑ - 등, 따옴표 밖): 둘이면 --, 하나면 rsync 의 긴 옵션 이름 (–delete) 일 때만 -- 그 밖 (–avz) 은 -. +/// 옵션의 값 자리 (--exclude –x.txt 의 –x.txt) · 낱말 가운데의 대시는 그대로 (파일 이름). --filter · -f 값의 규칙 머리 (“– *.tmp”) 는 - 로. +List splitOptions(String s) => _parseOptions(s).words; + +/// rsync 3.x 의 긴 옵션 이름 (앱에 든 rsync 3.5.1 의 --help) - 대시 하나로 붙여 넣은 –delete 를 --delete 로 읽을지 정한다 +const _rsyncLong = { + 'acls', 'address', 'append', 'append-verify', 'archive', 'atimes', 'backup', 'backup-dir', 'block-size', 'blocking-io', + 'bwlimit', 'cc', 'checksum', 'checksum-choice', 'checksum-seed', 'chmod', 'chown', 'compare-dest', 'compress', + 'compress-choice', 'compress-level', 'compress-threads', 'confine-root', 'contimeout', 'copy-as', 'copy-dest', + 'copy-devices', 'copy-dirlinks', 'copy-links', 'copy-unsafe-links', 'crtimes', 'cvs-exclude', 'daemon', 'debug', 'del', + 'delay-updates', 'delete', 'delete-after', 'delete-before', 'delete-delay', 'delete-during', 'delete-excluded', + 'delete-missing-args', 'devices', 'dirs', 'dry-run', 'early-input', 'exclude', 'exclude-from', 'executability', 'existing', + 'fake-super', 'files-from', 'filter', 'force', 'from0', 'fsync', 'fuzzy', 'group', 'groupmap', 'hard-links', 'help', + 'human-readable', 'i-r', 'iconv', 'ignore-errors', 'ignore-existing', 'ignore-missing-args', 'ignore-non-existing', + 'ignore-times', 'inc-recursive', 'include', 'include-from', 'info', 'inplace', 'insecure-links', 'ipv4', 'ipv6', + 'itemize-changes', 'keep-dirlinks', 'link-dest', 'links', 'list-only', 'log-file', 'log-file-format', 'max-alloc', + 'max-delete', 'max-size', 'min-size', 'mkpath', 'modify-window', 'munge-links', 'no-i-r', 'no-implied-dirs', + 'no-inc-recursive', 'no-motd', 'no-whole-file', 'numeric-ids', 'old-args', 'old-d', 'old-dirs', 'omit-dir-times', + 'omit-link-times', 'one-file-system', 'only-write-batch', 'open-noatime', 'out-format', 'outbuf', 'owner', 'partial', + 'partial-dir', 'password-file', 'perms', 'port', 'preallocate', 'progress', 'protocol', 'prune-empty-dirs', 'quiet', + 'read-batch', 'recursive', 'relative', 'remote-option', 'remove-source-files', 'rsh', 'rsync-path', 'safe-links', + 'secluded-args', 'size-only', 'skip-compress', 'sockopts', 'sparse', 'specials', 'stats', 'stderr', 'stop-after', 'stop-at', + 'suffix', 'super', 'temp-dir', 'timeout', 'times', 'trust-sender', 'update', 'usermap', 'verbose', 'version', + 'whole-file', 'write-batch', 'write-devices', 'xattrs', 'zc', 'zl', 'zt', +}; + +/// 나눈 낱말: 맨 앞이 따옴표 안이었는지 (그러면 맨 앞 대시를 옵션으로 고치지 않는다) +typedef _Word = ({String text, bool quoted}); + +/// [splitOptions] · [unreadableOptions] 의 몸통: 낱말들과 닫히지 않은 따옴표 (뒤 글 일부) +({List words, List unclosed}) _parseOptions(String s) { final chars = cleanOptionText(s).split(''); + final literal = {}; // 닫히지 않아 글자로 보는 따옴표 자리 + final unclosed = []; + while (true) { + final (raw, open) = _splitRaw(chars, literal); + if (open == null) return (words: _normalizeWords(raw), unclosed: unclosed); + literal.add(open); + final rest = chars.sublist(open).join().trim(); + unclosed.add(rest.length > 30 ? '${rest.substring(0, 30)}…' : rest); + } +} + +/// 따옴표 · 빈칸으로 나눈다. 끝까지 닫히지 않은 따옴표가 있으면 그 자리 (두 번째 값) +(List<_Word>, int?) _splitRaw(List chars, Set literal) { + final out = <_Word>[]; + final cur = StringBuffer(); String? quote; // 닫는 곧은 따옴표 (" 또는 ') var curly = false; // 굽은 따옴표로 열었는지 + var openAt = -1; var has = false; - bool endAt(int i) => i + 1 >= chars.length || chars[i + 1].trim().isEmpty; + var quoted = false; + bool letter(int i) => i >= 0 && i < chars.length && RegExp(r'[\p{L}\p{N}]', unicode: true).hasMatch(chars[i]); for (var i = 0; i < chars.length; i++) { final ch = chars[i]; if (quote != null) { - if (ch == quote || (curly && _curlyQuote[ch] == quote && endAt(i))) { + // 작은따옴표 무리의 ’ 가 글자 사이면 (it’s) 닫지 않는다 + final closes = ch == quote && !curly || + curly && _curlyQuote[ch] == quote && !(quote == "'" && letter(i - 1) && letter(i + 1)); + if (closes) { quote = null; curly = false; } else { cur.write(ch); } + } else if (literal.contains(i)) { + cur.write(ch); } else if (ch == '"' || ch == "'") { + if (cur.isEmpty && !has) quoted = true; quote = ch; + openAt = i; has = true; } else if (_curlyQuote.containsKey(ch) && (cur.isEmpty || '$cur'.endsWith('=') || RegExp(r'^-[A-Za-z0-9]+$').hasMatch('$cur'))) { + if (cur.isEmpty && !has) quoted = true; quote = _curlyQuote[ch]; curly = true; + openAt = i; has = true; } else if (ch.trim().isEmpty) { - if (cur.isNotEmpty || has) out.add(cur.toString()); + if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); cur.clear(); has = false; - } else if (cur.isEmpty && !has && _dashLike.contains(ch)) { - // 맨 앞의 대시 무리 (곧은 - 가 섞여도 하나로) - var n = 0; - while (i + n < chars.length && n < 2 && (chars[i + n] == '-' || _dashLike.contains(chars[i + n]))) { - n++; - } - final rest = StringBuffer(); - for (var j = i + n; j < chars.length && chars[j].trim().isNotEmpty && chars[j] != '='; j++) { - rest.write(chars[j]); - } - cur.write(n >= 2 || RegExp(r'^[a-z][a-z0-9-]{2,}$').hasMatch('$rest') ? '--' : '-'); - i += n - 1; + quoted = false; } else { cur.write(ch); } } - if (cur.isNotEmpty || has) out.add(cur.toString()); + if (quote != null) return (out, openAt); + if (cur.isNotEmpty || has) out.add((text: cur.toString(), quoted: quoted)); + return (out, null); +} + +bool _dashAt(String t, int i) => i < t.length && (t[i] == '-' || _dashLike.contains(t[i])); + +/// 거르기 규칙 머리 ("– *.tmp" · "+ *.txt") 를 rsync 가 읽는 - · + 로 +String _fixRuleHead(String v) { + if (v.isEmpty) return v; + if (_dashLike.contains(v[0])) return '-${v.substring(1)}'; + if (v[0] == '\uFF0B') return '+${v.substring(1)}'; + return v; +} + +/// 낱말 맨 앞 대시 고치기 · 거르기 값의 규칙 머리 고치기 (값 자리는 그대로) +List _normalizeWords(List<_Word> raw) { + final out = []; + var value = ''; // 다음 낱말이 값: 'filter' (규칙 머리를 고침) · 'other' (그대로) + for (final w in raw) { + var t = w.text; + if (value.isNotEmpty) { + out.add(value == 'filter' ? _fixRuleHead(t) : t); + value = ''; + continue; + } + if (!w.quoted && t.isNotEmpty && _dashLike.contains(t[0])) { + final n = _dashAt(t, 1) ? 2 : 1; + final rest = t.substring(n); + final name = rest.split('=').first; + final long = n == 2 || _rsyncLong.contains(name) || (name.startsWith('no-') && _rsyncLong.contains(name.substring(3))); + t = '${long ? '--' : '-'}$rest'; + } + if (t.startsWith('--filter=')) { + t = '--filter=${_fixRuleHead(t.substring(9))}'; + } else if (t.length > 1 && t.startsWith('-') && !t.startsWith('--')) { + final k = t.indexOf(RegExp('[BefMT@]'), 1); + if (k > 0 && t[k] == 'f' && k < t.length - 1) t = '${t.substring(0, k + 1)}${_fixRuleHead(t.substring(k + 1))}'; + } + out.add(t); + if (t.startsWith('--')) { + if (!t.contains('=') && _rsyncLongWithValue.contains(t)) value = t == '--filter' ? 'filter' : 'other'; + } else if (t.length > 1 && t.startsWith('-')) { + final k = t.indexOf(RegExp('[BefMT@]'), 1); + if (k == t.length - 1) value = t[k] == 'f' ? 'filter' : 'other'; + } + } return out; } @@ -105,9 +196,11 @@ const _rsyncLongWithValue = { /// (rsync 는 그것을 원본 경로로 읽어 옵션이 몰래 빠진다) 그 낱말들. 있으면 확인 창 · 실시간 동기화에 빨갛게 알리고 실행을 막는다. /// 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다 List unreadableOptions(String options) { - final bad = []; + final parsed = _parseOptions(options); + // 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다 + final bad = [for (final u in parsed.unclosed) trf('따옴표가 닫히지 않았습니다: {0}', [u])]; var value = false; - for (final t in splitOptions(options)) { + for (final t in parsed.words) { if (value) { value = false; continue; diff --git a/test/option_paste_test.dart b/test/option_paste_test.dart index 4c6f920..6fd8ac3 100644 --- a/test/option_paste_test.dart +++ b/test/option_paste_test.dart @@ -16,19 +16,26 @@ import 'support/rsync_scenes.dart'; void main() { // 28 의 13가지: (이름, 붙여 넣은 글, 곧은 글자로 쓴 것) const cases = <(String, String, String)>[ - ('줄바꿈 없는 빈칸 (U+00A0)', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), - ('화면 글의 U+2060', '-a --delete --exclude=⁠*.log', '-a --delete --exclude=*.log'), - ('U+200B', '-a --delete --excl​ude=*.log', '-a --delete --exclude=*.log'), + ('줄바꿈 없는 빈칸 (U+00A0)', '-a\u00A0--delete\u00A0--exclude=*.log', '-a --delete --exclude=*.log'), + ('화면 글의 U+2060', '-a --delete --exclude=\u2060*.log', '-a --delete --exclude=*.log'), + ('U+200B', '-a --delete --excl\u200Bude=*.log', '-a --delete --exclude=*.log'), ('줄바꿈 · 탭', '-a\t--delete\n--exclude=*.log\r\n', '-a --delete --exclude=*.log'), - ('전각 빈칸 (U+3000)', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), - ('en dash (–delete)', '-a –delete --exclude=*.log', '-a --delete --exclude=*.log'), - ('em dash (—delete)', '-a —delete --exclude=*.log', '-a --delete --exclude=*.log'), - ('굽은 큰따옴표', '-a --delete --filter=“- *.log”', '-a --delete --filter="- *.log"'), - ('굽은 작은따옴표', '-a --delete --exclude=‘*.log’', "-a --delete --exclude='*.log'"), - ('소프트 하이픈 (U+00AD)', '-a --del­ete --exclude=*.log', '-a --delete --exclude=*.log'), - ('줄바꿈 없는 하이픈 (U+2011)', '-a ‑‑delete --exclude=*.log', '-a --delete --exclude=*.log'), - ('전각 하이픈 (U+FF0D)', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), - ('BOM 앞', '-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('전각 빈칸 (U+3000)', '-a\u3000--delete\u3000--exclude=*.log', '-a --delete --exclude=*.log'), + ('en dash (\u2013delete)', '-a \u2013delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('em dash (\u2014delete)', '-a \u2014delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('굽은 큰따옴표', '-a --delete --filter=\u201C- *.log\u201D', '-a --delete --filter="- *.log"'), + ('굽은 작은따옴표', '-a --delete --exclude=\u2018*.log\u2019', "-a --delete --exclude='*.log'"), + ('소프트 하이픈 (U+00AD)', '-a --del\u00ADete --exclude=*.log', '-a --delete --exclude=*.log'), + ('줄바꿈 없는 하이픈 (U+2011)', '-a \u2011\u2011delete --exclude=*.log', '-a --delete --exclude=*.log'), + ('전각 하이픈 (U+FF0D)', '-a \uFF0D\uFF0Ddelete --exclude=*.log', '-a --delete --exclude=*.log'), + ('BOM 앞', '\uFEFF-a --delete --exclude=*.log', '-a --delete --exclude=*.log'), + // 15 확인 (10/20): F2 짧은 옵션 묶음 앞의 대시 하나 · F3 거르기 규칙 머리의 대시 · F5 닫는 따옴표 뒤에 글 + ('F2 \u2013avz', '\u2013avz --delete --exclude=*.log', '-avz --delete --exclude=*.log'), + ('F2 \u2013rlpt', '\u2013rlpt --delete --exclude=*.log', '-rlpt --delete --exclude=*.log'), + ('F3 --filter=\u201C\u2013 *.log\u201D', '-a --delete --filter=\u201C\u2013 *.log\u201D', '-a --delete --filter="- *.log"'), + ('F3 -f\u201C\u2013 *.log\u201D', '-a --delete -f\u201C\u2013 *.log\u201D', '-a --delete -f"- *.log"'), + ('F3 --filter \u201C\u2013 *.log\u201D (값 따로)', '-a --delete --filter \u201C\u2013 *.log\u201D', '-a --delete --filter "- *.log"'), + ('F5 \u201C*\u201D.log', '-a --delete --exclude=\u201C*\u201D.log', '-a --delete --exclude="*".log'), ]; group('붙여 넣은 옵션 = 곧은 글자로 쓴 것', () { @@ -52,19 +59,39 @@ void main() { test('낱말 가운데 · 이름 안은 그대로 (파일 이름일 수 있음)', () { // 이름 안의 굽은 작은따옴표 (it’s) · 대시 (a–b) 는 따옴표 · 옵션으로 읽지 않는다 - expect(splitOptions('--exclude=it’s.txt'), ['--exclude=it’s.txt']); - expect(splitOptions('--exclude=a–b.txt'), ['--exclude=a–b.txt']); + expect(splitOptions('--exclude=it\u2019s.txt'), ['--exclude=it\u2019s.txt']); + expect(splitOptions('--exclude=a\u2013b.txt'), ['--exclude=a\u2013b.txt']); // 굽은 따옴표 안의 굽은 따옴표 (뒤가 빈칸 · 끝이 아니면 닫지 않음) - expect(splitOptions('--exclude=‘it’s a.txt’ -a'), ['--exclude=it’s a.txt', '-a']); + expect(splitOptions('--exclude=\u2018it\u2019s a.txt\u2019 -a'), ['--exclude=it\u2019s a.txt', '-a']); // 짧은 옵션 뒤 (-f“- *.log”) - expect(splitOptions('-f“- *.log”'), ['-f- *.log']); + expect(splitOptions('-f\u201C- *.log\u201D'), ['-f- *.log']); // 대시 하나 + 짧은 글자는 - (–av → -av), 소문자 낱말이면 -- (–delete → --delete, –bwlimit=100 → --bwlimit=100) - expect(splitOptions('–av –delete –bwlimit=100'), ['-av', '--delete', '--bwlimit=100']); + expect(splitOptions('\u2013av \u2013delete \u2013bwlimit=100'), ['-av', '--delete', '--bwlimit=100']); + }); + + test('15 확인 F1 (데이터): 닫히지 않은 따옴표는 뒤의 옵션을 삼키지 않고, 알려서 실행을 막는다 (굽은 · 곧은 모두)', () { + for (final q in ['\u2018', '\u201C', '"', "'"]) { + final o = '-a --delete --exclude=${q}a.tmp --exclude=keep.txt'; + expect(splitOptions(o), ['-a', '--delete', '--exclude=${q}a.tmp', '--exclude=keep.txt'], reason: q); + expect(unreadableOptions(o).single, contains('따옴표가 닫히지 않았습니다'), reason: q); + expect(RsyncFilter.parse(o).excluded('keep.txt', isDir: false), isTrue, reason: '뒤의 규칙이 살아 있음'); + } + // 닫힌 것은 문제 없음 + expect(unreadableOptions('-a --exclude="a b.txt" --exclude=\u2018c d\u2019'), isEmpty); + }); + + test('15 확인 F4 · F5: 값 자리의 대시는 그대로, 닫는 굽은 따옴표 뒤에 글이 이어져도 닫는다', () { + expect(splitOptions('--exclude \u2013x.txt -a'), ['--exclude', '\u2013x.txt', '-a']); + expect(splitOptions('-e \u2013x'), ['-e', '\u2013x']); + expect(splitOptions('--exclude=\u201Ca b\u201D.txt'), ['--exclude=a b.txt']); + expect(splitOptions('--exclude=\u2018a b\u2019.txt'), ['--exclude=a b.txt']); + // 긴 옵션 이름 (–no-perms · –dry-run) 은 --, 목록에 없는 것은 짧은 옵션 묶음 + expect(splitOptions('\u2013no-perms \u2013dry-run \u2013avh'), ['--no-perms', '--dry-run', '-avh']); }); test('고쳐 읽어도 알아볼 수 없는 옵션 (ASCII 밖 글자로 시작) 은 알린다 - 옵션의 값 자리는 세지 않음', () { - expect(unreadableOptions('-a «delete»'), ['«delete»']); - expect(unreadableOptions('-a …delete'), ['…delete']); + expect(unreadableOptions('-a \u00ABdelete\u00BB'), ['\u00ABdelete\u00BB']); + expect(unreadableOptions('-a \u2026delete'), ['\u2026delete']); expect(unreadableOptions('--exclude 한글.txt -a'), isEmpty, reason: '--exclude 의 값'); expect(unreadableOptions('-f 한글.txt'), isEmpty, reason: '-f 의 값'); expect(unreadableOptions('-a 한글'), ['한글'], reason: '옵션 자리 - rsync 는 원본 경로로 읽는다'); @@ -72,7 +99,7 @@ void main() { }); testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OptionProblemNote(['«delete»'])))); + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OptionProblemNote(['\u00ABdelete\u00BB'])))); expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget); });