From aba095b342bde55ea8bebcb4b49ff4961cf16b40 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:21:56 +0900 Subject: [PATCH] =?UTF-8?q?P0=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94:=20=EB=AF=B8=EB=A6=AC=20=EB=B3=BC=20?= =?UTF-8?q?=EC=88=98=20=EC=97=86=EB=8A=94=20=EA=B1=B0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=B4=20=EC=9E=88=EB=8A=94=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=EC=8C=8D=EC=9D=80=20=EC=A0=80=EC=A0=88?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A=EA=B3=A0=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0=EB=A7=8C=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=EB=8A=94=20=ED=99=95=EC=9D=B8=20=EB=92=A4=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=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, 28 재현 - 데이터 버그 수정): 로컬 rsync 쌍 · 지우기 포함 · 옵션 --filter="-,s a.txt" (또는 "H a.txt" · -f"H a.txt") 에서 묻는 목록은 [extra.txt, old.log] 인데 [지우기] 를 누르면 a.txt 도 지워졌다 (실제 rsync 는 보내는 쪽만 빼기 · 숨기기를 받는 쪽에서 지울 것으로 봄). "P *.txt" 는 반대로 지킨 것을 끝없이 물었다. - 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 규칙 (RsyncFilter.unsupported) 이 하나라도 있으면 자동 맞추기는 --delete 계열 없이 (보내기만) 하고 묻지도 않는다 (목록이 실제와 달라서). 맞출 것 · 대상에만 있는 것도 지우기 없이 센다. - 카드에 빨간 "이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다 (규칙)" · [지금 지우기 (확인)]. 그 창은 FilterLimitNote (빨간 "실제로 더 지워질 수 있습니다") 와 [알고 실행합니다] 체크가 있어야 [지우고 맞추기] - 이번 한 번만 지우고 다음 자동 맞추기는 다시 보내기만. - 묻는 창 (confirmLiveDeletes) 도 같은 창을 써서 규칙이 있으면 FilterLimitNote · 체크. - P (지키기) 는 따로 따르지 않고 위와 같이 다룸 (경고 · 저절로 지우지 않음 · 끝없이 묻지 않음) - 지킨 것을 목록에서 빼려면 P · R 과 다른 규칙의 순서 (받는 쪽 규칙) 를 맞춰야 해서 이번에는 하지 않음. 시험: live_sync_scene_test P0 - 세 가지 규칙 × 새 쌍 · 확인한 쌍을 실제 rsync 로 (먼저 실제 rsync 가 a.txt 를 지우는 것 확인 → 자동 두 번은 아무것도 지우지 않음 · 묻지 않음 · − 줄 없음 → 지금 지우기 한 번 → 다음 자동은 다시 보내기만), P 규칙 세 번, 미리 볼 수 있는 규칙만이면 예전처럼, 창 (빨간 줄 · 체크 전 버튼 꺼짐 · [지우지 않기] 없음). LiveSync 시험 157개 통과. 캡처: windows_lsync_p0_shot_test (카드 · 지금 지우기 창, ko · en · ja × 412 · 960, 넘침 0) → release\test\shots_lsync_p0. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 5 + assets/l10n/ja.json | 5 + assets/l10n/zh-Hans.json | 5 + .../windows_lsync_p0_shot_test.dart | 95 ++++++++++++ lib/app/live_sync.dart | 49 +++++- lib/ui/monitor_page.dart | 145 +++++++++++------- test/live_sync_scene_test.dart | 135 ++++++++++++++++ 7 files changed, 381 insertions(+), 58 deletions(-) create mode 100644 integration_test/windows_lsync_p0_shot_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index d1d01b7..8b2ab56 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -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.", @@ -1260,6 +1261,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)", @@ -2047,6 +2049,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?", @@ -2414,6 +2417,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", @@ -2428,6 +2432,7 @@ "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", "지난 작업 기록": "Old job logs", "지문 확인하고 연결": "Check fingerprint and connect", + "지우고 맞추기": "Delete and sync", "지우고 초기화": "Delete and reset", "지우기": "Delete", "지우기 포함": "with delete", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ea91f81..bbf38e1 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1036,6 +1036,7 @@ "대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}", "대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "対象にだけある {0} 個を削除するか確認が必要です (確認するまで削除しません)", "대상에서 {0}개를 지울까요?": "対象から {0} 個を削除しますか?", + "대상에서 지울까요?": "対象から削除しますか?", "대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "対象へ移したファイルを元から削除します (移動)", "대상이 원본과 같거나 원본 안에 있습니다": "対象が元と同じか、元の中にあります", "대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "対象が元と同じか元の中にあります (同期するたびに元が変わります)。別のフォルダーを選んでください。", @@ -1260,6 +1261,7 @@ "묻기 (기본)": "確認する (既定)", "물어보지 않기": "尋ねない", "미리 보기": "クイックプレビュー", + "미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "プレビューでは削除するものはありません - ただし上のルールのため実際には削除されることがあります", "미리 볼 수 없습니다: {0}": "プレビューできません: {0}", "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "プレビューできません (文字コードが UTF-8 ではありません)", "미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "プレビューできません (文字コードが分かりません)", @@ -2047,6 +2049,7 @@ "이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "この接続は署名も暗号化もされません ({0})。同じネットワークの他の人がファイルの内容を見たり変えたりできるため、NAS の設定で SMB 署名か SMB3 暗号化をオンにすることをおすすめします", "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします", "이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "このオプションの一部のフィルター規則はプレビューできません - 実際にはさらに削除される場合があります: {0}", + "이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "このオプションの一部のフィルタールールはプレビューできないため、自動では削除しません", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。", "이 이동 버튼 지우기": "この移動ボタンを削除", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?", @@ -2414,6 +2417,7 @@ "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", "지금 작업 취소: {0}": "現在の作業をキャンセル: {0}", "지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)", + "지금 지우기 (확인)": "今すぐ削除 (確認)", "지금 짝 추가": "今のペアを追加", "지금 짝을 즐겨찾기에": "今のペアをお気に入りに", "지금 폴더": "現在のフォルダー", @@ -2428,6 +2432,7 @@ "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", "지난 작업 기록": "過去の作業記録", "지문 확인하고 연결": "フィンガープリントを確認して接続", + "지우고 맞추기": "削除して同期", "지우고 초기화": "削除して初期化", "지우기": "削除", "지우기 포함": "削除あり", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 2c26823..061dc08 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1036,6 +1036,7 @@ "대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}", "대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)": "需要确认是否删除仅在目标中的 {0} 个项目 (确认前不会删除)", "대상에서 {0}개를 지울까요?": "从目标中删除 {0} 个项目吗?", + "대상에서 지울까요?": "要从目标中删除吗?", "대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "已传到目标的文件从源中删除 (移动)", "대상이 원본과 같거나 원본 안에 있습니다": "目标与源相同或位于源内", "대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.": "目标与源相同或位于源内 (每次同步都会改变源)。请选择其他文件夹。", @@ -1260,6 +1261,7 @@ "묻기 (기본)": "询问 (默认)", "물어보지 않기": "不询问", "미리 보기": "快速预览", + "미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다": "预览中没有要删除的项目 - 但由于上面的规则,实际上可能会被删除", "미리 볼 수 없습니다: {0}": "无法预览: {0}", "미리 볼 수 없음 (글자 코드가 UTF-8 이 아닙니다)": "无法预览 (文本不是 UTF-8)", "미리 볼 수 없음 (글자 코드를 알 수 없습니다)": "无法预览 (未知的文本编码)", @@ -2047,6 +2049,7 @@ "이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다": "此连接既未签名也未加密 ({0})。同一网络中的其他人可能查看或篡改文件内容,建议在 NAS 设置中开启 SMB 签名或 SMB3 加密", "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密", "이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}": "这些选项中的部分过滤规则无法预览 - 实际可能会删除更多: {0}", + "이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다": "这些选项中的部分过滤规则无法预览,因此不会自动删除", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。", "이 이동 버튼 지우기": "删除此移动按钮", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?", @@ -2414,6 +2417,7 @@ "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", "지금 작업 취소: {0}": "已取消当前任务: {0}", "지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)", + "지금 지우기 (확인)": "立即删除 (确认)", "지금 짝 추가": "添加当前一对", "지금 짝을 즐겨찾기에": "将当前一对加入收藏", "지금 폴더": "当前文件夹", @@ -2428,6 +2432,7 @@ "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", "지난 작업 기록": "旧任务记录", "지문 확인하고 연결": "确认指纹并连接", + "지우고 맞추기": "删除并同步", "지우고 초기화": "删除并重置", "지우기": "删除", "지우기 포함": "含删除", diff --git a/integration_test/windows_lsync_p0_shot_test.dart b/integration_test/windows_lsync_p0_shot_test.dart new file mode 100644 index 0000000..a790ba1 --- /dev/null +++ b/integration_test/windows_lsync_p0_shot_test.dart @@ -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 settle([int n = 8]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 80))); + await t.pump(const Duration(milliseconds: 80)); + } + } + + Future 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: '찍기만 - 지우지 않음'); + }); + } + } +} diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index b833ece..cdb01d8 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -178,6 +178,34 @@ class LiveSync extends ChangeNotifier { /// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다) final reasked = {}; + /// P0 (관리자 3a 10/20, 28 재현): 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 거르기 규칙 (-,s · H · P · merge · --files-from 등) + /// 이 있으면 실제 rsync 가 지울 목록에 없던 것까지 지울 수 있다 ("-,s a.txt" · "H a.txt" 는 받는 쪽 a.txt 를 지움). + /// 저절로 도는 맞추기는 지우지 않고 보내기만 하고, 지우기는 카드의 [지금 지우기 (확인)] 로 한 번씩만. 값은 그 규칙들 + final noAutoDelete = >{}; + final _deleteOnce = {}; + + /// 이 쌍의 지우기에 걸리는 미리 볼 수 없는 거르기 규칙 (지우기 포함 rsync 쌍만) + List 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, List)> previewDeletes(LiveSyncPair x) async { + final excl = []; + 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 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: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에 diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 5795994..1ef0b3d 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -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,61 +80,87 @@ 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( - title: Text(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: [ - // 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것 - if (live.reasked.contains(LiveSync.keyOf(x))) ...[ - Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'), - style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)), - const SizedBox(height: 6), - ], - Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).', - [vDisplay(x.source), vDisplay(x.target)])), - const SizedBox(height: 8), - Expanded( - child: ListView(children: [ - 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])), - ], - ]), - ), - ]), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))), - 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])), - ), - ], - ), - ); + 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 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 _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List list, List exclIn, + {required bool now}) { + // 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게 + 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]; + // P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기] + final limits = live.unsupportedOf(x); + var ack = false; + return showDialog( + context: context, + 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 (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[ + Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'), + style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.orangeAccent)), + const SizedBox(height: 6), + ], + Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).', + [vDisplay(x.source), vDisplay(x.target)])), + 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) ...[ + 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])), + ], + ]), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))), + if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true), + child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])), + ), + ], + ), + ), + ); +} + /// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창) Future confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async { final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const []; @@ -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: [ diff --git a/test/live_sync_scene_test.dart b/test/live_sync_scene_test.dart index dd66557..37e3b87 100644 --- a/test/live_sync_scene_test.dart +++ b/test/live_sync_scene_test.dart @@ -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.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); + }); + }); }