From f33a4b35474b1e24643e389745dd6ca348e7d63e Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:09:36 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9C=B4=EC=A7=80=ED=86=B5=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98:=20=EA=B8=B0=EB=B3=B8=EC=9D=80=20=EA=B0=80=EC=A7=9C?= =?UTF-8?q?=20=ED=9C=B4=EC=A7=80=ED=86=B5,=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=ED=9C=B4=EC=A7=80=ED=86=B5=EC=9D=80=20JJ=5FTEST=5FREAL=5FRECYC?= =?UTF-8?q?LE=3D1=20=EC=9D=BC=20=EB=95=8C=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 시험 도중에 끊기면 사용자 휴지통에 시험 흔적이 남았다 (10/10 01:31). - 탐색기는 recycle() · restoreRecycled() 를 쓴다 (앱은 늘 실제 휴지통, 시험만 recycleBinForTest 로 바꿈). - test/support/fake_recycle_bin.dart: 실제와 같은 규칙 (긴 경로 거부 · 되돌릴 때 덮지 않음 · 원래 폴더 없으면 안 만듦), 다른 드라이브면 복사 후 지움. useTestRecycleBin() 은 JJ_TEST_REAL_RECYCLE=1 이 아니면 가짜로. - recycle_bin_test 의 실제 휴지통 시험 3개와 긴 경로의 실제 함수 부르기는 실제 모드에서만. explorer_conflict · explorer · 캡처 시험 (b3 · check) 은 기본 가짜. - windows_check_shot_test 의 "65 · 94 · 98" 은 144 뒤로 깨져 있었다 ([그대로 두기] 창을 닫지 않아 다음 크기에서 막힘) - 닫게 고침. Co-Authored-By: Claude Opus 5.5 --- integration_test/windows_b3_shot_test.dart | 3 + integration_test/windows_check_shot_test.dart | 11 +++ lib/platform/windows/recycle_bin.dart | 21 ++++ lib/ui/explorer_page.dart | 4 +- test/explorer_conflict_test.dart | 7 +- test/explorer_test.dart | 3 + test/recycle_bin_test.dart | 24 +++-- test/support/fake_recycle_bin.dart | 98 +++++++++++++++++++ 8 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 test/support/fake_recycle_bin.dart diff --git a/integration_test/windows_b3_shot_test.dart b/integration_test/windows_b3_shot_test.dart index d0bc981..f6c9399 100644 --- a/integration_test/windows_b3_shot_test.dart +++ b/integration_test/windows_b3_shot_test.dart @@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; +import '../test/support/fake_recycle_bin.dart'; import '../test/support/recycle_leftovers.dart'; /// 묶음 3 (데이터 쪽) 기기 감독 확인용 (Windows): 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다. @@ -77,6 +78,8 @@ void main() { ..writeAsStringSync('old'); c = AppController(PlatformServices.create()); c.settings.explorerPaths = [left, right]; + // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 + useTestRecycleBin(); }); tearDown(() async { ExplorerPage.debugVolumes = null; diff --git a/integration_test/windows_check_shot_test.dart b/integration_test/windows_check_shot_test.dart index 497b96d..d9cfb70 100644 --- a/integration_test/windows_check_shot_test.dart +++ b/integration_test/windows_check_shot_test.dart @@ -19,6 +19,8 @@ import 'package:jj_mkvmaker/ui/security_settings.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; +import '../test/support/fake_recycle_bin.dart'; + /// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다. /// 65 · 94 · 98 (휴지통 · 휴지통 없는 위치 · 오류 문구), 37 (경로 입력), 124 · 130 (마스터 · 최상), 123 (AI 그림). /// 시험 폴더 · 메모리 저장소만 쓴다 (사용자 설정 · 비밀번호는 건드리지 않음). 휴지통에 넣은 시험 항목은 끝에 $I 로 골라 지운다. @@ -78,6 +80,8 @@ void main() { testWidgets('65 · 94 · 98: 지우기 - 휴지통 · 영구 삭제 · 휴지통 없는 위치 · 오류 문구', (t) async { final tmp = tempDir('jj_shot_del_'); final started = DateTime.now(); + // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 (끝에 시험 항목만 지움) + useTestRecycleBin(); try { for (final (tag, size) in sizes) { final dir = Directory(p.join(tmp.path, tag))..createSync(); @@ -151,6 +155,13 @@ void main() { await t.tap(find.text('휴지통으로').last); await settle(t, 20); await shot(t, '98_error_text_$tag'); + // 144 뒤로는 이유 한 줄과 [그대로 두기] / [영구 삭제] 창이 뜬다 - 지우지 않고 닫아 다음 크기로 + for (final b in ['그대로 두기', '확인']) { + if (find.text(b).evaluate().isNotEmpty) { + await t.tap(find.text(b).last); + await settle(t); + } + } } } finally { await t.pumpWidget(const SizedBox()); diff --git a/lib/platform/windows/recycle_bin.dart b/lib/platform/windows/recycle_bin.dart index 179905f..db45712 100644 --- a/lib/platform/windows/recycle_bin.dart +++ b/lib/platform/windows/recycle_bin.dart @@ -2,6 +2,7 @@ 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'; @@ -18,6 +19,26 @@ enum RecycleResult { cancelled, } +/// 시험용: 실제 휴지통 대신 쓸 것. 기본 전체 시험은 가짜로 흐름만 본다 - 시험이 도중에 끊기면 사용자 휴지통에 +/// 흔적이 남기 때문 (실제 휴지통 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만, 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); +} + /// 이 경로가 있는 곳에 휴지통이 있는지 (94). Windows 의 휴지통은 고정 디스크에만 있다: /// 네트워크 드라이브 (Z:) · \\NAS\공유 · USB 메모리 · CD 는 휴지통 없이 바로 지워진다. bool hasRecycleBin(String path) { diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index f9427c6..5b8bc19 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1428,7 +1428,7 @@ class _ExplorerPageState extends State { for (final x in paths) { try { if (!permanent) { - switch (moveToRecycleBin(x)) { + switch (recycle(x)) { case RecycleResult.recycled: recycled++; sent.add(x); @@ -1530,7 +1530,7 @@ class _ExplorerPageState extends State { var back = 0; for (final x in paths) { try { - restoreFromRecycleBin(x, since: since); + restoreRecycled(x, since: since); back++; } catch (e) { failed.add('${vBasename(x)}: ${e is FileSystemException ? e.message : e}'); diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index 4a18c8a..e99c24d 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -10,6 +10,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:path/path.dart' as p; +import 'support/fake_recycle_bin.dart'; import 'support/recycle_leftovers.dart'; /// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다 @@ -17,6 +18,7 @@ void main() { late Directory tmp; late String left, right; late AppController c; + FakeRecycleBin? bin; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_conflict_'); @@ -32,10 +34,12 @@ void main() { c.settings.explorerPaths = [left, right]; final root = p.rootPrefix(tmp.path); ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; + // 휴지통: 기본은 가짜 (흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 + bin = useTestRecycleBin(); }); tearDown(() async { ExplorerPage.debugVolumes = null; - // 휴지통을 건드리는 시험 (148 · 144): 사용자 휴지통에 이 시험 폴더 아래의 흔적이 없는지 스스로 확인 + // 사용자 휴지통에 이 시험 폴더 아래의 흔적이 없는지 스스로 확인 (가짜일 때도 - 실제 휴지통에 닿지 않았는지) expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)'); tmp.deleteSync(recursive: true); }); @@ -166,6 +170,7 @@ void main() { await settle(tester, () => find.textContaining('되돌렸습니다').evaluate().isNotEmpty); expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'new'); expect(find.textContaining('1개 항목을 되돌렸습니다.'), findsOneWidget); + if (bin != null) expect(bin!.items, isEmpty, reason: '가짜 휴지통에서 되돌림'); // 알림 (10초) 이 시험이 끝난 뒤에 닫히지 않게 정리 ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); await tester.pumpAndSettle(); diff --git a/test/explorer_test.dart b/test/explorer_test.dart index d4c0888..6f75291 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:path/path.dart' as p; +import 'support/fake_recycle_bin.dart'; /// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게) class _Shell extends NoopShell { @@ -29,6 +30,8 @@ void main() { late AppController c; setUp(() { + // 휴지통: 기본은 가짜 (이 시험들은 영구 삭제를 고르지만 혹시라도 사용자 휴지통에 닿지 않게) + useTestRecycleBin(); tmp = Directory.systemTemp.createTempSync('jj_explorer_'); left = p.join(tmp.path, 'left'); right = p.join(tmp.path, 'right'); diff --git a/test/recycle_bin_test.dart b/test/recycle_bin_test.dart index d24e7d7..2d07194 100644 --- a/test/recycle_bin_test.dart +++ b/test/recycle_bin_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart'; import 'package:path/path.dart' as p; +import 'support/fake_recycle_bin.dart'; import 'support/recycle_leftovers.dart'; /// 시험이 휴지통에 넣은 것만 (원래 위치가 이 시험의 임시 폴더인 항목) 휴지통에서 지운다. 사용자 항목은 건드리지 않는다. @@ -30,7 +31,9 @@ foreach (\$it in @(\$rb.Items())) { return int.tryParse('${r.stdout}'.trim().split('\n').last.trim()) ?? -1; } -/// 65 · 94 · 98: Windows 휴지통 (실제 휴지통 - 시험 파일 하나 · 폴더 하나, 끝나면 휴지통에서도 지움) +/// 65 · 94 · 98: Windows 휴지통 (실제 휴지통 - 시험 파일 하나 · 폴더 하나, 끝나면 휴지통에서도 지움). +/// 실제 휴지통을 쓰는 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만 (전체 시험 중에 끊기면 사용자 휴지통에 흔적이 남으므로). +/// 휴지통 쪽 코드를 고칠 때 · 릴리스 전에 따로: JJ_TEST_REAL_RECYCLE=1 flutter test test/recycle_bin_test.dart void main() { test('파일 · 폴더를 휴지통으로 (실제로 들어갔는지까지) · 없는 것은 그냥 성공', () async { final tmp = Directory.systemTemp.createTempSync('jj_recycle_'); @@ -54,7 +57,7 @@ void main() { // 사용자 휴지통에 시험 항목을 남기지 않는다 expect(await _purgeFromRecycleBin(tmp.absolute.path), 2); } - }, skip: !Platform.isWindows); + }, skip: realRecycleSkip); test('148: 휴지통으로 보낸 파일 · 폴더를 원래 자리로 되돌린다 (휴지통에 남지 않음) · 원래 자리에 같은 이름이 있으면 덮지 않음', () async { final tmp = Directory.systemTemp.createTempSync('jj_undo_'); @@ -83,7 +86,7 @@ void main() { expect(recycledInfoExists(f.path, since: since), isTrue); // 휴지통에 없으면 실패 expect(() => restoreFromRecycleBin(p.join(tmp.absolute.path, 'never.txt'), since: since), throwsA(isA())); - }, skip: !Platform.isWindows); + }, skip: realRecycleSkip); test('148: 원래 폴더가 그사이 지워졌으면 다시 만들지 않고 알린다 · 이 사용자 (SID) 의 휴지통에서만 찾는다', () async { expect(currentUserSid(), matches(RegExp(r'^S-1-5-'))); @@ -105,7 +108,7 @@ void main() { .having((e) => e.message, 'message', contains('원래 폴더가 없어 되돌리지 못했습니다')))); expect(folder.existsSync(), isFalse, reason: '지운 폴더를 몰래 다시 만들지 않음'); expect(recycledInfoExists(f.path, since: since), isTrue, reason: '휴지통의 것은 그대로'); - }, skip: !Platform.isWindows); + }, skip: realRecycleSkip); test('긴 경로 (260자 넘음) 는 휴지통으로 보내지 않고 이유를 알린다 - 묻지 않고 영구 삭제되던 것 (폴더 안에 있어도)', () { if (!Platform.isWindows) return; @@ -122,13 +125,18 @@ void main() { File(lp(file)).writeAsStringSync('x'); expect(file.length, greaterThan(259)); expect(longPathInside(file), file); - expect(() => moveToRecycleBin(file), throwsA(isA().having((e) => e.message, 'message', contains('경로가 너무 깁니다')))); - expect(File(lp(file)).existsSync(), isTrue, reason: '지우지 않음'); + // 실제 휴지통 함수는 JJ_TEST_REAL_RECYCLE=1 일 때만 (고장 나면 사용자 휴지통에 들어가므로) + if (realRecycleBin) { + expect(() => moveToRecycleBin(file), throwsA(isA().having((e) => e.message, 'message', contains('경로가 너무 깁니다')))); + expect(File(lp(file)).existsSync(), isTrue, reason: '지우지 않음'); + } // 폴더 자체는 짧아도 안에 긴 경로가 있으면 final outer = p.join(tmp.path, 'outer'); expect(longPathInside(outer), isNotNull); - expect(() => moveToRecycleBin(outer), throwsA(isA())); - expect(Directory(outer).existsSync(), isTrue); + if (realRecycleBin) { + expect(() => moveToRecycleBin(outer), throwsA(isA())); + expect(Directory(outer).existsSync(), isTrue); + } final short = File(p.join(tmp.path, 'short.txt'))..writeAsStringSync('x'); expect(longPathInside(short.path), isNull, reason: '짧은 파일 경로'); }); diff --git a/test/support/fake_recycle_bin.dart b/test/support/fake_recycle_bin.dart new file mode 100644 index 0000000..0d250d6 --- /dev/null +++ b/test/support/fake_recycle_bin.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart'; +import 'package:path/path.dart' as p; + +/// 실제 사용자 휴지통을 쓰는 시험은 이 값이 1 일 때만 (휴지통 쪽 코드를 고칠 때 · 릴리스 전에 따로 돌리고 흔적 0 확인). +/// 기본 전체 시험은 [FakeRecycleBin] 으로 흐름만 본다 - 시험이 도중에 끊기면 사용자 휴지통에 흔적이 남기 때문. +bool get realRecycleBin => Platform.environment['JJ_TEST_REAL_RECYCLE'] == '1'; + +/// 실제 휴지통 시험을 건너뛸 때의 이유 (skip 에 그대로) +Object get realRecycleSkip => !Platform.isWindows + ? 'Windows 만' + : realRecycleBin + ? false + : '실제 휴지통 시험은 JJ_TEST_REAL_RECYCLE=1 일 때만'; + +/// 시험 하나 동안 휴지통을 가짜로 (JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 그대로). 가짜 휴지통을 돌려준다 (실제면 null). +FakeRecycleBin? useTestRecycleBin() { + if (realRecycleBin) return null; + final fake = FakeRecycleBin(); + recycleBinForTest = fake; + addTearDown(() { + recycleBinForTest = null; + fake.dispose(); + }); + return fake; +} + +/// 시험용 휴지통: 시험 임시 폴더 안의 자기 폴더로 옮기고, 실제 휴지통과 같은 규칙으로 되돌린다 +/// (긴 경로는 받지 않음 · 원래 자리에 같은 이름이 있으면 덮지 않음 · 원래 폴더가 없으면 다시 만들지 않음). +class FakeRecycleBin implements RecycleBinBackend { + final Directory bin = Directory.systemTemp.createTempSync('jj_fake_recycle_'); + + /// 원래 경로 → 휴지통 안의 경로 (보낸 차례대로) + final items = {}; + var _n = 0; + + @override + RecycleResult move(String path) { + if (FileSystemEntity.typeSync(path, followLinks: false) == FileSystemEntityType.notFound) return RecycleResult.recycled; + final long = longPathInside(path); + if (long != null) throw LongPathException(recycleErrorText(0x7C), long); + final full = File(path).absolute.path; + final to = p.join(bin.path, '\$R${_n++}${p.extension(full)}'); + _move(full, to); + items[full] = to; + return RecycleResult.recycled; + } + + @override + void restore(String original, {required DateTime since}) { + final from = items[File(original).absolute.path]; + if (from == null) throw FileSystemException(tr('휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)'), original); + if (FileSystemEntity.typeSync(original, followLinks: false) != FileSystemEntityType.notFound) { + throw FileSystemException(tr('원래 자리에 같은 이름이 이미 있습니다'), original); + } + if (!Directory(p.dirname(original)).existsSync()) { + throw FileSystemException(tr('원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)'), original); + } + _move(from, original); + items.remove(File(original).absolute.path); + } + + /// 옮기기 (다른 드라이브면 복사한 뒤 지운다 - 시험 폴더가 M: 이고 가짜 휴지통이 C: 의 임시 폴더일 때) + static void _move(String from, String to) { + final dir = FileSystemEntity.isDirectorySync(from); + try { + dir ? Directory(from).renameSync(to) : File(from).renameSync(to); + return; + } on FileSystemException { + // 아래에서 복사 + } + if (!dir) { + File(from).copySync(to); + File(from).deleteSync(); + return; + } + Directory(to).createSync(recursive: true); + for (final e in Directory(from).listSync(recursive: true, followLinks: false)) { + final dst = p.join(to, p.relative(e.path, from: from)); + if (e is Directory) { + Directory(dst).createSync(recursive: true); + } else if (e is File) { + File(dst).parent.createSync(recursive: true); + e.copySync(dst); + } + } + Directory(from).deleteSync(recursive: true); + } + + void dispose() { + try { + bin.deleteSync(recursive: true); + } catch (_) {} + } +}