M2-b: Windows 휴지통 주입 (전역 recycleBinForTest 없앰)

- 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 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 00:22:23 +09:00
parent beb8107d81
commit 512c9d8b14
20 changed files with 132 additions and 45 deletions

View File

@ -38,7 +38,7 @@ void main() {
t.view.devicePixelRatio = 1; t.view.devicePixelRatio = 1;
addTearDown(t.view.reset); addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply('ko', save: false)); await t.runAsync(() => i18n.apply('ko', save: false));
useTestRecycleBin(); useTestRecycleBin(device);
// 캡처 비교가 흔들리지 않게 시험 폴더 이름 · 파일 시각을 고정 (관리자 지시 10/22) - 끝나면 지움 // 캡처 비교가 흔들리지 않게 시험 폴더 이름 · 파일 시각을 고정 (관리자 지시 10/22) - 끝나면 지움
final tmp = Directory(p.join(Directory.systemTemp.path, 'jj_usechk_fixed_$w')); final tmp = Directory(p.join(Directory.systemTemp.path, 'jj_usechk_fixed_$w'));
if (tmp.existsSync()) tmp.deleteSync(recursive: true); if (tmp.existsSync()) tmp.deleteSync(recursive: true);

View File

@ -80,8 +80,9 @@ void main() {
..writeAsStringSync('old'); ..writeAsStringSync('old');
c = AppController(PlatformServices.create()); c = AppController(PlatformServices.create());
c.settings.explorer.paths = [left, right]; c.settings.explorer.paths = [left, right];
c.settings.explorer.style = 'xplore'; // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸)
// 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통
useTestRecycleBin(); useTestRecycleBin(device);
}); });
tearDown(() async { tearDown(() async {
expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)'); expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)');

View File

@ -20,6 +20,7 @@ import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../test/support/fake_recycle_bin.dart'; import '../test/support/fake_recycle_bin.dart';
import '../test/support/test_explorer_device.dart';
import 'support/temp_dir.dart'; import 'support/temp_dir.dart';
/// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다. /// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다.
@ -82,7 +83,8 @@ void main() {
final tmp = tempDir('jj_shot_del_'); final tmp = tempDir('jj_shot_del_');
final started = DateTime.now(); final started = DateTime.now();
// 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 (끝에 시험 항목만 지움) // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 (끝에 시험 항목만 지움)
useTestRecycleBin(); final device = TestExplorerDevice();
useTestRecycleBin(device);
try { try {
for (final (tag, size) in sizes) { for (final (tag, size) in sizes) {
final dir = Directory(p.join(tmp.path, tag))..createSync(); 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'); File(p.join(dir.path, 'keep_$tag.txt')).writeAsStringSync('x');
final c = controller(); final c = controller();
c.settings c.settings
..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸)
..explorer.layout = 'single' ..explorer.layout = 'single'
..explorer.click = 'select' ..explorer.click = 'select'
..explorer.paths = [dir.path, dir.path]; ..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 tapText(t, 'recycle_me_$tag.txt');
await t.sendKeyEvent(LogicalKeyboardKey.delete); await t.sendKeyEvent(LogicalKeyboardKey.delete);
await settle(t); await settle(t);
@ -119,10 +122,11 @@ void main() {
for (final (tag, size) in sizes) { for (final (tag, size) in sizes) {
final c = controller(); final c = controller();
c.settings c.settings
..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸)
..explorer.layout = 'single' ..explorer.layout = 'single'
..explorer.click = 'select' ..explorer.click = 'select'
..explorer.paths = [share.path, share.path]; ..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 settle(t, 20);
await tapText(t, 'on_share.txt'); await tapText(t, 'on_share.txt');
await t.sendKeyEvent(LogicalKeyboardKey.delete); await t.sendKeyEvent(LogicalKeyboardKey.delete);
@ -142,10 +146,11 @@ void main() {
for (final (tag, size) in sizes) { for (final (tag, size) in sizes) {
final c = controller(); final c = controller();
c.settings c.settings
..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸)
..explorer.layout = 'single' ..explorer.layout = 'single'
..explorer.click = 'select' ..explorer.click = 'select'
..explorer.paths = [deep, deep]; ..explorer.paths = [deep, deep];
await show(t, size, ExplorerPage(c: c)); await show(t, size, ExplorerPage(c: c, device: device));
await settle(t, 20); await settle(t, 20);
final f = find.textContaining('long_name_file'); final f = find.textContaining('long_name_file');
if (f.evaluate().isEmpty) break; if (f.evaluate().isEmpty) break;
@ -185,6 +190,7 @@ void main() {
for (final (tag, size) in sizes) { for (final (tag, size) in sizes) {
final c = controller(); final c = controller();
c.settings c.settings
..explorer.style = 'xplore' // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸)
..explorer.layout = 'single' ..explorer.layout = 'single'
..explorer.paths = [tmp.path, tmp.path]; ..explorer.paths = [tmp.path, tmp.path];
await show(t, size, ExplorerPage(c: c)); await show(t, size, ExplorerPage(c: c));

View File

@ -4,7 +4,7 @@ import '../core/app_trash.dart';
import '../core/file_ops.dart' show FileOps; import '../core/file_ops.dart' show FileOps;
import '../platform/windows/recycle_bin.dart'; import '../platform/windows/recycle_bin.dart';
/// 약속 (E5b): 지우기 · 되돌리기. 휴지통은 recycle_bin (Windows) · app_trash (Android) 를 부르기만 한다. /// 약속 (E5b): 지우기 · 되돌리기. 휴지통은 [RecycleBinBackend] (Windows, 처음 값 = 이 기기의 휴지통) · app_trash (Android) 를 부르기만 한다.
/// - [deletePaths]: 입력 = 경로들 · 영구인지 · 앱 휴지통 (있으면 그리로), 출력 = [DeleteReport] (실제로 어떻게 됐는지 하나씩). /// - [deletePaths]: 입력 = 경로들 · 영구인지 · 앱 휴지통 (있으면 그리로), 출력 = [DeleteReport] (실제로 어떻게 됐는지 하나씩).
/// 하나가 실패해도 나머지는 계속한다. 휴지통으로 못 보낸 것은 몰래 영구 삭제하지 않고 보고에 남긴다 /// 하나가 실패해도 나머지는 계속한다. 휴지통으로 못 보낸 것은 몰래 영구 삭제하지 않고 보고에 남긴다
/// (앱 휴지통 [DeleteReport.notTrashed] · 긴 경로 [DeleteReport.tooLong] - 영구 삭제는 부르는 쪽이 물은 뒤 [deleteForever]). /// (앱 휴지통 [DeleteReport.notTrashed] · 긴 경로 [DeleteReport.tooLong] - 영구 삭제는 부르는 쪽이 물은 뒤 [deleteForever]).
@ -40,7 +40,8 @@ class DeleteReport {
/// 사람이 읽을 실패 이유 (OS 메시지가 있으면 그것) /// 사람이 읽을 실패 이유 (OS 메시지가 있으면 그것)
String deleteFailReason(Object e) => e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'; String deleteFailReason(Object e) => e is FileSystemException ? (e.osError?.message ?? e.message) : '$e';
Future<DeleteReport> deletePaths(List<String> paths, {required bool permanent, AppTrash? trash, DateTime? now}) async { Future<DeleteReport> deletePaths(List<String> paths,
{required bool permanent, AppTrash? trash, RecycleBinBackend bin = const WindowsRecycleBin(), DateTime? now}) async {
final r = DeleteReport((now ?? DateTime.now()).subtract(const Duration(seconds: 2))); final r = DeleteReport((now ?? DateTime.now()).subtract(const Duration(seconds: 2)));
for (final x in paths) { for (final x in paths) {
if (!permanent && trash != null) { if (!permanent && trash != null) {
@ -55,7 +56,7 @@ Future<DeleteReport> deletePaths(List<String> paths, {required bool permanent, A
} }
try { try {
if (!permanent) { if (!permanent) {
switch (recycle(x)) { switch (bin.move(x)) {
case RecycleResult.recycled: case RecycleResult.recycled:
r.recycled++; r.recycled++;
r.sent.add(x); r.sent.add(x);
@ -101,12 +102,13 @@ Future<List<String>> undoTrash(AppTrash trash, List<TrashItem> items) async {
} }
/// Windows 휴지통에서 되살림 - 되살린 수 · 못 되살린 것 (경로, 이유) /// Windows 휴지통에서 되살림 - 되살린 수 · 못 되살린 것 (경로, 이유)
({int back, List<(String, String)> failed}) undoRecycle(List<String> paths, DateTime since) { ({int back, List<(String, String)> failed}) undoRecycle(List<String> paths, DateTime since,
{RecycleBinBackend bin = const WindowsRecycleBin()}) {
final failed = <(String, String)>[]; final failed = <(String, String)>[];
var back = 0; var back = 0;
for (final x in paths) { for (final x in paths) {
try { try {
restoreRecycled(x, since: since); bin.restore(x, since: since);
back++; back++;
} catch (e) { } catch (e) {
failed.add((x, e is FileSystemException ? e.message : '$e')); failed.add((x, e is FileSystemException ? e.message : '$e'));

View File

@ -2,12 +2,16 @@ import 'dart:io';
import 'copy_center.dart' as copy show diskSpace; import 'copy_center.dart' as copy show diskSpace;
import 'local_fs.dart'; import 'local_fs.dart';
import '../platform/windows/recycle_bin.dart';
/// 약속: 파일 탐색기가 기기에서 받는 것 - 폴더 목록 · 저장 장치 목록 · 드라이브를 열 수 있는지 ([fs], M2 LocalFs) · /// 약속: 파일 탐색기가 기기에서 받는 것 - 폴더 목록 · 저장 장치 목록 · 드라이브를 열 수 있는지 ([fs], M2 LocalFs) ·
/// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양, 못 재면 null) · Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴). /// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양, 못 재면 null) · Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴).
/// 앱은 [RealExplorerDevice] (이 기기 그대로) 를 쓰고, 시험은 시험 폴더를 저장 장치로 주는 구현을 넘긴다 /// 앱은 [RealExplorerDevice] (이 기기 그대로) 를 쓰고, 시험은 시험 폴더를 저장 장치로 주는 구현을 넘긴다
abstract interface class ExplorerDevice { abstract interface class ExplorerDevice {
LocalFs get fs; LocalFs get fs;
/// Windows 휴지통 (M2)
RecycleBinBackend get recycleBin;
Future<(int, int)?> diskSpace(String path); Future<(int, int)?> diskSpace(String path);
bool get androidPaths; bool get androidPaths;
bool get androidTrash; bool get androidTrash;
@ -21,6 +25,8 @@ class RealExplorerDevice implements ExplorerDevice {
@override @override
LocalFs get fs => const RealLocalFs(); LocalFs get fs => const RealLocalFs();
@override @override
RecycleBinBackend get recycleBin => const WindowsRecycleBin();
@override
Future<(int, int)?> diskSpace(String path) => copy.diskSpace(path); Future<(int, int)?> diskSpace(String path) => copy.diskSpace(path);
@override @override
bool get androidPaths => Platform.isAndroid; bool get androidPaths => Platform.isAndroid;

View File

@ -2,7 +2,6 @@ import 'dart:ffi';
import 'dart:io'; import 'dart:io';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../../l10n/tr.dart'; import '../../l10n/tr.dart';
@ -19,24 +18,22 @@ enum RecycleResult {
cancelled, cancelled,
} }
/// 시험용: 실제 휴지통 대신 쓸 것. 기본 전체 시험은 가짜로 흐름만 본다 - 시험이 도중에 끊기면 사용자 휴지통에 /// 약속 (M2 휴지통): [move] 경로 하나를 휴지통으로 → [RecycleResult] (보냄 · Windows 가 묻고 영구 삭제 · 취소).
/// 흔적이 남기 때문 (실제 휴지통 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만, test/support/fake_recycle_bin.dart). /// 오류: 긴 경로 [LongPathException] · 그 밖은 [FileSystemException]. [restore] 원래 자리로 (같은 이름이 있으면 덮지 않고 오류).
/// 앱은 [WindowsRecycleBin] (이 기기의 휴지통) 을 ExplorerDevice 로 넘겨받는다. 시험은 생성자 · 기기로 다른 것을 넘긴다
/// (기본 전체 시험은 시험 폴더 안의 휴지통 - 도중에 끊겨도 사용자 휴지통에 흔적이 남지 않게, test/support/fake_recycle_bin.dart)
abstract class RecycleBinBackend { abstract class RecycleBinBackend {
RecycleResult move(String path); RecycleResult move(String path);
void restore(String original, {required DateTime since}); void restore(String original, {required DateTime since});
} }
/// null 이면 실제 휴지통 (앱은 늘 null) /// 이 기기의 Windows 휴지통
@visibleForTesting class WindowsRecycleBin implements RecycleBinBackend {
RecycleBinBackend? recycleBinForTest; const WindowsRecycleBin();
@override
/// 휴지통으로 보내기 (앱의 화면은 이것을 쓴다 - 시험에서 가짜로 바꿀 수 있게) RecycleResult move(String path) => moveToRecycleBin(path);
RecycleResult recycle(String path) => recycleBinForTest?.move(path) ?? moveToRecycleBin(path); @override
void restore(String original, {required DateTime since}) => restoreFromRecycleBin(original, since: since);
/// 휴지통에서 원래 자리로 (앱의 화면은 이것을 쓴다)
void restoreRecycled(String original, {required DateTime since}) {
final t = recycleBinForTest;
t == null ? restoreFromRecycleBin(original, since: since) : t.restore(original, since: since);
} }
/// 이 경로가 있는 곳에 휴지통이 있는지 (94). Windows 의 휴지통은 고정 디스크에만 있다: /// 이 경로가 있는 곳에 휴지통이 있는지 (94). Windows 의 휴지통은 고정 디스크에만 있다:

View File

@ -24,7 +24,7 @@ import 'write_guard.dart';
/// 지우기 규칙은 delete_ops (E5b). 여러 개면 실패해도 나머지를 계속하고, 못 지운 것 · 휴지통에 못 넣은 것은 창으로 묻거나 알린다. /// 지우기 규칙은 delete_ops (E5b). 여러 개면 실패해도 나머지를 계속하고, 못 지운 것 · 휴지통에 못 넣은 것은 창으로 묻거나 알린다.
/// 휴지통으로 보낸 것은 알림의 [되돌리기]. 끝나면 표시 · 펼침 · 지금 폴더를 정리하고 다시 읽는다. 읽기 전용인 곳은 [ExplorerGuard] 가 막는다 /// 휴지통으로 보낸 것은 알림의 [되돌리기]. 끝나면 표시 · 펼침 · 지금 폴더를 정리하고 다시 읽는다. 읽기 전용인 곳은 [ExplorerGuard] 가 막는다
class ExplorerDeleteFlow { 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 ExplorerController ctl;
final ExplorerSelect sel; final ExplorerSelect sel;
@ -33,6 +33,9 @@ class ExplorerDeleteFlow {
/// 앱 휴지통을 쓰는 기기인지 (Android) /// 앱 휴지통을 쓰는 기기인지 (Android)
final bool Function() androidTrash; final bool Function() androidTrash;
/// Windows 휴지통 (기기에서 - ExplorerDevice.recycleBin)
final RecycleBinBackend Function() recycleBin;
/// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65) /// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65)
bool shiftDelete = false; bool shiftDelete = false;
@ -125,7 +128,7 @@ class ExplorerDeleteFlow {
final focusedBefore = pane.focused; final focusedBefore = pane.focused;
final shownBefore = focusedBefore == null ? const <String>[] : [for (final e in sel.shown(pane, vDirname(focusedBefore))) e.path]; final shownBefore = focusedBefore == null ? const <String>[] : [for (final e in sel.shown(pane, vDirname(focusedBefore))) e.path];
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b) // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (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 failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed;
final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since; final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since;
final recycled = report.recycled, nuked = report.nuked, kept = report.kept; final recycled = report.recycled, nuked = report.nuked, kept = report.kept;
@ -274,7 +277,7 @@ class ExplorerDeleteFlow {
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
Future<void> _undoRecycle(List<String> paths, DateTime since) async { Future<void> _undoRecycle(List<String> 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']; final failed = [for (final (x, e) in why) '${vBasename(x)}: $e'];
await ctl.refreshAll(paths.map(p.dirname)); await ctl.refreshAll(paths.map(p.dirname));
for (final x in ctl.panes) { for (final x in ctl.panes) {

View File

@ -12,16 +12,18 @@ import 'tools_flow.dart';
import 'transfer_flow.dart'; import 'transfer_flow.dart';
import 'view_dialogs.dart'; import 'view_dialogs.dart';
import 'write_guard.dart'; import 'write_guard.dart';
import '../../platform/windows/recycle_bin.dart' show RecycleBinBackend;
/// 약속 (E5d-6): 탐색기 하나의 흐름 모듈 묶음 - 버튼 · 줄 메뉴 · 단축키 · 끌어 놓기가 같은 것을 쓴다 (작업 목록 · 담아 둔 것이 하나). /// 약속 (E5d-6): 탐색기 하나의 흐름 모듈 묶음 - 버튼 · 줄 메뉴 · 단축키 · 끌어 놓기가 같은 것을 쓴다 (작업 목록 · 담아 둔 것이 하나).
/// 처음 쓸 때 만든다. 화면 배치에서 오는 것 (다른 창의 폴더 · 창이 둘인지 · 앱 휴지통을 쓰는 기기인지) 은 함수로 받는다 /// 처음 쓸 때 만든다. 화면 배치에서 오는 것 (다른 창의 폴더 · 창이 둘인지 · 앱 휴지통을 쓰는 기기인지) 은 함수로 받는다
class ExplorerFlows { class ExplorerFlows {
ExplorerFlows(this.ctl, this.sel, 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 ExplorerController ctl;
final ExplorerSelect sel; final ExplorerSelect sel;
final bool Function() androidTrash; final bool Function() androidTrash;
final RecycleBinBackend Function() recycleBin;
/// [pane] 옆 창의 지금 폴더 (두 창일 때만, 압축 풀 곳) /// [pane] 옆 창의 지금 폴더 (두 창일 때만, 압축 풀 곳)
final String? Function(ExplorerPane pane) otherDirOf; final String? Function(ExplorerPane pane) otherDirOf;
@ -34,7 +36,7 @@ class ExplorerFlows {
late final jobs = ExplorerJobs(ctl); late final jobs = ExplorerJobs(ctl);
late final transfers = ExplorerTransfers(ctl, guard, jobs); late final transfers = ExplorerTransfers(ctl, guard, jobs);
late final rsync = ExplorerRsyncFlow(ctl, jobs, transfers); 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 opener = ExplorerOpener(ctl, sel, guard, otherDirOf: otherDirOf);
late final renames = ExplorerRenames(ctl, guard); late final renames = ExplorerRenames(ctl, guard);
late final tools = ExplorerTools(ctl, deletes, transfers); late final tools = ExplorerTools(ctl, deletes, transfers);

View File

@ -309,6 +309,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
/// 흐름 모듈 묶음 - 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 · 열기 · 이름 · 도구 · 보기 창 · 찾기 (E5d-5 · E5d-6) /// 흐름 모듈 묶음 - 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 · 열기 · 이름 · 도구 · 보기 창 · 찾기 (E5d-5 · E5d-6)
late final ExplorerFlows _flows = ExplorerFlows(_ctl, _sel, late final ExplorerFlows _flows = ExplorerFlows(_ctl, _sel,
androidTrash: () => widget.device.androidTrash, androidTrash: () => widget.device.androidTrash,
recycleBin: () => widget.device.recycleBin,
otherDirOf: (pane) => _layout.dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null, otherDirOf: (pane) => _layout.dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null,
otherDir: () => _layout.other?.current, otherDir: () => _layout.other?.current,
twoPanes: () => _layout.dual || _layout.split) twoPanes: () => _layout.dual || _layout.split)

View File

@ -5,7 +5,7 @@ import 'package:jj_mkvmaker/app/delete_ops.dart';
import 'package:jj_mkvmaker/core/app_trash.dart'; import 'package:jj_mkvmaker/core/app_trash.dart';
import 'package:path/path.dart' as p; 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 맨 위 약속. 시험 폴더의 실제 파일 · 실제 앱 휴지통 (가짜 없음). /// E5b 약속 시험: lib/app/delete_ops.dart 맨 위 약속. 시험 폴더의 실제 파일 · 실제 앱 휴지통 (가짜 없음).
/// Windows 휴지통은 실제 사용자 휴지통이라 JJ_TEST_REAL_RECYCLE=1 일 때만 (끝나면 되살려 흔적 없음). /// Windows 휴지통은 실제 사용자 휴지통이라 JJ_TEST_REAL_RECYCLE=1 일 때만 (끝나면 되살려 흔적 없음).
@ -62,6 +62,17 @@ void main() {
expect(deleteFailReason(const FileSystemException('x', 'y', OSError('잠김', 32))), '잠김'); 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 { test('Windows 휴지통: 보내고 되돌림 (실제 휴지통)', () async {
final r = await deletePaths([a, b], permanent: false); final r = await deletePaths([a, b], permanent: false);
expect(r.sent, [a, b]); expect(r.sent, [a, b]);

View File

@ -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/toolbar.dart';
import 'package:jj_mkvmaker/ui/explorer/transfer_panel.dart'; import 'package:jj_mkvmaker/ui/explorer/transfer_panel.dart';
import 'package:path/path.dart' as p; 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 맨 위 약속 /// E5d-4 약속 시험: explorer_buttons · toolbar · marks_bar · transfer_panel · job_view 맨 위 약속
/// (실제 시험 폴더 · 실제 화면 · 실제 복사 작업. 화면의 일 [ExplorerCommands] 은 이 시험 화면이 받아 적는다) /// (실제 시험 폴더 · 실제 화면 · 실제 복사 작업. 화면의 일 [ExplorerCommands] 은 이 시험 화면이 받아 적는다)
@ -48,7 +49,7 @@ void main() {
ctl.dispose(); ctl.dispose();
}); });
await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); 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); addTearDown(flows.dispose);
final b = ExplorerButtons(ctl, sel, host, flows); final b = ExplorerButtons(ctl, sel, host, flows);
return (b, ctl, sel, host); return (b, ctl, sel, host);
@ -191,3 +192,14 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands
@override @override
Widget build(BuildContext context) => Scaffold(body: child?.call() ?? const SizedBox()); 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('이 시험은 휴지통으로 보내지 않음');
}

View File

@ -45,7 +45,7 @@ void main() {
final root = p.rootPrefix(tmp.path); final root = p.rootPrefix(tmp.path);
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
// 휴지통: 기본은 가짜 (흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 // 휴지통: 기본은 가짜 (흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통
bin = useTestRecycleBin(); bin = useTestRecycleBin(device);
}); });
tearDown(() async { tearDown(() async {
// 사용자 휴지통에 이 시험 폴더 아래의 흔적이 없는지 스스로 확인 (가짜일 때도 - 실제 휴지통에 닿지 않았는지) // 사용자 휴지통에 이 시험 폴더 아래의 흔적이 없는지 스스로 확인 (가짜일 때도 - 실제 휴지통에 닿지 않았는지)

View File

@ -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/explorer/write_guard.dart';
import 'package:jj_mkvmaker/ui/text_viewer.dart'; import 'package:jj_mkvmaker/ui/text_viewer.dart';
import 'package:path/path.dart' as p; 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 맨 위 약속 /// E5d-5 약속 시험: write_guard · explorer_jobs · transfer_flow · delete_flow · open_flow · item_menu · item_actions 맨 위 약속
/// (실제 시험 폴더 · 실제 화면 · 실제 복사 · 실제 지우기. 다른 앱으로 여는 길은 이 PC 의 앱을 열므로 보지 않는다) /// (실제 시험 폴더 · 실제 화면 · 실제 복사 · 실제 지우기. 다른 앱으로 여는 길은 이 PC 의 앱을 열므로 보지 않는다)
@ -58,7 +59,7 @@ void main() {
final host = key.currentState!; final host = key.currentState!;
final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')]; final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')];
final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); 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(() { addTearDown(() {
flows.dispose(); flows.dispose();
sel.dispose(); sel.dispose();
@ -197,3 +198,14 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands
@override @override
Widget build(BuildContext context) => const Scaffold(body: SizedBox()); 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('이 시험은 휴지통으로 보내지 않음');
}

View File

@ -11,7 +11,6 @@ import 'package:jj_mkvmaker/ui/explorer_drag.dart';
import 'package:jj_mkvmaker/ui/storage_page.dart'; import 'package:jj_mkvmaker/ui/storage_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart'; import 'support/fake_recycle_bin.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest;
import 'support/explorer_harness.dart'; import 'support/explorer_harness.dart';
/// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 단축키 (TC · Windows 탐색기) · 창 사이 끌어 놓기 · 진행 패널 · 빠른 보기 · 저장 공간 분석 · 열 머리 정렬 /// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 단축키 (TC · Windows 탐색기) · 창 사이 끌어 놓기 · 진행 패널 · 빠른 보기 · 저장 공간 분석 · 열 머리 정렬
@ -47,7 +46,7 @@ void main() {
}); });
testWidgets('83 (28 지적): Total Commander 모양 F8 = 지우기 - 확인 창 [취소] 면 그대로, [휴지통으로] 면 휴지통으로', (tester) async { 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 (bin is! FakeRecycleBin) return markTestSkipped('실제 휴지통 (JJ_TEST_REAL_RECYCLE=1) 에서는 건너뜀');
if (!Platform.isWindows) return markTestSkipped('휴지통은 Windows 만 (Android 는 앱 휴지통 시험)'); if (!Platform.isWindows) return markTestSkipped('휴지통은 Windows 만 (Android 는 앱 휴지통 시험)');
final victim = File(p.join(left, 'tcdel.txt'))..writeAsStringSync('x'); final victim = File(p.join(left, 'tcdel.txt'))..writeAsStringSync('x');

View File

@ -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/explorer_search.dart' show SearchResultsPage;
import 'package:jj_mkvmaker/ui/storage_page.dart' show StorageAnalysisPage; import 'package:jj_mkvmaker/ui/storage_page.dart' show StorageAnalysisPage;
import 'package:path/path.dart' as p; 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 맨 위 약속 /// E5e 약속 시험: explorer_body · search_flow · tools_flow · rsync_flow 맨 위 약속
/// (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음). rsync 실행은 rsync 실행 파일이 있어야 해 확인 창까지) /// (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음). rsync 실행은 rsync 실행 파일이 있어야 해 확인 창까지)
@ -155,7 +156,7 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands,
sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {});
layout = ExplorerLayout(ctl, context: () => context); layout = ExplorerLayout(ctl, context: () => context);
flows = ExplorerFlows(ctl, sel, 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); buttons = ExplorerButtons(ctl, sel, this, flows);
_made = true; _made = true;
} }
@ -225,3 +226,14 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands,
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
body: !_made ? const SizedBox() : ExplorerPanesBody(ctl: ctl, sel: sel, layout: layout, parts: this, buttons: buttons, flows: flows)); 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('이 시험은 휴지통으로 보내지 않음');
}

View File

@ -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/pane_notices.dart';
import 'package:jj_mkvmaker/ui/explorer/server_flows.dart'; import 'package:jj_mkvmaker/ui/explorer/server_flows.dart';
import 'package:path/path.dart' as p; 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 · /// E5d-6 약속 시험: explorer_layout · access_flow · pane_notices · view_dialogs · rename_flow · tools_flow · drop_flow · key_flow ·
/// explorer_bars 맨 위 약속 (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음) · 실제 이름 바꾸기) /// explorer_bars 맨 위 약속 (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음) · 실제 이름 바꾸기)
@ -57,7 +58,7 @@ void main() {
final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {});
final layout = ExplorerLayout(ctl, context: () => host.context); final layout = ExplorerLayout(ctl, context: () => host.context);
final flows = ExplorerFlows(ctl, sel, 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 access = ExplorerAccess(ctl, probe: (_) async => drive);
final input = ExplorerInput(ctl, sel, flows, ExplorerButtons(ctl, sel, host, flows), dual: () => layout.dual); final input = ExplorerInput(ctl, sel, flows, ExplorerButtons(ctl, sel, host, flows), dual: () => layout.dual);
addTearDown(() { addTearDown(() {
@ -209,3 +210,14 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands
@override @override
Widget build(BuildContext context) => Scaffold(body: _child?.call() ?? const SizedBox()); 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('이 시험은 휴지통으로 보내지 않음');
}

View File

@ -23,7 +23,7 @@ void main() {
late AppController c; late AppController c;
setUp(() { setUp(() {
useTestRecycleBin(); useTestRecycleBin(device);
tmp = Directory.systemTemp.createTempSync('jj_steady_'); tmp = Directory.systemTemp.createTempSync('jj_steady_');
left = p.join(tmp.path, 'left'); left = p.join(tmp.path, 'left');
right = p.join(tmp.path, 'right'); right = p.join(tmp.path, 'right');

View File

@ -49,8 +49,6 @@ TestExplorerDevice get device => _device!;
void useExplorerFixture() { void useExplorerFixture() {
setUp(() { setUp(() {
// 휴지통: 기본은 가짜 (이 시험들은 영구 삭제를 고르지만 혹시라도 사용자 휴지통에 닿지 않게)
useTestRecycleBin();
_tmp = Directory.systemTemp.createTempSync('jj_explorer_'); _tmp = Directory.systemTemp.createTempSync('jj_explorer_');
_left = p.join(tmp.path, 'left'); _left = p.join(tmp.path, 'left');
_right = p.join(tmp.path, 'right'); _right = p.join(tmp.path, 'right');
@ -63,6 +61,8 @@ void useExplorerFixture() {
Directory(right).createSync(); Directory(right).createSync();
_shell = ExplorerShell(); _shell = ExplorerShell();
_device = TestExplorerDevice(); _device = TestExplorerDevice();
// 휴지통: 기본은 가짜 (이 시험들은 영구 삭제를 고르지만 혹시라도 사용자 휴지통에 닿지 않게)
useTestRecycleBin(device);
_c = AppController(PlatformServices( _c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell)); mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell));
c.settings.explorer.paths = [left, right]; c.settings.explorer.paths = [left, right];

View File

@ -5,6 +5,8 @@ import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart'; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'test_explorer_device.dart';
/// 실제 사용자 휴지통을 쓰는 시험은 이 값이 1 일 때만 (휴지통 쪽 코드를 고칠 때 · 릴리스 전에 따로 돌리고 흔적 0 확인). /// 실제 사용자 휴지통을 쓰는 시험은 이 값이 1 일 때만 (휴지통 쪽 코드를 고칠 때 · 릴리스 전에 따로 돌리고 흔적 0 확인).
/// 기본 전체 시험은 [FakeRecycleBin] 으로 흐름만 본다 - 시험이 도중에 끊기면 사용자 휴지통에 흔적이 남기 때문. /// 기본 전체 시험은 [FakeRecycleBin] 으로 흐름만 본다 - 시험이 도중에 끊기면 사용자 휴지통에 흔적이 남기 때문.
bool get realRecycleBin => Platform.environment['JJ_TEST_REAL_RECYCLE'] == '1'; bool get realRecycleBin => Platform.environment['JJ_TEST_REAL_RECYCLE'] == '1';
@ -16,13 +18,14 @@ Object get realRecycleSkip => !Platform.isWindows
? false ? false
: '실제 휴지통 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만'; : '실제 휴지통 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만';
/// 시험 하나 동안 휴지통을 가짜로 (JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 그대로). 가짜 휴지통을 돌려준다 (실제면 null). /// 시험 하나 동안 [device] 의 휴지통을 시험 폴더 안의 것으로 (JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 그대로).
FakeRecycleBin? useTestRecycleBin() { /// 그 휴지통을 돌려준다 (실제면 null).
FakeRecycleBin? useTestRecycleBin(TestExplorerDevice device) {
if (realRecycleBin) return null; if (realRecycleBin) return null;
final fake = FakeRecycleBin(); final fake = FakeRecycleBin();
recycleBinForTest = fake; device.bin = fake;
addTearDown(() { addTearDown(() {
recycleBinForTest = null; device.bin = null;
fake.dispose(); fake.dispose();
}); });
return fake; return fake;
@ -43,6 +46,8 @@ class FakeRecycleBin implements RecycleBinBackend {
final long = longPathInside(path); final long = longPathInside(path);
if (long != null) throw LongPathException(recycleErrorText(0x7C), long); if (long != null) throw LongPathException(recycleErrorText(0x7C), long);
final full = File(path).absolute.path; 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)}'); final to = p.join(bin.path, '\$R${_n++}${p.extension(full)}');
_move(full, to); _move(full, to);
items[full] = to; items[full] = to;

View File

@ -1,6 +1,7 @@
import 'package:jj_mkvmaker/app/explorer_device.dart'; import 'package:jj_mkvmaker/app/explorer_device.dart';
import 'package:jj_mkvmaker/app/local_fs.dart'; import 'package:jj_mkvmaker/app/local_fs.dart';
import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry;
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend;
/// 시험용 [ExplorerDevice]: 정한 것만 바꾸고 나머지는 이 기기 그대로. /// 시험용 [ExplorerDevice]: 정한 것만 바꾸고 나머지는 이 기기 그대로.
/// [android] · [trash] · [nomediaOn] 은 Windows 에서 Android 의 갈래를 지나게 할 뿐이다 - 논리만 확인, Android 실제 확인은 노트 10+ /// [android] · [trash] · [nomediaOn] 은 Windows 에서 Android 의 갈래를 지나게 할 뿐이다 - 논리만 확인, Android 실제 확인은 노트 10+
@ -20,12 +21,17 @@ class TestExplorerDevice implements ExplorerDevice {
/// 남은 공간 · 전체 (null 이면 이 기기) - 캡처가 실행마다 같게 정한다 /// 남은 공간 · 전체 (null 이면 이 기기) - 캡처가 실행마다 같게 정한다
(int, int)? Function(String path)? space; (int, int)? Function(String path)? space;
/// Windows 휴지통 (null 이면 이 기기의 휴지통 - 시험은 useTestRecycleBin 으로 시험 폴더 안의 것)
RecycleBinBackend? bin;
/// 경로 입력 SMB 안내 · 앱 휴지통 · .nomedia 메뉴 (null 이면 이 기기) /// 경로 입력 SMB 안내 · 앱 휴지통 · .nomedia 메뉴 (null 이면 이 기기)
bool? android, trash, nomediaOn; bool? android, trash, nomediaOn;
@override @override
LocalFs get fs => _TestFs(this); LocalFs get fs => _TestFs(this);
@override @override
RecycleBinBackend get recycleBin => bin ?? _real.recycleBin;
@override
Future<(int, int)?> diskSpace(String path) async => space != null ? space!(path) : await _real.diskSpace(path); Future<(int, int)?> diskSpace(String path) async => space != null ? space!(path) : await _real.diskSpace(path);
@override @override
bool get androidPaths => android ?? _real.androidPaths; bool get androidPaths => android ?? _real.androidPaths;