JJ_MKVMaker/test/explorer_test.dart

1636 lines
93 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:async';
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/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/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<bool> 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<void>.delayed(const Duration(milliseconds: 100));
}
}
});
Future<void> 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<void>.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<void>.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<String>('k1:$right'));
expect(otherRoot, findsWidgets, reason: '왼쪽 창 트리에 R: (다른 저장 장치) 줄');
final favs = find.byKey(const ValueKey<String>('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<String>('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<String>('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<IconButton>(find.descendant(
of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, matching: find.byType(IconButton)))
.icon as Icon)
.icon!;
Future<void> 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 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<void>);
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: [폴더] · <DIR> · 확장자 칸
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('<DIR>'), 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<IconButton>(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<void> 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<void> 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<IconButton>(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<String> 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<TextField>(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<void> 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<void> 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<void> until(bool Function() done) async {
for (var i = 0; i < 100 && !done(); i++) {
await tester.runAsync(() => Future<void>.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<TransferProgressChip>(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<void> 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<TextField>(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<FilledButton>(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<void> act(WidgetTester tester, Future<void> 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<void> 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<void> idle(WidgetTester tester) => settle(
tester,
() => find
.byWidgetPredicate((w) => w is ProgressIndicator && w.value == null)
.evaluate()
.isEmpty);
/// 탐색기 시험의 pumpAndSettle: 목록 읽기가 끝난 뒤에 (경로를 바꾸거나 다시 읽은 뒤 무거울 때 흔들리던 것을 한 번에)
Future<void> calm(WidgetTester tester) async {
await idle(tester);
await tester.pumpAndSettle();
}
/// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신
// 끝나면 바로 나온다 - 넉넉한 횟수는 전체 시험 중 PC 가 바쁠 때 (실제 파일 입출력이 느려짐) 흔들리지 않게
Future<void> settle(WidgetTester tester, bool Function() done, {int rounds = 300}) async {
for (var i = 0; i < rounds; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
if (done()) break;
}
await tester.pump(const Duration(milliseconds: 300));
}