From 83e9fdd6767abaafd118fec20d6d971028eed8fa Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:20:38 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=9C=ED=97=98:=20SFTP=20=C2=B7=20FTP=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=94=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=3D=20=EC=8B=A4=EC=A0=9C=20rsync=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=ED=8A=B8=EB=A6=AC=20(28=20=EC=9D=98=20=EC=9E=A5?= =?UTF-8?q?=EB=A9=B4=20=C2=B7=20=EC=98=B5=EC=85=98=2028=EA=B0=9C,=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/20): 28 이 WebDAV 로 비교한 장면 · 옵션 (rsync_filter_test 의 "28 의 장면" 27개 + 지우기 없는 거르기 1개) 을 SFTP (paramiko) · FTP (pyftpdlib) 시험 서버로. 같은 장면을 로컬에서 실제 rsync (앱에 든 rsync.exe) 로 맞춘 결과 트리와 원격 맞추기 (FileOps.mirror + 옵션의 지우기 · 거르기) 뒤 서버 폴더의 트리 (폴더 · 파일 · 글) 를 비교. 28개 모두 같음 - 코드 변경 없음. 거르기를 빼고 돌리면 다른 점 423줄로 실패하는 것도 확인 (시험이 실제로 잡는지). 장면 · 비교 도구는 test/support/rsync_scenes.dart 로 (다른 원격 시험도 쓰게). Co-Authored-By: Claude Opus 5.5 --- test/ftp_test.dart | 19 +++++++ test/sftp_test.dart | 19 +++++++ test/support/rsync_scenes.dart | 101 +++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 test/support/rsync_scenes.dart diff --git a/test/ftp_test.dart b/test/ftp_test.dart index a36395b..1d0243d 100644 --- a/test/ftp_test.dart +++ b/test/ftp_test.dart @@ -13,6 +13,7 @@ import 'support/sftp_server.dart' show sftpTestPython; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; +import 'support/rsync_scenes.dart'; /// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) - /// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다 @@ -234,6 +235,24 @@ void main() { expect((File(real('gone/g.tmp')).existsSync(), File(real('gone/g.txt')).existsSync()), (true, false)); }, skip: hasPy ? false : '시험용 파이썬 없음'); + test('관리자 지시 (10/20): FTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { + final rsync = bundledRsync; + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); + await start('plain'); + FtpRegistry.configure([srv(security: 'none')]); + final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene'); + final bad = []; + for (final opts in sceneOptions) { + writeScene(src, want); + final w = await realRsyncTree(rsync, src, want, opts); + writeScene(src, got); + await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts)); + final d = treeDiff(w, treeOf(got)); + if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}'); + } + expect(bad, isEmpty, reason: bad.join('\n')); + }, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5))); + test('동영상 스트리밍 다리: ftp:// 경로의 부분 읽기 (FtpRemote.openReadFrom - REST 로 그 자리부터)', () async { await start('plain'); FtpRegistry.configure([srv(security: 'none')]); diff --git a/test/sftp_test.dart b/test/sftp_test.dart index 74215eb..a2a8551 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -13,6 +13,7 @@ import 'support/sftp_server.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart'; +import 'support/rsync_scenes.dart'; /// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - /// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. @@ -209,6 +210,24 @@ void main() { expect(File(real('gone/g.txt')).existsSync(), isFalse); }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + test('관리자 지시 (10/20): SFTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { + final rsync = bundledRsync; + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); + await start(); + SftpRegistry.configure([srv(fp: fingerprint)]); + final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene'); + final bad = []; + for (final opts in sceneOptions) { + writeScene(src, want); + final w = await realRsyncTree(rsync, src, want, opts); + writeScene(src, got); + await FileOps().mirror(src, 'sftp://s1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts)); + final d = treeDiff(w, treeOf(got)); + if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}'); + } + expect(bad, isEmpty, reason: bad.join('\n')); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음', timeout: const Timeout(Duration(minutes: 5))); + test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async { await start(); SftpRegistry.configure([srv(fp: fingerprint)]); diff --git a/test/support/rsync_scenes.dart b/test/support/rsync_scenes.dart new file mode 100644 index 0000000..fae5ad7 --- /dev/null +++ b/test/support/rsync_scenes.dart @@ -0,0 +1,101 @@ +import 'dart:convert' show utf8; +import 'dart:io'; + +import 'package:jj_mkvmaker/core/sync_tools.dart' show splitOptions, toCygwinPath; +import 'package:path/path.dart' as p; + +/// 28 (10/19) 이 원격 맞추기 vs 실제 rsync 비교에 쓴 장면 · 옵션 (rsync_filter_test 의 "28 의 장면" 과 같은 것). +/// 원격 (WebDAV · SFTP · FTP · SMB) 맞추기의 결과 트리를 실제 rsync 를 로컬에서 돌린 결과 트리와 비교할 때 쓴다. + +/// 보내는 쪽 파일 +const sceneSrc = [ + 'a.txt', 'b.log', 'build/x.o', 'build/sub/y.o', 'sub/c.txt', 'sub/deep/d.txt', 'sub/deep/e.log', 'node_modules/m/i.js', + 'src/node_modules/n.js', 'docs/readme.md', 'docs/img/p.png', '한글 폴더/가.txt', 'space dir/s.bak', 'Keep.TXT', 'tmp/t.txt', + 'x/tmp/t2.txt', 'foo/bar', 'foo/q/bar', +]; + +/// 받는 쪽에만 있는 파일 (지우기 · 거른 것 지키기 · 거른 것이 든 폴더) +const sceneDst = [ + 'extra.txt', 'keep.log', 'build/old.o', 'old/keep.log', 'old/gone.txt', 'sub/deep/z.txt', 'tmp/old.txt', 'onlydst/a.bak', + 'old2/deep/x/keep.log', 'old2/deep/x/g.txt', 'old2/deep/y/h.txt', 'old2/e.txt', +]; + +/// 옵션 (그대로 rsync 에 · 원격 맞추기는 그 옵션의 지우기 · 거르기) +const sceneOptions = [ + '-a --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/', + '-a --delete --exclude=*.log', + '-a --delete --include=*/ --include=*.txt --exclude=*', + '-a --delete --exclude=node_modules/', + '-a --delete --exclude=/tmp/', + '-a --delete --exclude=tmp/', + '-a --delete --exclude=sub/deep', + '-a --delete --exclude=docs/***', + '-a --delete --exclude=foo/**/bar', + '-a --delete --exclude=**/bar', + '-a --delete --exclude=*.[lb][oa][gk]', + '-a --delete --exclude="space dir/"', + '-a --delete --exclude=한글*/', + '-a --delete --exclude=*.bak --delete-excluded', + '-a --delete --exclude=*.log --delete-excluded', + '-a --delete --filter="- *.o" --filter="+ build/"', + '-a --delete --include=sub/deep/d.txt --exclude=sub/deep/*', + '-a --delete --exclude=?.txt', + '-a --delete --exclude=*.TXT', + '-a --delete --exclude=deep/', + '-a --delete --exclude=/old2/deep/x/keep.log', + '-a --del', + '-a --delete-after --exclude=*.log', + '-a --delete --exclude=**/x/', + '-a --delete --include=*.txt --exclude=*.log --exclude=*/', + '-a --delete -f"+ *.txt" --exclude=*', + '-a --delete -f"- *.log"', + // 지우기 없음 (거른 것만) + '-a --exclude=*.log --exclude=build/', +]; + +/// 앱에 들어 있는 rsync (Windows). 없으면 null +String? get bundledRsync { + final f = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe'); + return Platform.isWindows && File(f).existsSync() ? f : null; +} + +/// 장면을 만든다 (있던 것은 지우고). 보내는 쪽 글은 "s:경로", 받는 쪽은 "d:경로" +void writeScene(String src, String dst) { + for (final (dir, list, tag) in [(src, sceneSrc, 's'), (dst, sceneDst, 'd')]) { + final d = Directory(dir); + if (d.existsSync()) d.deleteSync(recursive: true); + d.createSync(recursive: true); + for (final r in list) { + File(p.join(dir, r)) + ..createSync(recursive: true) + ..writeAsStringSync('$tag:$r'); + } + } +} + +/// 폴더 안의 트리: 상대 경로 (/) → 폴더면 "/", 파일이면 그 글 +Map treeOf(String dir) { + final out = {}; + final root = Directory(dir); + if (!root.existsSync()) return out; + for (final e in root.listSync(recursive: true)) { + final rel = p.relative(e.path, from: dir).replaceAll(r'\', '/'); + out[rel] = e is Directory ? '/' : File(e.path).readAsStringSync(); + } + return out; +} + +/// 실제 rsync 로 src/ → dst/ 를 맞춘 뒤 dst 의 트리 (src · dst 는 writeScene 으로 만든 로컬 폴더) +Future> realRsyncTree(String rsync, String src, String dst, String opts) async { + final r = await Process.run(rsync, ['-8', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(dst)}/'], + stdoutEncoding: utf8, stderrEncoding: utf8); + if (r.exitCode != 0) throw StateError('rsync $opts → ${r.exitCode}: ${r.stderr}'); + return treeOf(dst); +} + +/// 두 트리의 다른 점 (없으면 빈 목록) +List treeDiff(Map want, Map got) => [ + for (final k in {...want.keys, ...got.keys}.toList()..sort()) + if (want[k] != got[k]) + '$k: rsync=${want[k] == null ? '없음' : want[k] == '/' ? '폴더' : '"${want[k]}"'} · 앱=${got[k] == null ? '없음' : got[k] == '/' ? '폴더' : '"${got[k]}"'}', + ];