From 39c6d9dce49c773f1a69be4b695665d7b59a8df8 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:25:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EC=8B=9C=ED=97=98:=20WebDAV=20=C2=B7=20SMB?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=94=EA=B8=B0=EB=8F=84=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?rsync=20=EA=B2=B0=EA=B3=BC=20=ED=8A=B8=EB=A6=AC=EC=99=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=8C=20(28=20=EC=9D=98=20=EC=9E=A5=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EC=98=B5=EC=85=98=2028=EA=B0=9C,=20=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/20) 2번 - 위키 974 목록 8 "원격 동기화도 거르기를 따름" 의 📱 ⏸ 가운데 기기 없이 할 수 있는 것: 28 이 손으로 한 WebDAV 21 장면 비교를 시험으로 남기고 (시험 WebDAV 서버), SMB 는 가짜 채널로 같은 28개. 네 원격 (WebDAV · SFTP · FTP · SMB) 모두 결과 트리 (폴더 · 파일 · 글) 가 실제 rsync 와 같음 - 코드 변경 없음. rsync_scenes 의 bundledRsync 를 sceneRsync 로 (live_sync.dart 의 같은 이름과 겹침). Co-Authored-By: Claude Opus 5.5 --- test/ftp_test.dart | 2 +- test/sftp_test.dart | 2 +- test/smb_test.dart | 31 +++++++++++++++++++++++++++++++ test/support/rsync_scenes.dart | 2 +- test/webdav_test.dart | 16 ++++++++++++++++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/test/ftp_test.dart b/test/ftp_test.dart index 1d0243d..a50dae4 100644 --- a/test/ftp_test.dart +++ b/test/ftp_test.dart @@ -236,7 +236,7 @@ void main() { }, skip: hasPy ? false : '시험용 파이썬 없음'); test('관리자 지시 (10/20): FTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { - final rsync = bundledRsync; + final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); await start('plain'); FtpRegistry.configure([srv(security: 'none')]); diff --git a/test/sftp_test.dart b/test/sftp_test.dart index a2a8551..637be7f 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -211,7 +211,7 @@ void main() { }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); test('관리자 지시 (10/20): SFTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { - final rsync = bundledRsync; + final rsync = sceneRsync; if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); await start(); SftpRegistry.configure([srv(fp: fingerprint)]); diff --git a/test/smb_test.dart b/test/smb_test.dart index 2765a5f..3d5eb4f 100644 --- a/test/smb_test.dart +++ b/test/smb_test.dart @@ -12,6 +12,7 @@ import 'package:path/path.dart' as p; 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'; /// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫) class _FakeSmb { @@ -263,4 +264,34 @@ void main() { expect(fake.files.containsKey(r'dst\old.txt'), isFalse); expect((fake.files.containsKey(r'dst\gone\g.tmp'), fake.files.containsKey(r'dst\gone\g.txt')), (true, false)); }); + test('관리자 지시 (10/20): SMB 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개, 가짜 채널)', () async { + final rsync = sceneRsync; + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); + SmbRegistry.configure(const [server]); + final tmp = Directory.systemTemp.createTempSync('jj_smbscene_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'), seed = p.join(tmp.path, 'seed'); + // 가짜 공유의 scene 폴더 → 트리 (treeOf 와 같은 모양) + Map fakeTree() => { + for (final e in fake.files.entries) + if (e.key.startsWith(r'scene\')) + e.key.substring(6).replaceAll(r'\', '/'): e.value == null ? '/' : utf8.decode(e.value!), + }; + final bad = []; + for (final opts in sceneOptions) { + writeScene(src, want); + final w = await realRsyncTree(rsync, src, want, opts); + writeScene(src, seed); + fake.files + ..clear() + ..['scene'] = null; + for (final e in treeOf(seed).entries) { + fake.files['scene\\${e.key.replaceAll('/', r'\')}'] = e.value == '/' ? null : utf8.encode(e.value); + } + await FileOps().mirror(src, 'smb://m1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts)); + final d = treeDiff(w, fakeTree()); + if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}'); + } + expect(bad, isEmpty, reason: bad.join('\n')); + }); } diff --git a/test/support/rsync_scenes.dart b/test/support/rsync_scenes.dart index fae5ad7..a29c053 100644 --- a/test/support/rsync_scenes.dart +++ b/test/support/rsync_scenes.dart @@ -54,7 +54,7 @@ const sceneOptions = [ ]; /// 앱에 들어 있는 rsync (Windows). 없으면 null -String? get bundledRsync { +String? get sceneRsync { final f = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe'); return Platform.isWindows && File(f).existsSync() ? f : null; } diff --git a/test/webdav_test.dart b/test/webdav_test.dart index 94d2134..048ecf0 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -16,6 +16,7 @@ import 'package:path/path.dart' as p; import 'support/dav_server.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/core/rsync_filter.dart'; +import 'support/rsync_scenes.dart'; void main() { late Directory tmp, remote, local; @@ -302,4 +303,19 @@ void main() { expect(Directory(real('cache')).existsSync(), isFalse); expect(Directory(real('gone')).existsSync(), isFalse); }); + test('관리자 지시 (10/20): WebDAV 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', () async { + final rsync = sceneRsync; + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); + final src = p.join(local.path, 'scene_src'), want = p.join(local.path, 'scene_rsync'), got = p.join(remote.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, 'dav://$id/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')); + }, timeout: const Timeout(Duration(minutes: 5))); } From 10954a632bf29eea7f0b8226f02761202560f4de Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:29:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=8B=9C=ED=97=98:=20FTP=20=C2=B7=20SMB=20?= =?UTF-8?q?=EB=8F=99=EC=98=81=EC=83=81=EB=8F=84=20=EB=B0=9B=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20=EC=9E=AC=EC=83=9D=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=ED=99=94=20=C2=B7=20=EA=B7=B8=20=EC=9E=90=EB=A7=89?= =?UTF-8?q?=20(=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) 2번 - 위키 974 목록 1 "받지 않고 재생" · "다음 화" 의 📱 ⏸ 가운데 기기 없이 할 수 있는 것: SFTP · WebDAV 에만 있던 시험을 FTP (pyftpdlib 시험 서버) · SMB (가짜 채널) 에도. preparePlayback 이 같은 폴더의 동영상만 차례로 (1화 → 2화), 이 기기 안의 다리 (127.0.0.1) 주소에 비밀번호 없음, Range (건너뛰기) 206, 다음 화도 바로, 다음 화의 자막을 찾음. 둘 다 통과 - 코드 변경 없음. SMB 시험은 위젯 시험 바인딩의 가짜 HttpClient 를 그 시험 동안만 끔 (다리에 실제로 묻기 위해). Co-Authored-By: Claude Opus 5.5 --- test/ftp_test.dart | 44 ++++++++++++++++++++++++++++++++++++++++ test/smb_test.dart | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/test/ftp_test.dart b/test/ftp_test.dart index a50dae4..8f2d24c 100644 --- a/test/ftp_test.dart +++ b/test/ftp_test.dart @@ -14,6 +14,11 @@ 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'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; /// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) - /// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다 @@ -263,6 +268,45 @@ void main() { expect(await range(7, null), '789'); }, skip: hasPy ? false : '시험용 파이썬 없음'); + test('관리자 지시 (10/20): FTP 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async { + await start('plain'); + FtpRegistry.configure([srv(security: 'none')]); + Directory(p.join(root.path, '영상')).createSync(); + File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789'); + File(p.join(root.path, '영상', '2화.mkv')).writeAsStringSync('abcdefghij'); + File(p.join(root.path, '영상', '2화.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n둘째\n'); + File(p.join(root.path, '영상', '메모.txt')).writeAsStringSync('t'); + addTearDown(RemoteStreamBridge.close); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.playlistMode = PlaylistMode.folder; + final plan = await c.preparePlayback(['ftp://f1/영상/1화.mkv']); + expect(plan!.$1, ['ftp://f1/영상/1화.mkv', 'ftp://f1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)'); + + final bridge = await RemoteStreamBridge.get(); + final u1 = bridge.url('ftp://f1/영상/1화.mkv'), u2 = bridge.url('ftp://f1/영상/2화.mkv'); + expect(u1, startsWith('http://127.0.0.1:')); + expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음'); + Future<(int, String)> get(String url, [String? range]) async { + final http = HttpClient(); + try { + final req = await http.getUrl(Uri.parse(url)); + if (range != null) req.headers.set('Range', range); + final res = await req.close(); + return (res.statusCode, await res.transform(utf8.decoder).join()); + } finally { + http.close(force: true); + } + } + + expect(await get(u1), (200, '0123456789')); + expect(await get(u1, 'bytes=4-6'), (206, '456'), reason: '건너뛰기 - 그 자리부터'); + expect(await get(u1, 'bytes=7-'), (206, '789')); + expect(await get(u2), (200, 'abcdefghij'), reason: '다음 화도 받지 않고 바로'); + final subs = await c.externalSubtitlesFor('ftp://f1/영상/2화.mkv'); + expect(subs.map(p.basename), ['2화.ko.srt']); + expect(File(subs.single).readAsStringSync(), contains('둘째')); + }, skip: hasPy ? false : '시험용 파이썬 없음'); + test('FTPS (암시, 990 꼴)', () async { await start('implicit'); final c = FtpRemote(srv(security: 'implicit', cert: fp)); diff --git a/test/smb_test.dart b/test/smb_test.dart index 3d5eb4f..7d8dfdf 100644 --- a/test/smb_test.dart +++ b/test/smb_test.dart @@ -13,6 +13,12 @@ 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'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; +import 'package:jj_mkvmaker/core/remote_stream.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; /// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫) class _FakeSmb { @@ -294,4 +300,48 @@ void main() { } expect(bad, isEmpty, reason: bad.join('\n')); }); + + test('관리자 지시 (10/20): SMB 동영상 - 같은 폴더 목록 · 다리로 받지 않고 재생 (Range · 건너뛰기) · 다음 화 · 그 자막', () async { + SmbRegistry.configure(const [server]); + // 위젯 시험 바인딩은 HttpClient 를 가짜 (늘 400) 로 바꾼다 - 이 시험은 이 기기 안의 다리 (127.0.0.1) 에 실제로 묻는다 + final saved = HttpOverrides.current; + HttpOverrides.global = null; + addTearDown(() => HttpOverrides.global = saved); + fake.files.addAll({ + '영상': null, + '영상\\1화.mkv': utf8.encode('0123456789'), + '영상\\2화.mkv': utf8.encode('abcdefghij'), + '영상\\2화.ko.srt': utf8.encode('1\n00:00:01,000 --> 00:00:02,000\n둘째\n'), + '영상\\메모.txt': utf8.encode('t'), + }); + addTearDown(RemoteStreamBridge.close); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.playlistMode = PlaylistMode.folder; + final plan = await c.preparePlayback(['smb://m1/영상/1화.mkv']); + expect(plan!.$1, ['smb://m1/영상/1화.mkv', 'smb://m1/영상/2화.mkv'], reason: '같은 폴더의 동영상만 차례로 (다음 화)'); + + final bridge = await RemoteStreamBridge.get(); + final u1 = bridge.url('smb://m1/영상/1화.mkv'), u2 = bridge.url('smb://m1/영상/2화.mkv'); + expect(u1, startsWith('http://127.0.0.1:')); + expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음'); + Future<(int, String)> get(String url, [String? range]) async { + final http = HttpClient(); + try { + final req = await http.getUrl(Uri.parse(url)); + if (range != null) req.headers.set('Range', range); + final res = await req.close(); + return (res.statusCode, await res.transform(utf8.decoder).join()); + } finally { + http.close(force: true); + } + } + + expect(await get(u1), (200, '0123456789')); + expect(await get(u1, 'bytes=4-6'), (206, '456'), reason: '건너뛰기 - 그 자리부터'); + expect(await get(u1, 'bytes=7-'), (206, '789')); + expect(await get(u2), (200, 'abcdefghij'), reason: '다음 화도 받지 않고 바로'); + final subs = await c.externalSubtitlesFor('smb://m1/영상/2화.mkv'); + expect(subs.map(p.basename), ['2화.ko.srt']); + expect(File(subs.single).readAsStringSync(), contains('둘째')); + }); } From 954703c4f03d93c62147cbd1e0304fb3199a8806 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:52:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20(rsync=20=EC=8C=8D):=20=EB=A7=9E=EC=B6=9C?= =?UTF-8?q?=20=EA=B2=83=20=C2=B7=20=EC=A7=80=EC=9A=B8=EC=A7=80=20=EB=AC=BB?= =?UTF-8?q?=EB=8A=94=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EC=9D=98=20=EA=B1=B0=EB=A5=B4=EA=B8=B0=EB=A5=BC=20=EB=94=B0?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20--delete-excluded=20=EB=8A=94=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=ED=8F=AC=ED=95=A8=20=EC=8C=8D=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=94=B0=EB=A6=84=20(=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/20) 2번 중 28 의 장면 (옵션 28개) 을 실시간 동기화로 돌려 찾음 (버그 수정): - 로컬 쌍의 diff 는 거르기를 전혀 따르지 않아, rsync 가 보내지 않는 것을 "맞출 것" 으로 늘 세고, 받는 쪽의 거른 것 (예: --exclude=*.log 의 keep.log) 까지 지울지 물었다 (rsync 는 지우지 않음). - 원격 쌍 (_diffV) · 로컬 쌍 모두: 받는 쪽에만 있는 폴더에 거른 것이 들어 있으면 "− 폴더" 한 줄로 셌다 - 묻는 창에 폴더가 통째로 지워지는 것처럼 보이고, 맞춘 뒤 (폴더는 남으므로) 맞출 것에 계속 남았다. 이제 rsync · _mirrorV 처럼 그 안의 거르지 않은 것만 (_gone · _hasExcluded 를 둘이 같이 씀). - 로컬 쌍의 rsync 인수가 --delete-excluded 를 늘 뺐다 (97 의 지우기 빼기에 걸림) - 묻는 창에는 지운다고 보이고 실제로는 남았다. 내 판단: 쌍이 "지우기 포함" 일 때만 남김 (원격 쌍 · Rsync 화면과 같게, 지우기 전 확인 (42) 은 그대로). 지우기 없는 쌍은 그대로 뺌. 시험: live_sync_scene_test (로컬, 실제 rsync) · webdav_test (WebDAV 쌍) - 결과 트리 = 실제 rsync, 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 맞출 것 없음. 고치기 전 로컬 실패 · WebDAV 실패 (− old · − old2 · − onlydst) 확인. live_sync_args_test 에 --delete-excluded. Co-Authored-By: Claude Opus 5.5 --- lib/app/live_sync.dart | 56 +++++++++++++++++++++++++++---- test/live_sync_args_test.dart | 9 +++++ test/live_sync_scene_test.dart | 60 ++++++++++++++++++++++++++++++++++ test/webdav_test.dart | 41 +++++++++++++++++++++++ 4 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 test/live_sync_scene_test.dart diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 5ea30e9..e5e52ad 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -154,6 +154,46 @@ class LiveSync extends ChangeNotifier { /// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이 RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none; + /// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게): + /// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만. + /// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고, + /// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다 + static Future _gone(String path, String r, bool isDir, RsyncFilter filter, List out, int limit) async { + if (out.length >= limit) return; + if (filter.excluded(r, isDir: isDir) && !filter.deleteExcluded) return; + if (!isDir || filter.isEmpty || filter.deleteExcluded || !await _hasExcluded(path, r, filter)) { + out.add('− $r'); + return; + } + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(path); + } catch (_) { + return; + } + for (final e in items) { + final name = vBasename(e.path); + if (name.endsWith('.jjsync') || isAppTempName(name)) continue; + await _gone(e.path, '$r/$name', e.isDir, filter, out, limit); + } + } + + /// 폴더 [dir] (상대 경로 [rel]) 안에 거른 것이 하나라도 있는지 + static Future _hasExcluded(String dir, String rel, RsyncFilter filter) async { + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(dir); + } catch (_) { + return false; + } + for (final e in items) { + final r = '$rel/${vBasename(e.path)}'; + if (filter.excluded(r, isDir: e.isDir)) return true; + if (e.isDir && await _hasExcluded(e.path, r, filter)) return true; + } + return false; + } + static Future> diff(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async { if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter); final out = []; @@ -170,8 +210,11 @@ class LiveSync extends ChangeNotifier { if (out.length >= limit) return; final name = p.basename(e.path); if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다 - names.add(name); final r = rel.isEmpty ? name : '$rel/$name'; + // 10/20 (15, 28 의 장면으로 찾음): rsync 쌍은 옵션의 거르기를 따른다 - 예전에는 rsync 가 보내지 않는 것을 + // 맞출 것으로 늘 세고, 지우지 않는 것 (받는 쪽의 거른 것) 을 지울지 물었다 + if (filter.excluded(r, isDir: e is Directory)) continue; + names.add(name); final t = p.join(dst, name); if (e is Directory) { await walk(e.path, t, r); @@ -189,7 +232,8 @@ class LiveSync extends ChangeNotifier { if (x.delete && await Directory(dst).exists()) { await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { final name = p.basename(e.path); - if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}'); + if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; + await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit); } } } @@ -238,10 +282,7 @@ class LiveSync extends ChangeNotifier { for (final t in there.entries) { final name = t.key; if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; - final r = rel.isEmpty ? name : '$rel/$name'; - // 거른 것은 지우지 않는다 (--delete-excluded 면 지움). 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만 (아래는 폴더 하나로 셈) - if (filter.excluded(r, isDir: t.value.isDir) && !filter.deleteExcluded) continue; - out.add('− $r'); + await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit); } } } @@ -494,6 +535,9 @@ class LiveSync extends ChangeNotifier { if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', if (x.delete) '--delete', + // 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창 + // (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름) + if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded', ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 '${toCygwinPath(x.source, windows: windows)}/', '${toCygwinPath(x.target, windows: windows)}/', diff --git a/test/live_sync_args_test.dart b/test/live_sync_args_test.dart index 1c0f47e..c3cffba 100644 --- a/test/live_sync_args_test.dart +++ b/test/live_sync_args_test.dart @@ -12,6 +12,15 @@ void main() { expect(on.where((o) => o.startsWith('--del')), ['--delete']); }); + test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () { + final s = AppSettings()..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; + final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false); + expect(off.where((o) => o.startsWith('--del')), isEmpty); + final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false); + expect(on.where((o) => o.startsWith('--del')), ['--delete', '--delete-excluded']); + expect(on, contains('--exclude=*.tmp')); + }); + test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () { final s = AppSettings()..robocopyOptions = '/MIR /PURGE /R:2'; final off = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), s).map((o) => o.toUpperCase()).toList(); diff --git a/test/live_sync_scene_test.dart b/test/live_sync_scene_test.dart new file mode 100644 index 0000000..5ea04d5 --- /dev/null +++ b/test/live_sync_scene_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/sync_preview.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +import 'support/rsync_scenes.dart'; + +/// 관리자 지시 (10/20): 실시간 동기화 (로컬 rsync 쌍) 를 28 의 장면 · 옵션으로 - 결과 트리 = 실제 rsync, +/// 지울지 묻는 목록 = rsync 가 지우는 것, 맞춘 뒤 "맞출 것" 이 남지 않음 (거른 것을 기다리는 것으로 세지 않음) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('실시간 동기화 (로컬 rsync 쌍) 결과 = 실제 rsync 결과 · 지울지 묻는 목록 = rsync 가 지우는 것 (28 의 장면 · 옵션 28개)', () async { + final rsync = sceneRsync; + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); + final tmp = Directory.systemTemp.createTempSync('jj_livescene_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final src = p.join(tmp.path, 'src'), want = p.join(tmp.path, 'rsync'), got = p.join(tmp.path, 'live'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + // 앱에 든 rsync 를 그대로 (시험 환경에는 앱 폴더 (path_provider) 가 없어 '직접 지정' 으로) + c.settings + ..rsyncSource = 'custom' + ..rsyncPath = rsync; + final bad = []; + for (final opts in sceneOptions) { + writeScene(src, want); + final w = await realRsyncTree(rsync, src, want, opts); + writeScene(src, got); + final before = treeOf(got); + final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; + c.settings.rsyncOptions = opts; + final pair = LiveSyncPair(src, got, method: 'rsync', delete: optionsDelete(opts)); + c.settings.liveSyncPairs = [pair]; + final live = LiveSync(c); + final k = LiveSync.keyOf(pair); + final why = []; + await live.syncNow(pair); + if (live.status[k]!.$2.contains('실패')) why.add('상태: ${live.status[k]!.$2}'); + final asked = live.toDelete[k] ?? const []; + for (final g in gone) { + if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('rsync 는 지우는데 묻지 않음: $g'); + } + for (final a in asked) { + if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a'); + } + if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); + why.addAll(treeDiff(w, treeOf(got))); + if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}'); + live.dispose(); + if (why.isNotEmpty) bad.add('$opts\n ${why.join('\n ')}'); + } + expect(bad, isEmpty, reason: bad.join('\n')); + }, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/test/webdav_test.dart b/test/webdav_test.dart index 048ecf0..be820b0 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -318,4 +318,45 @@ void main() { } expect(bad, isEmpty, reason: bad.join('\n')); }, timeout: const Timeout(Duration(minutes: 5))); + + test('관리자 지시 (10/20): 실시간 동기화 (WebDAV rsync 쌍) 결과 = 실제 rsync 결과 · 지울지 묻는 목록 = rsync 가 지우는 것 (28 의 장면 · 옵션 28개)', () async { + final rsync = sceneRsync; + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음 (tool\\fetch_rsync.ps1)'); + final src = p.join(local.path, 'live_src'), want = p.join(local.path, 'live_rsync'), got = p.join(remote.path, 'live'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + // 지우기를 정하면 설정을 저장하며 서버 목록을 설정에서 다시 읽는다 - 시험 서버를 설정에도 + c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]; + final bad = []; + for (final opts in sceneOptions) { + writeScene(src, want); + final w = await realRsyncTree(rsync, src, want, opts); + writeScene(src, got); + final before = treeOf(got); + // rsync 가 지운 것 (받는 쪽에 있다가 없어진 것) + final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; + c.settings.rsyncOptions = opts; + // 새 쌍 (지우기 확인 전) - 지울 것이 있으면 지우지 않고 맞춘 뒤 [toDelete] 로 묻는다 + final pair = LiveSyncPair(src, 'dav://$id/live', method: 'rsync', delete: optionsDelete(opts)); + c.settings.liveSyncPairs = [pair]; + final live = LiveSync(c); + final k = LiveSync.keyOf(pair); + final why = []; + await live.syncNow(pair); + if (live.status[k]!.$2.contains('실패')) why.add('상태: ${live.status[k]!.$2}'); + final asked = live.toDelete[k] ?? const []; + // 묻는 목록 = rsync 가 지우는 것 (폴더는 한 줄로 - 그 안의 것은 그 폴더에 든 것으로) + for (final g in gone) { + if (!asked.any((a) => g == a || g.startsWith('$a/'))) why.add('rsync 는 지우는데 묻지 않음: $g'); + } + for (final a in asked) { + if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a'); + } + if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); + why.addAll(treeDiff(w, treeOf(got))); + if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}'); + live.dispose(); + if (why.isNotEmpty) bad.add('$opts\n ${why.join('\n ')}'); + } + expect(bad, isEmpty, reason: bad.join('\n')); + }, timeout: const Timeout(Duration(minutes: 5))); } From 44a6c4e6b2220e1ac0cdafead5b96beaaf348e5c Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:10:30 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=A7=80=EC=9A=B8=EC=A7=80=20=EB=AC=BB?= =?UTF-8?q?=EB=8A=94=20=EC=B0=BD:=20--delete-excluded=20=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=8C=EC=A7=80=EB=8A=94=20=EA=B1=B0=EB=A5=B8=20?= =?UTF-8?q?=EA=B2=83=EC=9D=80=20=EB=94=B0=EB=A1=9C=20=EB=AC=B6=EC=96=B4=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=9B=EA=B2=8C=20(=EA=B2=80=ED=86=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 3a 조건 (10/20, 954703c 의 판단 ③ 을 받아들이며): 사용자가 알고 누르게. - diff · _diffV · _gone 이 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 따로 모음 → LiveSync.toDeleteExcluded. 확인 창 (confirmLiveDeletes) 은 그냥 지울 것 다음에 "거른 것 n개도 지워집니다 (--delete-excluded)" · 설명 (지키려면 옵션에서 빼기) · 그 목록을 빨갛게. 번역 en · ja · zh-Hans. 시험: live_sync_scene_test - 28 장면마다 따로 묶은 것 (없어야 할 때 없음 · 지울 목록 안 · *.bak / *.log 장면은 정확히), 창의 묶음 제목 · 순서. 캡처: windows_live_excl_shot_test (ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_live_excl. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 + assets/l10n/ja.json | 2 + assets/l10n/zh-Hans.json | 2 + .../windows_live_excl_shot_test.dart | 87 +++++++++++++++++++ lib/app/live_sync.dart | 51 ++++++++--- lib/ui/monitor_page.dart | 18 +++- test/live_sync_scene_test.dart | 57 ++++++++++++ 7 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 integration_test/windows_live_excl_shot_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 15973cf..5d69969 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -465,6 +465,7 @@ "rsync 양쪽 (⇄)": "rsync both ways (⇄)", "rsync 에 고르기": "Pick for rsync", "rsync 옵션": "rsync options", + "rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "These are left out by the rsync options (--exclude etc.), but --delete-excluded deletes them from the target. To keep them, remove --delete-excluded from the options.", "rsync 종료 코드 {0}": "rsync exit code {0}", "settings.json 없음": "settings.json missing", "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)", @@ -695,6 +696,7 @@ "개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect", "거꾸로": "Reverse", "거꾸로 된 범위: {0}": "Reversed range: {0}", + "거른 것 {0}개도 지워집니다 (--delete-excluded)": "{0} excluded items will also be deleted (--delete-excluded)", "건너뛰기": "Skip", "건너뛴 링크 {0}개": "{0} links skipped", "건너뜀 (이미 있음): {0}": "Skipped (already exists): {0}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 1791374..32be31e 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -465,6 +465,7 @@ "rsync 양쪽 (⇄)": "rsync 双方向 (⇄)", "rsync 에 고르기": "rsync に選ぶ", "rsync 옵션": "rsync オプション", + "rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "rsync オプションで除外したもの (--exclude など) ですが、--delete-excluded があるため対象から削除します。残すにはオプションから --delete-excluded を外してください。", "rsync 종료 코드 {0}": "rsync 終了コード {0}", "settings.json 없음": "settings.json がありません", "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows・CPU・Vulkan)", @@ -695,6 +696,7 @@ "개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません", "거꾸로": "逆順", "거꾸로 된 범위: {0}": "逆順の範囲: {0}", + "거른 것 {0}개도 지워집니다 (--delete-excluded)": "除外したもの {0} 件も削除されます (--delete-excluded)", "건너뛰기": "スキップ", "건너뛴 링크 {0}개": "スキップしたリンク {0} 件", "건너뜀 (이미 있음): {0}": "スキップ (すでにあります): {0}", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 25087fd..cfb9940 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -465,6 +465,7 @@ "rsync 양쪽 (⇄)": "rsync 双向 (⇄)", "rsync 에 고르기": "选为 rsync", "rsync 옵션": "rsync 选项", + "rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.": "这些是 rsync 选项中排除的项目 (--exclude 等),但因为有 --delete-excluded,会从目标中删除。若要保留,请从选项中去掉 --delete-excluded。", "rsync 종료 코드 {0}": "rsync 退出代码 {0}", "settings.json 없음": "缺少 settings.json", "stable-diffusion.cpp (Windows · CPU · Vulkan)": "stable-diffusion.cpp (Windows · CPU · Vulkan)", @@ -695,6 +696,7 @@ "개인 키의 암호가 맞지 않습니다": "私钥密码不正确", "거꾸로": "倒序", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", + "거른 것 {0}개도 지워집니다 (--delete-excluded)": "已排除的 {0} 项也会被删除 (--delete-excluded)", "건너뛰기": "跳过", "건너뛴 링크 {0}개": "跳过的链接 {0} 个", "건너뜀 (이미 있음): {0}": "已跳过(已存在):{0}", diff --git a/integration_test/windows_live_excl_shot_test.dart b/integration_test/windows_live_excl_shot_test.dart new file mode 100644 index 0000000..a97af62 --- /dev/null +++ b/integration_test/windows_live_excl_shot_test.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/monitor_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 관리자 3a 조건 (10/20): 실시간 동기화의 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것을 따로 묶은 모양을 +/// 크기 412 · 960, 언어 ko · en · ja 로 찍는다. 시험 폴더 안에서만 (확인 창은 [나중에] - 지우지 않음). +/// 실행: flutter test integration_test\windows_live_excl_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_live_excl'); + final rsync = p.join(Directory.current.path, 'third_party', 'rsync', 'windows', 'rsync.exe'); + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('지울지 묻는 창 $lang ${w}x$h', (t) async { + if (!File(rsync).existsSync()) return markTestSkipped('앱에 든 rsync 가 없음'); + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_liveexcl_shot_'); + final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd'); + for (final (dir, rel) in [ + (src, 'a.txt'), + (dst, 'only.txt'), + (dst, '오래된 메모.txt'), + (dst, 'skip.tmp'), + (dst, 'cache/x.tmp'), + (dst, 'build/obj/very_long_intermediate_file_name.tmp'), + ]) { + File(p.join(dir, rel)) + ..createSync(recursive: true) + ..writeAsStringSync(rel); + } + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..rsyncSource = 'custom' + ..rsyncPath = rsync + ..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; + final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); + c.settings.liveSyncPairs = [pair]; + final live = LiveSync(c); + addTearDown(live.dispose); + await t.runAsync(() => live.syncNow(pair)); + expect(live.toDeleteExcluded[LiveSync.keyOf(pair)], hasLength(3)); + final navKey = GlobalKey(); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())), + )); + unawaited(confirmLiveDeletes(navKey.currentContext!, live, pair)); + for (var i = 0; i < 8; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(const Duration(milliseconds: 60)); + } + expect(t.takeException(), isNull, reason: '넘침'); + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'live_excl_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + navKey.currentState!.pop(); + await t.pump(); + expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '[나중에] - 지우지 않음'); + }); + } + } +} diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index e5e52ad..d621450 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -157,11 +157,27 @@ class LiveSync extends ChangeNotifier { /// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게): /// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만. /// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고, - /// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다 - static Future _gone(String path, String r, bool isDir, RsyncFilter filter, List out, int limit) async { + /// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다. + /// [excluded] 가 있으면 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 거기에도 (확인 창에서 따로 묶음) + static Future _gone(String path, String r, bool isDir, RsyncFilter filter, List out, int limit, + [List? excluded]) async { if (out.length >= limit) return; - if (filter.excluded(r, isDir: isDir) && !filter.deleteExcluded) return; - if (!isDir || filter.isEmpty || filter.deleteExcluded || !await _hasExcluded(path, r, filter)) { + if (filter.excluded(r, isDir: isDir)) { + if (!filter.deleteExcluded) return; + out.add('− $r'); + excluded?.add(r); + return; + } + if (!isDir || filter.isEmpty) { + out.add('− $r'); + return; + } + if (filter.deleteExcluded) { + out.add('− $r'); + if (excluded != null && await _hasExcluded(path, r, filter)) excluded.add(r); + return; + } + if (!await _hasExcluded(path, r, filter)) { out.add('− $r'); return; } @@ -174,7 +190,7 @@ class LiveSync extends ChangeNotifier { for (final e in items) { final name = vBasename(e.path); if (name.endsWith('.jjsync') || isAppTempName(name)) continue; - await _gone(e.path, '$r/$name', e.isDir, filter, out, limit); + await _gone(e.path, '$r/$name', e.isDir, filter, out, limit, excluded); } } @@ -194,8 +210,10 @@ class LiveSync extends ChangeNotifier { return false; } - static Future> diff(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async { - if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter); + /// [excluded]: 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 모은다 ([_gone]) + static Future> diff(LiveSyncPair x, + {int limit = 500, RsyncFilter filter = RsyncFilter.none, List? excluded}) async { + if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter, excluded: excluded); final out = []; Future walk(String src, String dst, String rel) async { if (out.length >= limit) return; @@ -233,7 +251,7 @@ class LiveSync extends ChangeNotifier { await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) { final name = p.basename(e.path); if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; - await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit); + await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit, excluded); } } } @@ -243,7 +261,8 @@ class LiveSync extends ChangeNotifier { } /// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면 - static Future> _diffV(LiveSyncPair x, {int limit = 500, RsyncFilter filter = RsyncFilter.none}) async { + static Future> _diffV(LiveSyncPair x, + {int limit = 500, RsyncFilter filter = RsyncFilter.none, List? excluded}) async { final out = []; Future walk(String src, String dst, String rel) async { if (out.length >= limit) return; @@ -282,7 +301,7 @@ class LiveSync extends ChangeNotifier { for (final t in there.entries) { final name = t.key; if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue; - await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit); + await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit, excluded); } } } @@ -330,6 +349,10 @@ class LiveSync extends ChangeNotifier { /// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42) final toDelete = >{}; + /// [toDelete] 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더). 확인 창에서 따로 묶어 보인다 + /// (관리자 3a 조건 10/20: 사용자가 알고 누르게) + final toDeleteExcluded = >{}; + /// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도 final problems = {}; @@ -354,6 +377,7 @@ class LiveSync extends ChangeNotifier { Future decideDelete(LiveSyncPair x, {required bool delete}) async { final now = delete ? x.copyWith(deleteConfirmed: true) : x.copyWith(delete: false); toDelete.remove(keyOf(x)); + toDeleteExcluded.remove(keyOf(x)); await _setPair(x, now); await syncNow(now); } @@ -455,15 +479,20 @@ class LiveSync extends ChangeNotifier { try { // 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete]) if (x.delete && !x.deleteConfirmed) { - final del = [for (final d in await diff(x, limit: 100000, filter: _filterOf(x))) if (d.startsWith('− ')) d.substring(2)]; + final excl = []; + final del = [ + for (final d in await diff(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2) + ]; if (del.isEmpty) { await _setPair(x, x.copyWith(deleteConfirmed: true)); } else { toDelete[k] = del; + toDeleteExcluded[k] = excl; x = x.copyWith(delete: false); } } else { toDelete.remove(k); + toDeleteExcluded.remove(k); } // 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다 final allowEmpty = _allowEmpty.remove(k); diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 1448e6f..193f465 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -80,6 +80,10 @@ class MonitorPage extends StatelessWidget { Future confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async { final list = live.toDelete[LiveSync.keyOf(x)] ?? const []; if (list.isEmpty) return; + // 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게 + final excl = (live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const []).toSet(); + final plain = [for (final n in list) if (!excl.contains(n)) n]; + final filtered = [for (final n in list) if (excl.contains(n)) n]; final pick = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -93,8 +97,18 @@ Future confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPai const SizedBox(height: 8), Expanded( child: ListView(children: [ - for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), - if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])), + for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), + if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])), + if (filtered.isNotEmpty) ...[ + if (plain.isNotEmpty) const SizedBox(height: 10), + Text(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length]), + style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)), + Text(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 4), + for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)), + if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])), + ], ]), ), ]), diff --git a/test/live_sync_scene_test.dart b/test/live_sync_scene_test.dart index 5ea04d5..7d2d8c0 100644 --- a/test/live_sync_scene_test.dart +++ b/test/live_sync_scene_test.dart @@ -1,13 +1,17 @@ +import 'dart:async'; import 'dart:io'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/live_sync.dart'; import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/rsync_filter.dart'; import 'package:jj_mkvmaker/core/sync_preview.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:path/path.dart' as p; import 'support/rsync_scenes.dart'; @@ -49,6 +53,15 @@ void main() { for (final a in asked) { if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a'); } + // 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 확인 창에서 따로 + final excl = live.toDeleteExcluded[k] ?? const []; + final wantExcl = { + '-a --delete --exclude=*.bak --delete-excluded': {'onlydst'}, + '-a --delete --exclude=*.log --delete-excluded': {'keep.log', 'old', 'old2'}, + }[opts] ?? const {}; + if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl'); + if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl'); + if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl'); if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); why.addAll(treeDiff(w, treeOf(got))); if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}'); @@ -57,4 +70,48 @@ void main() { } expect(bad, isEmpty, reason: bad.join('\n')); }, timeout: const Timeout(Duration(minutes: 5))); + + testWidgets('관리자 3a 조건 (10/20): 지울지 묻는 창 - --delete-excluded 로 지워지는 거른 것은 따로 묶어 빨갛게', (tester) async { + final rsync = sceneRsync; + if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); + final tmp = Directory.systemTemp.createTempSync('jj_liveexcl_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd'); + for (final (dir, rel) in [(src, 'a.txt'), (dst, 'only.txt'), (dst, 'skip.tmp'), (dst, 'cache/x.tmp')]) { + File(p.join(dir, rel)) + ..createSync(recursive: true) + ..writeAsStringSync(rel); + } + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..rsyncSource = 'custom' + ..rsyncPath = rsync + ..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; + final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); + c.settings.liveSyncPairs = [pair]; + final live = LiveSync(c); + addTearDown(live.dispose); + await tester.runAsync(() => live.syncNow(pair)); + final k = LiveSync.keyOf(pair); + expect(live.toDelete[k], unorderedEquals(['only.txt', 'skip.tmp', 'cache'])); + expect(live.toDeleteExcluded[k], unorderedEquals(['skip.tmp', 'cache']), reason: 'cache 는 거른 것이 든 폴더'); + expect(File(p.join(dst, 'skip.tmp')).existsSync(), isTrue, reason: '확인 전에는 지우지 않음'); + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) { + ctx = x; + return const SizedBox(); + }))); + unawaited(confirmLiveDeletes(ctx, live, pair)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('대상에서 3개를 지울까요?'), findsOneWidget); + expect(find.text('거른 것 2개도 지워집니다 (--delete-excluded)'), findsOneWidget); + // 순서: 그냥 지울 것 → 묶음 제목 → 거른 것 + final y = {for (final n in ['only.txt', '거른 것 2개도 지워집니다 (--delete-excluded)', 'skip.tmp', 'cache']) n: tester.getTopLeft(find.text(n)).dy}; + expect(y['only.txt']! < y['거른 것 2개도 지워집니다 (--delete-excluded)']!, isTrue); + expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['skip.tmp']!, isTrue); + expect(y['거른 것 2개도 지워집니다 (--delete-excluded)']! < y['cache']!, isTrue); + await tester.tap(find.text('나중에')); + await tester.pump(); + }); }