From 512c9d8b14109e739e069c438c3a5f2ea746b12f Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:22:23 +0900 Subject: [PATCH] =?UTF-8?q?M2-b:=20Windows=20=ED=9C=B4=EC=A7=80=ED=86=B5?= =?UTF-8?q?=20=EC=A3=BC=EC=9E=85=20(=EC=A0=84=EC=97=AD=20recycleBinForTest?= =?UTF-8?q?=20=EC=97=86=EC=95=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recycle_bin: RecycleBinBackend 약속 + WindowsRecycleBin (이 기기의 휴지통). 전역 recycleBinForTest · recycle() · restoreRecycled() 없앰 - delete_ops: deletePaths(bin:) · undoRecycle(bin:) - 처음 값 WindowsRecycleBin - ExplorerDevice.recycleBin → ExplorerFlows · ExplorerDeleteFlow(recycleBin:) - 시험: useTestRecycleBin(device) 가 시험 기기에 시험 폴더 안의 휴지통을 넣음. 영구 삭제만 하는 약속 시험 4개는 휴지통으로 보내면 실패하는 것을 넘김 (사용자 휴지통에 닿지 않게). 넘겨받은 휴지통 약속 시험 1개 더함 - 시험용 휴지통: 자기 폴더가 없어졌으면 다시 만들지 않고 그대로 알림 (진단) - 전부터 있던 실패 2개 고침 (E6 부터 Windows 처음 모양이 Total Commander 라 이름 · 확장자가 다른 칸): windows_check_shot_test · windows_b3_shot_test 가 X-plore 모양을 직접 고름 확인: analyze 0 · 단위 1472 ×2 · 통합 6/6 ×2 · base10 214장 0px · windows_check_shot ×2 · windows_b3_shot ×2 (첫 확인의 통합 1회에서 412 지우기가 '경로를 찾을 수 없음' 으로 11장 벗어남 - 다시 4회 모두 0px, 원인 미확인) Co-Authored-By: Claude Opus 5.5 --- integration_test/explorer_use_check_test.dart | 2 +- integration_test/windows_b3_shot_test.dart | 3 ++- integration_test/windows_check_shot_test.dart | 14 ++++++++--- lib/app/delete_ops.dart | 12 +++++---- lib/app/explorer_device.dart | 6 +++++ lib/platform/windows/recycle_bin.dart | 25 ++++++++----------- lib/ui/explorer/delete_flow.dart | 9 ++++--- lib/ui/explorer/explorer_flows.dart | 6 +++-- lib/ui/explorer_page.dart | 1 + test/delete_ops_contract_test.dart | 13 +++++++++- test/explorer_buttons_contract_test.dart | 14 ++++++++++- test/explorer_conflict_test.dart | 2 +- test/explorer_flows_contract_test.dart | 14 ++++++++++- test/explorer_keys_drag_test.dart | 3 +-- test/explorer_more_flows_contract_test.dart | 14 ++++++++++- test/explorer_page_modules_contract_test.dart | 14 ++++++++++- test/explorer_steady_test.dart | 2 +- test/support/explorer_harness.dart | 4 +-- test/support/fake_recycle_bin.dart | 13 +++++++--- test/support/test_explorer_device.dart | 6 +++++ 20 files changed, 132 insertions(+), 45 deletions(-) diff --git a/integration_test/explorer_use_check_test.dart b/integration_test/explorer_use_check_test.dart index 8c8cb70..ebb9b5d 100644 --- a/integration_test/explorer_use_check_test.dart +++ b/integration_test/explorer_use_check_test.dart @@ -38,7 +38,7 @@ void main() { t.view.devicePixelRatio = 1; addTearDown(t.view.reset); await t.runAsync(() => i18n.apply('ko', save: false)); - useTestRecycleBin(); + useTestRecycleBin(device); // 캡처 비교가 흔들리지 않게 시험 폴더 이름 · 파일 시각을 고정 (관리자 지시 10/22) - 끝나면 지움 final tmp = Directory(p.join(Directory.systemTemp.path, 'jj_usechk_fixed_$w')); if (tmp.existsSync()) tmp.deleteSync(recursive: true); diff --git a/integration_test/windows_b3_shot_test.dart b/integration_test/windows_b3_shot_test.dart index 7584c47..cb3382c 100644 --- a/integration_test/windows_b3_shot_test.dart +++ b/integration_test/windows_b3_shot_test.dart @@ -80,8 +80,9 @@ void main() { ..writeAsStringSync('old'); c = AppController(PlatformServices.create()); c.settings.explorer.paths = [left, right]; + c.settings.explorer.style = 'xplore'; // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸) // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 - useTestRecycleBin(); + useTestRecycleBin(device); }); tearDown(() async { expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)'); diff --git a/integration_test/windows_check_shot_test.dart b/integration_test/windows_check_shot_test.dart index d06f0ed..0dd9eae 100644 --- a/integration_test/windows_check_shot_test.dart +++ b/integration_test/windows_check_shot_test.dart @@ -20,6 +20,7 @@ import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; import '../test/support/fake_recycle_bin.dart'; +import '../test/support/test_explorer_device.dart'; import 'support/temp_dir.dart'; /// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다. @@ -82,7 +83,8 @@ void main() { final tmp = tempDir('jj_shot_del_'); final started = DateTime.now(); // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 (끝에 시험 항목만 지움) - useTestRecycleBin(); + final device = TestExplorerDevice(); + useTestRecycleBin(device); try { for (final (tag, size) in sizes) { final dir = Directory(p.join(tmp.path, tag))..createSync(); @@ -90,10 +92,11 @@ void main() { File(p.join(dir.path, 'keep_$tag.txt')).writeAsStringSync('x'); final c = controller(); c.settings + ..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸) ..explorer.layout = 'single' ..explorer.click = 'select' ..explorer.paths = [dir.path, dir.path]; - await show(t, size, ExplorerPage(c: c)); + await show(t, size, ExplorerPage(c: c, device: device)); await tapText(t, 'recycle_me_$tag.txt'); await t.sendKeyEvent(LogicalKeyboardKey.delete); await settle(t); @@ -119,10 +122,11 @@ void main() { for (final (tag, size) in sizes) { final c = controller(); c.settings + ..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸) ..explorer.layout = 'single' ..explorer.click = 'select' ..explorer.paths = [share.path, share.path]; - await show(t, size, ExplorerPage(c: c)); + await show(t, size, ExplorerPage(c: c, device: device)); await settle(t, 20); await tapText(t, 'on_share.txt'); await t.sendKeyEvent(LogicalKeyboardKey.delete); @@ -142,10 +146,11 @@ void main() { for (final (tag, size) in sizes) { final c = controller(); c.settings + ..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸) ..explorer.layout = 'single' ..explorer.click = 'select' ..explorer.paths = [deep, deep]; - await show(t, size, ExplorerPage(c: c)); + await show(t, size, ExplorerPage(c: c, device: device)); await settle(t, 20); final f = find.textContaining('long_name_file'); if (f.evaluate().isEmpty) break; @@ -185,6 +190,7 @@ void main() { for (final (tag, size) in sizes) { final c = controller(); c.settings + ..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸) ..explorer.layout = 'single' ..explorer.paths = [tmp.path, tmp.path]; await show(t, size, ExplorerPage(c: c)); diff --git a/lib/app/delete_ops.dart b/lib/app/delete_ops.dart index 9dd0965..c4fc15c 100644 --- a/lib/app/delete_ops.dart +++ b/lib/app/delete_ops.dart @@ -4,7 +4,7 @@ import '../core/app_trash.dart'; import '../core/file_ops.dart' show FileOps; import '../platform/windows/recycle_bin.dart'; -/// 약속 (E5b): 지우기 · 되돌리기. 휴지통은 recycle_bin (Windows) · app_trash (Android) 를 부르기만 한다. +/// 약속 (E5b): 지우기 · 되돌리기. 휴지통은 [RecycleBinBackend] (Windows, 처음 값 = 이 기기의 휴지통) · app_trash (Android) 를 부르기만 한다. /// - [deletePaths]: 입력 = 경로들 · 영구인지 · 앱 휴지통 (있으면 그리로), 출력 = [DeleteReport] (실제로 어떻게 됐는지 하나씩). /// 하나가 실패해도 나머지는 계속한다. 휴지통으로 못 보낸 것은 몰래 영구 삭제하지 않고 보고에 남긴다 /// (앱 휴지통 [DeleteReport.notTrashed] · 긴 경로 [DeleteReport.tooLong] - 영구 삭제는 부르는 쪽이 물은 뒤 [deleteForever]). @@ -40,7 +40,8 @@ class DeleteReport { /// 사람이 읽을 실패 이유 (OS 메시지가 있으면 그것) String deleteFailReason(Object e) => e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'; -Future deletePaths(List paths, {required bool permanent, AppTrash? trash, DateTime? now}) async { +Future deletePaths(List paths, + {required bool permanent, AppTrash? trash, RecycleBinBackend bin = const WindowsRecycleBin(), DateTime? now}) async { final r = DeleteReport((now ?? DateTime.now()).subtract(const Duration(seconds: 2))); for (final x in paths) { if (!permanent && trash != null) { @@ -55,7 +56,7 @@ Future deletePaths(List paths, {required bool permanent, A } try { if (!permanent) { - switch (recycle(x)) { + switch (bin.move(x)) { case RecycleResult.recycled: r.recycled++; r.sent.add(x); @@ -101,12 +102,13 @@ Future> undoTrash(AppTrash trash, List items) async { } /// Windows 휴지통에서 되살림 - 되살린 수 · 못 되살린 것 (경로, 이유) -({int back, List<(String, String)> failed}) undoRecycle(List paths, DateTime since) { +({int back, List<(String, String)> failed}) undoRecycle(List paths, DateTime since, + {RecycleBinBackend bin = const WindowsRecycleBin()}) { final failed = <(String, String)>[]; var back = 0; for (final x in paths) { try { - restoreRecycled(x, since: since); + bin.restore(x, since: since); back++; } catch (e) { failed.add((x, e is FileSystemException ? e.message : '$e')); diff --git a/lib/app/explorer_device.dart b/lib/app/explorer_device.dart index b3e9cc1..ef4b7b8 100644 --- a/lib/app/explorer_device.dart +++ b/lib/app/explorer_device.dart @@ -2,12 +2,16 @@ import 'dart:io'; import 'copy_center.dart' as copy show diskSpace; import 'local_fs.dart'; +import '../platform/windows/recycle_bin.dart'; /// 약속: 파일 탐색기가 기기에서 받는 것 - 폴더 목록 · 저장 장치 목록 · 드라이브를 열 수 있는지 ([fs], M2 LocalFs) · /// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양, 못 재면 null) · Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴). /// 앱은 [RealExplorerDevice] (이 기기 그대로) 를 쓰고, 시험은 시험 폴더를 저장 장치로 주는 구현을 넘긴다 abstract interface class ExplorerDevice { LocalFs get fs; + + /// Windows 휴지통 (M2) + RecycleBinBackend get recycleBin; Future<(int, int)?> diskSpace(String path); bool get androidPaths; bool get androidTrash; @@ -21,6 +25,8 @@ class RealExplorerDevice implements ExplorerDevice { @override LocalFs get fs => const RealLocalFs(); @override + RecycleBinBackend get recycleBin => const WindowsRecycleBin(); + @override Future<(int, int)?> diskSpace(String path) => copy.diskSpace(path); @override bool get androidPaths => Platform.isAndroid; diff --git a/lib/platform/windows/recycle_bin.dart b/lib/platform/windows/recycle_bin.dart index db45712..4b8b0a7 100644 --- a/lib/platform/windows/recycle_bin.dart +++ b/lib/platform/windows/recycle_bin.dart @@ -2,7 +2,6 @@ import 'dart:ffi'; import 'dart:io'; import 'package:ffi/ffi.dart'; -import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as p; import '../../l10n/tr.dart'; @@ -19,24 +18,22 @@ enum RecycleResult { cancelled, } -/// 시험용: 실제 휴지통 대신 쓸 것. 기본 전체 시험은 가짜로 흐름만 본다 - 시험이 도중에 끊기면 사용자 휴지통에 -/// 흔적이 남기 때문 (실제 휴지통 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만, test/support/fake_recycle_bin.dart). +/// 약속 (M2 휴지통): [move] 경로 하나를 휴지통으로 → [RecycleResult] (보냄 · Windows 가 묻고 영구 삭제 · 취소). +/// 오류: 긴 경로 [LongPathException] · 그 밖은 [FileSystemException]. [restore] 원래 자리로 (같은 이름이 있으면 덮지 않고 오류). +/// 앱은 [WindowsRecycleBin] (이 기기의 휴지통) 을 ExplorerDevice 로 넘겨받는다. 시험은 생성자 · 기기로 다른 것을 넘긴다 +/// (기본 전체 시험은 시험 폴더 안의 휴지통 - 도중에 끊겨도 사용자 휴지통에 흔적이 남지 않게, test/support/fake_recycle_bin.dart) abstract class RecycleBinBackend { RecycleResult move(String path); void restore(String original, {required DateTime since}); } -/// null 이면 실제 휴지통 (앱은 늘 null) -@visibleForTesting -RecycleBinBackend? recycleBinForTest; - -/// 휴지통으로 보내기 (앱의 화면은 이것을 쓴다 - 시험에서 가짜로 바꿀 수 있게) -RecycleResult recycle(String path) => recycleBinForTest?.move(path) ?? moveToRecycleBin(path); - -/// 휴지통에서 원래 자리로 (앱의 화면은 이것을 쓴다) -void restoreRecycled(String original, {required DateTime since}) { - final t = recycleBinForTest; - t == null ? restoreFromRecycleBin(original, since: since) : t.restore(original, since: since); +/// 이 기기의 Windows 휴지통 +class WindowsRecycleBin implements RecycleBinBackend { + const WindowsRecycleBin(); + @override + RecycleResult move(String path) => moveToRecycleBin(path); + @override + void restore(String original, {required DateTime since}) => restoreFromRecycleBin(original, since: since); } /// 이 경로가 있는 곳에 휴지통이 있는지 (94). Windows 의 휴지통은 고정 디스크에만 있다: diff --git a/lib/ui/explorer/delete_flow.dart b/lib/ui/explorer/delete_flow.dart index b4cf5ea..3e830cf 100644 --- a/lib/ui/explorer/delete_flow.dart +++ b/lib/ui/explorer/delete_flow.dart @@ -24,7 +24,7 @@ import 'write_guard.dart'; /// 지우기 규칙은 delete_ops (E5b). 여러 개면 실패해도 나머지를 계속하고, 못 지운 것 · 휴지통에 못 넣은 것은 창으로 묻거나 알린다. /// 휴지통으로 보낸 것은 알림의 [되돌리기]. 끝나면 표시 · 펼침 · 지금 폴더를 정리하고 다시 읽는다. 읽기 전용인 곳은 [ExplorerGuard] 가 막는다 class ExplorerDeleteFlow { - ExplorerDeleteFlow(this.ctl, this.sel, this.guard, {required this.androidTrash}); + ExplorerDeleteFlow(this.ctl, this.sel, this.guard, {required this.androidTrash, required this.recycleBin}); final ExplorerController ctl; final ExplorerSelect sel; @@ -33,6 +33,9 @@ class ExplorerDeleteFlow { /// 앱 휴지통을 쓰는 기기인지 (Android) final bool Function() androidTrash; + /// Windows 휴지통 (기기에서 - ExplorerDevice.recycleBin) + final RecycleBinBackend Function() recycleBin; + /// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65) bool shiftDelete = false; @@ -125,7 +128,7 @@ class ExplorerDeleteFlow { final focusedBefore = pane.focused; final shownBefore = focusedBefore == null ? const [] : [for (final e in sel.shown(pane, vDirname(focusedBefore))) e.path]; // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b) - final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null); + final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null, bin: recycleBin()); final failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed; final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since; final recycled = report.recycled, nuked = report.nuked, kept = report.kept; @@ -274,7 +277,7 @@ class ExplorerDeleteFlow { /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) Future _undoRecycle(List paths, DateTime since) async { - final (:back, failed: why) = undoRecycle(paths, since); + final (:back, failed: why) = undoRecycle(paths, since, bin: recycleBin()); final failed = [for (final (x, e) in why) '${vBasename(x)}: $e']; await ctl.refreshAll(paths.map(p.dirname)); for (final x in ctl.panes) { diff --git a/lib/ui/explorer/explorer_flows.dart b/lib/ui/explorer/explorer_flows.dart index 5950a28..797644b 100644 --- a/lib/ui/explorer/explorer_flows.dart +++ b/lib/ui/explorer/explorer_flows.dart @@ -12,16 +12,18 @@ import 'tools_flow.dart'; import 'transfer_flow.dart'; import 'view_dialogs.dart'; import 'write_guard.dart'; +import '../../platform/windows/recycle_bin.dart' show RecycleBinBackend; /// 약속 (E5d-6): 탐색기 하나의 흐름 모듈 묶음 - 버튼 · 줄 메뉴 · 단축키 · 끌어 놓기가 같은 것을 쓴다 (작업 목록 · 담아 둔 것이 하나). /// 처음 쓸 때 만든다. 화면 배치에서 오는 것 (다른 창의 폴더 · 창이 둘인지 · 앱 휴지통을 쓰는 기기인지) 은 함수로 받는다 class ExplorerFlows { ExplorerFlows(this.ctl, this.sel, - {required this.androidTrash, required this.otherDirOf, required this.otherDir, required this.twoPanes}); + {required this.androidTrash, required this.recycleBin, required this.otherDirOf, required this.otherDir, required this.twoPanes}); final ExplorerController ctl; final ExplorerSelect sel; final bool Function() androidTrash; + final RecycleBinBackend Function() recycleBin; /// [pane] 옆 창의 지금 폴더 (두 창일 때만, 압축 풀 곳) final String? Function(ExplorerPane pane) otherDirOf; @@ -34,7 +36,7 @@ class ExplorerFlows { late final jobs = ExplorerJobs(ctl); late final transfers = ExplorerTransfers(ctl, guard, jobs); late final rsync = ExplorerRsyncFlow(ctl, jobs, transfers); - late final deletes = ExplorerDeleteFlow(ctl, sel, guard, androidTrash: androidTrash); + late final deletes = ExplorerDeleteFlow(ctl, sel, guard, androidTrash: androidTrash, recycleBin: recycleBin); late final opener = ExplorerOpener(ctl, sel, guard, otherDirOf: otherDirOf); late final renames = ExplorerRenames(ctl, guard); late final tools = ExplorerTools(ctl, deletes, transfers); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index ba2e8d3..5553fd5 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -309,6 +309,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex /// 흐름 모듈 묶음 - 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 · 열기 · 이름 · 도구 · 보기 창 · 찾기 (E5d-5 · E5d-6) late final ExplorerFlows _flows = ExplorerFlows(_ctl, _sel, androidTrash: () => widget.device.androidTrash, + recycleBin: () => widget.device.recycleBin, otherDirOf: (pane) => _layout.dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null, otherDir: () => _layout.other?.current, twoPanes: () => _layout.dual || _layout.split) diff --git a/test/delete_ops_contract_test.dart b/test/delete_ops_contract_test.dart index 3cd7f58..5170ef3 100644 --- a/test/delete_ops_contract_test.dart +++ b/test/delete_ops_contract_test.dart @@ -5,7 +5,7 @@ import 'package:jj_mkvmaker/app/delete_ops.dart'; import 'package:jj_mkvmaker/core/app_trash.dart'; import 'package:path/path.dart' as p; -import 'support/fake_recycle_bin.dart' show realRecycleSkip; +import 'support/fake_recycle_bin.dart' show FakeRecycleBin, realRecycleSkip; /// E5b 약속 시험: lib/app/delete_ops.dart 맨 위 약속. 시험 폴더의 실제 파일 · 실제 앱 휴지통 (가짜 없음). /// Windows 휴지통은 실제 사용자 휴지통이라 JJ_TEST_REAL_RECYCLE=1 일 때만 (끝나면 되살려 흔적 없음). @@ -62,6 +62,17 @@ void main() { expect(deleteFailReason(const FileSystemException('x', 'y', OSError('잠김', 32))), '잠김'); }); + test('M2: 넘겨받은 휴지통으로 보내고 되돌림 (시험 폴더 안의 휴지통)', () async { + final bin = FakeRecycleBin(); + addTearDown(bin.dispose); + final r = await deletePaths([a, b], permanent: false, bin: bin); + expect([r.sent, r.recycled, bin.items.length], [[a, b], 2, 2]); + expect(File(a).existsSync(), isFalse); + final u = undoRecycle(r.sent, r.since, bin: bin); + expect([u.back, u.failed], [2, isEmpty]); + expect([File(a).existsSync(), File(b).existsSync()], [true, true]); + }); + test('Windows 휴지통: 보내고 되돌림 (실제 휴지통)', () async { final r = await deletePaths([a, b], permanent: false); expect(r.sent, [a, b]); diff --git a/test/explorer_buttons_contract_test.dart b/test/explorer_buttons_contract_test.dart index b24e1b4..1d86c02 100644 --- a/test/explorer_buttons_contract_test.dart +++ b/test/explorer_buttons_contract_test.dart @@ -19,6 +19,7 @@ import 'package:jj_mkvmaker/ui/explorer/marks_bar.dart'; import 'package:jj_mkvmaker/ui/explorer/toolbar.dart'; import 'package:jj_mkvmaker/ui/explorer/transfer_panel.dart'; import 'package:path/path.dart' as p; +import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend, RecycleResult; /// E5d-4 약속 시험: explorer_buttons · toolbar · marks_bar · transfer_panel · job_view 맨 위 약속 /// (실제 시험 폴더 · 실제 화면 · 실제 복사 작업. 화면의 일 [ExplorerCommands] 은 이 시험 화면이 받아 적는다) @@ -48,7 +49,7 @@ void main() { ctl.dispose(); }); await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); - final flows = ExplorerFlows(ctl, sel, androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => null, twoPanes: () => false); + final flows = ExplorerFlows(ctl, sel, androidTrash: () => false, recycleBin: _noRecycle, otherDirOf: (_) => null, otherDir: () => null, twoPanes: () => false); addTearDown(flows.dispose); final b = ExplorerButtons(ctl, sel, host, flows); return (b, ctl, sel, host); @@ -191,3 +192,14 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands @override Widget build(BuildContext context) => Scaffold(body: child?.call() ?? const SizedBox()); } + +/// 이 시험은 휴지통으로 보내지 않는다 (영구 삭제만) - 혹시 보내면 사용자 휴지통에 닿지 않고 실패한다 +RecycleBinBackend _noRecycle() => const _NoRecycle(); + +class _NoRecycle implements RecycleBinBackend { + const _NoRecycle(); + @override + RecycleResult move(String path) => throw StateError('이 시험은 휴지통으로 보내지 않음'); + @override + void restore(String original, {required DateTime since}) => throw StateError('이 시험은 휴지통으로 보내지 않음'); +} diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index b8a3bd3..eaa3a50 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -45,7 +45,7 @@ void main() { final root = p.rootPrefix(tmp.path); device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; // 휴지통: 기본은 가짜 (흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 - bin = useTestRecycleBin(); + bin = useTestRecycleBin(device); }); tearDown(() async { // 사용자 휴지통에 이 시험 폴더 아래의 흔적이 없는지 스스로 확인 (가짜일 때도 - 실제 휴지통에 닿지 않았는지) diff --git a/test/explorer_flows_contract_test.dart b/test/explorer_flows_contract_test.dart index 2bf742f..d6ffea4 100644 --- a/test/explorer_flows_contract_test.dart +++ b/test/explorer_flows_contract_test.dart @@ -23,6 +23,7 @@ import 'package:jj_mkvmaker/ui/explorer/transfer_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/write_guard.dart'; import 'package:jj_mkvmaker/ui/text_viewer.dart'; import 'package:path/path.dart' as p; +import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend, RecycleResult; /// E5d-5 약속 시험: write_guard · explorer_jobs · transfer_flow · delete_flow · open_flow · item_menu · item_actions 맨 위 약속 /// (실제 시험 폴더 · 실제 화면 · 실제 복사 · 실제 지우기. 다른 앱으로 여는 길은 이 PC 의 앱을 열므로 보지 않는다) @@ -58,7 +59,7 @@ void main() { final host = key.currentState!; final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')]; final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); - final flows = ExplorerFlows(ctl, sel, androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => null, twoPanes: () => false); + final flows = ExplorerFlows(ctl, sel, androidTrash: () => false, recycleBin: _noRecycle, otherDirOf: (_) => null, otherDir: () => null, twoPanes: () => false); addTearDown(() { flows.dispose(); sel.dispose(); @@ -197,3 +198,14 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands @override Widget build(BuildContext context) => const Scaffold(body: SizedBox()); } + +/// 이 시험은 휴지통으로 보내지 않는다 (영구 삭제만) - 혹시 보내면 사용자 휴지통에 닿지 않고 실패한다 +RecycleBinBackend _noRecycle() => const _NoRecycle(); + +class _NoRecycle implements RecycleBinBackend { + const _NoRecycle(); + @override + RecycleResult move(String path) => throw StateError('이 시험은 휴지통으로 보내지 않음'); + @override + void restore(String original, {required DateTime since}) => throw StateError('이 시험은 휴지통으로 보내지 않음'); +} diff --git a/test/explorer_keys_drag_test.dart b/test/explorer_keys_drag_test.dart index 17e3811..e93e033 100644 --- a/test/explorer_keys_drag_test.dart +++ b/test/explorer_keys_drag_test.dart @@ -11,7 +11,6 @@ import 'package:jj_mkvmaker/ui/explorer_drag.dart'; import 'package:jj_mkvmaker/ui/storage_page.dart'; import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; -import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest; import 'support/explorer_harness.dart'; /// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 단축키 (TC · Windows 탐색기) · 창 사이 끌어 놓기 · 진행 패널 · 빠른 보기 · 저장 공간 분석 · 열 머리 정렬 @@ -47,7 +46,7 @@ void main() { }); testWidgets('83 (28 지적): Total Commander 모양 F8 = 지우기 - 확인 창 [취소] 면 그대로, [휴지통으로] 면 휴지통으로', (tester) async { - final bin = recycleBinForTest; + final bin = device.bin; if (bin is! FakeRecycleBin) return markTestSkipped('실제 휴지통 (JJ_TEST_REAL_RECYCLE=1) 에서는 건너뜀'); if (!Platform.isWindows) return markTestSkipped('휴지통은 Windows 만 (Android 는 앱 휴지통 시험)'); final victim = File(p.join(left, 'tcdel.txt'))..writeAsStringSync('x'); diff --git a/test/explorer_more_flows_contract_test.dart b/test/explorer_more_flows_contract_test.dart index 9d5183e..7c1201d 100644 --- a/test/explorer_more_flows_contract_test.dart +++ b/test/explorer_more_flows_contract_test.dart @@ -25,6 +25,7 @@ import 'package:jj_mkvmaker/ui/explorer_drag.dart' show DragLoad; import 'package:jj_mkvmaker/ui/explorer_search.dart' show SearchResultsPage; import 'package:jj_mkvmaker/ui/storage_page.dart' show StorageAnalysisPage; import 'package:path/path.dart' as p; +import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend, RecycleResult; /// E5e 약속 시험: explorer_body · search_flow · tools_flow · rsync_flow 맨 위 약속 /// (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음). rsync 실행은 rsync 실행 파일이 있어야 해 확인 창까지) @@ -155,7 +156,7 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands, sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); layout = ExplorerLayout(ctl, context: () => context); flows = ExplorerFlows(ctl, sel, - androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => layout.other?.current, twoPanes: () => layout.dual || layout.split); + androidTrash: () => false, recycleBin: _noRecycle, otherDirOf: (_) => null, otherDir: () => layout.other?.current, twoPanes: () => layout.dual || layout.split); buttons = ExplorerButtons(ctl, sel, this, flows); _made = true; } @@ -225,3 +226,14 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands, Widget build(BuildContext context) => Scaffold( body: !_made ? const SizedBox() : ExplorerPanesBody(ctl: ctl, sel: sel, layout: layout, parts: this, buttons: buttons, flows: flows)); } + +/// 이 시험은 휴지통으로 보내지 않는다 (영구 삭제만) - 혹시 보내면 사용자 휴지통에 닿지 않고 실패한다 +RecycleBinBackend _noRecycle() => const _NoRecycle(); + +class _NoRecycle implements RecycleBinBackend { + const _NoRecycle(); + @override + RecycleResult move(String path) => throw StateError('이 시험은 휴지통으로 보내지 않음'); + @override + void restore(String original, {required DateTime since}) => throw StateError('이 시험은 휴지통으로 보내지 않음'); +} diff --git a/test/explorer_page_modules_contract_test.dart b/test/explorer_page_modules_contract_test.dart index 1117d61..543005b 100644 --- a/test/explorer_page_modules_contract_test.dart +++ b/test/explorer_page_modules_contract_test.dart @@ -22,6 +22,7 @@ import 'package:jj_mkvmaker/ui/explorer/key_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/pane_notices.dart'; import 'package:jj_mkvmaker/ui/explorer/server_flows.dart'; import 'package:path/path.dart' as p; +import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend, RecycleResult; /// E5d-6 약속 시험: explorer_layout · access_flow · pane_notices · view_dialogs · rename_flow · tools_flow · drop_flow · key_flow · /// explorer_bars 맨 위 약속 (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음) · 실제 이름 바꾸기) @@ -57,7 +58,7 @@ void main() { final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); final layout = ExplorerLayout(ctl, context: () => host.context); final flows = ExplorerFlows(ctl, sel, - androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => layout.other?.current, twoPanes: () => layout.dual || layout.split); + androidTrash: () => false, recycleBin: _noRecycle, otherDirOf: (_) => null, otherDir: () => layout.other?.current, twoPanes: () => layout.dual || layout.split); final access = ExplorerAccess(ctl, probe: (_) async => drive); final input = ExplorerInput(ctl, sel, flows, ExplorerButtons(ctl, sel, host, flows), dual: () => layout.dual); addTearDown(() { @@ -209,3 +210,14 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands @override Widget build(BuildContext context) => Scaffold(body: _child?.call() ?? const SizedBox()); } + +/// 이 시험은 휴지통으로 보내지 않는다 (영구 삭제만) - 혹시 보내면 사용자 휴지통에 닿지 않고 실패한다 +RecycleBinBackend _noRecycle() => const _NoRecycle(); + +class _NoRecycle implements RecycleBinBackend { + const _NoRecycle(); + @override + RecycleResult move(String path) => throw StateError('이 시험은 휴지통으로 보내지 않음'); + @override + void restore(String original, {required DateTime since}) => throw StateError('이 시험은 휴지통으로 보내지 않음'); +} diff --git a/test/explorer_steady_test.dart b/test/explorer_steady_test.dart index a5e92b4..6322c0e 100644 --- a/test/explorer_steady_test.dart +++ b/test/explorer_steady_test.dart @@ -23,7 +23,7 @@ void main() { late AppController c; setUp(() { - useTestRecycleBin(); + useTestRecycleBin(device); tmp = Directory.systemTemp.createTempSync('jj_steady_'); left = p.join(tmp.path, 'left'); right = p.join(tmp.path, 'right'); diff --git a/test/support/explorer_harness.dart b/test/support/explorer_harness.dart index 0fc15a9..c699bcd 100644 --- a/test/support/explorer_harness.dart +++ b/test/support/explorer_harness.dart @@ -49,8 +49,6 @@ TestExplorerDevice get device => _device!; void useExplorerFixture() { setUp(() { - // 휴지통: 기본은 가짜 (이 시험들은 영구 삭제를 고르지만 혹시라도 사용자 휴지통에 닿지 않게) - useTestRecycleBin(); _tmp = Directory.systemTemp.createTempSync('jj_explorer_'); _left = p.join(tmp.path, 'left'); _right = p.join(tmp.path, 'right'); @@ -63,6 +61,8 @@ void useExplorerFixture() { Directory(right).createSync(); _shell = ExplorerShell(); _device = TestExplorerDevice(); + // 휴지통: 기본은 가짜 (이 시험들은 영구 삭제를 고르지만 혹시라도 사용자 휴지통에 닿지 않게) + useTestRecycleBin(device); _c = AppController(PlatformServices( mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell)); c.settings.explorer.paths = [left, right]; diff --git a/test/support/fake_recycle_bin.dart b/test/support/fake_recycle_bin.dart index 0d250d6..3b3edbe 100644 --- a/test/support/fake_recycle_bin.dart +++ b/test/support/fake_recycle_bin.dart @@ -5,6 +5,8 @@ import 'package:jj_mkvmaker/l10n/tr.dart'; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart'; import 'package:path/path.dart' as p; +import 'test_explorer_device.dart'; + /// 실제 사용자 휴지통을 쓰는 시험은 이 값이 1 일 때만 (휴지통 쪽 코드를 고칠 때 · 릴리스 전에 따로 돌리고 흔적 0 확인). /// 기본 전체 시험은 [FakeRecycleBin] 으로 흐름만 본다 - 시험이 도중에 끊기면 사용자 휴지통에 흔적이 남기 때문. bool get realRecycleBin => Platform.environment['JJ_TEST_REAL_RECYCLE'] == '1'; @@ -16,13 +18,14 @@ Object get realRecycleSkip => !Platform.isWindows ? false : '실제 휴지통 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만'; -/// 시험 하나 동안 휴지통을 가짜로 (JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 그대로). 가짜 휴지통을 돌려준다 (실제면 null). -FakeRecycleBin? useTestRecycleBin() { +/// 시험 하나 동안 [device] 의 휴지통을 시험 폴더 안의 것으로 (JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 그대로). +/// 그 휴지통을 돌려준다 (실제면 null). +FakeRecycleBin? useTestRecycleBin(TestExplorerDevice device) { if (realRecycleBin) return null; final fake = FakeRecycleBin(); - recycleBinForTest = fake; + device.bin = fake; addTearDown(() { - recycleBinForTest = null; + device.bin = null; fake.dispose(); }); return fake; @@ -43,6 +46,8 @@ class FakeRecycleBin implements RecycleBinBackend { final long = longPathInside(path); if (long != null) throw LongPathException(recycleErrorText(0x7C), long); final full = File(path).absolute.path; + // 진단 (10/23 한 번 '경로를 찾을 수 없음'): 시험 휴지통 폴더가 없어졌으면 다시 만들지 않고 그대로 알린다 + if (!bin.existsSync()) throw FileSystemException('시험 휴지통 폴더가 없어짐', bin.path); final to = p.join(bin.path, '\$R${_n++}${p.extension(full)}'); _move(full, to); items[full] = to; diff --git a/test/support/test_explorer_device.dart b/test/support/test_explorer_device.dart index 5c9c5f3..ffc205a 100644 --- a/test/support/test_explorer_device.dart +++ b/test/support/test_explorer_device.dart @@ -1,6 +1,7 @@ import 'package:jj_mkvmaker/app/explorer_device.dart'; import 'package:jj_mkvmaker/app/local_fs.dart'; import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; +import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend; /// 시험용 [ExplorerDevice]: 정한 것만 바꾸고 나머지는 이 기기 그대로. /// [android] · [trash] · [nomediaOn] 은 Windows 에서 Android 의 갈래를 지나게 할 뿐이다 - 논리만 확인, Android 실제 확인은 노트 10+ @@ -20,12 +21,17 @@ class TestExplorerDevice implements ExplorerDevice { /// 남은 공간 · 전체 (null 이면 이 기기) - 캡처가 실행마다 같게 정한다 (int, int)? Function(String path)? space; + /// Windows 휴지통 (null 이면 이 기기의 휴지통 - 시험은 useTestRecycleBin 으로 시험 폴더 안의 것) + RecycleBinBackend? bin; + /// 경로 입력 SMB 안내 · 앱 휴지통 · .nomedia 메뉴 (null 이면 이 기기) bool? android, trash, nomediaOn; @override LocalFs get fs => _TestFs(this); @override + RecycleBinBackend get recycleBin => bin ?? _real.recycleBin; + @override Future<(int, int)?> diskSpace(String path) async => space != null ? space!(path) : await _real.diskSpace(path); @override bool get androidPaths => android ?? _real.androidPaths;