통합: 묶음5 의 새 끝 (e81fa6e: 243~246 · 233 · 242 · 241 · 251 · 252 · 시험 흔들림 calm) 을 합침

관리자 지시 (10/12). 겹친 곳 없이 자동으로 합쳐짐 (번역 json · app_actions · home_page · settings_page).
번역 json 은 정렬 · 모양 그대로 확인, 빠진 번역 0, analyze 0, 전체 시험 646 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-12 05:49:18 +09:00
commit 55f5676cf5
25 changed files with 582 additions and 148 deletions

View File

@ -439,10 +439,12 @@
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
"{0} 전체": "All of {0}", "{0} 전체": "All of {0}",
"{0} 접기": "Collapse {0}",
"{0} 제거": "Remove {0}", "{0} 제거": "Remove {0}",
"{0} 추가": "Add {0}", "{0} 추가": "Add {0}",
"{0} 파일": "{0} file", "{0} 파일": "{0} file",
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)",
"{0} 펼치기": "Expand {0}",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "Folder {0} is missing or can't be opened, so it was removed from history", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "Folder {0} is missing or can't be opened, so it was removed from history",
"{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "Stopped because it made no progress past {0}% for over 2 minutes. The network may be blocking Tor or be slow. Press [Try again] in a moment.", "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "Stopped because it made no progress past {0}% for over 2 minutes. The network may be blocking Tor or be slow. Press [Try again] in a moment.",
"{0}. {1} (대기)": "{0}. {1} (waiting)", "{0}. {1} (대기)": "{0}. {1} (waiting)",
@ -926,6 +928,7 @@
"마지막으로: {0}": "To the last: {0}", "마지막으로: {0}": "To the last: {0}",
"만든 그림 · 저장: {0}": "Made images · saved in: {0}", "만든 그림 · 저장: {0}": "Made images · saved in: {0}",
"만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)",
"만들거나 쓰지 못했습니다": "Could not create or write",
"만들기": "Make", "만들기": "Make",
"만들다 만 파일 {0}개": "{0} incomplete files", "만들다 만 파일 {0}개": "{0} incomplete files",
"만들다 만 파일 {0}개를 지울까요?": "Delete {0} incomplete files?", "만들다 만 파일 {0}개를 지울까요?": "Delete {0} incomplete files?",
@ -1003,7 +1006,7 @@
"바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "To change or turn it off, enter the current master password (or the top password).", "바꾸거나 끄려면 지금 마스터 비밀번호 (또는 최상 비밀번호) 를 넣으세요.": "To change or turn it off, enter the current master password (or the top password).",
"바꾸기": "Change", "바꾸기": "Change",
"바꾸는 정도 {0}%": "Change amount {0}%", "바꾸는 정도 {0}%": "Change amount {0}%",
"바꾸면 바로 저장됩니다": "Changes are saved right away", "바꾸면 바로 저장됩니다": "Saved automatically",
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)", "바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
"바뀌는 것 없음 (양쪽이 같습니다)": "Nothing to change (both sides are the same)", "바뀌는 것 없음 (양쪽이 같습니다)": "Nothing to change (both sides are the same)",
"바뀐 것을 살펴보는 간격": "Interval for checking changes", "바뀐 것을 살펴보는 간격": "Interval for checking changes",
@ -1118,6 +1121,7 @@
"복사 실패 ({0}): {1}": "Copy failed ({0}): {1}", "복사 실패 ({0}): {1}": "Copy failed ({0}): {1}",
"복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "This is a copy task, so source-removing options in the field (--remove-source-files · /MOV) are not used. To move, turn on [Remove source files] on the Rsync screen", "복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "This is a copy task, so source-removing options in the field (--remove-source-files · /MOV) are not used. To move, turn on [Remove source files] on the Rsync screen",
"복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs", "복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs",
"복사로 맞춤": "Syncs by copying",
"복사하는 중": "Copying", "복사하는 중": "Copying",
"복사할": "copy", "복사할": "copy",
"복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy (tap, or mark with the circle on the right).", "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy (tap, or mark with the circle on the right).",
@ -1318,6 +1322,7 @@
"실시간 동기화: 원본 폴더": "Live sync: source folder", "실시간 동기화: 원본 폴더": "Live sync: source folder",
"실시간 동기화가 멈췄습니다": "Live sync stopped", "실시간 동기화가 멈췄습니다": "Live sync stopped",
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "No live sync. [Add] one, or use [Move to lsync] in the copy · rsync tab.", "실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "No live sync. [Add] one, or use [Move to lsync] in the copy · rsync tab.",
"실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.": "Real-time sync never deletes the source (it syncs by copying). To empty the source, use [Final cleanup] in lsync.",
"실시간 동기화를 지울까요?": "Delete this live sync?", "실시간 동기화를 지울까요?": "Delete this live sync?",
"실시간 동기화의 [최종 정리]": "[Final cleanup] in live sync", "실시간 동기화의 [최종 정리]": "[Final cleanup] in live sync",
"실제 실행: {0}": "Actually runs: {0}", "실제 실행: {0}": "Actually runs: {0}",
@ -1461,6 +1466,7 @@
"올린 그림은 지워집니다. 다시 보려면 해상도 올리기를 다시 해야 합니다.": "The upscaled image will be deleted. To see it again you will need to upscale again.", "올린 그림은 지워집니다. 다시 보려면 해상도 올리기를 다시 해야 합니다.": "The upscaled image will be deleted. To see it again you will need to upscale again.",
"올린 이 {0}": "Uploaded by {0}", "올린 이 {0}": "Uploaded by {0}",
"옮겼습니다.": "moved.", "옮겼습니다.": "moved.",
"옮기거나 이름을 바꾸지 못했습니다": "Could not move or rename",
"옮기기": "Move", "옮기기": "Move",
"옮기는 중": "Moving", "옮기는 중": "Moving",
"옮길": "move", "옮길": "move",
@ -1469,10 +1475,10 @@
"옵션": "Options", "옵션": "Options",
"옵션을 저장했습니다": "Options saved", "옵션을 저장했습니다": "Options saved",
"완료": "Done", "완료": "Done",
"완료 시 자동 동영상 추가": "Auto-add videos when done",
"완료 정리": "Clear completed", "완료 정리": "Clear completed",
"완료: 성공 {0} / 전체 {1}": "Done: {0} succeeded / {1} total", "완료: 성공 {0} / 전체 {1}": "Done: {0} succeeded / {1} total",
"완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "Done: {0} succeeded · {1} cancelled · {2} failed / {3} total", "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "Done: {0} succeeded · {1} cancelled · {2} failed / {3} total",
"완료시 자동 동영상추가": "Auto-add videos when done",
"외부": "External", "외부": "External",
"외부 브라우저": "External browser", "외부 브라우저": "External browser",
"외부 브라우저로 열기": "Open in external browser", "외부 브라우저로 열기": "Open in external browser",
@ -1517,6 +1523,7 @@
"원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "This run removes source files, so it can run once the list of files to move is shown.", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "This run removes source files, so it can run once the list of files to move is shown.",
"원본을 지우는 작업입니다 (이동)": "This task removes source files (move)", "원본을 지우는 작업입니다 (이동)": "This task removes source files (move)",
"원본의 {0}배": "{0}× original", "원본의 {0}배": "{0}× original",
"원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)": "Everything in the source is in the target ({0} items only in the target are left as they are)",
"원본의 빈 폴더 정리 중…": "Cleaning up empty folders in the source…", "원본의 빈 폴더 정리 중…": "Cleaning up empty folders in the source…",
"원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "Moves all files from the source to the target (deleting them from the source), then deletes the empty folders left in the source.", "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "Moves all files from the source to the target (deleting them from the source), then deletes the empty folders left in the source.",
"원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "The source is inside the target (blocked in settings)", "원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "The source is inside the target (blocked in settings)",
@ -1615,6 +1622,7 @@
"이 언어 지우기": "Remove this language", "이 언어 지우기": "Remove this language",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
"이 이동 버튼 지우기": "Delete this move button", "이 이동 버튼 지우기": "Delete this move button",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?",
"이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)", "이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)",
"이 파일용": "For this file", "이 파일용": "For this file",
"이 파일을 열 수 있는 앱이 없습니다.": "No app can open this file.", "이 파일을 열 수 있는 앱이 없습니다.": "No app can open this file.",
@ -1866,8 +1874,8 @@
"좌 ⇄ 우": "Left ⇄ right", "좌 ⇄ 우": "Left ⇄ right",
"좌우": "Side by side", "좌우": "Side by side",
"좌우 (기본)": "Side by side (default)", "좌우 (기본)": "Side by side (default)",
"좌우 ⇆": "Side ⇆", "좌우 ⇆": "Side by side ⇆",
"좌우 ⇆ / 위아래 ⇅": "Side by side ⇆ / Top and bottom ⇅", "좌우 ⇆ / 위아래 ⇅": "Side by side ⇆ / Stacked ⇅",
"좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "Sync two folders (rsync) · monitoring · live sync (lsync)", "좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "Sync two folders (rsync) · monitoring · live sync (lsync)",
"좌우 맞추기 (세로로 밀어 봄)": "Fit width (scroll vertically)", "좌우 맞추기 (세로로 밀어 봄)": "Fit width (scroll vertically)",
"좌우로 밀어 화면 이동": "Swipe left/right to switch screens", "좌우로 밀어 화면 이동": "Swipe left/right to switch screens",
@ -1914,6 +1922,7 @@
"지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "The app is running as administrator now. When off (default), the Chrome engine isn't started and Edge is suggested. When on, web pages run as administrator without a sandbox.", "지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "The app is running as administrator now. When off (default), the Chrome engine isn't started and Edge is suggested. When on, web pages run as administrator without a sandbox.",
"지금 끄기": "Stop now", "지금 끄기": "Stop now",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
"지금 도는 이동은 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running move is using the previous options. Stop it and run again with the new options?",
"지금 마스터 (또는 최상) 비밀번호": "Current master (or top) password", "지금 마스터 (또는 최상) 비밀번호": "Current master (or top) password",
"지금 맞추기": "Sync now", "지금 맞추기": "Sync now",
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "Syncing right now. Try again when it finishes.", "지금 맞추는 중입니다. 끝난 뒤에 하세요.": "Syncing right now. Try again when it finishes.",
@ -1944,6 +1953,7 @@
"지우면 휴지통으로": "Delete to the Recycle Bin", "지우면 휴지통으로": "Delete to the Recycle Bin",
"지우지 못한 파일: {0}": "Couldn't delete: {0}", "지우지 못한 파일: {0}": "Couldn't delete: {0}",
"지우지 못함: {0}": "Could not delete: {0}", "지우지 못함: {0}": "Could not delete: {0}",
"지우지 못했습니다 (권한이 없거나 사용 중)": "Could not delete (no permission or in use)",
"지우지 못했습니다: {0}": "Could not delete: {0}", "지우지 못했습니다: {0}": "Could not delete: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "Synced without deleting · confirm whether to delete {0} items only in the target", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "Synced without deleting · confirm whether to delete {0} items only in the target",
"지우지 않기 (지우기 끄기)": "Don't delete (turn off delete)", "지우지 않기 (지우기 끄기)": "Don't delete (turn off delete)",
@ -2089,7 +2099,9 @@
"파일 폴더": "File folder", "파일 폴더": "File folder",
"파일 확인 중…": "Checking files…", "파일 확인 중…": "Checking files…",
"파일만 골랐을 때": "When only files are selected", "파일만 골랐을 때": "When only files are selected",
"파일을 복사하지 못했습니다": "Could not copy the file",
"파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}", "파일을 분석할 수 없습니다: {0}": "Cannot analyze the file: {0}",
"파일을 읽지 못했습니다": "Could not read the file",
"파일이 깨졌습니다": "The file is damaged", "파일이 깨졌습니다": "The file is damaged",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "The file is damaged · tap [Download again]", "파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "The file is damaged · tap [Download again]",
"파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "Files are not shown: \"All files access\" is not allowed", "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "Files are not shown: \"All files access\" is not allowed",

View File

@ -439,10 +439,12 @@
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
"{0} 전체": "{0} 全体", "{0} 전체": "{0} 全体",
"{0} 접기": "{0} を折りたたむ",
"{0} 제거": "{0} の削除", "{0} 제거": "{0} の削除",
"{0} 추가": "{0} を追加", "{0} 추가": "{0} を追加",
"{0} 파일": "{0} ファイル", "{0} 파일": "{0} ファイル",
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)",
"{0} 펼치기": "{0} を展開",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "{0} フォルダーが存在しないか開けないため、履歴から削除しました", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "{0} フォルダーが存在しないか開けないため、履歴から削除しました",
"{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "{0}% から 2 分以上進まなかったため停止しました。ネットワークが Tor をブロックしているか、遅い可能性があります。少し待ってから [再試行] してください。", "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "{0}% から 2 分以上進まなかったため停止しました。ネットワークが Tor をブロックしているか、遅い可能性があります。少し待ってから [再試行] してください。",
"{0}. {1} (대기)": "{0}. {1}(待機)", "{0}. {1} (대기)": "{0}. {1}(待機)",
@ -926,6 +928,7 @@
"마지막으로: {0}": "最後へ: {0}", "마지막으로: {0}": "最後へ: {0}",
"만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}",
"만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)",
"만들거나 쓰지 못했습니다": "作成または書き込みできませんでした",
"만들기": "作成", "만들기": "作成",
"만들다 만 파일 {0}개": "未完成のファイル {0} 個", "만들다 만 파일 {0}개": "未完成のファイル {0} 個",
"만들다 만 파일 {0}개를 지울까요?": "未完成のファイル {0} 個を削除しますか?", "만들다 만 파일 {0}개를 지울까요?": "未完成のファイル {0} 個を削除しますか?",
@ -1118,6 +1121,7 @@
"복사 실패 ({0}): {1}": "コピー失敗 ({0}): {1}", "복사 실패 ({0}): {1}": "コピー失敗 ({0}): {1}",
"복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "コピー作業のため、欄の元ファイル削除オプション (--remove-source-files · /MOV) は使いません。移動するには Rsync 画面で [元ファイルを削除] をオンにしてください", "복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "コピー作業のため、欄の元ファイル削除オプション (--remove-source-files · /MOV) は使いません。移動するには Rsync 画面で [元ファイルを削除] をオンにしてください",
"복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード", "복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード",
"복사로 맞춤": "コピーで合わせる",
"복사하는 중": "コピー中", "복사하는 중": "コピー中",
"복사할": "コピーする", "복사할": "コピーする",
"복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピーする項目を選んでください (タップするか右の丸で印を付ける)。", "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピーする項目を選んでください (タップするか右の丸で印を付ける)。",
@ -1318,6 +1322,7 @@
"실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー", "실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー",
"실시간 동기화가 멈췄습니다": "リアルタイム同期が停止しました", "실시간 동기화가 멈췄습니다": "リアルタイム同期が停止しました",
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "リアルタイム同期はありません。[追加] するか、コピー · rsync タブで [lsync へ移動] してください。", "실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "リアルタイム同期はありません。[追加] するか、コピー · rsync タブで [lsync へ移動] してください。",
"실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.": "リアルタイム同期は元を削除しません (コピーで合わせます)。元を空にするには lsync の [最終整理] を使ってください。",
"실시간 동기화를 지울까요?": "このリアルタイム同期を削除しますか?", "실시간 동기화를 지울까요?": "このリアルタイム同期を削除しますか?",
"실시간 동기화의 [최종 정리]": "リアルタイム同期の [最終整理]", "실시간 동기화의 [최종 정리]": "リアルタイム同期の [最終整理]",
"실제 실행: {0}": "実際の実行: {0}", "실제 실행: {0}": "実際の実行: {0}",
@ -1461,6 +1466,7 @@
"올린 그림은 지워집니다. 다시 보려면 해상도 올리기를 다시 해야 합니다.": "高解像度化した画像は削除されます。もう一度見るには高解像度化をやり直す必要があります。", "올린 그림은 지워집니다. 다시 보려면 해상도 올리기를 다시 해야 합니다.": "高解像度化した画像は削除されます。もう一度見るには高解像度化をやり直す必要があります。",
"올린 이 {0}": "投稿者 {0}", "올린 이 {0}": "投稿者 {0}",
"옮겼습니다.": "移動しました。", "옮겼습니다.": "移動しました。",
"옮기거나 이름을 바꾸지 못했습니다": "移動または名前の変更ができませんでした",
"옮기기": "移動", "옮기기": "移動",
"옮기는 중": "移動中", "옮기는 중": "移動中",
"옮길": "移す", "옮길": "移す",
@ -1469,10 +1475,10 @@
"옵션": "オプション", "옵션": "オプション",
"옵션을 저장했습니다": "オプションを保存しました", "옵션을 저장했습니다": "オプションを保存しました",
"완료": "完了", "완료": "完了",
"완료 시 자동 동영상 추가": "完了時に動画を自動追加",
"완료 정리": "完了を整理", "완료 정리": "完了を整理",
"완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}", "완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}",
"완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完了: 成功 {0} · 取消 {1} · 失敗 {2} / 全 {3}", "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完了: 成功 {0} · 取消 {1} · 失敗 {2} / 全 {3}",
"완료시 자동 동영상추가": "完了時に動画を自動追加",
"외부": "外部", "외부": "外部",
"외부 브라우저": "外部ブラウザー", "외부 브라우저": "外部ブラウザー",
"외부 브라우저로 열기": "外部ブラウザーで開く", "외부 브라우저로 열기": "外部ブラウザーで開く",
@ -1517,6 +1523,7 @@
"원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "元のファイルを削除する実行のため、移動する一覧が表示されてから実行できます。", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "元のファイルを削除する実行のため、移動する一覧が表示されてから実行できます。",
"원본을 지우는 작업입니다 (이동)": "元のファイルを削除する作業です (移動)", "원본을 지우는 작업입니다 (이동)": "元のファイルを削除する作業です (移動)",
"원본의 {0}배": "元の {0}倍", "원본의 {0}배": "元の {0}倍",
"원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)": "元のものはすべて対象にあります (対象にだけある {0} 件はそのまま)",
"원본의 빈 폴더 정리 중…": "元の空フォルダーを整理中…", "원본의 빈 폴더 정리 중…": "元の空フォルダーを整理中…",
"원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "元のファイルをすべて対象へ移し (元から削除)、元に残った空フォルダーを削除します。", "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "元のファイルをすべて対象へ移し (元から削除)、元に残った空フォルダーを削除します。",
"원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "元が対象の中にあります (環境設定で禁止)", "원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "元が対象の中にあります (環境設定で禁止)",
@ -1615,6 +1622,7 @@
"이 언어 지우기": "この言語を削除", "이 언어 지우기": "この言語を削除",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
"이 이동 버튼 지우기": "この移動ボタンを削除", "이 이동 버튼 지우기": "この移動ボタンを削除",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
"이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)", "이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)",
"이 파일용": "このファイル用", "이 파일용": "このファイル用",
"이 파일을 열 수 있는 앱이 없습니다.": "このファイルを開けるアプリがありません。", "이 파일을 열 수 있는 앱이 없습니다.": "このファイルを開けるアプリがありません。",
@ -1914,6 +1922,7 @@
"지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "現在、管理者権限で実行中です。オフ (既定) の場合は Chrome エンジンを起動せず Edge をおすすめします。オンにすると、サンドボックスなしで管理者権限のまま Web ページを動かします。", "지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "現在、管理者権限で実行中です。オフ (既定) の場合は Chrome エンジンを起動せず Edge をおすすめします。オンにすると、サンドボックスなしで管理者権限のまま Web ページを動かします。",
"지금 끄기": "今すぐ終了", "지금 끄기": "今すぐ終了",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
"지금 도는 이동은 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中の移動は前のオプションで動いています。止めて新しいオプションでやり直しますか?",
"지금 마스터 (또는 최상) 비밀번호": "現在のマスター (または最上位) パスワード", "지금 마스터 (또는 최상) 비밀번호": "現在のマスター (または最上位) パスワード",
"지금 맞추기": "今すぐ同期", "지금 맞추기": "今すぐ同期",
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "同期中です。終わってから行ってください。", "지금 맞추는 중입니다. 끝난 뒤에 하세요.": "同期中です。終わってから行ってください。",
@ -1944,6 +1953,7 @@
"지우면 휴지통으로": "削除するとごみ箱へ", "지우면 휴지통으로": "削除するとごみ箱へ",
"지우지 못한 파일: {0}": "削除できなかったファイル: {0}", "지우지 못한 파일: {0}": "削除できなかったファイル: {0}",
"지우지 못함: {0}": "削除できません: {0}", "지우지 못함: {0}": "削除できません: {0}",
"지우지 못했습니다 (권한이 없거나 사용 중)": "削除できませんでした (権限がないか使用中)",
"지우지 못했습니다: {0}": "削除できませんでした: {0}", "지우지 못했습니다: {0}": "削除できませんでした: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "削除せずに同期・対象にだけある {0} 個を削除するか確認が必要", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "削除せずに同期・対象にだけある {0} 個を削除するか確認が必要",
"지우지 않기 (지우기 끄기)": "削除しない (削除をオフ)", "지우지 않기 (지우기 끄기)": "削除しない (削除をオフ)",
@ -2089,7 +2099,9 @@
"파일 폴더": "ファイル フォルダー", "파일 폴더": "ファイル フォルダー",
"파일 확인 중…": "ファイルを確認中…", "파일 확인 중…": "ファイルを確認中…",
"파일만 골랐을 때": "ファイルだけを選んだとき", "파일만 골랐을 때": "ファイルだけを選んだとき",
"파일을 복사하지 못했습니다": "ファイルをコピーできませんでした",
"파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}", "파일을 분석할 수 없습니다: {0}": "ファイルを分析できません: {0}",
"파일을 읽지 못했습니다": "ファイルを読み取れませんでした",
"파일이 깨졌습니다": "ファイルが壊れています", "파일이 깨졌습니다": "ファイルが壊れています",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "ファイルが壊れています・[再ダウンロード] を押してください", "파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "ファイルが壊れています・[再ダウンロード] を押してください",
"파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "ファイルが表示されません: 「すべてのファイルへのアクセス」権限がありません", "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "ファイルが表示されません: 「すべてのファイルへのアクセス」権限がありません",

View File

@ -439,10 +439,12 @@
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
"{0} 전체": "{0} 全部", "{0} 전체": "{0} 全部",
"{0} 접기": "折叠 {0}",
"{0} 제거": "移除 {0}", "{0} 제거": "移除 {0}",
"{0} 추가": "添加 {0}", "{0} 추가": "添加 {0}",
"{0} 파일": "{0} 文件", "{0} 파일": "{0} 文件",
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)",
"{0} 펼치기": "展开 {0}",
"{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "文件夹 {0} 不存在或无法打开,已从历史记录中移除", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "文件夹 {0} 不存在或无法打开,已从历史记录中移除",
"{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "在 {0}% 停滞超过 2 分钟,已停止。网络可能在阻止 Tor 或速度较慢。请稍后点 [重试]。", "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "在 {0}% 停滞超过 2 分钟,已停止。网络可能在阻止 Tor 或速度较慢。请稍后点 [重试]。",
"{0}. {1} (대기)": "{0}. {1}(等待)", "{0}. {1} (대기)": "{0}. {1}(等待)",
@ -926,6 +928,7 @@
"마지막으로: {0}": "到最后: {0}", "마지막으로: {0}": "到最后: {0}",
"만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}",
"만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)",
"만들거나 쓰지 못했습니다": "无法创建或写入",
"만들기": "生成", "만들기": "生成",
"만들다 만 파일 {0}개": "{0} 个未完成的文件", "만들다 만 파일 {0}개": "{0} 个未完成的文件",
"만들다 만 파일 {0}개를 지울까요?": "删除 {0} 个未完成的文件吗?", "만들다 만 파일 {0}개를 지울까요?": "删除 {0} 个未完成的文件吗?",
@ -1118,6 +1121,7 @@
"복사 실패 ({0}): {1}": "复制失败 ({0}): {1}", "복사 실패 ({0}): {1}": "复制失败 ({0}): {1}",
"복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "这是复制任务,栏中的删除源文件选项(--remove-source-files · /MOV)不会使用。要移动,请在 Rsync 界面打开 [删除源文件]", "복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "这是复制任务,栏中的删除源文件选项(--remove-source-files · /MOV)不会使用。要移动,请在 Rsync 界面打开 [删除源文件]",
"복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址", "복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址",
"복사로 맞춤": "以复制同步",
"복사하는 중": "正在复制", "복사하는 중": "正在复制",
"복사할": "复制", "복사할": "复制",
"복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制的项目 (点按或用右侧圆圈标记)。", "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制的项目 (点按或用右侧圆圈标记)。",
@ -1318,6 +1322,7 @@
"실시간 동기화: 원본 폴더": "实时同步: 源文件夹", "실시간 동기화: 원본 폴더": "实时同步: 源文件夹",
"실시간 동기화가 멈췄습니다": "实时同步已停止", "실시간 동기화가 멈췄습니다": "实时同步已停止",
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "没有实时同步。请 [添加],或在 复制 · rsync 标签中 [移到 lsync]。", "실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "没有实时同步。请 [添加],或在 复制 · rsync 标签中 [移到 lsync]。",
"실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.": "实时同步不会删除源(以复制方式同步)。要清空源,请使用 lsync 的 [最终整理]。",
"실시간 동기화를 지울까요?": "删除此实时同步吗?", "실시간 동기화를 지울까요?": "删除此实时同步吗?",
"실시간 동기화의 [최종 정리]": "实时同步的 [最终整理]", "실시간 동기화의 [최종 정리]": "实时同步的 [最终整理]",
"실제 실행: {0}": "实际执行:{0}", "실제 실행: {0}": "实际执行:{0}",
@ -1461,6 +1466,7 @@
"올린 그림은 지워집니다. 다시 보려면 해상도 올리기를 다시 해야 합니다.": "提升后的图片将被删除。要再次查看需要重新提升分辨率。", "올린 그림은 지워집니다. 다시 보려면 해상도 올리기를 다시 해야 합니다.": "提升后的图片将被删除。要再次查看需要重新提升分辨率。",
"올린 이 {0}": "上传者 {0}", "올린 이 {0}": "上传者 {0}",
"옮겼습니다.": "已移动。", "옮겼습니다.": "已移动。",
"옮기거나 이름을 바꾸지 못했습니다": "无法移动或重命名",
"옮기기": "移动", "옮기기": "移动",
"옮기는 중": "正在移动", "옮기는 중": "正在移动",
"옮길": "移动", "옮길": "移动",
@ -1469,10 +1475,10 @@
"옵션": "选项", "옵션": "选项",
"옵션을 저장했습니다": "已保存选项", "옵션을 저장했습니다": "已保存选项",
"완료": "完成", "완료": "完成",
"완료 시 자동 동영상 추가": "完成后自动添加视频",
"완료 정리": "清理已完成", "완료 정리": "清理已完成",
"완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}", "완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}",
"완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完成:成功 {0} · 取消 {1} · 失败 {2} / 共 {3}", "완료: 성공 {0} · 취소 {1} · 실패 {2} / 전체 {3}": "完成:成功 {0} · 取消 {1} · 失败 {2} / 共 {3}",
"완료시 자동 동영상추가": "完成后自动添加视频",
"외부": "外部", "외부": "外部",
"외부 브라우저": "外部浏览器", "외부 브라우저": "外部浏览器",
"외부 브라우저로 열기": "在外部浏览器中打开", "외부 브라우저로 열기": "在外部浏览器中打开",
@ -1517,6 +1523,7 @@
"원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "此操作会删除源文件,需在显示要移动的列表后才能执行。", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "此操作会删除源文件,需在显示要移动的列表后才能执行。",
"원본을 지우는 작업입니다 (이동)": "此任务会删除源文件(移动)", "원본을 지우는 작업입니다 (이동)": "此任务会删除源文件(移动)",
"원본의 {0}배": "原始的 {0} 倍", "원본의 {0}배": "原始的 {0} 倍",
"원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)": "源中的内容都已在目标中(仅在目标中的 {0} 项保持不变)",
"원본의 빈 폴더 정리 중…": "正在清理源中的空文件夹…", "원본의 빈 폴더 정리 중…": "正在清理源中的空文件夹…",
"원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "将源中的所有文件移到目标 (从源中删除),然后删除源中剩下的空文件夹。", "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "将源中的所有文件移到目标 (从源中删除),然后删除源中剩下的空文件夹。",
"원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "源位于目标内 (已在设置中阻止)", "원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "源位于目标内 (已在设置中阻止)",
@ -1615,6 +1622,7 @@
"이 언어 지우기": "删除此语言", "이 언어 지우기": "删除此语言",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
"이 이동 버튼 지우기": "删除此移动按钮", "이 이동 버튼 지우기": "删除此移动按钮",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",
"이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)", "이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)",
"이 파일용": "适用于此文件", "이 파일용": "适用于此文件",
"이 파일을 열 수 있는 앱이 없습니다.": "没有可以打开此文件的应用。", "이 파일을 열 수 있는 앱이 없습니다.": "没有可以打开此文件的应用。",
@ -1914,6 +1922,7 @@
"지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "当前正以管理员权限运行。关闭时(默认)不启动 Chrome 引擎并建议使用 Edge。开启后将在无沙盒的情况下以管理员权限运行网页。", "지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "当前正以管理员权限运行。关闭时(默认)不启动 Chrome 引擎并建议使用 Edge。开启后将在无沙盒的情况下以管理员权限运行网页。",
"지금 끄기": "立即关闭", "지금 끄기": "立即关闭",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
"지금 도는 이동은 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在进行的移动使用的是之前的选项。要停止并用新选项重新执行吗?",
"지금 마스터 (또는 최상) 비밀번호": "当前主密码 (或最高密码)", "지금 마스터 (또는 최상) 비밀번호": "当前主密码 (或最高密码)",
"지금 맞추기": "立即同步", "지금 맞추기": "立即同步",
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "正在同步。请等结束后再进行。", "지금 맞추는 중입니다. 끝난 뒤에 하세요.": "正在同步。请等结束后再进行。",
@ -1944,6 +1953,7 @@
"지우면 휴지통으로": "删除时移到回收站", "지우면 휴지통으로": "删除时移到回收站",
"지우지 못한 파일: {0}": "无法删除的文件: {0}", "지우지 못한 파일: {0}": "无法删除的文件: {0}",
"지우지 못함: {0}": "无法删除: {0}", "지우지 못함: {0}": "无法删除: {0}",
"지우지 못했습니다 (권한이 없거나 사용 중)": "无法删除(没有权限或正在使用)",
"지우지 못했습니다: {0}": "无法删除: {0}", "지우지 못했습니다: {0}": "无法删除: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "已同步但未删除 · 需确认是否删除仅在目标中的 {0} 个项目", "지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "已同步但未删除 · 需确认是否删除仅在目标中的 {0} 个项目",
"지우지 않기 (지우기 끄기)": "不删除 (关闭删除)", "지우지 않기 (지우기 끄기)": "不删除 (关闭删除)",
@ -2089,7 +2099,9 @@
"파일 폴더": "文件夹", "파일 폴더": "文件夹",
"파일 확인 중…": "正在检查文件…", "파일 확인 중…": "正在检查文件…",
"파일만 골랐을 때": "只选择文件时", "파일만 골랐을 때": "只选择文件时",
"파일을 복사하지 못했습니다": "无法复制文件",
"파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}", "파일을 분석할 수 없습니다: {0}": "无法分析文件: {0}",
"파일을 읽지 못했습니다": "无法读取文件",
"파일이 깨졌습니다": "文件已损坏", "파일이 깨졌습니다": "文件已损坏",
"파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "文件已损坏 · 请点按 [重新下载]", "파일이 깨졌습니다 · [다시 받기] 를 눌러 주세요": "文件已损坏 · 请点按 [重新下载]",
"파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "文件不可见: 没有“所有文件访问”权限", "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "文件不可见: 没有“所有文件访问”权限",

View File

@ -142,7 +142,7 @@ class CopyCenter extends ChangeNotifier {
if (job.leftInSource > 0) trf('{0}개는 원본에 남음 (건너뜀)', [job.leftInSource]), if (job.leftInSource > 0) trf('{0}개는 원본에 남음 (건너뜀)', [job.leftInSource]),
if (job.pruned > 0) trf('빈 폴더 {0}개 지움', [job.pruned]), if (job.pruned > 0) trf('빈 폴더 {0}개 지움', [job.pruned]),
].join(' · ') ].join(' · ')
: userErrorText(e), // 237: 예외 이름 없이 (원래 글은 작업 기록에) : userErrorText(e, sources: t.sources), // 237 · 245: 예외 이름 없이 · 화면 언어로 (원래 글은 작업 기록에)
lastFiles: job.doneFiles, lastFiles: job.doneFiles,
lastLeft: e == null ? job.leftInSource : 0, lastLeft: e == null ? job.leftInSource : 0,
lastDetail: e == null || e is FileOpCancelled ? '' : '$e', lastDetail: e == null || e is FileOpCancelled ? '' : '$e',

View File

@ -38,7 +38,7 @@ class DownloadManager extends ChangeNotifier {
/// 받은 동영상을 편집 목록(MKV 만들기)에 넣는 함수 (새로 넣은 개수를 돌려줌). 앱이 연결해 준다. /// 받은 동영상을 편집 목록(MKV 만들기)에 넣는 함수 (새로 넣은 개수를 돌려줌). 앱이 연결해 준다.
Future<int> Function(List<String> files)? addToEditList; Future<int> Function(List<String> files)? addToEditList;
/// "완료시 자동 동영상추가" 를 켜고 끄는 함수 (설정에 저장). 앱이 연결해 준다. /// "완료 시 자동 동영상 추가" 를 켜고 끄는 함수 (설정에 저장). 앱이 연결해 준다.
Future<void> Function(bool on)? setAutoAdd; Future<void> Function(bool on)? setAutoAdd;
/// 설정이 바뀌었을 때 화면 갱신 /// 설정이 바뀌었을 때 화면 갱신

View File

@ -247,10 +247,28 @@ class LiveSync extends ChangeNotifier {
} }
try { try {
pending[k] = await diff(x); pending[k] = await diff(x);
await _countOnlyTarget(x);
} catch (_) {} } catch (_) {}
notifyListeners(); notifyListeners();
} }
/// 244: 지우기 포함이 아닌 쌍에서 대상에만 있는 항목 수 (맞출 것이 없을 때 "같습니다" 대신 사실대로 보이려고)
final onlyTarget = <String, int>{};
Future<void> _countOnlyTarget(LiveSyncPair x) async {
final k = keyOf(x);
if (x.delete || (pending[k]?.isNotEmpty ?? true)) {
onlyTarget.remove(k);
return;
}
// 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것
try {
onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000)) if (d.startsWith('− ')) d].length;
} catch (_) {
onlyTarget.remove(k); // 세지 못하면 예전 글
}
}
static String keyOf(LiveSyncPair x) => '${x.source}=>${x.target}'; static String keyOf(LiveSyncPair x) => '${x.source}=>${x.target}';
/// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42) /// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42)
@ -401,6 +419,7 @@ class LiveSync extends ChangeNotifier {
problems.remove(k); problems.remove(k);
emptyDeletes.remove(k); emptyDeletes.remove(k);
pending[k] = await diff(asked); pending[k] = await diff(asked);
await _countOnlyTarget(asked);
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target])); if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
} on SourceUnreadableException catch (e) { } on SourceUnreadableException catch (e) {
// 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에 // 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에

View File

@ -14,17 +14,39 @@ import '../l10n/tr.dart';
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: /// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 /// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
/// - 아래쪽: 지금 항목 (폴더) 의 파일 중 몇 개를 했는지 /// - 아래쪽: 지금 항목 (폴더) 의 파일 중 몇 개를 했는지
/// 215: rsync 가 실패했을 때 보일 글 - 원본을 못 지웠으면 쉬운 말 한 줄, 그리고 오류 · 권한 줄을 끝쪽부터 (전송한 파일 이름이 아니라).
/// 예전에는 출력의 앞쪽 8줄이라 파일 이름만 보이고 "Permission denied" · "rsync error … (code 23)" 가 빠지기 쉬웠다
/// 237: 사용자에게 보이는 실패 글 - "ProcessException:" 같은 예외 이름 · "Command:" 줄은 빼고 내용만. /// 237: 사용자에게 보이는 실패 글 - "ProcessException:" 같은 예외 이름 · "Command:" 줄은 빼고 내용만.
/// 원래 글 ('$e') 은 작업 기록에 그대로 남긴다 /// 원래 글 ('$e') 은 작업 기록에 그대로 남긴다.
String userErrorText(Object e) => switch (e) { /// 245: 앱이 직접 할 때의 파일 오류 (Dart 의 "Cannot delete file" 등 영어) 는 화면 언어로. [sources] 안의 파일을 못 지웠으면
/// rsync 때 (215) 와 같은 글 - 이동에서 원본만 남은 것
String userErrorText(Object e, {List<String> sources = const []}) => switch (e) {
ProcessException(:final message) when message.isNotEmpty => message, ProcessException(:final message) when message.isNotEmpty => message,
FileSystemException(:final message, :final path, :final osError) => FileSystemException(:final message, :final path, :final osError) => [
[message, if (path != null && path.isNotEmpty) path, if (osError != null && osError.message.isNotEmpty) osError.message].join(' · '), _fileErrorText(message, path, sources),
if (path != null && path.isNotEmpty) path,
if (osError != null && osError.message.isNotEmpty) osError.message,
].join(' · '),
_ => '$e'.replaceFirst(RegExp(r'^\w*(Exception|Error)(\([^)]*\))?: '), ''), _ => '$e'.replaceFirst(RegExp(r'^\w*(Exception|Error)(\([^)]*\))?: '), ''),
}; };
String _fileErrorText(String message, String? path, List<String> sources) {
final m = message.toLowerCase();
if (m.startsWith('cannot delete') || m.startsWith('deletion failed')) {
final inSource = path != null && sources.any((s) => isSameOrInside(path, s));
return inSource
? tr('원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다.')
: tr('지우지 못했습니다 (권한이 없거나 사용 중)');
}
if (m.startsWith('cannot copy')) return tr('파일을 복사하지 못했습니다');
if (m.startsWith('cannot rename')) return tr('옮기거나 이름을 바꾸지 못했습니다');
if (m.startsWith('cannot open') || m.startsWith('cannot read')) return tr('파일을 읽지 못했습니다');
if (m.startsWith('cannot create') || m.startsWith('creation failed') || m.startsWith('cannot write')) {
return tr('만들거나 쓰지 못했습니다');
}
return message;
}
/// 215: rsync 가 실패했을 때 보일 글 - 원본을 못 지웠으면 쉬운 말 한 줄, 그리고 오류 · 권한 줄을 끝쪽부터 (전송한 파일 이름이 아니라).
/// 예전에는 출력의 앞쪽 8줄이라 파일 이름만 보이고 "Permission denied" · "rsync error … (code 23)" 가 빠지기 쉬웠다
String rsyncFailureText(int code, List<String> errLines, List<String> allLines) { String rsyncFailureText(int code, List<String> errLines, List<String> allLines) {
final all = [...errLines, ...allLines]; final all = [...errLines, ...allLines];
final removeFailed = all.any((l) { final removeFailed = all.any((l) {

View File

@ -27,13 +27,22 @@ bool isCompact(BuildContext context) => MediaQuery.sizeOf(context).width < compa
/// 모든 화면의 위쪽 막대: [nav] [middle (남는 너비)] [actions]. /// 모든 화면의 위쪽 막대: [nav] [middle (남는 너비)] [actions].
/// 좁은 화면에서는 두 줄 - 위: [nav] … [actions], 아래: [middle] 이 한 줄을 다 쓴다. /// 좁은 화면에서는 두 줄 - 위: [nav] … [actions], 아래: [middle] 이 한 줄을 다 쓴다.
/// [minMiddleWidth] (201): 한 줄일 때 middle 이 이보다 좁아지면 (폰 가로처럼 넓지만 버튼이 폭을 다 차지할 때) 두 줄로. /// [minMiddleWidth] (201): 한 줄일 때 middle 이 이보다 좁아지면 (폰 가로처럼 넓지만 버튼이 폭을 다 차지할 때) 두 줄로.
/// 브라우저 주소 칸처럼 middle 이 꼭 보여야 하는 화면이 준다 (0 이면 폭만으로 정함) /// 브라우저 주소 칸처럼 middle 이 꼭 보여야 하는 화면이 준다 (0 이면 폭만으로 정함).
/// [trailing] (242): middle 오른쪽에 붙는, 줄지 않는 버튼 묶음 - 이것 때문에 middle 이 [minMiddleWidth] 보다 좁아지면 두 줄
/// (어림한 폭이 실제보다 작아 넘친 버튼이 화면 크기 "100%" 를 덮던 것 - 실제로 재어 정한다)
class AppTopBar extends StatefulWidget { class AppTopBar extends StatefulWidget {
final Widget nav; final Widget nav;
final Widget middle; final Widget middle;
final Widget? trailing;
final Widget actions; final Widget actions;
final double minMiddleWidth; final double minMiddleWidth;
const AppTopBar({super.key, required this.nav, required this.middle, required this.actions, this.minMiddleWidth = 0}); const AppTopBar(
{super.key,
required this.nav,
required this.middle,
this.trailing,
required this.actions,
this.minMiddleWidth = 0});
@override @override
State<AppTopBar> createState() => _AppTopBarState(); State<AppTopBar> createState() => _AppTopBarState();
@ -63,7 +72,23 @@ class _AppTopBarState extends State<AppTopBar> {
height: appBarHeight, height: appBarHeight,
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [nav, const SizedBox(width: 8), Expanded(child: KeyedSubtree(key: _mid, child: middle)), actions]), // 242: middle 이 넘쳐도 오른쪽 (화면 크기 "100%" · 설정 · 종료) 을 덮어 그리지 않게.
// trailing 이 있으면 제 폭대로 재고 남는 폭을 middle 에 (모자라면 [_check] 가 두 줄로 - 그사이 넘침 오류 없이 잘라 그림)
child: widget.trailing == null
? Row(children: [
nav,
const SizedBox(width: 8),
Expanded(child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))),
actions,
])
: ClipRect(
child: CustomMultiChildLayout(delegate: _BarLayout(), children: [
LayoutId(id: #nav, child: nav),
LayoutId(id: #mid, child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))),
LayoutId(id: #trail, child: widget.trailing!),
LayoutId(id: #act, child: actions),
]),
),
); );
} }
return Container( return Container(
@ -78,12 +103,37 @@ class _AppTopBarState extends State<AppTopBar> {
actions, actions,
]), ]),
), ),
SizedBox(height: 48, child: Padding(padding: const EdgeInsets.only(left: 6), child: middle)), SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: widget.trailing == null ? middle : Row(children: [Expanded(child: middle), widget.trailing!]))),
]), ]),
); );
} }
} }
/// 242: [nav] [middle (남는 폭)] [trailing] [actions] - nav · trailing · actions 는 제 폭대로, 세로 가운데
class _BarLayout extends MultiChildLayoutDelegate {
@override
void performLayout(Size size) {
final loose = BoxConstraints(maxHeight: size.height);
final nav = layoutChild(#nav, loose);
final act = layoutChild(#act, loose);
final trail = layoutChild(#trail, loose);
final midW = (size.width - nav.width - 8 - trail.width - act.width).clamp(0.0, double.infinity);
final mid = layoutChild(#mid, BoxConstraints(minWidth: midW, maxWidth: midW, maxHeight: size.height));
double y(Size s) => (size.height - s.height) / 2;
positionChild(#nav, Offset(0, y(nav)));
positionChild(#mid, Offset(nav.width + 8, y(mid)));
positionChild(#trail, Offset(nav.width + 8 + midW, y(trail)));
positionChild(#act, Offset(size.width - act.width, y(act)));
}
@override
bool shouldRelayout(_BarLayout oldDelegate) => false;
}
/// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다 /// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다
class AppScope extends InheritedWidget { class AppScope extends InheritedWidget {
final AppController controller; final AppController controller;

View File

@ -151,13 +151,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
onChanged: (v) => c.updateSettings((x) => x.copyConflict = v ?? 'ask'), onChanged: (v) => c.updateSettings((x) => x.copyConflict = v ?? 'ask'),
), ),
), ),
// 229: 폴더 안의 것을 맞출 때 (모니터링의 현재 방식 · WebDAV) - rsync 의 -u 와 같다 _skipNewerTile(),
SwitchListTile(
value: s.copySkipNewer,
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')),
),
SettingTile( SettingTile(
title: Text(tr('속도 제한 (KB/s)')), title: Text(tr('속도 제한 (KB/s)')),
subtitle: Text(tr('0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. ' subtitle: Text(tr('0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. '
@ -178,6 +172,14 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
} }
/// Rsync 화면: rsync 옵션 · rsync 가져오기 · 실시간 동기화 (lsync) /// Rsync 화면: rsync 옵션 · rsync 가져오기 · 실시간 동기화 (lsync)
/// 229: 받는 쪽이 더 새것이면 건너뛰기 - 파일 탐색기 · Rsync 탭 두 곳에 같은 설정 (Rsync 화면 · 모니터링에서도 쓰여 찾기 쉽게)
Widget _skipNewerTile() => SwitchListTile(
value: c.settings.copySkipNewer,
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')),
);
Widget _rsyncPart() { Widget _rsyncPart() {
final s = c.settings; final s = c.settings;
final desk = Platform.isWindows; final desk = Platform.isWindows;
@ -186,6 +188,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. ' _options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. '
'새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'), '새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'),
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v), s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v),
_skipNewerTile(),
SettingTile( SettingTile(
title: Text(tr('rsync 가져오기')), title: Text(tr('rsync 가져오기')),
subtitle: Text(_checking subtitle: Text(_checking

View File

@ -119,11 +119,12 @@ class _DownloadsPageState extends State<DownloadsPage> {
Container( Container(
color: JjColors.bg, color: JjColors.bg,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(children: [ // 246: 좁으면 (폰 가로 등) 버튼들을 다음 줄로 - 예전에는 옆으로 미는 줄이라 앞쪽 글이 잘려 "전체 선택" 옆에 겹쳐 보였다
child: Wrap(alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, children: [
InkWell( InkWell(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
onTap: d.tasks.isEmpty ? null : () => allSel ? d.selectNone() : d.selectAll(), onTap: d.tasks.isEmpty ? null : () => allSel ? d.selectNone() : d.selectAll(),
child: Row(children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
Checkbox( Checkbox(
value: allSel ? true : (hasSel ? null : false), value: allSel ? true : (hasSel ? null : false),
tristate: true, tristate: true,
@ -136,12 +137,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
), ),
]), ]),
), ),
// 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 끝부터 보임) Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(children: [
if (d.addToEditList != null) ...[ if (d.addToEditList != null) ...[
_btn(Icons.playlist_add, tr('동영상 추가'), _btn(Icons.playlist_add, tr('동영상 추가'),
hasSel && d.selectedVideoFiles.isNotEmpty ? () => _addToEditList(context) : null), hasSel && d.selectedVideoFiles.isNotEmpty ? () => _addToEditList(context) : null),
@ -160,7 +156,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
), ),
Padding( Padding(
padding: EdgeInsets.only(right: 8), padding: EdgeInsets.only(right: 8),
child: Text(tr('완료시 자동 동영상추가'), style: TextStyle(fontSize: 12)), child: Text(tr('완료 시 자동 동영상 추가'), style: TextStyle(fontSize: 12)),
), ),
]), ]),
), ),
@ -175,9 +171,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
const SizedBox(width: 12), const SizedBox(width: 12),
_btn(Icons.cleaning_services_outlined, tr('완료 정리'), _btn(Icons.cleaning_services_outlined, tr('완료 정리'),
d.tasks.any((t) => !t.unfinished) ? d.cleanupFinished : null), d.tasks.any((t) => !t.unfinished) ? d.cleanupFinished : null),
]), ]),
),
),
]), ]),
), ),
const Divider(height: 1), const Divider(height: 1),

View File

@ -3367,12 +3367,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
? (loading ? (loading
? const Center( ? const Center(
child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)))
: InkResponse( // 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기")
radius: chevronWidth / 2 + 4, : Semantics(
containedInkWell: false, button: true,
label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]),
excludeSemantics: true,
onTap: () => _toggle(pane, e), onTap: () => _toggle(pane, e),
child: Center( child: InkResponse(
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)), radius: chevronWidth / 2 + 4,
containedInkWell: false,
onTap: () => _toggle(pane, e),
child: Center(
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)),
),
)) ))
: null, : null,
); );

View File

@ -318,6 +318,7 @@ class _TopBar extends StatelessWidget {
if (need() > w) usage = false; if (need() > w) usage = false;
if (need() > w) dlBox = false; if (need() > w) dlBox = false;
if (need() > w) mkvText = false; if (need() > w) mkvText = false;
// 242: 다 줄여도 모자라는지는 어림 (need) 이 아니라 위 막대가 실제로 잰다 (오른쪽 묶음 [trailing] · minMiddleWidth)
// 7: 폰에는 "탐색기에서 끌어다 놓기" 가 없다 // 7: 폰에는 "탐색기에서 끌어다 놓기" 가 없다
final addTip = Platform.isAndroid ? tr('동영상 추가') : tr('동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'); final addTip = Platform.isAndroid ? tr('동영상 추가') : tr('동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)');
@ -328,19 +329,21 @@ class _TopBar extends StatelessWidget {
{required bool text, required String tip, Color? color, bool ai = false}) { {required bool text, required String tip, Color? color, bool ai = false}) {
final fg = ai ? aiColor : null; final fg = ai ? aiColor : null;
final side = BorderSide(color: ai && onPressed != null ? aiColor.withValues(alpha: 0.7) : JjColors.border); final side = BorderSide(color: ai && onPressed != null ? aiColor.withValues(alpha: 0.7) : JjColors.border);
// 241: 아이콘만 있는 버튼은 이름을 버튼 자체에 (바깥 Tooltip 은 TalkBack 이 읽는 이름이 되지 않았다)
if (!text) {
return IconButton.outlined(
tooltip: tip,
onPressed: onPressed,
icon: Icon(icon, size: 20, color: onPressed == null ? null : color ?? fg),
style: IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: side));
}
return Tooltip( return Tooltip(
message: tip, message: tip,
child: text child: OutlinedButton.icon(
? OutlinedButton.icon( style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null,
style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null, onPressed: onPressed,
onPressed: onPressed, icon: Icon(icon, size: 18, color: color ?? fg),
icon: Icon(icon, size: 18, color: color ?? fg), label: Text(label)),
label: Text(label))
: IconButton.outlined(
onPressed: onPressed,
icon: Icon(icon, size: 20, color: onPressed == null ? null : color ?? fg),
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: side)),
); );
} }
@ -407,6 +410,10 @@ class _TopBar extends StatelessWidget {
} }
return AppTopBar( return AppTopBar(
// 242: 작업 중 (진행 표시 · 취소) 이 생기거나 없어지면 다시 잰다
key: ValueKey(('mkvTopBar', c.busy)),
// 242: 오른쪽 묶음 때문에 왼쪽 (동영상 추가 · AI) 이 아이콘 하나보다 좁아지면 두 줄
minMiddleWidth: 56,
// 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록] // 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록]
nav: const AppNavButtons(onMkvPage: true), nav: const AppNavButtons(onMkvPage: true),
// 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리 // 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리
@ -431,6 +438,12 @@ class _TopBar extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
],
),
// 오른쪽 묶음 (줄지 않음): 작업 · 재생 · MKV 만들기 · 결과 폴더 · 다운로드
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (c.busy) ...[ if (c.busy) ...[
JobIndicator(c: c, compact: !jobText, iconOnly: !jobText), JobIndicator(c: c, compact: !jobText, iconOnly: !jobText),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -453,7 +466,8 @@ class _TopBar extends StatelessWidget {
], ],
// 체크한 동영상이 있으면 그것만, 없으면 "전체 만들기 / 취소" 를 묻는다 // 체크한 동영상이 있으면 그것만, 없으면 "전체 만들기 / 취소" 를 묻는다
Tooltip( Tooltip(
message: trf('MKV 만들기{0}', [count]), // 241: 아이콘만일 때는 버튼 자체의 tooltip (이름) 을 쓰고 바깥은 비운다 (두 번 뜨지 않게)
message: mkvText ? trf('MKV 만들기{0}', [count]) : '',
child: mkvText child: mkvText
? FilledButton.icon( ? FilledButton.icon(
onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)),
@ -461,6 +475,7 @@ class _TopBar extends StatelessWidget {
label: Text(trf('MKV 만들기{0}{1}', [count, c.busy && jobText ? tr(' (대기열)') : ''])), label: Text(trf('MKV 만들기{0}{1}', [count, c.busy && jobText ? tr(' (대기열)') : ''])),
) )
: IconButton.filled( : IconButton.filled(
tooltip: trf('MKV 만들기{0}', [count]), // 241: TalkBack 이 읽는 이름
onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)),
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20),
), ),
@ -611,7 +626,8 @@ class _EncodeBar extends StatelessWidget {
), ),
), ),
]); ]);
if (compact) { // 241 시험에서 찾음: 폭 720 ~ 840 (노트 10+ 가로 등) 에서 한 줄이 넘쳤다 - 960 보다 좁으면 폰처럼 줄을 바꿔 보인다 (내 판단)
if (compact || MediaQuery.sizeOf(context).width < 960) {
return Container( return Container(
width: double.infinity, width: double.infinity,
color: JjColors.panel, color: JjColors.panel,

View File

@ -178,7 +178,11 @@ class _DiskInfo extends StatelessWidget {
return Row(mainAxisSize: MainAxisSize.min, children: [ return Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.storage, size: 14, color: JjColors.textDim), const Icon(Icons.storage, size: 14, color: JjColors.textDim),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]), style: const TextStyle(fontSize: 12)), // 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다
Flexible(
child: Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
width: 90, width: 90,
@ -435,8 +439,13 @@ class _CopyCardState extends State<_CopyCard> {
scrollable: true, scrollable: true,
title: Text(tr('옵션을 저장했습니다')), title: Text(tr('옵션을 저장했습니다')),
content: Text(running content: Text(running
? tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?') // 233: 이동 카드는 "이동" 으로
: tr('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?')), ? (t.move
? tr('지금 도는 이동은 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?')
: tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?'))
: (t.move
? tr('이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?')
: tr('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?'))),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('현재 유지'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('현재 유지'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설정 반영 후 실행'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설정 반영 후 실행'))),
@ -455,20 +464,30 @@ class _CopyCardState extends State<_CopyCard> {
/// lsync 로: 끝나지 않은 것 (실행 중 · 실패 · 취소 · 아직 안 함) 도 옮길 수 있다 - 남은 것은 lsync 가 이어서 맞춘다. /// lsync 로: 끝나지 않은 것 (실행 중 · 실패 · 취소 · 아직 안 함) 도 옮길 수 있다 - 남은 것은 lsync 가 이어서 맞춘다.
/// 실행 중이면 물어본 뒤 멈추고 옮긴다. /// 실행 중이면 물어본 뒤 멈추고 옮긴다.
Future<void> _toLive(CopyTask t) async { Future<void> _toLive(CopyTask t) async {
if (center.isRunning(t.id)) { final running = center.isRunning(t.id);
// 243: 이동 카드 - 실시간 동기화는 원본을 지우지 않는다 (107). 옮기기 전에 알린다
if (running || t.move) {
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
scrollable: true, scrollable: true,
title: Text(tr('lsync 로 이동')), title: Text(tr('lsync 로 이동')),
content: Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (running) Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')),
if (running && t.move) const SizedBox(height: 10),
if (t.move)
Text(tr('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.'),
style: const TextStyle(color: Colors.orangeAccent)),
]),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('멈추고 옮기기'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(running ? tr('멈추고 옮기기') : tr('옮기기'))),
], ],
), ),
); );
if (ok != true) return; if (ok != true || !mounted) return;
}
if (running) {
final job = center.jobs[t.id]!; final job = center.jobs[t.id]!;
job.cancel(); job.cancel();
await job.done; await job.done;
@ -942,7 +961,8 @@ class _LiveTabState extends State<_LiveTab> {
), ),
Chip( Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: Text([tr(CopyMethod.of(x.method).label), if (x.delete) tr('지우기 포함')].join(' · ')), // 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
label: Text([tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · ')),
), ),
// 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치) // 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치)
if (live != null && x.enabled) if (live != null && x.enabled)
@ -1013,7 +1033,12 @@ class _LiveTabState extends State<_LiveTab> {
child: pend == null child: pend == null
? Text(tr('맞출 것을 세는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)) ? Text(tr('맞출 것을 세는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim))
: pend.isEmpty : pend.isEmpty
? Text(tr('맞출 것 없음 (원본과 대상이 같습니다)'), style: const TextStyle(fontSize: 12, color: Colors.greenAccent)) // 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로
? Text(
(live?.onlyTarget[k] ?? 0) > 0
? trf('원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)', [live!.onlyTarget[k]])
: tr('맞출 것 없음 (원본과 대상이 같습니다)'),
style: const TextStyle(fontSize: 12, color: Colors.greenAccent))
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ : Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text( Text(
trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]), trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]),

View File

@ -33,6 +33,13 @@ class SettingTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tile = _tile(context);
// 241: 오른쪽 스위치 · 고르기 상자가 제목을 이름으로 읽히게 (TalkBack 이 이름 없는 "켜짐" 만 읽었다).
// 줄 자체를 누르는 타일 ([onTap]) 은 누르는 곳이 둘이 되므로 합치지 않는다
return trailing != null && onTap == null ? MergeSemantics(child: tile) : tile;
}
Widget _tile(BuildContext context) {
final t = trailing; final t = trailing;
if (t == null || _small(t) || !isCompact(context)) { if (t == null || _small(t) || !isCompact(context)) {
return ListTile( return ListTile(

View File

@ -329,8 +329,9 @@ class _SettingsPageState extends State<SettingsPage> {
Text(tr('환경 설정'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), Text(tr('환경 설정'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
Flexible( Flexible(
// 251: 영어 등 긴 언어에서 잘리지 않게 두 줄까지
child: Text(tr('바꾸면 바로 저장됩니다'), child: Text(tr('바꾸면 바로 저장됩니다'),
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
]), ]),
@ -796,7 +797,7 @@ class _SettingsPageState extends State<SettingsPage> {
SwitchListTile( SwitchListTile(
value: s.addFinishedDownloads, value: s.addFinishedDownloads,
onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v), onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v),
title: Text(tr('완료시 자동 동영상추가')), title: Text(tr('완료 시 자동 동영상 추가')),
subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')), subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')),
), ),
SettingTile( SettingTile(

85
test/a11y_names_test.dart Normal file
View File

@ -0,0 +1,85 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/settings.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/home_page.dart';
import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
/// 241: TalkBack 이 읽는 이름이 없는 아이콘 버튼이 없다 (아이콘 크기의 누르는 곳 - 넓은 목록 줄은 안의 글을 읽는다).
/// 바깥 Tooltip 으로 감싼 IconButton · 이름 없는 InkResponse 는 Android 에 이름이 가지 않았다
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_a11y_'));
tearDown(() => tmp.deleteSync(recursive: true));
Future<List<String>> unnamed(WidgetTester tester, Widget Function(AppController) page, double w) async {
final handle = tester.ensureSemantics();
// 설정 화면의 rsync 확인이 앱 폴더를 묻는다 (시험 환경에는 path_provider 가 없음)
const pp = MethodChannel('plugins.flutter.io/path_provider');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null));
tester.view.physicalSize = Size(w, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
File(p.join(tmp.path, 'a.txt')).writeAsStringSync('a');
Directory(p.join(tmp.path, 'sub')).createSync();
c.settings
..explorerPaths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: page(c))));
for (var i = 0; i < 20; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
}
final out = <String>[];
void walk(SemanticsNode n) {
final d = n.getSemanticsData();
final tappable = d.flagsCollection.isButton || d.hasAction(SemanticsAction.tap);
if (!n.isMergedIntoParent &&
tappable &&
n.rect.width <= 72 &&
n.rect.height >= 20 &&
d.label.trim().isEmpty &&
d.tooltip.trim().isEmpty &&
d.value.trim().isEmpty) {
out.add('${n.rect} (위: ${n.parent?.getSemanticsData().label})');
}
n.visitChildren((x) {
walk(x);
return true;
});
}
walk(tester.binding.renderViews.first.owner!.semanticsOwner!.rootSemanticsNode!);
handle.dispose();
return out;
}
final pages = <String, Widget Function(AppController)>{
'MKV': (c) => HomePage(c: c),
'파일 탐색기': (c) => ExplorerPage(c: c),
'Rsync': (c) => ExplorerPage(c: c, rsync: true),
'모니터링': (c) => MonitorPage(c: c),
'환경 설정': (c) => SettingsPage(c: c),
};
for (final MapEntry(key: name, value: page) in pages.entries) {
for (final w in [500.0, 800.0, 1600.0]) {
testWidgets('241: $name ($w) - 이름 없는 아이콘 버튼 없음', (tester) async {
expect(await unnamed(tester, page, w), isEmpty);
});
}
}
}

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -12,6 +13,7 @@ import 'package:jj_mkvmaker/core/encode_options.dart';
import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart'; import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/core/srt.dart'; import 'package:jj_mkvmaker/core/srt.dart';
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.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/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/ai_services.dart'; import 'package:jj_mkvmaker/services/ai_services.dart';
@ -374,7 +376,7 @@ void main() {
d.dispose(); d.dispose();
}); });
testWidgets('다운로드 화면: "완료시 자동 동영상추가" 체크 → 설정 변경, 목록에서 빼기', (tester) async { testWidgets('다운로드 화면: "완료 시 자동 동영상 추가" 체크 → 설정 변경, 목록에서 빼기', (tester) async {
tester.view.physicalSize = const Size(1500, 900); tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
@ -391,7 +393,7 @@ void main() {
Checkbox box() => tester.widget<Checkbox>( Checkbox box() => tester.widget<Checkbox>(
find.descendant(of: find.byTooltip(RegExp('MKV 만들기의 동영상 목록에 자동으로')), matching: find.byType(Checkbox))); find.descendant(of: find.byTooltip(RegExp('MKV 만들기의 동영상 목록에 자동으로')), matching: find.byType(Checkbox)));
expect(box().value, isFalse); expect(box().value, isFalse);
await tester.tap(find.text('완료시 자동 동영상추가')); await tester.tap(find.text('완료 시 자동 동영상 추가'));
await tester.pump(); await tester.pump();
expect(s.addFinishedDownloads, isTrue); expect(s.addFinishedDownloads, isTrue);
expect(box().value, isTrue); expect(box().value, isTrue);
@ -404,6 +406,38 @@ void main() {
d.dispose(); d.dispose();
}); });
// 246 · P11 확인 (921): 영어 · 일본어는 글이 길어 체크가 아예 안 보였다 - 언어 · 폭마다
for (final lang in ['ko', 'en', 'ja']) {
for (final w in [800.0, 1200.0]) {
testWidgets('246: 다운로드 화면 ($lang, 폭 $w) - 버튼 줄의 글이 잘리거나 "전체 선택" 과 겹치지 않는다', (tester) async {
Map<String, String> dict(String code) =>
Map<String, String>.from(jsonDecode(File('assets/l10n/$code.json').readAsStringSync()) as Map);
setTranslations(lang, lang == 'ko' ? const {} : dict(lang), lang == 'ko' ? const {} : dict('en'));
addTearDown(() => setTranslations('ko', const {}));
tester.view.physicalSize = Size(w, 411);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final s = AppSettings();
final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null);
addTearDown(d.dispose);
d.addToEditList = (files) async => files.length;
d.setAutoAdd = (on) async => s.addFinishedDownloads = on;
d.addPage('https://vimeo.com/1')!.title = '영상';
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
await tester.pump();
expect(tester.takeException(), isNull);
final all = tester.getRect(find.text(tr('전체 선택')));
final auto = tester.getRect(find.text(tr('완료 시 자동 동영상 추가')));
expect(auto.overlaps(all), isFalse, reason: '$auto · $all');
expect([auto.left >= 0, auto.right <= w], [true, true], reason: '화면 안에 다 보임 $auto');
for (final t in ['일시정지', '재개', '다운로드 취소', '삭제', '완료 정리']) {
final r = tester.getRect(find.text(tr(t)).first);
expect([r.left >= 0, r.right <= w], [true, true], reason: '$t $r');
}
});
}
}
testWidgets('환경 설정 · 종료 버튼: 모든 화면에서 같은 자리', (tester) async { testWidgets('환경 설정 · 종료 버튼: 모든 화면에서 같은 자리', (tester) async {
tester.view.physicalSize = const Size(1500, 900); tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -581,4 +615,43 @@ void main() {
expect(b.status, JobStatus.cancelled); expect(b.status, JobStatus.cancelled);
expect(find.text('모두 취소 (2개)'), findsNothing); expect(find.text('모두 취소 (2개)'), findsNothing);
}); });
testWidgets('242: 폰 가로 (노트 10+ - 내비게이션 막대를 뺀 약 800×411) 에서 작업 중이어도 위 막대 버튼이 화면 크기 "100%" 를 덮지 않는다', (tester) async {
final w = double.parse(Platform.environment['JJ_W'] ?? '800');
tester.view.physicalSize = Size(w, 411);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tool = _HoldTool();
final dir = Directory.systemTemp.createTempSync('jj_batch242_');
addTearDown(() => dir.deleteSync(recursive: true));
final file = (File(p.join(dir.path, 'a.mp4'))..writeAsBytesSync([0])).path;
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
late Future<void> building;
await tester.runAsync(() async {
await c.init();
await c.addVideos([file]);
c.selected = c.videos.first;
building = c.buildVideos([...c.videos]);
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
});
expect(c.busy, isTrue);
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
// 첫 그림에서 재어 보고 모자라면 다음 그림부터 두 줄 (첫 그림도 넘침 오류 없이 잘라 그림)
expect(tester.takeException(), isNull);
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull, reason: '두 줄로 바뀐 뒤에는 넘침 없음');
final pct = tester.getRect(find.text('${(c.settings.uiScale * 100).round()}%'));
for (final tip in ['작업 취소', '결과 폴더 열기: ${outputDirFor(file)}']) {
final f = find.byTooltip(tip);
expect(f, findsWidgets, reason: tip);
final r = tester.getRect(f.first);
expect(r.overlaps(pct), isFalse, reason: '$tip 버튼 $r 이 "100%" $pct 를 덮음');
}
c.cancel();
await tester.runAsync(() => building);
await tester.pump();
});
} }

View File

@ -274,9 +274,37 @@ void main() {
expect(userErrorText(const ProcessException('rsync', ['-a'], '원본 파일을 지우지 못했습니다', 23)), '원본 파일을 지우지 못했습니다'); expect(userErrorText(const ProcessException('rsync', ['-a'], '원본 파일을 지우지 못했습니다', 23)), '원본 파일을 지우지 못했습니다');
expect(userErrorText(const FileSystemException('지울 수 없음', r'C:\x', OSError('액세스가 거부되었습니다', 5))), r'지울 수 없음 · C:\x · 액세스가 거부되었습니다'); expect(userErrorText(const FileSystemException('지울 수 없음', r'C:\x', OSError('액세스가 거부되었습니다', 5))), r'지울 수 없음 · C:\x · 액세스가 거부되었습니다');
expect(userErrorText(Exception('끊김')), '끊김'); expect(userErrorText(Exception('끊김')), '끊김');
// 245: 앱이 직접 할 때의 Dart 파일 오류 (영어) 도 rsync 때와 같은 글 - 원본을 못 지웠으면
expect(userErrorText(const FileSystemException('Cannot delete file', r'C:\s\a.txt', OSError('사용 중', 32)), sources: [r'C:\s']),
r'원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다. · C:\s\a.txt · 사용 중');
expect(userErrorText(const FileSystemException('Cannot delete file', r'D:\t\x.txt'), sources: [r'C:\s']).startsWith('지우지 못했습니다'), isTrue);
expect(userErrorText(const FileSystemException('Cannot copy file to D:\\t', r'C:\s\a.txt')).startsWith('파일을 복사하지 못했습니다'), isTrue);
expect(userErrorText('그냥 글'), '그냥 글'); expect(userErrorText('그냥 글'), '그냥 글');
}); });
testWidgets('243: 이동 카드를 lsync 로 옮길 때 "원본을 지우지 않음 (복사로 맞춤)" 을 알리고, 취소하면 옮기지 않는다', (tester) async {
final src = p.join(tmp.path, 'mv243'), dst = p.join(tmp.path, 'mv243d');
Directory(src).createSync();
Directory(dst).createSync();
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')];
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();
await tester.tap(find.text('lsync 로 이동').first);
await tester.pumpAndSettle();
expect(find.textContaining('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤)'), findsOneWidget);
await tester.tap(find.text('취소').last);
await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs, isEmpty, reason: '취소하면 옮기지 않음');
await tester.tap(find.text('lsync 로 이동').first);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '옮기기'));
await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs.map((x) => x.source), [src]);
});
testWidgets('105: 원본이 대상 안 + 창에서 [원본 파일 지우기] 로 지우기 옵션이 생기면 막는다', (tester) async { testWidgets('105: 원본이 대상 안 + 창에서 [원본 파일 지우기] 로 지우기 옵션이 생기면 막는다', (tester) async {
final outer = p.join(tmp.path, 'O'), inner = p.join(outer, 'in'); final outer = p.join(tmp.path, 'O'), inner = p.join(outer, 'in');
File(p.join(inner, 'n.txt')) File(p.join(inner, 'n.txt'))

View File

@ -14,6 +14,7 @@ import 'support/fake_recycle_bin.dart';
import 'support/recycle_leftovers.dart'; import 'support/recycle_leftovers.dart';
import 'support/elevated.dart'; import 'support/elevated.dart';
import 'explorer_test.dart' show calm;
/// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다 /// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다
void main() { void main() {
late Directory tmp; late Directory tmp;
@ -165,7 +166,7 @@ void main() {
await tester.runAsync(() => tester.tap(find.text('doc.txt').first)); await tester.runAsync(() => tester.tap(find.text('doc.txt').first));
await settle(tester, () => false, rounds: 10); await settle(tester, () => false, rounds: 10);
await tester.runAsync(() => tester.tap(find.text('삭제').first)); await tester.runAsync(() => tester.tap(find.text('삭제').first));
await tester.pumpAndSettle(); await calm(tester);
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '휴지통으로')));
await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty);
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse); expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
@ -179,7 +180,7 @@ void main() {
if (bin != null) expect(bin!.items, isEmpty, reason: '가짜 휴지통에서 되돌림'); if (bin != null) expect(bin!.items, isEmpty, reason: '가짜 휴지통에서 되돌림');
// 알림 (10초) 이 시험이 끝난 뒤에 닫히지 않게 정리 // 알림 (10초) 이 시험이 끝난 뒤에 닫히지 않게 정리
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await tester.pumpAndSettle(); await calm(tester);
}, skip: !Platform.isWindows); }, skip: !Platform.isWindows);
testWidgets('144: 긴 경로는 휴지통에 넣지 못한 이유 한 줄과 [영구 삭제] - 누르면 지운다 · [그대로 두기] 면 남는다', (tester) async { testWidgets('144: 긴 경로는 휴지통에 넣지 못한 이유 한 줄과 [영구 삭제] - 누르면 지운다 · [그대로 두기] 면 남는다', (tester) async {
@ -199,7 +200,7 @@ void main() {
await tester.runAsync(() => tester.tap(find.text('deep').first)); await tester.runAsync(() => tester.tap(find.text('deep').first));
await settle(tester, () => false, rounds: 10); await settle(tester, () => false, rounds: 10);
await tester.runAsync(() => tester.tap(find.text('삭제').first)); await tester.runAsync(() => tester.tap(find.text('삭제').first));
await tester.pumpAndSettle(); await calm(tester);
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '휴지통으로')));
await settle(tester, () => find.textContaining('경로가 너무 길어').evaluate().isNotEmpty); await settle(tester, () => find.textContaining('경로가 너무 길어').evaluate().isNotEmpty);
} }
@ -210,7 +211,7 @@ void main() {
expect(find.text('· deep'), findsOneWidget); expect(find.text('· deep'), findsOneWidget);
expect(find.textContaining('영구 삭제로 지울 수 있습니다'), findsNothing, reason: '긴 설명을 항목마다 되풀이하지 않음'); expect(find.textContaining('영구 삭제로 지울 수 있습니다'), findsNothing, reason: '긴 설명을 항목마다 되풀이하지 않음');
await tester.tap(find.text('그대로 두기')); await tester.tap(find.text('그대로 두기'));
await tester.pumpAndSettle(); await calm(tester);
expect(Directory(lp(deep)).existsSync(), isTrue); expect(Directory(lp(deep)).existsSync(), isTrue);
// 다시 → [영구 삭제] // 다시 → [영구 삭제]
await deleteDeep(); await deleteDeep();
@ -249,7 +250,7 @@ void main() {
expect(find.byType(ExplorerPage), findsOneWidget); expect(find.byType(ExplorerPage), findsOneWidget);
expect(find.textContaining(p.join(tmp.path, 'left')), findsNothing); expect(find.textContaining(p.join(tmp.path, 'left')), findsNothing);
await back(); // 3) 맨 위에서는 화면 닫기 await back(); // 3) 맨 위에서는 화면 닫기
await tester.pumpAndSettle(); // 닫히는 전환 (가짜 시간을 흘려야 끝난다) await calm(tester); // 닫히는 전환 (가짜 시간을 흘려야 끝난다)
expect(find.byType(ExplorerPage), findsNothing); expect(find.byType(ExplorerPage), findsNothing);
expect(find.text('앞 화면'), findsOneWidget); expect(find.text('앞 화면'), findsOneWidget);
}); });
@ -287,14 +288,14 @@ void main() {
expect(find.descendant(of: find.byType(AlertDialog), matching: find.text('이미 이 폴더에 있습니다')), findsOneWidget); expect(find.descendant(of: find.byType(AlertDialog), matching: find.text('이미 이 폴더에 있습니다')), findsOneWidget);
expect(find.textContaining('doc.txt 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다'), findsOneWidget); expect(find.textContaining('doc.txt 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '확인')); await tester.tap(find.widgetWithText(FilledButton, '확인'));
await tester.pumpAndSettle(); await calm(tester);
expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue);
// 같은 폴더로 [복사] 는 사본을 만들지 묻는다: [취소] → 그대로, [사본 만들기] → "doc (2).txt" // 같은 폴더로 [복사] 는 사본을 만들지 묻는다: [취소] → 그대로, [사본 만들기] → "doc (2).txt"
await tester.runAsync(() => tester.tap(find.text('복사').first)); await tester.runAsync(() => tester.tap(find.text('복사').first));
await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30); await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30);
expect(find.textContaining('같은 폴더에 사본을 만들까요? (이름 (2))'), findsOneWidget); expect(find.textContaining('같은 폴더에 사본을 만들까요? (이름 (2))'), findsOneWidget);
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await tester.pumpAndSettle(); await calm(tester);
expect(File(p.join(left, 'doc (2).txt')).existsSync(), isFalse); expect(File(p.join(left, 'doc (2).txt')).existsSync(), isFalse);
await tester.runAsync(() => tester.tap(find.text('복사').first)); await tester.runAsync(() => tester.tap(find.text('복사').first));
await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30); await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30);
@ -346,7 +347,7 @@ void main() {
expect(find.text('더 보기'), findsOneWidget); expect(find.text('더 보기'), findsOneWidget);
expect(find.text('숨은 항목 표시'), findsNothing, reason: '들어가지 않는 버튼은 줄에 없음'); expect(find.text('숨은 항목 표시'), findsNothing, reason: '들어가지 않는 버튼은 줄에 없음');
await tester.tap(find.text('더 보기')); await tester.tap(find.text('더 보기'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.text('숨은 항목 표시'), findsOneWidget); expect(find.text('숨은 항목 표시'), findsOneWidget);
await tester.runAsync(() => tester.tap(find.text('숨은 항목 표시'))); await tester.runAsync(() => tester.tap(find.text('숨은 항목 표시')));
await settle(tester, () => c.settings.explorerShowHidden != before, rounds: 20); await settle(tester, () => c.settings.explorerShowHidden != before, rounds: 20);
@ -391,7 +392,7 @@ void main() {
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await tester.tap(find.byTooltip('경로 입력').first); await tester.tap(find.byTooltip('경로 입력').first);
await tester.pumpAndSettle(); await calm(tester);
await tester.enterText(find.byType(TextField).last, p.join(tmp.path, 'nope')); await tester.enterText(find.byType(TextField).last, p.join(tmp.path, 'nope'));
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '확인'))); await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.text('폴더가 없거나 열 수 없습니다').evaluate().isNotEmpty); await settle(tester, () => find.text('폴더가 없거나 열 수 없습니다').evaluate().isNotEmpty);
@ -431,7 +432,7 @@ void main() {
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await tester.tap(find.byTooltip('경로 입력').first); await tester.tap(find.byTooltip('경로 입력').first);
await tester.pumpAndSettle(); await calm(tester);
expect(find.widgetWithText(TextButton, 'WebDAV 서버 추가'), findsNothing); expect(find.widgetWithText(TextButton, 'WebDAV 서버 추가'), findsNothing);
await tester.enterText(find.byType(TextField).last, 'smb://nas/영상'); await tester.enterText(find.byType(TextField).last, 'smb://nas/영상');
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '확인'))); await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '확인')));
@ -444,7 +445,7 @@ void main() {
expect(find.widgetWithText(AlertDialog, 'WebDAV 서버 추가'), findsOneWidget); expect(find.widgetWithText(AlertDialog, 'WebDAV 서버 추가'), findsOneWidget);
expect(find.textContaining('SMB'), findsNothing, reason: '경로 창은 닫힘'); expect(find.textContaining('SMB'), findsNothing, reason: '경로 창은 닫힘');
await tester.tap(find.text('취소').last); await tester.tap(find.text('취소').last);
await tester.pumpAndSettle(); await calm(tester);
expect(find.byType(AlertDialog), findsNothing); expect(find.byType(AlertDialog), findsNothing);
}); });

View File

@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'explorer_test.dart' show calm;
void main() { void main() {
late Directory tmp; late Directory tmp;
late AppController c; late AppController c;
@ -93,10 +94,10 @@ void main() {
expect(tester.getRect(find.byWidget(e.widget).first).right, lessThanOrEqualTo(1280)); expect(tester.getRect(find.byWidget(e.widget).first).right, lessThanOrEqualTo(1280));
} }
await tester.tap(pathBtn.first); await tester.tap(pathBtn.first);
await tester.pumpAndSettle(); await calm(tester);
expect(find.byType(AlertDialog), findsOneWidget); expect(find.byType(AlertDialog), findsOneWidget);
await tester.tap(find.text('취소').last); await tester.tap(find.text('취소').last);
await tester.pumpAndSettle(); await calm(tester);
// 정상 드라이브 (T:) 가 빈 드라이브들보다 앞 // 정상 드라이브 (T:) 가 빈 드라이브들보다 앞
expect(tester.getTopLeft(find.text('T:').first).dx, lessThan(tester.getTopLeft(find.textContaining('G: ·').first).dx)); expect(tester.getTopLeft(find.text('T:').first).dx, lessThan(tester.getTopLeft(find.textContaining('G: ·').first).dx));
// 휠 (위 · 아래) 로 탭 줄이 옆으로 (왼쪽 창의 T: 탭이 왼쪽으로 밀린다) // 휠 (위 · 아래) 로 탭 줄이 옆으로 (왼쪽 창의 T: 탭이 왼쪽으로 밀린다)
@ -108,15 +109,15 @@ void main() {
expect(tester.getTopLeft(find.text('T:').first).dx, lessThan(before - 10)); expect(tester.getTopLeft(find.text('T:').first).dx, lessThan(before - 10));
// 좁은 창 (위아래 두 창 · 폰): 끝 버튼은 [⋮] 하나로 늘 보임 → 경로 입력 // 좁은 창 (위아래 두 창 · 폰): 끝 버튼은 [⋮] 하나로 늘 보임 → 경로 입력
tester.view.physicalSize = const Size(560, 900); tester.view.physicalSize = const Size(560, 900);
await tester.pumpAndSettle(); await calm(tester);
expect(find.byTooltip('경로 입력 · WebDAV'), findsWidgets); expect(find.byTooltip('경로 입력 · WebDAV'), findsWidgets);
await tester.tap(find.byTooltip('경로 입력 · WebDAV').first); await tester.tap(find.byTooltip('경로 입력 · WebDAV').first);
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.text('경로 입력').last); await tester.tap(find.text('경로 입력').last);
await tester.pumpAndSettle(); await calm(tester);
expect(find.byType(AlertDialog), findsOneWidget); expect(find.byType(AlertDialog), findsOneWidget);
await tester.tap(find.text('취소').last); await tester.tap(find.text('취소').last);
await tester.pumpAndSettle(); await calm(tester);
expect(tester.takeException(), isNull, reason: '넘침 (RenderFlex overflow) 없음'); expect(tester.takeException(), isNull, reason: '넘침 (RenderFlex overflow) 없음');
}); });

View File

@ -11,7 +11,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, settle; import 'explorer_test.dart' show act, settle, calm;
/// 23: 앞의 복사가 끝나지 않아도 다음 복사를 함께 시작한다 (막지 않음, 진행 막대가 둘) /// 23: 앞의 복사가 끝나지 않아도 다음 복사를 함께 시작한다 (막지 않음, 진행 막대가 둘)
void main() { void main() {
@ -161,7 +161,7 @@ void main() {
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty); await settle(tester, () => find.text('sub').evaluate().isNotEmpty);
await tester.longPress(find.text('sub')); await tester.longPress(find.text('sub'));
await tester.pumpAndSettle(); await calm(tester);
for (final t in ['rsync 에 고르기', '열기', '다른 창에서 열기', '이름 변경', '삭제', '정보']) { for (final t in ['rsync 에 고르기', '열기', '다른 창에서 열기', '이름 변경', '삭제', '정보']) {
expect(find.text(t), findsOneWidget, reason: t); expect(find.text(t), findsOneWidget, reason: t);
} }
@ -194,7 +194,7 @@ void main() {
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await settle(tester, () => find.text('top.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('top.txt').evaluate().isNotEmpty);
await tester.tap(find.byTooltip('경로 입력').first); await tester.tap(find.byTooltip('경로 입력').first);
await tester.pumpAndSettle(); await calm(tester);
await tester.enterText(find.byType(TextField).last, p.join(tmp.path, 'none')); await tester.enterText(find.byType(TextField).last, p.join(tmp.path, 'none'));
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.textContaining('폴더가 없거나 열 수 없습니다').evaluate().isNotEmpty, rounds: 20); await settle(tester, () => find.textContaining('폴더가 없거나 열 수 없습니다').evaluate().isNotEmpty, rounds: 20);
@ -232,7 +232,7 @@ void main() {
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsBytesSync([1, 2, 3]); ..writeAsBytesSync([1, 2, 3]);
await tester.tap(find.byTooltip('경로 입력').first); await tester.tap(find.byTooltip('경로 입력').first);
await tester.pumpAndSettle(); await calm(tester);
await tester.enterText(find.byType(TextField).last, made); await tester.enterText(find.byType(TextField).last, made);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.text('pg1.png').evaluate().isNotEmpty, rounds: 300); await settle(tester, () => find.text('pg1.png').evaluate().isNotEmpty, rounds: 300);
@ -308,7 +308,7 @@ void main() {
await act(tester, () => tester.tap(find.byTooltip(tooltip))); await act(tester, () => tester.tap(find.byTooltip(tooltip)));
} else { } else {
await tester.tap(find.text('더 보기')); await tester.tap(find.text('더 보기'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.text(label), findsOneWidget, reason: '[더 보기] 메뉴에 지금 배치가 보임: $label'); expect(find.text(label), findsOneWidget, reason: '[더 보기] 메뉴에 지금 배치가 보임: $label');
await act(tester, () => tester.tap(find.text(label))); await act(tester, () => tester.tap(find.text(label)));
} }

View File

@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart'; import 'support/fake_recycle_bin.dart';
import 'explorer_test.dart' show calm;
/// 180: 복사 · 이동이 시작 · 진행 · 끝나도 목록의 자리와 스크롤은 그대로 (다음 것을 누르려다 엉뚱한 것을 누르지 않게) /// 180: 복사 · 이동이 시작 · 진행 · 끝나도 목록의 자리와 스크롤은 그대로 (다음 것을 누르려다 엉뚱한 것을 누르지 않게)
void main() { void main() {
late Directory tmp; late Directory tmp;
@ -62,7 +63,7 @@ void main() {
// 왼쪽 목록을 조금 내려 둔다 // 왼쪽 목록을 조금 내려 둔다
final list = find.ancestor(of: find.text('f05.txt'), matching: find.byType(Scrollable)).first; final list = find.ancestor(of: find.text('f05.txt'), matching: find.byType(Scrollable)).first;
await tester.drag(find.text('f05.txt'), const Offset(0, -200)); await tester.drag(find.text('f05.txt'), const Offset(0, -200));
await tester.pumpAndSettle(); await calm(tester);
final probe = find.text('f07.txt'); final probe = find.text('f07.txt');
expect(probe, findsOneWidget); expect(probe, findsOneWidget);
final where = tester.getTopLeft(probe); final where = tester.getTopLeft(probe);
@ -84,7 +85,7 @@ void main() {
await act(tester, () => tester.tap(find.text('새로 고침').first)); await act(tester, () => tester.tap(find.text('새로 고침').first));
} else { } else {
await act(tester, () => tester.tap(find.text('복사').first)); await act(tester, () => tester.tap(find.text('복사').first));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사')));
} }
// 시작 · 진행 · 끝 (진행 막대가 내려갈 때까지) 내내 같은 자리 // 시작 · 진행 · 끝 (진행 막대가 내려갈 때까지) 내내 같은 자리
@ -126,7 +127,7 @@ void main() {
await settle(tester, () => find.text('f05.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('f05.txt').evaluate().isNotEmpty);
final list = find.ancestor(of: find.text('f05.txt'), matching: find.byType(Scrollable)).first; final list = find.ancestor(of: find.text('f05.txt'), matching: find.byType(Scrollable)).first;
await tester.drag(find.text('f05.txt'), const Offset(0, -200)); await tester.drag(find.text('f05.txt'), const Offset(0, -200));
await tester.pumpAndSettle(); await calm(tester);
final at = tester.state<ScrollableState>(list).position.pixels; final at = tester.state<ScrollableState>(list).position.pixels;
expect(at, greaterThan(0)); expect(at, greaterThan(0));
final w = tester.getSize(list).width; final w = tester.getSize(list).width;

View File

@ -112,7 +112,7 @@ void main() {
// 길게 누르기 → 메뉴 → 다른 앱으로 열기 (고르기 창) // 길게 누르기 → 메뉴 → 다른 앱으로 열기 (고르기 창)
await tester.longPress(find.text('doc.txt')); await tester.longPress(find.text('doc.txt'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.text('다른 창으로 복사'), findsOneWidget); expect(find.text('다른 창으로 복사'), findsOneWidget);
expect(find.text('삭제'), findsWidgets); expect(find.text('삭제'), findsWidgets);
await act(tester, () => tester.tap(find.text('다른 앱으로 열기'))); await act(tester, () => tester.tap(find.text('다른 앱으로 열기')));
@ -123,7 +123,7 @@ void main() {
await act(tester, () => tester.tap(find.text('doc.txt'))); await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15); await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.tap(find.text('복사').first)); await act(tester, () => tester.tap(find.text('복사').first));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사')));
await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty);
expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello'); expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello');
@ -138,17 +138,17 @@ void main() {
await act(tester, () => tester.tap(find.text('doc.txt'))); await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15); await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.tap(find.text('복사').first)); await act(tester, () => tester.tap(find.text('복사').first));
await tester.pumpAndSettle(); await calm(tester);
expect(find.textContaining('방법: robocopy'), findsOneWidget); expect(find.textContaining('방법: robocopy'), findsOneWidget);
expect(find.text('환경 설정의 /MOV 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)'), findsOneWidget); expect(find.text('환경 설정의 /MOV 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)'), findsOneWidget);
await act(tester, () => tester.tap(find.text('취소').last)); await act(tester, () => tester.tap(find.text('취소').last));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.tap(find.text('이동').first)); await act(tester, () => tester.tap(find.text('이동').first));
await tester.pumpAndSettle(); await calm(tester);
expect(find.textContaining('옮길까요?'), findsOneWidget); expect(find.textContaining('옮길까요?'), findsOneWidget);
expect(find.textContaining('복사에서는 쓰지 않습니다'), findsNothing); expect(find.textContaining('복사에서는 쓰지 않습니다'), findsNothing);
await act(tester, () => tester.tap(find.text('취소').last)); await act(tester, () => tester.tap(find.text('취소').last));
await tester.pumpAndSettle(); await calm(tester);
expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue);
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse); expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);
}, skip: !Platform.isWindows); }, skip: !Platform.isWindows);
@ -192,17 +192,17 @@ void main() {
// 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기] // 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기]
await tester.longPress(find.text('inner.txt')); await tester.longPress(find.text('inner.txt'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.text('다른 창으로 복사'), findsNothing); expect(find.text('다른 창으로 복사'), findsNothing);
await tester.tap(find.text('복사').last); await tester.tap(find.text('복사').last);
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.tap(find.text('..'))); await act(tester, () => tester.tap(find.text('..')));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
// 73 (관리자): 담아 둔 것이 있으면 [붙여넣기] 는 좁아도 [더 보기] 로 가지 않고 줄에 남는다 // 73 (관리자): 담아 둔 것이 있으면 [붙여넣기] 는 좁아도 [더 보기] 로 가지 않고 줄에 남는다
expect(find.text('더 보기'), findsOneWidget, reason: '이 너비에서는 줄이 넘친다'); expect(find.text('더 보기'), findsOneWidget, reason: '이 너비에서는 줄이 넘친다');
expect(find.text('붙여넣기'), findsOneWidget); expect(find.text('붙여넣기'), findsOneWidget);
await act(tester, () => tester.tap(find.text('붙여넣기'))); await act(tester, () => tester.tap(find.text('붙여넣기')));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사')));
await settle(tester, () => File(p.join(left, 'inner.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); await settle(tester, () => File(p.join(left, 'inner.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty);
expect(File(p.join(left, 'inner.txt')).readAsStringSync(), 'inner'); expect(File(p.join(left, 'inner.txt')).readAsStringSync(), 'inner');
@ -220,7 +220,7 @@ void main() {
expect(find.text('확장자'), findsOneWidget); expect(find.text('확장자'), findsOneWidget);
// 알림 (복사 끝 등) 이 시험이 끝난 뒤에 닫히지 않게 정리 // 알림 (복사 끝 등) 이 시험이 끝난 뒤에 닫히지 않게 정리
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await tester.pumpAndSettle(); await calm(tester);
}); });
testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 폴더 누르기 = 자기 자신과 안의 것 모두 선택 · 다시 누르면 모두 취소', (tester) async { testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 폴더 누르기 = 자기 자신과 안의 것 모두 선택 · 다시 누르면 모두 취소', (tester) async {
@ -278,9 +278,9 @@ void main() {
await act(tester, () => tester.tap(markOf('sub'))); await act(tester, () => tester.tap(markOf('sub')));
await settle(tester, () => find.textContaining('3개 표시함').evaluate().isNotEmpty); await settle(tester, () => find.textContaining('3개 표시함').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.text('삭제').first)); await act(tester, () => tester.tap(find.text('삭제').first));
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)'));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제')));
await settle(tester, () => find.textContaining('지우지 못했습니다').evaluate().isNotEmpty); await settle(tester, () => find.textContaining('지우지 못했습니다').evaluate().isNotEmpty);
expect(find.text('3개 중 1개를 지우지 못했습니다'), findsOneWidget); expect(find.text('3개 중 1개를 지우지 못했습니다'), findsOneWidget);
@ -289,43 +289,43 @@ void main() {
expect(Directory(p.join(left, 'sub')).existsSync(), isFalse); expect(Directory(p.join(left, 'sub')).existsSync(), isFalse);
expect(locked.existsSync(), isTrue); expect(locked.existsSync(), isTrue);
await tester.tap(find.widgetWithText(FilledButton, '확인')); await tester.tap(find.widgetWithText(FilledButton, '확인'));
await tester.pumpAndSettle(); await calm(tester);
}, skip: !Platform.isWindows); }, skip: !Platform.isWindows);
testWidgets('73: 즐겨찾기 - 지금 폴더 넣기 (이름 고침) · 한 번에 가기 · 빼기 (폴더는 그대로)', (tester) async { testWidgets('73: 즐겨찾기 - 지금 폴더 넣기 (이름 고침) · 한 번에 가기 · 빼기 (폴더는 그대로)', (tester) async {
await open(tester); await open(tester);
// 창 위의 ★ (왼쪽 창) // 창 위의 ★ (왼쪽 창)
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
await tester.pumpAndSettle(); await calm(tester);
expect(find.text('아직 없습니다. 아래에서 지금 폴더를 넣으세요.'), findsOneWidget); expect(find.text('아직 없습니다. 아래에서 지금 폴더를 넣으세요.'), findsOneWidget);
await tester.tap(find.text('지금 폴더 추가')); await tester.tap(find.text('지금 폴더 추가'));
await tester.pumpAndSettle(); await calm(tester);
await tester.enterText(find.byType(TextField).last, '왼쪽 폴더'); await tester.enterText(find.byType(TextField).last, '왼쪽 폴더');
await tester.tap(find.widgetWithText(FilledButton, '확인')); await tester.tap(find.widgetWithText(FilledButton, '확인'));
await tester.pumpAndSettle(); await calm(tester);
expect([for (final b in c.settings.folderBookmarks) (b.name, b.path)], [('왼쪽 폴더', left)]); expect([for (final b in c.settings.folderBookmarks) (b.name, b.path)], [('왼쪽 폴더', left)]);
expect(find.text('왼쪽 폴더'), findsOneWidget); expect(find.text('왼쪽 폴더'), findsOneWidget);
await tester.tap(find.text('닫기')); await tester.tap(find.text('닫기'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.byIcon(Icons.star), findsOneWidget, reason: '즐겨찾기한 폴더를 보는 창의 ★ 는 채움'); expect(find.byIcon(Icons.star), findsOneWidget, reason: '즐겨찾기한 폴더를 보는 창의 ★ 는 채움');
// 오른쪽 폴더를 즐겨찾기에 두고 왼쪽 창에서 한 번에 가기 // 오른쪽 폴더를 즐겨찾기에 두고 왼쪽 창에서 한 번에 가기
await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: 'R', path: right)]); await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: 'R', path: right)]);
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.tap(find.text('R'))); await act(tester, () => tester.tap(find.text('R')));
await settle(tester, () => false, rounds: 20); await settle(tester, () => false, rounds: 20);
expect(find.byIcon(Icons.star), findsNWidgets(2), reason: '두 창 모두 즐겨찾기한 폴더 (왼쪽 창이 R 로 감)'); expect(find.byIcon(Icons.star), findsNWidgets(2), reason: '두 창 모두 즐겨찾기한 폴더 (왼쪽 창이 R 로 감)');
// 빼기: 즐겨찾기에서만 (폴더는 그대로) // 빼기: 즐겨찾기에서만 (폴더는 그대로)
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.byTooltip('즐겨찾기에서 빼기 (폴더는 그대로)').first); await tester.tap(find.byTooltip('즐겨찾기에서 빼기 (폴더는 그대로)').first);
await tester.pumpAndSettle(); await calm(tester);
expect([for (final b in c.settings.folderBookmarks) b.name], ['R']); expect([for (final b in c.settings.folderBookmarks) b.name], ['R']);
expect(Directory(left).existsSync(), isTrue); expect(Directory(left).existsSync(), isTrue);
await tester.tap(find.text('닫기')); await tester.tap(find.text('닫기'));
await tester.pumpAndSettle(); await calm(tester);
}); });
testWidgets('73: Rsync 화면 - "왼쪽 + 오른쪽" 짝을 넣고 한 번에 연다', (tester) async { testWidgets('73: Rsync 화면 - "왼쪽 + 오른쪽" 짝을 넣고 한 번에 연다', (tester) async {
@ -336,19 +336,19 @@ void main() {
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.text('지금 짝 추가')); await tester.tap(find.text('지금 짝 추가'));
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.widgetWithText(FilledButton, '확인')); await tester.tap(find.widgetWithText(FilledButton, '확인'));
await tester.pumpAndSettle(); await calm(tester);
final pair = c.settings.folderBookmarks.single; final pair = c.settings.folderBookmarks.single;
expect((pair.path, pair.right, pair.name), (left, right, 'left ⇄ right')); expect((pair.path, pair.right, pair.name), (left, right, 'left ⇄ right'));
await tester.tap(find.text('닫기')); await tester.tap(find.text('닫기'));
await tester.pumpAndSettle(); await calm(tester);
// 반대 짝을 넣어 두고 열면 두 창이 함께 바뀐다 // 반대 짝을 넣어 두고 열면 두 창이 함께 바뀐다
await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: '거꾸로', path: right, right: left)]); await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: '거꾸로', path: right, right: left)]);
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.tap(find.text('거꾸로'))); await act(tester, () => tester.tap(find.text('거꾸로')));
await settle(tester, () => c.settings.rsyncPaths.length == 2 && c.settings.rsyncPaths[0] == right && c.settings.rsyncPaths[1] == left, await settle(tester, () => c.settings.rsyncPaths.length == 2 && c.settings.rsyncPaths[0] == right && c.settings.rsyncPaths[1] == left,
rounds: 100); rounds: 100);
@ -368,9 +368,9 @@ void main() {
Future<void> tool(String label) async { Future<void> tool(String label) async {
await tester.tap(find.text('고르기 도구').first); await tester.tap(find.text('고르기 도구').first);
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.textContaining(label).last); await tester.tap(find.textContaining(label).last);
await tester.pumpAndSettle(); await calm(tester);
} }
// 같은 확장자 모두 (.srt) - 마지막으로 누른 a.srt 기준 // 같은 확장자 모두 (.srt) - 마지막으로 누른 a.srt 기준
@ -386,7 +386,7 @@ void main() {
await tool('패턴으로 고르기'); await tool('패턴으로 고르기');
await tester.enterText(find.byType(TextField).last, '*.MKV; doc.*'); await tester.enterText(find.byType(TextField).last, '*.MKV; doc.*');
await tester.tap(find.widgetWithText(FilledButton, '고르기')); await tester.tap(find.widgetWithText(FilledButton, '고르기'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.text('2개 표시함 · 13B'), findsOneWidget); expect(find.text('2개 표시함 · 13B'), findsOneWidget);
// 범위: a.srt 를 표시한 뒤 Shift + c.mkv → a.srt · b.srt · c.mkv (보이는 순서) // 범위: a.srt 를 표시한 뒤 Shift + c.mkv → a.srt · b.srt · c.mkv (보이는 순서)
@ -404,9 +404,9 @@ void main() {
await tester.pump(); await tester.pump();
await act(tester, () => tester.tap(markOf('b.srt'))); await act(tester, () => tester.tap(markOf('b.srt')));
await tester.longPress(find.text('doc.txt').first); await tester.longPress(find.text('doc.txt').first);
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.text('여기까지 범위 고르기')); await tester.tap(find.text('여기까지 범위 고르기'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.textContaining('3개 표시함'), findsOneWidget, reason: 'b.srt · c.mkv · doc.txt'); expect(find.textContaining('3개 표시함'), findsOneWidget, reason: 'b.srt · c.mkv · doc.txt');
}); });
@ -548,14 +548,14 @@ void main() {
await settle(tester, () => find.textContaining('2개 표시함').evaluate().isNotEmpty); await settle(tester, () => find.textContaining('2개 표시함').evaluate().isNotEmpty);
expect(find.textContaining('2개 표시함'), findsOneWidget); expect(find.textContaining('2개 표시함'), findsOneWidget);
await act(tester, () => tester.tap(find.text('삭제').first)); await act(tester, () => tester.tap(find.text('삭제').first));
await tester.pumpAndSettle(); await calm(tester);
// 지울 항목 이름이 확인 창에 보인다. 65: Windows 는 기본이 휴지통 (시험에서는 영구 삭제를 골라 휴지통을 어지럽히지 않음) // 지울 항목 이름이 확인 창에 보인다. 65: Windows 는 기본이 휴지통 (시험에서는 영구 삭제를 골라 휴지통을 어지럽히지 않음)
expect(find.textContaining('2개 항목을 휴지통으로 보낼까요?'), findsOneWidget); expect(find.textContaining('2개 항목을 휴지통으로 보낼까요?'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '휴지통으로'), findsOneWidget); expect(find.widgetWithText(FilledButton, '휴지통으로'), findsOneWidget);
expect(find.textContaining('· sub'), findsOneWidget); expect(find.textContaining('· sub'), findsOneWidget);
expect(find.textContaining('· doc.txt'), findsOneWidget); expect(find.textContaining('· doc.txt'), findsOneWidget);
await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.textContaining('2개 항목을 지울까요? 되돌릴 수 없습니다.'), findsOneWidget); expect(find.textContaining('2개 항목을 지울까요? 되돌릴 수 없습니다.'), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제')));
await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); await settle(tester, () => find.text('doc.txt').evaluate().isEmpty);
@ -564,14 +564,14 @@ void main() {
// 새 폴더 → 이름 변경 (지운 뒤 창들이 목록을 다시 읽는 동안은 진행 막대가 돌아 pumpAndSettle 이 끝나지 않는다 - 무거울 때 흔들림) // 새 폴더 → 이름 변경 (지운 뒤 창들이 목록을 다시 읽는 동안은 진행 막대가 돌아 pumpAndSettle 이 끝나지 않는다 - 무거울 때 흔들림)
await idle(tester); await idle(tester);
await act(tester, () => tester.tap(find.text('새 폴더').first)); await act(tester, () => tester.tap(find.text('새 폴더').first));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.enterText(find.byType(TextField), 'films')); await act(tester, () => tester.enterText(find.byType(TextField), 'films'));
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.text('films').evaluate().isNotEmpty); await settle(tester, () => find.text('films').evaluate().isNotEmpty);
expect(Directory(p.join(left, 'films')).existsSync(), isTrue); expect(Directory(p.join(left, 'films')).existsSync(), isTrue);
await idle(tester); await idle(tester);
await act(tester, () => tester.tap(find.text('이름 변경').first)); await act(tester, () => tester.tap(find.text('이름 변경').first));
await tester.pumpAndSettle(); await calm(tester);
await act(tester, () => tester.enterText(find.byType(TextField), 'movies')); await act(tester, () => tester.enterText(find.byType(TextField), 'movies'));
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.text('movies').evaluate().isNotEmpty); await settle(tester, () => find.text('movies').evaluate().isNotEmpty);
@ -581,33 +581,33 @@ void main() {
testWidgets('창 배치: 한 창 · 버튼 줄 숨김, 버튼 구성: 버튼 빼기 → 설정에 저장', (tester) async { testWidgets('창 배치: 한 창 · 버튼 줄 숨김, 버튼 구성: 버튼 빼기 → 설정에 저장', (tester) async {
await open(tester); await open(tester);
await tester.tap(find.byTooltip('창 배치 · 버튼 구성')); await tester.tap(find.byTooltip('창 배치 · 버튼 구성'));
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.text('창 배치').last); await tester.tap(find.text('창 배치').last);
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.widgetWithText(ChoiceChip, '한 창')); await tester.tap(find.widgetWithText(ChoiceChip, '한 창'));
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(ChoiceChip, '숨김')); await tester.tap(find.widgetWithText(ChoiceChip, '숨김'));
await tester.pump(); await tester.pump();
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await tester.pumpAndSettle(); await calm(tester);
expect([c.settings.explorerLayout, c.settings.explorerToolbar], ['single', 'hidden']); expect([c.settings.explorerLayout, c.settings.explorerToolbar], ['single', 'hidden']);
expect(find.text('상위 폴더'), findsNothing); // 버튼 줄 숨김 expect(find.text('상위 폴더'), findsNothing); // 버튼 줄 숨김
expect(find.text('doc.txt'), findsOneWidget); // 한 창 (왼쪽만) expect(find.text('doc.txt'), findsOneWidget); // 한 창 (왼쪽만)
// ⋮ 메뉴에서 버튼 구성: "찾기" 빼기 // ⋮ 메뉴에서 버튼 구성: "찾기" 빼기
await c.updateSettings((s) => s.explorerToolbar = 'middle'); await c.updateSettings((s) => s.explorerToolbar = 'middle');
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.byTooltip('창 배치 · 버튼 구성')); await tester.tap(find.byTooltip('창 배치 · 버튼 구성'));
await tester.pumpAndSettle(); await calm(tester);
await tester.tap(find.text('버튼 구성').last); await tester.tap(find.text('버튼 구성').last);
await tester.pumpAndSettle(); await calm(tester);
// 처음 값 순서 (자주 쓰는 것부터) 에서 뒤쪽이라 목록을 내려 보이게 한 뒤 // 처음 값 순서 (자주 쓰는 것부터) 에서 뒤쪽이라 목록을 내려 보이게 한 뒤
await tester.scrollUntilVisible(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가'), 80, await tester.scrollUntilVisible(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가'), 80,
scrollable: find.descendant(of: find.byType(AlertDialog), matching: find.byType(Scrollable)).first); scrollable: find.descendant(of: find.byType(AlertDialog), matching: find.byType(Scrollable)).first);
await tester.tap(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가')); await tester.tap(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가'));
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '확인')); await tester.tap(find.widgetWithText(FilledButton, '확인'));
await tester.pumpAndSettle(); await calm(tester);
expect(c.settings.explorerButtons, isNot(contains('addMkv'))); expect(c.settings.explorerButtons, isNot(contains('addMkv')));
expect(c.settings.explorerButtons, contains('copy')); expect(c.settings.explorerButtons, contains('copy'));
expect(find.text('MKV 목록에 추가'), findsNothing); expect(find.text('MKV 목록에 추가'), findsNothing);
@ -673,7 +673,7 @@ void main() {
expect(find.text('$rs/ → $ls/'), findsOneWidget); expect(find.text('$rs/ → $ls/'), findsOneWidget);
expect(find.textContaining('-avPog -u'), findsNWidgets(2)); expect(find.textContaining('-avPog -u'), findsNWidgets(2));
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await tester.pumpAndSettle(); await calm(tester);
expect(c.settings.copyTasks, isEmpty); expect(c.settings.copyTasks, isEmpty);
expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음 expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음
// → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움)
@ -684,13 +684,13 @@ void main() {
expect(find.textContaining('-u'), findsNothing); expect(find.textContaining('-u'), findsNothing);
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing); expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing);
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)'));
await tester.pumpAndSettle(); await calm(tester);
expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget); expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget);
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget); expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget);
expect(find.text('빈 폴더 지움 · 원본 폴더도 지움'), findsOneWidget); expect(find.text('빈 폴더 지움 · 원본 폴더도 지움'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget); expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget);
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await tester.pumpAndSettle(); await calm(tester);
// 고른 폴더는 기억 (다음에 열 때 그 폴더부터) // 고른 폴더는 기억 (다음에 열 때 그 폴더부터)
expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]); expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]);
@ -700,7 +700,7 @@ void main() {
await act(tester, () => tester.tap(find.text('rsub'))); await act(tester, () => tester.tap(find.text('rsub')));
await settle(tester, () => false, rounds: 5); // 다시 골라 지금 폴더로 await settle(tester, () => false, rounds: 5); // 다시 골라 지금 폴더로
await act(tester, () => tester.tap(find.text('새 폴더'))); await act(tester, () => tester.tap(find.text('새 폴더')));
await tester.pumpAndSettle(); await calm(tester);
await tester.enterText(find.byType(TextField), '새로만든'); await tester.enterText(find.byType(TextField), '새로만든');
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
// 만든 폴더가 보이고 골라질 때까지 (전체 시험 중 바쁠 때는 고르기가 조금 늦다) // 만든 폴더가 보이고 골라질 때까지 (전체 시험 중 바쁠 때는 고르기가 조금 늦다)
@ -743,10 +743,21 @@ Future<void> doubleTap(WidgetTester tester, Finder f) async {
await tester.pump(); await tester.pump();
} }
/// 창들이 목록을 다시 읽는 중 (맨 위 진행 막대) 이 끝날 때까지 실제 시간으로 기다린다. /// 창들이 목록을 다시 읽는 중 (맨 위 진행 막대 · 도는 표시) 이 끝날 때까지 실제 시간으로 기다린다.
/// 그사이 pumpAndSettle 을 하면 막대가 계속 움직여, 디스크 읽기가 늦을 때 (전체 시험이 무거울 때) 시간 초과가 난다 /// 그사이 pumpAndSettle 을 하면 막대가 계속 움직여, 디스크 읽기가 늦을 때 (전체 시험이 무거울 때) 시간 초과가 난다.
Future<void> idle(WidgetTester tester) => /// 값이 있는 막대 (디스크 용량 · 진행률) 는 움직이지 않으므로 세지 않는다
settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.byType(CircularProgressIndicator).evaluate().isEmpty); Future<void> idle(WidgetTester tester) => settle(
tester,
() => find
.byWidgetPredicate((w) => w is ProgressIndicator && w.value == null)
.evaluate()
.isEmpty);
/// 탐색기 시험의 pumpAndSettle: 목록 읽기가 끝난 뒤에 (경로를 바꾸거나 다시 읽은 뒤 무거울 때 흔들리던 것을 한 번에)
Future<void> calm(WidgetTester tester) async {
await idle(tester);
await tester.pumpAndSettle();
}
/// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신 /// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신
// 끝나면 바로 나온다 - 넉넉한 횟수는 전체 시험 중 PC 가 바쁠 때 (실제 파일 입출력이 느려짐) 흔들리지 않게 // 끝나면 바로 나온다 - 넉넉한 횟수는 전체 시험 중 PC 가 바쁠 때 (실제 파일 입출력이 느려짐) 흔들리지 않게

View File

@ -13,7 +13,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, doubleTap, settle; import 'explorer_test.dart' show act, doubleTap, settle, calm;
import 'support/dav_server.dart'; import 'support/dav_server.dart';
class _Shell extends NoopShell { class _Shell extends NoopShell {
@ -76,7 +76,7 @@ void main() {
// 서버가 없으면 위쪽 저장 장치 옆에 "WebDAV" 탭 (창마다) // 서버가 없으면 위쪽 저장 장치 옆에 "WebDAV" 탭 (창마다)
expect(find.text('WebDAV'), findsNWidgets(2)); expect(find.text('WebDAV'), findsNWidgets(2));
await act(tester, () => tester.tap(find.text('WebDAV').last)); await act(tester, () => tester.tap(find.text('WebDAV').last));
await tester.pumpAndSettle(); await calm(tester);
expect(find.text('WebDAV 서버 추가'), findsOneWidget); expect(find.text('WebDAV 서버 추가'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, '이름 (탭에 보임, 비우면 주소)'), '집 NAS'); await tester.enterText(find.widgetWithText(TextField, '이름 (탭에 보임, 비우면 주소)'), '집 NAS');
await tester.enterText(find.widgetWithText(TextField, '주소'), server.url); await tester.enterText(find.widgetWithText(TextField, '주소'), server.url);
@ -108,7 +108,7 @@ void main() {
await act(tester, () => tester.tap(find.text('doc.txt'))); await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15); await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.tap(find.text('복사').first)); await act(tester, () => tester.tap(find.text('복사').first));
await tester.pumpAndSettle(); await calm(tester);
expect(find.textContaining('☁ 집 NAS/원격'), findsOneWidget); expect(find.textContaining('☁ 집 NAS/원격'), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사')));
final up = File(p.join(remote.path, '원격', 'doc.txt')); final up = File(p.join(remote.path, '원격', 'doc.txt'));
@ -170,7 +170,8 @@ void main() {
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
Future<void> openBookmark() async { Future<void> openBookmark() async {
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
await tester.pumpAndSettle(); // pumpAndSettle 이 아니라 창이 뜰 때까지 - 목록을 읽는 중 (진행 막대) 이면 무거울 때 시간 초과
await settle(tester, () => find.text('NAS 원격').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.text('NAS 원격'))); await act(tester, () => tester.tap(find.text('NAS 원격')));
} }

View File

@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/core/cron_window.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.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/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/copy_sync_settings.dart' show CopySyncSettings;
import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -237,6 +238,31 @@ void main() {
expect(live.watching.map((x) => x.source), [b.path]); expect(live.watching.map((x) => x.source), [b.path]);
}); });
test('244: 맞출 것이 없어도 대상에만 있는 것이 있으면 "같습니다" 가 아니라 그 수를 센다', () async {
final tmp = Directory.systemTemp.createTempSync('jj_only_');
addTearDown(() => tmp.deleteSync(recursive: true));
final a = Directory(p.join(tmp.path, 'a'))..createSync();
final t = Directory(p.join(tmp.path, 't'))..createSync();
final now = DateTime.now();
for (final d in [a, t]) {
(File(p.join(d.path, 'same.txt'))..writeAsStringSync('s')).setLastModifiedSync(now);
}
File(p.join(t.path, 'only1.txt')).writeAsStringSync('1');
Directory(p.join(t.path, 'onlydir')).createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(a.path, t.path, method: 'builtin');
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await live.refreshPending(pair);
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
expect(live.onlyTarget[LiveSync.keyOf(pair)], 2);
File(p.join(t.path, 'only1.txt')).deleteSync();
Directory(p.join(t.path, 'onlydir')).deleteSync();
await live.refreshPending(pair);
expect(live.onlyTarget[LiveSync.keyOf(pair)], 0, reason: '정말 같으면 0 - "같습니다"');
});
test('dispose 뒤에 맞추기가 끝나도 알리지 않는다 (앱을 끄는 중 - used after disposed 오류 없음)', () async { test('dispose 뒤에 맞추기가 끝나도 알리지 않는다 (앱을 끄는 중 - used after disposed 오류 없음)', () async {
final tmp = Directory.systemTemp.createTempSync('jj_disp_'); final tmp = Directory.systemTemp.createTempSync('jj_disp_');
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));
@ -275,6 +301,7 @@ void main() {
await tester.tap(find.text('저장')); await tester.tap(find.text('저장'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('옵션을 저장했습니다'), findsOneWidget); expect(find.text('옵션을 저장했습니다'), findsOneWidget);
expect(find.text('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?'), findsOneWidget);
expect(find.text('설정 반영 후 실행'), findsOneWidget); expect(find.text('설정 반영 후 실행'), findsOneWidget);
await tester.tap(find.text('현재 유지')); await tester.tap(find.text('현재 유지'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -288,4 +315,30 @@ void main() {
expect(find.text('지금 맞추기'), findsOneWidget); expect(find.text('지금 맞추기'), findsOneWidget);
expect(find.text('복사 · rsync 로 이동'), findsOneWidget); expect(find.text('복사 · rsync 로 이동'), findsOneWidget);
}); });
testWidgets('233: 이동 카드의 저장 창은 "이 이동을" · 229 설정은 Rsync 탭에도 있다', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_mon233_');
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, '..'), move: true, method: 'builtin')];
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();
await tester.enterText(find.widgetWithText(TextField, '속도 제한'), '500');
await tester.pump();
await tester.tap(find.text('저장'));
await tester.pumpAndSettle();
expect(find.text('이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?'), findsOneWidget);
await tester.tap(find.text('현재 유지'));
await tester.pumpAndSettle();
// Rsync 탭의 설정에도 같은 스위치
await tester.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: CopySyncSettings(c: c, rsync: true)))));
await tester.pump();
expect(find.text('받는 쪽이 더 새것이면 건너뛰기'), findsOneWidget);
await tester.tap(find.text('받는 쪽이 더 새것이면 건너뛰기'));
await tester.pump();
expect(c.settings.copySkipNewer, isFalse);
});
} }