import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart' show optionsDelete; import 'package:jj_mkvmaker/core/sync_tools.dart'; import 'package:jj_mkvmaker/ui/sync_preview_view.dart'; import 'package:path/path.dart' as p; import 'support/rsync_scenes.dart'; /// 관리자 지시 (10/20, 28 확인): 웹 · 문서에서 옵션 칸에 붙여 넣은 글 (굽은 따옴표 · 대시 · 보이지 않는 글자) 을 /// 곧은 글자로 쓴 것과 똑같이 읽는다. 예전에는 --exclude=‘*.log’ 가 아무것도 거르지 못해 받는 쪽 keep.log 가 지워졌고, /// –delete 는 원본 경로로 넘어가 --delete 가 몰래 빠졌다. 보이지 않는 글자는 \u 표기로 쓴다 void main() { // 28 의 13가지: (이름, 붙여 넣은 글, 곧은 글자로 쓴 것) const cases = <(String, String, String)>[ ('줄바꿈 없는 빈칸 (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\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('붙여 넣은 옵션 = 곧은 글자로 쓴 것', () { for (final (name, pasted, straight) in cases) { test(name, () { // 옵션 칸 (OptionTextFormatter) 을 거친 글도, 거치지 않은 글 (예전에 저장된 값) 도 final typed = const OptionTextFormatter() .formatEditUpdate(TextEditingValue.empty, TextEditingValue(text: pasted, selection: TextSelection.collapsed(offset: pasted.length))) .text; for (final s in [pasted, typed]) { expect(splitOptions(s), splitOptions(straight)); expect(unreadableOptions(s), isEmpty); expect(optionsDelete(s), optionsDelete(straight)); final f = RsyncFilter.parse(s), g = RsyncFilter.parse(straight); expect(f.excluded('keep.log', isDir: false), g.excluded('keep.log', isDir: false)); expect(f.excluded('a.txt', isDir: false), isFalse); } }); } }); test('낱말 가운데 · 이름 안은 그대로 (파일 이름일 수 있음)', () { // 이름 안의 굽은 작은따옴표 (it’s) · 대시 (a–b) 는 따옴표 · 옵션으로 읽지 않는다 expect(splitOptions('--exclude=it\u2019s.txt'), ['--exclude=it\u2019s.txt']); expect(splitOptions('--exclude=a\u2013b.txt'), ['--exclude=a\u2013b.txt']); // 굽은 따옴표 안의 굽은 따옴표 (뒤가 빈칸 · 끝이 아니면 닫지 않음) expect(splitOptions('--exclude=\u2018it\u2019s a.txt\u2019 -a'), ['--exclude=it\u2019s a.txt', '-a']); // 짧은 옵션 뒤 (-f“- *.log”) expect(splitOptions('-f\u201C- *.log\u201D'), ['-f- *.log']); // 대시 하나 + 짧은 글자는 - (–av → -av), 소문자 낱말이면 -- (–delete → --delete, –bwlimit=100 → --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 \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 는 원본 경로로 읽는다'); expect(unreadableOptions('-avPog --delete'), isEmpty); // 관리자 판단 (10/20, 28 확인): - 로 시작하지 않는 낱말 · 하이픈 셋 이상 · 끝에 값 없는 옵션 expect(unreadableOptions('-a delete'), ['경로로 읽힙니다: delete']); expect(unreadableOptions('-a - --delete'), ['경로로 읽힙니다: -']); expect(unreadableOptions('-a ---delete'), ['---delete']); expect(unreadableOptions('-a --exclude'), ['--exclude 의 값이 없습니다']); expect(unreadableOptions('-a -f'), ['-f 의 값이 없습니다']); expect(unreadableOptions('-a --exclude *.log --delete'), isEmpty, reason: '값이 있음'); expect(unreadableOptions('-a --exclude=*.log'), isEmpty); expect(unreadableOptions(''), isEmpty); // 28 확인 (10/20): rsync 의 짧은 별칭 (--cc · --zc · --zl) 도 값을 받는다 (오탐이던 것) expect(unreadableOptions('-a --cc md5 --zc zlib --zl 3 --zt 2'), isEmpty); // 곧은 하이픈과 대시가 섞인 것 (-\u2013delete) expect(splitOptions('-a -\u2013delete \u2013-del'), ['-a', '--delete', '--del']); expect(unreadableOptions('-a -\u2013delete'), isEmpty); }); test('28 지적 (10/20): 안내 끝 문구는 문제 종류에 맞게', () { String hint(String o) => optionProblemHint(optionProblems(o)); expect(hint('-a --exclude="a.tmp'), '따옴표를 닫아 주세요'); expect(hint('-a delete'), '옵션은 - 나 -- 로 시작합니다'); expect(hint('-a --exclude'), '값을 적어 주세요 (예: --exclude=*.tmp)'); expect(hint('-a ---delete'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'); expect(hint('-a \u00ABx\u00BB'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'); expect(hint('-a delete --exclude'), '옵션은 - 나 -- 로 시작합니다 · 값을 적어 주세요 (예: --exclude=*.tmp)'); expect(optionProblemMessage(optionProblems('-a delete')), '옵션을 알아볼 수 없습니다: 경로로 읽힙니다: delete - 옵션은 - 나 -- 로 시작합니다'); }); testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async { await tester.pumpWidget(MaterialApp(home: Scaffold(body: OptionProblemNote(optionProblems('-a \u00ABdelete\u00BB'))))); expect(find.textContaining('옵션을 알아볼 수 없습니다'), findsOneWidget); expect(find.textContaining('곧은 따옴표와 하이픈'), findsOneWidget); }); // 28 확인 (10/20): 막기 전 실제 rsync 는 이런 낱말을 원본 경로로 먹는다 - 그래서 [unreadableOptions] 로 막는다 test('실제 rsync: -a delete 의 delete · 끝의 --exclude 는 원본 경로로 먹혀 의도와 다르게 돈다', () async { final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 없음 (Windows · third_party)'); final tmp = Directory.systemTemp.createTempSync('jj_optpath_'); addTearDown(() => tmp.deleteSync(recursive: true)); final s = p.join(tmp.path, 's'), d = p.join(tmp.path, 'd'); Future run(String opts) { writeScene(s, d, srcFiles: const ['a.txt'], dstFiles: const ['old.txt']); return Process.run(rsync, [...splitOptions(opts), '${toCygwinPath(s)}/', '${toCygwinPath(d)}/'], workingDirectory: tmp.path); } // "delete" 를 원본으로 찾다 실패 (exit 23) - --delete 를 뜻했지만 old.txt 는 그대로 final r1 = await run('-r delete'); expect(r1.exitCode, isNot(0)); expect(File(p.join(d, 'old.txt')).existsSync(), isTrue); // --exclude 가 원본 폴더를 값으로 먹고 받는 쪽 하나만 남아 목록만 보이고 끝 (아무것도 보내지 않음) final r2 = await run('-r --exclude'); expect(r2.exitCode, 0); expect(File(p.join(d, 'a.txt')).existsSync(), isFalse, reason: '보내지 않음'); expect(unreadableOptions('-r delete'), isNotEmpty); expect(unreadableOptions('-r --exclude'), isNotEmpty); }); // 실제 rsync (Windows, 앱에 든 것): 붙여 넣은 글로 맞춘 결과 = 곧은 글자로 맞춘 결과 (28 의 장면: 받는 쪽 keep.log · old.txt) group('실제 rsync 결과가 같다', () { for (final (name, pasted, straight) in cases) { test(name, () async { final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 없음 (Windows · third_party)'); final tmp = Directory.systemTemp.createTempSync('jj_optpaste_'); addTearDown(() => tmp.deleteSync(recursive: true)); Future> run(String opts, String tag) async { final s = p.join(tmp.path, '${tag}s'), d = p.join(tmp.path, '${tag}d'); writeScene(s, d, srcFiles: const ['a.txt', 'b.log'], dstFiles: const ['old.txt', 'keep.log']); return realRsyncTree(rsync, s, d, opts); } final want = await run(straight, 'w'); expect(want.containsKey('keep.log'), isTrue, reason: '곧은 글자: *.log 는 거르므로 받는 쪽 keep.log 를 지키고'); expect(want.containsKey('old.txt'), isFalse, reason: '--delete 로 old.txt 는 지움'); expect(await run(pasted, 'g'), want); }); } }); }