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/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:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; /// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게) class _Shell extends NoopShell { final opened = <(String, bool)>[]; @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)))); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); } testWidgets('두 창: 폴더 한 번 = 펼치기 · 파일 한 번 = 선택 · 두 번 = 실행 · 길게 = 메뉴 · 다른 창으로 복사', (tester) async { await open(tester); // 두 창 + 가운데 버튼 줄. 선택 동그라미: X-plore 스타일 (처음 값) 은 늘 보임 (관리자 결정 10/18 - 설정으로 끌 수 있음) expect(find.text('상위 폴더'), findsOneWidget); expect(find.text('doc.txt'), findsOneWidget); expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); // 모니터링 · rsync 는 Rsync 화면에만 expect(find.text('모니터링'), findsNothing); expect(find.text('좌 → 우'), findsNothing); // 폴더를 한 번 누르면 바로 펼친다. 189: 다시 눌러도 (두 번 누름 포함) 접지 않는다 - 접기 · 펼치기는 화살표로 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('sub'))); await settle(tester, () => false, rounds: 15); expect(find.text('inner.txt'), findsOneWidget, reason: '이름을 다시 눌러도 그대로'); await doubleTap(tester, find.text('sub')); await settle(tester, () => false, rounds: 15); 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 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 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('X-plore d: 설정 "다시 누르면 접기" - 펼친 지금 폴더를 다시 누르면 접힘 · 두 번 누르기의 두 번째는 접지 않음 (처음 값 끔)', (tester) async { expect(AppSettings().explorerRetapCollapse, isFalse, reason: '189 그대로'); c.settings.explorerRetapCollapse = true; 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 tester.runAsync(() => Future.delayed(const Duration(milliseconds: 800))); 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 ⚖: 폴더 표시 X-plore 식 (1번 폴더 · 2번 안의 파일 · 3번 풀기) - 처음 값은 지금 규칙', (tester) async { expect(AppSettings().explorerFolderMarkRule, 'tree', reason: '고르는 범위가 바뀌면 지우기 · 이동 대상이 바뀌어 처음 값은 그대로'); c.settings.explorerFolderMarkRule = 'xplore'; await open(tester); IconData markIcon(String name) => (tester .widget(find.descendant( of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, matching: find.byType(IconButton))) .icon as Icon) .icon!; Future mark() async { // 표시 동그라미 (풍선 도움말이 표시 여부에 따라 바뀌므로 줄 안의 단추로 찾는다) await act(tester, () => tester.tap(find.descendant(of: find.ancestor(of: find.text('sub'), matching: find.byType(Material)).first, matching: find.byType(IconButton)).first)); await settle(tester, () => false, rounds: 10); } await mark(); expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle, Icons.check_circle), reason: '1번 = 폴더 자신 (안의 것도 포함)'); await mark(); expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle_outline, Icons.check_circle), reason: '2번 = 안의 파일 전체'); await mark(); expect((markIcon('sub'), markIcon('inner.txt')), (Icons.check_circle_outline, Icons.check_circle_outline), reason: '3번 = 풀기'); }); 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)); }); 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 calm(tester); 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); IconData markIcon(String name) => (tester .widget(find.descendant( of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, matching: find.byType(IconButton))) .icon as Icon) .icon!; // 폴더: 누르면 자기 자신과 안의 것 모두 선택 (펼쳐서 ✔ 로 보여 줌) 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(Icons.check_circle)); // 안의 것 하나를 누르면 그것만 빠지고 나머지는 고른 채로 await act(tester, () => tester.tap(find.text('inner.txt'))); await settle(tester, () => false, rounds: 5); expect(markIcon('inner.txt'), Icons.check_circle_outline); expect(markIcon('second.txt'), Icons.check_circle); expect(markIcon('sub'), Icons.check_circle_outline); // 폴더를 다시 누르면 다시 전체 선택, 한 번 더 누르면 자기 자신과 안의 것 모두 취소 await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => false, rounds: 5); expect(find.textContaining('1개 표시함'), findsOneWidget); expect([markIcon('sub'), markIcon('inner.txt'), markIcon('second.txt')], everyElement(Icons.check_circle)); 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(Icons.check_circle_outline)); // [선택 끝] → 표시가 모두 풀린다 (X-plore 스타일은 동그라미 자체는 늘 보임 - 관리자 결정 10/18) await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음 expect(find.byTooltip('표시 지우기'), findsNothing); expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); // 설정에서 끄면 선택 모드에서만 (예전대로) await tester.runAsync(() => c.updateSettings((x) => x.explorerAlwaysMarks = false)); await settle(tester, () => false, rounds: 5); expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); }); 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('원본 파일 지우기 (--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); expect(find.textContaining('-u'), findsNothing); expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing); await tester.tap(find.text('원본 파일 지우기 (--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: 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); 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: Space 로 빠른 보기 (글 파일 앞부분) · 필터에 치는 중에는 그냥 빈칸 · TC 는 F3', (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(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('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, () => tester.sendKeyEvent(LogicalKeyboardKey.f5)); 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)); }