WIP 원격 확인 창: 옵션에 거르기 (--exclude 등) 가 있으면 "원격은 거르기를 따르지 않음" 주황 경고 · 안내 글에 SMB · FTP (검토 대기)
관리자 지시 (10/19, 15 의 원격 동기화 검토 M2): 원격 (WebDAV · SFTP · SMB · FTP) 은 rsync 대신 앱이 직접 맞추며 옵션의 거르기
(--exclude · --include · --filter · -f · --exclude-from · --include-from · -C · -F) 를 따르지 않는다 - 지키려고 뺀 것도 복사하고, 지우기면 지운다.
미리 보기도 거르기 없이 계산해 실제와 같지만, 확인 창은 이것을 알리지 않았다.
- 새 optionsFilter (sync_preview.dart): 옵션에 거르기가 있는지.
- Rsync 화면 확인 창 · 지우기 포함 작업의 확인 창 (모니터링 [실행] 등, confirmDeletingRun): 원격이고 거르기가 있으면 주황 경고
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 … 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다."
- 원격 안내 글 "WebDAV · SFTP: rsync 대신 앱이 직접 맞춥니다" → "WebDAV · SFTP · SMB · FTP: …" (SMB · FTP 도 같은 길).
- 관리자 지적: 6278403 에서 test/webdav_test.dart · test/sftp_test.dart 의 줄바꿈이 파일 전체에서 CRLF 로 바뀐 것을 원래 (LF) 로 되돌림
(6278403~1 과 비교하면 +27 · +23 줄만 남음).
_mirrorV 가 RsyncFilter 를 따르게 하는 것은 next3 와 합친 뒤의 다음 일 (관리자 결정).
시험: sync_preview_test (새 1: optionsFilter 있음 10 · 없음 6, -e "ssh -C" 는 아님) · explorer_webdav_test (안내 글 · 거르기 없으면 경고 없음) ·
sync_preview_view · webdav · sftp 31 통과, 임시 폴더 흔적 0, analyze 문제 없음, l10n 빠짐 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next2
parent
62784039e4
commit
22ae03953d
|
|
@ -356,6 +356,7 @@
|
||||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}",
|
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}",
|
||||||
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept",
|
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV, OpenSubtitles, etc. Server list and user names are kept",
|
||||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts",
|
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts",
|
||||||
|
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).",
|
||||||
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV / SFTP reads everything inside from the server, which can take a long time. Analyze?",
|
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV / SFTP reads everything inside from the server, which can take a long time. Analyze?",
|
||||||
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV / SFTP can't be added to the MKV list. Copy them to this device first.",
|
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV / SFTP can't be added to the MKV list. Copy them to this device first.",
|
||||||
"WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV / SFTP files show only name, size and date",
|
"WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV / SFTP files show only name, size and date",
|
||||||
|
|
@ -1892,6 +1893,7 @@
|
||||||
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.",
|
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.",
|
||||||
"우르두어": "Urdu",
|
"우르두어": "Urdu",
|
||||||
"우크라이나어": "Ukrainian",
|
"우크라이나어": "Ukrainian",
|
||||||
|
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "Remote locations (WebDAV · SFTP · SMB · FTP) do not follow the filter options (--exclude, --include, --filter, …) - excluded items are copied too, and deleted if the run deletes. The comparison list above is what will actually happen.",
|
||||||
"원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}",
|
"원래 이름으로 되돌리지 못한 것: {0}": "Couldn't restore the original names of: {0}",
|
||||||
"원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place",
|
"원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place",
|
||||||
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)",
|
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)",
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,7 @@
|
||||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}",
|
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}",
|
||||||
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します",
|
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV・OpenSubtitles など。サーバー一覧と ID は残します",
|
||||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
|
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
|
||||||
|
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
|
||||||
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?",
|
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?",
|
||||||
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV・SFTP の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",
|
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV・SFTP の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",
|
||||||
"WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV・SFTP のファイルは名前・サイズ・日付のみ表示します",
|
"WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV・SFTP のファイルは名前・サイズ・日付のみ表示します",
|
||||||
|
|
@ -1892,6 +1893,7 @@
|
||||||
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。",
|
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。",
|
||||||
"우르두어": "ウルドゥー語",
|
"우르두어": "ウルドゥー語",
|
||||||
"우크라이나어": "ウクライナ語",
|
"우크라이나어": "ウクライナ語",
|
||||||
|
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "リモート (WebDAV・SFTP・SMB・FTP) はオプションのフィルター (--exclude・--include・--filter など) に従いません - 除外したものもコピーし、削除を含む場合は削除します。上の比較一覧が実際に行う内容です。",
|
||||||
"원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}",
|
"원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}",
|
||||||
"원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります",
|
"원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります",
|
||||||
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)",
|
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)",
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,7 @@
|
||||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}",
|
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}",
|
||||||
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名",
|
"WebDAV · OpenSubtitles 등. 서버 목록과 아이디는 남깁니다": "WebDAV · OpenSubtitles 等。保留服务器列表和用户名",
|
||||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
|
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
|
||||||
|
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。",
|
||||||
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?",
|
"WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?",
|
||||||
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV · SFTP 上的视频加入 MKV 列表。请先复制到本设备。",
|
"WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV · SFTP 上的视频加入 MKV 列表。请先复制到本设备。",
|
||||||
"WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV · SFTP 文件仅显示名称、大小和日期",
|
"WebDAV · SFTP 파일은 이름 · 크기 · 날짜만 봅니다": "WebDAV · SFTP 文件仅显示名称、大小和日期",
|
||||||
|
|
@ -1892,6 +1893,7 @@
|
||||||
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。",
|
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。",
|
||||||
"우르두어": "乌尔都语",
|
"우르두어": "乌尔都语",
|
||||||
"우크라이나어": "乌克兰语",
|
"우크라이나어": "乌克兰语",
|
||||||
|
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "远程 (WebDAV · SFTP · SMB · FTP) 不遵循选项中的过滤规则 (--exclude · --include · --filter 等) - 被排除的项目也会复制,如包含删除则会删除。上面的比较列表就是实际会执行的操作。",
|
||||||
"원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}",
|
"원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}",
|
||||||
"원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目",
|
"원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目",
|
||||||
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)",
|
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,16 @@ bool isRsyncDeleteOption(String o) => o == '--del' || o.startsWith('--delete') |
|
||||||
/// rsync 옵션에 지우기 (--delete 계열) 가 있는지
|
/// rsync 옵션에 지우기 (--delete 계열) 가 있는지
|
||||||
bool optionsDelete(String options) => splitOptions(options).any(isRsyncDeleteOption);
|
bool optionsDelete(String options) => splitOptions(options).any(isRsyncDeleteOption);
|
||||||
|
|
||||||
|
/// M2 (관리자 지시 10/19): rsync 옵션에 거르기 (--exclude · --include · --filter · -f · --exclude-from · --include-from · -C · -F) 가 있는지.
|
||||||
|
/// 원격 (WebDAV · SFTP · SMB · FTP) 은 앱이 직접 맞추며 이 거르기를 따르지 않으므로 확인 창에 알린다
|
||||||
|
bool optionsFilter(String options) => splitOptions(options).any((o) =>
|
||||||
|
o == '-f' ||
|
||||||
|
o == '-F' ||
|
||||||
|
o == '--cvs-exclude' ||
|
||||||
|
o == '-C' ||
|
||||||
|
RegExp(r'^--(exclude|include|filter|exclude-from|include-from)(=|$)').hasMatch(o) ||
|
||||||
|
(RegExp(r'^-[a-zA-Z]+$').hasMatch(o) && (o.contains('C') || o.contains('F'))));
|
||||||
|
|
||||||
/// 옵션 글을 낱말로 나누되 따옴표는 그대로 둔 원래 글 조각 (106: --exclude="My Folder" 가 갈라지지 않게)
|
/// 옵션 글을 낱말로 나누되 따옴표는 그대로 둔 원래 글 조각 (106: --exclude="My Folder" 가 갈라지지 않게)
|
||||||
List<String> rawOptionTokens(String s) {
|
List<String> rawOptionTokens(String s) {
|
||||||
final out = <String>[];
|
final out = <String>[];
|
||||||
|
|
|
||||||
|
|
@ -3533,8 +3533,15 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
if (_viaDav([l, r]))
|
if (_viaDav([l, r]))
|
||||||
Text(tr('WebDAV · SFTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).'),
|
Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).'),
|
||||||
style: const TextStyle(fontSize: 12, color: JjColors.accent)),
|
style: const TextStyle(fontSize: 12, color: JjColors.accent)),
|
||||||
|
// M2 (관리자 지시 10/19): 원격이고 옵션에 거르기가 있으면 - 앱 맞추기는 거르기를 따르지 않는다 (뺀 것도 복사 · 지움)
|
||||||
|
if (_viaDav([l, r]) && tasks.any((t) => optionsFilter(opts(t))))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(tr('원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,13 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
||||||
],
|
],
|
||||||
// 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다)
|
// 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다)
|
||||||
Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
||||||
|
// M2 (관리자 지시 10/19): 원격 (앱이 직접 맞춤) 인데 옵션에 거르기가 있으면 - 거르기를 따르지 않는다
|
||||||
|
if (direct && t.method == 'rsync' && optionsFilter(t.options))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(tr('원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
// 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히
|
// 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히
|
||||||
if (t.move)
|
if (t.move)
|
||||||
Padding(
|
Padding(
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,9 @@ void main() {
|
||||||
// 72: 비교 - 보낼 것 · 받는 쪽 (WebDAV) 에만 있는 것 (지우기 없으니 그대로 둠)
|
// 72: 비교 - 보낼 것 · 받는 쪽 (WebDAV) 에만 있는 것 (지우기 없으니 그대로 둠)
|
||||||
expect(find.textContaining('→ + inner.txt'), findsOneWidget);
|
expect(find.textContaining('→ + inner.txt'), findsOneWidget);
|
||||||
expect(find.textContaining('= note.txt'), findsOneWidget);
|
expect(find.textContaining('= note.txt'), findsOneWidget);
|
||||||
expect(find.textContaining('WebDAV · SFTP: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget);
|
expect(find.textContaining('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget);
|
||||||
|
// M2: 옵션에 거르기가 없으면 거르기 경고는 없다
|
||||||
|
expect(find.textContaining('거르기 (--exclude'), findsNothing);
|
||||||
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
|
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
|
||||||
final got = File(p.join(remote.path, '원격', 'inner.txt'));
|
final got = File(p.join(remote.path, '원격', 'inner.txt'));
|
||||||
await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200);
|
await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200);
|
||||||
|
|
|
||||||
|
|
@ -1,271 +1,271 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:jj_mkvmaker/app/settings.dart';
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
import 'package:jj_mkvmaker/core/sftp.dart';
|
import 'package:jj_mkvmaker/core/sftp.dart';
|
||||||
import 'package:jj_mkvmaker/core/vfs.dart';
|
import 'package:jj_mkvmaker/core/vfs.dart';
|
||||||
import 'package:jj_mkvmaker/services/secret_store.dart';
|
import 'package:jj_mkvmaker/services/secret_store.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import 'support/sftp_server.dart';
|
import 'support/sftp_server.dart';
|
||||||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||||
|
|
||||||
/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 -
|
/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 -
|
||||||
/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다.
|
/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다.
|
||||||
void main() {
|
void main() {
|
||||||
final hasServer = sftpTestServerAvailable;
|
final hasServer = sftpTestServerAvailable;
|
||||||
final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen';
|
final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen';
|
||||||
|
|
||||||
group('설정 · 경로 (서버 없이)', () {
|
group('설정 · 경로 (서버 없이)', () {
|
||||||
test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async {
|
test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async {
|
||||||
final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_');
|
final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_');
|
||||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||||
final secrets = MemorySecretStore();
|
final secrets = MemorySecretStore();
|
||||||
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
||||||
final s = AppSettings()
|
final s = AppSettings()
|
||||||
..sftpServers = [
|
..sftpServers = [
|
||||||
const SftpServer(
|
const SftpServer(
|
||||||
id: 's1',
|
id: 's1',
|
||||||
name: 'NAS',
|
name: 'NAS',
|
||||||
host: 'nas.local',
|
host: 'nas.local',
|
||||||
port: 2222,
|
port: 2222,
|
||||||
user: 'jj',
|
user: 'jj',
|
||||||
password: 'pw-secret',
|
password: 'pw-secret',
|
||||||
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----',
|
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----',
|
||||||
keyPassphrase: 'kp-secret',
|
keyPassphrase: 'kp-secret',
|
||||||
legacyRsa: true,
|
legacyRsa: true,
|
||||||
hostKeyType: 'ssh-ed25519',
|
hostKeyType: 'ssh-ed25519',
|
||||||
hostKeyFingerprint: 'SHA256:abc'),
|
hostKeyFingerprint: 'SHA256:abc'),
|
||||||
];
|
];
|
||||||
await store.save(s);
|
await store.save(s);
|
||||||
final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync();
|
final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync();
|
||||||
expect(text, isNot(contains('pw-secret')));
|
expect(text, isNot(contains('pw-secret')));
|
||||||
expect(text, isNot(contains('kp-secret')));
|
expect(text, isNot(contains('kp-secret')));
|
||||||
expect(text, isNot(contains('OPENSSH PRIVATE KEY')));
|
expect(text, isNot(contains('OPENSSH PRIVATE KEY')));
|
||||||
expect(secrets.values['sftp.pw.s1'], 'pw-secret');
|
expect(secrets.values['sftp.pw.s1'], 'pw-secret');
|
||||||
expect(secrets.values['sftp.kp.s1'], 'kp-secret');
|
expect(secrets.values['sftp.kp.s1'], 'kp-secret');
|
||||||
expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY'));
|
expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY'));
|
||||||
final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load();
|
final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load();
|
||||||
final x = back.sftpServers.single;
|
final x = back.sftpServers.single;
|
||||||
expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc'));
|
expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc'));
|
||||||
expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret'));
|
expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret'));
|
||||||
expect(x.privateKey, contains('OPENSSH PRIVATE KEY'));
|
expect(x.privateKey, contains('OPENSSH PRIVATE KEY'));
|
||||||
// .bak · 모르는 항목 보존용 JSON 에서도 빠진다
|
// .bak · 모르는 항목 보존용 JSON 에서도 빠진다
|
||||||
final stripped = AppSettings.stripSecrets({
|
final stripped = AppSettings.stripSecrets({
|
||||||
'sftpServers': [
|
'sftpServers': [
|
||||||
{'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'}
|
{'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"'))));
|
expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"'))));
|
||||||
expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}');
|
expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () {
|
test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () {
|
||||||
SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]);
|
SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]);
|
||||||
addTearDown(() => SftpRegistry.configure(const []));
|
addTearDown(() => SftpRegistry.configure(const []));
|
||||||
final d = DavPath.parse(r'sftp://s1/a\b/');
|
final d = DavPath.parse(r'sftp://s1/a\b/');
|
||||||
expect(d.full, 'sftp://s1/a/b');
|
expect(d.full, 'sftp://s1/a/b');
|
||||||
expect(d.isSftp, isTrue);
|
expect(d.isSftp, isTrue);
|
||||||
expect(d.parent.full, 'sftp://s1/a');
|
expect(d.parent.full, 'sftp://s1/a');
|
||||||
expect(d.parent.parent.full, 'sftp://s1/');
|
expect(d.parent.parent.full, 'sftp://s1/');
|
||||||
expect(d.child('c').full, 'sftp://s1/a/b/c');
|
expect(d.child('c').full, 'sftp://s1/a/b/c');
|
||||||
expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x');
|
expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x');
|
||||||
expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a');
|
expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a');
|
||||||
expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)');
|
expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)');
|
||||||
expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true));
|
expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true));
|
||||||
expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b');
|
expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b');
|
||||||
expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다');
|
expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다');
|
||||||
expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse);
|
expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('실제 SFTP 서버 (paramiko)', () {
|
group('실제 SFTP 서버 (paramiko)', () {
|
||||||
late Directory tmp;
|
late Directory tmp;
|
||||||
late Directory root;
|
late Directory root;
|
||||||
SftpTestServer? server;
|
SftpTestServer? server;
|
||||||
late int port;
|
late int port;
|
||||||
late String fingerprint;
|
late String fingerprint;
|
||||||
|
|
||||||
Future<void> start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async {
|
Future<void> start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async {
|
||||||
final s = await SftpTestServer.start(
|
final s = await SftpTestServer.start(
|
||||||
root: root.path,
|
root: root.path,
|
||||||
hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'),
|
hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'),
|
||||||
password: password,
|
password: password,
|
||||||
pubkey: pubkey,
|
pubkey: pubkey,
|
||||||
keytype: keytype,
|
keytype: keytype,
|
||||||
legacyOnly: legacyOnly);
|
legacyOnly: legacyOnly);
|
||||||
server = s;
|
server = s;
|
||||||
port = s.port;
|
port = s.port;
|
||||||
fingerprint = s.fingerprint;
|
fingerprint = s.fingerprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
tmp = Directory.systemTemp.createTempSync('jj_sftp88_');
|
tmp = Directory.systemTemp.createTempSync('jj_sftp88_');
|
||||||
root = Directory(p.join(tmp.path, 'root'))..createSync();
|
root = Directory(p.join(tmp.path, 'root'))..createSync();
|
||||||
});
|
});
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
SftpRegistry.configure(const []);
|
SftpRegistry.configure(const []);
|
||||||
final s = server;
|
final s = server;
|
||||||
server = null;
|
server = null;
|
||||||
await s?.stop();
|
await s?.stop();
|
||||||
for (var i = 0;; i++) {
|
for (var i = 0;; i++) {
|
||||||
try {
|
try {
|
||||||
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
|
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
|
||||||
break;
|
break;
|
||||||
} on FileSystemException {
|
} on FileSystemException {
|
||||||
if (i > 30) rethrow;
|
if (i > 30) rethrow;
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) =>
|
SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) =>
|
||||||
SftpServer(
|
SftpServer(
|
||||||
id: 's1',
|
id: 's1',
|
||||||
name: 'test',
|
name: 'test',
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: port,
|
port: port,
|
||||||
user: 'jj',
|
user: 'jj',
|
||||||
password: password,
|
password: password,
|
||||||
privateKey: key,
|
privateKey: key,
|
||||||
keyPassphrase: kp,
|
keyPassphrase: kp,
|
||||||
legacyRsa: legacy,
|
legacyRsa: legacy,
|
||||||
hostKeyFingerprint: fp,
|
hostKeyFingerprint: fp,
|
||||||
root: rootDir);
|
root: rootDir);
|
||||||
|
|
||||||
Future<Object?> err(Future<void> Function() f) async {
|
Future<Object?> err(Future<void> Function() f) async {
|
||||||
try {
|
try {
|
||||||
await f();
|
await f();
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async {
|
test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async {
|
||||||
await start();
|
await start();
|
||||||
final e = await err(() => SftpRemote(srv()).list('/'));
|
final e = await err(() => SftpRemote(srv()).list('/'));
|
||||||
expect(e, isA<SftpHostKeyException>());
|
expect(e, isA<SftpHostKeyException>());
|
||||||
final hk = e! as SftpHostKeyException;
|
final hk = e! as SftpHostKeyException;
|
||||||
expect(hk.kind, SftpFailure.hostKeyUnknown);
|
expect(hk.kind, SftpFailure.hostKeyUnknown);
|
||||||
expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문');
|
expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문');
|
||||||
expect(hk.type, 'ssh-ed25519');
|
expect(hk.type, 'ssh-ed25519');
|
||||||
expect(hk.changed, isFalse);
|
expect(hk.changed, isFalse);
|
||||||
// 믿음 → 연결
|
// 믿음 → 연결
|
||||||
final ok = SftpRemote(srv(fp: fingerprint));
|
final ok = SftpRemote(srv(fp: fingerprint));
|
||||||
addTearDown(ok.close);
|
addTearDown(ok.close);
|
||||||
expect(await ok.list('/'), isEmpty);
|
expect(await ok.list('/'), isEmpty);
|
||||||
// 바뀐 키 → 막고 예전 · 지금 지문을 알림
|
// 바뀐 키 → 막고 예전 · 지금 지문을 알림
|
||||||
final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/'));
|
final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/'));
|
||||||
expect(e2, isA<SftpHostKeyException>());
|
expect(e2, isA<SftpHostKeyException>());
|
||||||
expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged);
|
expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged);
|
||||||
expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키');
|
expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키');
|
||||||
expect('$e2', contains('바뀌었습니다'));
|
expect('$e2', contains('바뀌었습니다'));
|
||||||
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||||
|
|
||||||
test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async {
|
test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async {
|
||||||
await start();
|
await start();
|
||||||
final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/'));
|
final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/'));
|
||||||
expect((bad! as SftpException).kind, SftpFailure.auth);
|
expect((bad! as SftpException).kind, SftpFailure.auth);
|
||||||
final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/'));
|
final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/'));
|
||||||
expect((none! as SftpException).kind, SftpFailure.noSecret);
|
expect((none! as SftpException).kind, SftpFailure.noSecret);
|
||||||
expect('$none', '저장된 비밀번호가 없습니다');
|
expect('$none', '저장된 비밀번호가 없습니다');
|
||||||
// 닫힌 포트
|
// 닫힌 포트
|
||||||
final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
final closedPort = free.port;
|
final closedPort = free.port;
|
||||||
await free.close();
|
await free.close();
|
||||||
final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'),
|
final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'),
|
||||||
timeout: const Duration(seconds: 3))
|
timeout: const Duration(seconds: 3))
|
||||||
.list('/'));
|
.list('/'));
|
||||||
expect((off! as SftpException).kind, SftpFailure.unreachable);
|
expect((off! as SftpException).kind, SftpFailure.unreachable);
|
||||||
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||||
|
|
||||||
test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async {
|
test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async {
|
||||||
await start();
|
await start();
|
||||||
SftpRegistry.configure([srv(fp: fingerprint)]);
|
SftpRegistry.configure([srv(fp: fingerprint)]);
|
||||||
final local = Directory(p.join(tmp.path, 'local', 'src'))..createSync(recursive: true);
|
final local = Directory(p.join(tmp.path, 'local', 'src'))..createSync(recursive: true);
|
||||||
File(p.join(local.path, 'x')).writeAsStringSync('file');
|
File(p.join(local.path, 'x')).writeAsStringSync('file');
|
||||||
Directory(p.join(root.path, 'dst', 'x', 'sub')).createSync(recursive: true);
|
Directory(p.join(root.path, 'dst', 'x', 'sub')).createSync(recursive: true);
|
||||||
File(p.join(root.path, 'dst', 'x', 'keep.txt')).writeAsStringSync('k');
|
File(p.join(root.path, 'dst', 'x', 'keep.txt')).writeAsStringSync('k');
|
||||||
const dst = 'sftp://s1/dst';
|
const dst = 'sftp://s1/dst';
|
||||||
var pv = await previewSync(local.path, dst, toRight: true);
|
var pv = await previewSync(local.path, dst, toRight: true);
|
||||||
expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']);
|
expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']);
|
||||||
final ops = FileOps();
|
final ops = FileOps();
|
||||||
await ops.mirror(local.path, dst);
|
await ops.mirror(local.path, dst);
|
||||||
expect(File(p.join(root.path, 'dst', 'x', 'keep.txt')).existsSync(), isTrue);
|
expect(File(p.join(root.path, 'dst', 'x', 'keep.txt')).existsSync(), isTrue);
|
||||||
expect(ops.dirConflicts, hasLength(1));
|
expect(ops.dirConflicts, hasLength(1));
|
||||||
pv = await previewSync(local.path, dst, toRight: true, delete: true);
|
pv = await previewSync(local.path, dst, toRight: true, delete: true);
|
||||||
final del = pv.singleWhere((x) => x.action == PreviewAction.delete);
|
final del = pv.singleWhere((x) => x.action == PreviewAction.delete);
|
||||||
expect((del.replacedByFile, del.inner), (true, 2));
|
expect((del.replacedByFile, del.inner), (true, 2));
|
||||||
await FileOps().mirror(local.path, dst, delete: true);
|
await FileOps().mirror(local.path, dst, delete: true);
|
||||||
expect(File(p.join(root.path, 'dst', 'x')).readAsStringSync(), 'file');
|
expect(File(p.join(root.path, 'dst', 'x')).readAsStringSync(), 'file');
|
||||||
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||||
|
|
||||||
test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async {
|
test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async {
|
||||||
await start();
|
await start();
|
||||||
SftpRegistry.configure([srv(fp: fingerprint)]);
|
SftpRegistry.configure([srv(fp: fingerprint)]);
|
||||||
const base = 'sftp://s1/';
|
const base = 'sftp://s1/';
|
||||||
await vMkdirs('${base}a/b');
|
await vMkdirs('${base}a/b');
|
||||||
expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue);
|
expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue);
|
||||||
Stream<List<int>> bytes(String s) => Stream.value(utf8.encode(s));
|
Stream<List<int>> bytes(String s) => Stream.value(utf8.encode(s));
|
||||||
await vWrite('${base}a/b/f.txt', bytes('hello'));
|
await vWrite('${base}a/b/f.txt', bytes('hello'));
|
||||||
await vWrite('${base}a/b/f.txt', bytes('hello again'));
|
await vWrite('${base}a/b/f.txt', bytes('hello again'));
|
||||||
expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)');
|
expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)');
|
||||||
expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음');
|
expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음');
|
||||||
final list = await vList('${base}a/b');
|
final list = await vList('${base}a/b');
|
||||||
expect(list.single.path, 'sftp://s1/a/b/f.txt');
|
expect(list.single.path, 'sftp://s1/a/b/f.txt');
|
||||||
expect((list.single.isDir, list.single.size), (false, 11));
|
expect((list.single.isDir, list.single.size), (false, 11));
|
||||||
final st = await vStat('${base}a');
|
final st = await vStat('${base}a');
|
||||||
expect(st!.isDir, isTrue);
|
expect(st!.isDir, isTrue);
|
||||||
expect(await vStat('$base없음'), isNull);
|
expect(await vStat('$base없음'), isNull);
|
||||||
expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again');
|
expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again');
|
||||||
await vRename('${base}a/b/f.txt', '${base}a/g.txt');
|
await vRename('${base}a/b/f.txt', '${base}a/g.txt');
|
||||||
expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue);
|
expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue);
|
||||||
// 받아서 열기 (임시 폴더)
|
// 받아서 열기 (임시 폴더)
|
||||||
final local = await vLocalCopy('${base}a/g.txt', tmp.path);
|
final local = await vLocalCopy('${base}a/g.txt', tmp.path);
|
||||||
expect(File(local).readAsStringSync(), 'hello again');
|
expect(File(local).readAsStringSync(), 'hello again');
|
||||||
expect(local, contains('jj_sftp'));
|
expect(local, contains('jj_sftp'));
|
||||||
await vDelete('${base}a');
|
await vDelete('${base}a');
|
||||||
expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse);
|
expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse);
|
||||||
// 시작 폴더: 그 안이 탭의 맨 위
|
// 시작 폴더: 그 안이 탭의 맨 위
|
||||||
Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true);
|
Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true);
|
||||||
File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m');
|
File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m');
|
||||||
SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]);
|
SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]);
|
||||||
expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']);
|
expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']);
|
||||||
final (free, _) = await DavPath.parse(base).client.quota();
|
final (free, _) = await DavPath.parse(base).client.quota();
|
||||||
expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null');
|
expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null');
|
||||||
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||||
|
|
||||||
test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async {
|
test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async {
|
||||||
final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}');
|
final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}');
|
||||||
final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]);
|
final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]);
|
||||||
if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음');
|
if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음');
|
||||||
addTearDown(() {
|
addTearDown(() {
|
||||||
for (final f in [key, '$key.pub']) {
|
for (final f in [key, '$key.pub']) {
|
||||||
if (File(f).existsSync()) File(f).deleteSync();
|
if (File(f).existsSync()) File(f).deleteSync();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await start(password: null, pubkey: '$key.pub');
|
await start(password: null, pubkey: '$key.pub');
|
||||||
final pem = File(key).readAsStringSync();
|
final pem = File(key).readAsStringSync();
|
||||||
final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint));
|
final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint));
|
||||||
addTearDown(c.close);
|
addTearDown(c.close);
|
||||||
expect(await c.list('/'), isEmpty);
|
expect(await c.list('/'), isEmpty);
|
||||||
final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/'));
|
final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/'));
|
||||||
expect((bad! as SftpException).kind, SftpFailure.auth);
|
expect((bad! as SftpException).kind, SftpFailure.auth);
|
||||||
expect('$bad', contains('개인 키의 암호'));
|
expect('$bad', contains('개인 키의 암호'));
|
||||||
}, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음');
|
}, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음');
|
||||||
|
|
||||||
test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async {
|
test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async {
|
||||||
await start(keytype: 'rsa', legacyOnly: true);
|
await start(keytype: 'rsa', legacyOnly: true);
|
||||||
final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/'));
|
final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/'));
|
||||||
expect(e, isA<SftpException>());
|
expect(e, isA<SftpException>());
|
||||||
expect('$e', contains('ssh-rsa'));
|
expect('$e', contains('ssh-rsa'));
|
||||||
final c = SftpRemote(srv(fp: fingerprint, legacy: true));
|
final c = SftpRemote(srv(fp: fingerprint, legacy: true));
|
||||||
addTearDown(c.close);
|
addTearDown(c.close);
|
||||||
expect(await c.list('/'), isEmpty);
|
expect(await c.list('/'), isEmpty);
|
||||||
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,4 +101,23 @@ void main() {
|
||||||
expect(r['only_b.txt'], (PreviewAction.delete, true));
|
expect(r['only_b.txt'], (PreviewAction.delete, true));
|
||||||
expect(r['new.txt'], (PreviewAction.add, true));
|
expect(r['new.txt'], (PreviewAction.add, true));
|
||||||
});
|
});
|
||||||
|
test('M2 (10/19): optionsFilter - 거르기 옵션이 있는지 (원격 확인 창의 경고)', () {
|
||||||
|
for (final o in [
|
||||||
|
'-av --exclude=*.tmp',
|
||||||
|
'-av --exclude "My Folder"',
|
||||||
|
'-a --include=*.mkv --exclude=*',
|
||||||
|
'--filter="- *.bak"',
|
||||||
|
'-a -f "- x"',
|
||||||
|
'--exclude-from=list.txt',
|
||||||
|
'--include-from list.txt',
|
||||||
|
'-avC',
|
||||||
|
'--cvs-exclude',
|
||||||
|
'-aF',
|
||||||
|
]) {
|
||||||
|
expect(optionsFilter(o), isTrue, reason: o);
|
||||||
|
}
|
||||||
|
for (final o in ['-avPog', '-a --delete', '-avu --remove-source-files', '--delete-excluded', '-e "ssh -C"', '']) {
|
||||||
|
expect(optionsFilter(o), isFalse, reason: o);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,220 +1,220 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||||
import 'package:jj_mkvmaker/app/settings.dart';
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||||
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
|
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
|
||||||
import 'package:jj_mkvmaker/core/vfs.dart';
|
import 'package:jj_mkvmaker/core/vfs.dart';
|
||||||
import 'package:jj_mkvmaker/core/webdav.dart';
|
import 'package:jj_mkvmaker/core/webdav.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:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import 'support/dav_server.dart';
|
import 'support/dav_server.dart';
|
||||||
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
import 'package:jj_mkvmaker/core/sync_preview.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late Directory tmp, remote, local;
|
late Directory tmp, remote, local;
|
||||||
late TestDavServer server;
|
late TestDavServer server;
|
||||||
const id = 'nas';
|
const id = 'nas';
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
tmp = Directory.systemTemp.createTempSync('jj_dav_');
|
tmp = Directory.systemTemp.createTempSync('jj_dav_');
|
||||||
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
|
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
|
||||||
local = Directory(p.join(tmp.path, 'local'))..createSync();
|
local = Directory(p.join(tmp.path, 'local'))..createSync();
|
||||||
server = TestDavServer(remote);
|
server = TestDavServer(remote);
|
||||||
await server.start();
|
await server.start();
|
||||||
DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]);
|
DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]);
|
||||||
});
|
});
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
DavRegistry.configure([]);
|
DavRegistry.configure([]);
|
||||||
await server.stop();
|
await server.stop();
|
||||||
tmp.deleteSync(recursive: true);
|
tmp.deleteSync(recursive: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel))
|
File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel))
|
||||||
..parent.createSync(recursive: true)
|
..parent.createSync(recursive: true)
|
||||||
..writeAsStringSync(text);
|
..writeAsStringSync(text);
|
||||||
File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel))
|
File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel))
|
||||||
..parent.createSync(recursive: true)
|
..parent.createSync(recursive: true)
|
||||||
..writeAsStringSync(text);
|
..writeAsStringSync(text);
|
||||||
|
|
||||||
test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () {
|
test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () {
|
||||||
expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b');
|
expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b');
|
||||||
expect(DavPath.parse('dav://nas').full, 'dav://nas/');
|
expect(DavPath.parse('dav://nas').full, 'dav://nas/');
|
||||||
expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더');
|
expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더');
|
||||||
expect(vDirname('dav://nas/a/b'), 'dav://nas/a');
|
expect(vDirname('dav://nas/a/b'), 'dav://nas/a');
|
||||||
expect(vDirname('dav://nas/a'), 'dav://nas/');
|
expect(vDirname('dav://nas/a'), 'dav://nas/');
|
||||||
expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv');
|
expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv');
|
||||||
expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue);
|
expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue);
|
||||||
expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue);
|
expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue);
|
||||||
expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse);
|
expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse);
|
||||||
expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse);
|
expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse);
|
||||||
expect(samePath('dav://nas/a', r'C:\a'), isFalse);
|
expect(samePath('dav://nas/a', r'C:\a'), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async {
|
test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async {
|
||||||
rf('영상/a.mkv', 'aaaa');
|
rf('영상/a.mkv', 'aaaa');
|
||||||
rf('영상/sub/b.txt', 'b');
|
rf('영상/sub/b.txt', 'b');
|
||||||
final top = await listEntries('dav://$id/');
|
final top = await listEntries('dav://$id/');
|
||||||
expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]);
|
expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]);
|
||||||
final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name);
|
final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name);
|
||||||
expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]);
|
expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]);
|
||||||
final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더');
|
final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더');
|
||||||
expect(made, 'dav://$id/영상/새 폴더');
|
expect(made, 'dav://$id/영상/새 폴더');
|
||||||
expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue);
|
expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue);
|
||||||
final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv');
|
final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv');
|
||||||
expect(renamed, 'dav://$id/영상/c.mkv');
|
expect(renamed, 'dav://$id/영상/c.mkv');
|
||||||
expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa');
|
expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa');
|
||||||
await FileOps().delete(['dav://$id/영상/sub']);
|
await FileOps().delete(['dav://$id/영상/sub']);
|
||||||
expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse);
|
expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse);
|
||||||
// 잘못된 비밀번호
|
// 잘못된 비밀번호
|
||||||
DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]);
|
DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]);
|
||||||
expect(() => listEntries('dav://$id/'), throwsA(isA<DavException>().having((e) => e.status, 'status', 401)));
|
expect(() => listEntries('dav://$id/'), throwsA(isA<DavException>().having((e) => e.status, 'status', 401)));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async {
|
test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async {
|
||||||
lf('up/x.txt', 'xx');
|
lf('up/x.txt', 'xx');
|
||||||
lf('up/deep/y.bin', 'yyy');
|
lf('up/deep/y.bin', 'yyy');
|
||||||
var bytes = 0;
|
var bytes = 0;
|
||||||
final ops = FileOps(onBytes: (n) => bytes += n);
|
final ops = FileOps(onBytes: (n) => bytes += n);
|
||||||
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']);
|
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']);
|
||||||
expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy');
|
expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy');
|
||||||
expect(bytes, 5);
|
expect(bytes, 5);
|
||||||
// 같은 이름이면 "이름 (2)"
|
// 같은 이름이면 "이름 (2)"
|
||||||
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']);
|
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']);
|
||||||
// WebDAV → 로컬
|
// WebDAV → 로컬
|
||||||
final down = Directory(p.join(local.path, 'down'))..createSync();
|
final down = Directory(p.join(local.path, 'down'))..createSync();
|
||||||
await ops.copy(['dav://$id/up'], down.path);
|
await ops.copy(['dav://$id/up'], down.path);
|
||||||
expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx');
|
expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx');
|
||||||
// 같은 서버 안 이동 (서버가 MOVE)
|
// 같은 서버 안 이동 (서버가 MOVE)
|
||||||
await FileOps.makeFolder('dav://$id/', 'box');
|
await FileOps.makeFolder('dav://$id/', 'box');
|
||||||
await ops.move(['dav://$id/up (2)'], 'dav://$id/box');
|
await ops.move(['dav://$id/up (2)'], 'dav://$id/box');
|
||||||
expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue);
|
expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue);
|
||||||
expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse);
|
expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse);
|
||||||
expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty);
|
expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty);
|
||||||
// 로컬 → WebDAV 이동 (복사 후 원본 지움)
|
// 로컬 → WebDAV 이동 (복사 후 원본 지움)
|
||||||
lf('mv/m.txt', 'm');
|
lf('mv/m.txt', 'm');
|
||||||
await ops.move([p.join(local.path, 'mv')], 'dav://$id/box');
|
await ops.move([p.join(local.path, 'mv')], 'dav://$id/box');
|
||||||
expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm');
|
expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm');
|
||||||
expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse);
|
expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async {
|
test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async {
|
||||||
lf('src/a.txt', 'a');
|
lf('src/a.txt', 'a');
|
||||||
lf('src/sub/b.txt', 'b');
|
lf('src/sub/b.txt', 'b');
|
||||||
final ops = FileOps();
|
final ops = FileOps();
|
||||||
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2);
|
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2);
|
||||||
expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b');
|
expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b');
|
||||||
// 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것)
|
// 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것)
|
||||||
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0);
|
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0);
|
||||||
// 원본에서 내용 (크기) 이 바뀐 것만
|
// 원본에서 내용 (크기) 이 바뀐 것만
|
||||||
lf('src/a.txt', 'aaa');
|
lf('src/a.txt', 'aaa');
|
||||||
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1);
|
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1);
|
||||||
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa');
|
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa');
|
||||||
// 지우기 포함: 대상에만 있는 것
|
// 지우기 포함: 대상에만 있는 것
|
||||||
rf('dst/extra.txt', 'e');
|
rf('dst/extra.txt', 'e');
|
||||||
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true);
|
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true);
|
||||||
expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse);
|
expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse);
|
||||||
// -u: 대상이 더 새것이면 덮어쓰지 않음
|
// -u: 대상이 더 새것이면 덮어쓰지 않음
|
||||||
rf('dst/a.txt', 'remote newer!!');
|
rf('dst/a.txt', 'remote newer!!');
|
||||||
final old = DateTime.now().subtract(const Duration(days: 1));
|
final old = DateTime.now().subtract(const Duration(days: 1));
|
||||||
File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old);
|
File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old);
|
||||||
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true);
|
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true);
|
||||||
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!');
|
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!');
|
||||||
// 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음)
|
// 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음)
|
||||||
final back = p.join(local.path, 'back');
|
final back = p.join(local.path, 'back');
|
||||||
expect(await ops.mirror('dav://$id/dst', back), 2);
|
expect(await ops.mirror('dav://$id/dst', back), 2);
|
||||||
expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b');
|
expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b');
|
||||||
expect(await ops.mirror('dav://$id/dst', back), 0);
|
expect(await ops.mirror('dav://$id/dst', back), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async {
|
test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async {
|
||||||
rf('p/e1/e2/.keep', '');
|
rf('p/e1/e2/.keep', '');
|
||||||
File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync();
|
File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync();
|
||||||
rf('p/f/z.txt', 'z');
|
rf('p/f/z.txt', 'z');
|
||||||
expect(await vCountFiles('dav://$id/p'), 1);
|
expect(await vCountFiles('dav://$id/p'), 1);
|
||||||
expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1
|
expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1
|
||||||
expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue);
|
expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue);
|
||||||
expect(await DavRegistry.client(id).quota(), (1000000, 500));
|
expect(await DavRegistry.client(id).quota(), (1000000, 500));
|
||||||
final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache'));
|
final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache'));
|
||||||
expect(File(got).readAsStringSync(), 'z');
|
expect(File(got).readAsStringSync(), 'z');
|
||||||
expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로'));
|
expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async {
|
test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async {
|
||||||
lf('live/a.txt', 'a');
|
lf('live/a.txt', 'a');
|
||||||
lf('live/sub/b.txt', 'bb');
|
lf('live/sub/b.txt', 'bb');
|
||||||
rf('mirror/old.txt', 'o');
|
rf('mirror/old.txt', 'o');
|
||||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
|
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
|
||||||
c.settings.liveSyncPairs = [pair];
|
c.settings.liveSyncPairs = [pair];
|
||||||
final live = LiveSync(c);
|
final live = LiveSync(c);
|
||||||
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
|
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
|
||||||
await live.syncNow(pair);
|
await live.syncNow(pair);
|
||||||
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
|
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
|
||||||
expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');
|
expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');
|
||||||
expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse);
|
expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse);
|
||||||
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
|
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
|
||||||
live.dispose();
|
live.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async {
|
test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async {
|
||||||
rf('영상/1화.mkv', 'v1');
|
rf('영상/1화.mkv', 'v1');
|
||||||
rf('영상/2화.mkv', 'v2');
|
rf('영상/2화.mkv', 'v2');
|
||||||
rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
|
rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
|
||||||
rf('영상/메모.txt', 't');
|
rf('영상/메모.txt', 't');
|
||||||
final s = davStream('dav://$id/영상/1화.mkv');
|
final s = davStream('dav://$id/영상/1화.mkv');
|
||||||
expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}');
|
expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}');
|
||||||
expect(s.headers['Authorization'], startsWith('Basic '));
|
expect(s.headers['Authorization'], startsWith('Basic '));
|
||||||
// 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다
|
// 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다
|
||||||
expect(vPlayable('dav://$id/영상/1화.mkv'), s.url);
|
expect(vPlayable('dav://$id/영상/1화.mkv'), s.url);
|
||||||
expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass')));
|
expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass')));
|
||||||
expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv');
|
expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv');
|
||||||
// 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청)
|
// 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청)
|
||||||
final http = HttpClient();
|
final http = HttpClient();
|
||||||
final req = await http.getUrl(Uri.parse(s.url));
|
final req = await http.getUrl(Uri.parse(s.url));
|
||||||
s.headers.forEach(req.headers.set);
|
s.headers.forEach(req.headers.set);
|
||||||
final res = await req.close();
|
final res = await req.close();
|
||||||
expect(await res.transform(const SystemEncoding().decoder).join(), 'v1');
|
expect(await res.transform(const SystemEncoding().decoder).join(), 'v1');
|
||||||
http.close();
|
http.close();
|
||||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
c.settings.playlistMode = PlaylistMode.folder;
|
c.settings.playlistMode = PlaylistMode.folder;
|
||||||
final before = server.requests.where((r) => r.startsWith('GET')).length;
|
final before = server.requests.where((r) => r.startsWith('GET')).length;
|
||||||
final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']);
|
final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']);
|
||||||
expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']);
|
expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']);
|
||||||
expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음
|
expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음
|
||||||
final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv');
|
final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv');
|
||||||
expect(subs.map(p.basename), ['1화.ko.srt']);
|
expect(subs.map(p.basename), ['1화.ko.srt']);
|
||||||
expect(File(subs.single).readAsStringSync(), contains('안녕'));
|
expect(File(subs.single).readAsStringSync(), contains('안녕'));
|
||||||
});
|
});
|
||||||
test('M1 (관리자 P0): 원본 파일 · 받는 쪽 같은 이름 폴더 - 지우기 없으면 폴더를 지우지 않고 건너뜀, 지우기면 지우고 파일로 · 미리 보기도 같음', () async {
|
test('M1 (관리자 P0): 원본 파일 · 받는 쪽 같은 이름 폴더 - 지우기 없으면 폴더를 지우지 않고 건너뜀, 지우기면 지우고 파일로 · 미리 보기도 같음', () async {
|
||||||
lf('src/x', 'file');
|
lf('src/x', 'file');
|
||||||
lf('src/ok.txt', 'ok');
|
lf('src/ok.txt', 'ok');
|
||||||
rf('dst/x/keep.txt', 'k');
|
rf('dst/x/keep.txt', 'k');
|
||||||
rf('dst/x/sub/deep.txt', 'd');
|
rf('dst/x/sub/deep.txt', 'd');
|
||||||
final src = p.join(local.path, 'src');
|
final src = p.join(local.path, 'src');
|
||||||
const dst = 'dav://$id/dst';
|
const dst = 'dav://$id/dst';
|
||||||
// 지우기 없음: 미리 보기 "건너뜀" · 지울 것 없음 → 실제로도 폴더 그대로
|
// 지우기 없음: 미리 보기 "건너뜀" · 지울 것 없음 → 실제로도 폴더 그대로
|
||||||
var pv = await previewSync(src, dst, toRight: true);
|
var pv = await previewSync(src, dst, toRight: true);
|
||||||
expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']);
|
expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']);
|
||||||
expect(pv.where((x) => x.action == PreviewAction.delete), isEmpty);
|
expect(pv.where((x) => x.action == PreviewAction.delete), isEmpty);
|
||||||
final ops = FileOps();
|
final ops = FileOps();
|
||||||
await ops.mirror(src, dst);
|
await ops.mirror(src, dst);
|
||||||
expect(File(p.join(remote.path, 'dst', 'x', 'keep.txt')).readAsStringSync(), 'k', reason: '예전에는 이 폴더를 통째로 지웠다');
|
expect(File(p.join(remote.path, 'dst', 'x', 'keep.txt')).readAsStringSync(), 'k', reason: '예전에는 이 폴더를 통째로 지웠다');
|
||||||
expect(File(p.join(remote.path, 'dst', 'x', 'sub', 'deep.txt')).existsSync(), isTrue);
|
expect(File(p.join(remote.path, 'dst', 'x', 'sub', 'deep.txt')).existsSync(), isTrue);
|
||||||
expect(File(p.join(remote.path, 'dst', 'ok.txt')).readAsStringSync(), 'ok', reason: '다른 것은 그대로 맞춤');
|
expect(File(p.join(remote.path, 'dst', 'ok.txt')).readAsStringSync(), 'ok', reason: '다른 것은 그대로 맞춤');
|
||||||
expect(ops.dirConflicts.map(vBasename), ['x']);
|
expect(ops.dirConflicts.map(vBasename), ['x']);
|
||||||
// 지우기 포함: 미리 보기에 "폴더 · 안의 3개를 지우고 파일로" → 실제로 폴더가 파일로
|
// 지우기 포함: 미리 보기에 "폴더 · 안의 3개를 지우고 파일로" → 실제로 폴더가 파일로
|
||||||
pv = await previewSync(src, dst, toRight: true, delete: true);
|
pv = await previewSync(src, dst, toRight: true, delete: true);
|
||||||
final del = pv.singleWhere((x) => x.action == PreviewAction.delete);
|
final del = pv.singleWhere((x) => x.action == PreviewAction.delete);
|
||||||
expect((del.rel, del.isDir, del.replacedByFile, del.inner), ('x', true, true, 3));
|
expect((del.rel, del.isDir, del.replacedByFile, del.inner), ('x', true, true, 3));
|
||||||
final ops2 = FileOps();
|
final ops2 = FileOps();
|
||||||
await ops2.mirror(src, dst, delete: true);
|
await ops2.mirror(src, dst, delete: true);
|
||||||
expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file');
|
expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file');
|
||||||
expect(ops2.dirConflicts, isEmpty);
|
expect(ops2.dirConflicts, isEmpty);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue