next7: next5 (6b60f34) + next6 (83e9fdd) 합침 - 시험판 979

next7 v2026.10.18_979
RyuJongJin 2026-10-20 11:46:19 +09:00
commit 8cce03d99c
3 changed files with 139 additions and 0 deletions

View File

@ -13,6 +13,7 @@ import 'support/sftp_server.dart' show sftpTestPython;
import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'package:jj_mkvmaker/core/sync_preview.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) - /// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 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)); expect((File(real('gone/g.tmp')).existsSync(), File(real('gone/g.txt')).existsSync()), (true, false));
}, skip: hasPy ? 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 = <String>[];
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 { test('동영상 스트리밍 다리: ftp:// 경로의 부분 읽기 (FtpRemote.openReadFrom - REST 로 그 자리부터)', () async {
await start('plain'); await start('plain');
FtpRegistry.configure([srv(security: 'none')]); FtpRegistry.configure([srv(security: 'none')]);

View File

@ -13,6 +13,7 @@ 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';
import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart';
import 'support/rsync_scenes.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) 이 없으면 건너뛴다.
@ -209,6 +210,24 @@ void main() {
expect(File(real('gone/g.txt')).existsSync(), isFalse); expect(File(real('gone/g.txt')).existsSync(), isFalse);
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); }, 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 = <String>[];
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 { test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async {
await start(); await start();
SftpRegistry.configure([srv(fp: fingerprint)]); SftpRegistry.configure([srv(fp: fingerprint)]);

View File

@ -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<String, String> treeOf(String dir) {
final out = <String, String>{};
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<Map<String, String>> 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<String> treeDiff(Map<String, String> want, Map<String, String> 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]}"'}',
];