next9: + lsync_p0 (aba095b: 미리 볼 수 없는 거르기 규칙의 지우기 포함 쌍은 저절로 지우지 않음) - 시험판 981

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
review_d2dc267 v2026.10.18_981
RyuJongJin 2026-10-20 13:25:15 +09:00
commit f6de74010c
7 changed files with 381 additions and 58 deletions

View File

@ -1036,6 +1036,7 @@
"대상 폴더가 없습니다: {0}": "Target folder not found: {0}",
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "Confirm whether to delete {0} items that exist only in the target (nothing is deleted until you confirm)",
"대상에서 {0}개를 지울까요?": "Delete {0} items from the target?",
"대상에서 지울까요?": "Delete from the target?",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "Files transferred to the target are deleted from the source (move)",
"대상이 원본과 같거나 원본 안에 있습니다": "The target is the same as or inside the source",
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "The target is the same as or inside the source (the source would change every sync). Choose another folder.",
@ -1261,6 +1262,7 @@
"묻기 (기본)": "Ask (default)",
"물어보지 않기": "Never ask",
"미리 보기": "Quick look",
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "The preview shows nothing to delete - but because of the rules above, items may actually be deleted",
"미리 볼 수 없습니다: {0}": "Cannot preview: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "Can't preview (the text isn't UTF-8)",
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "Can't preview (unknown text encoding)",
@ -2048,6 +2050,7 @@
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "This connection is neither signed nor encrypted ({0}). Others on the same network could read or change the files, so it's best to turn on SMB signing or SMB3 encryption in the NAS settings",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "This connection isn't encrypted ({0}). Others on the same network could see file contents, so it's best to turn on SMB3 encryption in the NAS settings",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "Some filter rules in these options can't be previewed - more may actually be deleted: {0}",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "Some filter rules in these options cannot be previewed, so nothing is deleted automatically",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
"이 이동 버튼 지우기": "Delete this move button",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?",
@ -2415,6 +2418,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
"지금 작업 취소: {0}": "Cancelled current job: {0}",
"지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)",
"지금 지우기 (확인)": "Delete now (confirm)",
"지금 짝 추가": "Add current pair",
"지금 짝을 즐겨찾기에": "Add the current pair to favorites",
"지금 폴더": "Current folder",
@ -2430,6 +2434,7 @@
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "Removed temporary files left by the last update: {0}",
"지난 작업 기록": "Old job logs",
"지문 확인하고 연결": "Check fingerprint and connect",
"지우고 맞추기": "Delete and sync",
"지우고 초기화": "Delete and reset",
"지우기": "Delete",
"지우기 포함": "with delete",

View File

@ -1036,6 +1036,7 @@
"대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}",
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "対象にだけある {0} 個を削除するか確認が必要です (確認するまで削除しません)",
"대상에서 {0}개를 지울까요?": "対象から {0} 個を削除しますか?",
"대상에서 지울까요?": "対象から削除しますか?",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "対象へ移したファイルを元から削除します (移動)",
"대상이 원본과 같거나 원본 안에 있습니다": "対象が元と同じか、元の中にあります",
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "対象が元と同じか元の中にあります (同期するたびに元が変わります)。別のフォルダーを選んでください。",
@ -1261,6 +1262,7 @@
"묻기 (기본)": "確認する (既定)",
"물어보지 않기": "尋ねない",
"미리 보기": "クイックプレビュー",
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "プレビューでは削除するものはありません - ただし上のルールのため実際には削除されることがあります",
"미리 볼 수 없습니다: {0}": "プレビューできません: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "プレビューできません (文字コードが UTF-8 ではありません)",
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "プレビューできません (文字コードが分かりません)",
@ -2048,6 +2050,7 @@
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "このオプションの一部のフィルター規則はプレビューできません - 実際にはさらに削除される場合があります: {0}",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "このオプションの一部のフィルタールールはプレビューできないため、自動では削除しません",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
"이 이동 버튼 지우기": "この移動ボタンを削除",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?",
@ -2415,6 +2418,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
"지금 작업 취소: {0}": "現在の作業をキャンセル: {0}",
"지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)",
"지금 지우기 (확인)": "今すぐ削除 (確認)",
"지금 짝 추가": "今のペアを追加",
"지금 짝을 즐겨찾기에": "今のペアをお気に入りに",
"지금 폴더": "現在のフォルダー",
@ -2430,6 +2434,7 @@
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}",
"지난 작업 기록": "過去の作業記録",
"지문 확인하고 연결": "フィンガープリントを確認して接続",
"지우고 맞추기": "削除して同期",
"지우고 초기화": "削除して初期化",
"지우기": "削除",
"지우기 포함": "削除あり",

View File

@ -1036,6 +1036,7 @@
"대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}",
"대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "需要确认是否删除仅在目标中的 {0} 个项目 (确认前不会删除)",
"대상에서 {0}개를 지울까요?": "从目标中删除 {0} 个项目吗?",
"대상에서 지울까요?": "要从目标中删除吗?",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "已传到目标的文件从源中删除 (移动)",
"대상이 원본과 같거나 원본 안에 있습니다": "目标与源相同或位于源内",
"대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "目标与源相同或位于源内 (每次同步都会改变源)。请选择其他文件夹。",
@ -1261,6 +1262,7 @@
"묻기 (기본)": "询问 (默认)",
"물어보지 않기": "不询问",
"미리 보기": "快速预览",
"미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "预览中没有要删除的项目 - 但由于上面的规则,实际上可能会被删除",
"미리 볼 수 없습니다: {0}": "无法预览: {0}",
"미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "无法预览 (文本不是 UTF-8)",
"미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "无法预览 (未知的文本编码)",
@ -2048,6 +2050,7 @@
"이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密",
"이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "这些选项中的部分过滤规则无法预览 - 实际可能会删除更多: {0}",
"이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "这些选项中的部分过滤规则无法预览,因此不会自动删除",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
"이 이동 버튼 지우기": "删除此移动按钮",
"이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?",
@ -2415,6 +2418,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
"지금 작업 취소: {0}": "已取消当前任务: {0}",
"지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)",
"지금 지우기 (확인)": "立即删除 (确认)",
"지금 짝 추가": "添加当前一对",
"지금 짝을 즐겨찾기에": "将当前一对加入收藏",
"지금 폴더": "当前文件夹",
@ -2430,6 +2434,7 @@
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}",
"지난 작업 기록": "旧任务记录",
"지문 확인하고 연결": "确认指纹并连接",
"지우고 맞추기": "删除并同步",
"지우고 초기화": "删除并重置",
"지우기": "删除",
"지우기 포함": "含删除",

View File

@ -0,0 +1,95 @@
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/l10n/tr.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';
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 ("H a.txt") 이 있는 지우기 포함 쌍 - 모니터링 카드의 빨간 줄
/// "자동으로 지우지 않습니다" · [지금 지우기 (확인)] 창 (FilterLimitNote · 체크) 을 412 · 960, ko · en · ja 로 찍는다.
/// 시험 폴더 안에서만 (창은 [나중에] - 지우지 않음).
/// 실행: flutter test integration_test\windows_lsync_p0_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_lsync_p0');
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_lsyncp0_shot_');
final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd');
for (final (dir, rel) in [(src, 'a.txt'), (src, 'b.txt'), (dst, 'a.txt'), (dst, 'old.log'), (dst, 'extra.txt')]) {
File(p.join(dir, rel))
..createSync(recursive: true)
..writeAsStringSync('$dir/$rel');
}
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a --filter="H a.txt"'
..liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)];
final live = LiveSync(c)..start();
addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single;
await t.runAsync(() => live.syncNow(pair));
expect(live.noAutoDelete[LiveSync.keyOf(pair)], isNotEmpty);
final shotKey = GlobalKey();
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
home: MonitorPage(c: c),
));
Future<void> settle([int n = 8]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 80)));
await t.pump(const Duration(milliseconds: 80));
}
}
Future<void> shot(String name) async {
await settle(3);
expect(t.takeException(), isNull, reason: '$name 넘침');
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, 'lsync_p0_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
await t.tap(find.text(tr('lsync (실시간 동기화)')));
await settle();
expect(find.text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다')), findsOneWidget);
await shot('1_card');
await t.tap(find.text(tr('지금 지우기 (확인)')));
await settle(12);
expect(find.textContaining(tr('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)')), findsOneWidget);
await shot('2_now');
await t.tap(find.text(tr('나중에')));
await settle();
expect(File(p.join(dst, 'old.log')).existsSync() && File(p.join(dst, 'a.txt')).existsSync(), isTrue, reason: '찍기만 - 지우지 않음');
});
}
}
}

View File

@ -178,6 +178,34 @@ class LiveSync extends ChangeNotifier {
/// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다)
final reasked = <String>{};
/// P0 (관리자 3a 10/20, 28 재현): 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 거르기 규칙 (-,s · H · P · merge · --files-from 등)
/// 이 있으면 실제 rsync 가 지울 목록에 없던 것까지 지울 수 있다 ("-,s a.txt" · "H a.txt" 는 받는 쪽 a.txt 를 지움).
/// 저절로 도는 맞추기는 지우지 않고 보내기만 하고, 지우기는 카드의 [지금 지우기 (확인)] 로 한 번씩만. 값은 그 규칙들
final noAutoDelete = <String, List<String>>{};
final _deleteOnce = <String>{};
/// 이 쌍의 지우기에 걸리는 미리 볼 수 없는 거르기 규칙 (지우기 포함 rsync 쌍만)
List<String> unsupportedOf(LiveSyncPair x) => x.delete ? _filterOf(x).unsupported : const [];
/// 미리 볼 수 없는 규칙이 있는 쌍의 "맞출 것" 은 지우기 없이 센다 (저절로 지우지 않으므로 − 줄이 늘 남지 않게)
LiveSyncPair _pendingPair(LiveSyncPair x) => unsupportedOf(x).isNotEmpty ? x.copyWith(delete: false) : x;
/// [지금 지우기] 창에 보일 지울 것 (미리 본 것 - 위 규칙 때문에 실제와 다를 수 있음) · 그 가운데 --delete-excluded 로 지워지는 것
Future<(List<String>, List<String>)> previewDeletes(LiveSyncPair x) async {
final excl = <String>[];
final del = [
for (final d in await diff(x.copyWith(delete: true), limit: 100000, filter: _filterOf(x), excluded: excl))
if (d.startsWith('− ')) d.substring(2)
];
return (del, excl);
}
/// 확인 창 (FilterLimitNote 와 체크) 을 거친 뒤 이번 한 번만 지우기 포함으로 맞춘다. 다음 자동 맞추기는 다시 보내기만
Future<void> deleteNow(LiveSyncPair x) async {
_deleteOnce.add(keyOf(x));
await syncNow(x);
}
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
@ -345,8 +373,8 @@ class LiveSync extends ChangeNotifier {
problems[k] = SourceUnreadableException(x.source, cause: e);
}
try {
pending[k] = await diff(x, filter: _filterOf(x));
await _countOnlyTarget(x);
pending[k] = await diff(_pendingPair(x), filter: _filterOf(x));
await _countOnlyTarget(_pendingPair(x));
} catch (_) {}
notifyListeners();
}
@ -503,8 +531,16 @@ class LiveSync extends ChangeNotifier {
final asked = x;
try {
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
var ask = x.delete && !x.deleteConfirmed;
if (x.delete && x.deleteConfirmed && x.confirmedOptions != _optionsOf(x)) {
final once = _deleteOnce.remove(k);
final limits = unsupportedOf(x);
if (limits.isEmpty) {
noAutoDelete.remove(k);
} else {
noAutoDelete[k] = limits;
}
// 미리 볼 수 없는 규칙이 있으면 묻지도 않는다 (묻는 목록이 실제와 달라 - 28: 묻는 목록 밖의 a.txt 가 지워짐 · P 는 지킨 것을 끝없이 물음)
var ask = limits.isEmpty && x.delete && !x.deleteConfirmed;
if (limits.isEmpty && x.delete && x.deleteConfirmed && x.confirmedOptions != _optionsOf(x)) {
if (await _deletesGrew(x)) {
ask = true;
reasked.add(k);
@ -534,6 +570,7 @@ class LiveSync extends ChangeNotifier {
toDeleteExcluded.remove(k);
reasked.remove(k);
}
if (limits.isNotEmpty && !once) x = x.copyWith(delete: false); // 보내기만
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
final allowEmpty = _allowEmpty.remove(k);
if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty);
@ -553,8 +590,8 @@ class LiveSync extends ChangeNotifier {
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
problems.remove(k);
emptyDeletes.remove(k);
pending[k] = await diff(asked, filter: _filterOf(asked));
await _countOnlyTarget(asked);
pending[k] = await diff(_pendingPair(asked), filter: _filterOf(asked));
await _countOnlyTarget(_pendingPair(asked));
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
} on SourceUnreadableException catch (e) {
// 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에

View File

@ -21,7 +21,7 @@ import 'path_label.dart';
import 'rsync_setup.dart';
import 'schedule_editor.dart';
import '../core/sync_preview.dart' show optionsRemoveSource;
import 'sync_preview_view.dart' show confirmDeletingRun, effectiveRunText;
import 'sync_preview_view.dart' show FilterLimitNote, confirmDeletingRun, effectiveRunText;
import 'theme.dart';
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
@ -80,21 +80,45 @@ class MonitorPage extends StatelessWidget {
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
if (list.isEmpty) return;
final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false);
if (pick == null) return;
await live.decideDelete(x, delete: pick);
}
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)].
/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만)
Future<void> confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async {
final (list, excl) = await live.previewDeletes(x);
if (!context.mounted) return;
final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true);
if (pick == true) await live.deleteNow(x);
}
Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List<String> list, List<String> exclIn,
{required bool now}) {
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
final excl = (live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const <String>[]).toSet();
final excl = exclIn.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<bool>(
// P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기]
final limits = live.unsupportedOf(x);
var ack = false;
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(trf('대상에서 {0}개를 지울까요?', [list.length])),
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])),
content: SizedBox(
width: 560,
// 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (limits.isNotEmpty) ...[
FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)),
const SizedBox(height: 4),
],
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
if (live.reasked.contains(LiveSync.keyOf(x))) ...[
if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)),
const SizedBox(height: 6),
@ -104,6 +128,9 @@ Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPai
const SizedBox(height: 8),
Expanded(
child: ListView(children: [
if (list.isEmpty)
Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
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) ...[
@ -122,17 +149,16 @@ Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPai
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('{0}개 지우고 맞추기', [list.length])),
onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true),
child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])),
),
],
),
),
);
if (pick == null) return;
await live.decideDelete(x, delete: pick);
}
/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창)
@ -1020,6 +1046,21 @@ class _LiveTabState extends State<_LiveTab> {
),
]),
),
// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩
if (live != null && live.noAutoDelete[k] != null)
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
const Icon(Icons.block, size: 16, color: Colors.redAccent),
Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
Text('(${live.noAutoDelete[k]!.join(' · ')})', style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
OutlinedButton(
onPressed: () => confirmLiveDeleteNow(context, live, x),
child: Text(tr('지금 지우기 (확인)')),
),
]),
),
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [

View File

@ -8,6 +8,7 @@ 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/core/sync_tools.dart' show splitOptions, toCygwinPath;
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';
@ -193,4 +194,138 @@ void main() {
expect(LiveSyncPair.fromJson(j).confirmedOptions, '-av --exclude=*.tmp');
expect(LiveSyncPair.fromJson({...j, 'confirmedOptions': 3}).confirmedOptions, isNull, reason: '틀린 값은 없는 것으로');
});
// P0 (관리자 3a 10/20, 28 재현): 미리 볼 수 없는 거르기 규칙이 있는 지우기 포함 쌍
group('P0: 미리 볼 수 없는 거르기 규칙 - 자동은 보내기만 · 지우기는 확인 뒤 한 번', () {
late Directory tmp;
late String src, dst;
late AppController c;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_p0_');
src = p.join(tmp.path, 's');
dst = p.join(tmp.path, 'd');
for (final (dir, rel, s) in [
(src, 'a.txt', 's:a'),
(src, 'b.txt', 's:b'),
(src, 'c.log', 's:c'),
(dst, 'a.txt', 'd:a'),
(dst, 'old.log', 'd:old'),
(dst, 'extra.txt', 'd:extra'),
]) {
File(p.join(dir, rel))
..createSync(recursive: true)
..writeAsStringSync(s);
}
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = sceneRsync ?? '';
});
tearDown(() => tmp.deleteSync(recursive: true));
bool there(String rel) => File(p.join(dst, rel)).existsSync();
for (final opts in ['-a --filter="-,s a.txt"', '-a --filter="H a.txt"', '-a -f"H a.txt"']) {
for (final confirmed in [false, true]) {
test('28 재현 $opts (${confirmed ? '확인한 쌍' : '새 쌍'}): 묻는 목록 밖의 a.txt 가 지워지지 않음', () async {
final rsync = sceneRsync;
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
// 먼저 실제 rsync 가 이 규칙 + --delete 로 받는 쪽 a.txt 를 지우는 것을 확인 (위험이 실제로 있음)
final probe = p.join(tmp.path, 'probe');
Directory(probe).createSync();
for (final f in Directory(dst).listSync()) {
File(f.path).copySync(p.join(probe, p.basename(f.path)));
}
final r = await Process.run(rsync, ['-8', '--delete', ...splitOptions(opts), '${toCygwinPath(src)}/', '${toCygwinPath(probe)}/']);
expect(r.exitCode, 0, reason: '${r.stderr}');
expect(File(p.join(probe, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움');
c.settings.rsyncOptions = opts;
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: confirmed, confirmedOptions: confirmed ? opts : null);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
final k = LiveSync.keyOf(pair);
for (var i = 0; i < 2; i++) {
await live.syncNow(c.settings.liveSyncPairs.single);
expect(live.status[k]!.$2, isNot(contains('실패')));
expect([there('a.txt'), there('old.log'), there('extra.txt')], [true, true, true], reason: '자동 맞추기는 지우지 않음 ($i)');
expect(there('b.txt') && there('c.log'), isTrue, reason: '보내기는 함');
expect(live.noAutoDelete[k], isNotEmpty);
expect(live.toDelete[k], isNull, reason: '묻지도 않음 (목록이 실제와 다름)');
expect(live.pending[k]!.where((d) => d.startsWith('− ')), isEmpty, reason: '− 줄이 늘 남지 않음');
}
// [지금 지우기 (확인)] - 창에 보일 목록은 미리 본 것 (a.txt 는 없음 - 그래서 빨간 경고와 체크)
final (list, _) = await live.previewDeletes(c.settings.liveSyncPairs.single);
expect(list, unorderedEquals(['old.log', 'extra.txt']));
await live.deleteNow(c.settings.liveSyncPairs.single);
expect([there('old.log'), there('extra.txt')], [false, false]);
// 다음 자동 맞추기는 다시 보내기만
File(p.join(dst, 'extra2.txt')).writeAsStringSync('x');
await live.syncNow(c.settings.liveSyncPairs.single);
expect(there('extra2.txt'), isTrue);
expect(live.noAutoDelete[k], isNotEmpty);
});
}
}
test('P (지키기) 규칙: 경고만 · 지킨 것을 끝없이 묻지 않음 · 저절로 지우지 않음', () async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="P old.log"';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
final k = LiveSync.keyOf(pair);
for (var i = 0; i < 3; i++) {
await live.syncNow(c.settings.liveSyncPairs.single);
expect(live.toDelete[k], isNull, reason: '묻지 않음 ($i)');
expect(live.noAutoDelete[k], ['--filter=P old.log']);
}
expect(there('old.log') && there('extra.txt'), isTrue);
});
test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --exclude=*.log';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await live.syncNow(pair);
final k = LiveSync.keyOf(pair);
expect(live.noAutoDelete[k], isNull);
expect(live.toDelete[k], ['extra.txt']);
});
testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="H a.txt"';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
late BuildContext ctx;
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {
ctx = x;
return const SizedBox();
})));
await tester.runAsync(() async {
unawaited(confirmLiveDeleteNow(ctx, live, pair));
await Future<void>.delayed(const Duration(milliseconds: 500));
});
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.textContaining('미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다'), findsOneWidget);
expect(find.text('대상에서 2개를 지울까요?'), findsOneWidget);
expect(find.text('지우지 않기 (지우기 끄기)'), findsNothing);
FilledButton go() => tester.widget(find.widgetWithText(FilledButton, '2개 지우고 맞추기'));
expect(go().onPressed, isNull, reason: '체크 전에는 꺼짐');
await tester.tap(find.byType(Checkbox));
await tester.pump();
expect(go().onPressed, isNotNull);
await tester.tap(find.text('나중에'));
await tester.pump();
expect(there('old.log') && there('extra.txt') && there('a.txt'), isTrue);
});
});
}