Compare commits

...

16 Commits

Author SHA1 Message Date
RyuJongJin 1b3160939c 버전 2026.10.20_001
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:22:46 +09:00
RyuJongJin 5366bb3a14 원격 시험 흔들림: "주소에 비밀번호 없음" 이 무작위 표에 걸리던 것 · FTP = rsync 비교 실패 때 자세히 (관리자 지시 10/20, 28 이 찾음)
- 원인 (28 의 hwp2 전체 시험 원문): 실패한 것은 "FTP 맞추기 결과 = 실제 rsync 결과" 가 아니라 다음 시험 "FTP 동영상 … 다리로 재생".
  시험 비밀번호가 두 글자 "pw" 인데 다리 주소의 무작위 표 (…-4pwvUR-…) 에 우연히 들어가 isNot(contains('pw')) 가 실패
  (대략 1% 안팎). 비밀번호가 샌 것이 아님 - 앱은 고치지 않음
- ftp · sftp_stream · smb 시험: 길고 드문 비밀번호로 보고, 주소의 사용자 정보 (userinfo) 가 비었는지도 따로
- ftp_test "FTP 맞추기 결과 = 실제 rsync 결과": 실패하면 옵션 · 걸린 시간 · 포트 · 예외 · 다른 점과 보내는 쪽 · 맞추기 전 앱 쪽 ·
  rsync 쪽 · 앱 쪽 목록 (크기 · 바뀐 시각 ms) 을 한 번에. 맞추기가 예외를 던져도 어느 옵션인지 남김
- 확인: 혼자 12번 · 전체 단위 시험 (1143 통과) · 원격 5개 파일 -j 8 로 4번 모두 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:21:07 +09:00
RyuJongJin ab4c82d0b0 WIP 판 번호 검사 (관리자 지시 10/20, 15 제안): check_apk - versionCode = 판 이름 날짜 (261018) × 1000 + 순번, 시험판은 순번 >= 900 (987 을 --build-number 261018987 로 빌드해 522036987 이 통과하던 것) · check_windows_build - 시험판 번호의 날짜는 판 이름 날짜 (빌드한 날 아님, d3 판단: APK 와 같은 번호 · compareVersions 가 + 뒤 번호 전체를 견줘 261020985 가 261018987 보다 새것으로 보임) · 확인: 987 통과, 985 · 988 Windows (261020xxx) 실패, 정식 판 001 -Release 통과 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:48:48 +09:00
RyuJongJin 3d3ab5666f 통합 회귀 고침: 시험이 옛 글 · 정해진 시간 · 빗나간 누르기로 실패하던 것과 남긴 임시 파일 (관리자 지시 10/20)
- rsync_page: rsync_narrow (dfed3fa) 부터 옵션 이름은 줄바꿈되지 않게 묶여 그려져
  '원본 파일 지우기 (--remove-source-files)' 를 글 그대로 찾지 못함 → keepOptionsTogether 로 화면 글 그대로 찾기
- windows_b6_input (82 끌어 놓기 → [되돌리기]): 스낵바가 아래에서 올라오는 동안 (글은 이미 있음) 눌러 빗나감
  (hit test 에 앱 위젯이 하나도 없음) → 되돌리기가 시작되지 않아 이동 확인 창이 안 나옴.
  누르기 전에 정말 눌리는 자리에 올 때까지 기다림 (tapReady) - 실패하면 캡처 (JJ_SHOT_DIR) · 창 · 단추 · 글 목록.
  측정: [되돌리기] 는 보인 뒤 1 ~ 97ms 지나야 눌림. 985 (f947935) 의 같은 시험도 3번 중 2번 똑같이 실패 → rsync_narrow 이전부터,
  앱 쪽 바뀜 없음 (스낵바 · 되돌리기 코드는 985 와 같음). 사용자는 보이는 단추를 누르므로 앱은 고치지 않음
- windows_xplore_shot: 목록을 정해진 1.2초만 기다려 en 412 가 한 번 실패 → 'Show S01' 줄이 올 때까지 (최대 약 10초)
- video_adjust_e2e · windows_xplore_shot: 앱 임시 폴더 jj_mkvmaker 를 시험 폴더로 (appTempInto)
- shell: 끝까지 돌면 단계 기록 jj_shell_steps.txt 를 지움 (앱이 도중에 꺼졌을 때만 남음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:44 +09:00
RyuJongJin 44c69af83e 통합 시험 종료 코드: 다 통과했는데 0 이 아니던 것 (ai_e2e · browser_keep · dns_block_real) 고침 (관리자 지시 10/20)
- ai_e2e: 앱 임시 폴더 (jj_mkvmaker) 가 실행 폴더에 남아 exit 2 → 새 도우미 appTempInto 로 시험 폴더 아래에 두고
  시험 끝에 기준을 되돌린 뒤 함께 지움 (ai_cancel · ai_long 도 같게). 사용자 %TEMP%\jj_mkvmaker 는 건드리지 않음
- dns_block_real (· cef_dns_real · tor_dns_real): Edge (WebView2) 사용자 데이터 폴더는 heldByApp (jj_held_)
- tool/test_temp_check.ps1: 실행 폴더 지우기를 Edge 가 놓을 때까지 2초 간격 최대 30번 다시 해 봄,
  rd 오류는 cmd 안에서 버림 (PowerShell 의 2>$null 이 ErrorActionPreference Stop 에서 오류가 되어 exit 1 이었음).
  지우는 것은 그 실행 폴더 안뿐
- 확인: 다섯 파일 모두 -d windows · test_temp_check 로 exit 0, 남은 것 0, 실행 폴더 지워짐. analyze 문제 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:46:21 +09:00
RyuJongJin 9e40b485a9 rc 회귀 고침: 네트워크 공유 (UNC) 목록 · 기록 파일은 \\ 그대로 · 받는 쪽 UNC 도 \\ · 막히면 모니터링 [▶ 실행] 도 끔 · 표준 입력 (-) 문구 (검토 대기)
관리자 지시 (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 <noreply@anthropic.com>
2026-10-20 19:29:47 +09:00
RyuJongJin f43f067d4b 시험 흔들림: remote_idle_test - "조각이 계속 오면 실패하지 않음" 은 0.1초 간격 · 3초 제한, SFTP 의 준비 (연결 · 목록 · 열기) 는 10초 (검토 대기)
0b 지적 (10/20): next10 전체 시험 (10분 넘음) 의 부하에서 0.4초 간격 대 1초 제한이 한 번 넘어 "서버가 답하지 않습니다 (1초)" 로 실패 (혼자서는 통과) - 여유를 30배로. 15 검증 (wt_verify_d3): 시험 다섯 파일을 함께 돌릴 때 SFTP 멈춤 시험의 준비 단계 (아직 멈추지 않은 list) 가 paramiko 의 답이 늦어 1초에 걸림 - 준비는 10초로 두고 멈추게 하기 바로 전에 1초로. 끊기는 쪽 확인은 그대로 1초. 코드 변경 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:31:22 +09:00
RyuJongJin ed459a0229 rc: 목록 · 기록 파일의 C:/ · C:\ · UNC 를 rsync 가 여는 꼴로 넘김 · Windows 는 드라이브 경로만 · 막힐 때는 이유 한 줄만 · ---delete 문구 (검토 대기)
관리자 지시 (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 <noreply@anthropic.com>
2026-10-20 18:24:35 +09:00
RyuJongJin 86b8ddd603 rc_next: next9 f947935 (985) + rsync_narrow dfed3fa (좁은 Rsync 화면 · 옵션 이름 줄바꿈)
다음 정식 판 후보 (관리자 지시 10/20). 겹친 곳: copy_sync_settings.dart 의 sync_preview_view import (optionProblemText + keepOptionsTogether 둘 다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:42:20 +09:00
RyuJongJin dfed3fa6bb 좁은 Rsync 화면: [오른쪽] 탭 · 넓게 바꾼 두 창도 그 창의 지금 폴더가 보임 · 옵션 이름이 든 제목 · 체크가 "(--" 에서 갈리지 않음 (검토 대기)
관리자 지시 (10/20, 28 이 983 에서 찾음):
1. 412 Rsync 화면의 [오른쪽] 탭: 탭 이름은 지금 폴더인데 목록은 저장 장치 맨 위에서 시작했다 (넓게 바꿔도 그대로).
   원인: 켤 때 _goTo 가 두 창 모두 지금 폴더까지 펼치고 스크롤하는데, 좁은 화면은 왼쪽 창만 그려 오른쪽 창의 스크롤은 아직
   화면에 붙지 않아 건너뛰었다. 고침: 창의 목록이 화면에 새로 붙을 때마다 (탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때) 그 창의
   지금 폴더를 보이게 (_revealNewViews · _revealNow). 폭이 바뀌면 LayoutBuilder 만 다시 불리므로 그 안에서 부른다.
2. en 412 확인 창의 체크 제목 "Delete source files (--remove-source-files)" 가 "(--" 에서 갈렸다. keepOptionsTogether: 글 안의 옵션
   덩어리만 (괄호째 · 따로 쓴 --이름 · -u · /PURGE) unbreakableOption 으로 묶고 나머지는 그대로 줄바꿈. 옵션 이름이 든 다른 제목 ·
   체크 · 설명에도: 받는 쪽에서 n개가 지워집니다 (--delete …) · 거른 것 n개도 지워집니다 (--delete-excluded) 와 그 설명 ·
   ⇄ 의 (-u) · 받는 쪽이 더 새것이면 건너뛰기 (-u · /XO) · 원본에 없는 것을 대상에서 지우기 (--delete · /PURGE).
시험: explorer_parallel_test (28 장면: 맨 위에 폴더 80개 · 지금 폴더는 깊은 곳 - 왼쪽 · [오른쪽] 탭 · 넓게 · 다시 좁게 모두 지금 폴더가
보임, 고치기 전에는 [오른쪽] 탭에서 실패) · sync_preview_view_test (keepOptionsTogether). 이 글을 찾는 시험 5개는 같은 도우미를 거친
글로. 캡처 windows_rsync_narrow_shot_test ([오른쪽] 탭 · 확인 창, ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_rsync_narrow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:39:07 +09:00
RyuJongJin f947935c19 next9: + msys_args 끝 (634fcbc · 849c830: 목록 · 기록 · 배치 파일의 상대 경로 막기) - 시험판 985
합침 (d3): 상대 경로 막기를 optionProblems 의 새 종류 relativePath 로 (안내 끝 문구 "드라이브 (C:) 나 / 로 시작하는 경로로", 내 판단).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:14:23 +09:00
RyuJongJin 849c83031d 기록 · 배치 파일 (--log-file · --write-batch · --read-batch · --only-write-batch) 의 상대 경로도 막음 (검토 대기)
관리자 지시 (10/20, 28 확인): 상대 경로면 응답 파일 임시 폴더에 생겼다가 함께 지워져 사용자가 기록을 찾지 못함 - "기록 · 배치 파일은 전체 경로로 적어 주세요". --backup-dir · --partial-dir · --temp-dir · --compare-dest 류는 받는 쪽 기준이라 그대로 (시험에 둠). 번역 en · ja · zh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:10:55 +09:00
RyuJongJin 634fcbc920 목록 파일 (--exclude-from · --include-from · --files-from · ". 파일" · merge) 의 상대 경로는 막음 - "목록 파일은 전체 경로로 적어 주세요" (검토 대기)
관리자 판단 (10/20, 7160577 에 이어): rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의 작업 폴더
기준으로 상대 경로를 읽어 서로 다른 목록 파일을 볼 수 있다. unreadableOptions 가 막는다 (옵션 칸 · 확인 창 · 모니터링 빨갛게, 실행 ·
실시간 동기화 막음).
- 대상: --exclude-from · --include-from · --files-from (= 와 다음 낱말), --filter / -f (붙여 · 다음 낱말 · 짧은 묶음 -avf) 의
  ". 파일" · "merge[,고치개] 파일". 전체 경로 = C:\ · C:/ · \서버 · / (/cygdrive/… · Android).
- 빼는 것: ": 이름" · dir-merge · -F (폴더마다 읽는 규칙 파일 이름 - 원래 상대), --exclude 의 값 (패턴).
시험: rsync_msys_args_test (막는 것 10 · 막지 않는 것 11). dismiss_test · live_sync_scene_test 의 merge 규칙을 전체 경로로.
거르기 · 맞추기 · 실시간 · 복사 시험 25개 파일 382개 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:03:29 +09:00
RyuJongJin bd0801ce66 next9: + msys_args (7160577: Windows rsync 인수를 응답 파일로 · MSYS=noglob - ' 가 인수를 합쳐 거르기가 사라지던 것) - 시험판 985
합침 (d3): unreadableOptions 는 optionProblems 를 거친다 (b1a43c3 의 종류별 안내). 15 의 windows 매개변수 · 큰따옴표 막기를
optionProblems 로 옮기고, 안내는 새 종류 doubleQuote "큰따옴표 (\") 를 빼 주세요" (내 판단).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:59:12 +09:00
RyuJongJin 716057734a P0 Windows rsync: 인수를 응답 파일 (@) 로 넘기고 MSYS=noglob - ' 짝짓기 · 글로브 펼치기로 거르기가 사라져 지킬 파일이 지워지던 것 (검토 대기)
관리자 지시 (10/20, 28 재현 - 데이터, 정식 판에도 있음): Windows 의 rsync (MSYS2 런타임, 3.5.1) 는 Windows 프로그램이 넘긴
명령 줄을 스스로 다시 읽는다. 앱의 인수는 맞는데:
- ' 를 따옴표로 짝지어 인수를 합침: -a --delete --exclude="it's.txt" --exclude="Bob's.txt" 가 한 인수가 되어 거르기가 사라지고
  it's.txt · Bob's.txt 가 지워짐 (미리 보기는 "남음"). ' 하나면 rsync 가 실패, ' 가 든 경로 (한쪽 · 양쪽) 는 합쳐져 실패.
- * ? [ ] { } 를 작업 폴더의 파일로 펼침: --exclude *.log 가 작업 폴더의 x.log y.log 로 바뀌어 거르기가 빗나감.
실험 (실제 rsync): MSYS=noglob 은 글로브만 막고 ' 짝짓기는 그대로 (CYGWIN=noglob 은 소용없음). Dart 는 빈칸 · 큰따옴표가 없는
인수를 따옴표로 감싸지 않고, 감쌀 때의 \" 를 MSYS 는 다르게 읽어 명령 줄로는 고칠 수 없음. 응답 파일에 인수마다 큰따옴표로 감싸
넣고 (안의 \ 는 그대로) "@args.txt" 로 넘기면 ' · * ? [ ] { } · 빈칸 · 한글 · \ · @ 가 모두 그대로 감. 응답 파일 경로에 빈칸 · ' 가
있으면 @ 가 먹히지 않아 그 폴더를 작업 폴더로 하고 상대 이름으로 넘김.
- lib/core/rsync_launch.dart: prepareRsyncLaunch (Windows 만 - Android 는 그대로) · 끝나면 지움. 기존 MSYS 값에 noglob 을 더함.
- 띄우는 곳 둘 다: TransferJob (복사 · Rsync 화면 · 모니터링 [실행] · 최종 정리 - robocopy 는 그대로) · LiveSync (실시간 동기화).
  미리 보기는 rsync 를 쓰지 않는다.
- 큰따옴표 (") 가 든 인수는 응답 파일로도 그대로 못 넘김 (Windows 파일 이름에는 없음): 옵션 칸 · 확인 창 · 모니터링에 빨갛게
  (unreadableOptions, Windows 만) · 띄울 때도 막고 이유를 보임.
- 알아 둘 것: rsync 의 작업 폴더가 응답 파일 폴더가 되어, 옵션의 상대 경로 (--exclude-from=목록.txt) 는 그 폴더 기준 (예전에도
  앱의 작업 폴더라 정해지지 않았음 - 절대 경로를 쓰는 것이 맞음).
시험 rsync_msys_args_test (실제 rsync): ' 두 개 · 하나의 --exclude, ' 가 든 원본 경로 · 원본 · 대상 경로 · 경로 + exclude, 따로 쓴 *
· ? · [ ] · { }, [ ] · { } · 빈칸 · 한글 경로, 실시간 동기화 쌍, 응답 파일 지우기, 도우미 · 큰따옴표 막기. 고치기 전에는 7개 실패.
거르기 · 맞추기 · 실시간 · 복사 시험 25개 파일 · rsync_page_test (Windows 통합) 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:52:26 +09:00
RyuJongJin b1a43c36c5 WIP 옵션 칸 보충 (관리자 지시 10/20, 28 지적): --cc · --zc · --zl · --zt 등 값 받는 별칭 (오탐) · -–delete 처럼 하이픈과 대시가 섞인 것도 고침 · 안내 끝 문구를 문제 종류에 맞게 (따옴표 닫기 · - 로 시작 · 값 적기 · 곧은 글자로) · 옵션 낱말은 줄바꿈 없이 · 캡처 시험 windows_option_problem_shot_test (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:46:57 +09:00
43 changed files with 1184 additions and 101 deletions

View File

@ -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",
@ -657,6 +659,7 @@
"감지한 언어: {0} (자동 감지)": "Detected language: {0} (auto-detect)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "Detected language: {0} - if it is wrong, make them again with another language",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "Add a value (e.g. --exclude=*.tmp)",
"강하게 (0.4)": "Strong (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original",
"같습니다 ✔": "Match ✔",
@ -751,6 +754,7 @@
"고치기": "Edit",
"고친 옵션을 저장하지 않았습니다": "The edited options aren't saved",
"고화질": "High quality",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "Retype with straight quotes and hyphens (-)",
"골라서 시작": "Choose and start",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)",
@ -820,6 +824,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.",
"기능 버튼 줄": "Button bar",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "Please write the full path of the log or batch file: {0}",
"기본": "Default",
"기본 DNS": "Primary DNS",
"기본 앱으로 열기": "Open with default app",
@ -1131,6 +1136,7 @@
"등록됨: \"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}",
"따옴표를 닫아 주세요": "Close the quote",
"또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)": "Or scan the QR code with the other device's camera (it includes the code)",
"라오어": "Lao",
"라이선스 원문 보기": "View full license text",
@ -1238,6 +1244,8 @@
"목록": "list",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "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",
@ -1892,8 +1900,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).",
"옮길까요?": "move?",
"옵션": "Options",
"옵션을 알아볼 수 없습니다: {0}": "Unrecognized option: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "Unrecognized option: {0} - retype it with straight quotes and hyphens (-)",
"옵션은 - 나 -- 로 시작합니다": "Options start with - or --",
"옵션을 알아볼 수 없습니다: {0} - {1}": "Unrecognized option: {0} - {1}",
"옵션을 저장했습니다": "Options saved",
"완료": "Done",
"완료 시 자동 동영상 추가": "Auto-add videos when done",
@ -2598,6 +2606,9 @@
"큰 파일": "Large files",
"큰 파일이 없습니다": "No large files",
"큰 폴더": "Large folders",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "Options containing a double quote (\") cannot be passed to rsync on Windows: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "Arguments containing a double quote (\") cannot be passed as-is to rsync on Windows, so it was not run: {0}",
"큰따옴표 (\") 를 빼 주세요": "Remove the double quote (\")",
"키 있음": "Key saved",
"타갈로그어": "Tagalog",
"타밀어": "Tamil",
@ -2721,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",

View File

@ -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 から取り込む",
@ -657,6 +659,7 @@
"감지한 언어: {0} (자동 감지)": "検出した言語: {0} (自動検出)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "検出した言語: {0} - 違っていれば別の言語で作り直してください",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "値を入力してください (例: --exclude=*.tmp)",
"강하게 (0.4)": "強め (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります",
"같습니다 ✔": "一致します ✔",
@ -751,6 +754,7 @@
"고치기": "編集",
"고친 옵션을 저장하지 않았습니다": "編集したオプションが保存されていません",
"고화질": "高画質",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "半角の引用符とハイフン (-) で入力し直してください",
"골라서 시작": "選んで開始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)",
@ -820,6 +824,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。",
"기능 버튼 줄": "機能ボタンの列",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "ログ・バッチファイルはフルパスで書いてください: {0}",
"기본": "既定",
"기본 DNS": "優先 DNS",
"기본 앱으로 열기": "既定のアプリで開く",
@ -1131,6 +1136,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 コードを読み取ってください (コード入り)",
"라오어": "ラオ語",
"라이선스 원문 보기": "ライセンス原文を見る",
@ -1238,6 +1244,8 @@
"목록": "一覧",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 보기": "リストを見る",
"목록 파일은 전체 경로로 적어 주세요: {0}": "リストファイルはフルパスで書いてください: {0}",
"목록 파일의 전체 경로를 적어 주세요": "リストファイルのフルパスを書いてください",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます",
"목록에서 빼기": "リストから外す",
@ -1892,8 +1900,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。",
"옮길까요?": "移動しますか?",
"옵션": "オプション",
"옵션을 알아볼 수 없습니다: {0}": "オプションを認識できません: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "オプションを認識できません: {0} - 半角の引用符とハイフン (-) で入力し直してください",
"옵션은 - 나 -- 로 시작합니다": "オプションは - か -- で始まります",
"옵션을 알아볼 수 없습니다: {0} - {1}": "オプションを認識できません: {0} - {1}",
"옵션을 저장했습니다": "オプションを保存しました",
"완료": "完了",
"완료 시 자동 동영상 추가": "完了時に動画を自動追加",
@ -2598,6 +2606,9 @@
"큰 파일": "大きいファイル",
"큰 파일이 없습니다": "大きいファイルはありません",
"큰 폴더": "大きいフォルダー",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "ダブルクォート (\") を含むオプションは Windows の rsync に渡せません: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "ダブルクォート (\") を含む引数は Windows の rsync にそのまま渡せないため、実行しませんでした: {0}",
"큰따옴표 (\") 를 빼 주세요": "二重引用符 (\") を外してください",
"키 있음": "キーあり",
"타갈로그어": "タガログ語",
"타밀어": "タミル語",
@ -2721,6 +2732,7 @@
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "表示名はその場で変更できます。動画の行を 3回押すと最初のボタンのフォルダーに移動します",
"표시 지우기": "マークを解除",
"표준": "標準",
"표준 입력 (-) 은 쓸 수 없습니다": "標準入力 (-) は使えません",
"푸는 중": "展開中",
"푸는 중: {0}": "展開中: {0}",
"풀 수 없는 이름 {0}개": "展開できない名前 {0} 件",

View File

@ -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 导入",
@ -657,6 +659,7 @@
"감지한 언어: {0} (자동 감지)": "检测到的语言:{0}(自动检测)",
"감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요": "检测到的语言:{0} - 如有误,请用其他语言重新生成",
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
"값을 적어 주세요 (예: --exclude=*.tmp)": "请填写值 (例: --exclude=*.tmp)",
"강하게 (0.4)": "强 (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同",
"같습니다 ✔": "相同 ✔",
@ -751,6 +754,7 @@
"고치기": "编辑",
"고친 옵션을 저장하지 않았습니다": "修改的选项尚未保存",
"고화질": "高画质",
"곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "请用直引号和连字符 (-) 重新输入",
"골라서 시작": "选择后开始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)",
@ -820,6 +824,7 @@
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。",
"기능 버튼 줄": "功能按钮栏",
"기록 · 배치 파일은 전체 경로로 적어 주세요: {0}": "请填写日志或批处理文件的完整路径: {0}",
"기본": "默认",
"기본 DNS": "首选 DNS",
"기본 앱으로 열기": "用默认应用打开",
@ -1131,6 +1136,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 을 찍으세요 (코드가 들어 있습니다)": "或用另一台设备的相机扫描二维码 (已包含代码)",
"라오어": "老挝语",
"라이선스 원문 보기": "查看许可证原文",
@ -1238,6 +1244,8 @@
"목록": "列表",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 보기": "查看列表",
"목록 파일은 전체 경로로 적어 주세요: {0}": "请填写列表文件的完整路径: {0}",
"목록 파일의 전체 경로를 적어 주세요": "请填写列表文件的完整路径",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情",
"목록에서 빼기": "从列表中移除",
@ -1892,8 +1900,8 @@
"옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。",
"옮길까요?": "移动吗?",
"옵션": "选项",
"옵션을 알아볼 수 없습니다: {0}": "无法识别的选项: {0}",
"옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요": "无法识别的选项: {0} - 请用直引号和连字符 (-) 重新输入",
"옵션은 - 나 -- 로 시작합니다": "选项以 - 或 -- 开头",
"옵션을 알아볼 수 없습니다: {0} - {1}": "无法识别的选项: {0} - {1}",
"옵션을 저장했습니다": "已保存选项",
"완료": "完成",
"완료 시 자동 동영상 추가": "完成后自动添加视频",
@ -2598,6 +2606,9 @@
"큰 파일": "大文件",
"큰 파일이 없습니다": "没有大文件",
"큰 폴더": "大文件夹",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "含有双引号 (\") 的选项无法传给 Windows 上的 rsync: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "含有双引号 (\") 的参数无法原样传给 Windows 上的 rsync,因此未执行: {0}",
"큰따옴표 (\") 를 빼 주세요": "请去掉双引号 (\")",
"키 있음": "已有密钥",
"타갈로그어": "他加禄语",
"타밀어": "泰米尔语",
@ -2721,6 +2732,7 @@
"표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다": "显示名称可直接修改。连续点击视频行三次会移到第一个按钮的文件夹",
"표시 지우기": "清除标记",
"표준": "标准",
"표준 입력 (-) 은 쓸 수 없습니다": "不能使用标准输入 (-)",
"푸는 중": "正在解压",
"푸는 중: {0}": "正在解压: {0}",
"풀 수 없는 이름 {0}개": "{0} 个无法解压的名称",

View File

@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/core/languages.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'support/temp_dir.dart';
/// 긴 영상 (JJ_LONG_VIDEO) 의 음성인식 도중 [작업 취소] → 몇 초 안에 멈춘다 (끝까지 기다리지 않음)
void main() {
@ -15,6 +16,7 @@ void main() {
testWidgets('음성인식 중 취소', (tester) async {
final video = Platform.environment['JJ_LONG_VIDEO'];
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
final c = AppController(PlatformServices.create());
await tester.runAsync(() async {
await c.init();

View File

@ -18,6 +18,7 @@ void main() {
testWidgets('영어 음성 영상 → AI 자막(ko/en/ja) → MKV', (tester) async {
final dir = testTempDir('jj_e2e_');
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final video = p.join(dir.path, '영어 강의.mp4');
final speech = p.join(Directory.current.path, 'test_assets', 'speech_en.wav');
final r = await Process.run('ffmpeg', [

View File

@ -8,6 +8,7 @@ import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/services/model_store.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'support/temp_dir.dart';
/// 긴 실제 영상 (JJ_LONG_VIDEO) 으로 AI 자막: 원어 자동 → [JJ_LONG_TARGETS] (기본 ja,en).
/// 번역이 제대로 됐는지 줄 단위로 센다 (영어 자막에 일본어가 남은 줄 · 빈 줄 · 같은 말 되풀이).
@ -18,6 +19,7 @@ void main() {
final video = Platform.environment['JJ_LONG_VIDEO'];
if (video == null || !File(video).existsSync()) return markTestSkipped('JJ_LONG_VIDEO 없음');
final targets = (Platform.environment['JJ_LONG_TARGETS'] ?? 'ja,en').split(',');
appTempInto(testTempDir('jj_aitmp_')); // 앱 임시 폴더 (jj_mkvmaker) 를 시험 폴더로 - 끝에 지움 (관리자 지시 10/20)
final c = AppController(PlatformServices.create());
final sw = Stopwatch()..start();
await tester.runAsync(() async {

View File

@ -34,7 +34,7 @@ void main() {
}
CefRuntime.rememberStartState();
expect(CefRuntime.readyThisRun, isTrue);
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_', heldByApp: true).path;
Directory(data).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings

View File

@ -21,7 +21,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_', heldByApp: true).path;
Directory(data).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings

View File

@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
@ -227,7 +228,8 @@ void main() {
await tap(find.text('srcA').first);
await tap(find.text('dstB').last);
await tap(find.text('좌 → 우'));
await tap(find.text('원본 파일 지우기 (--remove-source-files)'));
// 옵션 이름은 줄바꿈되지 않게 묶여 그려진다 (rsync_narrow dfed3fa) - 화면에 그려지는 글 그대로 찾는다
await tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await tap(find.text('빈 폴더 지움 · 원본 폴더도 지움'));
await shot('rsync_move_confirm');
await tap(find.widgetWithText(FilledButton, '이동'));

View File

@ -14,6 +14,10 @@ void main() {
final steps = File(p.join(Directory.systemTemp.path, 'jj_shell_steps.txt'));
if (steps.existsSync()) steps.deleteSync();
void mark(String s) => steps.writeAsStringSync('$s\n', mode: FileMode.append, flush: true);
// 관리자 지시 (10/20): 끝까지 돌면 단계 기록을 지운다 (앱이 도중에 꺼졌을 때만 남아 어디까지 갔는지 보인다)
tearDownAll(() {
if (steps.existsSync()) steps.deleteSync();
});
testWidgets('DesktopShell', (tester) async {
mark('start');

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
/// 관리자 지시 (10/19, 28 이 찾음): 통합 시험 · 캡처가 %TEMP%\jj_* 시험 폴더를 남겼다 - 시험 끝 (실패해도) 에 지운다.
/// [base] 가 있으면 그 아래 (짧은 경로가 필요한 캡처). 반드시 시험 (testWidgets · test) 안에서 부른다 (addTearDown).
@ -14,6 +15,15 @@ Directory testTempDir(String prefix, {String? base, bool heldByApp = false}) {
return dir;
}
/// 관리자 지시 (10/20): 앱 임시 폴더 ([DesktopStorageService.tempDirectory] = %TEMP%\jj_mkvmaker) 를 이 시험 폴더 아래로 돌린다
/// (ai_e2e 가 남겨 test_temp_check 가 exit 2). 시험 끝에 기준을 되돌리고, 폴더는 [testTempDir] 의 정리가 함께 지운다
/// (addTearDown 은 나중에 넣은 것부터 돌아 앱 기준을 먼저 되돌린다). 사용자 %TEMP%\jj_mkvmaker 는 건드리지 않는다
void appTempInto(Directory dir) {
final before = DesktopStorageService.tempRoot;
DesktopStorageService.tempRoot = dir.path;
addTearDown(() => DesktopStorageService.tempRoot = before);
}
/// 관리자 지시 (10/20): tool/test_temp_check.ps1 이 TEMP · TMP 를 실행 폴더 (%TEMP%\t_jjrun_<표>) 로 돌리고 표를 JJ_TEST_TMP_TAG 로 넘긴다.
/// 표가 있는데 임시 폴더가 그 밖에 생기면 (환경 변수가 시험 · 앱까지 오지 않음) 다른 세션 시험과 섞이므로 시험을 실패시킨다
void expectInTaggedTemp(Directory dir) {

View File

@ -27,7 +27,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_', heldByApp: true).path;
Directory(data).createSync(recursive: true);
ComponentStore.dataDirectory = data;
ComponentStore.shared = ComponentStore(data);

View File

@ -21,6 +21,7 @@ void main() {
testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async {
final dir = testTempDir('jj_adj_e2e_');
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final video = p.join(dir.path, '가로 영상.mp4');
final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe');
final r = await Process.run(ffmpeg, [

View File

@ -1,7 +1,9 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -24,6 +26,7 @@ void main() {
late Directory tmp;
late String left, right;
late AppController c;
final shotKey = GlobalKey();
setUp(() async {
await i18n.apply('ko', save: false);
@ -70,7 +73,11 @@ void main() {
t.view.physicalSize = const Size(1600, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.pumpWidget(MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)));
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
home: ExplorerPage(c: c)));
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty && find.text('r1').evaluate().isNotEmpty);
expect(find.text('doc.txt'), findsOneWidget);
}
@ -104,6 +111,61 @@ void main() {
int feedback() => find.byIcon(Icons.drive_file_move_outline).evaluate().length;
/// 관리자 지시 (10/20): 실패 때의 화면 캡처 (JJ_SHOT_DIR) 와 그때 열린 창 · 단추 · 글 목록
Future<void> dump(WidgetTester t, String what) async {
final texts = [for (final e in find.byType(Text).evaluate()) (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? ''];
final buttons = [
for (final e in find.byWidgetPredicate((w) => w is ButtonStyleButton || w is SnackBarAction).evaluate())
'${e.widget.runtimeType}: ${[for (final x in find.descendant(of: find.byWidget(e.widget), matching: find.byType(Text)).evaluate()) (x.widget as Text).data].join(' ')}'
];
// ignore: avoid_print
print('== $what 실패 때: 창 ${find.byType(Dialog).evaluate().length} · 스낵바 ${find.byType(SnackBar).evaluate().length}\n단추: $buttons\n글: $texts');
final dir = Platform.environment['JJ_SHOT_DIR'];
final b = shotKey.currentContext?.findRenderObject();
if (dir == null || dir.isEmpty || b is! RenderRepaintBoundary) return;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
File(p.join(dir, 'b6_input_fail_${what.hashCode.toRadixString(16)}.png'))
..createSync(recursive: true)
..writeAsBytesSync(bytes!.buffer.asUint8List());
}
/// 관리자 지시 (10/20): [f] 가 보이는 것만이 아니라 정말 눌리는 자리에 올 때까지 기다린 뒤 누른다.
/// 스낵바는 아래에서 올라오는 동안 (약 0.25초) 글은 이미 있지만 그 자리를 누르면 아무것도 맞지 않는다 - 예전에는 그때 눌러 [되돌리기] 가 빗나갔다.
/// 끝내 눌리지 않으면 캡처 · 목록을 남기고 실패
Future<void> tapReady(WidgetTester t, Finder f, String what, {int ms = 8000}) async {
final sw = Stopwatch()..start();
int? seen;
bool within(Object? target, RenderObject box) {
for (RenderObject? x = target is RenderObject ? target : null; x != null; x = x.parent) {
if (identical(x, box)) return true;
}
return false;
}
for (var i = 0; i < ms ~/ 50; i++) {
final e = f.evaluate();
if (e.length == 1) {
seen ??= sw.elapsedMilliseconds;
final box = e.single.renderObject;
if (box is RenderBox && box.attached && box.hasSize) {
final at = box.localToGlobal(box.size.center(Offset.zero));
final hit = HitTestResult();
t.binding.hitTestInView(hit, at, t.view.viewId);
if (hit.path.any((x) => within(x.target, box))) {
// ignore: avoid_print
print('누를 수 있음: $what - 보인 뒤 ${sw.elapsedMilliseconds - seen}ms');
await t.tapAt(at);
return;
}
}
}
await t.pump(const Duration(milliseconds: 50));
}
await dump(t, what);
fail('$what 을 누를 수 없음 (보인 때: ${seen == null ? '안 보임' : '${seen}ms'})');
}
testWidgets('82: 8px 문턱 · 놓을 곳 강조 · 같은 창은 빨간 테두리 · 묻기 창 [이동] · [되돌리기]', (t) async {
await open(t);
final doc = t.getCenter(find.text('doc.txt'));
@ -141,17 +203,15 @@ void main() {
await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty);
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget, reason: '처음 고른 단추는 [복사]');
await t.tap(find.widgetWithText(OutlinedButton, '이동'));
await tapReady(t, find.widgetWithText(OutlinedButton, '이동'), '묻기 창 [이동]');
// 지금의 이동 확인 창
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
await t.tap(find.widgetWithText(FilledButton, '이동'));
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '이동 확인 창 [이동]');
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
// 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창)
await t.tap(find.text('되돌리기'));
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
await t.tap(find.widgetWithText(FilledButton, '이동'));
await tapReady(t, find.text('되돌리기'), '[되돌리기]');
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '되돌리기의 이동 확인 창 [이동]');
await until(t, () => File(p.join(left, 'doc.txt')).existsSync() && !File(p.join(right, 'doc.txt')).existsSync());
expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello');
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);

View File

@ -0,0 +1,79 @@
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/i18n_controller.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 (10/20, 28 지적): 알아볼 수 없는 옵션 안내 - 끝 문구가 문제 종류에 맞는지, 412 에서 넘치지 않는지.
/// 확인 창의 빨간 글 (OptionProblemNote) 과 설정 칸 아래 글 (errorText, 3줄까지) 을 크기 412 · 960, 언어 ko · en · ja 로 찍는다.
/// 실행: flutter test integration_test\windows_option_problem_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_optproblem');
const cases = [
'-a --delete --exclude="a.tmp --exclude=keep.txt', // 닫히지 않은 따옴표
'-a delete', // 경로로 읽힘
'-a --delete --exclude', // 값 없음
'-a ---delete «x»', // 이상한 글자
'-av delete --exclude', // 여러 종류
];
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, hh) in [(412, 900), (960, 600)]) {
testWidgets('옵션 안내 $lang ${w}x$hh', (t) async {
t.view.physicalSize = Size(w.toDouble(), hh.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: ListView(padding: const EdgeInsets.all(16), children: [
for (final o in cases) ...[
Text(o, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
OptionProblemNote(optionProblems(o)),
const SizedBox(height: 8),
],
// 설정 칸 (copy_sync_settings 의 rsync 옵션 칸과 같은 폭 · 글 줄 수)
SizedBox(
width: 380,
child: TextField(
controller: TextEditingController(text: cases.last),
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
errorText: optionProblemText(optionProblems(cases.last)),
errorMaxLines: 3,
),
),
),
]),
),
),
));
for (var i = 0; i < 4; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump(const Duration(milliseconds: 60));
}
expect(t.takeException(), isNull, reason: '넘침');
expect(find.byType(OptionProblemNote), findsNWidgets(cases.length));
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, 'optproblem_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
});
}
}
}

View File

@ -0,0 +1,98 @@
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/l10n/tr.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/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 그 창의 지금 폴더가 보이는지 · 확인 창의 체크 제목
/// "(--remove-source-files)" 가 "(--" 에서 갈리지 않는지를 412 · 960, ko · en · ja 로 찍는다 (시험 폴더 안에서만, [취소]).
/// 실행: flutter test integration_test\windows_rsync_narrow_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_rsync_narrow');
for (final lang in ['ko', 'en', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('좁은 Rsync 화면 $lang ${w}x$h', (t) async {
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_rsnarrow_shot_');
// 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳
for (var i = 0; i < 60; i++) {
Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync();
}
final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R');
File(p.join(l, 'sub', 'a.txt'))
..createSync(recursive: true)
..writeAsStringSync('a');
Directory(p.join(r, 'rsub')).createSync(recursive: true);
for (var i = 0; i < 20; i++) {
Directory(p.join(tmp.path, 'zz${i.toString().padLeft(2, '0')}')).createSync();
}
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..rsyncPaths = [l, r];
final shotKey = GlobalKey();
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
home: ExplorerPage(c: c, rsync: true),
));
Future<void> settle([int n = 10]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump(const Duration(milliseconds: 60));
}
}
Future<void> shot(String name) async {
await settle(3);
expect(t.takeException(), isNull, reason: '$name 넘침');
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, 'rsync_narrow_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
await settle(20);
final narrow = find.text('${tr('오른쪽')} · R').evaluate().isNotEmpty;
await t.tap(find.text('sub').hitTestable());
await settle();
if (narrow) {
await t.tap(find.text('${tr('오른쪽')} · R'));
await settle(15);
expect(find.text('rsub').hitTestable(), findsOneWidget, reason: '[오른쪽] 탭을 처음 보면 지금 폴더');
await shot('1_right_tab');
}
await t.tap(find.text('rsub').hitTestable());
await settle();
await t.tap(find.text(tr('좌 → 우')).first);
await settle(25);
await shot('2_confirm');
await t.tap(find.text(tr('취소')).last);
await settle();
expect(File(p.join(l, 'sub', 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 실행하지 않음');
});
}
}
}

View File

@ -33,6 +33,7 @@ void main() {
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_xpshot_');
appTempInto(tmp); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
for (final n in ['Show S01', 'Movies']) {
@ -74,6 +75,13 @@ void main() {
}
await settle(20);
// 관리자 지시 (10/20): 목록은 따로 읽혀 늦게 올 수 있다 (회귀에서 en 412 가 1.2초 안에 못 받아 실패) - 정해진 시간이 아니라 그 줄이 올 때까지 (최대 약 10초)
final load = Stopwatch()..start();
for (var i = 0; i < 160 && find.text('Show S01').evaluate().isEmpty; i++) {
await settle(1);
}
// ignore: avoid_print
print('목록 받음: $lang $w - 1.2초 뒤 ${load.elapsedMilliseconds}ms 더');
// Show S01 펼치기 · 즐겨찾기 펼치기
await t.tap(find.text('Show S01').first);
await settle();

View File

@ -17,6 +17,7 @@ import '../platform/windows/rsync_installer.dart';
import 'app_controller.dart';
import 'settings.dart';
import '../core/rsync_filter.dart';
import '../core/rsync_launch.dart';
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다.
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
@ -511,13 +512,13 @@ class LiveSync extends ChangeNotifier {
}
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션 (있으면 맞추지 않는다 - 카드에 빨갛게)
List<String> unreadableOf(LiveSyncPair x) => unreadableOptions(_optionsOf(x));
List<OptionProblem> unreadableOf(LiveSyncPair x) => optionProblems(_optionsOf(x));
Future<void> syncNow(LiveSyncPair x) async {
final k = keyOf(x);
final bad = unreadableOf(x);
final nest = bad.isNotEmpty
? trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')])
? optionProblemMessage(bad)
: nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter);
if (nest != null) {
status[k] = (DateTime.now(), nest);
@ -630,7 +631,15 @@ class LiveSync extends ChangeNotifier {
if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)'));
final args = rsyncArgsFor(x, s, windows: Platform.isWindows);
await Directory(x.target).create(recursive: true);
final r = await Process.run(exe, args, stdoutEncoding: utf8, stderrEncoding: utf8);
// P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch])
final launch = await prepareRsyncLaunch(args);
final ProcessResult r;
try {
r = await Process.run(exe, launch.args,
workingDirectory: launch.workingDirectory, environment: launch.environment, stdoutEncoding: utf8, stderrEncoding: utf8);
} finally {
launch.dispose();
}
if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode);
return RsyncOutput().feed('${r.stdout}\n').length;
}
@ -639,8 +648,11 @@ class LiveSync extends ChangeNotifier {
/// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97)
static List<String> 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',
@ -649,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

View File

@ -11,6 +11,7 @@ import '../core/sync_tools.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import '../core/rsync_filter.dart';
import '../core/rsync_launch.dart';
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
@ -270,8 +271,8 @@ class TransferJob extends ChangeNotifier {
try {
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 실행하지 않는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
if (method == CopyMethod.rsync) {
final bad = unreadableOptions(options);
if (bad.isNotEmpty) throw StateError(trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')]));
final bad = optionProblems(options);
if (bad.isNotEmpty) throw StateError(optionProblemMessage(bad));
}
for (var i = 0; i < sources.length; i++) {
final s = sources[i];
@ -401,8 +402,20 @@ class TransferJob extends ChangeNotifier {
if (log.length > 20) log.removeAt(0);
}
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut) async {
_proc = await Process.start(exe, args, runInShell: false);
/// [rsync]: Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 띄운다 (P0 10/20 - ' · * 를 런타임이 다시 읽지 않게, [prepareRsyncLaunch]).
/// robocopy 는 Windows 프로그램이라 그대로
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut, {bool rsync = false}) async {
final launch = rsync ? await prepareRsyncLaunch(args) : null;
try {
return await _startRaw(exe, launch?.args ?? args, enc, onOut, workingDirectory: launch?.workingDirectory, environment: launch?.environment);
} finally {
launch?.dispose();
}
}
Future<int> _startRaw(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut,
{String? workingDirectory, Map<String, String>? environment}) async {
_proc = await Process.start(exe, args, runInShell: false, workingDirectory: workingDirectory, environment: environment);
final a = _proc!.stdout.transform(enc.decoder).listen(onOut);
final b = _proc!.stderr.transform(enc.decoder).listen((t) {
for (final l in t.split(RegExp(r'[\r\n]'))) {
@ -459,6 +472,7 @@ class TransferJob extends ChangeNotifier {
}
_tick();
},
rsync: true,
);
if (cancelled) return;
if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code);

View File

@ -0,0 +1,44 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
/// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을
/// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고
/// 지킬 파일이 지워짐, ' 가 든 경로는 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼친다 (--exclude *.log 가 x.log y.log 로).
/// 앱이 만든 인수를 응답 파일 (@파일) 에 큰따옴표로 감싸 넣고 MSYS=noglob 으로 띄우면 그대로 간다 (실제 rsync 3.5.1 로 확인 -
/// MSYS=noglob 만으로는 ' 를 막지 못하고, CYGWIN=noglob 은 소용없다).
/// 응답 파일의 경로에 빈칸 · ' 가 있으면 @ 가 먹히지 않아, 그 파일이 든 폴더를 작업 폴더로 하고 "@args.txt" 로 넘긴다.
/// 큰따옴표 (") 가 든 인수는 이 방법으로도 그대로 넘기지 못한다 - 실행하지 않고 이유를 알린다 (Windows 파일 이름에는 " 가 없다).
class RsyncLaunch {
final List<String> args;
final String? workingDirectory;
final Map<String, String>? environment;
final Directory? _dir;
RsyncLaunch._(this.args, this.workingDirectory, this.environment, this._dir);
/// 다 쓴 응답 파일을 지운다 (rsync 가 끝난 뒤)
void dispose() {
try {
_dir?.deleteSync(recursive: true);
} catch (_) {}
}
}
/// Windows rsync 에 그대로 넘길 수 없는 인수 (큰따옴표가 듦)
List<String> msysUnsafeArgs(List<String> args) => [for (final a in args) if (a.contains('"')) a];
/// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose]
Future<RsyncLaunch> prepareRsyncLaunch(List<String> args, {bool? windows}) async {
if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null);
final bad = msysUnsafeArgs(args);
if (bad.isNotEmpty) {
throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')]));
}
final dir = await Directory.systemTemp.createTemp('jj_rsargs_');
await File(p.join(dir.path, 'args.txt')).writeAsString(args.map((a) => '"$a"').join(' '), encoding: utf8);
final msys = Platform.environment['MSYS'];
return RsyncLaunch._(['@args.txt'], dir.path, {'MSYS': msys == null || msys.trim().isEmpty ? 'noglob' : '$msys noglob'}, dir);
}

View File

@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:path/path.dart' as p;
import 'file_ops.dart' show appTempRsyncExcludes;
@ -157,7 +159,8 @@ List<String> _normalizeWords(List<_Word> raw) {
value = '';
continue;
}
if (!w.quoted && t.isNotEmpty && _dashLike.contains(t[0])) {
// 28 확인 (10/20): 곧은 하이픈과 섞인 것 (-–delete) 도 맨 앞 둘 중 하나라도 대시면 같은 규칙으로
if (!w.quoted && t.isNotEmpty && (_dashLike.contains(t[0]) || t.length > 1 && t[0] == '-' && _dashLike.contains(t[1]))) {
final n = _dashAt(t, 1) ? 2 : 1;
final rest = t.substring(n);
final name = rest.split('=').first;
@ -190,25 +193,74 @@ const _rsyncLongWithValue = {
'--block-size', '--checksum-choice', '--compress-choice', '--compress-level', '--skip-compress', '--info', '--debug',
'--sockopts', '--port', '--address', '--outbuf', '--remote-option', '--max-alloc', '--stop-after', '--stop-at',
'--write-batch', '--only-write-batch', '--read-batch', '--protocol', '--checksum-seed', '--copy-as', '--early-input',
// 28 확인 (10/20): rsync --help 의 짧은 별칭 (aka --cc · --zc · --zl · --zt) 과 값 받는 옵션 - 빠져 있어 값이 "경로로 읽힙니다" 로 막혔다
'--cc', '--zc', '--zl', '--zt', '--compress-threads', '--confine-root',
};
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
List<String> unreadableOptions(String options) {
List<String> 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, stdin }
/// [unreadableOptions] 의 한 줄과 그 종류
class OptionProblem {
final OptionProblemKind kind;
final String text;
/// [text] 안의 옵션 낱말 (화면이 줄바꿈 없이 묶는다)
final String token;
const OptionProblem(this.kind, this.text, [this.token = '']);
@override
String toString() => text;
}
/// 문제 종류에 맞는 고칠 방법 (여러 종류면 · 로 잇는다)
String optionProblemHint(Iterable<OptionProblem> problems, {bool? windows}) {
windows ??= Platform.isWindows;
final kinds = {for (final x in problems) x.kind};
return [
if (kinds.contains(OptionProblemKind.unclosed)) tr('따옴표를 닫아 주세요'),
if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'),
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
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(' · ');
}
/// "옵션을 알아볼 수 없습니다: … - 고칠 방법" (작업 기록 · 실시간 동기화 카드 · 설정 칸)
String optionProblemMessage(List<OptionProblem> problems) =>
trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]);
/// [unreadableOptions] 의 몸통 (종류와 함께)
List<OptionProblem> optionProblems(String options, {bool? windows}) {
windows ??= Platform.isWindows;
final parsed = _parseOptions(options);
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
final bad = [for (final u in parsed.unclosed) trf('따옴표가 닫히지 않았습니다: {0}', [u])];
final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)];
var value = false;
var last = '';
for (final t in parsed.words) {
// P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도.
// 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다
if (windows && parsed.unclosed.isEmpty && t.contains('"')) {
bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t));
value = false;
continue;
}
if (value) {
value = false;
continue;
}
if (t.startsWith('---')) {
// 관리자 판단 (10/20, 28 확인): 하이픈이 셋 이상 (---delete) 은 rsync 가 알아보지 못한다
bad.add(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;
@ -220,16 +272,152 @@ List<String> unreadableOptions(String options) {
} else {
// 관리자 판단 (10/20, 28 확인): 옵션 칸에는 경로가 들어갈 자리가 없다 (원본 · 대상은 앱이 넣음) - 값 자리가 아닌데
// - 로 시작하지 않는 낱말 (-a delete 의 delete, 혼자 쓴 -) 은 rsync 가 원본 경로로 읽어 옵션이 몰래 빠진다
bad.add(trf('경로로 읽힙니다: {0}', [t]));
// ASCII 밖 글자로 시작하면 (« … 등) 붙여 넣은 이상한 글자 - 안내는 곧은 글자로 다시 적기
bad.add(OptionProblem(t.codeUnitAt(0) > 0x7F ? OptionProblemKind.odd : OptionProblemKind.path, trf('경로로 읽힙니다: {0}', [t]), t));
}
}
// 값이 필요한 옵션이 끝에 값 없이 (--exclude) - rsync 가 원본 경로를 그 값으로 먹는다
if (value) bad.add(trf('{0} 의 값이 없습니다', [last]));
if (value) bad.add(OptionProblem(OptionProblemKind.noValue, trf('{0} 의 값이 없습니다', [last]), last));
// 관리자 판단 (10/20): 목록 파일의 상대 경로 - rsync 는 자기 작업 폴더 (Windows 는 응답 파일 폴더) 기준으로, 미리 보기는 앱의
// 작업 폴더 기준으로 읽어 서로 다른 파일을 볼 수 있다. 전체 경로만 받는다
for (final f in listFileValues(parsed.words)) {
// 관리자 지시 (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 류는 받는 쪽 기준이라 그대로
for (final f in _optionValues(parsed.words, const {'--log-file', '--write-batch', '--read-batch', '--only-write-batch'})) {
if (!_absolutePath(f, windows: windows)) bad.add(OptionProblem(OptionProblemKind.relativePath, trf('기록 · 배치 파일은 전체 경로로 적어 주세요: {0}', [f]), f));
}
return bad;
}
/// 긴 옵션 [names] 의 값 (--이름=값 · --이름 값)
List<String> _optionValues(List<String> words, Set<String> names) => [
for (var i = 0; i < words.length; i++)
if (words[i].contains('=') && names.contains(words[i].substring(0, words[i].indexOf('='))))
words[i].substring(words[i].indexOf('=') + 1)
else if (names.contains(words[i]) && i + 1 < words.length)
words[i + 1],
];
/// 파일을 받는 옵션의 값 (--exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일").
/// ": 이름" · dir-merge 는 폴더마다 읽는 파일 이름이라 (원래 상대) 빼고
List<String> listFileValues(List<String> words) {
const fileOpts = {'--exclude-from', '--include-from', '--files-from'};
final out = <String>[];
String? mergeFile(String rule) => RegExp(r'^(?:\.|merge)(?:,[^\s]*)?\s+(.+)$').firstMatch(rule.trim())?.group(1);
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 && fileOpts.contains(w.substring(0, eq))) {
out.add(w.substring(eq + 1));
} else if (fileOpts.contains(w) && next != null) {
out.add(next);
i++;
} else if (w.startsWith('--filter=')) {
final f = mergeFile(w.substring(9));
if (f != null) out.add(f);
} else if (w == '--filter' && next != null) {
final f = mergeFile(next);
if (f != null) out.add(f);
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') continue;
final rule = k == w.length - 1 ? next : w.substring(k + 1);
if (k == w.length - 1) i++;
final f = rule == null ? null : mergeFile(rule);
if (f != null) out.add(f);
}
}
return out;
}
/// 전체 경로. 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<String> rsyncFileOptionArgs(List<String> 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',
};
// 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]!)}';
}
final out = <String>[];
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:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
/// 받는 쪽 (대상) 경로를 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('\\', '/');
@ -257,7 +445,7 @@ List<String> 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',
@ -267,7 +455,7 @@ List<String> 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)}/',
];
}

View File

@ -19,7 +19,7 @@ import 'rsync_setup.dart';
import 'schedule_editor.dart';
import 'theme.dart';
import 'setting_tile.dart';
import 'sync_preview_view.dart' show OptionTextFormatter;
import 'sync_preview_view.dart' show OptionTextFormatter, keepOptionsTogether, optionProblemText;
/// 환경 설정의 복사 · 동기화 부분.
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
@ -79,7 +79,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set, {bool rsync = false}) {
final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value));
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션은 칸 아래에 빨갛게 (실행 · 실시간 동기화는 막힘)
final bad = rsync ? unreadableOptions(ctl.text) : const <String>[];
final bad = rsync ? optionProblems(ctl.text) : const <OptionProblem>[];
return SettingTile(
title: Text(title),
subtitle: Text(hint),
@ -93,7 +93,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
errorText: bad.isEmpty ? null : trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')]),
errorText: bad.isEmpty ? null : optionProblemText(bad),
errorMaxLines: 3,
suffixIcon: IconButton(
tooltip: trf('기본값으로 ({0})', [def]),
@ -183,7 +183,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
value: c.settings.copySkipNewer,
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')),
subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))),
);
Widget _rsyncPart() {
@ -342,7 +342,7 @@ class _LiveSyncTile extends StatelessWidget {
value: delete,
onChanged: (v) => set(() => delete = v ?? false),
title: Text(tr('원본에 없는 것을 대상에서 지우기')),
subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')),
subtitle: Text(keepOptionsTogether(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만'))),
),
SettingTile(
contentPadding: EdgeInsets.zero,

View File

@ -243,6 +243,9 @@ class _Pane extends ChangeNotifier {
final back = <String>[];
final forward = <String>[];
bool traveling = false;
/// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게 - [_ExplorerPageState._revealNewViews])
bool onScreen = false;
String? focused;
final expanded = <String>{};
final cache = <String, List<FileEntry>>{};
@ -1056,19 +1059,30 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
void _reveal(_Pane pane, String path) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !pane.scroll.hasClients) return;
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path));
if (i < 0) return;
final pos = pane.scroll.position;
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity));
});
void _reveal(_Pane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path));
void _revealNow(_Pane pane, String path) {
if (!mounted || !pane.scroll.hasClients) return;
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
final i = _rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path));
if (i < 0) return;
final pos = pane.scroll.position;
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
pane.scroll.jumpTo((i * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity));
}
/// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때)
/// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다
void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
for (final pane in _panes) {
final on = pane.scroll.hasClients;
if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current);
pane.onScreen = on;
}
});
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로)
Future<void> _travel(_Pane pane, {required bool forward}) async {
// 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게
@ -3419,7 +3433,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다
List<String> filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList();
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다
List<String> badOptions() => {for (final t in tasks) ...unreadableOptions(opts(t))}.toList();
List<OptionProblem> badOptions() {
final seen = <String>{};
return [for (final t in tasks) ...optionProblems(opts(t)).where((x) => seen.add(x.text))];
}
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
@ -3461,7 +3478,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
// 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178)
if (both) ...[
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'),
Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
if (optionMove)
Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'),
@ -3476,7 +3493,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
preview = null;
previewError = null;
}),
title: Text(tr('원본 파일 지우기 (--remove-source-files)')),
title: Text(keepOptionsTogether(tr('원본 파일 지우기 (--remove-source-files)'))),
subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')),
),
if (removeSource) ...[
@ -3496,7 +3513,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 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(
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
@ -4407,6 +4425,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
final dual = _dual;
final split = _split;
return LayoutBuilder(builder: (context, box) {
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
if (_ready) _revealNewViews();
final side = !(dual || split) ||
switch (s.explorerOrientation) {
'side' => true,

View File

@ -21,7 +21,7 @@ import 'path_label.dart';
import 'rsync_setup.dart';
import 'schedule_editor.dart';
import '../core/sync_preview.dart' show optionsRemoveSource;
import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, unbreakableOption;
import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, keepOptionsTogether, unbreakableOption;
import 'theme.dart';
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
@ -135,9 +135,9 @@ Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPai
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]),
Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])),
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
Text(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.'),
Text(keepOptionsTogether(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)),
@ -663,7 +663,7 @@ class _CopyCardState extends State<_CopyCard> {
style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim),
),
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)
if (_method == 'rsync' && unreadableOptions(_options.text).isNotEmpty) OptionProblemNote(unreadableOptions(_options.text)),
if (_method == 'rsync' && optionProblems(_options.text).isNotEmpty) OptionProblemNote(optionProblems(_options.text)),
if (!t.move && optionsRemoveSource(_options.text))
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
@ -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('실행')),
),

View File

@ -126,7 +126,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run),
]),
if (dels > 0)
Text(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels]),
Text(keepOptionsTogether(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels])),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로
if (partials > 0)
@ -228,7 +228,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음)
final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none;
final bad = t.method == 'rsync' ? unreadableOptions(t.options) : const <String>[];
final bad = t.method == 'rsync' ? optionProblems(t.options) : const <OptionProblem>[];
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
@ -258,7 +258,8 @@ Future<bool> 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 => [
@ -316,6 +317,12 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
/// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음)
String unbreakableOption(String s) => s.replaceAll(' ', '\u00A0').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}\u2060');
/// 28 확인 (10/20): 글 안의 옵션 이름만 묶는다 - 괄호째 ("(--remove-source-files)" · "(--delete, 되돌릴 수 없음)") 와 따로 쓴
/// "--delete-excluded" · "-u" · "/PURGE". 예전에는 en 412 에서 "(--" 에서 갈렸다. 나머지 글은 그대로 줄바꿈
String keepOptionsTogether(String s) => s
.replaceAllMapped(RegExp(r'\([-/][^()]*\)'), (m) => unbreakableOption(m[0]!))
.replaceAllMapped(RegExp(r'(?<![\w\-\u2060/])(?:--?[A-Za-z][\w-]*(?:=\S*)?|/[A-Z]{2,}\b)'), (m) => unbreakableOption(m[0]!));
/// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20).
/// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게
class OptionTextFormatter extends TextInputFormatter {
@ -335,15 +342,26 @@ class OptionTextFormatter extends TextInputFormatter {
}
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 - 빨갛게 알리고 [실행] 을 막는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
/// 알아볼 수 없는 옵션 안내 글 (확인 창 · 설정 칸): 옵션 낱말은 줄바꿈 없이 묶고 (28 지적: "--" 와 "exclude" 로 갈림),
/// 고칠 방법의 - · -- 뒤에서도 줄을 바꾸지 않는다 (나머지 글은 보통처럼 줄을 바꾼다)
String optionProblemText(List<OptionProblem> bad) {
final wj = String.fromCharCode(0x2060);
return trf('옵션을 알아볼 수 없습니다: {0} - {1}', [
bad.map((x) => x.token.isEmpty ? x.text : x.text.replaceFirst(x.token, unbreakableOption(x.token))).join(' · '),
optionProblemHint(bad).replaceAll('-', '-$wj'),
]);
}
class OptionProblemNote extends StatelessWidget {
final List<String> bad;
final List<OptionProblem> bad;
const OptionProblemNote(this.bad, {super.key});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
trf('옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요', [bad.map(unbreakableOption).join(' · ')]),
// 28 지적 (10/20): 끝 문구는 문제 종류에 맞게 (따옴표 닫기 · - 로 시작 · 값 적기 · 곧은 글자로)
optionProblemText(bad),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
);
}

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.10.18+1
version: 2026.10.20+1
environment:
sdk: ^3.13.4

View File

@ -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<FilledButton>(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'))
@ -229,11 +249,11 @@ void main() {
..writeAsStringSync('o');
bool? r;
// 관리자 지시 (10/20): H 는 이제 미리 볼 수 있다 - 아직 따르지 못하는 merge 규칙으로
final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge rules.txt"');
final t = CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete --filter="merge C:/jj_rules/rules.txt"'); // 목록 파일은 전체 경로 (관리자 판단 10/20)
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('merge rules.txt'), findsWidgets);
expect(find.textContaining('merge C:/jj_rules/rules.txt'), findsWidgets);
FilledButton run() => tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행'));
expect(run().onPressed, isNull, reason: '체크 전에는 실행할 수 없음');
await tester.tap(find.text('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'));
@ -365,7 +385,7 @@ void main() {
expect(find.text('rsync'), findsWidgets);
// 222: 원본이 대상 안 - 복사면 "복사됩니다 · 원본 폴더 그대로", 이동이면 "옮겨집니다 · 원본 파일은 지워짐"
expect(find.textContaining('바로 아래에 복사됩니다'), findsOneWidget);
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)'));
await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await tester.pump();
expect(find.textContaining('바로 아래로 옮겨집니다 (원본 파일은 지워지고'), findsOneWidget);
expect(find.textContaining('바로 아래에 복사됩니다'), findsNothing);

View File

@ -94,6 +94,47 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭 · 넓게 바꾼 두 창도 그 창의 지금 폴더가 보인다 (저장 장치 맨 위에 머물지 않음)', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_rsync_reveal_');
addTearDown(() => tmp.deleteSync(recursive: true));
// 맨 위에 폴더가 많고 (C: 처럼) 두 창의 지금 폴더는 깊은 곳 - 펼쳐도 스크롤하지 않으면 화면 밖
for (var i = 0; i < 80; i++) {
Directory(p.join(tmp.path, 'a${i.toString().padLeft(2, '0')}')).createSync();
}
final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R');
Directory(p.join(l, 'sub')).createSync(recursive: true);
Directory(p.join(r, 'rsub')).createSync(recursive: true);
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..rsyncPaths = [l, r];
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty);
expect(find.text('sub').hitTestable(), findsOneWidget, reason: '왼쪽은 처음부터 지금 폴더');
// [오른쪽] 탭을 처음 보면 그 창의 지금 폴더 (R) 로 - 예전에는 맨 위 (a00 …) 에서 시작
await tester.tap(find.text('오른쪽 · R'));
await settle(tester, () => find.text('rsub').hitTestable().evaluate().isNotEmpty, rounds: 40);
expect(find.text('rsub').hitTestable(), findsOneWidget);
// 넓게 바꾸면 두 창 - 둘 다 지금 폴더가 보임
tester.view.physicalSize = const Size(1280, 800);
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty && find.text('rsub').hitTestable().evaluate().isNotEmpty,
rounds: 40);
expect(find.text('sub').hitTestable(), findsOneWidget);
expect(find.text('rsub').hitTestable(), findsOneWidget);
// 다시 좁게 → [왼쪽] 탭 (지금 보이는 창) 도 지금 폴더
tester.view.physicalSize = const Size(412, 900);
await settle(tester, () => find.text('왼쪽 · L').evaluate().isNotEmpty);
await tester.tap(find.text('왼쪽 · L'));
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty, rounds: 40);
expect(find.text('sub').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('208: 폰 가로 (낮은 화면) Rsync 화면에서도 방향 실행 버튼 · 모니터링은 늘 보인다 ([더 보기] 로 가는 것은 뒤쪽)', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_rsync_low_');
addTearDown(() => tmp.deleteSync(recursive: true));

View File

@ -27,6 +27,7 @@ import 'package:jj_mkvmaker/ui/path_label.dart';
import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest;
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게)
class _Shell extends NoopShell {
@ -1164,7 +1165,7 @@ void main() {
await tester.tap(find.text('취소'));
await calm(tester);
expect(c.settings.copyTasks, isEmpty);
expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음
expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음
// → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움)
await act(tester, () => tester.tap(find.text('좌 → 우')));
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400);
@ -1173,7 +1174,7 @@ void main() {
// 옵션 낱말 -u 만 (폴더 이름 vscode-stable-user-x64 같은 것은 아님 - 시험 임시 폴더 위가 %TEMP% 일 때)
expect(find.textContaining(RegExp(r'(^|\s)-u(\s|$)')), findsNothing);
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing);
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)'));
await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await calm(tester);
expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget);
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget);

View File

@ -22,6 +22,9 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
/// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
const ftpPw = 'ftp-Pw-7Rk2xQ9t';
void main() {
final hasPy = File(sftpTestPython).existsSync();
@ -76,7 +79,8 @@ void main() {
late String caFp;
Future<void> start(String mode, {bool chain = false}) async {
final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, if (chain) '--chain']);
final pr = await Process.start(
sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, '--password', ftpPw, if (chain) '--chain']);
proc = pr;
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
pr.stderr.drain<void>();
@ -111,7 +115,7 @@ void main() {
}
});
FtpServer srv({String security = 'explicit', String cert = '', String password = 'pw'}) =>
FtpServer srv({String security = 'explicit', String cert = '', String password = ftpPw}) =>
FtpServer(id: 'f1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert);
Future<Object?> err(Future<void> Function() f) async {
@ -247,13 +251,55 @@ void main() {
FtpRegistry.configure([srv(security: 'none')]);
final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene');
final bad = <String>[];
// 관리자 지시 (10/20): 가끔 실패해 원인을 보려고, 실패하면 옵션 · 걸린 시간 · 포트 · 예외 · 다른 점과
// 보내는 쪽 · 맞추기 전 앱 쪽 · rsync 쪽 · 앱 쪽 목록 (파일은 크기 · 바뀐 시각 ms) 을 한 번에 보인다
String listing(String dir) {
final root = Directory(dir);
if (!root.existsSync()) return ' (없음)';
final rows = <String>[];
for (final e in root.listSync(recursive: true)) {
final rel = p.relative(e.path, from: dir).replaceAll(r'\', '/');
if (e is Directory) {
rows.add('$rel/');
} else {
final s = e.statSync();
rows.add('$rel ${s.size}B ${s.modified.toIso8601String()}');
}
}
return (rows..sort()).map((r) => ' $r').join('\n');
}
final sw = Stopwatch()..start();
for (final opts in sceneOptions) {
final t0 = sw.elapsedMilliseconds;
writeScene(src, want);
final w = await realRsyncTree(rsync, src, want, opts);
final wantList = listing(want);
writeScene(src, got);
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
final beforeList = listing(got);
Object? error;
try {
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
} catch (e, st) {
error = '$e\n$st';
}
final d = treeDiff(w, treeOf(got));
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
if (d.isNotEmpty || error != null) {
bad.add([
'옵션: $opts (${sw.elapsedMilliseconds - t0}ms, 시험 시작부터 ${t0}ms, 포트 $port, ${DateTime.now().toIso8601String()})',
if (error != null) ' 예외: $error',
' 다른 점:',
...d.map((x) => ' $x'),
' 보내는 쪽:',
listing(src),
' 맞추기 전 앱 쪽:',
beforeList,
' rsync 쪽:',
wantList,
' 앱 쪽:',
listing(got),
].join('\n'));
}
}
expect(bad, isEmpty, reason: bad.join('\n'));
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
@ -285,7 +331,10 @@ void main() {
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: '주소에 비밀번호 없음');
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
expect(u1, isNot(contains(ftpPw)), reason: '주소에 비밀번호 없음');
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient();
try {

View File

@ -16,6 +16,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:path/path.dart' as p;
import 'support/rsync_scenes.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync,
/// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음)
@ -106,9 +107,9 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget);
expect(find.text('거른 것 2개도 지워집니다 (--delete-excluded)'), findsOneWidget);
expect(find.text(keepOptionsTogether('거른 것 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};
final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(keepOptionsTogether(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);
@ -311,7 +312,7 @@ void main() {
testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="merge rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름)
c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);

View File

@ -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<FilledButton>(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));

View File

@ -105,11 +105,28 @@ void main() {
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'), '옵션은 - 나 -- 로 시작합니다', reason: '관리자 지시 (10/20)');
expect(hint('-a \u00ABx\u00BB'), '곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요');
expect(hint('-a delete --exclude'), '옵션은 - 나 -- 로 시작합니다 · 값을 적어 주세요 (예: --exclude=*.tmp)');
expect(optionProblemMessage(optionProblems('-a delete')), '옵션을 알아볼 수 없습니다: 경로로 읽힙니다: delete - 옵션은 - 나 -- 로 시작합니다');
});
testWidgets('알아볼 수 없는 옵션 알림 (빨간 글, 옵션 글은 줄바꿈 없이)', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OptionProblemNote(['\u00ABdelete\u00BB']))));
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] 로 막는다

View File

@ -154,15 +154,15 @@ void main() {
expect(t, lessThan(const Duration(seconds: 5)));
expect(cut, isTrue);
await c.close();
// 0.4초마다 조각 (정한 1초 안) - 끝까지
// 0.1초마다 조각 (정한 3초 안 - 전체 시험의 부하에서도 넉넉하게, 0b 지적 10/20: 0.4초 대 1초는 한 번 넘음) - 끝까지
Stream<List<int>> slow() async* {
for (var i = 0; i < 5; i++) {
await Future<void>.delayed(const Duration(milliseconds: 400));
await Future<void>.delayed(const Duration(milliseconds: 100));
yield [i];
}
}
expect(await idleRead(slow()).length, 5);
expect(await idleRead(slow(), limit: const Duration(seconds: 3)).length, 5);
});
test('도우미: 올리기 - 보내는 쪽이 더 가져가지 않으면 · 다 가져간 뒤 답이 없으면 실패', () async {
@ -297,8 +297,11 @@ void main() {
test('받는 중에 서버가 멈추면 · 명령 (열기) 에 답이 없으면 정한 시간 뒤 실패', () async {
final c = client();
addTearDown(c.close);
// 준비 (연결 · 목록 · 열기) 는 넉넉하게 - 함께 도는 시험의 부하에서 paramiko 의 답이 1초를 넘은 적이 있다 (15 검증 10/20)
RemoteIdle.limit = const Duration(seconds: 10);
expect((await c.list('/')).map((e) => e.rel), contains('/a.bin'));
final s = await c.openReadFrom('a.bin'); // 열기까지는 됨
RemoteIdle.limit = const Duration(seconds: 1); // 받기 (listen) 때 이 시간으로
proxy!.stall = true; // 이제 서버가 아무것도 보내지 않음
final (t, e) = await _timed(() => s.drain<void>());
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');
@ -306,7 +309,9 @@ void main() {
final c2 = client();
addTearDown(c2.close);
proxy!.stall = false;
RemoteIdle.limit = const Duration(seconds: 10);
await c2.list('/');
RemoteIdle.limit = const Duration(seconds: 1);
proxy!.stall = true;
final (t2, e2) = await _timed(() => c2.openReadFrom('a.bin'));
expect(e2, isA<RemoteIdleTimeout>(), reason: '$e2');
@ -316,7 +321,9 @@ void main() {
test('올리는 중에 서버가 멈추면 정한 시간 뒤 실패', () async {
final c = client();
addTearDown(c.close);
RemoteIdle.limit = const Duration(seconds: 10); // 준비는 넉넉하게 (위와 같음)
await c.list('/');
RemoteIdle.limit = const Duration(seconds: 1);
proxy!.stall = true;
final (t, e) = await _timed(() => c.write('b.bin', _big()));
expect(e, isA<RemoteIdleTimeout>(), reason: '$e');

View File

@ -0,0 +1,272 @@
import 'dart:io';
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/app/transfer_job.dart';
import 'package:jj_mkvmaker/core/rsync_launch.dart';
import 'package:jj_mkvmaker/core/sync_tools.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:path/path.dart' as p;
import 'support/rsync_scenes.dart' show sceneRsync, treeOf;
/// P0 (관리자 지시 10/20, 28 재현 - 데이터): Windows 의 rsync (MSYS2 런타임) 가 명령 줄을 다시 읽어 ' 를 짝짓고 (거르기가 사라져
/// 지킬 파일이 지워짐 · ' 경로가 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼치던 것 - 응답 파일 + MSYS=noglob 으로 그대로 넘긴다.
/// 앱이 rsync 를 띄우는 두 길 (복사 · Rsync 화면 · 모니터링 · 최종 정리 = TransferJob, 실시간 동기화 = LiveSync) 을 실제 rsync 로.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('도우미: Windows 는 응답 파일 · MSYS=noglob, 그 밖은 그대로 · 큰따옴표가 든 인수는 막음', () async {
final plain = await prepareRsyncLaunch(['-a', "it's"], windows: false);
expect(plain.args, ['-a', "it's"]);
expect(plain.environment, isNull);
final w = await prepareRsyncLaunch(['-a', "--exclude=it's.txt", 'a b'], windows: true);
addTearDown(w.dispose);
expect(w.args, ['@args.txt']);
expect(w.environment!['MSYS'], contains('noglob'));
expect(File(p.join(w.workingDirectory!, 'args.txt')).readAsStringSync(), '"-a" "--exclude=it\'s.txt" "a b"');
await expectLater(prepareRsyncLaunch(['--exclude=a"b'], windows: true), throwsA(isA<StateError>()));
// 옵션 칸에서도 미리 빨갛게 (Windows 만)
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: true), hasLength(1));
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: false), isEmpty);
expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty);
});
test('관리자 판단 (10/20): 목록 파일의 상대 경로는 막는다 (rsync 와 미리 보기가 다른 작업 폴더 기준으로 읽음) - 전체 경로 · 폴더마다 읽는 규칙은 그대로', () {
for (final o in [
'-a --exclude-from=list.txt',
'-a --exclude-from list.txt',
'-a --include-from=./a.txt',
'-a --files-from=sub/files.txt',
'-a --filter=". rules.txt"',
'-a --filter="merge,- rules.txt"',
'-a --filter ". w.txt"',
'-a -f". y.txt"',
'-avf ". z.txt"',
'-a --exclude-from=~/list.txt',
]) {
expect(unreadableOptions(o, windows: true), [contains('목록 파일은 전체 경로로 적어 주세요')], reason: o);
}
for (final o in [
r'-a --exclude-from=C:\lists\l.txt',
'-a --exclude-from=C:/lists/l.txt',
'-a --include-from=/cygdrive/c/l.txt',
r'-a --files-from=\\nas\share\l.txt',
'-a --filter=". /cygdrive/c/rules.txt"',
r'-a -f". C:\r.txt"',
'-a --filter=": .rsync-filter"',
'-a --filter="dir-merge .x"',
'-a -F',
'-a --exclude=list.txt',
// 받는 쪽 기준이라 그대로 (28 확인)
'-a --backup-dir=old --partial-dir=.part --temp-dir=tmp --compare-dest=../base',
r'-a --log-file=C:\logs\r.log --write-batch=/cygdrive/c/b/batch',
]) {
expect(unreadableOptions(o, windows: true), isEmpty, reason: o);
}
// 관리자 지시 (10/20, 28 확인): 기록 · 배치 파일도 전체 경로만 (상대면 응답 파일 임시 폴더에 생겼다가 함께 지워짐)
for (final o in ['-a --log-file=r.log', '-a --log-file r.log', '-a --write-batch=b', '-a --only-write-batch b', '-a --read-batch=./b']) {
expect(unreadableOptions(o, windows: true), [contains('기록 · 배치 파일은 전체 경로로 적어 주세요')], reason: o);
}
});
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 가 읽는 꼴로 바꿔 넘긴다 (막지 않음)', () {
// 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),
['--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']);
});
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;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_msysargs_'));
tearDown(() => tmp.deleteSync(recursive: true));
/// 원본 · 대상 폴더를 만들고 파일을 넣는다 (내용 = 쪽:이름)
(String, String) scene(String sn, String dn, List<String> sf, List<String> df) {
final s = Directory(p.join(tmp.path, sn))..createSync(recursive: true);
final d = Directory(p.join(tmp.path, dn))..createSync(recursive: true);
for (final f in sf) {
File(p.join(s.path, f))
..parent.createSync(recursive: true)
..writeAsStringSync('s:$f');
}
for (final f in df) {
File(p.join(d.path, f))
..parent.createSync(recursive: true)
..writeAsStringSync('d:$f');
}
return (s.path, d.path);
}
// (이름, 옵션, 원본 폴더, 대상 폴더, 원본 파일, 대상 파일, 남아야 할 것 (대상), 없어야 할 것 (대상))
final cases = <(String, String, String, String, List<String>, List<String>, List<String>, List<String>)>[
("28 재현: ' 두 개의 --exclude", '-a --delete --exclude="it\'s.txt" --exclude="Bob\'s.txt"', 's', 'd', ['a.txt'],
["it's.txt", "Bob's.txt", 'old.txt'], ["it's.txt", "Bob's.txt", 'a.txt'], ['old.txt']),
("' 하나의 --exclude", "-a --delete --exclude=\"it's.txt\"", 's', 'd', ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], ['old.txt']),
("' 가 든 원본 경로", '-a --delete', "s'1", 'd', ["a'b.txt"], ['old.txt'], ["a'b.txt"], ['old.txt']),
("' 가 든 원본 · 대상 경로", '-a --delete', "s'1", "d'2", ['a.txt', "c'd/e'f.txt"], ['old.txt'], ['a.txt', "c'd/e'f.txt"], ['old.txt']),
("' 경로 + ' --exclude", "-a --delete --exclude=\"it's.txt\"", "s'1", "d'2", ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'],
['old.txt']),
('따로 쓴 * (작업 폴더에 맞는 파일이 있어도)', '-a --delete --exclude *.txt', 's', 'd', ['a.txt', 'n.log'], ['keep.txt', 'old.log'],
['keep.txt', 'n.log'], ['old.log', 'a.txt']),
('따로 쓴 ? · [ ] · { }', '-a --delete --exclude b?.txt --exclude [x]* --exclude {q,r}.txt', 's', 'd', ['a.txt'],
['b9.txt', 'xk.txt', '{q,r}.txt', 'q.txt', 'old.txt'], ['b9.txt', 'xk.txt', '{q,r}.txt', 'a.txt'], ['q.txt', 'old.txt']),
('[ ] · { } · 빈칸 · 한글이 든 경로', '-a --delete', '원본 [1] {a}', "받는 쪽 [2] {b}'s", ['가 나.txt'], ['old.txt'], ['가 나.txt'], ['old.txt']),
];
for (final (name, opts, sn, dn, sf, df, keep, gone) in cases) {
test('복사 작업 (TransferJob): $name', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene(sn, dn, sf, df);
// 작업 폴더에 글로브에 맞을 파일을 둔다 (예전에는 따로 쓴 * 가 이것들로 펼쳐졌다)
final cwd = Directory.current;
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: '${job.error}');
final t = treeOf(d);
for (final k in keep) {
expect(t.containsKey(k), isTrue, reason: '$name: $k 가 있어야 함 - $t');
}
for (final g in gone) {
expect(t.containsKey(g), isFalse, reason: '$name: $g 는 없어야 함 - $t');
}
expect(Directory.current.path, cwd.path);
}, 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('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']);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"'
..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')];
final live = LiveSync(c);
addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single;
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
final t = treeOf(d);
expect(t.keys.toSet(), {"it's.txt", "Bob's.txt", 'a.txt'}, reason: '거르기를 지키고 old.txt 만 지움');
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
test('응답 파일은 끝나면 지운다', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene('s', 'd', ['a.txt'], []);
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a', rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull);
// 이 작업의 응답 파일 (이 시험의 경로가 든 것) 이 남지 않았는지 - 함께 도는 다른 시험의 것은 세지 않는다
final mine = [
for (final e in Directory.systemTemp.listSync())
if (p.basename(e.path).startsWith('jj_rsargs_') && File(p.join(e.path, 'args.txt')).existsSync())
if (File(p.join(e.path, 'args.txt')).readAsStringSync().contains(toCygwinPath(s))) e.path,
];
expect(mine, isEmpty);
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
});
}

View File

@ -13,6 +13,7 @@ import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, settle;
import 'support/dav_server.dart';
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 95 · 96 · 97 · 100: Rsync 화면의 지우기 - 확인 창 · 미리 보기 · 실행이 같고, ⇄ 는 차례로 (← 는 지우지 않음)
void main() {
@ -78,7 +79,7 @@ void main() {
expect(find.textContaining('-avPog -u --delete'), findsNothing);
expect(find.textContaining('rsync -avPog -u'), findsWidgets);
expect(runButton(tester).onPressed, isNotNull);
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget);
expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.textContaining('rsync 끝').evaluate().isNotEmpty,
rounds: 400);
@ -101,12 +102,12 @@ void main() {
expect(find.textContaining('rsync -avPog --delete'), findsNothing);
expect(find.textContaining('− b.txt'), findsNothing);
// 원본 파일 지우기 (이동 작업) → 그 작업의 옵션 (--delete) 으로 다시 보이고 다시 비교한다
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)'));
await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
await tester.pump();
expect(find.textContaining('rsync -avPog --delete --remove-source-files'), findsOneWidget);
await waitCompare(tester);
expect(find.textContaining('− b.txt'), findsOneWidget);
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget);
expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget);
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
@ -118,7 +119,7 @@ void main() {
await open(tester);
await act(tester, () => tester.tap(find.text('좌 → 우')));
await waitCompare(tester);
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget); // --del 도 지우기로 알아본다
expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget); // --del 도 지우기로 알아본다
await tester.tap(find.byTooltip('다시 비교'));
await tester.pump();
expect(runButton(tester).onPressed, isNull);

View File

@ -16,6 +16,9 @@ import 'support/sftp_server.dart';
/// sftp_next: SFTP 동영상을 받지 않고 이 기기 안의 다리 (127.0.0.1) 로 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막.
/// 실제 서버 시험은 tool/sftp_test_server.py (paramiko) - 파이썬이 없으면 건너뛴다 (sftp_test 와 같음)
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
const sftpPw = 'sftp-Pw-3Lm8vZ2w';
void main() {
final hasServer = sftpTestServerAvailable;
late Directory tmp;
@ -26,10 +29,10 @@ void main() {
if (!hasServer) return;
tmp = Directory.systemTemp.createTempSync('jj_sftpnext_');
root = Directory(p.join(tmp.path, 'root'))..createSync();
final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'));
final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: sftpPw);
server = s;
SftpRegistry.configure([
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: 'pw', hostKeyFingerprint: s.fingerprint),
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: sftpPw, hostKeyFingerprint: s.fingerprint),
]);
Directory(p.join(root.path, '영상')).createSync();
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789');
@ -93,7 +96,10 @@ void main() {
expect(u1, endsWith('/${Uri.encodeComponent('1화.mkv')}'), reason: 'mpv 가 확장자로 형식을 알게');
expect(u1, isNot(equals(u2)));
expect(bridge.url('sftp://s1/영상/1화.mkv'), u1, reason: '같은 경로는 같은 주소');
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
expect(u1, isNot(contains(sftpPw)), reason: '주소에 비밀번호 없음');
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
var (code, h, body) = await get(u1);
expect((code, body), (200, '0123456789'));

View File

@ -124,7 +124,8 @@ void main() {
SmbRegistry.configure(const []);
});
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'pw');
// 길고 드문 비밀번호 - 다리 주소의 무작위 표에 우연히 들어가지 않게 (관리자 지시 10/20, 28 이 찾음)
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'smb-Pw-5Qh9kT4r');
test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async {
SmbRegistry.configure([server]);
@ -196,10 +197,10 @@ void main() {
final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
await store.save(AppSettings()..smbServers = [server]);
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('"pw"')));
expect(secrets.values['smb.pw.m1'], 'pw');
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('smb-Pw-5Qh9kT4r')));
expect(secrets.values['smb.pw.m1'], 'smb-Pw-5Qh9kT4r');
final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single;
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'pw'));
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'smb-Pw-5Qh9kT4r'));
});
test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () {
@ -329,7 +330,10 @@ void main() {
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: '주소에 비밀번호 없음');
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
expect(u1, isNot(contains(server.password)), reason: '주소에 비밀번호 없음');
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
Future<(int, String)> get(String url, [String? range]) async {
final http = HttpClient();
try {

View File

@ -50,4 +50,16 @@ void main() {
expect(find.textContaining('(더 새 파일을 덮어씀)'), findsOneWidget);
expect(find.textContaining('(받는 쪽이 더 새것 - 건너뜀)'), findsOneWidget);
});
test('28 확인 (10/20): keepOptionsTogether - 글 안의 옵션 덩어리만 묶고 (괄호째 · 따로 쓴 것) 나머지는 그대로', () {
const wj = '\u2060', nb = '\u00A0';
expect(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'), '원본 파일 지우기 (-$wj-${wj}remove-${wj}source-${wj}files)');
expect(keepOptionsTogether('Delete source files (--remove-source-files)'), 'Delete source files (-$wj-${wj}remove-${wj}source-${wj}files)',
reason: '앞 글의 빈칸은 그대로 (거기서는 줄을 바꿀 수 있음)');
expect(keepOptionsTogether('지워집니다 (--delete, 되돌릴 수 없음)'), '지워집니다 (-$wj-${wj}delete,$nb되돌릴$nb수$nb없음)');
expect(keepOptionsTogether('rsync --delete · robocopy /PURGE. 끄면'), 'rsync -$wj-${wj}delete · robocopy /${wj}PURGE. 끄면');
expect(keepOptionsTogether('(rsync 의 -u, robocopy 의 /XO 와 같음)'), '(rsync 의 -${wj}u, robocopy 의 /${wj}XO 와 같음)');
// 낱말 가운데의 - · / 는 그대로 (real-time · a/b)
expect(keepOptionsTogether('real-time 동기화 · a/b · WebDAV'), 'real-time 동기화 · a/b · WebDAV');
});
}

View File

@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, calm, settle;
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
/// 68 · 70 · 71
void main() {
@ -204,7 +205,7 @@ void main() {
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNull);
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400);
// 끝나면: 지울 목록 (빨강) · [실행 (1개 지움)] 켜짐
expect(find.text('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)'), findsOneWidget);
expect(find.text(keepOptionsTogether('받는 쪽에서 1개가 지워집니다 (--delete, 되돌릴 수 없음)')), findsOneWidget);
expect(find.textContaining('− only_r.txt'), findsOneWidget);
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '실행')).onPressed, isNotNull);
await tester.tap(find.text('취소'));

View File

@ -8,6 +8,10 @@
# 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release.
# - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적:
# 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패.
# - versionCode 의 모양 (관리자 지시 10/20, 15 제안): versionCode = 판 이름 날짜 (2026.10.18 → 261018) × 1000 + 순번 (000~999).
# 987 을 --build-number 261018987 로 빌드해 versionCode 522036987 (날짜가 두 번 더해짐) 이 나왔는데 통과했다 - 사용자 기기에 깔리면
# 그 뒤의 정식 판 (261018xxx) 이 "낮은 버전" 이라 업데이트가 막힌다. build.gradle.kts 가 날짜를 붙이므로 APK 의 --build-number 는 순번만 (예 987).
# 정식 판 (-Release) 은 순번 < 900, 시험판은 순번 >= 900.
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk [-Release]
param(
[Parameter(Mandatory = $true)][string]$Apk,
@ -74,6 +78,17 @@ $same = @(foreach ($t in $tags) {
if ($t -match '^v(\d+)\.(\d+)\.(\d+)_(\d+)$' -and [long]$Matches[4] -lt 900 -and
([long]$Matches[1] % 100) * 10000000 + [long]$Matches[2] * 100000 + [long]$Matches[3] * 1000 + [long]$Matches[4] -eq $code) { $t }
})
# versionCode 의 모양: 판 이름의 날짜 × 1000 + 순번 (build.gradle.kts 와 같은 셈)
if ($vname -match '^(\d+)\.(\d+)\.(\d+)') {
$day = ([long]$Matches[1] % 100) * 10000 + [long]$Matches[2] * 100 + [long]$Matches[3]
if ([math]::Floor($code / 1000) -ne $day) {
$bad += "versionCode $code 가 판 이름 $vname 의 날짜 ($day × 1000 + 순번) 와 맞지 않음 - APK 는 --build-number 에 순번만 (예 987, 날짜를 붙이지 않음)"
} elseif (-not $Release -and ($code % 1000) -lt 900) {
$bad += "시험판인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판은 --build-number 9xx (정식 판이면 -Release)"
}
} else {
$bad += "판 이름을 읽지 못함: '$vname' (년.월.일 이어야 함)"
}
if ($Release -and ($code % 1000) -ge 900) {
$bad += "정식 판 (-Release) 인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판 번호입니다 (정식 판은 --build-number 없이, pubspec 의 +순번)"
}

View File

@ -5,6 +5,10 @@
# - 정식 판 (-Release): 번호가 1000 보다 작아야 한다 (시험 번호가 정식 zip 에 들어가지 않게).
# - 정식 판 (-Release) 의 순번이 900 이상이면 실패 (900 이상은 시험판 순번 - check_apk.ps1 과 같은 규칙, 관리자 지시 10/19).
# 이 검사는 git 태그를 보지 않으므로 시험판 태그를 단 뒤 다시 검사해도 거짓 실패가 없다.
# - 시험판 번호의 날짜는 판 이름 (pubspec) 의 날짜 (관리자 지시 10/20, d3 판단): 2026.10.18+261018987 은 맞고 +261020985 (빌드한 날) 는 실패.
# 근거: ① APK versionCode (판 이름 날짜 × 1000 + 순번) 와 같은 번호가 되어 두 판을 같은 이름으로 견줄 수 있다.
# ② 앱의 판 견주기 (compareVersions) 는 + 뒤 번호 전체를 견준다 - 날짜가 섞이면 같은 판 이름에서 985 (261020985) 가
# 987 (261018987) 보다 새것으로 보인다. 순번은 900 이상 (시험판).
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_windows_build.ps1 -Dir <배포 폴더 (Lib 가 있는 곳)> [-Release]
param(
[Parameter(Mandatory = $true)][string]$Dir,
@ -21,6 +25,12 @@ if ($Release) {
if ($build -ge 900) { throw "정식 판인데 시험 번호입니다: $ver (화면 v$shown). --build-number 없이 (pubspec 의 +순번) 빌드하세요" }
} elseif ($build -lt 1000) {
throw ("시험판인데 정식 판 번호입니다: $ver (화면 v$shown - 정식 판과 같게 보임).`n" +
"flutter build windows --release --build-number <년월일><순번 3자리> (예: 261012972) 로 다시 빌드하세요")
"flutter build windows --release --build-number <판 이름 날짜 6자리><순번 3자리> (예: 261012972) 로 다시 빌드하세요")
} else {
$day = ([long]$Matches[1] % 100) * 10000 + [long]$Matches[2] * 100 + [long]$Matches[3]
if ([math]::Floor($build / 1000) -ne $day -or ($build % 1000) -lt 900) {
throw ("시험판 번호가 판 이름과 맞지 않습니다: $ver - 번호는 판 이름의 날짜 ($day) + 순번 900~999 여야 합니다 (빌드한 날이 아님).`n" +
"flutter build windows --release --build-number $day<순번 3자리> (예: ${day}987) 로 다시 빌드하세요")
}
}
"통과: $ver (화면 v$shown) · $(if ($Release) { '정식 판' } else { '시험판 - 정식 판 번호와 다름' })"

View File

@ -55,9 +55,15 @@ if ($left.Count -gt 0) {
Write-Host ("시험이 남긴 임시 폴더 {0}개 ({1}):" -f $left.Count, $run)
$left | Sort-Object | ForEach-Object { Write-Host " $_" }
}
# 실행 폴더는 이 스크립트가 만든 것이라 통째로 지운다 (긴 경로는 \\?\ 로)
cmd /c "rd /s /q `"\\?\$run`"" 2>$null
if (Test-Path -LiteralPath $run) { Write-Host "실행 폴더를 다 지우지 못함: $run" }
# 실행 폴더는 이 스크립트가 만든 것이라 통째로 지운다 (긴 경로는 \\?\ 로).
# 관리자 지시 (10/20): Edge (WebView2) 는 시험 앱이 끝난 뒤에도 몇 초 사용자 데이터 (jj_held_) 를 잡고 있다 - 놓을 때까지 몇 번 다시 해 본다.
# rd 의 오류는 cmd 안에서 버린다 (PowerShell 쪽 2>$null 은 $ErrorActionPreference = 'Stop' 에서 오류가 되어 시험이 다 통과해도 exit 1 이었다).
# 지우는 것은 이 실행 폴더 안뿐이다 (사용자 폴더 · 다른 세션의 %TEMP% 는 건드리지 않음)
for ($i = 0; $i -lt 30 -and (Test-Path -LiteralPath $run); $i++) {
if ($i -gt 0) { Start-Sleep -Seconds 2 }
cmd /c "rd /s /q `"\\?\$run`" 2>nul"
}
if (Test-Path -LiteralPath $run) { Write-Host "실행 폴더를 다 지우지 못함 (1분 기다림): $run" }
if ($left.Count -gt 0) {
if ($code -eq 0) { exit 2 }
} else {