JJ_MKVMaker/test/explorer_test.dart

772 lines
42 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: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/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);
// 두 창 + 가운데 버튼 줄, 선택 동그라미는 평소에 안 보임
expect(find.text('상위 폴더'), findsOneWidget);
expect(find.text('doc.txt'), findsOneWidget);
expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing);
// 모니터링 · 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);
expect(find.text('다른 창으로 복사'), 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('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.text('다른 창으로 복사'), 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));
// [선택 끝] → 동그라미가 사라진다
await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음
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('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');
});
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); // 창마다 하나만
});
}
/// [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));
}