next4: next2 7cd8eb8 (M2 · 줄바꿈 되돌림 · 글 뷰어 이모지) 합침

# Conflicts:
#	assets/l10n/en.json
#	assets/l10n/ja.json
#	assets/l10n/zh-Hans.json
next4 v2026.10.18_977
RyuJongJin 2026-10-19 22:35:14 +09:00
commit 65f5e49aad
13 changed files with 574 additions and 495 deletions

View File

@ -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 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "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 · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept",
"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",
@ -1894,6 +1895,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)",
@ -2833,4 +2835,4 @@
"휴지통이 없습니다": "No Recycle Bin", "휴지통이 없습니다": "No Recycle Bin",
"히브리어": "Hebrew", "히브리어": "Hebrew",
"힌디어": "Hindi" "힌디어": "Hindi"
} }

View File

@ -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 · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します",
"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 のファイルは名前・サイズ・日付のみ表示します",
@ -1894,6 +1895,7 @@
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。",
"우르두어": "ウルドゥー語", "우르두어": "ウルドゥー語",
"우크라이나어": "ウクライナ語", "우크라이나어": "ウクライナ語",
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "リモート (WebDAV・SFTP・SMB・FTP) はオプションのフィルター (--exclude・--include・--filter など) に従いません - 除外したものもコピーし、削除を含む場合は削除します。上の比較一覧が実際に行う内容です。",
"원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}", "원래 이름으로 되돌리지 못한 것: {0}": "元の名前に戻せなかったもの: {0}",
"원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります", "원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります",
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)",
@ -2833,4 +2835,4 @@
"휴지통이 없습니다": "ごみ箱がありません", "휴지통이 없습니다": "ごみ箱がありません",
"히브리어": "ヘブライ語", "히브리어": "ヘブライ語",
"힌디어": "ヒンディー語" "힌디어": "ヒンディー語"
} }

View File

@ -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 · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名", "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名",
"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 文件仅显示名称、大小和日期",
@ -1894,6 +1895,7 @@
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。",
"우르두어": "乌尔都语", "우르두어": "乌尔都语",
"우크라이나어": "乌克兰语", "우크라이나어": "乌克兰语",
"원격 (WebDAV · SFTP · SMB · FTP) 은 옵션의 거르기 (--exclude · --include · --filter 등) 를 따르지 않습니다 - 거른 것도 복사하고, 지우기가 들어 있으면 지웁니다. 위 비교 목록이 실제로 할 일입니다.": "远程 (WebDAV · SFTP · SMB · FTP) 不遵循选项中的过滤规则 (--exclude · --include · --filter 等) - 被排除的项目也会复制,如包含删除则会删除。上面的比较列表就是实际会执行的操作。",
"원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}", "원래 이름으로 되돌리지 못한 것: {0}": "未能恢复原名称: {0}",
"원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目", "원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目",
"원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)", "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)",
@ -2833,4 +2835,4 @@
"휴지통이 없습니다": "没有回收站", "휴지통이 없습니다": "没有回收站",
"히브리어": "希伯来语", "히브리어": "希伯来语",
"힌디어": "印地语" "힌디어": "印地语"
} }

View File

@ -21,6 +21,8 @@ void main() {
('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()), ('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()),
('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()), ('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()),
('korean', '가나다라마바사아자차카타파하' * 200), ('korean', '가나다라마바사아자차카타파하' * 200),
// 28 지적 (10/19): 이모지 (두 단위 글자) 가 든 긴 줄 - 재는 조각이 글자 가운데서 끊기면 UTF-16 오류
('emoji', List.generate(3000, (i) => 'abc😀').join()),
]) { ]) {
testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async { testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble()); t.view.physicalSize = Size(w.toDouble(), h.toDouble());
@ -44,6 +46,7 @@ void main() {
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true); Directory(out).createSync(recursive: true);
File(p.join(out, 'textview_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); File(p.join(out, 'textview_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
expect(t.takeException(), isNull, reason: '그리는 중 오류 없음 ($name $w)');
await t.pumpWidget(const SizedBox()); await t.pumpWidget(const SizedBox());
}); });
} }

View File

@ -58,6 +58,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>[];

View File

@ -3541,8 +3541,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: [

View File

@ -245,6 +245,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(

View File

@ -122,6 +122,7 @@ class _TextViewerPageState extends State<TextViewerPage> {
/// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게) /// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게)
double _wideRatio(BuildContext context) { double _wideRatio(BuildContext context) {
// l10n-skip: 폭을 재는 넓은 글자 (화면 글이 아님)
final m = _measure(context, 'M'), w = _measure(context, '가'); final m = _measure(context, 'M'), w = _measure(context, '가');
return m == null || w == null ? 2 : w / m; return m == null || w == null ? 2 : w / m;
} }
@ -158,7 +159,10 @@ class _TextViewerPageState extends State<TextViewerPage> {
_rows = viewerRows(_text, perRow, wide: _wideRatio(context), cut: (line, from) { _rows = viewerRows(_text, perRow, wide: _wideRatio(context), cut: (line, from) {
var size = perRow * 8; var size = perRow * 8;
while (true) { while (true) {
final end = (from + size).clamp(0, line.length); var end = (from + size).clamp(0, line.length);
// 28 지적 (10/19): 재는 조각이 이모지 같은 두 단위 글자의 가운데에서 끝나면 반쪽 글자라 TextSpan 이
// "not well-formed UTF-16" 으로 실패했다 (화면은 정상이나 오류가 쏟아지고 더 큰 조각을 다시 잼) - 글자 앞에서 자른다
if (end < line.length && end > from + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--;
tp.text = TextSpan(text: line.substring(from, end), style: style); tp.text = TextSpan(text: line.substring(from, end), style: style);
tp.layout(maxWidth: room); tp.layout(maxWidth: room);
final m = tp.computeLineMetrics(); final m = tp.computeLineMetrics();

View File

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

View File

@ -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) 없음');
}); });
} }

View File

@ -104,4 +104,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);
}
});
} }

View File

@ -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);
}); });
} }

View File

@ -143,6 +143,27 @@ void main() {
expect(lines, 4, reason: '네 줄 분량으로 나눔 (다섯째 반쪽 줄 없음)'); expect(lines, 4, reason: '네 줄 분량으로 나눔 (다섯째 반쪽 줄 없음)');
}); });
testWidgets('h 글 뷰어 (28 지적 10/19): 이모지가 든 긴 줄 - 재는 조각이 두 단위 글자 가운데서 끊기지 않음 (UTF-16 오류 없음)', (t) async {
final line = List.generate(3000, (i) => 'abc😀').join();
final f = File(p.join(tmp.path, 'emoji.txt'))..writeAsStringSync('$line\nend');
for (final w in [412.0, 960.0]) {
t.view.physicalSize = Size(w, 900);
t.view.devicePixelRatio = 1;
await t.pumpWidget(MaterialApp(home: TextViewerPage(key: ValueKey(w), path: f.path)));
for (var i = 0; i < 40 && find.textContaining('abc😀abc').evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump();
}
expect(t.takeException(), isNull, reason: '폭 $w');
final texts = t.widgetList<Text>(find.textContaining('abc😀')).map((x) => x.data!).toList();
expect(texts, isNotEmpty);
for (final s in texts) {
expect((s.codeUnitAt(s.length - 1) & 0xFC00) == 0xD800, isFalse, reason: '조각이 반쪽 이모지로 끝나지 않음');
}
}
t.view.reset();
});
testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async { testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async {
final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]); final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]);
final opened = <String>[]; final opened = <String>[];