From fad723355f15cadd25879bb61ca8e723f35055c2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:21:38 +0900 Subject: [PATCH] =?UTF-8?q?E5e:=20explorer=5Ftest=20(2,009)=20=EB=A5=BC=20?= =?UTF-8?q?6=EA=B0=9C=EB=A1=9C=20=EB=82=98=EB=88=94=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EC=A4=80=EB=B9=84=20explorer=5Fharness=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=EC=9A=A9=20=EC=9D=B4=EB=A6=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=95=BD=EC=86=8D=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?4=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test/support/explorer_harness.dart: 시험 폴더 · 셸 · open · act · settle · calm · doubleTap · markOf · chevronOf - explorer_panes · archive_trash · select_ops · filter_rsync · keys_drag · hidden_find (모두 600 이하) - 다른 시험 8개는 explorer_test.dart 대신 harness 를 부름 - lib: ExplorerPage.checkPathForTest · debugOpenTrash 지움 (시험은 checkEnteredPath · 실제 [휴지통] 버튼) - 약속 시험: test/explorer_more_flows_contract_test.dart (explorer_body · search_flow · tools_flow · rsync_flow) - 시험 이름 1,196 개 나누기 전후 같음, 전체 단위 1,357 (= 1,353 + 4) 2회 통과 Co-Authored-By: Claude Opus 5.5 --- lib/ui/explorer_page.dart | 10 - test/dismiss_test.dart | 2 +- test/explorer_archive_trash_test.dart | 194 ++ test/explorer_conflict_test.dart | 5 +- test/explorer_drives_test.dart | 2 +- test/explorer_filter_rsync_test.dart | 389 ++++ test/explorer_hidden_find_test.dart | 304 +++ test/explorer_keys_drag_test.dart | 379 ++++ test/explorer_more_flows_contract_test.dart | 225 +++ test/explorer_panes_test.dart | 255 +++ test/explorer_parallel_test.dart | 2 +- test/explorer_select_ops_test.dart | 447 +++++ test/explorer_steady_test.dart | 2 +- test/explorer_test.dart | 2009 ------------------- test/explorer_webdav_test.dart | 2 +- test/rsync_safety_test.dart | 2 +- test/support/explorer_harness.dart | 140 ++ test/sync_problem_test.dart | 2 +- 18 files changed, 2343 insertions(+), 2028 deletions(-) create mode 100644 test/explorer_archive_trash_test.dart create mode 100644 test/explorer_filter_rsync_test.dart create mode 100644 test/explorer_hidden_find_test.dart create mode 100644 test/explorer_keys_drag_test.dart create mode 100644 test/explorer_more_flows_contract_test.dart create mode 100644 test/explorer_panes_test.dart create mode 100644 test/explorer_select_ops_test.dart delete mode 100644 test/explorer_test.dart create mode 100644 test/support/explorer_harness.dart diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 902a740..4d0c1a7 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -21,7 +21,6 @@ import 'explorer/explorer_host.dart'; import 'explorer/explorer_pane.dart'; import 'explorer/file_thumbs.dart'; import 'explorer/explorer_select.dart'; -import 'explorer/path_dialog.dart'; import 'explorer/pane_header.dart'; import 'explorer/pane_view.dart'; import 'explorer/explorer_buttons.dart'; @@ -89,11 +88,6 @@ class ExplorerPage extends StatefulWidget { static bool get rootWarnedThisRun => ExplorerAccess.rootWarned; static set rootWarnedThisRun(bool v) => ExplorerAccess.rootWarned = v; - /// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유) - @visibleForTesting - static Future<(String?, String?)> checkPathForTest(String input) => - checkEnteredPath(input, android: debugAndroidPaths ?? Platform.isAndroid); - static const routeName = 'explorer'; static const rsyncRouteName = 'rsync'; static int _open = 0, _openRsync = 0; @@ -335,10 +329,6 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex @override String? get otherDir => _layout.other?.current; - /// 시험: [휴지통] 열기 (E5e 에서 지움) - @visibleForTesting - Future debugOpenTrash() => _flows.deletes.openTrash(context); - /// 줄 메뉴 (E5d-5) late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this, _flows, nomedia: () => ExplorerPage.debugNomedia ?? Platform.isAndroid); diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index 7fc1807..9e1362f 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -17,7 +17,7 @@ import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:jj_mkvmaker/ui/sync_preview_view.dart'; import 'package:path/path.dart' as p; -import 'explorer_test.dart' show act, settle; +import 'support/explorer_harness.dart' show act, settle; /// 110: 확인 창은 뒤로 가기 · 바깥 누르기 · [취소] 로 닫으면 아무것도 하지 않는다 ([실행] · [지우기] 등만 실행) void main() { diff --git a/test/explorer_archive_trash_test.dart b/test/explorer_archive_trash_test.dart new file mode 100644 index 0000000..757f1f7 --- /dev/null +++ b/test/explorer_archive_trash_test.dart @@ -0,0 +1,194 @@ +import 'dart:io'; + +import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/explorer_grid.dart'; +import 'package:jj_mkvmaker/ui/text_viewer.dart'; +import 'package:path/path.dart' as p; +import 'support/explorer_clock_keys.dart'; +import 'support/explorer_harness.dart'; + +/// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 압축을 폴더처럼 · 격자 · 글 뷰어 · 앱 휴지통 · 원본 지우기 옵션 알림 · 바로 실행 +void main() { + useExplorerFixture(); + + testWidgets('X-plore f: 압축을 트리 안에서 폴더처럼 펼치기 · 안은 읽기 전용 · 복사는 꺼내기', (tester) async { + final zip = p.join(left, 'pack.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive() + ..addFile(ArchiveFile.string('top.txt', 'top')) + ..addFile(ArchiveFile.string('a/b.txt', 'bee')))); + await open(tester); + await settle(tester, () => find.text('pack.zip').evaluate().isNotEmpty); + // › 로 펼치면 압축 안의 것이 폴더처럼 (목록에 폴더 이름이 없어도 경로에서 폴더를 만든다) + await act(tester, () => tester.tap(chevronOf('pack.zip').first)); + await settle(tester, () => find.text('top.txt').evaluate().isNotEmpty); + expect(find.text('a'), findsOneWidget); + await act(tester, () => tester.tap(chevronOf('a').first)); + await settle(tester, () => find.text('b.txt').evaluate().isNotEmpty); + expect(find.text('b.txt'), findsOneWidget); + // 안의 것을 지우려 하면 막는다 (압축은 그대로) + await act(tester, () => tester.tap(find.text('top.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await settle(tester, () => find.textContaining('읽기 전용').evaluate().isNotEmpty); + expect(find.textContaining('압축 안은 읽기 전용'), findsOneWidget); + expect(File(zip).existsSync(), isTrue); + expect(Directory(p.join(left, 'pack')).existsSync(), isFalse, reason: '풀지 않음'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 1)); + // 압축 안의 폴더를 펼쳐 지금 폴더가 압축 안이면 [새 폴더] 도 막는다 (이름을 묻지 않음) + await act(tester, () => tester.tap(find.text('a'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('새 폴더').first)); + await settle(tester, () => find.textContaining('읽기 전용').evaluate().isNotEmpty); + expect(find.textContaining('압축 안은 읽기 전용'), findsOneWidget); + expect(find.byType(TextField), findsNothing, reason: '이름 묻는 창 없음'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 1)); + // 데이터 검토 (10/18): 압축 안의 것 메뉴에는 [ZIP 으로 묶기] · [Wi-Fi 로 공유] 가 없다 (실제 파일이 아니라서). 압축 밖은 그대로 + await tester.longPress(find.text('a')); + await calm(tester); + expect(find.text('ZIP 으로 묶기'), findsNothing); + expect(find.text('Wi-Fi 로 공유'), findsNothing); + await tester.tapAt(const Offset(5, 5)); + await calm(tester); + await tester.longPress(find.text('sub')); + await calm(tester); + expect(find.text('ZIP 으로 묶기'), findsOneWidget); + await tester.tapAt(const Offset(5, 5)); + await calm(tester); + }); + + testWidgets('X-plore f 보충: 펼친 뒤 압축 파일이 바뀌면 새로 고칠 때 목록을 다시 읽는다 (관리자 지시 10/19)', (tester) async { + final zip = p.join(left, 'pack.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()..addFile(ArchiveFile.string('old.txt', 'old')))); + await open(tester); + await settle(tester, () => find.text('pack.zip').evaluate().isNotEmpty); + await act(tester, () => tester.tap(chevronOf('pack.zip').first)); + await settle(tester, () => find.text('old.txt').evaluate().isNotEmpty); + expect(find.text('old.txt'), findsOneWidget); + // 다른 프로그램이 압축을 바꿈 (크기 · 바뀐 때가 달라짐) + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 1100)); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive() + ..addFile(ArchiveFile.string('new.txt', 'new content')) + ..addFile(ArchiveFile.string('more.txt', 'more')))); + }); + await act(tester, () => refreshKey(tester)); + await settle(tester, () => find.text('new.txt').evaluate().isNotEmpty); + expect(find.text('new.txt'), findsOneWidget); + expect(find.text('more.txt'), findsOneWidget); + expect(find.text('old.txt'), findsNothing); + }); + + testWidgets('X-plore g · h: 폴더 메뉴 [격자로 보기] (길게 = 그 자리로) · 파일 메뉴 [글 뷰어로 보기]', (tester) async { + File(p.join(left, 'sub', 'pic.png')).writeAsBytesSync([0x89, 0x50]); + await open(tester); + // g: 폴더를 길게 → 격자로 보기 → 그림만 (글 파일은 빠짐) + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.text('격자로 보기 (그림 · 동영상)'))); + await settle(tester, () => find.byType(ExplorerGridPage).evaluate().isNotEmpty); + expect(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('pic.png')), findsOneWidget); + expect(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('inner.txt')), findsNothing); + // 칸을 길게 누르면 탐색기에서 그 자리로 (격자 닫힘) + await tester.longPress(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('pic.png'))); + await settle(tester, () => find.byType(ExplorerGridPage).evaluate().isEmpty && find.descendant(of: find.byType(ExplorerPage), matching: find.text('pic.png')).evaluate().isNotEmpty); + expect(find.descendant(of: find.byType(ExplorerPage), matching: find.text('pic.png')), findsOneWidget, reason: '탐색기에 그 폴더가 펼쳐져 보임 (길게 누른 칸의 이름 풍선은 빼고)'); + // h: 파일을 길게 → 글 뷰어로 보기 (읽기 전용) + await tester.longPress(find.text('doc.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.text('글 뷰어로 보기'))); + await settle(tester, () => find.text('hello').evaluate().isNotEmpty); + expect(find.byType(TextViewerPage), findsOneWidget); + expect(shell.opened, isEmpty, reason: '다른 앱으로 열지 않음'); + await act(tester, () async => Navigator.of(tester.element(find.byType(TextViewerPage))).pop()); + await settle(tester, () => find.byType(TextViewerPage).evaluate().isEmpty); + }); + + testWidgets('X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기', (tester) async { + // 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게) + ExplorerPage.debugAndroidTrash = true; + ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; + // [휴지통] 버튼이 줄에 보이게 (처음 구성은 좁으면 [더 보기] 로 간다) + c.settings.explorerButtons = ['trash', for (final b in ExplorerButton.defaultOrder) if (b != ExplorerButton.trash) b.name]; + addTearDown(() { + ExplorerPage.debugAndroidTrash = null; + ExplorerPage.debugVolumes = null; + }); + await open(tester); + final doc = p.join(left, 'doc.txt'); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await calm(tester); + expect(find.textContaining('앱 휴지통에 14일'), findsOneWidget); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(tester, () => !File(doc).existsSync() && find.text('되돌리기').evaluate().isNotEmpty); + expect(File(doc).existsSync(), isFalse); + expect(Directory(p.join(tmp.path, '.jj_trash')).existsSync(), isTrue); + // [되돌리기] + await act(tester, () => tester.tap(find.text('되돌리기'))); + await settle(tester, () => File(doc).existsSync()); + expect(File(doc).readAsStringSync(), 'hello'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + // 다시 지우고 [휴지통] 화면에서 되살리기 + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(tester, () => !File(doc).existsSync()); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + // [휴지통] 버튼으로 휴지통 화면을 연다 + await act(tester, () => tester.tap(find.text('휴지통').first)); + await settle(tester, () => find.byType(CheckboxListTile).evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byType(CheckboxListTile).first)); + await settle(tester, () => find.text('되살리기').hitTestable().evaluate().isNotEmpty); // 화면 넘김이 끝나 눌릴 때까지 + await act(tester, () => tester.tap(find.text('되살리기'))); + await settle(tester, () => File(doc).existsSync()); + expect(File(doc).readAsStringSync(), 'hello'); + await tester.pageBack(); + await calm(tester); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async { + c.settings + ..copyMethodFile = 'robocopy' + ..robocopyOptions = '/E /MOV'; + await open(tester); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await act(tester, () => tester.tap(find.text('복사').first)); + await calm(tester); + expect(find.textContaining('방법: robocopy'), findsOneWidget); + expect(find.text('환경 설정의 /MOV 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)'), findsOneWidget); + await act(tester, () => tester.tap(find.text('취소').last)); + await calm(tester); + await act(tester, () => tester.tap(find.text('이동').first)); + await calm(tester); + expect(find.textContaining('옮길까요?'), findsOneWidget); + expect(find.textContaining('복사에서는 쓰지 않습니다'), findsNothing); + await act(tester, () => tester.tap(find.text('취소').last)); + await calm(tester); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); + expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse); + }, skip: !Platform.isWindows); + + testWidgets('바로 실행 모드: 폴더를 누르면 펼치고, 파일을 누르면 바로 실행', (tester) async { + c.settings.explorerClick = 'open'; + await open(tester); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => shell.opened.isNotEmpty); + expect(shell.opened.single, (p.join(left, 'doc.txt'), false)); + }); +} diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index 5963214..5e666ee 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/app/app_controller.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/explorer/path_dialog.dart' show checkEnteredPath; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:path/path.dart' as p; @@ -14,7 +15,7 @@ import 'support/fake_recycle_bin.dart'; import 'support/recycle_leftovers.dart'; import 'support/elevated.dart'; -import 'explorer_test.dart' show calm; +import 'support/explorer_harness.dart' show calm; /// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다 void main() { late Directory tmp; @@ -448,7 +449,7 @@ void main() { expect(smbToUnc(r'\\nas\share'), isNull); expect(smbToUnc('D:/x'), isNull); if (!Platform.isWindows) return; - final (path, error) = await ExplorerPage.checkPathForTest('smb://localhost/jj_no_such_share_190'); + final (path, error) = await checkEnteredPath('smb://localhost/jj_no_such_share_190', android: false); expect(path, isNull); expect(error, contains(r'\\localhost\jj_no_such_share_190')); expect(error, contains('형식')); diff --git a/test/explorer_drives_test.dart b/test/explorer_drives_test.dart index 0008268..7f14e11 100644 --- a/test/explorer_drives_test.dart +++ b/test/explorer_drives_test.dart @@ -12,7 +12,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 'explorer_test.dart' show calm; +import 'support/explorer_harness.dart' show calm; void main() { late Directory tmp; late AppController c; diff --git a/test/explorer_filter_rsync_test.dart b/test/explorer_filter_rsync_test.dart new file mode 100644 index 0000000..abd9d1d --- /dev/null +++ b/test/explorer_filter_rsync_test.dart @@ -0,0 +1,389 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/explorer_search.dart'; +import 'package:jj_mkvmaker/ui/path_label.dart'; +import 'package:path/path.dart' as p; +import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; +import 'support/explorer_harness.dart'; + +/// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 빠른 필터 · 여러 개 지우기 · 새 폴더 · 이름 변경 · 창 배치 · 버튼 구성 · Rsync 화면 · 일괄 이름 · 폴더 + 안의 파일 +void main() { + useExplorerFixture(); + + testWidgets('76: 빠른 필터 - Ctrl+F · 글자 바로 치기 · 패턴, 모두 선택은 보이는 것만, Esc 로 닫으면 모두', (tester) async { + for (final n in ['ep01.mkv', 'ep02.mkv', 'ep10.srt', 'other.txt']) { + File(p.join(left, n)).writeAsStringSync('12345678'); + } + await open(tester); + await settle(tester, () => find.text('other.txt').evaluate().isNotEmpty); + Iterable names() => [ + for (final n in ['doc.txt', 'sub', 'ep01.mkv', 'ep02.mkv', 'ep10.srt', 'other.txt']) + if (find.text(n).evaluate().isNotEmpty) n, + ]; + + // Ctrl+F → 칸 · "ep0" + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await calm(tester); + expect(find.textContaining('이름에 든 글자로 좁히기'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, 'EP0'); + await tester.pump(); + expect(names(), ['ep01.mkv', 'ep02.mkv'], reason: '대소문자 무시'); + expect(find.text('2개 보임'), findsOneWidget); + + // 모두 선택 · 반전은 보이는 것만 + await act(tester, () => tester.tap(find.text('선택').first)); + await tester.tap(find.text('모두 선택').first); + await tester.pump(); + expect(find.textContaining('2개 표시함'), findsOneWidget); + await tester.tap(find.text('선택 끝').first); + await tester.pump(); + + // 패턴 · 맞는 것 없음 + await tester.enterText(find.byType(TextField).first, '*.srt'); + await tester.pump(); + expect(names(), ['ep10.srt']); + await tester.enterText(find.byType(TextField).first, 'zzz'); + await tester.pump(); + expect(find.text('맞는 것이 없습니다'), findsOneWidget); + + // Esc → 닫고 모두 + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await calm(tester); + expect(find.textContaining('이름에 든 글자로 좁히기'), findsNothing); + expect(names(), containsAll(['doc.txt', 'sub', 'ep01.mkv', 'other.txt'])); + + // 닫으면 키는 탐색기로 돌아온다 - 목록에서 글자를 바로 치면 그 글자로 열린다. + // 사람처럼 줄을 누르고 곧바로 친다 (두 번 누르기를 기다리는 중에 줄 목록이 바뀜) + await tester.pump(); + await tester.tap(find.text('doc.txt').first); + await tester.pump(const Duration(milliseconds: 20)); + await tester.sendKeyEvent(LogicalKeyboardKey.keyO, character: 'o'); + await calm(tester); + expect(tester.widget(find.byType(TextField).first).controller!.text, 'o'); + expect(names(), ['doc.txt', 'other.txt']); + // 칸에서 더 치면 칸이 받는다 (두 번 들어가지 않음) + await tester.enterText(find.byType(TextField).first, 'oth'); + await tester.pump(); + expect(names(), ['other.txt']); + expect(tester.takeException(), isNull); + }); + + testWidgets('76: 파일 줄을 누르고 곧바로 쳐서 그 줄이 사라지거나 다른 줄이 그 자리에 와도, 누른 것이 다른 항목에 들어가지 않는다', (tester) async { + for (final n in ['a1.mkv', 'b2.mkv']) { + File(p.join(left, n)).writeAsStringSync('x'); + } + Directory(p.join(left, 'a_folder')).createSync(); + await open(tester); + await settle(tester, () => find.text('b2.mkv').evaluate().isNotEmpty); + // 누르기 = 선택 (두 번 = 실행) 모드: 파일 줄은 두 번 누르기를 기다린다 + await tester.tap(find.text('b2.mkv').first); + await tester.pump(const Duration(milliseconds: 20)); + // "a" → b2.mkv 가 사라지고 그 자리에 폴더 · 다른 파일이 온다 + await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a'); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pump(const Duration(milliseconds: 500)); + expect(tester.takeException(), isNull); + expect(find.text('b2.mkv'), findsNothing); + expect(shell.opened, isEmpty, reason: '누른 것이 실행되지 않음'); + // 다른 항목이 고른 것 (강조) 이 되지 않음 - 폴더가 펼쳐지지도 않음 + expect(find.text('a_folder'), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await calm(tester); + expect(find.text('b2.mkv'), findsOneWidget); + }); + + test('76: 빠른 필터 맞추기 - 빈 칸은 모두 · 글자는 이름 안 어디든 · * ? 는 이름 전체 패턴', () { + expect(quickFilterMatch('Show.S01E02.mkv', ''), isTrue); + expect(quickFilterMatch('Show.S01E02.mkv', 's01e'), isTrue); + expect(quickFilterMatch('Show.S01E02.mkv', 'e03'), isFalse); + expect(quickFilterMatch('Show.S01E02.mkv', '*.mkv'), isTrue); + expect(quickFilterMatch('Show.S01E02.mkv', 'S01*'), isFalse, reason: '패턴은 이름 전체'); + }); + + test('74: 패턴 (* · ? · 여러 개 · 대소문자 무시)', () { + final re = globToRegExp('*.srt; ep??.mkv'); + expect(['a.srt', 'A.SRT', 'ep01.mkv'].every(re.hasMatch), isTrue); + expect(['a.srt.bak', 'ep1.mkv', 'x.ass'].any(re.hasMatch), isFalse); + }); + + test('73: 즐겨찾기 저장 · 읽기 (Rsync 짝 포함)', () { + final s = AppSettings() + ..folderBookmarks = const [FolderBookmark(name: 'NAS/영화', path: r'\\nas\movies'), FolderBookmark(name: '백업', path: r'D:\a', right: r'E:\b')]; + final b = AppSettings.fromJson(s.toJson()).folderBookmarks; + expect([for (final x in b) (x.name, x.path, x.right, x.isPair)], [('NAS/영화', r'\\nas\movies', null, false), ('백업', r'D:\a', r'E:\b', true)]); + }); + + testWidgets('여러 개 표시 → 삭제 · 새 폴더 · 이름 변경', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('선택').first)); + // 동그라미로 doc.txt · sub 표시 (반드시 이름으로 찾은 줄의 버튼만: 목록에는 시험 폴더 위쪽의 실제 폴더도 보인다) + await act(tester, () => tester.tap(markOf('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); // 폴더를 고르면 펼쳐서 안의 것도 ✔ + await act(tester, () => tester.tap(markOf('doc.txt'))); + await settle(tester, () => find.textContaining('2개 표시함').evaluate().isNotEmpty); + expect(find.textContaining('2개 표시함'), findsOneWidget); + await act(tester, () => tester.tap(find.text('삭제').first)); + await calm(tester); + // 지울 항목 이름이 확인 창에 보인다. 65: Windows 는 기본이 휴지통 (시험에서는 영구 삭제를 골라 휴지통을 어지럽히지 않음) + expect(find.textContaining('2개 항목을 휴지통으로 보낼까요?'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '휴지통으로'), findsOneWidget); + expect(find.textContaining('· sub'), findsOneWidget); + expect(find.textContaining('· doc.txt'), findsOneWidget); + await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); + await calm(tester); + expect(find.textContaining('2개 항목을 지울까요? 되돌릴 수 없습니다.'), findsOneWidget); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); + await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); + expect(Directory(left).listSync(), isEmpty); + + // 새 폴더 → 이름 변경 (지운 뒤 창들이 목록을 다시 읽는 동안은 진행 막대가 돌아 pumpAndSettle 이 끝나지 않는다 - 무거울 때 흔들림) + await idle(tester); + await act(tester, () => tester.tap(find.text('새 폴더').first)); + await calm(tester); + await act(tester, () => tester.enterText(find.byType(TextField), 'films')); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); + await settle(tester, () => find.text('films').evaluate().isNotEmpty); + expect(Directory(p.join(left, 'films')).existsSync(), isTrue); + await idle(tester); + await act(tester, () => tester.tap(find.text('이름 변경').first)); + await calm(tester); + await act(tester, () => tester.enterText(find.byType(TextField), 'movies')); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); + await settle(tester, () => find.text('movies').evaluate().isNotEmpty); + expect(Directory(p.join(left, 'movies')).existsSync(), isTrue); + }); + + testWidgets('창 배치: 한 창 · 버튼 줄 숨김, 버튼 구성: 버튼 빼기 → 설정에 저장', (tester) async { + await open(tester); + await tester.tap(find.byTooltip('창 배치 · 버튼 구성')); + await calm(tester); + await tester.tap(find.text('창 배치').last); + await calm(tester); + await tester.tap(find.widgetWithText(ChoiceChip, '한 창')); + await tester.pump(); + await tester.tap(find.widgetWithText(ChoiceChip, '숨김')); + await tester.pump(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); + await calm(tester); + expect([c.settings.explorerLayout, c.settings.explorerToolbar], ['single', 'hidden']); + expect(find.text('상위 폴더'), findsNothing); // 버튼 줄 숨김 + expect(find.text('doc.txt'), findsOneWidget); // 한 창 (왼쪽만) + + // ⋮ 메뉴에서 버튼 구성: "찾기" 빼기 + await c.updateSettings((s) => s.explorerToolbar = 'middle'); + await calm(tester); + await tester.tap(find.byTooltip('창 배치 · 버튼 구성')); + await calm(tester); + await tester.tap(find.text('버튼 구성').last); + await calm(tester); + // 처음 값 순서 (자주 쓰는 것부터) 에서 뒤쪽이라 목록을 내려 보이게 한 뒤 + await tester.scrollUntilVisible(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가'), 80, + scrollable: find.descendant(of: find.byType(AlertDialog), matching: find.byType(Scrollable)).first); + await tester.tap(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '확인')); + await calm(tester); + expect(c.settings.explorerButtons, isNot(contains('addMkv'))); + expect(c.settings.explorerButtons, contains('copy')); + expect(find.text('MKV 목록에 추가'), findsNothing); + + // 설정 저장 · 읽기 + final back = AppSettings.fromJson(c.settings.toJson()); + expect([back.explorerLayout, back.explorerToolbar, back.explorerButtons], + [c.settings.explorerLayout, c.settings.explorerToolbar, c.settings.explorerButtons]); + }); + + testWidgets('Rsync 화면: 늘 좌우 두 창 · 폴더만 · 창마다 하나만 고르기 · → ← ⇄ · 모니터링', (tester) async { + Directory(p.join(left, 'sub2')).createSync(); + Directory(p.join(right, 'rsub')).createSync(); + File(p.join(right, 'r.txt')).writeAsStringSync('r'); + File(p.join(right, 'rsub', 'back.txt')).writeAsStringSync('b'); // ⇄ 비교에서 ← 로 건너갈 것 + c.settings + ..explorerLayout = 'single' // 파일 탐색기 배치와 상관없이 두 창 + ..rsyncPaths = [left, right]; + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); + await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('rsub').evaluate().isNotEmpty); + expect(find.text('Rsync'), findsWidgets); + // 폴더만 (파일은 안 보임) · 버튼은 → ← ⇄ 모니터링 (파일 기능 · 선택 없음) + expect(find.text('doc.txt'), findsNothing); + expect(find.text('r.txt'), findsNothing); + for (final t in ['좌 → 우', '좌 ← 우', '좌 ⇄ 우', '모니터링']) { + expect(find.text(t), findsOneWidget); + } + expect(find.text('선택'), findsNothing); + expect(find.text('삭제'), findsNothing); + + // 고르지 않고 → : 알림 + await act(tester, () => tester.tap(find.text('좌 → 우'))); + expect(find.text('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.'), findsOneWidget); + + // 창마다 하나만: sub → sub2 로 바꾸면 sub 는 풀림, 다시 누르면 취소 + await act(tester, () => tester.tap(find.text('sub'))); + await act(tester, () => tester.tap(find.text('sub2'))); + await settle(tester, () => false, rounds: 5); + expect(find.byTooltip('고르기 취소'), findsOneWidget); + await act(tester, () => tester.tap(find.text('sub2'))); + await settle(tester, () => false, rounds: 5); + expect(find.byTooltip('고르기 취소'), findsNothing); + await act(tester, () => tester.tap(find.text('sub'))); + await act(tester, () => tester.tap(find.text('rsub'))); + await settle(tester, () => false, rounds: 5); + expect(find.byTooltip('고르기 취소'), findsNWidgets(2)); // 왼쪽 하나 · 오른쪽 하나 + + // ⇄ : 두 방향을 보여 주고 -u, 취소하면 모니터링에 남지 않는다 + await act(tester, () => tester.tap(find.text('좌 ⇄ 우'))); + await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); + // 72: 실행 전에 무엇이 바뀌는지 (⇄: 양쪽에만 있는 파일이 서로 건너감) + expect(find.textContaining('→ 새로'), findsOneWidget); + expect(find.textContaining('← 새로'), findsOneWidget); + expect(find.text('rsync 양쪽 (⇄)'), findsOneWidget); + // 109: 짧은 경로 (끝 폴더) + 전체 경로는 작게 + final ls = p.join(left, 'sub'), rs = p.join(right, 'rsub'); + expect(find.text('→ ${shortPath(rs)}'), findsOneWidget); + expect(find.text('→ ${shortPath(ls)}'), findsOneWidget); + expect(find.text('$ls/ → $rs/'), findsOneWidget); + expect(find.text('$rs/ → $ls/'), findsOneWidget); + expect(find.textContaining('-avPog -u'), findsNWidgets(2)); + await tester.tap(find.text('취소')); + await calm(tester); + expect(c.settings.copyTasks, isEmpty); + expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음 + // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) + await act(tester, () => tester.tap(find.text('좌 → 우'))); + await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); + expect(find.text('$ls/ → $rs/'), findsOneWidget); + expect(find.text('$rs/ → $ls/'), findsNothing); + // 옵션 낱말 -u 만 (폴더 이름 vscode-stable-user-x64 같은 것은 아님 - 시험 임시 폴더 위가 %TEMP% 일 때) + expect(find.textContaining(RegExp(r'(^|\s)-u(\s|$)')), findsNothing); + expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing); + await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'))); + await calm(tester); + expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget); + expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget); + expect(find.text('빈 폴더 지움 · 원본 폴더도 지움'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget); + await tester.tap(find.text('취소')); + await calm(tester); + // 고른 폴더는 기억 (다음에 열 때 그 폴더부터) + expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]); + + // 새 폴더: 만든 폴더가 바로 그 창의 원본 · 대상으로 골라진다 (오른쪽 창 rsub 안에) + await act(tester, () => tester.tap(find.text('rsub'))); + await settle(tester, () => false, rounds: 5); // rsub 를 다시 눌러 취소 + await act(tester, () => tester.tap(find.text('rsub'))); + await settle(tester, () => false, rounds: 5); // 다시 골라 지금 폴더로 + await act(tester, () => tester.tap(find.text('새 폴더'))); + await calm(tester); + await tester.enterText(find.byType(TextField), '새로만든'); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); + // 만든 폴더가 보이고 골라질 때까지 (전체 시험 중 바쁠 때는 고르기가 조금 늦다) + Finder newRow() => find.ancestor(of: find.text('새로만든'), matching: find.byType(Material)).first; + await settle(tester, () => + find.text('새로만든').evaluate().isNotEmpty && + find.descendant(of: newRow(), matching: find.byTooltip('고르기 취소')).evaluate().isNotEmpty); + expect(Directory(p.join(right, 'rsub', '새로만든')).existsSync(), isTrue); + expect(find.descendant(of: newRow(), matching: find.byTooltip('고르기 취소')), findsOneWidget); + expect(find.descendant(of: find.ancestor(of: find.text('rsub'), matching: find.byType(Material)).first, + matching: find.byTooltip('고르기 취소')), findsNothing); // 창마다 하나만 + }); + + testWidgets('78: 여럿을 골라 [이름 변경] = 일괄 이름 바꾸기 → 목록 차례대로 번호 · [되돌리기]', (tester) async { + File(p.join(left, 'b.txt')).writeAsStringSync('b'); + await open(tester); + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('doc.txt'))); + await act(tester, () => tester.tap(markOf('b.txt'))); + await act(tester, () => tester.tap(find.text('이름 변경').first)); + await settle(tester, () => find.text('바뀌는 것 0개').evaluate().isNotEmpty); + await tester.enterText(find.widgetWithText(TextField, '이름 틀'), 'Ep[C]'); + await tester.pump(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '바꾸기 (2개)'))); + // 알림은 목록을 다시 읽은 뒤에 뜬다 - 알림까지 기다린다 (PC 가 바쁠 때 한 번 흔들림) + await settle(tester, () => File(p.join(left, 'Ep02.txt')).existsSync() && find.text('2개의 이름을 바꿨습니다.').evaluate().isNotEmpty); + // 목록 차례 (이름순: b.txt, doc.txt) 대로 번호 + expect(File(p.join(left, 'Ep01.txt')).readAsStringSync(), 'b'); + expect(File(p.join(left, 'Ep02.txt')).readAsStringSync(), 'hello'); + expect(find.text('2개의 이름을 바꿨습니다.'), findsOneWidget); + await act(tester, () => tester.tap(find.text('되돌리기'))); + // 알림이 닫히는 움직임을 끝까지 (시험이 끝난 뒤에 끝나면 Scaffold 'mounted' 단언에 걸린다) + await tester.pump(const Duration(seconds: 1)); + await settle(tester, () => File(p.join(left, 'doc.txt')).existsSync() && find.text('doc.txt').evaluate().isNotEmpty); + expect(File(p.join(left, 'b.txt')).readAsStringSync(), 'b'); + expect(File(p.join(left, 'Ep01.txt')).existsSync(), isFalse); + }); + + // 79-c (관리자 판단 · 데이터): 찾기 결과에서 폴더와 그 안의 파일을 함께 고르면 안의 것은 한 번만 (폴더와 함께) + group('79-c 폴더 + 안의 파일을 함께 고름', () { + /// "sub" 로 찾아 sub 폴더 · sub/sub_a.txt 를 모두 고른다 + Future findSubAll(WidgetTester tester) async { + File(p.join(left, 'sub', 'sub_a.txt')).writeAsStringSync('aaaaa'); + await open(tester); + await act(tester, () => tester.tap(find.text('찾기').first)); + await calm(tester); + await tester.enterText(find.widgetWithText(TextField, '이름 (비워도 됨)'), 'sub'); + await tester.pump(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); + await settle(tester, () => find.text('찾기 결과 · 2개').evaluate().isNotEmpty && find.widgetWithText(TextButton, '모두').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.widgetWithText(TextButton, '모두'))); + await calm(tester); + // 79-b: 고른 폴더 안의 파일은 세지 않고, 폴더 크기는 빠졌다고 적는다 + expect(find.text('폴더 1개 (크기 미포함)'), findsOneWidget); + } + + testWidgets('복사: 한 번만 - 다른 창에 sub/sub_a.txt 하나', (tester) async { + await findSubAll(tester); + await act(tester, () => tester.tap(find.text('복사 → right'))); + await calm(tester); + expect(find.textContaining('sub_a.txt'), findsNothing, reason: '확인 창에도 안쪽 파일은 따로 나오지 않는다'); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(right, 'sub', 'sub_a.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'sub_a.txt')).existsSync(), isFalse, reason: '안쪽 파일이 따로 한 번 더 복사되지 않음'); + expect(find.textContaining('오류'), findsNothing); + }); + + testWidgets('이동: "없음" 오류 없이 한 번만 · [찾기 결과로 돌아가기] 는 새 자리', (tester) async { + await findSubAll(tester); + await act(tester, () => tester.tap(find.text('이동 → right'))); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); + await settle(tester, () => File(p.join(right, 'sub', 'sub_a.txt')).existsSync() && !Directory(p.join(left, 'sub')).existsSync() && + find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'sub_a.txt')).existsSync(), isFalse); + expect(find.textContaining('없'), findsNothing, reason: '안쪽 파일을 따로 옮기려다 "없음" 이 나지 않는다'); + // 79-a: 다시 찾지 않고 결과로 - 옮긴 것은 새 자리 + expect(find.text('찾기 결과로 돌아가기'), findsOneWidget); + await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); + await settle(tester, () => find.text('찾기 결과 · 2개').evaluate().isNotEmpty); + final results = find.byType(SearchResultsPage); + expect(find.descendant(of: results, matching: find.textContaining(right)), findsNWidgets(2)); + }); + + testWidgets('지우기: 확인 창은 1개 항목 · 폴더 1개', (tester) async { + await findSubAll(tester); + await act(tester, () => tester.tap(find.text('지우기'))); + await calm(tester); + expect(find.textContaining('"sub" 을(를) 휴지통으로 보낼까요?'), findsOneWidget, reason: '안쪽 파일을 빼고 하나'); + expect(find.text('그중 폴더 1개 - 안의 파일 · 폴더도 모두 함께'), findsOneWidget); + await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); + await settle(tester, () => !Directory(p.join(left, 'sub')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(find.textContaining('지우지 못'), findsNothing); + // 79-a: 지운 것은 결과에서 빠진다 + await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); + await settle(tester, () => find.text('찾기 결과 · 0개').evaluate().isNotEmpty); + expect(find.text('찾은 것이 없습니다.'), findsOneWidget); + }); + }); +} diff --git a/test/explorer_hidden_find_test.dart b/test/explorer_hidden_find_test.dart new file mode 100644 index 0000000..8b959a2 --- /dev/null +++ b/test/explorer_hidden_find_test.dart @@ -0,0 +1,304 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/hidden_items_dialog.dart'; +import 'package:jj_mkvmaker/ui/explorer_search.dart'; +import 'package:path/path.dart' as p; +import 'support/explorer_clock_keys.dart'; +import 'support/explorer_harness.dart'; + +/// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 목록에서 숨기기 · .nomedia · 폴더 크기 · 찾기 결과 +void main() { + useExplorerFixture(); + + testWidgets('X-plore 숨기기: 길게 누르기 [목록에서 숨기기] → 목록에서만 빠짐 (파일 그대로) · 맨 아래 [숨긴 항목 보이기] · [숨기기 풀기]', (tester) async { + // 폴더 + 파일 목록 배치: 오른쪽 목록은 그 폴더의 것만이라 짧다 (트리는 Temp 폴더 줄이 많아 맨 아래가 멀다) + c.settings.explorerLayout = 'split'; + await open(tester); + final doc = p.join(left, 'doc.txt'); + await tester.longPress(find.text('doc.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.text('목록에서 숨기기'))); + await calm(tester); + // X-plore 맞추기 (관리자 지시 10/20): 무엇이 바뀌는지 확인 창 + expect(find.textContaining('파일 탐색기 목록에서만 빠집니다'), findsOneWidget); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기'))); + await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); + expect(find.text('doc.txt'), findsNothing); + expect(File(doc).existsSync(), isTrue, reason: '파일은 바꾸지 않는다'); + expect(c.settings.explorerHiddenItems, [doc]); + // 목록 맨 아래에 [숨긴 항목 1개 보이기] + expect(find.text('숨긴 항목 1개 보이기'), findsOneWidget); + await idle(tester); + // 보이기 → 흐리게 보이고, 메뉴는 [숨기기 풀기] + await act(tester, () => tester.tap(find.text('숨긴 항목 1개 보이기'))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + expect(find.text('숨긴 항목 1개 다시 숨기기'), findsOneWidget); + expect(find.ancestor(of: find.text('doc.txt'), matching: find.byType(Opacity)), findsOneWidget); + await tester.longPress(find.text('doc.txt')); + await calm(tester); + expect(find.text('목록에서 숨기기'), findsNothing); + await act(tester, () => tester.tap(find.text('숨기기 풀기'))); + await settle(tester, () => find.textContaining('숨긴 항목').evaluate().isEmpty); + expect(c.settings.explorerHiddenItems, isEmpty); + expect(find.text('doc.txt'), findsOneWidget); + expect(find.ancestor(of: find.text('doc.txt'), matching: find.byType(Opacity)), findsNothing); + }); + + testWidgets('X-plore 숨기기 확인 창: [취소] 면 그대로 · [다음부터 묻지 않기] 를 고르면 다음엔 묻지 않고 바로 숨김 (설정 저장)', (tester) async { + c.settings.explorerLayout = 'split'; + expect(AppSettings().explorerHideConfirm, isTrue, reason: '처음은 묻기'); + File(p.join(left, 'two.txt')).writeAsStringSync('2'); + await open(tester); + await tester.longPress(find.text('doc.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.text('목록에서 숨기기'))); + await calm(tester); + await act(tester, () => tester.tap(find.text('취소'))); + await calm(tester); + expect(c.settings.explorerHiddenItems, isEmpty, reason: '취소하면 숨기지 않는다'); + expect(find.text('doc.txt'), findsOneWidget); + // 다음부터 묻지 않기 + 숨기기 + await tester.longPress(find.text('doc.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.text('목록에서 숨기기'))); + await calm(tester); + await act(tester, () => tester.tap(find.text('다음부터 묻지 않기'))); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기'))); + await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); + expect(c.settings.explorerHideConfirm, isFalse); + expect(AppSettings.fromJson(c.settings.toJson()).explorerHideConfirm, isFalse); + expect(AppSettings.fromJson({}).explorerHideConfirm, isTrue, reason: '예전 설정 (값 없음) 은 묻기'); + // 이제는 창 없이 바로 + await tester.longPress(find.text('two.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.text('목록에서 숨기기'))); + await settle(tester, () => find.text('two.txt').evaluate().isEmpty); + expect(find.textContaining('파일 탐색기 목록에서만 빠집니다'), findsNothing); + expect(c.settings.explorerHiddenItems, [p.join(left, 'doc.txt'), p.join(left, 'two.txt')]); + }); + + testWidgets('X-plore 맞추기: 폴더 길게 누르기 [갤러리 · 음악 앱에서 숨기기 (.nomedia)] - 확인 창 뒤 빈 .nomedia, 다시 누르면 지우기 · 안의 파일은 그대로', (tester) async { + ExplorerPage.debugNomedia = true; + addTearDown(() => ExplorerPage.debugNomedia = null); + final mark = File(p.join(left, 'sub', '.nomedia')); + await open(tester); + expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing); + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'))); + await calm(tester); + expect(find.textContaining('빈 .nomedia 파일을 만듭니다'), findsOneWidget); + // 취소하면 아무것도 안 바뀜 + await act(tester, () => tester.tap(find.text('취소'))); + await calm(tester); + expect(mark.existsSync(), isFalse); + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'))); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기'))); + await settle(tester, () => mark.existsSync() && shell.scanned.isNotEmpty); + expect(mark.lengthSync(), 0); + expect(shell.scanned, [ + [mark.path, p.join(left, 'sub')] + ], reason: '갤러리가 다시 읽게 (MediaScanner)'); + expect(File(p.join(left, 'sub', 'inner.txt')).readAsStringSync(), 'inner', reason: '안의 파일은 그대로'); + // 이제 메뉴는 [다시 보이기] + await idle(tester); + await tester.longPress(find.text('sub')); + await calm(tester); + expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing); + await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'))); + await calm(tester); + expect(find.textContaining('.nomedia 파일을 지웁니다'), findsOneWidget); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '다시 보이기'))); + await settle(tester, () => !mark.existsSync()); + await settle(tester, () => shell.scanned.length == 2); + expect(shell.scanned.last, [mark.path, p.join(left, 'sub')], reason: '지울 때도 다시 읽게'); + expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue); + // 파일에는 없음 + await idle(tester); + await tester.longPress(find.text('doc.txt')); + await calm(tester); + expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing); + expect(find.text('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'), findsNothing); + }); + + testWidgets('X-plore 숨기기: 숨긴 것이 든 폴더를 지울 때 확인 창에 알림 (숨긴 것도 함께 지워진다)', (tester) async { + c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')]; + await open(tester); + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.byTooltip('삭제').last)); + await calm(tester); + expect(find.text('지우는 폴더 안에 목록에서 숨긴 항목 1개가 있습니다 - 함께 지워집니다'), findsOneWidget); + await act(tester, () => tester.tap(find.text('취소').last)); + await calm(tester); + expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue); + // 다른 파일만 지울 때는 알리지 않는다 + await tester.longPress(find.text('doc.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.byTooltip('삭제').last)); + await calm(tester); + expect(find.textContaining('목록에서 숨긴 항목'), findsNothing); + await act(tester, () => tester.tap(find.text('취소').last)); + await calm(tester); + }); + + testWidgets('X-plore 숨기기: 이름을 바꾸면 숨긴 목록도 새 경로로 · 영구로 지우면 목록에서 뺀다 (관리자 결정)', (tester) async { + c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')]; + await open(tester); + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.byTooltip('이름 변경').last)); + await calm(tester); + await act(tester, () => tester.enterText(find.byType(TextField), 'sub2')); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); + await settle(tester, () => find.text('sub2').evaluate().isNotEmpty); + expect(c.settings.explorerHiddenItems, [p.join(left, 'sub2', 'inner.txt')]); + await idle(tester); + await tester.longPress(find.text('sub2')); + await calm(tester); + await act(tester, () => tester.tap(find.byTooltip('삭제').last)); + await calm(tester); + await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); + await settle(tester, () => find.text('sub2').evaluate().isEmpty); + await settle(tester, () => c.settings.explorerHiddenItems.isEmpty); + expect(c.settings.explorerHiddenItems, isEmpty); + }); + + testWidgets('X-plore 숨기기: 다른 창으로 옮기면 숨긴 목록도 새 자리로 (그 안의 것도)', (tester) async { + c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')]; + await open(tester); + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.byTooltip('다른 창으로 이동').last)); + await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); + await settle(tester, () => c.settings.explorerHiddenItems.first != p.join(left, 'sub', 'inner.txt')); + expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isTrue); + expect(c.settings.explorerHiddenItems, [p.join(right, 'sub', 'inner.txt')]); + await idle(tester); + }); + + testWidgets('X-plore 숨기기: 위 막대 ⋮ [목록에서 숨긴 항목 보이기] · 환경 설정의 [목록에서 숨긴 항목] 에서 풀기', (tester) async { + final doc = p.join(left, 'doc.txt'); + c.settings + ..explorerLayout = 'split' + ..explorerHiddenItems = [doc]; + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); + await settle(tester, () => find.text('숨긴 항목 1개 보이기').evaluate().isNotEmpty); + expect(find.text('doc.txt'), findsNothing); + await act(tester, () => tester.tap(find.byTooltip('창 배치 · 버튼 구성'))); + await calm(tester); + await act(tester, () => tester.tap(find.text('목록에서 숨긴 항목 보이기 (1개)'))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + expect(find.text('숨긴 항목 1개 다시 숨기기'), findsOneWidget); + await act(tester, () => tester.tap(find.byTooltip('창 배치 · 버튼 구성'))); + await calm(tester); + await act(tester, () => tester.tap(find.text('목록에서 숨긴 항목 다시 숨기기'))); + await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); + // 설정 화면의 목록에서 풀면 탐색기 목록에 바로 보인다 + final ctx = tester.element(find.byType(ExplorerPage)); + unawaited(showHiddenItems(ctx, c)); + await calm(tester); + expect(find.text('목록에서 숨긴 항목 (1개)'), findsOneWidget); + await act(tester, () => tester.tap(find.byTooltip('숨기기 풀기'))); + await calm(tester); + expect(c.settings.explorerHiddenItems, isEmpty); + expect(find.text('숨긴 항목이 없습니다'), findsOneWidget); + await act(tester, () => tester.tap(find.text('닫기'))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + expect(find.textContaining('숨긴 항목'), findsNothing); + }); + + testWidgets('80: 크기순 정렬이면 폴더 크기를 계산해 보이고, "안 함" 이어도 길게 누르기 [폴더 크기 계산]', (tester) async { + File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(3000, 1)); + c.settings + ..explorerSort = 'size' + ..explorerSortDesc = true; + await open(tester); + // sub = inner.txt (5) + big.bin (3000) + await settle(tester, () => find.text('3KB').evaluate().isNotEmpty); + expect(find.text('3KB'), findsWidgets); + + // 바뀌면 (새로 고침) 다시 계산 + File(p.join(left, 'sub', 'more.bin')).writeAsBytesSync(List.filled(3000, 1)); + await act(tester, () => refreshKey(tester)); + await settle(tester, () => find.text('6KB').evaluate().isNotEmpty); + expect(find.text('6KB'), findsWidgets); + }); + + testWidgets('80: "계산 안 함" - 크기를 보이지 않다가 [폴더 크기 계산] 을 누르면', (tester) async { + File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(3000, 1)); + c.settings + ..explorerSort = 'size' + ..explorerFolderSize = 'off'; + await open(tester); + await settle(tester, () => false, rounds: 15); + expect(find.text('3KB'), findsNothing); + await tester.longPress(find.text('sub').first); + await calm(tester); + await act(tester, () => tester.tap(find.text('폴더 크기 계산'))); + await settle(tester, () => find.text('3KB').evaluate().isNotEmpty); + expect(find.text('3KB'), findsWidgets); + }); + + testWidgets('79-a: [그 자리로] 로 나가도 [찾기 결과로 돌아가기] 가 남는다 (관리자 판단) · ✕ 로 닫음', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('찾기').first)); + await calm(tester); + await tester.enterText(find.widgetWithText(TextField, '이름 (비워도 됨)'), 'inner'); + await tester.pump(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); + await settle(tester, () => find.text('찾기 결과 · 1개').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byTooltip('그 자리로'))); + await settle(tester, () => find.text('찾기 결과로 돌아가기').evaluate().isNotEmpty); + await calm(tester); // 결과 화면이 닫히는 움직임까지 + expect(find.byType(SearchResultsPage), findsNothing); + await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); + await settle(tester, () => find.text('찾기 결과 · 1개').evaluate().isNotEmpty); + // 결과 화면을 그냥 닫으면 없어진다 + await act(tester, () => tester.pageBack()); + await calm(tester); + expect(find.text('찾기 결과로 돌아가기'), findsNothing); + }); + + testWidgets('79: 찾기 결과를 목록으로 - 이름 없이 종류 조건으로 찾고, 골라서 그대로 다른 창으로 복사', (tester) async { + await open(tester); + File(p.join(left, 'sub', 'big.mkv')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(2048, 1)); + await act(tester, () => tester.tap(find.text('찾기').first)); + await calm(tester); + // 이름은 비우고 종류만 (동영상) - 이름이 비면 [찾기] 가 꺼져 있다가 조건을 넣으면 켜진다 + expect(tester.widget(find.widgetWithText(FilledButton, '찾기')).onPressed, isNull); + await act(tester, () => tester.tap(find.widgetWithText(ChoiceChip, '동영상'))); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); + await settle(tester, () => find.text('big.mkv').evaluate().isNotEmpty); + expect(find.text('찾기 결과 · 1개'), findsOneWidget); + // (뒤의 탐색기 목록에는 doc.txt 가 있다 - 결과 화면 안만 본다) + expect(find.descendant(of: find.byType(SearchResultsPage), matching: find.text('doc.txt')), findsNothing, reason: '종류가 맞지 않음'); + // 고르면 아래에 할 일 줄 - 다른 창 (right) 으로 복사 + await act(tester, () => tester.tap(find.descendant(of: find.byType(SearchResultsPage), matching: find.byType(Checkbox)).first)); + await calm(tester); + await act(tester, () => tester.tap(find.text('복사 → right'))); + await calm(tester); + // 탐색기의 복사 확인 창 그대로 + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(right, 'big.mkv')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'big.mkv')).lengthSync(), 2048); + expect(File(p.join(left, 'sub', 'big.mkv')).existsSync(), isTrue); + }); +} diff --git a/test/explorer_keys_drag_test.dart b/test/explorer_keys_drag_test.dart new file mode 100644 index 0000000..f52d3ec --- /dev/null +++ b/test/explorer_keys_drag_test.dart @@ -0,0 +1,379 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/quick_look.dart'; +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 탐색기) · 창 사이 끌어 놓기 · 진행 패널 · 빠른 보기 · 저장 공간 분석 · 열 머리 정렬 +void main() { + useExplorerFixture(); + + testWidgets('83: Total Commander 모양 단축키 - F5 복사 · F7 새 폴더 · Space/Insert 표시 (Space 는 빠른 필터가 아님)', (tester) async { + File(p.join(left, 'e.txt')).writeAsStringSync('e'); + c.settings.explorerStyle = 'totalcmd'; + await open(tester); + await act(tester, () => tester.tap(find.text('doc'))); + await settle(tester, () => false, rounds: 15); + // Insert: 표시하고 다음 줄 (e.txt) 로, Space: 지금 줄 (e.txt) 표시 + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.insert)); + await calm(tester); + expect(find.textContaining('1개 표시함'), findsOneWidget); + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.space)); + await calm(tester); + expect(find.textContaining('2개 표시함'), findsOneWidget, reason: 'Insert 뒤 다음 줄이 표시됨'); + expect(find.byIcon(Icons.filter_alt), findsNothing, reason: 'Space 가 빠른 필터를 열지 않는다'); + // F5 = 복사 (확인 창) - 취소 + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f5)); + await calm(tester); + expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget); + await tester.tap(find.text('취소').last); + await calm(tester); + // F7 = 새 폴더 + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f7)); + await calm(tester); + expect(find.text('새 폴더'), findsWidgets); + await tester.tap(find.text('취소').last); + await calm(tester); + }); + + testWidgets('83 (28 지적): Total Commander 모양 F8 = 지우기 - 확인 창 [취소] 면 그대로, [휴지통으로] 면 휴지통으로', (tester) async { + final bin = recycleBinForTest; + 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'); + c.settings.explorerStyle = 'totalcmd'; + await open(tester); + await settle(tester, () => find.text('tcdel').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('tcdel'))); + await settle(tester, () => false, rounds: 15); + + // F8 → 확인 창 → [취소]: 아무것도 지우지 않음 + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f8)); + await calm(tester); + expect(find.text('휴지통으로 보내기'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '휴지통으로'), findsOneWidget, reason: '처음 값은 휴지통 (영구 삭제 아님)'); + await tester.tap(find.text('취소').last); + await calm(tester); + expect(victim.existsSync(), isTrue); + expect(bin.items, isEmpty); + + // F8 → [휴지통으로]: 휴지통 길로 (영구 삭제가 아님) + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f8)); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(tester, () => !victim.existsSync() && find.byType(SnackBar).evaluate().isNotEmpty); + expect(victim.existsSync(), isFalse); + expect(bin.items.keys, [victim.path], reason: '휴지통으로 보냄 (되돌릴 수 있음)'); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue, reason: '고른 것만'); + expect(find.text('되돌리기'), findsOneWidget); + await calm(tester); + }); + + testWidgets('83: Windows 탐색기 모양 단축키 - Ctrl+A 모두 표시 · Ctrl+C 담기 · Ctrl+V 붙여넣기', (tester) async { + c.settings.explorerStyle = 'windows'; + await open(tester); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await act(tester, () async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + }); + await calm(tester); + expect(find.textContaining('2개 표시함'), findsOneWidget, reason: 'doc.txt · sub'); + await act(tester, () async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + }); + await calm(tester); + expect(find.textContaining('담았습니다'), findsOneWidget); + }); + + group('82 창 사이 끌어 놓기', () { + test('놓을 수 있는 곳: 같은 창 · 자기 안 · 원본 폴더 자신은 안 됨', () { + final a = p.join('C:', 'x', 'a'), f = p.join('C:', 'x', 'f.txt'); + expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'y')), isTrue); + expect(canDropInto(DragLoad(0, [f]), 0, p.join('C:', 'y')), isFalse, reason: '같은 창'); + expect(canDropInto(DragLoad(0, [a]), 1, p.join(a, 'in')), isFalse, reason: '자기 안'); + expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'x')), isFalse, reason: '원본 폴더 자신'); + }); + + /// 오른쪽 창에 보이는 [name] 줄 (두 창 모두 같은 이름이 보이면 오른쪽 것) + Offset rightOf(WidgetTester tester, String name) { + final all = find.text(name).evaluate().map((e) => tester.getCenter(find.byWidget(e.widget).first)).toList() + ..sort((x, y) => x.dx.compareTo(y.dx)); + return all.last; + } + + Future mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await g.moveBy(const Offset(20, 0)); + await tester.pump(); + await g.moveTo(to); + await tester.pump(); + await g.up(); + await calm(tester); + } + + testWidgets('짧게 움직이면 고르기 · 같은 창에는 놓아도 아무 일 없음 · 묻기 [취소] 도 아무 일 없음', (tester) async { + await open(tester); + final doc = tester.getCenter(find.text('doc.txt').first); + // 4px 만 움직임 = 누르기 (끌기 아님) + final g = await tester.startGesture(doc, kind: PointerDeviceKind.mouse); + await g.moveBy(const Offset(4, 0)); + await g.up(); + await calm(tester); + expect(find.byType(AlertDialog), findsNothing); + // 같은 창의 sub 위에 놓기 - 못 놓음 + await mouseDrag(tester, doc, tester.getCenter(find.text('sub').first)); + expect(find.byType(AlertDialog), findsNothing, reason: '같은 창에는 놓지 않는다'); + // 오른쪽 창 (right 폴더) 에 놓기 → 묻기 → 취소 + await mouseDrag(tester, doc, rightOf(tester, 'right')); + expect(find.text('1개 항목을 놓았습니다'), findsOneWidget); + await tester.tap(find.text('취소').last); + await calm(tester); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); + expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse); + }); + + testWidgets('이동 → 같은 이름을 덮어쓴 파일은 되돌리지 않는다고 파일로 알림 (15 지적)', (tester) async { + File(p.join(right, 'doc.txt')).writeAsStringSync('old'); + File(p.join(left, 'e.txt')).writeAsStringSync('e'); + c.settings + ..explorerDropAction = 'move' + ..copyConflict = 'overwrite'; + await open(tester); + // 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지 + await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('doc.txt'))); + await act(tester, () => tester.tap(markOf('e.txt'))); + await calm(tester); + await mouseDrag(tester, tester.getCenter(find.text('e.txt').first), rightOf(tester, 'right')); + await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); + await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); + expect(find.textContaining('같은 이름을 덮어쓴 파일 1개는 되돌리지 않습니다'), findsOneWidget); + expect(find.textContaining('합친 폴더'), findsNothing); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + await tester.pump(const Duration(seconds: 2)); // 진행 막대가 내려가는 시간 (1.2초) 까지 + }); + + testWidgets('이동 → [되돌리기]: 새로 생긴 것은 통째로, 이미 있던 폴더에 합친 것은 옮긴 파일만', (tester) async { + // 오른쪽에 이미 sub 폴더 (원래 파일 하나) - 합치기 + File(p.join(right, 'sub', 'mine.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('mine'); + c.settings + ..explorerDropAction = 'move' + ..copyConflict = 'overwrite'; + await open(tester); + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('doc.txt'))); + await act(tester, () => tester.tap(markOf('sub'))); + await calm(tester); + await mouseDrag(tester, tester.getCenter(find.text('doc.txt').first), rightOf(tester, 'right')); + // (놓은 뒤 대상에 이미 있는 이름을 실제 파일로 확인하므로 실제 시간을 흘려 기다린다) + await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); + // 지금의 이동 확인 창 그대로 + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); + await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); + expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue); + expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isTrue); + expect(Directory(p.join(left, 'sub')).existsSync(), isFalse); + // 되돌리기: 원래 폴더마다 지금의 이동 (확인 창) + await act(tester, () => tester.tap(find.text('되돌리기'))); + // 원래 폴더가 둘 (left · left/sub) 이라 이동 확인 창도 둘 + for (var i = 0; i < 2; i++) { + await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); + await settle(tester, () => find.byType(AlertDialog).evaluate().isEmpty, rounds: 50); + } + await settle(tester, () => File(p.join(left, 'doc.txt')).existsSync() && File(p.join(left, 'sub', 'inner.txt')).existsSync()); + expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello'); + expect(File(p.join(left, 'sub', 'inner.txt')).readAsStringSync(), 'inner'); + expect(File(p.join(right, 'sub', 'mine.txt')).readAsStringSync(), 'mine', reason: '원래 있던 폴더 · 파일은 그대로 (폴더째 옮기지 않음)'); + expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isFalse); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + }); + }); + + testWidgets('85: 진행 패널 [숨기기] → 아래 작은 표시 (누르면 다시) · 끝난 알림의 [폴더 열기]', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await act(tester, () => tester.tap(find.text('복사').first)); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.text('폴더 열기').evaluate().isNotEmpty); + expect(find.text('폴더 열기'), findsOneWidget, reason: '끝난 알림에 [폴더 열기]'); + // 패널은 끝난 뒤 잠깐 남는다 - [숨기기] + expect(find.text('숨기기'), findsOneWidget); + // 패널 · 작은 표시는 끝나고 1.2초 (시험 시계) 뒤 내려간다. pumpAndSettle (calm) 은 애니메이션이 있는 동안 시계를 그만큼 + // 넘길 수 있어 (PC 가 바쁠 때 흔들림 - 관리자 지시), 정해진 횟수 대신 조건이 될 때까지 시계를 조금씩만 돌린다 + Future until(bool Function() done) async { + for (var i = 0; i < 100 && !done(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(const Duration(milliseconds: 10)); + } + } + + await act(tester, () => tester.tap(find.text('숨기기'))); + await until(() => c.settings.explorerProgressHidden && find.text('복사 · 이동 끝').evaluate().isNotEmpty); + expect(c.settings.explorerProgressHidden, isTrue, reason: '숨김은 기억'); + expect(find.text('숨기기'), findsNothing); + expect(find.text('복사 · 이동 끝'), findsOneWidget, reason: '아래 작은 표시'); + // 작은 표시를 누름 (아래 알림이 덮고 있어도 - 알림이 닫히기를 기다리면 그사이 작은 표시가 내려갈 수 있다) + tester.widget(find.byType(TransferProgressChip)).onTap(); + await until(() => !c.settings.explorerProgressHidden); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + expect(c.settings.explorerProgressHidden, isFalse, reason: '누르면 다시 펼침'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + }); + + testWidgets('81: 빠른 보기 (글 파일 앞부분) - Windows 는 TC 키 F3 (규칙 1), Android 는 Space · 필터에 치는 중에는 그냥 빈칸', (tester) async { + c.settings.explorerStyle = 'windows'; + await open(tester); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await act(tester, () => tester.sendKeyEvent(Platform.isWindows ? LogicalKeyboardKey.f3 : LogicalKeyboardKey.space)); + await settle(tester, () => find.byType(QuickLook).evaluate().isNotEmpty); + expect(find.byType(QuickLook), findsOneWidget); + await settle(tester, () => find.text('hello').evaluate().isNotEmpty); + expect(find.text('hello'), findsOneWidget, reason: '글 파일 앞부분'); + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); + await calm(tester); + expect(find.byType(QuickLook), findsNothing); + + // 빠른 필터에 치는 중이면 Space 는 칸의 글자 (빠른 보기를 열지 않음) + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.keyD)); + await calm(tester); + expect(find.byType(TextField), findsOneWidget); + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.space)); + await calm(tester); + expect(find.byType(QuickLook), findsNothing); + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); + await calm(tester); + + // TC: F3 + await c.updateSettings((s) => s.explorerStyle = 'totalcmd'); + await calm(tester); + await act(tester, () => tester.tap(find.text('doc'))); + await settle(tester, () => false, rounds: 15); + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f3)); + await settle(tester, () => find.byType(QuickLook).evaluate().isNotEmpty); + expect(find.byType(QuickLook), findsOneWidget); + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); + await calm(tester); + }); + + // P0 (15 지적 10/17): 글자 칸에 치는 중에는 파일 단축키가 키를 가로채지 않는다 (예전: 필터 칸의 Delete → 파일 지우기 창) + for (final style in const ['windows', 'xplore', 'totalcmd']) { + testWidgets('P0 $style: 빠른 필터 칸에서 Ctrl+A · C · V · Delete · Space · F8 · Backspace 는 파일에 쓰이지 않는다', (tester) async { + c.settings.explorerStyle = style; + await open(tester); + await act(tester, () => tester.tap(find.text(style == 'totalcmd' ? 'doc' : 'doc.txt'))); + await settle(tester, () => false, rounds: 15); + // Ctrl+F 로 필터 칸 열고 글자 + Future ctrl(LogicalKeyboardKey k) => act(tester, () async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(k); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + }); + await ctrl(LogicalKeyboardKey.keyF); + await calm(tester); + final field = find.byType(TextField); + expect(field, findsOneWidget); + await tester.enterText(field, 'do'); + await calm(tester); + await ctrl(LogicalKeyboardKey.keyA); + await calm(tester); + final ctl = tester.widget(field).controller!; + expect(ctl.selection, const TextSelection(baseOffset: 0, extentOffset: 2), reason: 'Ctrl+A 는 칸의 글 전체 선택'); + await ctrl(LogicalKeyboardKey.keyC); + await calm(tester); + await ctrl(LogicalKeyboardKey.keyV); + await calm(tester); + for (final k in [LogicalKeyboardKey.delete, LogicalKeyboardKey.space, LogicalKeyboardKey.f8, LogicalKeyboardKey.backspace]) { + await act(tester, () => tester.sendKeyEvent(k)); + await calm(tester); + } + expect(find.textContaining('표시함'), findsNothing, reason: '파일이 표시되지 않음'); + expect(find.textContaining('담았습니다'), findsNothing, reason: '파일을 담지 않음'); + expect(find.textContaining('휴지통'), findsNothing, reason: '지우기 창이 뜨지 않음'); + expect(find.textContaining('지울까요'), findsNothing); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); + expect(field, findsOneWidget, reason: '칸은 그대로 열려 있음 (Backspace 로 위 폴더로 가지 않음)'); + // Esc 는 지금처럼 필터를 닫는다 + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); + await calm(tester); + expect(find.byType(TextField), findsNothing); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + }); + } + + testWidgets('87: 폴더 길게 누르기 [저장 공간 분석] → 큰 파일을 누르면 탐색기에서 그 자리로', (tester) async { + File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(4000, 1)); + await open(tester); + await tester.longPress(find.text('sub').first); + await calm(tester); + await act(tester, () => tester.tap(find.text('저장 공간 분석'))); + await settle(tester, () => find.text('큰 파일').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('큰 파일'))); + await calm(tester); + await act(tester, () => tester.tap(find.text('big.bin'))); + await settle(tester, () => find.byType(StorageAnalysisPage).evaluate().isEmpty && find.text('big.bin').evaluate().isNotEmpty); + await calm(tester); + expect(find.byType(StorageAnalysisPage), findsNothing); + expect(find.text('big.bin'), findsWidgets, reason: '탐색기에서 그 자리 (sub 안) 가 보인다'); + }); + + testWidgets('84: 열 머리를 누르면 그 기준으로 · 다시 누르면 거꾸로 (처음: 모든 폴더 같은 정렬)', (tester) async { + File(p.join(left, 'a_big.txt')).writeAsStringSync('x' * 50); + c.settings.explorerStyle = 'windows'; + await open(tester); + double y(String name) => tester.getTopLeft(find.text(name).first).dy; + expect(y('a_big.txt'), lessThan(y('doc.txt')), reason: '처음: 이름순'); + await act(tester, () => tester.tap(find.text('크기').first)); + await calm(tester); + expect((c.settings.explorerSort, c.settings.explorerSortDesc), ('size', false)); + expect(y('doc.txt'), lessThan(y('a_big.txt')), reason: '크기 오름차순: doc.txt (5) 가 먼저'); + await act(tester, () => tester.tap(find.text('크기').first)); + await calm(tester); + expect(c.settings.explorerSortDesc, isTrue, reason: '같은 열을 다시 누르면 거꾸로'); + // (거꾸로면 트리에 펼쳐진 위 폴더 (Temp 등) 의 차례도 바뀌어 시험 폴더가 화면 밖으로 갈 수 있어 줄 위치는 보지 않는다) + expect(find.byIcon(Icons.arrow_drop_down), findsWidgets, reason: '지금 정렬 열에 ▼'); + }); + + testWidgets('84: "정렬을 폴더마다 기억" 을 켜면 그 폴더에만 (전체 정렬 · 다른 창은 그대로)', (tester) async { + c.settings + ..explorerStyle = 'totalcmd' + ..explorerSortPerFolder = true; + await open(tester); + // 왼쪽 창에서 날짜 열 + await act(tester, () => tester.tap(find.text('날짜').first)); + await calm(tester); + expect(c.settings.explorerSort, 'name', reason: '전체 정렬은 그대로'); + expect(c.settings.explorerFolderSorts.values, contains('date|0')); + expect(c.settings.explorerFolderSorts.length, 1); + // 저장 · 읽기 + expect(AppSettings.fromJson(c.settings.toJson()).explorerFolderSorts, c.settings.explorerFolderSorts); + expect(AppSettings.fromJson(c.settings.toJson()).explorerSortPerFolder, isTrue); + expect(AppSettings.fromJson({'explorerFolderSorts': 'broken'}).explorerFolderSorts, isEmpty, reason: '틀린 값은 비움'); + }); +} diff --git a/test/explorer_more_flows_contract_test.dart b/test/explorer_more_flows_contract_test.dart new file mode 100644 index 0000000..ee4fda6 --- /dev/null +++ b/test/explorer_more_flows_contract_test.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; +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/explorer/explorer_bars.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_body.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_buttons.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_flows.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_layout.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart'; +import 'package:jj_mkvmaker/ui/explorer/pane_header.dart'; +import 'package:jj_mkvmaker/ui/explorer/pane_view.dart'; +import 'package:jj_mkvmaker/ui/explorer/server_flows.dart'; +import 'package:jj_mkvmaker/ui/explorer/toolbar.dart'; +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; + +/// E5e 약속 시험: explorer_body · search_flow · tools_flow · rsync_flow 맨 위 약속 +/// (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음). rsync 실행은 rsync 실행 파일이 있어야 해 확인 창까지) +void main() { + late Directory tmp; + late String root, l, r; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_more_flow_'); + root = tmp.path; + l = p.join(root, 'L'); + r = p.join(root, 'R'); + File(p.join(l, 'doc.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('hello'); + Directory(r).createSync(); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future settle(WidgetTester t, [bool Function()? until]) async { + for (var i = 0; i < 40 && !(until?.call() ?? false); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(const Duration(milliseconds: 100)); + } + } + + Future<_HostState> open(WidgetTester t, {bool rsync = false, Size size = const Size(1200, 800)}) async { + await t.binding.setSurfaceSize(size); + addTearDown(() => t.binding.setSurfaceSize(null)); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey<_HostState>(); + await t.pumpWidget(MaterialApp(home: _Host(key: key))); + final host = key.currentState!; + host.make(c, rsync: rsync, root: root); + addTearDown(host.drop); + await t.runAsync(() => host.ctl.goTo(host.ctl.panes[0], l)); + await t.runAsync(() => host.ctl.goTo(host.ctl.panes[1], r)); + host.ctl.ready = true; + host.redraw(); + await t.pump(); + return host; + } + + testWidgets('창 배치: 두 창 = 창 둘 + 버튼 줄 · 한 창 = 창 하나 · 폴더 + 파일 목록 = 트리 + 목록 · 좁은 Rsync = [왼쪽 | 오른쪽]', (t) async { + final h = await open(t); + expect([find.byType(ExplorerPaneView).evaluate().length, find.byType(ExplorerToolbar).evaluate().length], [2, 1]); + h.ctl.c.settings.explorerLayout = 'single'; + h.redraw(); + await t.pump(); + expect(find.byType(ExplorerPaneView).evaluate().length, 1); + h.ctl.c.settings.explorerLayout = 'split'; + h.redraw(); + await t.pump(); + expect([find.byType(ExplorerPaneView).evaluate().length, find.byType(ExplorerListView).evaluate().length], [1, 1]); + h.ctl.c.settings.explorerToolbar = 'hidden'; + h.redraw(); + await t.pump(); + expect(find.byType(ExplorerToolbar), findsNothing, reason: '버튼 줄 숨김'); + final rs = await open(t, rsync: true, size: const Size(420, 800)); + expect([find.byType(RsyncPaneSwitch).evaluate().length, find.byType(ExplorerPaneView).evaluate().length, rs.ctl.rsync], [1, 1, true]); + }); + + testWidgets('찾기: 처음엔 돌아갈 결과 없음 · 조건 창 [찾기] → 결과 화면에 찾은 것 · 그냥 닫으면 돌아갈 결과도 없음', (t) async { + final h = await open(t); + expect(h.flows.search.backBar(h.context), isNull); + unawaited(h.flows.search.open(h.context, h.ctl.panes[0])); + await t.pump(); + await t.enterText(find.byType(TextField).first, 'doc'); + await t.pump(); + await t.tap(find.widgetWithText(FilledButton, '찾기')); + await settle(t, () => find.byType(SearchResultsPage).evaluate().isNotEmpty && find.text('doc.txt').evaluate().length > 1); + expect(find.byType(SearchResultsPage), findsOneWidget); + Navigator.of(t.element(find.byType(SearchResultsPage))).pop(); + await settle(t); + expect(h.flows.search.backBar(h.context), isNull); + }); + + testWidgets('저장 공간: 원격은 먼저 묻고 [취소] 면 그대로 · 이 기기 폴더는 분석 화면 (고르지 않고 닫으면 그대로)', (t) async { + final h = await open(t); + final q = h.ctl.panes[0]; + unawaited(h.flows.tools.openStorage(h.context, q, 'dav://srv/x/')); + await t.pump(); + expect(find.textContaining('오래 걸릴 수 있습니다'), findsOneWidget); + await t.tap(find.text('취소')); + await t.pump(); + expect(find.byType(StorageAnalysisPage), findsNothing); + var done = false; + unawaited(h.flows.tools.openStorage(h.context, q, l).then((_) => done = true)); + await settle(t, () => find.byType(StorageAnalysisPage).evaluate().isNotEmpty); + expect(find.byType(StorageAnalysisPage), findsOneWidget); + Navigator.of(t.element(find.byType(StorageAnalysisPage))).pop(); + await settle(t, () => done); + expect([done, q.current], [true, l]); + }); + + testWidgets('Rsync: 고르지 않으면 까닭 알림 · 두 폴더를 고르면 확인 창 → [취소] 면 실행 안 함', (t) async { + final h = await open(t, rsync: true); + await t.runAsync(() => h.flows.rsync.run(h.context, right: true, left: false)); + await t.pump(); + expect(find.text('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.'), findsOneWidget); + await t.runAsync(() => h.sel.pick(h.ctl.panes[0], FileEntry(l, isDir: true, modified: DateTime(0)))); + await t.runAsync(() => h.sel.pick(h.ctl.panes[1], FileEntry(r, isDir: true, modified: DateTime(0)))); + var done = false; + unawaited(h.flows.rsync.run(h.context, right: true, left: false).then((_) => done = true)); + await settle(t, () => find.text('취소').evaluate().isNotEmpty); + await t.tap(find.text('취소').last); + await settle(t, () => done); + expect([done, File(p.join(r, 'doc.txt')).existsSync(), h.flows.jobs.list.isEmpty], [true, false, true]); + }); +} + +class _Host extends StatefulWidget { + const _Host({super.key}); + @override + State<_Host> createState() => _HostState(); +} + +/// 실제 화면: ExplorerHost · ExplorerCommands · ExplorerPaneParts 를 이 화면이 맡는다 (틀 · 안내 · 표시 막대는 단순하게) +class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands, ExplorerPaneParts { + late ExplorerController ctl; + late ExplorerSelect sel; + late ExplorerLayout layout; + late ExplorerFlows flows; + late ExplorerButtons buttons; + bool _made = false; + + void make(AppController c, {required bool rsync, required String root}) { + ctl = ExplorerController(c, rsync: rsync, host: this)..local = [(root, 'T:')]; + 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); + buttons = ExplorerButtons(ctl, sel, this, flows); + _made = true; + } + + void drop() { + flows.dispose(); + sel.dispose(); + ctl.dispose(); + } + + void redraw() => setState(() {}); + + @override + bool get alive => mounted; + @override + void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + @override + void reveal(ExplorerPane pane, String path) {} + @override + void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); + @override + String? get otherDir => layout.other?.current; + @override + bool get dual => layout.dual; + + @override + double get rowHeight => layout.rowHeight(); + @override + double get listTextScale => layout.listTextScale; + @override + bool get narrowSplit => layout.narrowSplit; + @override + bool get alwaysMarks => layout.alwaysMarks; + @override + Widget frame(int i, List children) => Column(children: children); + @override + Widget header(ExplorerPane pane) => ExplorerPaneHeader( + ctl: ctl, + servers: ExplorerServers(ctl), + pane: pane, + android: false, + onBookmarks: () {}, + onOpenFilter: () {}, + onCloseFilter: () {}, + onOpenRoot: () async {}, + onOpenShizuku: () async {}, + onReconnect: (_) async {}, + ); + @override + List notices(ExplorerPane pane, String dir) => const []; + @override + Widget marksBar(ExplorerPane pane, {String? listDir}) => const SizedBox(); + @override + void onMenu(ExplorerPane pane, FileEntry e, Offset at) {} + @override + List dragPaths(ExplorerPane pane, FileEntry e) => flows.drops.dragPaths(pane, e); + @override + Future onDrop(DragLoad load, int to, String dest) => flows.drops.drop(context, load, to, dest); + @override + void closeFilter(ExplorerPane pane) {} + @override + void filterChanged() => setState(() {}); + + @override + Widget build(BuildContext context) => Scaffold( + body: !_made ? const SizedBox() : ExplorerPanesBody(ctl: ctl, sel: sel, layout: layout, parts: this, buttons: buttons, flows: flows)); +} diff --git a/test/explorer_panes_test.dart b/test/explorer_panes_test.dart new file mode 100644 index 0000000..6c5f165 --- /dev/null +++ b/test/explorer_panes_test.dart @@ -0,0 +1,255 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/file_tools.dart' show HexViewerPage; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage; +import 'package:path/path.dart' as p; +import 'support/explorer_clock_keys.dart'; +import 'support/explorer_harness.dart'; + +/// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 두 창 누르기 · 한 창 갈 곳 · 슬라이드쇼 · 해시 · 경로 막대 · 길게 = 짝 · 다시 누르면 접기 · 트리 맨 위 항목 · 밀어서 창 바꾸기 +void main() { + useExplorerFixture(); + + testWidgets('두 창: 폴더 한 번 = 펼치기 · 파일 한 번 = 선택 · 두 번 = 실행 · 길게 = 메뉴 · 다른 창으로 복사', (tester) async { + await open(tester); + // 두 창 + 가운데 버튼 줄. 선택 표시는 선택 모드에서만 (규칙 4, 10/22 - 설정으로 늘 보이게 할 수 있음) + expect(find.text('상위 폴더'), findsOneWidget); + expect(find.text('doc.txt'), findsOneWidget); + expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); + // 모니터링 · rsync 는 Rsync 화면에만 + expect(find.text('모니터링'), findsNothing); + expect(find.text('좌 → 우'), findsNothing); + + // 폴더를 한 번 누르면 바로 펼친다. 규칙 3 (10/22): 조금 뒤 다시 누르면 접고, 또 누르면 펼친다 + // (예전 189 "다시 눌러도 접지 않음" 은 규칙 3 으로 바뀜. 두 번 누르기의 두 번째는 접지 않음 - 규칙 3 시험) + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + testNow = testNow.add(const Duration(seconds: 1)); // 두 번 누르기 시간이 지난 뒤 + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); + expect(find.text('inner.txt'), findsNothing, reason: '규칙 3: 다시 누르면 접힘'); + testNow = testNow.add(const Duration(seconds: 1)); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget, reason: '또 누르면 펼침'); + await act(tester, () => tester.tap(find.descendant( + of: find.ancestor(of: find.text('sub'), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.expand_more)))); + await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); + expect(find.text('inner.txt'), findsNothing, reason: '화살표로 접는다'); + // › 화살표로 펼친다 + await act(tester, () => tester.tap(chevronOf('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + + // 파일을 한 번 누르면 고르기만 (실행 안 함) + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + expect(shell.opened, isEmpty); + // 두 번 누르면 기본 앱으로 실행 + await doubleTap(tester, find.text('doc.txt')); + await settle(tester, () => shell.opened.isNotEmpty); + expect(shell.opened.last, (p.join(left, 'doc.txt'), false)); + + // 길게 누르기 → 메뉴 → 다른 앱으로 열기 (고르기 창) + await tester.longPress(find.text('doc.txt')); + await calm(tester); + // 77: 복사 · 이동 · 이름 변경 · 삭제는 메뉴 위 아이콘 줄 (두 창이면 복사는 다른 창으로) + expect(find.byTooltip('다른 창으로 복사'), findsOneWidget); + expect(find.text('삭제'), findsWidgets); + await act(tester, () => tester.tap(find.text('다른 앱으로 열기'))); + await settle(tester, () => shell.opened.length == 2); + expect(shell.opened.last, (p.join(left, 'doc.txt'), true)); + + // doc.txt 를 고른 채 [복사] → 확인 → 오른쪽 창 폴더에 생긴다 + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await act(tester, () => tester.tap(find.text('복사').first)); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello'); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); + }); + + testWidgets('X-plore a: 한 창 + "숨은 다른 창으로" - 위에 갈 곳 줄 · [복사] 는 그 폴더로 · [지금 폴더로] 로 바꾸기 (처음 값은 담아 두기)', (tester) async { + expect(AppSettings().explorerOnePaneCopy, 'clip', reason: '처음 값은 지금 그대로'); + c.settings + ..explorerLayout = 'single' + ..explorerOnePaneCopy = 'other'; + await open(tester); + expect(find.textContaining('갈 곳: '), findsOneWidget); + expect(find.textContaining('갈 곳: right'), findsOneWidget, reason: '숨은 창의 폴더 이름 (전체 경로는 풍선 도움말)'); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await act(tester, () => tester.tap(find.text('복사').first)); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello'); + // [지금 폴더로] → 갈 곳이 지금 보는 폴더 (left) + await act(tester, () => tester.tap(find.text('지금 폴더로'))); + await settle(tester, () => c.settings.explorerPaths[1] == left, rounds: 50); + expect(c.settings.explorerPaths[1], left); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('X-plore 맞추기 (관리자 지시 10/20): 폴더 [사진 슬라이드쇼] - 그 폴더 그림을 [계속 보기] 로 열기 · 그림이 없으면 알림', (tester) async { + for (final n in ['b.png', 'a.png']) { + File(p.join(left, 'sub', n)).writeAsBytesSync(img.encodePng(img.Image(width: 20, height: 30))); + } + Directory(p.join(left, 'empty')).createSync(); + await open(tester); + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.text('사진 슬라이드쇼'))); + await settle(tester, () => find.byType(ReaderPage).evaluate().isNotEmpty); + expect(find.text('1 / 2'), findsOneWidget); + expect(find.text('a.png'), findsOneWidget, reason: '지금 정렬 순서 (이름) 의 첫 그림부터'); + expect(find.byTooltip('계속 보기 멈추기'), findsOneWidget, reason: '[계속 보기] 를 켠 채 시작'); + await act(tester, () async => tester.state(find.byType(Navigator).first).pop()); + await calm(tester); + // 그림이 없는 폴더 + await tester.longPress(find.text('empty')); + await calm(tester); + await act(tester, () => tester.tap(find.text('사진 슬라이드쇼'))); + await settle(tester, () => find.text('이 폴더에는 그림이 없습니다').evaluate().isNotEmpty); + expect(find.byType(ReaderPage), findsNothing); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + }); + + testWidgets('X-plore P3: 길게 누르기 [해시] (MD5 · SHA-256) · 폴더 [폴더 속성] · 빠른 보기 [16진]', (tester) async { + await open(tester); + // 해시: doc.txt = "hello" + await tester.longPress(find.text('doc.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.text('해시 (MD5 · SHA-256)'))); + await settle(tester, () => find.textContaining('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824').evaluate().isNotEmpty); + expect(find.textContaining('5d41402abc4b2a76b9719d911017c592'), findsOneWidget, reason: 'MD5'); + await act(tester, () => tester.tap(find.text('닫기').last)); + await calm(tester); + // 폴더 속성: sub 안에 inner.txt 하나 + await tester.longPress(find.text('sub')); + await calm(tester); + await act(tester, () => tester.tap(find.text('폴더 속성'))); + await settle(tester, () => find.text('파일 1개 · 폴더 0개').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('닫기').last)); + await calm(tester); + // 빠른 보기 → [16진] + await tester.longPress(find.text('doc.txt')); + await calm(tester); + await act(tester, () => tester.tap(find.text('미리 보기'))); + await settle(tester, () => find.text('16진').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('16진'))); + await settle(tester, () => find.byType(HexViewerPage).evaluate().isNotEmpty); + expect(find.byType(HexViewerPage), findsOneWidget); + // 16진 보기가 앞부분을 다 읽어 파일을 놓을 때까지 (실제 시간) 기다린 뒤 닫는다 - 열린 파일이 남으면 시험 폴더를 못 지운다 + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 300))); + await tester.pump(); + await act(tester, () => tester.pageBack()); + await calm(tester); + }); + + testWidgets('X-plore 경로 막대: 위 막대의 경로를 누르면 지금 폴더 · 위 폴더들 (창의 맨 위까지) - 고르면 그리로', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기'))); + await calm(tester); + final items = find.byType(PopupMenuItem); + final n = items.evaluate().length; + expect(n, greaterThanOrEqualTo(2), reason: 'sub · 그 위 폴더들 (창의 맨 위까지)'); + expect(tester.widget>(items.first).enabled, isFalse, reason: '지금 폴더는 고를 수 없음'); + expect(tester.widget>(items.at(1)).value, left, reason: '바로 위 = left'); + await act(tester, () => tester.tap(items.at(1))); + await settle(tester, () => false, rounds: 15); + // 위로 갔으면 다시 열 때 하나 적다 (지금 폴더 = left) + await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기'))); + await calm(tester); + expect(find.byType(PopupMenuItem), findsNWidgets(n - 1)); + }); + + testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('doc.txt'))); + await settle(tester, () => false, rounds: 15); + await tester.longPress(find.text('복사').first); + await calm(tester); + expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget, reason: '복사 길게 = 이동'); + await tester.tap(find.text('취소').last); + await calm(tester); + await tester.longPress(find.text('삭제').first); + await calm(tester); + expect(find.widgetWithText(FilledButton, '영구 삭제'), findsOneWidget, reason: '삭제 길게 = 영구 삭제로 묻기'); + await tester.tap(find.text('취소').last); + await calm(tester); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); + }); + + testWidgets('규칙 3: 펼친 폴더를 다시 누르면 접힘 · 두 번 누르기의 두 번째는 접지 않음 (처음 값 켬)', (tester) async { + expect(AppSettings().explorerRetapCollapse, isTrue, reason: '규칙 3 (10/22)'); + await open(tester); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + // 두 번 누르기 시간 안의 두 번째 누르기 - 접지 않음 + testNow = testNow.add(const Duration(milliseconds: 100)); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => false, rounds: 15); + expect(find.text('inner.txt'), findsOneWidget, reason: '두 번 누르기의 두 번째'); + // 시간이 지난 뒤 다시 누름 - 접힘 + testNow = testNow.add(const Duration(seconds: 1)); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); + expect(find.text('inner.txt'), findsNothing); + }); + + testWidgets('X-plore c: X-plore 스타일 트리 맨 위에 다른 저장 장치 · 즐겨찾기 - 누르면 그곳으로, 즐겨찾기는 펼침', (tester) async { + ExplorerPage.debugVolumes = () => [(left, 'L:'), (right, 'R:')]; + addTearDown(() => ExplorerPage.debugVolumes = null); + c.settings + ..explorerStyle = 'xplore' + ..folderBookmarks = [FolderBookmark(name: '내 즐겨찾기', path: p.join(left, 'sub'))]; + await open(tester); + final otherRoot = find.byKey(ValueKey('k1:$right')); + expect(otherRoot, findsWidgets, reason: '왼쪽 창 트리에 R: (다른 저장 장치) 줄'); + final favs = find.byKey(const ValueKey('k2:\u0000favorites')); + expect(favs, findsWidgets); + expect(find.text('내 즐겨찾기'), findsNothing, reason: '접힌 즐겨찾기'); + await act(tester, () => tester.tap(favs.first)); + await calm(tester); + expect(find.text('내 즐겨찾기'), findsOneWidget); + await act(tester, () => tester.tap(find.text('내 즐겨찾기'))); + await settle(tester, () => c.settings.explorerPaths.first == p.join(left, 'sub'), rounds: 80); + expect(c.settings.explorerPaths.first, p.join(left, 'sub')); + await act(tester, () => tester.tap(find.byKey(ValueKey('k1:$right')).first)); + await settle(tester, () => c.settings.explorerPaths.first == right, rounds: 80); + expect(c.settings.explorerPaths.first, right, reason: '다른 저장 장치 줄 = 그 저장 장치로'); + // 다른 스타일은 그대로 (맨 위 항목 없음) + await tester.runAsync(() => c.updateSettings((x) => x.explorerStyle = 'windows')); + await calm(tester); + expect(find.byKey(const ValueKey('k2:\u0000favorites')), findsNothing); + }); + + testWidgets('X-plore ⚖: 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (한 창이면 숨은 창과 바꿈)', (tester) async { + expect(AppSettings().explorerSwipePanes, isFalse, reason: '처음 값은 끔 - 화면 가운데를 밀면 화면 이동 (10/8 사용자 결정)'); + c.settings.explorerSwipePanes = true; + c.settings.explorerLayout = 'single'; + await open(tester); + expect(c.settings.explorerPaths, [left, right]); + // 화면 가운데 높이에서 (위 · 아래 가장자리 1/5 는 밀기로 보지 않는다 - SwipeNav) + await tester.flingFrom(tester.getCenter(find.byType(ExplorerPage)), const Offset(-400, 0), 1500); + await settle(tester, () => c.settings.explorerPaths.first == right, rounds: 60); + expect(c.settings.explorerPaths, [right, left], reason: '숨은 창이 보이는 창으로'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 1)); + }); +} diff --git a/test/explorer_parallel_test.dart b/test/explorer_parallel_test.dart index e8082f4..446a405 100644 --- a/test/explorer_parallel_test.dart +++ b/test/explorer_parallel_test.dart @@ -11,7 +11,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 'explorer_test.dart' show act, settle, calm; +import 'support/explorer_harness.dart' show act, settle, calm; /// 23: 앞의 복사가 끝나지 않아도 다음 복사를 함께 시작한다 (막지 않음, 진행 막대가 둘) void main() { diff --git a/test/explorer_select_ops_test.dart b/test/explorer_select_ops_test.dart new file mode 100644 index 0000000..4c2d6f5 --- /dev/null +++ b/test/explorer_select_ops_test.dart @@ -0,0 +1,447 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show formatSize; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:path/path.dart' as p; +import 'support/explorer_harness.dart'; + +/// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 폴더 + 파일 목록 · 스타일 · 선택 모드 · 지우기 실패 · 즐겨찾기 · 고르기 도구 · 길게 누르기 메뉴 · 뒤로 · 앞으로 · 트리 화살표 폭 +void main() { + useExplorerFixture(); + + testWidgets('폴더 + 파일 목록 배치 · Windows 탐색기 / Total Commander 스타일', (tester) async { + c.settings + ..explorerLayout = 'split' + ..explorerStyle = 'windows'; + await open(tester); + // 왼쪽 트리는 폴더만, 오른쪽 목록은 고른 폴더 (left) 의 내용 + expect(find.text('sub'), findsNWidgets(2)); // 트리 + 목록 + expect(find.text('doc.txt'), findsOneWidget); // 목록에만 + expect(find.text('수정한 날짜'), findsOneWidget); // Windows 탐색기 열 머리 + expect(find.text('TXT 파일'), findsOneWidget); + expect(find.text('상위 폴더'), findsOneWidget); // 버튼 줄은 두 창 사이 + + // 왼쪽 트리에서 sub 를 고르면 오른쪽 목록이 그 폴더로 + await act(tester, () => tester.tap(find.text('sub').first)); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + expect(find.text('doc.txt'), findsNothing); + // 목록의 ".." 을 누르면 상위 폴더로 + await act(tester, () => tester.tap(find.text('..'))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + expect(find.text('doc.txt'), findsOneWidget); + // 목록의 폴더를 두 번 누르면 들어간다 + await doubleTap(tester, find.text('sub').last); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + + // 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기] + await tester.longPress(find.text('inner.txt')); + await calm(tester); + expect(find.byTooltip('다른 창으로 복사'), findsNothing); + await tester.tap(find.text('복사').last); + await calm(tester); + await act(tester, () => tester.tap(find.text('..'))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + // 73 (관리자): 담아 둔 것이 있으면 [붙여넣기] 는 좁아도 [더 보기] 로 가지 않고 줄에 남는다 + expect(find.text('더 보기'), findsOneWidget, reason: '이 너비에서는 줄이 넘친다'); + expect(find.text('붙여넣기'), findsOneWidget); + await act(tester, () => tester.tap(find.text('붙여넣기'))); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(left, 'inner.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(left, 'inner.txt')).readAsStringSync(), 'inner'); + expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue); // 복사이므로 원본은 그대로 + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); // 목록에 바로 보임 + + // Total Commander: [폴더] · · 확장자 칸 + await c.updateSettings((s) => s.explorerStyle = 'totalcmd'); + await settle(tester, () => find.text('[sub]').evaluate().length == 2); + expect(find.text('[sub]'), findsNWidgets(2)); + expect(find.text(''), findsOneWidget); + expect(find.text('doc'), findsOneWidget); // 확장자를 뺀 이름 + expect(find.text('txt'), findsNWidgets(2)); // 확장자 칸 (doc.txt · 붙여넣은 inner.txt) + expect(find.text('확장자'), findsOneWidget); + // 알림 (복사 끝 등) 이 시험이 끝난 뒤에 닫히지 않게 정리 + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + }); + + testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 폴더 누르기 = 자기 자신과 안의 것 모두 선택 · 다시 누르면 모두 취소', (tester) async { + File(p.join(left, 'sub', 'second.txt')).writeAsStringSync('2'); + await open(tester); + await act(tester, () => tester.tap(find.text('선택').first)); // 가운데 [선택] 버튼 + expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); // 동그라미가 보인다 + // 파일: 누르면 선택, 다시 누르면 취소 (실행하지 않음) + await act(tester, () => tester.tap(find.text('doc.txt'))); + expect(find.textContaining('1개 표시함'), findsOneWidget); + await act(tester, () => tester.tap(find.text('doc.txt'))); + expect(find.textContaining('0개 표시함'), findsNWidgets(2)); // 두 창 모두 (각 창의 선택 수) + expect(shell.opened, isEmpty); + + // 줄 끝 표시 단추: 규칙 4 - 모양은 V (Icons.check), 고른 것은 "표시 지우기" + IconButton markOf(String name) => tester.widget(find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, matching: find.byType(IconButton))); + bool markIcon(String name) { + expect((markOf(name).icon as Icon).icon, Icons.check, reason: '규칙 4: V 모양'); + return markOf(name).tooltip == '표시 지우기'; + } + + // 폴더: 누르면 자기 자신과 안의 것 모두 선택 (펼쳐서 ✔ 로 보여 줌) + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.textContaining('1개 표시함'), findsOneWidget); // 폴더 하나로 안의 것까지 + expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(isTrue)); + // 안의 것 하나를 누르면 그것만 빠지고 나머지는 고른 채로 + await act(tester, () => tester.tap(find.text('inner.txt'))); + await settle(tester, () => false, rounds: 5); + expect(markIcon('inner.txt'), isFalse); + expect(markIcon('second.txt'), isTrue); + expect(markIcon('sub'), isFalse); + // 하나를 뺀 폴더를 다시 누르면 그 폴더와 안의 것 모두 취소 (관리자 결정 2, 10/22 - 규칙 6 "다시 누르면 전체 취소") + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => false, rounds: 5); + expect(find.textContaining('0개 표시함'), findsNWidgets(2)); + expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(isFalse)); + // [선택 끝] → 표시가 모두 풀리고 표시 단추도 사라진다 (규칙 4: 선택 모드에서만) + await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음 + expect(find.byTooltip('표시 지우기'), findsNothing); + expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); + // 설정 "늘 보이기" 를 켜면 X-plore 스타일은 선택 모드 밖에서도 보임 + await tester.runAsync(() => c.updateSettings((x) => x.explorerAlwaysMarks = true)); + await settle(tester, () => false, rounds: 5); + expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); + }); + + testWidgets('65: 여러 개를 지울 때 하나가 실패해도 나머지는 지우고, 못 지운 것을 알린다', (tester) async { + final locked = File(p.join(left, 'locked.txt'))..writeAsStringSync('l'); + final h = locked.openSync(mode: FileMode.append); // Windows: 열려 있는 파일은 지울 수 없다 + addTearDown(h.closeSync); + await open(tester); + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('doc.txt'))); + await act(tester, () => tester.tap(markOf('locked.txt'))); + await act(tester, () => tester.tap(markOf('sub'))); + await settle(tester, () => find.textContaining('3개 표시함').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('삭제').first)); + await calm(tester); + await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); + await settle(tester, () => find.textContaining('지우지 못했습니다').evaluate().isNotEmpty); + expect(find.text('3개 중 1개를 지우지 못했습니다'), findsOneWidget); + expect(find.text('locked.txt'), findsWidgets); + expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse); + expect(Directory(p.join(left, 'sub')).existsSync(), isFalse); + expect(locked.existsSync(), isTrue); + await tester.tap(find.widgetWithText(FilledButton, '확인')); + await calm(tester); + }, skip: !Platform.isWindows); + + testWidgets('73: 즐겨찾기 - 지금 폴더 넣기 (이름 고침) · 한 번에 가기 · 빼기 (폴더는 그대로)', (tester) async { + await open(tester); + // 창 위의 ★ (왼쪽 창) + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await calm(tester); + expect(find.text('아직 없습니다. 아래에서 지금 폴더를 넣으세요.'), findsOneWidget); + await tester.tap(find.text('지금 폴더 추가')); + await calm(tester); + await tester.enterText(find.byType(TextField).last, '왼쪽 폴더'); + await tester.tap(find.widgetWithText(FilledButton, '확인')); + await calm(tester); + expect([for (final b in c.settings.folderBookmarks) (b.name, b.path)], [('왼쪽 폴더', left)]); + expect(find.text('왼쪽 폴더'), findsOneWidget); + await tester.tap(find.text('닫기')); + await calm(tester); + expect(find.byIcon(Icons.star), findsOneWidget, reason: '즐겨찾기한 폴더를 보는 창의 ★ 는 채움'); + + // 오른쪽 폴더를 즐겨찾기에 두고 왼쪽 창에서 한 번에 가기 + await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: 'R', path: right)]); + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await calm(tester); + await act(tester, () => tester.tap(find.text('R'))); + await settle(tester, () => false, rounds: 20); + expect(find.byIcon(Icons.star), findsNWidgets(2), reason: '두 창 모두 즐겨찾기한 폴더 (왼쪽 창이 R 로 감)'); + + // 빼기: 즐겨찾기에서만 (폴더는 그대로) + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await calm(tester); + await tester.tap(find.byTooltip('즐겨찾기에서 빼기 (폴더는 그대로)').first); + await calm(tester); + expect([for (final b in c.settings.folderBookmarks) b.name], ['R']); + expect(Directory(left).existsSync(), isTrue); + await tester.tap(find.text('닫기')); + await calm(tester); + }); + + testWidgets('73: Rsync 화면 - "왼쪽 + 오른쪽" 짝을 넣고 한 번에 연다', (tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + c.settings.rsyncPaths = [left, right]; + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await calm(tester); + await tester.tap(find.text('지금 짝 추가')); + await calm(tester); + await tester.tap(find.widgetWithText(FilledButton, '확인')); + await calm(tester); + final pair = c.settings.folderBookmarks.single; + expect((pair.path, pair.right, pair.name), (left, right, 'left ⇄ right')); + await tester.tap(find.text('닫기')); + await calm(tester); + // 반대 짝을 넣어 두고 열면 두 창이 함께 바뀐다 + await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: '거꾸로', path: right, right: left)]); + await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); + await calm(tester); + await act(tester, () => tester.tap(find.text('거꾸로'))); + await settle(tester, () => c.settings.rsyncPaths.length == 2 && c.settings.rsyncPaths[0] == right && c.settings.rsyncPaths[1] == left, + rounds: 100); + expect(c.settings.rsyncPaths, [right, left]); + }); + + testWidgets('74: 고르기 도구 - 개수 · 합계 크기, 반전, 패턴, 같은 확장자, 범위 (Shift · 길게 누르기)', (tester) async { + for (final n in ['a.srt', 'b.srt', 'c.mkv']) { + File(p.join(left, n)).writeAsStringSync('12345678'); // 8B + } + await open(tester); + await settle(tester, () => find.text('c.mkv').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('a.srt'))); + await settle(tester, () => find.textContaining('1개 표시함').evaluate().isNotEmpty); + expect(find.text('1개 표시함 · 8B'), findsOneWidget); + + Future tool(String label) async { + await tester.tap(find.text('고르기 도구').first); + await calm(tester); + await tester.tap(find.textContaining(label).last); + await calm(tester); + } + + // 같은 확장자 모두 (.srt) - 마지막으로 누른 a.srt 기준 + await tool('같은 확장자 모두'); + expect(find.text('2개 표시함 · 16B'), findsOneWidget); + // 반전: 이 폴더의 나머지 (doc.txt · c.mkv · sub) + await tool('반전'); + expect(find.textContaining('3개 표시함'), findsOneWidget); + expect(find.textContaining('폴더 1개 (크기 빼고)'), findsOneWidget); + // 패턴 + await tester.tap(find.text('표시 지우기').first); + await tester.pump(); + await tool('패턴으로 고르기'); + await tester.enterText(find.byType(TextField).last, '*.MKV; doc.*'); + await tester.tap(find.widgetWithText(FilledButton, '고르기')); + await calm(tester); + expect(find.text('2개 표시함 · 13B'), findsOneWidget); + + // 범위: a.srt 를 표시한 뒤 Shift + c.mkv → a.srt · b.srt · c.mkv (보이는 순서) + await tester.tap(find.text('표시 지우기').first); + await tester.pump(); + await act(tester, () => tester.tap(markOf('a.srt'))); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await act(tester, () => tester.tap(find.text('c.mkv').first)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(find.text('3개 표시함 · 24B'), findsOneWidget); + + // 폰: 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 [여기까지 범위 고르기] + await tester.tap(find.text('표시 지우기').first); + await tester.pump(); + await act(tester, () => tester.tap(markOf('b.srt'))); + await tester.longPress(find.text('doc.txt').first); + await calm(tester); + await tester.tap(find.text('여기까지 범위 고르기')); + await calm(tester); + expect(find.textContaining('3개 표시함'), findsOneWidget, reason: 'b.srt · c.mkv · doc.txt'); + }); + + testWidgets('77: 길게 누르기 메뉴 - 위 아이콘 줄 · 여럿이면 "n개 항목" (이름 변경은 하나일 때만) · 경로 복사 · 즐겨찾기에 추가 · 삭제는 표시한 것 모두', (tester) async { + File(p.join(left, 'b.txt')).writeAsStringSync('b'); + String? clip; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') clip = (call.arguments as Map)['text'] as String?; + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await open(tester); + await settle(tester, () => find.text('b.txt').evaluate().isNotEmpty); + + // 하나: 머리 없음 · 아이콘 줄 · 경로 복사 (실제 경로) + await tester.longPress(find.text('doc.txt').first); + await calm(tester); + expect(find.textContaining('개 항목'), findsNothing); + for (final t in ['다른 창으로 복사', '다른 창으로 이동', '이름 변경', '삭제']) { + expect(find.byTooltip(t), findsWidgets, reason: t); // 이름 변경 · 삭제는 아래 버튼 줄에도 + } + await tester.tap(find.text('경로 복사')); + await calm(tester); + expect(clip, p.join(left, 'doc.txt')); + + // 여럿: "2개 항목" · 이름 변경은 일괄 이름 바꾸기 (78) · 경로 복사는 둘 다 + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('doc.txt'))); + await act(tester, () => tester.tap(markOf('b.txt'))); + await tester.longPress(find.text('b.txt').first); + await calm(tester); + expect(find.text('2개 항목'), findsOneWidget); + expect(find.byTooltip('2개 삭제'), findsOneWidget); + await tester.tap(find.byTooltip('2개 이름 일괄 바꾸기')); + await calm(tester); + expect(find.text('이름 일괄 바꾸기 · 2개'), findsOneWidget, reason: '78: 여럿이면 일괄 이름 바꾸기 창'); + await tester.tap(find.text('취소').last); + await calm(tester); + await tester.longPress(find.text('b.txt').first); + await calm(tester); + await tester.tap(find.text('경로 복사 (2개)')); + await calm(tester); + expect(clip!.split('\n'), unorderedEquals([p.join(left, 'doc.txt'), p.join(left, 'b.txt')])); + + // 삭제 (아이콘 줄) 는 표시한 것 모두 - 확인 창에서 취소 + await tester.longPress(find.text('b.txt').first); + await calm(tester); + await tester.tap(find.byTooltip('2개 삭제')); + await calm(tester); + expect(find.textContaining('2개 항목을'), findsOneWidget); + await tester.tap(find.text('취소').last); + await calm(tester); + expect(File(p.join(left, 'b.txt')).existsSync(), isTrue); + // 선택 끝 (아래 알림이 [선택 끝] 을 가릴 수 있어 Esc) + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await calm(tester); + + // 폴더: 즐겨찾기에 추가 (넣은 뒤에는 메뉴에서 빠짐) + await tester.longPress(find.text('sub').first); + await calm(tester); + await tester.tap(find.text('즐겨찾기에 추가')); + await calm(tester); + expect(c.settings.folderBookmarks.map((x) => x.path), contains(p.join(left, 'sub'))); + await tester.longPress(find.text('sub').first); + await calm(tester); + expect(find.text('즐겨찾기에 추가'), findsNothing); + await tester.tapAt(const Offset(5, 5)); + await calm(tester); + }); + + testWidgets('75: 창마다 뒤로 · 앞으로 - 버튼 · Alt+← / → · 마우스 4 · 5번', (tester) async { + await open(tester); + final sub = p.join(left, 'sub'); + String now() => c.settings.explorerPaths.first; + await act(tester, () => tester.tap(find.text('sub').first)); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + // 뒤로 (버튼): 왼쪽 창은 left 로 + await act(tester, () => tester.tap(find.byTooltip('뒤로 (Alt+←)').first)); + await settle(tester, () => now() == left, rounds: 50); + expect(now(), left); + // 앞으로 (버튼) + await act(tester, () => tester.tap(find.byTooltip('앞으로 (Alt+→)').first)); + await settle(tester, () => now() == sub, rounds: 50); + expect(now(), sub); + // Alt+← · Alt+→ + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await settle(tester, () => now() == left, rounds: 50); + expect(now(), left); + // 마우스 5번 (앞으로) · 4번 (뒤로) + Future mouse(int button) async { + final g = await tester.createGesture(kind: PointerDeviceKind.mouse, buttons: button); + await g.addPointer(location: tester.getCenter(find.text('doc.txt').first)); + await g.down(tester.getCenter(find.text('doc.txt').first)); + await g.up(); + await g.removePointer(); + } + + await mouse(kForwardMouseButton); + await settle(tester, () => now() == sub, rounds: 50); + expect(now(), sub); + await mouse(kBackMouseButton); + await settle(tester, () => now() == left, rounds: 50); + expect(now(), left); + }); + + testWidgets('75: 두 창 - 마우스 4 · 5번은 마우스가 올라간 창에 (그 창이 활성으로)', (tester) async { + File(p.join(right, 'r1', 'x.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('x'); + await open(tester); + final r1 = p.join(right, 'r1'); + final sub = p.join(left, 'sub'); + String at(int i) => c.settings.explorerPaths[i]; + await act(tester, () => tester.tap(find.text('r1'))); + await settle(tester, () => at(1) == r1 && find.text('x.txt').evaluate().isNotEmpty, rounds: 50); + // 왼쪽 창이 활성 + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => at(0) == sub, rounds: 50); + // 오른쪽 창 위에서 4번 (뒤로) - 오른쪽만 뒤로 + final g = await tester.createGesture(kind: PointerDeviceKind.mouse, buttons: kBackMouseButton); + await g.addPointer(location: tester.getCenter(find.text('x.txt'))); + await g.down(tester.getCenter(find.text('x.txt'))); + await g.up(); + await g.removePointer(); + await settle(tester, () => at(1) == right, rounds: 50); + expect([at(0), at(1)], [sub, right]); + // 오른쪽 창이 활성이 되었으므로 Alt+→ 도 오른쪽 + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await settle(tester, () => at(1) == r1, rounds: 50); + expect([at(0), at(1)], [sub, r1]); + }); + + testWidgets('75: 지워진 폴더로 앞으로 - 그 항목은 버리고 지금 폴더 그대로 · 이유를 알림', (tester) async { + await open(tester); + final sub = p.join(left, 'sub'); + String now() => c.settings.explorerPaths.first; + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => now() == sub, rounds: 50); + await act(tester, () => tester.tap(find.byTooltip('뒤로 (Alt+←)').first)); + await settle(tester, () => now() == left, rounds: 50); + await tester.runAsync(() => Directory(sub).delete(recursive: true)); + expect(find.text('sub'), findsOneWidget, reason: '밖에서 지운 것은 아직 모름'); + await act(tester, () => tester.tap(find.byTooltip('앞으로 (Alt+→)').first)); + await settle(tester, () => find.textContaining('내역에서 뺐습니다').evaluate().isNotEmpty, rounds: 50); + expect(find.textContaining('내역에서 뺐습니다'), findsOneWidget); + expect(now(), left); + await settle(tester, () => find.text('sub').evaluate().isEmpty, rounds: 50); + expect(find.text('sub'), findsNothing, reason: '트리에도 남지 않음 (상위 폴더를 다시 읽음)'); + // 버린 항목은 다시 가지 않는다 (앞으로 버튼이 꺼짐) + final fwd = tester.widget(find.ancestor(of: find.byTooltip('앞으로 (Alt+→)').first, matching: find.byType(IconButton)).first); + expect(fwd.onPressed, isNull); + ScaffoldMessenger.of(tester.element(find.byType(Scaffold).first)).clearSnackBars(); + await tester.pump(); + }); + + testWidgets('189: Android 는 트리 화살표를 손가락으로 누를 수 있게 넓게 (줄 높이 전체 · 폭 44)', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await open(tester); + final area = find.ancestor(of: find.descendant( + of: find.ancestor(of: find.text('sub'), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.chevron_right)), + matching: find.byType(InkResponse)).first; + final size = tester.getSize(area); + expect(size.width, greaterThanOrEqualTo(44)); + expect(size.height, greaterThanOrEqualTo(34)); + // 아이콘이 아니라 누르는 영역의 가장자리를 눌러도 펼친다 + final r = tester.getRect(area); + await act(tester, () => tester.tapAt(Offset(r.left + 3, r.top + 3))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + + test('203: 크기 · 바뀐 날을 모르면 (잠긴 시스템 파일) "-1B · 1970-01-01" 대신 "—"', () { + expect(formatSize(-1), '—'); + expect(formatSize(0), '0B'); + expect(fileDateText(DateTime(1970)), '—'); + expect(fileDateText(DateTime.fromMillisecondsSinceEpoch(0)), '—'); + expect(fileDateText(DateTime(2026, 10, 11, 9, 5)), '2026-10-11 09:05'); + }); +} diff --git a/test/explorer_steady_test.dart b/test/explorer_steady_test.dart index db28c2e..14f2466 100644 --- a/test/explorer_steady_test.dart +++ b/test/explorer_steady_test.dart @@ -12,7 +12,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; -import 'explorer_test.dart' show calm; +import 'support/explorer_harness.dart' show calm; /// 180: 복사 · 이동이 시작 · 진행 · 끝나도 목록의 자리와 스크롤은 그대로 (다음 것을 누르려다 엉뚱한 것을 누르지 않게) void main() { late Directory tmp; diff --git a/test/explorer_test.dart b/test/explorer_test.dart deleted file mode 100644 index 7aa2a74..0000000 --- a/test/explorer_test.dart +++ /dev/null @@ -1,2009 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/app_controller.dart'; -import 'package:jj_mkvmaker/app/settings.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/app_shell.dart'; -import 'package:jj_mkvmaker/services/platform_services.dart'; -import 'package:jj_mkvmaker/core/file_ops.dart' show formatSize; -import 'package:jj_mkvmaker/ui/explorer_page.dart'; -import 'package:jj_mkvmaker/ui/hidden_items_dialog.dart'; -import 'package:jj_mkvmaker/ui/explorer_search.dart'; -import 'package:jj_mkvmaker/ui/file_tools.dart' show HexViewerPage; -import 'package:jj_mkvmaker/ui/quick_look.dart'; -import 'package:jj_mkvmaker/ui/explorer_drag.dart'; -import 'package:jj_mkvmaker/ui/explorer_grid.dart'; -import 'package:jj_mkvmaker/ui/text_viewer.dart'; -import 'package:jj_mkvmaker/ui/storage_page.dart'; -import 'package:jj_mkvmaker/ui/path_label.dart'; -import 'package:image/image.dart' as img; -import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage; -import 'package:path/path.dart' as p; -import 'support/explorer_clock_keys.dart'; -import 'support/fake_recycle_bin.dart'; -import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest; -import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; - -/// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게) -class _Shell extends NoopShell { - final opened = <(String, bool)>[]; - final scanned = >[]; - @override - Future mediaScan(List paths) async => scanned.add(paths); - @override - Future openWith(String path, {bool choose = false}) async { - opened.add((path, choose)); - return true; - } -} - -void main() { - late Directory tmp; - late String left, right; - late _Shell shell; - late AppController c; - - setUp(() { - // 휴지통: 기본은 가짜 (이 시험들은 영구 삭제를 고르지만 혹시라도 사용자 휴지통에 닿지 않게) - useTestRecycleBin(); - tmp = Directory.systemTemp.createTempSync('jj_explorer_'); - left = p.join(tmp.path, 'left'); - right = p.join(tmp.path, 'right'); - File(p.join(left, 'doc.txt')) - ..createSync(recursive: true) - ..writeAsStringSync('hello'); - File(p.join(left, 'sub', 'inner.txt')) - ..createSync(recursive: true) - ..writeAsStringSync('inner'); - Directory(right).createSync(); - shell = _Shell(); - c = AppController(PlatformServices( - mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell)); - c.settings.explorerPaths = [left, right]; - }); - // Windows: 방금 닫은 파일 (65 의 잠금 시험) 이나 색인 · 백신이 잠깐 잡고 있으면 지우기가 실패한다 - 될 때까지 기다린다 - tearDown(() async { - for (var i = 0;; i++) { - try { - if (tmp.existsSync()) tmp.deleteSync(recursive: true); - return; - } on FileSystemException { - if (i >= 50) rethrow; - await Future.delayed(const Duration(milliseconds: 100)); - } - } - }); - - Future open(WidgetTester tester) async { - tester.view.physicalSize = const Size(1600, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, clock: () => testNow)))); - await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); - } - - testWidgets('두 창: 폴더 한 번 = 펼치기 · 파일 한 번 = 선택 · 두 번 = 실행 · 길게 = 메뉴 · 다른 창으로 복사', (tester) async { - await open(tester); - // 두 창 + 가운데 버튼 줄. 선택 표시는 선택 모드에서만 (규칙 4, 10/22 - 설정으로 늘 보이게 할 수 있음) - expect(find.text('상위 폴더'), findsOneWidget); - expect(find.text('doc.txt'), findsOneWidget); - expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); - // 모니터링 · rsync 는 Rsync 화면에만 - expect(find.text('모니터링'), findsNothing); - expect(find.text('좌 → 우'), findsNothing); - - // 폴더를 한 번 누르면 바로 펼친다. 규칙 3 (10/22): 조금 뒤 다시 누르면 접고, 또 누르면 펼친다 - // (예전 189 "다시 눌러도 접지 않음" 은 규칙 3 으로 바뀜. 두 번 누르기의 두 번째는 접지 않음 - 규칙 3 시험) - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); - testNow = testNow.add(const Duration(seconds: 1)); // 두 번 누르기 시간이 지난 뒤 - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); - expect(find.text('inner.txt'), findsNothing, reason: '규칙 3: 다시 누르면 접힘'); - testNow = testNow.add(const Duration(seconds: 1)); - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget, reason: '또 누르면 펼침'); - await act(tester, () => tester.tap(find.descendant( - of: find.ancestor(of: find.text('sub'), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.expand_more)))); - await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); - expect(find.text('inner.txt'), findsNothing, reason: '화살표로 접는다'); - // › 화살표로 펼친다 - await act(tester, () => tester.tap(chevronOf('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); - - // 파일을 한 번 누르면 고르기만 (실행 안 함) - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 15); - expect(shell.opened, isEmpty); - // 두 번 누르면 기본 앱으로 실행 - await doubleTap(tester, find.text('doc.txt')); - await settle(tester, () => shell.opened.isNotEmpty); - expect(shell.opened.last, (p.join(left, 'doc.txt'), false)); - - // 길게 누르기 → 메뉴 → 다른 앱으로 열기 (고르기 창) - await tester.longPress(find.text('doc.txt')); - await calm(tester); - // 77: 복사 · 이동 · 이름 변경 · 삭제는 메뉴 위 아이콘 줄 (두 창이면 복사는 다른 창으로) - expect(find.byTooltip('다른 창으로 복사'), findsOneWidget); - expect(find.text('삭제'), findsWidgets); - await act(tester, () => tester.tap(find.text('다른 앱으로 열기'))); - await settle(tester, () => shell.opened.length == 2); - expect(shell.opened.last, (p.join(left, 'doc.txt'), true)); - - // doc.txt 를 고른 채 [복사] → 확인 → 오른쪽 창 폴더에 생긴다 - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 15); - await act(tester, () => tester.tap(find.text('복사').first)); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); - await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); - expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello'); - expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); - }); - - testWidgets('X-plore a: 한 창 + "숨은 다른 창으로" - 위에 갈 곳 줄 · [복사] 는 그 폴더로 · [지금 폴더로] 로 바꾸기 (처음 값은 담아 두기)', (tester) async { - expect(AppSettings().explorerOnePaneCopy, 'clip', reason: '처음 값은 지금 그대로'); - c.settings - ..explorerLayout = 'single' - ..explorerOnePaneCopy = 'other'; - await open(tester); - expect(find.textContaining('갈 곳: '), findsOneWidget); - expect(find.textContaining('갈 곳: right'), findsOneWidget, reason: '숨은 창의 폴더 이름 (전체 경로는 풍선 도움말)'); - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 15); - await act(tester, () => tester.tap(find.text('복사').first)); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); - await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); - expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello'); - // [지금 폴더로] → 갈 곳이 지금 보는 폴더 (left) - await act(tester, () => tester.tap(find.text('지금 폴더로'))); - await settle(tester, () => c.settings.explorerPaths[1] == left, rounds: 50); - expect(c.settings.explorerPaths[1], left); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - await tester.pump(const Duration(seconds: 2)); - }); - - testWidgets('X-plore 맞추기 (관리자 지시 10/20): 폴더 [사진 슬라이드쇼] - 그 폴더 그림을 [계속 보기] 로 열기 · 그림이 없으면 알림', (tester) async { - for (final n in ['b.png', 'a.png']) { - File(p.join(left, 'sub', n)).writeAsBytesSync(img.encodePng(img.Image(width: 20, height: 30))); - } - Directory(p.join(left, 'empty')).createSync(); - await open(tester); - await tester.longPress(find.text('sub')); - await calm(tester); - await act(tester, () => tester.tap(find.text('사진 슬라이드쇼'))); - await settle(tester, () => find.byType(ReaderPage).evaluate().isNotEmpty); - expect(find.text('1 / 2'), findsOneWidget); - expect(find.text('a.png'), findsOneWidget, reason: '지금 정렬 순서 (이름) 의 첫 그림부터'); - expect(find.byTooltip('계속 보기 멈추기'), findsOneWidget, reason: '[계속 보기] 를 켠 채 시작'); - await act(tester, () async => tester.state(find.byType(Navigator).first).pop()); - await calm(tester); - // 그림이 없는 폴더 - await tester.longPress(find.text('empty')); - await calm(tester); - await act(tester, () => tester.tap(find.text('사진 슬라이드쇼'))); - await settle(tester, () => find.text('이 폴더에는 그림이 없습니다').evaluate().isNotEmpty); - expect(find.byType(ReaderPage), findsNothing); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - }); - - testWidgets('X-plore P3: 길게 누르기 [해시] (MD5 · SHA-256) · 폴더 [폴더 속성] · 빠른 보기 [16진]', (tester) async { - await open(tester); - // 해시: doc.txt = "hello" - await tester.longPress(find.text('doc.txt')); - await calm(tester); - await act(tester, () => tester.tap(find.text('해시 (MD5 · SHA-256)'))); - await settle(tester, () => find.textContaining('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824').evaluate().isNotEmpty); - expect(find.textContaining('5d41402abc4b2a76b9719d911017c592'), findsOneWidget, reason: 'MD5'); - await act(tester, () => tester.tap(find.text('닫기').last)); - await calm(tester); - // 폴더 속성: sub 안에 inner.txt 하나 - await tester.longPress(find.text('sub')); - await calm(tester); - await act(tester, () => tester.tap(find.text('폴더 속성'))); - await settle(tester, () => find.text('파일 1개 · 폴더 0개').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.text('닫기').last)); - await calm(tester); - // 빠른 보기 → [16진] - await tester.longPress(find.text('doc.txt')); - await calm(tester); - await act(tester, () => tester.tap(find.text('미리 보기'))); - await settle(tester, () => find.text('16진').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.text('16진'))); - await settle(tester, () => find.byType(HexViewerPage).evaluate().isNotEmpty); - expect(find.byType(HexViewerPage), findsOneWidget); - // 16진 보기가 앞부분을 다 읽어 파일을 놓을 때까지 (실제 시간) 기다린 뒤 닫는다 - 열린 파일이 남으면 시험 폴더를 못 지운다 - await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 300))); - await tester.pump(); - await act(tester, () => tester.pageBack()); - await calm(tester); - }); - - testWidgets('X-plore 경로 막대: 위 막대의 경로를 누르면 지금 폴더 · 위 폴더들 (창의 맨 위까지) - 고르면 그리로', (tester) async { - await open(tester); - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기'))); - await calm(tester); - final items = find.byType(PopupMenuItem); - final n = items.evaluate().length; - expect(n, greaterThanOrEqualTo(2), reason: 'sub · 그 위 폴더들 (창의 맨 위까지)'); - expect(tester.widget>(items.first).enabled, isFalse, reason: '지금 폴더는 고를 수 없음'); - expect(tester.widget>(items.at(1)).value, left, reason: '바로 위 = left'); - await act(tester, () => tester.tap(items.at(1))); - await settle(tester, () => false, rounds: 15); - // 위로 갔으면 다시 열 때 하나 적다 (지금 폴더 = left) - await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기'))); - await calm(tester); - expect(find.byType(PopupMenuItem), findsNWidgets(n - 1)); - }); - - testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async { - await open(tester); - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 15); - await tester.longPress(find.text('복사').first); - await calm(tester); - expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget, reason: '복사 길게 = 이동'); - await tester.tap(find.text('취소').last); - await calm(tester); - await tester.longPress(find.text('삭제').first); - await calm(tester); - expect(find.widgetWithText(FilledButton, '영구 삭제'), findsOneWidget, reason: '삭제 길게 = 영구 삭제로 묻기'); - await tester.tap(find.text('취소').last); - await calm(tester); - expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); - }); - - testWidgets('규칙 3: 펼친 폴더를 다시 누르면 접힘 · 두 번 누르기의 두 번째는 접지 않음 (처음 값 켬)', (tester) async { - expect(AppSettings().explorerRetapCollapse, isTrue, reason: '규칙 3 (10/22)'); - await open(tester); - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); - // 두 번 누르기 시간 안의 두 번째 누르기 - 접지 않음 - testNow = testNow.add(const Duration(milliseconds: 100)); - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => false, rounds: 15); - expect(find.text('inner.txt'), findsOneWidget, reason: '두 번 누르기의 두 번째'); - // 시간이 지난 뒤 다시 누름 - 접힘 - testNow = testNow.add(const Duration(seconds: 1)); - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); - expect(find.text('inner.txt'), findsNothing); - }); - - testWidgets('X-plore c: X-plore 스타일 트리 맨 위에 다른 저장 장치 · 즐겨찾기 - 누르면 그곳으로, 즐겨찾기는 펼침', (tester) async { - ExplorerPage.debugVolumes = () => [(left, 'L:'), (right, 'R:')]; - addTearDown(() => ExplorerPage.debugVolumes = null); - c.settings - ..explorerStyle = 'xplore' - ..folderBookmarks = [FolderBookmark(name: '내 즐겨찾기', path: p.join(left, 'sub'))]; - await open(tester); - final otherRoot = find.byKey(ValueKey('k1:$right')); - expect(otherRoot, findsWidgets, reason: '왼쪽 창 트리에 R: (다른 저장 장치) 줄'); - final favs = find.byKey(const ValueKey('k2:\u0000favorites')); - expect(favs, findsWidgets); - expect(find.text('내 즐겨찾기'), findsNothing, reason: '접힌 즐겨찾기'); - await act(tester, () => tester.tap(favs.first)); - await calm(tester); - expect(find.text('내 즐겨찾기'), findsOneWidget); - await act(tester, () => tester.tap(find.text('내 즐겨찾기'))); - await settle(tester, () => c.settings.explorerPaths.first == p.join(left, 'sub'), rounds: 80); - expect(c.settings.explorerPaths.first, p.join(left, 'sub')); - await act(tester, () => tester.tap(find.byKey(ValueKey('k1:$right')).first)); - await settle(tester, () => c.settings.explorerPaths.first == right, rounds: 80); - expect(c.settings.explorerPaths.first, right, reason: '다른 저장 장치 줄 = 그 저장 장치로'); - // 다른 스타일은 그대로 (맨 위 항목 없음) - await tester.runAsync(() => c.updateSettings((x) => x.explorerStyle = 'windows')); - await calm(tester); - expect(find.byKey(const ValueKey('k2:\u0000favorites')), findsNothing); - }); - - testWidgets('X-plore ⚖: 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (한 창이면 숨은 창과 바꿈)', (tester) async { - expect(AppSettings().explorerSwipePanes, isFalse, reason: '처음 값은 끔 - 화면 가운데를 밀면 화면 이동 (10/8 사용자 결정)'); - c.settings.explorerSwipePanes = true; - c.settings.explorerLayout = 'single'; - await open(tester); - expect(c.settings.explorerPaths, [left, right]); - // 화면 가운데 높이에서 (위 · 아래 가장자리 1/5 는 밀기로 보지 않는다 - SwipeNav) - await tester.flingFrom(tester.getCenter(find.byType(ExplorerPage)), const Offset(-400, 0), 1500); - await settle(tester, () => c.settings.explorerPaths.first == right, rounds: 60); - expect(c.settings.explorerPaths, [right, left], reason: '숨은 창이 보이는 창으로'); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await tester.pump(const Duration(seconds: 1)); - }); - - testWidgets('X-plore f: 압축을 트리 안에서 폴더처럼 펼치기 · 안은 읽기 전용 · 복사는 꺼내기', (tester) async { - final zip = p.join(left, 'pack.zip'); - File(zip).writeAsBytesSync(ZipEncoder().encode(Archive() - ..addFile(ArchiveFile.string('top.txt', 'top')) - ..addFile(ArchiveFile.string('a/b.txt', 'bee')))); - await open(tester); - await settle(tester, () => find.text('pack.zip').evaluate().isNotEmpty); - // › 로 펼치면 압축 안의 것이 폴더처럼 (목록에 폴더 이름이 없어도 경로에서 폴더를 만든다) - await act(tester, () => tester.tap(chevronOf('pack.zip').first)); - await settle(tester, () => find.text('top.txt').evaluate().isNotEmpty); - expect(find.text('a'), findsOneWidget); - await act(tester, () => tester.tap(chevronOf('a').first)); - await settle(tester, () => find.text('b.txt').evaluate().isNotEmpty); - expect(find.text('b.txt'), findsOneWidget); - // 안의 것을 지우려 하면 막는다 (압축은 그대로) - await act(tester, () => tester.tap(find.text('top.txt'))); - await settle(tester, () => false, rounds: 10); - await act(tester, () => tester.tap(find.text('삭제').first)); - await settle(tester, () => find.textContaining('읽기 전용').evaluate().isNotEmpty); - expect(find.textContaining('압축 안은 읽기 전용'), findsOneWidget); - expect(File(zip).existsSync(), isTrue); - expect(Directory(p.join(left, 'pack')).existsSync(), isFalse, reason: '풀지 않음'); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await tester.pump(const Duration(seconds: 1)); - // 압축 안의 폴더를 펼쳐 지금 폴더가 압축 안이면 [새 폴더] 도 막는다 (이름을 묻지 않음) - await act(tester, () => tester.tap(find.text('a'))); - await settle(tester, () => false, rounds: 10); - await act(tester, () => tester.tap(find.text('새 폴더').first)); - await settle(tester, () => find.textContaining('읽기 전용').evaluate().isNotEmpty); - expect(find.textContaining('압축 안은 읽기 전용'), findsOneWidget); - expect(find.byType(TextField), findsNothing, reason: '이름 묻는 창 없음'); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await tester.pump(const Duration(seconds: 1)); - // 데이터 검토 (10/18): 압축 안의 것 메뉴에는 [ZIP 으로 묶기] · [Wi-Fi 로 공유] 가 없다 (실제 파일이 아니라서). 압축 밖은 그대로 - await tester.longPress(find.text('a')); - await calm(tester); - expect(find.text('ZIP 으로 묶기'), findsNothing); - expect(find.text('Wi-Fi 로 공유'), findsNothing); - await tester.tapAt(const Offset(5, 5)); - await calm(tester); - await tester.longPress(find.text('sub')); - await calm(tester); - expect(find.text('ZIP 으로 묶기'), findsOneWidget); - await tester.tapAt(const Offset(5, 5)); - await calm(tester); - }); - - testWidgets('X-plore f 보충: 펼친 뒤 압축 파일이 바뀌면 새로 고칠 때 목록을 다시 읽는다 (관리자 지시 10/19)', (tester) async { - final zip = p.join(left, 'pack.zip'); - File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()..addFile(ArchiveFile.string('old.txt', 'old')))); - await open(tester); - await settle(tester, () => find.text('pack.zip').evaluate().isNotEmpty); - await act(tester, () => tester.tap(chevronOf('pack.zip').first)); - await settle(tester, () => find.text('old.txt').evaluate().isNotEmpty); - expect(find.text('old.txt'), findsOneWidget); - // 다른 프로그램이 압축을 바꿈 (크기 · 바뀐 때가 달라짐) - await tester.runAsync(() async { - await Future.delayed(const Duration(milliseconds: 1100)); - File(zip).writeAsBytesSync(ZipEncoder().encode(Archive() - ..addFile(ArchiveFile.string('new.txt', 'new content')) - ..addFile(ArchiveFile.string('more.txt', 'more')))); - }); - await act(tester, () => refreshKey(tester)); - await settle(tester, () => find.text('new.txt').evaluate().isNotEmpty); - expect(find.text('new.txt'), findsOneWidget); - expect(find.text('more.txt'), findsOneWidget); - expect(find.text('old.txt'), findsNothing); - }); - - testWidgets('X-plore g · h: 폴더 메뉴 [격자로 보기] (길게 = 그 자리로) · 파일 메뉴 [글 뷰어로 보기]', (tester) async { - File(p.join(left, 'sub', 'pic.png')).writeAsBytesSync([0x89, 0x50]); - await open(tester); - // g: 폴더를 길게 → 격자로 보기 → 그림만 (글 파일은 빠짐) - await tester.longPress(find.text('sub')); - await calm(tester); - await act(tester, () => tester.tap(find.text('격자로 보기 (그림 · 동영상)'))); - await settle(tester, () => find.byType(ExplorerGridPage).evaluate().isNotEmpty); - expect(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('pic.png')), findsOneWidget); - expect(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('inner.txt')), findsNothing); - // 칸을 길게 누르면 탐색기에서 그 자리로 (격자 닫힘) - await tester.longPress(find.descendant(of: find.byType(ExplorerGridPage), matching: find.text('pic.png'))); - await settle(tester, () => find.byType(ExplorerGridPage).evaluate().isEmpty && find.descendant(of: find.byType(ExplorerPage), matching: find.text('pic.png')).evaluate().isNotEmpty); - expect(find.descendant(of: find.byType(ExplorerPage), matching: find.text('pic.png')), findsOneWidget, reason: '탐색기에 그 폴더가 펼쳐져 보임 (길게 누른 칸의 이름 풍선은 빼고)'); - // h: 파일을 길게 → 글 뷰어로 보기 (읽기 전용) - await tester.longPress(find.text('doc.txt')); - await calm(tester); - await act(tester, () => tester.tap(find.text('글 뷰어로 보기'))); - await settle(tester, () => find.text('hello').evaluate().isNotEmpty); - expect(find.byType(TextViewerPage), findsOneWidget); - expect(shell.opened, isEmpty, reason: '다른 앱으로 열지 않음'); - await act(tester, () async => Navigator.of(tester.element(find.byType(TextViewerPage))).pop()); - await settle(tester, () => find.byType(TextViewerPage).evaluate().isEmpty); - }); - - testWidgets('X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기', (tester) async { - // 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게) - ExplorerPage.debugAndroidTrash = true; - ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')]; - addTearDown(() { - ExplorerPage.debugAndroidTrash = null; - ExplorerPage.debugVolumes = null; - }); - await open(tester); - final doc = p.join(left, 'doc.txt'); - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 10); - await act(tester, () => tester.tap(find.text('삭제').first)); - await calm(tester); - expect(find.textContaining('앱 휴지통에 14일'), findsOneWidget); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); - await settle(tester, () => !File(doc).existsSync() && find.text('되돌리기').evaluate().isNotEmpty); - expect(File(doc).existsSync(), isFalse); - expect(Directory(p.join(tmp.path, '.jj_trash')).existsSync(), isTrue); - // [되돌리기] - await act(tester, () => tester.tap(find.text('되돌리기'))); - await settle(tester, () => File(doc).existsSync()); - expect(File(doc).readAsStringSync(), 'hello'); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - // 다시 지우고 [휴지통] 화면에서 되살리기 - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 10); - await act(tester, () => tester.tap(find.text('삭제').first)); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); - await settle(tester, () => !File(doc).existsSync()); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - // 휴지통 화면을 연다 (닫힐 때까지 기다리는 Future 라 기다리지 않는다) - unawaited((tester.state(find.byType(ExplorerPage)) as dynamic).debugOpenTrash() as Future); - await settle(tester, () => find.byType(CheckboxListTile).evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.byType(CheckboxListTile).first)); - await settle(tester, () => find.text('되살리기').hitTestable().evaluate().isNotEmpty); // 화면 넘김이 끝나 눌릴 때까지 - await act(tester, () => tester.tap(find.text('되살리기'))); - await settle(tester, () => File(doc).existsSync()); - expect(File(doc).readAsStringSync(), 'hello'); - await tester.pageBack(); - await calm(tester); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await tester.pump(const Duration(seconds: 2)); - }); - - testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async { - c.settings - ..copyMethodFile = 'robocopy' - ..robocopyOptions = '/E /MOV'; - await open(tester); - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 15); - await act(tester, () => tester.tap(find.text('복사').first)); - await calm(tester); - expect(find.textContaining('방법: robocopy'), findsOneWidget); - expect(find.text('환경 설정의 /MOV 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)'), findsOneWidget); - await act(tester, () => tester.tap(find.text('취소').last)); - await calm(tester); - await act(tester, () => tester.tap(find.text('이동').first)); - await calm(tester); - expect(find.textContaining('옮길까요?'), findsOneWidget); - expect(find.textContaining('복사에서는 쓰지 않습니다'), findsNothing); - await act(tester, () => tester.tap(find.text('취소').last)); - await calm(tester); - expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); - expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse); - }, skip: !Platform.isWindows); - - testWidgets('바로 실행 모드: 폴더를 누르면 펼치고, 파일을 누르면 바로 실행', (tester) async { - c.settings.explorerClick = 'open'; - await open(tester); - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => shell.opened.isNotEmpty); - expect(shell.opened.single, (p.join(left, 'doc.txt'), false)); - }); - - testWidgets('폴더 + 파일 목록 배치 · Windows 탐색기 / Total Commander 스타일', (tester) async { - c.settings - ..explorerLayout = 'split' - ..explorerStyle = 'windows'; - await open(tester); - // 왼쪽 트리는 폴더만, 오른쪽 목록은 고른 폴더 (left) 의 내용 - expect(find.text('sub'), findsNWidgets(2)); // 트리 + 목록 - expect(find.text('doc.txt'), findsOneWidget); // 목록에만 - expect(find.text('수정한 날짜'), findsOneWidget); // Windows 탐색기 열 머리 - expect(find.text('TXT 파일'), findsOneWidget); - expect(find.text('상위 폴더'), findsOneWidget); // 버튼 줄은 두 창 사이 - - // 왼쪽 트리에서 sub 를 고르면 오른쪽 목록이 그 폴더로 - await act(tester, () => tester.tap(find.text('sub').first)); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); - expect(find.text('doc.txt'), findsNothing); - // 목록의 ".." 을 누르면 상위 폴더로 - await act(tester, () => tester.tap(find.text('..'))); - await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); - expect(find.text('doc.txt'), findsOneWidget); - // 목록의 폴더를 두 번 누르면 들어간다 - await doubleTap(tester, find.text('sub').last); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); - - // 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기] - await tester.longPress(find.text('inner.txt')); - await calm(tester); - expect(find.byTooltip('다른 창으로 복사'), findsNothing); - await tester.tap(find.text('복사').last); - await calm(tester); - await act(tester, () => tester.tap(find.text('..'))); - await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); - // 73 (관리자): 담아 둔 것이 있으면 [붙여넣기] 는 좁아도 [더 보기] 로 가지 않고 줄에 남는다 - expect(find.text('더 보기'), findsOneWidget, reason: '이 너비에서는 줄이 넘친다'); - expect(find.text('붙여넣기'), findsOneWidget); - await act(tester, () => tester.tap(find.text('붙여넣기'))); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); - await settle(tester, () => File(p.join(left, 'inner.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); - expect(File(p.join(left, 'inner.txt')).readAsStringSync(), 'inner'); - expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue); // 복사이므로 원본은 그대로 - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); // 목록에 바로 보임 - - // Total Commander: [폴더] · · 확장자 칸 - await c.updateSettings((s) => s.explorerStyle = 'totalcmd'); - await settle(tester, () => find.text('[sub]').evaluate().length == 2); - expect(find.text('[sub]'), findsNWidgets(2)); - expect(find.text(''), findsOneWidget); - expect(find.text('doc'), findsOneWidget); // 확장자를 뺀 이름 - expect(find.text('txt'), findsNWidgets(2)); // 확장자 칸 (doc.txt · 붙여넣은 inner.txt) - expect(find.text('확장자'), findsOneWidget); - // 알림 (복사 끝 등) 이 시험이 끝난 뒤에 닫히지 않게 정리 - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - }); - - testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 폴더 누르기 = 자기 자신과 안의 것 모두 선택 · 다시 누르면 모두 취소', (tester) async { - File(p.join(left, 'sub', 'second.txt')).writeAsStringSync('2'); - await open(tester); - await act(tester, () => tester.tap(find.text('선택').first)); // 가운데 [선택] 버튼 - expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); // 동그라미가 보인다 - // 파일: 누르면 선택, 다시 누르면 취소 (실행하지 않음) - await act(tester, () => tester.tap(find.text('doc.txt'))); - expect(find.textContaining('1개 표시함'), findsOneWidget); - await act(tester, () => tester.tap(find.text('doc.txt'))); - expect(find.textContaining('0개 표시함'), findsNWidgets(2)); // 두 창 모두 (각 창의 선택 수) - expect(shell.opened, isEmpty); - - // 줄 끝 표시 단추: 규칙 4 - 모양은 V (Icons.check), 고른 것은 "표시 지우기" - IconButton markOf(String name) => tester.widget(find.descendant( - of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, matching: find.byType(IconButton))); - bool markIcon(String name) { - expect((markOf(name).icon as Icon).icon, Icons.check, reason: '규칙 4: V 모양'); - return markOf(name).tooltip == '표시 지우기'; - } - - // 폴더: 누르면 자기 자신과 안의 것 모두 선택 (펼쳐서 ✔ 로 보여 줌) - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.textContaining('1개 표시함'), findsOneWidget); // 폴더 하나로 안의 것까지 - expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(isTrue)); - // 안의 것 하나를 누르면 그것만 빠지고 나머지는 고른 채로 - await act(tester, () => tester.tap(find.text('inner.txt'))); - await settle(tester, () => false, rounds: 5); - expect(markIcon('inner.txt'), isFalse); - expect(markIcon('second.txt'), isTrue); - expect(markIcon('sub'), isFalse); - // 하나를 뺀 폴더를 다시 누르면 그 폴더와 안의 것 모두 취소 (관리자 결정 2, 10/22 - 규칙 6 "다시 누르면 전체 취소") - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => false, rounds: 5); - expect(find.textContaining('0개 표시함'), findsNWidgets(2)); - expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(isFalse)); - // [선택 끝] → 표시가 모두 풀리고 표시 단추도 사라진다 (규칙 4: 선택 모드에서만) - await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음 - expect(find.byTooltip('표시 지우기'), findsNothing); - expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); - // 설정 "늘 보이기" 를 켜면 X-plore 스타일은 선택 모드 밖에서도 보임 - await tester.runAsync(() => c.updateSettings((x) => x.explorerAlwaysMarks = true)); - await settle(tester, () => false, rounds: 5); - expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); - }); - - testWidgets('65: 여러 개를 지울 때 하나가 실패해도 나머지는 지우고, 못 지운 것을 알린다', (tester) async { - final locked = File(p.join(left, 'locked.txt'))..writeAsStringSync('l'); - final h = locked.openSync(mode: FileMode.append); // Windows: 열려 있는 파일은 지울 수 없다 - addTearDown(h.closeSync); - await open(tester); - await act(tester, () => tester.tap(find.text('선택').first)); - await act(tester, () => tester.tap(markOf('doc.txt'))); - await act(tester, () => tester.tap(markOf('locked.txt'))); - await act(tester, () => tester.tap(markOf('sub'))); - await settle(tester, () => find.textContaining('3개 표시함').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.text('삭제').first)); - await calm(tester); - await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); - await settle(tester, () => find.textContaining('지우지 못했습니다').evaluate().isNotEmpty); - expect(find.text('3개 중 1개를 지우지 못했습니다'), findsOneWidget); - expect(find.text('locked.txt'), findsWidgets); - expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse); - expect(Directory(p.join(left, 'sub')).existsSync(), isFalse); - expect(locked.existsSync(), isTrue); - await tester.tap(find.widgetWithText(FilledButton, '확인')); - await calm(tester); - }, skip: !Platform.isWindows); - - testWidgets('73: 즐겨찾기 - 지금 폴더 넣기 (이름 고침) · 한 번에 가기 · 빼기 (폴더는 그대로)', (tester) async { - await open(tester); - // 창 위의 ★ (왼쪽 창) - await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); - await calm(tester); - expect(find.text('아직 없습니다. 아래에서 지금 폴더를 넣으세요.'), findsOneWidget); - await tester.tap(find.text('지금 폴더 추가')); - await calm(tester); - await tester.enterText(find.byType(TextField).last, '왼쪽 폴더'); - await tester.tap(find.widgetWithText(FilledButton, '확인')); - await calm(tester); - expect([for (final b in c.settings.folderBookmarks) (b.name, b.path)], [('왼쪽 폴더', left)]); - expect(find.text('왼쪽 폴더'), findsOneWidget); - await tester.tap(find.text('닫기')); - await calm(tester); - expect(find.byIcon(Icons.star), findsOneWidget, reason: '즐겨찾기한 폴더를 보는 창의 ★ 는 채움'); - - // 오른쪽 폴더를 즐겨찾기에 두고 왼쪽 창에서 한 번에 가기 - await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: 'R', path: right)]); - await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); - await calm(tester); - await act(tester, () => tester.tap(find.text('R'))); - await settle(tester, () => false, rounds: 20); - expect(find.byIcon(Icons.star), findsNWidgets(2), reason: '두 창 모두 즐겨찾기한 폴더 (왼쪽 창이 R 로 감)'); - - // 빼기: 즐겨찾기에서만 (폴더는 그대로) - await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); - await calm(tester); - await tester.tap(find.byTooltip('즐겨찾기에서 빼기 (폴더는 그대로)').first); - await calm(tester); - expect([for (final b in c.settings.folderBookmarks) b.name], ['R']); - expect(Directory(left).existsSync(), isTrue); - await tester.tap(find.text('닫기')); - await calm(tester); - }); - - testWidgets('73: Rsync 화면 - "왼쪽 + 오른쪽" 짝을 넣고 한 번에 연다', (tester) async { - tester.view.physicalSize = const Size(1600, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - c.settings.rsyncPaths = [left, right]; - await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); - await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); - await calm(tester); - await tester.tap(find.text('지금 짝 추가')); - await calm(tester); - await tester.tap(find.widgetWithText(FilledButton, '확인')); - await calm(tester); - final pair = c.settings.folderBookmarks.single; - expect((pair.path, pair.right, pair.name), (left, right, 'left ⇄ right')); - await tester.tap(find.text('닫기')); - await calm(tester); - // 반대 짝을 넣어 두고 열면 두 창이 함께 바뀐다 - await c.updateSettings((x) => x.folderBookmarks = [...x.folderBookmarks, FolderBookmark(name: '거꾸로', path: right, right: left)]); - await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); - await calm(tester); - await act(tester, () => tester.tap(find.text('거꾸로'))); - await settle(tester, () => c.settings.rsyncPaths.length == 2 && c.settings.rsyncPaths[0] == right && c.settings.rsyncPaths[1] == left, - rounds: 100); - expect(c.settings.rsyncPaths, [right, left]); - }); - - testWidgets('74: 고르기 도구 - 개수 · 합계 크기, 반전, 패턴, 같은 확장자, 범위 (Shift · 길게 누르기)', (tester) async { - for (final n in ['a.srt', 'b.srt', 'c.mkv']) { - File(p.join(left, n)).writeAsStringSync('12345678'); // 8B - } - await open(tester); - await settle(tester, () => find.text('c.mkv').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.text('선택').first)); - await act(tester, () => tester.tap(markOf('a.srt'))); - await settle(tester, () => find.textContaining('1개 표시함').evaluate().isNotEmpty); - expect(find.text('1개 표시함 · 8B'), findsOneWidget); - - Future tool(String label) async { - await tester.tap(find.text('고르기 도구').first); - await calm(tester); - await tester.tap(find.textContaining(label).last); - await calm(tester); - } - - // 같은 확장자 모두 (.srt) - 마지막으로 누른 a.srt 기준 - await tool('같은 확장자 모두'); - expect(find.text('2개 표시함 · 16B'), findsOneWidget); - // 반전: 이 폴더의 나머지 (doc.txt · c.mkv · sub) - await tool('반전'); - expect(find.textContaining('3개 표시함'), findsOneWidget); - expect(find.textContaining('폴더 1개 (크기 빼고)'), findsOneWidget); - // 패턴 - await tester.tap(find.text('표시 지우기').first); - await tester.pump(); - await tool('패턴으로 고르기'); - await tester.enterText(find.byType(TextField).last, '*.MKV; doc.*'); - await tester.tap(find.widgetWithText(FilledButton, '고르기')); - await calm(tester); - expect(find.text('2개 표시함 · 13B'), findsOneWidget); - - // 범위: a.srt 를 표시한 뒤 Shift + c.mkv → a.srt · b.srt · c.mkv (보이는 순서) - await tester.tap(find.text('표시 지우기').first); - await tester.pump(); - await act(tester, () => tester.tap(markOf('a.srt'))); - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await act(tester, () => tester.tap(find.text('c.mkv').first)); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.pump(); - expect(find.text('3개 표시함 · 24B'), findsOneWidget); - - // 폰: 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 [여기까지 범위 고르기] - await tester.tap(find.text('표시 지우기').first); - await tester.pump(); - await act(tester, () => tester.tap(markOf('b.srt'))); - await tester.longPress(find.text('doc.txt').first); - await calm(tester); - await tester.tap(find.text('여기까지 범위 고르기')); - await calm(tester); - expect(find.textContaining('3개 표시함'), findsOneWidget, reason: 'b.srt · c.mkv · doc.txt'); - }); - - testWidgets('77: 길게 누르기 메뉴 - 위 아이콘 줄 · 여럿이면 "n개 항목" (이름 변경은 하나일 때만) · 경로 복사 · 즐겨찾기에 추가 · 삭제는 표시한 것 모두', (tester) async { - File(p.join(left, 'b.txt')).writeAsStringSync('b'); - String? clip; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'Clipboard.setData') clip = (call.arguments as Map)['text'] as String?; - return null; - }); - addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); - await open(tester); - await settle(tester, () => find.text('b.txt').evaluate().isNotEmpty); - - // 하나: 머리 없음 · 아이콘 줄 · 경로 복사 (실제 경로) - await tester.longPress(find.text('doc.txt').first); - await calm(tester); - expect(find.textContaining('개 항목'), findsNothing); - for (final t in ['다른 창으로 복사', '다른 창으로 이동', '이름 변경', '삭제']) { - expect(find.byTooltip(t), findsWidgets, reason: t); // 이름 변경 · 삭제는 아래 버튼 줄에도 - } - await tester.tap(find.text('경로 복사')); - await calm(tester); - expect(clip, p.join(left, 'doc.txt')); - - // 여럿: "2개 항목" · 이름 변경은 일괄 이름 바꾸기 (78) · 경로 복사는 둘 다 - await act(tester, () => tester.tap(find.text('선택').first)); - await act(tester, () => tester.tap(markOf('doc.txt'))); - await act(tester, () => tester.tap(markOf('b.txt'))); - await tester.longPress(find.text('b.txt').first); - await calm(tester); - expect(find.text('2개 항목'), findsOneWidget); - expect(find.byTooltip('2개 삭제'), findsOneWidget); - await tester.tap(find.byTooltip('2개 이름 일괄 바꾸기')); - await calm(tester); - expect(find.text('이름 일괄 바꾸기 · 2개'), findsOneWidget, reason: '78: 여럿이면 일괄 이름 바꾸기 창'); - await tester.tap(find.text('취소').last); - await calm(tester); - await tester.longPress(find.text('b.txt').first); - await calm(tester); - await tester.tap(find.text('경로 복사 (2개)')); - await calm(tester); - expect(clip!.split('\n'), unorderedEquals([p.join(left, 'doc.txt'), p.join(left, 'b.txt')])); - - // 삭제 (아이콘 줄) 는 표시한 것 모두 - 확인 창에서 취소 - await tester.longPress(find.text('b.txt').first); - await calm(tester); - await tester.tap(find.byTooltip('2개 삭제')); - await calm(tester); - expect(find.textContaining('2개 항목을'), findsOneWidget); - await tester.tap(find.text('취소').last); - await calm(tester); - expect(File(p.join(left, 'b.txt')).existsSync(), isTrue); - // 선택 끝 (아래 알림이 [선택 끝] 을 가릴 수 있어 Esc) - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await calm(tester); - - // 폴더: 즐겨찾기에 추가 (넣은 뒤에는 메뉴에서 빠짐) - await tester.longPress(find.text('sub').first); - await calm(tester); - await tester.tap(find.text('즐겨찾기에 추가')); - await calm(tester); - expect(c.settings.folderBookmarks.map((x) => x.path), contains(p.join(left, 'sub'))); - await tester.longPress(find.text('sub').first); - await calm(tester); - expect(find.text('즐겨찾기에 추가'), findsNothing); - await tester.tapAt(const Offset(5, 5)); - await calm(tester); - }); - - testWidgets('75: 창마다 뒤로 · 앞으로 - 버튼 · Alt+← / → · 마우스 4 · 5번', (tester) async { - await open(tester); - final sub = p.join(left, 'sub'); - String now() => c.settings.explorerPaths.first; - await act(tester, () => tester.tap(find.text('sub').first)); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - // 뒤로 (버튼): 왼쪽 창은 left 로 - await act(tester, () => tester.tap(find.byTooltip('뒤로 (Alt+←)').first)); - await settle(tester, () => now() == left, rounds: 50); - expect(now(), left); - // 앞으로 (버튼) - await act(tester, () => tester.tap(find.byTooltip('앞으로 (Alt+→)').first)); - await settle(tester, () => now() == sub, rounds: 50); - expect(now(), sub); - // Alt+← · Alt+→ - await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); - await settle(tester, () => now() == left, rounds: 50); - expect(now(), left); - // 마우스 5번 (앞으로) · 4번 (뒤로) - Future mouse(int button) async { - final g = await tester.createGesture(kind: PointerDeviceKind.mouse, buttons: button); - await g.addPointer(location: tester.getCenter(find.text('doc.txt').first)); - await g.down(tester.getCenter(find.text('doc.txt').first)); - await g.up(); - await g.removePointer(); - } - - await mouse(kForwardMouseButton); - await settle(tester, () => now() == sub, rounds: 50); - expect(now(), sub); - await mouse(kBackMouseButton); - await settle(tester, () => now() == left, rounds: 50); - expect(now(), left); - }); - - testWidgets('75: 두 창 - 마우스 4 · 5번은 마우스가 올라간 창에 (그 창이 활성으로)', (tester) async { - File(p.join(right, 'r1', 'x.txt')) - ..createSync(recursive: true) - ..writeAsStringSync('x'); - await open(tester); - final r1 = p.join(right, 'r1'); - final sub = p.join(left, 'sub'); - String at(int i) => c.settings.explorerPaths[i]; - await act(tester, () => tester.tap(find.text('r1'))); - await settle(tester, () => at(1) == r1 && find.text('x.txt').evaluate().isNotEmpty, rounds: 50); - // 왼쪽 창이 활성 - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => at(0) == sub, rounds: 50); - // 오른쪽 창 위에서 4번 (뒤로) - 오른쪽만 뒤로 - final g = await tester.createGesture(kind: PointerDeviceKind.mouse, buttons: kBackMouseButton); - await g.addPointer(location: tester.getCenter(find.text('x.txt'))); - await g.down(tester.getCenter(find.text('x.txt'))); - await g.up(); - await g.removePointer(); - await settle(tester, () => at(1) == right, rounds: 50); - expect([at(0), at(1)], [sub, right]); - // 오른쪽 창이 활성이 되었으므로 Alt+→ 도 오른쪽 - await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); - await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); - await settle(tester, () => at(1) == r1, rounds: 50); - expect([at(0), at(1)], [sub, r1]); - }); - - testWidgets('75: 지워진 폴더로 앞으로 - 그 항목은 버리고 지금 폴더 그대로 · 이유를 알림', (tester) async { - await open(tester); - final sub = p.join(left, 'sub'); - String now() => c.settings.explorerPaths.first; - await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => now() == sub, rounds: 50); - await act(tester, () => tester.tap(find.byTooltip('뒤로 (Alt+←)').first)); - await settle(tester, () => now() == left, rounds: 50); - await tester.runAsync(() => Directory(sub).delete(recursive: true)); - expect(find.text('sub'), findsOneWidget, reason: '밖에서 지운 것은 아직 모름'); - await act(tester, () => tester.tap(find.byTooltip('앞으로 (Alt+→)').first)); - await settle(tester, () => find.textContaining('내역에서 뺐습니다').evaluate().isNotEmpty, rounds: 50); - expect(find.textContaining('내역에서 뺐습니다'), findsOneWidget); - expect(now(), left); - await settle(tester, () => find.text('sub').evaluate().isEmpty, rounds: 50); - expect(find.text('sub'), findsNothing, reason: '트리에도 남지 않음 (상위 폴더를 다시 읽음)'); - // 버린 항목은 다시 가지 않는다 (앞으로 버튼이 꺼짐) - final fwd = tester.widget(find.ancestor(of: find.byTooltip('앞으로 (Alt+→)').first, matching: find.byType(IconButton)).first); - expect(fwd.onPressed, isNull); - ScaffoldMessenger.of(tester.element(find.byType(Scaffold).first)).clearSnackBars(); - await tester.pump(); - }); - - testWidgets('189: Android 는 트리 화살표를 손가락으로 누를 수 있게 넓게 (줄 높이 전체 · 폭 44)', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - await open(tester); - final area = find.ancestor(of: find.descendant( - of: find.ancestor(of: find.text('sub'), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.chevron_right)), - matching: find.byType(InkResponse)).first; - final size = tester.getSize(area); - expect(size.width, greaterThanOrEqualTo(44)); - expect(size.height, greaterThanOrEqualTo(34)); - // 아이콘이 아니라 누르는 영역의 가장자리를 눌러도 펼친다 - final r = tester.getRect(area); - await act(tester, () => tester.tapAt(Offset(r.left + 3, r.top + 3))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); - expect(find.text('inner.txt'), findsOneWidget); - debugDefaultTargetPlatformOverride = null; - }); - - test('203: 크기 · 바뀐 날을 모르면 (잠긴 시스템 파일) "-1B · 1970-01-01" 대신 "—"', () { - expect(formatSize(-1), '—'); - expect(formatSize(0), '0B'); - expect(fileDateText(DateTime(1970)), '—'); - expect(fileDateText(DateTime.fromMillisecondsSinceEpoch(0)), '—'); - expect(fileDateText(DateTime(2026, 10, 11, 9, 5)), '2026-10-11 09:05'); - }); - - testWidgets('76: 빠른 필터 - Ctrl+F · 글자 바로 치기 · 패턴, 모두 선택은 보이는 것만, Esc 로 닫으면 모두', (tester) async { - for (final n in ['ep01.mkv', 'ep02.mkv', 'ep10.srt', 'other.txt']) { - File(p.join(left, n)).writeAsStringSync('12345678'); - } - await open(tester); - await settle(tester, () => find.text('other.txt').evaluate().isNotEmpty); - Iterable names() => [ - for (final n in ['doc.txt', 'sub', 'ep01.mkv', 'ep02.mkv', 'ep10.srt', 'other.txt']) - if (find.text(n).evaluate().isNotEmpty) n, - ]; - - // Ctrl+F → 칸 · "ep0" - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyF); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await calm(tester); - expect(find.textContaining('이름에 든 글자로 좁히기'), findsOneWidget); - await tester.enterText(find.byType(TextField).first, 'EP0'); - await tester.pump(); - expect(names(), ['ep01.mkv', 'ep02.mkv'], reason: '대소문자 무시'); - expect(find.text('2개 보임'), findsOneWidget); - - // 모두 선택 · 반전은 보이는 것만 - await act(tester, () => tester.tap(find.text('선택').first)); - await tester.tap(find.text('모두 선택').first); - await tester.pump(); - expect(find.textContaining('2개 표시함'), findsOneWidget); - await tester.tap(find.text('선택 끝').first); - await tester.pump(); - - // 패턴 · 맞는 것 없음 - await tester.enterText(find.byType(TextField).first, '*.srt'); - await tester.pump(); - expect(names(), ['ep10.srt']); - await tester.enterText(find.byType(TextField).first, 'zzz'); - await tester.pump(); - expect(find.text('맞는 것이 없습니다'), findsOneWidget); - - // Esc → 닫고 모두 - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await calm(tester); - expect(find.textContaining('이름에 든 글자로 좁히기'), findsNothing); - expect(names(), containsAll(['doc.txt', 'sub', 'ep01.mkv', 'other.txt'])); - - // 닫으면 키는 탐색기로 돌아온다 - 목록에서 글자를 바로 치면 그 글자로 열린다. - // 사람처럼 줄을 누르고 곧바로 친다 (두 번 누르기를 기다리는 중에 줄 목록이 바뀜) - await tester.pump(); - await tester.tap(find.text('doc.txt').first); - await tester.pump(const Duration(milliseconds: 20)); - await tester.sendKeyEvent(LogicalKeyboardKey.keyO, character: 'o'); - await calm(tester); - expect(tester.widget(find.byType(TextField).first).controller!.text, 'o'); - expect(names(), ['doc.txt', 'other.txt']); - // 칸에서 더 치면 칸이 받는다 (두 번 들어가지 않음) - await tester.enterText(find.byType(TextField).first, 'oth'); - await tester.pump(); - expect(names(), ['other.txt']); - expect(tester.takeException(), isNull); - }); - - testWidgets('76: 파일 줄을 누르고 곧바로 쳐서 그 줄이 사라지거나 다른 줄이 그 자리에 와도, 누른 것이 다른 항목에 들어가지 않는다', (tester) async { - for (final n in ['a1.mkv', 'b2.mkv']) { - File(p.join(left, n)).writeAsStringSync('x'); - } - Directory(p.join(left, 'a_folder')).createSync(); - await open(tester); - await settle(tester, () => find.text('b2.mkv').evaluate().isNotEmpty); - // 누르기 = 선택 (두 번 = 실행) 모드: 파일 줄은 두 번 누르기를 기다린다 - await tester.tap(find.text('b2.mkv').first); - await tester.pump(const Duration(milliseconds: 20)); - // "a" → b2.mkv 가 사라지고 그 자리에 폴더 · 다른 파일이 온다 - await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a'); - await tester.pump(); - expect(tester.takeException(), isNull); - await tester.pump(const Duration(milliseconds: 500)); - expect(tester.takeException(), isNull); - expect(find.text('b2.mkv'), findsNothing); - expect(shell.opened, isEmpty, reason: '누른 것이 실행되지 않음'); - // 다른 항목이 고른 것 (강조) 이 되지 않음 - 폴더가 펼쳐지지도 않음 - expect(find.text('a_folder'), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await calm(tester); - expect(find.text('b2.mkv'), findsOneWidget); - }); - - test('76: 빠른 필터 맞추기 - 빈 칸은 모두 · 글자는 이름 안 어디든 · * ? 는 이름 전체 패턴', () { - expect(quickFilterMatch('Show.S01E02.mkv', ''), isTrue); - expect(quickFilterMatch('Show.S01E02.mkv', 's01e'), isTrue); - expect(quickFilterMatch('Show.S01E02.mkv', 'e03'), isFalse); - expect(quickFilterMatch('Show.S01E02.mkv', '*.mkv'), isTrue); - expect(quickFilterMatch('Show.S01E02.mkv', 'S01*'), isFalse, reason: '패턴은 이름 전체'); - }); - - test('74: 패턴 (* · ? · 여러 개 · 대소문자 무시)', () { - final re = globToRegExp('*.srt; ep??.mkv'); - expect(['a.srt', 'A.SRT', 'ep01.mkv'].every(re.hasMatch), isTrue); - expect(['a.srt.bak', 'ep1.mkv', 'x.ass'].any(re.hasMatch), isFalse); - }); - - test('73: 즐겨찾기 저장 · 읽기 (Rsync 짝 포함)', () { - final s = AppSettings() - ..folderBookmarks = const [FolderBookmark(name: 'NAS/영화', path: r'\\nas\movies'), FolderBookmark(name: '백업', path: r'D:\a', right: r'E:\b')]; - final b = AppSettings.fromJson(s.toJson()).folderBookmarks; - expect([for (final x in b) (x.name, x.path, x.right, x.isPair)], [('NAS/영화', r'\\nas\movies', null, false), ('백업', r'D:\a', r'E:\b', true)]); - }); - - testWidgets('여러 개 표시 → 삭제 · 새 폴더 · 이름 변경', (tester) async { - await open(tester); - await act(tester, () => tester.tap(find.text('선택').first)); - // 동그라미로 doc.txt · sub 표시 (반드시 이름으로 찾은 줄의 버튼만: 목록에는 시험 폴더 위쪽의 실제 폴더도 보인다) - await act(tester, () => tester.tap(markOf('sub'))); - await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); // 폴더를 고르면 펼쳐서 안의 것도 ✔ - await act(tester, () => tester.tap(markOf('doc.txt'))); - await settle(tester, () => find.textContaining('2개 표시함').evaluate().isNotEmpty); - expect(find.textContaining('2개 표시함'), findsOneWidget); - await act(tester, () => tester.tap(find.text('삭제').first)); - await calm(tester); - // 지울 항목 이름이 확인 창에 보인다. 65: Windows 는 기본이 휴지통 (시험에서는 영구 삭제를 골라 휴지통을 어지럽히지 않음) - expect(find.textContaining('2개 항목을 휴지통으로 보낼까요?'), findsOneWidget); - expect(find.widgetWithText(FilledButton, '휴지통으로'), findsOneWidget); - expect(find.textContaining('· sub'), findsOneWidget); - expect(find.textContaining('· doc.txt'), findsOneWidget); - await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); - await calm(tester); - expect(find.textContaining('2개 항목을 지울까요? 되돌릴 수 없습니다.'), findsOneWidget); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); - await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); - expect(Directory(left).listSync(), isEmpty); - - // 새 폴더 → 이름 변경 (지운 뒤 창들이 목록을 다시 읽는 동안은 진행 막대가 돌아 pumpAndSettle 이 끝나지 않는다 - 무거울 때 흔들림) - await idle(tester); - await act(tester, () => tester.tap(find.text('새 폴더').first)); - await calm(tester); - await act(tester, () => tester.enterText(find.byType(TextField), 'films')); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); - await settle(tester, () => find.text('films').evaluate().isNotEmpty); - expect(Directory(p.join(left, 'films')).existsSync(), isTrue); - await idle(tester); - await act(tester, () => tester.tap(find.text('이름 변경').first)); - await calm(tester); - await act(tester, () => tester.enterText(find.byType(TextField), 'movies')); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); - await settle(tester, () => find.text('movies').evaluate().isNotEmpty); - expect(Directory(p.join(left, 'movies')).existsSync(), isTrue); - }); - - testWidgets('창 배치: 한 창 · 버튼 줄 숨김, 버튼 구성: 버튼 빼기 → 설정에 저장', (tester) async { - await open(tester); - await tester.tap(find.byTooltip('창 배치 · 버튼 구성')); - await calm(tester); - await tester.tap(find.text('창 배치').last); - await calm(tester); - await tester.tap(find.widgetWithText(ChoiceChip, '한 창')); - await tester.pump(); - await tester.tap(find.widgetWithText(ChoiceChip, '숨김')); - await tester.pump(); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); - await calm(tester); - expect([c.settings.explorerLayout, c.settings.explorerToolbar], ['single', 'hidden']); - expect(find.text('상위 폴더'), findsNothing); // 버튼 줄 숨김 - expect(find.text('doc.txt'), findsOneWidget); // 한 창 (왼쪽만) - - // ⋮ 메뉴에서 버튼 구성: "찾기" 빼기 - await c.updateSettings((s) => s.explorerToolbar = 'middle'); - await calm(tester); - await tester.tap(find.byTooltip('창 배치 · 버튼 구성')); - await calm(tester); - await tester.tap(find.text('버튼 구성').last); - await calm(tester); - // 처음 값 순서 (자주 쓰는 것부터) 에서 뒤쪽이라 목록을 내려 보이게 한 뒤 - await tester.scrollUntilVisible(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가'), 80, - scrollable: find.descendant(of: find.byType(AlertDialog), matching: find.byType(Scrollable)).first); - await tester.tap(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가')); - await tester.pump(); - await tester.tap(find.widgetWithText(FilledButton, '확인')); - await calm(tester); - expect(c.settings.explorerButtons, isNot(contains('addMkv'))); - expect(c.settings.explorerButtons, contains('copy')); - expect(find.text('MKV 목록에 추가'), findsNothing); - - // 설정 저장 · 읽기 - final back = AppSettings.fromJson(c.settings.toJson()); - expect([back.explorerLayout, back.explorerToolbar, back.explorerButtons], - [c.settings.explorerLayout, c.settings.explorerToolbar, c.settings.explorerButtons]); - }); - - testWidgets('Rsync 화면: 늘 좌우 두 창 · 폴더만 · 창마다 하나만 고르기 · → ← ⇄ · 모니터링', (tester) async { - Directory(p.join(left, 'sub2')).createSync(); - Directory(p.join(right, 'rsub')).createSync(); - File(p.join(right, 'r.txt')).writeAsStringSync('r'); - File(p.join(right, 'rsub', 'back.txt')).writeAsStringSync('b'); // ⇄ 비교에서 ← 로 건너갈 것 - c.settings - ..explorerLayout = 'single' // 파일 탐색기 배치와 상관없이 두 창 - ..rsyncPaths = [left, right]; - tester.view.physicalSize = const Size(1600, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); - await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('rsub').evaluate().isNotEmpty); - expect(find.text('Rsync'), findsWidgets); - // 폴더만 (파일은 안 보임) · 버튼은 → ← ⇄ 모니터링 (파일 기능 · 선택 없음) - expect(find.text('doc.txt'), findsNothing); - expect(find.text('r.txt'), findsNothing); - for (final t in ['좌 → 우', '좌 ← 우', '좌 ⇄ 우', '모니터링']) { - expect(find.text(t), findsOneWidget); - } - expect(find.text('선택'), findsNothing); - expect(find.text('삭제'), findsNothing); - - // 고르지 않고 → : 알림 - await act(tester, () => tester.tap(find.text('좌 → 우'))); - expect(find.text('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.'), findsOneWidget); - - // 창마다 하나만: sub → sub2 로 바꾸면 sub 는 풀림, 다시 누르면 취소 - await act(tester, () => tester.tap(find.text('sub'))); - await act(tester, () => tester.tap(find.text('sub2'))); - await settle(tester, () => false, rounds: 5); - expect(find.byTooltip('고르기 취소'), findsOneWidget); - await act(tester, () => tester.tap(find.text('sub2'))); - await settle(tester, () => false, rounds: 5); - expect(find.byTooltip('고르기 취소'), findsNothing); - await act(tester, () => tester.tap(find.text('sub'))); - await act(tester, () => tester.tap(find.text('rsub'))); - await settle(tester, () => false, rounds: 5); - expect(find.byTooltip('고르기 취소'), findsNWidgets(2)); // 왼쪽 하나 · 오른쪽 하나 - - // ⇄ : 두 방향을 보여 주고 -u, 취소하면 모니터링에 남지 않는다 - await act(tester, () => tester.tap(find.text('좌 ⇄ 우'))); - await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); - // 72: 실행 전에 무엇이 바뀌는지 (⇄: 양쪽에만 있는 파일이 서로 건너감) - expect(find.textContaining('→ 새로'), findsOneWidget); - expect(find.textContaining('← 새로'), findsOneWidget); - expect(find.text('rsync 양쪽 (⇄)'), findsOneWidget); - // 109: 짧은 경로 (끝 폴더) + 전체 경로는 작게 - final ls = p.join(left, 'sub'), rs = p.join(right, 'rsub'); - expect(find.text('→ ${shortPath(rs)}'), findsOneWidget); - expect(find.text('→ ${shortPath(ls)}'), findsOneWidget); - expect(find.text('$ls/ → $rs/'), findsOneWidget); - expect(find.text('$rs/ → $ls/'), findsOneWidget); - expect(find.textContaining('-avPog -u'), findsNWidgets(2)); - await tester.tap(find.text('취소')); - await calm(tester); - expect(c.settings.copyTasks, isEmpty); - expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음 - // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) - await act(tester, () => tester.tap(find.text('좌 → 우'))); - await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); - expect(find.text('$ls/ → $rs/'), findsOneWidget); - expect(find.text('$rs/ → $ls/'), findsNothing); - // 옵션 낱말 -u 만 (폴더 이름 vscode-stable-user-x64 같은 것은 아님 - 시험 임시 폴더 위가 %TEMP% 일 때) - expect(find.textContaining(RegExp(r'(^|\s)-u(\s|$)')), findsNothing); - expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing); - await tester.tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)'))); - await calm(tester); - expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget); - expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget); - expect(find.text('빈 폴더 지움 · 원본 폴더도 지움'), findsOneWidget); - expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget); - await tester.tap(find.text('취소')); - await calm(tester); - // 고른 폴더는 기억 (다음에 열 때 그 폴더부터) - expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]); - - // 새 폴더: 만든 폴더가 바로 그 창의 원본 · 대상으로 골라진다 (오른쪽 창 rsub 안에) - await act(tester, () => tester.tap(find.text('rsub'))); - await settle(tester, () => false, rounds: 5); // rsub 를 다시 눌러 취소 - await act(tester, () => tester.tap(find.text('rsub'))); - await settle(tester, () => false, rounds: 5); // 다시 골라 지금 폴더로 - await act(tester, () => tester.tap(find.text('새 폴더'))); - await calm(tester); - await tester.enterText(find.byType(TextField), '새로만든'); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); - // 만든 폴더가 보이고 골라질 때까지 (전체 시험 중 바쁠 때는 고르기가 조금 늦다) - Finder newRow() => find.ancestor(of: find.text('새로만든'), matching: find.byType(Material)).first; - await settle(tester, () => - find.text('새로만든').evaluate().isNotEmpty && - find.descendant(of: newRow(), matching: find.byTooltip('고르기 취소')).evaluate().isNotEmpty); - expect(Directory(p.join(right, 'rsub', '새로만든')).existsSync(), isTrue); - expect(find.descendant(of: newRow(), matching: find.byTooltip('고르기 취소')), findsOneWidget); - expect(find.descendant(of: find.ancestor(of: find.text('rsub'), matching: find.byType(Material)).first, - matching: find.byTooltip('고르기 취소')), findsNothing); // 창마다 하나만 - }); - - testWidgets('78: 여럿을 골라 [이름 변경] = 일괄 이름 바꾸기 → 목록 차례대로 번호 · [되돌리기]', (tester) async { - File(p.join(left, 'b.txt')).writeAsStringSync('b'); - await open(tester); - await act(tester, () => tester.tap(find.text('선택').first)); - await act(tester, () => tester.tap(markOf('doc.txt'))); - await act(tester, () => tester.tap(markOf('b.txt'))); - await act(tester, () => tester.tap(find.text('이름 변경').first)); - await settle(tester, () => find.text('바뀌는 것 0개').evaluate().isNotEmpty); - await tester.enterText(find.widgetWithText(TextField, '이름 틀'), 'Ep[C]'); - await tester.pump(); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '바꾸기 (2개)'))); - // 알림은 목록을 다시 읽은 뒤에 뜬다 - 알림까지 기다린다 (PC 가 바쁠 때 한 번 흔들림) - await settle(tester, () => File(p.join(left, 'Ep02.txt')).existsSync() && find.text('2개의 이름을 바꿨습니다.').evaluate().isNotEmpty); - // 목록 차례 (이름순: b.txt, doc.txt) 대로 번호 - expect(File(p.join(left, 'Ep01.txt')).readAsStringSync(), 'b'); - expect(File(p.join(left, 'Ep02.txt')).readAsStringSync(), 'hello'); - expect(find.text('2개의 이름을 바꿨습니다.'), findsOneWidget); - await act(tester, () => tester.tap(find.text('되돌리기'))); - // 알림이 닫히는 움직임을 끝까지 (시험이 끝난 뒤에 끝나면 Scaffold 'mounted' 단언에 걸린다) - await tester.pump(const Duration(seconds: 1)); - await settle(tester, () => File(p.join(left, 'doc.txt')).existsSync() && find.text('doc.txt').evaluate().isNotEmpty); - expect(File(p.join(left, 'b.txt')).readAsStringSync(), 'b'); - expect(File(p.join(left, 'Ep01.txt')).existsSync(), isFalse); - }); - - // 79-c (관리자 판단 · 데이터): 찾기 결과에서 폴더와 그 안의 파일을 함께 고르면 안의 것은 한 번만 (폴더와 함께) - group('79-c 폴더 + 안의 파일을 함께 고름', () { - /// "sub" 로 찾아 sub 폴더 · sub/sub_a.txt 를 모두 고른다 - Future findSubAll(WidgetTester tester) async { - File(p.join(left, 'sub', 'sub_a.txt')).writeAsStringSync('aaaaa'); - await open(tester); - await act(tester, () => tester.tap(find.text('찾기').first)); - await calm(tester); - await tester.enterText(find.widgetWithText(TextField, '이름 (비워도 됨)'), 'sub'); - await tester.pump(); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); - await settle(tester, () => find.text('찾기 결과 · 2개').evaluate().isNotEmpty && find.widgetWithText(TextButton, '모두').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.widgetWithText(TextButton, '모두'))); - await calm(tester); - // 79-b: 고른 폴더 안의 파일은 세지 않고, 폴더 크기는 빠졌다고 적는다 - expect(find.text('폴더 1개 (크기 미포함)'), findsOneWidget); - } - - testWidgets('복사: 한 번만 - 다른 창에 sub/sub_a.txt 하나', (tester) async { - await findSubAll(tester); - await act(tester, () => tester.tap(find.text('복사 → right'))); - await calm(tester); - expect(find.textContaining('sub_a.txt'), findsNothing, reason: '확인 창에도 안쪽 파일은 따로 나오지 않는다'); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); - await settle(tester, () => File(p.join(right, 'sub', 'sub_a.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); - expect(File(p.join(right, 'sub_a.txt')).existsSync(), isFalse, reason: '안쪽 파일이 따로 한 번 더 복사되지 않음'); - expect(find.textContaining('오류'), findsNothing); - }); - - testWidgets('이동: "없음" 오류 없이 한 번만 · [찾기 결과로 돌아가기] 는 새 자리', (tester) async { - await findSubAll(tester); - await act(tester, () => tester.tap(find.text('이동 → right'))); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); - await settle(tester, () => File(p.join(right, 'sub', 'sub_a.txt')).existsSync() && !Directory(p.join(left, 'sub')).existsSync() && - find.byType(LinearProgressIndicator).evaluate().isEmpty); - expect(File(p.join(right, 'sub_a.txt')).existsSync(), isFalse); - expect(find.textContaining('없'), findsNothing, reason: '안쪽 파일을 따로 옮기려다 "없음" 이 나지 않는다'); - // 79-a: 다시 찾지 않고 결과로 - 옮긴 것은 새 자리 - expect(find.text('찾기 결과로 돌아가기'), findsOneWidget); - await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); - await settle(tester, () => find.text('찾기 결과 · 2개').evaluate().isNotEmpty); - final results = find.byType(SearchResultsPage); - expect(find.descendant(of: results, matching: find.textContaining(right)), findsNWidgets(2)); - }); - - testWidgets('지우기: 확인 창은 1개 항목 · 폴더 1개', (tester) async { - await findSubAll(tester); - await act(tester, () => tester.tap(find.text('지우기'))); - await calm(tester); - expect(find.textContaining('"sub" 을(를) 휴지통으로 보낼까요?'), findsOneWidget, reason: '안쪽 파일을 빼고 하나'); - expect(find.text('그중 폴더 1개 - 안의 파일 · 폴더도 모두 함께'), findsOneWidget); - await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); - await settle(tester, () => !Directory(p.join(left, 'sub')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); - expect(find.textContaining('지우지 못'), findsNothing); - // 79-a: 지운 것은 결과에서 빠진다 - await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); - await settle(tester, () => find.text('찾기 결과 · 0개').evaluate().isNotEmpty); - expect(find.text('찾은 것이 없습니다.'), findsOneWidget); - }); - }); - - testWidgets('83: Total Commander 모양 단축키 - F5 복사 · F7 새 폴더 · Space/Insert 표시 (Space 는 빠른 필터가 아님)', (tester) async { - File(p.join(left, 'e.txt')).writeAsStringSync('e'); - c.settings.explorerStyle = 'totalcmd'; - await open(tester); - await act(tester, () => tester.tap(find.text('doc'))); - await settle(tester, () => false, rounds: 15); - // Insert: 표시하고 다음 줄 (e.txt) 로, Space: 지금 줄 (e.txt) 표시 - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.insert)); - await calm(tester); - expect(find.textContaining('1개 표시함'), findsOneWidget); - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.space)); - await calm(tester); - expect(find.textContaining('2개 표시함'), findsOneWidget, reason: 'Insert 뒤 다음 줄이 표시됨'); - expect(find.byIcon(Icons.filter_alt), findsNothing, reason: 'Space 가 빠른 필터를 열지 않는다'); - // F5 = 복사 (확인 창) - 취소 - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f5)); - await calm(tester); - expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget); - await tester.tap(find.text('취소').last); - await calm(tester); - // F7 = 새 폴더 - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f7)); - await calm(tester); - expect(find.text('새 폴더'), findsWidgets); - await tester.tap(find.text('취소').last); - await calm(tester); - }); - - testWidgets('83 (28 지적): Total Commander 모양 F8 = 지우기 - 확인 창 [취소] 면 그대로, [휴지통으로] 면 휴지통으로', (tester) async { - final bin = recycleBinForTest; - 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'); - c.settings.explorerStyle = 'totalcmd'; - await open(tester); - await settle(tester, () => find.text('tcdel').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.text('tcdel'))); - await settle(tester, () => false, rounds: 15); - - // F8 → 확인 창 → [취소]: 아무것도 지우지 않음 - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f8)); - await calm(tester); - expect(find.text('휴지통으로 보내기'), findsOneWidget); - expect(find.widgetWithText(FilledButton, '휴지통으로'), findsOneWidget, reason: '처음 값은 휴지통 (영구 삭제 아님)'); - await tester.tap(find.text('취소').last); - await calm(tester); - expect(victim.existsSync(), isTrue); - expect(bin.items, isEmpty); - - // F8 → [휴지통으로]: 휴지통 길로 (영구 삭제가 아님) - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f8)); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); - await settle(tester, () => !victim.existsSync() && find.byType(SnackBar).evaluate().isNotEmpty); - expect(victim.existsSync(), isFalse); - expect(bin.items.keys, [victim.path], reason: '휴지통으로 보냄 (되돌릴 수 있음)'); - expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue, reason: '고른 것만'); - expect(find.text('되돌리기'), findsOneWidget); - await calm(tester); - }); - - testWidgets('83: Windows 탐색기 모양 단축키 - Ctrl+A 모두 표시 · Ctrl+C 담기 · Ctrl+V 붙여넣기', (tester) async { - c.settings.explorerStyle = 'windows'; - await open(tester); - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 15); - await act(tester, () async { - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyA); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - }); - await calm(tester); - expect(find.textContaining('2개 표시함'), findsOneWidget, reason: 'doc.txt · sub'); - await act(tester, () async { - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyC); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - }); - await calm(tester); - expect(find.textContaining('담았습니다'), findsOneWidget); - }); - - group('82 창 사이 끌어 놓기', () { - test('놓을 수 있는 곳: 같은 창 · 자기 안 · 원본 폴더 자신은 안 됨', () { - final a = p.join('C:', 'x', 'a'), f = p.join('C:', 'x', 'f.txt'); - expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'y')), isTrue); - expect(canDropInto(DragLoad(0, [f]), 0, p.join('C:', 'y')), isFalse, reason: '같은 창'); - expect(canDropInto(DragLoad(0, [a]), 1, p.join(a, 'in')), isFalse, reason: '자기 안'); - expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'x')), isFalse, reason: '원본 폴더 자신'); - }); - - /// 오른쪽 창에 보이는 [name] 줄 (두 창 모두 같은 이름이 보이면 오른쪽 것) - Offset rightOf(WidgetTester tester, String name) { - final all = find.text(name).evaluate().map((e) => tester.getCenter(find.byWidget(e.widget).first)).toList() - ..sort((x, y) => x.dx.compareTo(y.dx)); - return all.last; - } - - Future mouseDrag(WidgetTester tester, Offset from, Offset to) async { - final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); - await g.moveBy(const Offset(20, 0)); - await tester.pump(); - await g.moveTo(to); - await tester.pump(); - await g.up(); - await calm(tester); - } - - testWidgets('짧게 움직이면 고르기 · 같은 창에는 놓아도 아무 일 없음 · 묻기 [취소] 도 아무 일 없음', (tester) async { - await open(tester); - final doc = tester.getCenter(find.text('doc.txt').first); - // 4px 만 움직임 = 누르기 (끌기 아님) - final g = await tester.startGesture(doc, kind: PointerDeviceKind.mouse); - await g.moveBy(const Offset(4, 0)); - await g.up(); - await calm(tester); - expect(find.byType(AlertDialog), findsNothing); - // 같은 창의 sub 위에 놓기 - 못 놓음 - await mouseDrag(tester, doc, tester.getCenter(find.text('sub').first)); - expect(find.byType(AlertDialog), findsNothing, reason: '같은 창에는 놓지 않는다'); - // 오른쪽 창 (right 폴더) 에 놓기 → 묻기 → 취소 - await mouseDrag(tester, doc, rightOf(tester, 'right')); - expect(find.text('1개 항목을 놓았습니다'), findsOneWidget); - await tester.tap(find.text('취소').last); - await calm(tester); - expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); - expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse); - }); - - testWidgets('이동 → 같은 이름을 덮어쓴 파일은 되돌리지 않는다고 파일로 알림 (15 지적)', (tester) async { - File(p.join(right, 'doc.txt')).writeAsStringSync('old'); - File(p.join(left, 'e.txt')).writeAsStringSync('e'); - c.settings - ..explorerDropAction = 'move' - ..copyConflict = 'overwrite'; - await open(tester); - // 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지 - await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.text('선택').first)); - await act(tester, () => tester.tap(markOf('doc.txt'))); - await act(tester, () => tester.tap(markOf('e.txt'))); - await calm(tester); - await mouseDrag(tester, tester.getCenter(find.text('e.txt').first), rightOf(tester, 'right')); - await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); - await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); - expect(find.textContaining('같은 이름을 덮어쓴 파일 1개는 되돌리지 않습니다'), findsOneWidget); - expect(find.textContaining('합친 폴더'), findsNothing); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - await tester.pump(const Duration(seconds: 2)); // 진행 막대가 내려가는 시간 (1.2초) 까지 - }); - - testWidgets('이동 → [되돌리기]: 새로 생긴 것은 통째로, 이미 있던 폴더에 합친 것은 옮긴 파일만', (tester) async { - // 오른쪽에 이미 sub 폴더 (원래 파일 하나) - 합치기 - File(p.join(right, 'sub', 'mine.txt')) - ..createSync(recursive: true) - ..writeAsStringSync('mine'); - c.settings - ..explorerDropAction = 'move' - ..copyConflict = 'overwrite'; - await open(tester); - await act(tester, () => tester.tap(find.text('선택').first)); - await act(tester, () => tester.tap(markOf('doc.txt'))); - await act(tester, () => tester.tap(markOf('sub'))); - await calm(tester); - await mouseDrag(tester, tester.getCenter(find.text('doc.txt').first), rightOf(tester, 'right')); - // (놓은 뒤 대상에 이미 있는 이름을 실제 파일로 확인하므로 실제 시간을 흘려 기다린다) - await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); - // 지금의 이동 확인 창 그대로 - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); - await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); - expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue); - expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isTrue); - expect(Directory(p.join(left, 'sub')).existsSync(), isFalse); - // 되돌리기: 원래 폴더마다 지금의 이동 (확인 창) - await act(tester, () => tester.tap(find.text('되돌리기'))); - // 원래 폴더가 둘 (left · left/sub) 이라 이동 확인 창도 둘 - for (var i = 0; i < 2; i++) { - await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); - await settle(tester, () => find.byType(AlertDialog).evaluate().isEmpty, rounds: 50); - } - await settle(tester, () => File(p.join(left, 'doc.txt')).existsSync() && File(p.join(left, 'sub', 'inner.txt')).existsSync()); - expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello'); - expect(File(p.join(left, 'sub', 'inner.txt')).readAsStringSync(), 'inner'); - expect(File(p.join(right, 'sub', 'mine.txt')).readAsStringSync(), 'mine', reason: '원래 있던 폴더 · 파일은 그대로 (폴더째 옮기지 않음)'); - expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isFalse); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - }); - }); - - testWidgets('85: 진행 패널 [숨기기] → 아래 작은 표시 (누르면 다시) · 끝난 알림의 [폴더 열기]', (tester) async { - await open(tester); - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 15); - await act(tester, () => tester.tap(find.text('복사').first)); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); - await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.text('폴더 열기').evaluate().isNotEmpty); - expect(find.text('폴더 열기'), findsOneWidget, reason: '끝난 알림에 [폴더 열기]'); - // 패널은 끝난 뒤 잠깐 남는다 - [숨기기] - expect(find.text('숨기기'), findsOneWidget); - // 패널 · 작은 표시는 끝나고 1.2초 (시험 시계) 뒤 내려간다. pumpAndSettle (calm) 은 애니메이션이 있는 동안 시계를 그만큼 - // 넘길 수 있어 (PC 가 바쁠 때 흔들림 - 관리자 지시), 정해진 횟수 대신 조건이 될 때까지 시계를 조금씩만 돌린다 - Future until(bool Function() done) async { - for (var i = 0; i < 100 && !done(); i++) { - await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); - await tester.pump(const Duration(milliseconds: 10)); - } - } - - await act(tester, () => tester.tap(find.text('숨기기'))); - await until(() => c.settings.explorerProgressHidden && find.text('복사 · 이동 끝').evaluate().isNotEmpty); - expect(c.settings.explorerProgressHidden, isTrue, reason: '숨김은 기억'); - expect(find.text('숨기기'), findsNothing); - expect(find.text('복사 · 이동 끝'), findsOneWidget, reason: '아래 작은 표시'); - // 작은 표시를 누름 (아래 알림이 덮고 있어도 - 알림이 닫히기를 기다리면 그사이 작은 표시가 내려갈 수 있다) - tester.widget(find.byType(TransferProgressChip)).onTap(); - await until(() => !c.settings.explorerProgressHidden); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - expect(c.settings.explorerProgressHidden, isFalse, reason: '누르면 다시 펼침'); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - }); - - testWidgets('81: 빠른 보기 (글 파일 앞부분) - Windows 는 TC 키 F3 (규칙 1), Android 는 Space · 필터에 치는 중에는 그냥 빈칸', (tester) async { - c.settings.explorerStyle = 'windows'; - await open(tester); - await act(tester, () => tester.tap(find.text('doc.txt'))); - await settle(tester, () => false, rounds: 15); - await act(tester, () => tester.sendKeyEvent(Platform.isWindows ? LogicalKeyboardKey.f3 : LogicalKeyboardKey.space)); - await settle(tester, () => find.byType(QuickLook).evaluate().isNotEmpty); - expect(find.byType(QuickLook), findsOneWidget); - await settle(tester, () => find.text('hello').evaluate().isNotEmpty); - expect(find.text('hello'), findsOneWidget, reason: '글 파일 앞부분'); - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); - await calm(tester); - expect(find.byType(QuickLook), findsNothing); - - // 빠른 필터에 치는 중이면 Space 는 칸의 글자 (빠른 보기를 열지 않음) - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.keyD)); - await calm(tester); - expect(find.byType(TextField), findsOneWidget); - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.space)); - await calm(tester); - expect(find.byType(QuickLook), findsNothing); - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); - await calm(tester); - - // TC: F3 - await c.updateSettings((s) => s.explorerStyle = 'totalcmd'); - await calm(tester); - await act(tester, () => tester.tap(find.text('doc'))); - await settle(tester, () => false, rounds: 15); - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f3)); - await settle(tester, () => find.byType(QuickLook).evaluate().isNotEmpty); - expect(find.byType(QuickLook), findsOneWidget); - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); - await calm(tester); - }); - - // P0 (15 지적 10/17): 글자 칸에 치는 중에는 파일 단축키가 키를 가로채지 않는다 (예전: 필터 칸의 Delete → 파일 지우기 창) - for (final style in const ['windows', 'xplore', 'totalcmd']) { - testWidgets('P0 $style: 빠른 필터 칸에서 Ctrl+A · C · V · Delete · Space · F8 · Backspace 는 파일에 쓰이지 않는다', (tester) async { - c.settings.explorerStyle = style; - await open(tester); - await act(tester, () => tester.tap(find.text(style == 'totalcmd' ? 'doc' : 'doc.txt'))); - await settle(tester, () => false, rounds: 15); - // Ctrl+F 로 필터 칸 열고 글자 - Future ctrl(LogicalKeyboardKey k) => act(tester, () async { - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(k); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - }); - await ctrl(LogicalKeyboardKey.keyF); - await calm(tester); - final field = find.byType(TextField); - expect(field, findsOneWidget); - await tester.enterText(field, 'do'); - await calm(tester); - await ctrl(LogicalKeyboardKey.keyA); - await calm(tester); - final ctl = tester.widget(field).controller!; - expect(ctl.selection, const TextSelection(baseOffset: 0, extentOffset: 2), reason: 'Ctrl+A 는 칸의 글 전체 선택'); - await ctrl(LogicalKeyboardKey.keyC); - await calm(tester); - await ctrl(LogicalKeyboardKey.keyV); - await calm(tester); - for (final k in [LogicalKeyboardKey.delete, LogicalKeyboardKey.space, LogicalKeyboardKey.f8, LogicalKeyboardKey.backspace]) { - await act(tester, () => tester.sendKeyEvent(k)); - await calm(tester); - } - expect(find.textContaining('표시함'), findsNothing, reason: '파일이 표시되지 않음'); - expect(find.textContaining('담았습니다'), findsNothing, reason: '파일을 담지 않음'); - expect(find.textContaining('휴지통'), findsNothing, reason: '지우기 창이 뜨지 않음'); - expect(find.textContaining('지울까요'), findsNothing); - expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); - expect(field, findsOneWidget, reason: '칸은 그대로 열려 있음 (Backspace 로 위 폴더로 가지 않음)'); - // Esc 는 지금처럼 필터를 닫는다 - await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); - await calm(tester); - expect(find.byType(TextField), findsNothing); - ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); - await calm(tester); - }); - } - - testWidgets('87: 폴더 길게 누르기 [저장 공간 분석] → 큰 파일을 누르면 탐색기에서 그 자리로', (tester) async { - File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(4000, 1)); - await open(tester); - await tester.longPress(find.text('sub').first); - await calm(tester); - await act(tester, () => tester.tap(find.text('저장 공간 분석'))); - await settle(tester, () => find.text('큰 파일').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.text('큰 파일'))); - await calm(tester); - await act(tester, () => tester.tap(find.text('big.bin'))); - await settle(tester, () => find.byType(StorageAnalysisPage).evaluate().isEmpty && find.text('big.bin').evaluate().isNotEmpty); - await calm(tester); - expect(find.byType(StorageAnalysisPage), findsNothing); - expect(find.text('big.bin'), findsWidgets, reason: '탐색기에서 그 자리 (sub 안) 가 보인다'); - }); - - testWidgets('84: 열 머리를 누르면 그 기준으로 · 다시 누르면 거꾸로 (처음: 모든 폴더 같은 정렬)', (tester) async { - File(p.join(left, 'a_big.txt')).writeAsStringSync('x' * 50); - c.settings.explorerStyle = 'windows'; - await open(tester); - double y(String name) => tester.getTopLeft(find.text(name).first).dy; - expect(y('a_big.txt'), lessThan(y('doc.txt')), reason: '처음: 이름순'); - await act(tester, () => tester.tap(find.text('크기').first)); - await calm(tester); - expect((c.settings.explorerSort, c.settings.explorerSortDesc), ('size', false)); - expect(y('doc.txt'), lessThan(y('a_big.txt')), reason: '크기 오름차순: doc.txt (5) 가 먼저'); - await act(tester, () => tester.tap(find.text('크기').first)); - await calm(tester); - expect(c.settings.explorerSortDesc, isTrue, reason: '같은 열을 다시 누르면 거꾸로'); - // (거꾸로면 트리에 펼쳐진 위 폴더 (Temp 등) 의 차례도 바뀌어 시험 폴더가 화면 밖으로 갈 수 있어 줄 위치는 보지 않는다) - expect(find.byIcon(Icons.arrow_drop_down), findsWidgets, reason: '지금 정렬 열에 ▼'); - }); - - testWidgets('84: "정렬을 폴더마다 기억" 을 켜면 그 폴더에만 (전체 정렬 · 다른 창은 그대로)', (tester) async { - c.settings - ..explorerStyle = 'totalcmd' - ..explorerSortPerFolder = true; - await open(tester); - // 왼쪽 창에서 날짜 열 - await act(tester, () => tester.tap(find.text('날짜').first)); - await calm(tester); - expect(c.settings.explorerSort, 'name', reason: '전체 정렬은 그대로'); - expect(c.settings.explorerFolderSorts.values, contains('date|0')); - expect(c.settings.explorerFolderSorts.length, 1); - // 저장 · 읽기 - expect(AppSettings.fromJson(c.settings.toJson()).explorerFolderSorts, c.settings.explorerFolderSorts); - expect(AppSettings.fromJson(c.settings.toJson()).explorerSortPerFolder, isTrue); - expect(AppSettings.fromJson({'explorerFolderSorts': 'broken'}).explorerFolderSorts, isEmpty, reason: '틀린 값은 비움'); - }); - - testWidgets('X-plore 숨기기: 길게 누르기 [목록에서 숨기기] → 목록에서만 빠짐 (파일 그대로) · 맨 아래 [숨긴 항목 보이기] · [숨기기 풀기]', (tester) async { - // 폴더 + 파일 목록 배치: 오른쪽 목록은 그 폴더의 것만이라 짧다 (트리는 Temp 폴더 줄이 많아 맨 아래가 멀다) - c.settings.explorerLayout = 'split'; - await open(tester); - final doc = p.join(left, 'doc.txt'); - await tester.longPress(find.text('doc.txt')); - await calm(tester); - await act(tester, () => tester.tap(find.text('목록에서 숨기기'))); - await calm(tester); - // X-plore 맞추기 (관리자 지시 10/20): 무엇이 바뀌는지 확인 창 - expect(find.textContaining('파일 탐색기 목록에서만 빠집니다'), findsOneWidget); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기'))); - await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); - expect(find.text('doc.txt'), findsNothing); - expect(File(doc).existsSync(), isTrue, reason: '파일은 바꾸지 않는다'); - expect(c.settings.explorerHiddenItems, [doc]); - // 목록 맨 아래에 [숨긴 항목 1개 보이기] - expect(find.text('숨긴 항목 1개 보이기'), findsOneWidget); - await idle(tester); - // 보이기 → 흐리게 보이고, 메뉴는 [숨기기 풀기] - await act(tester, () => tester.tap(find.text('숨긴 항목 1개 보이기'))); - await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); - expect(find.text('숨긴 항목 1개 다시 숨기기'), findsOneWidget); - expect(find.ancestor(of: find.text('doc.txt'), matching: find.byType(Opacity)), findsOneWidget); - await tester.longPress(find.text('doc.txt')); - await calm(tester); - expect(find.text('목록에서 숨기기'), findsNothing); - await act(tester, () => tester.tap(find.text('숨기기 풀기'))); - await settle(tester, () => find.textContaining('숨긴 항목').evaluate().isEmpty); - expect(c.settings.explorerHiddenItems, isEmpty); - expect(find.text('doc.txt'), findsOneWidget); - expect(find.ancestor(of: find.text('doc.txt'), matching: find.byType(Opacity)), findsNothing); - }); - - testWidgets('X-plore 숨기기 확인 창: [취소] 면 그대로 · [다음부터 묻지 않기] 를 고르면 다음엔 묻지 않고 바로 숨김 (설정 저장)', (tester) async { - c.settings.explorerLayout = 'split'; - expect(AppSettings().explorerHideConfirm, isTrue, reason: '처음은 묻기'); - File(p.join(left, 'two.txt')).writeAsStringSync('2'); - await open(tester); - await tester.longPress(find.text('doc.txt')); - await calm(tester); - await act(tester, () => tester.tap(find.text('목록에서 숨기기'))); - await calm(tester); - await act(tester, () => tester.tap(find.text('취소'))); - await calm(tester); - expect(c.settings.explorerHiddenItems, isEmpty, reason: '취소하면 숨기지 않는다'); - expect(find.text('doc.txt'), findsOneWidget); - // 다음부터 묻지 않기 + 숨기기 - await tester.longPress(find.text('doc.txt')); - await calm(tester); - await act(tester, () => tester.tap(find.text('목록에서 숨기기'))); - await calm(tester); - await act(tester, () => tester.tap(find.text('다음부터 묻지 않기'))); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기'))); - await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); - expect(c.settings.explorerHideConfirm, isFalse); - expect(AppSettings.fromJson(c.settings.toJson()).explorerHideConfirm, isFalse); - expect(AppSettings.fromJson({}).explorerHideConfirm, isTrue, reason: '예전 설정 (값 없음) 은 묻기'); - // 이제는 창 없이 바로 - await tester.longPress(find.text('two.txt')); - await calm(tester); - await act(tester, () => tester.tap(find.text('목록에서 숨기기'))); - await settle(tester, () => find.text('two.txt').evaluate().isEmpty); - expect(find.textContaining('파일 탐색기 목록에서만 빠집니다'), findsNothing); - expect(c.settings.explorerHiddenItems, [p.join(left, 'doc.txt'), p.join(left, 'two.txt')]); - }); - - testWidgets('X-plore 맞추기: 폴더 길게 누르기 [갤러리 · 음악 앱에서 숨기기 (.nomedia)] - 확인 창 뒤 빈 .nomedia, 다시 누르면 지우기 · 안의 파일은 그대로', (tester) async { - ExplorerPage.debugNomedia = true; - addTearDown(() => ExplorerPage.debugNomedia = null); - final mark = File(p.join(left, 'sub', '.nomedia')); - await open(tester); - expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing); - await tester.longPress(find.text('sub')); - await calm(tester); - await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'))); - await calm(tester); - expect(find.textContaining('빈 .nomedia 파일을 만듭니다'), findsOneWidget); - // 취소하면 아무것도 안 바뀜 - await act(tester, () => tester.tap(find.text('취소'))); - await calm(tester); - expect(mark.existsSync(), isFalse); - await tester.longPress(find.text('sub')); - await calm(tester); - await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'))); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기'))); - await settle(tester, () => mark.existsSync() && shell.scanned.isNotEmpty); - expect(mark.lengthSync(), 0); - expect(shell.scanned, [ - [mark.path, p.join(left, 'sub')] - ], reason: '갤러리가 다시 읽게 (MediaScanner)'); - expect(File(p.join(left, 'sub', 'inner.txt')).readAsStringSync(), 'inner', reason: '안의 파일은 그대로'); - // 이제 메뉴는 [다시 보이기] - await idle(tester); - await tester.longPress(find.text('sub')); - await calm(tester); - expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing); - await act(tester, () => tester.tap(find.text('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'))); - await calm(tester); - expect(find.textContaining('.nomedia 파일을 지웁니다'), findsOneWidget); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '다시 보이기'))); - await settle(tester, () => !mark.existsSync()); - await settle(tester, () => shell.scanned.length == 2); - expect(shell.scanned.last, [mark.path, p.join(left, 'sub')], reason: '지울 때도 다시 읽게'); - expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue); - // 파일에는 없음 - await idle(tester); - await tester.longPress(find.text('doc.txt')); - await calm(tester); - expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing); - expect(find.text('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'), findsNothing); - }); - - testWidgets('X-plore 숨기기: 숨긴 것이 든 폴더를 지울 때 확인 창에 알림 (숨긴 것도 함께 지워진다)', (tester) async { - c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')]; - await open(tester); - await tester.longPress(find.text('sub')); - await calm(tester); - await act(tester, () => tester.tap(find.byTooltip('삭제').last)); - await calm(tester); - expect(find.text('지우는 폴더 안에 목록에서 숨긴 항목 1개가 있습니다 - 함께 지워집니다'), findsOneWidget); - await act(tester, () => tester.tap(find.text('취소').last)); - await calm(tester); - expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue); - // 다른 파일만 지울 때는 알리지 않는다 - await tester.longPress(find.text('doc.txt')); - await calm(tester); - await act(tester, () => tester.tap(find.byTooltip('삭제').last)); - await calm(tester); - expect(find.textContaining('목록에서 숨긴 항목'), findsNothing); - await act(tester, () => tester.tap(find.text('취소').last)); - await calm(tester); - }); - - testWidgets('X-plore 숨기기: 이름을 바꾸면 숨긴 목록도 새 경로로 · 영구로 지우면 목록에서 뺀다 (관리자 결정)', (tester) async { - c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')]; - await open(tester); - await tester.longPress(find.text('sub')); - await calm(tester); - await act(tester, () => tester.tap(find.byTooltip('이름 변경').last)); - await calm(tester); - await act(tester, () => tester.enterText(find.byType(TextField), 'sub2')); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인'))); - await settle(tester, () => find.text('sub2').evaluate().isNotEmpty); - expect(c.settings.explorerHiddenItems, [p.join(left, 'sub2', 'inner.txt')]); - await idle(tester); - await tester.longPress(find.text('sub2')); - await calm(tester); - await act(tester, () => tester.tap(find.byTooltip('삭제').last)); - await calm(tester); - await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); - await settle(tester, () => find.text('sub2').evaluate().isEmpty); - await settle(tester, () => c.settings.explorerHiddenItems.isEmpty); - expect(c.settings.explorerHiddenItems, isEmpty); - }); - - testWidgets('X-plore 숨기기: 다른 창으로 옮기면 숨긴 목록도 새 자리로 (그 안의 것도)', (tester) async { - c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')]; - await open(tester); - await tester.longPress(find.text('sub')); - await calm(tester); - await act(tester, () => tester.tap(find.byTooltip('다른 창으로 이동').last)); - await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); - await settle(tester, () => c.settings.explorerHiddenItems.first != p.join(left, 'sub', 'inner.txt')); - expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isTrue); - expect(c.settings.explorerHiddenItems, [p.join(right, 'sub', 'inner.txt')]); - await idle(tester); - }); - - testWidgets('X-plore 숨기기: 위 막대 ⋮ [목록에서 숨긴 항목 보이기] · 환경 설정의 [목록에서 숨긴 항목] 에서 풀기', (tester) async { - final doc = p.join(left, 'doc.txt'); - c.settings - ..explorerLayout = 'split' - ..explorerHiddenItems = [doc]; - tester.view.physicalSize = const Size(1600, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); - await settle(tester, () => find.text('숨긴 항목 1개 보이기').evaluate().isNotEmpty); - expect(find.text('doc.txt'), findsNothing); - await act(tester, () => tester.tap(find.byTooltip('창 배치 · 버튼 구성'))); - await calm(tester); - await act(tester, () => tester.tap(find.text('목록에서 숨긴 항목 보이기 (1개)'))); - await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); - expect(find.text('숨긴 항목 1개 다시 숨기기'), findsOneWidget); - await act(tester, () => tester.tap(find.byTooltip('창 배치 · 버튼 구성'))); - await calm(tester); - await act(tester, () => tester.tap(find.text('목록에서 숨긴 항목 다시 숨기기'))); - await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); - // 설정 화면의 목록에서 풀면 탐색기 목록에 바로 보인다 - final ctx = tester.element(find.byType(ExplorerPage)); - unawaited(showHiddenItems(ctx, c)); - await calm(tester); - expect(find.text('목록에서 숨긴 항목 (1개)'), findsOneWidget); - await act(tester, () => tester.tap(find.byTooltip('숨기기 풀기'))); - await calm(tester); - expect(c.settings.explorerHiddenItems, isEmpty); - expect(find.text('숨긴 항목이 없습니다'), findsOneWidget); - await act(tester, () => tester.tap(find.text('닫기'))); - await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); - expect(find.textContaining('숨긴 항목'), findsNothing); - }); - - testWidgets('80: 크기순 정렬이면 폴더 크기를 계산해 보이고, "안 함" 이어도 길게 누르기 [폴더 크기 계산]', (tester) async { - File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(3000, 1)); - c.settings - ..explorerSort = 'size' - ..explorerSortDesc = true; - await open(tester); - // sub = inner.txt (5) + big.bin (3000) - await settle(tester, () => find.text('3KB').evaluate().isNotEmpty); - expect(find.text('3KB'), findsWidgets); - - // 바뀌면 (새로 고침) 다시 계산 - File(p.join(left, 'sub', 'more.bin')).writeAsBytesSync(List.filled(3000, 1)); - await act(tester, () => refreshKey(tester)); - await settle(tester, () => find.text('6KB').evaluate().isNotEmpty); - expect(find.text('6KB'), findsWidgets); - }); - - testWidgets('80: "계산 안 함" - 크기를 보이지 않다가 [폴더 크기 계산] 을 누르면', (tester) async { - File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(3000, 1)); - c.settings - ..explorerSort = 'size' - ..explorerFolderSize = 'off'; - await open(tester); - await settle(tester, () => false, rounds: 15); - expect(find.text('3KB'), findsNothing); - await tester.longPress(find.text('sub').first); - await calm(tester); - await act(tester, () => tester.tap(find.text('폴더 크기 계산'))); - await settle(tester, () => find.text('3KB').evaluate().isNotEmpty); - expect(find.text('3KB'), findsWidgets); - }); - - testWidgets('79-a: [그 자리로] 로 나가도 [찾기 결과로 돌아가기] 가 남는다 (관리자 판단) · ✕ 로 닫음', (tester) async { - await open(tester); - await act(tester, () => tester.tap(find.text('찾기').first)); - await calm(tester); - await tester.enterText(find.widgetWithText(TextField, '이름 (비워도 됨)'), 'inner'); - await tester.pump(); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); - await settle(tester, () => find.text('찾기 결과 · 1개').evaluate().isNotEmpty); - await act(tester, () => tester.tap(find.byTooltip('그 자리로'))); - await settle(tester, () => find.text('찾기 결과로 돌아가기').evaluate().isNotEmpty); - await calm(tester); // 결과 화면이 닫히는 움직임까지 - expect(find.byType(SearchResultsPage), findsNothing); - await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); - await settle(tester, () => find.text('찾기 결과 · 1개').evaluate().isNotEmpty); - // 결과 화면을 그냥 닫으면 없어진다 - await act(tester, () => tester.pageBack()); - await calm(tester); - expect(find.text('찾기 결과로 돌아가기'), findsNothing); - }); - - testWidgets('79: 찾기 결과를 목록으로 - 이름 없이 종류 조건으로 찾고, 골라서 그대로 다른 창으로 복사', (tester) async { - await open(tester); - File(p.join(left, 'sub', 'big.mkv')) - ..createSync(recursive: true) - ..writeAsBytesSync(List.filled(2048, 1)); - await act(tester, () => tester.tap(find.text('찾기').first)); - await calm(tester); - // 이름은 비우고 종류만 (동영상) - 이름이 비면 [찾기] 가 꺼져 있다가 조건을 넣으면 켜진다 - expect(tester.widget(find.widgetWithText(FilledButton, '찾기')).onPressed, isNull); - await act(tester, () => tester.tap(find.widgetWithText(ChoiceChip, '동영상'))); - await calm(tester); - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); - await settle(tester, () => find.text('big.mkv').evaluate().isNotEmpty); - expect(find.text('찾기 결과 · 1개'), findsOneWidget); - // (뒤의 탐색기 목록에는 doc.txt 가 있다 - 결과 화면 안만 본다) - expect(find.descendant(of: find.byType(SearchResultsPage), matching: find.text('doc.txt')), findsNothing, reason: '종류가 맞지 않음'); - // 고르면 아래에 할 일 줄 - 다른 창 (right) 으로 복사 - await act(tester, () => tester.tap(find.descendant(of: find.byType(SearchResultsPage), matching: find.byType(Checkbox)).first)); - await calm(tester); - await act(tester, () => tester.tap(find.text('복사 → right'))); - await calm(tester); - // 탐색기의 복사 확인 창 그대로 - await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); - await settle(tester, () => File(p.join(right, 'big.mkv')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); - expect(File(p.join(right, 'big.mkv')).lengthSync(), 2048); - expect(File(p.join(left, 'sub', 'big.mkv')).existsSync(), isTrue); - }); -} - -/// [name] 줄의 "표시" 동그라미 버튼 -Finder markOf(String name) => find.descendant( - of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, - matching: find.byTooltip('표시 (여러 개 고르기)'), - ); - -/// 누르기 등을 실제 비동기 구역에서 (그 때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) -Future act(WidgetTester tester, Future Function() f) async { - await tester.runAsync(f); - await tester.pump(); -} - -/// [name] 트리 줄의 › (펼치기) 화살표 -Finder chevronOf(String name) => find.descendant( - of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first, - matching: find.byIcon(Icons.chevron_right), - ); - -/// 두 번 누르기 (실제 비동기 구역에서: 그때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) -Future doubleTap(WidgetTester tester, Finder f) async { - // 흔들림: 두 누름 사이를 실제 시간 (80ms) 으로 쉬면 무거울 때 두 번 누르기 판정 (300ms) 을 넘겨 한 번씩 두 번으로 됐다 - - // 시험의 가짜 시간으로 50ms. 그 뒤 시작한 파일 작업은 settle (실제 시간) 이 기다린다 - await tester.tap(f); - await tester.pump(const Duration(milliseconds: 50)); - await tester.tap(f); - await tester.pump(); -} - -/// 창들이 목록을 다시 읽는 중 (맨 위 진행 막대 · 도는 표시) 이 끝날 때까지 실제 시간으로 기다린다. -/// 그사이 pumpAndSettle 을 하면 막대가 계속 움직여, 디스크 읽기가 늦을 때 (전체 시험이 무거울 때) 시간 초과가 난다. -/// 값이 있는 막대 (디스크 용량 · 진행률) 는 움직이지 않으므로 세지 않는다 -Future idle(WidgetTester tester) => settle( - tester, - () => find - .byWidgetPredicate((w) => w is ProgressIndicator && w.value == null) - .evaluate() - .isEmpty); - -/// 탐색기 시험의 pumpAndSettle: 목록 읽기가 끝난 뒤에 (경로를 바꾸거나 다시 읽은 뒤 무거울 때 흔들리던 것을 한 번에) -Future calm(WidgetTester tester) async { - await idle(tester); - await tester.pumpAndSettle(); -} - -/// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신 -// 끝나면 바로 나온다 - 넉넉한 횟수는 전체 시험 중 PC 가 바쁠 때 (실제 파일 입출력이 느려짐) 흔들리지 않게 -Future settle(WidgetTester tester, bool Function() done, {int rounds = 300}) async { - for (var i = 0; i < rounds; i++) { - await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); - await tester.pump(); - if (done()) break; - } - await tester.pump(const Duration(milliseconds: 300)); -} diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart index 659f126..2eaf9e6 100644 --- a/test/explorer_webdav_test.dart +++ b/test/explorer_webdav_test.dart @@ -13,7 +13,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 'explorer_test.dart' show act, doubleTap, settle, calm; +import 'support/explorer_harness.dart' show act, doubleTap, settle, calm; import 'support/dav_server.dart'; class _Shell extends NoopShell { diff --git a/test/rsync_safety_test.dart b/test/rsync_safety_test.dart index 12536eb..f0ae515 100644 --- a/test/rsync_safety_test.dart +++ b/test/rsync_safety_test.dart @@ -11,7 +11,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 'explorer_test.dart' show act, settle; +import 'support/explorer_harness.dart' show act, settle; import 'support/dav_server.dart'; import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; diff --git a/test/support/explorer_harness.dart b/test/support/explorer_harness.dart new file mode 100644 index 0000000..26048aa --- /dev/null +++ b/test/support/explorer_harness.dart @@ -0,0 +1,140 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.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/app_shell.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:path/path.dart' as p; + +import 'explorer_clock_keys.dart'; +import 'fake_recycle_bin.dart'; + +/// E5e: 탐색기 화면 시험의 공용 준비 (예전 explorer_test.dart 의 setUp · tearDown · open · 도우미). +/// [useExplorerFixture] 를 main 맨 앞에서 부르면 시험마다 시험 폴더 (left: doc.txt · sub/inner.txt, right: 빈 폴더) 와 +/// 앱 (다른 앱으로 열기를 적기만 하는 셸, 저장소 없음) 을 새로 만들고 끝나면 지운다. 휴지통은 가짜 (사용자 휴지통에 닿지 않게) + +/// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게) +class ExplorerShell extends NoopShell { + final opened = <(String, bool)>[]; + final scanned = >[]; + @override + Future mediaScan(List paths) async => scanned.add(paths); + @override + Future openWith(String path, {bool choose = false}) async { + opened.add((path, choose)); + return true; + } +} + +Directory? _tmp; +String? _left, _right; +ExplorerShell? _shell; +AppController? _c; + +/// 이 시험의 시험 폴더 · 왼쪽 · 오른쪽 창 폴더 · 셸 · 앱 +Directory get tmp => _tmp!; +String get left => _left!; +String get right => _right!; +ExplorerShell get shell => _shell!; +AppController get c => _c!; + +void useExplorerFixture() { + setUp(() { + // 휴지통: 기본은 가짜 (이 시험들은 영구 삭제를 고르지만 혹시라도 사용자 휴지통에 닿지 않게) + useTestRecycleBin(); + _tmp = Directory.systemTemp.createTempSync('jj_explorer_'); + _left = p.join(tmp.path, 'left'); + _right = p.join(tmp.path, 'right'); + File(p.join(left, 'doc.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('hello'); + File(p.join(left, 'sub', 'inner.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('inner'); + Directory(right).createSync(); + _shell = ExplorerShell(); + _c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell)); + c.settings.explorerPaths = [left, right]; + }); + // Windows: 방금 닫은 파일 (65 의 잠금 시험) 이나 색인 · 백신이 잠깐 잡고 있으면 지우기가 실패한다 - 될 때까지 기다린다 + tearDown(() async { + for (var i = 0;; i++) { + try { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + return; + } on FileSystemException { + if (i >= 50) rethrow; + await Future.delayed(const Duration(milliseconds: 100)); + } + } + }); +} + +/// 탐색기를 1600 × 900 으로 열고 왼쪽 창 목록이 보일 때까지 기다린다 (지금 시각은 정한 시각) +Future open(WidgetTester tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, clock: () => testNow)))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); +} + +/// [name] 줄의 "표시" 동그라미 버튼 +Finder markOf(String name) => find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, + matching: find.byTooltip('표시 (여러 개 고르기)'), + ); + +/// 누르기 등을 실제 비동기 구역에서 (그 때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) +Future act(WidgetTester tester, Future Function() f) async { + await tester.runAsync(f); + await tester.pump(); +} + +/// [name] 트리 줄의 › (펼치기) 화살표 +Finder chevronOf(String name) => find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first, + matching: find.byIcon(Icons.chevron_right), + ); + +/// 두 번 누르기 (실제 비동기 구역에서: 그때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) +Future doubleTap(WidgetTester tester, Finder f) async { + // 흔들림: 두 누름 사이를 실제 시간 (80ms) 으로 쉬면 무거울 때 두 번 누르기 판정 (300ms) 을 넘겨 한 번씩 두 번으로 됐다 - + // 시험의 가짜 시간으로 50ms. 그 뒤 시작한 파일 작업은 settle (실제 시간) 이 기다린다 + await tester.tap(f); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(f); + await tester.pump(); +} + +/// 창들이 목록을 다시 읽는 중 (맨 위 진행 막대 · 도는 표시) 이 끝날 때까지 실제 시간으로 기다린다. +/// 그사이 pumpAndSettle 을 하면 막대가 계속 움직여, 디스크 읽기가 늦을 때 (전체 시험이 무거울 때) 시간 초과가 난다. +/// 값이 있는 막대 (디스크 용량 · 진행률) 는 움직이지 않으므로 세지 않는다 +Future idle(WidgetTester tester) => settle( + tester, + () => find + .byWidgetPredicate((w) => w is ProgressIndicator && w.value == null) + .evaluate() + .isEmpty); + +/// 탐색기 시험의 pumpAndSettle: 목록 읽기가 끝난 뒤에 (경로를 바꾸거나 다시 읽은 뒤 무거울 때 흔들리던 것을 한 번에) +Future calm(WidgetTester tester) async { + await idle(tester); + await tester.pumpAndSettle(); +} + +/// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신 +// 끝나면 바로 나온다 - 넉넉한 횟수는 전체 시험 중 PC 가 바쁠 때 (실제 파일 입출력이 느려짐) 흔들리지 않게 +Future settle(WidgetTester tester, bool Function() done, {int rounds = 300}) async { + for (var i = 0; i < rounds; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + if (done()) break; + } + await tester.pump(const Duration(milliseconds: 300)); +} diff --git a/test/sync_problem_test.dart b/test/sync_problem_test.dart index 70ef346..0c7c976 100644 --- a/test/sync_problem_test.dart +++ b/test/sync_problem_test.dart @@ -14,7 +14,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:path/path.dart' as p; -import 'explorer_test.dart' show act, calm, settle; +import 'support/explorer_harness.dart' show act, calm, settle; import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; /// 68 · 70 · 71