import 'dart:io'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/quick_look.dart'; import 'package:jj_mkvmaker/ui/explorer_drag.dart'; import 'package:jj_mkvmaker/ui/storage_page.dart'; import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; import 'support/explorer_harness.dart'; /// E5e: 예전 explorer_test.dart 에서 나눈 탐색기 화면 시험 - 단축키 (TC · Windows 탐색기) · 창 사이 끌어 놓기 · 진행 패널 · 빠른 보기 · 저장 공간 분석 · 열 머리 정렬 void main() { useExplorerFixture(); testWidgets('83: Total Commander 모양 단축키 - F5 복사 · F7 새 폴더 · Space/Insert 표시 (Space 는 빠른 필터가 아님)', (tester) async { File(p.join(left, 'e.txt')).writeAsStringSync('e'); c.settings.explorer.style = 'totalcmd'; await open(tester); await act(tester, () => tester.tap(find.text('doc'))); await settle(tester, () => false, rounds: 15); // Insert: 표시하고 다음 줄 (e.txt) 로, Space: 지금 줄 (e.txt) 표시 await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.insert)); await calm(tester); expect(find.textContaining('1개 표시함'), findsOneWidget); await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.space)); await calm(tester); expect(find.textContaining('2개 표시함'), findsOneWidget, reason: 'Insert 뒤 다음 줄이 표시됨'); expect(find.byIcon(Icons.filter_alt), findsNothing, reason: 'Space 가 빠른 필터를 열지 않는다'); // F5 = 복사 (확인 창) - 취소 await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f5)); await calm(tester); expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget); await tester.tap(find.text('취소').last); await calm(tester); // F7 = 새 폴더 await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f7)); await calm(tester); expect(find.text('새 폴더'), findsWidgets); await tester.tap(find.text('취소').last); await calm(tester); }); testWidgets('83 (28 지적): Total Commander 모양 F8 = 지우기 - 확인 창 [취소] 면 그대로, [휴지통으로] 면 휴지통으로', (tester) async { final bin = device.bin; if (bin is! FakeRecycleBin) return markTestSkipped('실제 휴지통 (JJ_TEST_REAL_RECYCLE=1) 에서는 건너뜀'); if (!Platform.isWindows) return markTestSkipped('휴지통은 Windows 만 (Android 는 앱 휴지통 시험)'); final victim = File(p.join(left, 'tcdel.txt'))..writeAsStringSync('x'); c.settings.explorer.style = 'totalcmd'; await open(tester); await settle(tester, () => find.text('tcdel').evaluate().isNotEmpty); await act(tester, () => tester.tap(find.text('tcdel'))); await settle(tester, () => false, rounds: 15); // F8 → 확인 창 → [취소]: 아무것도 지우지 않음 await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f8)); await calm(tester); expect(find.text('휴지통으로 보내기'), findsOneWidget); expect(find.widgetWithText(FilledButton, '휴지통으로'), findsOneWidget, reason: '처음 값은 휴지통 (영구 삭제 아님)'); await tester.tap(find.text('취소').last); await calm(tester); expect(victim.existsSync(), isTrue); expect(bin.items, isEmpty); // F8 → [휴지통으로]: 휴지통 길로 (영구 삭제가 아님) await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f8)); await calm(tester); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); await settle(tester, () => !victim.existsSync() && find.byType(SnackBar).evaluate().isNotEmpty); expect(victim.existsSync(), isFalse); expect(bin.items.keys, [victim.path], reason: '휴지통으로 보냄 (되돌릴 수 있음)'); expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue, reason: '고른 것만'); expect(find.text('되돌리기'), findsOneWidget); await calm(tester); }); testWidgets('83: Windows 탐색기 모양 단축키 - Ctrl+A 모두 표시 · Ctrl+C 담기 · Ctrl+V 붙여넣기', (tester) async { c.settings.explorer.style = 'windows'; await open(tester); await act(tester, () => tester.tap(find.text('doc.txt'))); await settle(tester, () => false, rounds: 15); await act(tester, () async { await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyEvent(LogicalKeyboardKey.keyA); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); }); await calm(tester); expect(find.textContaining('2개 표시함'), findsOneWidget, reason: 'doc.txt · sub'); await act(tester, () async { await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyEvent(LogicalKeyboardKey.keyC); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); }); await calm(tester); expect(find.textContaining('담았습니다'), findsOneWidget); }); group('82 창 사이 끌어 놓기', () { test('놓을 수 있는 곳: 같은 창 · 자기 안 · 원본 폴더 자신은 안 됨', () { final a = p.join('C:', 'x', 'a'), f = p.join('C:', 'x', 'f.txt'); expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'y')), isTrue); expect(canDropInto(DragLoad(0, [f]), 0, p.join('C:', 'y')), isFalse, reason: '같은 창'); expect(canDropInto(DragLoad(0, [a]), 1, p.join(a, 'in')), isFalse, reason: '자기 안'); expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'x')), isFalse, reason: '원본 폴더 자신'); }); /// 오른쪽 창에 보이는 [name] 줄 (두 창 모두 같은 이름이 보이면 오른쪽 것) Offset rightOf(WidgetTester tester, String name) { final all = find.text(name).evaluate().map((e) => tester.getCenter(find.byWidget(e.widget).first)).toList() ..sort((x, y) => x.dx.compareTo(y.dx)); return all.last; } Future mouseDrag(WidgetTester tester, Offset from, Offset to) async { final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); await g.moveBy(const Offset(20, 0)); await tester.pump(); await g.moveTo(to); await tester.pump(); await g.up(); await calm(tester); } testWidgets('짧게 움직이면 고르기 · 같은 창에는 놓아도 아무 일 없음 · 묻기 [취소] 도 아무 일 없음', (tester) async { await open(tester); final doc = tester.getCenter(find.text('doc.txt').first); // 4px 만 움직임 = 누르기 (끌기 아님) final g = await tester.startGesture(doc, kind: PointerDeviceKind.mouse); await g.moveBy(const Offset(4, 0)); await g.up(); await calm(tester); expect(find.byType(AlertDialog), findsNothing); // 같은 창의 sub 위에 놓기 - 못 놓음 await mouseDrag(tester, doc, tester.getCenter(find.text('sub').first)); expect(find.byType(AlertDialog), findsNothing, reason: '같은 창에는 놓지 않는다'); // 오른쪽 창 (right 폴더) 에 놓기 → 묻기 → 취소 await mouseDrag(tester, doc, rightOf(tester, 'right')); expect(find.text('1개 항목을 놓았습니다'), findsOneWidget); await tester.tap(find.text('취소').last); await calm(tester); expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse); }); testWidgets('이동 → 같은 이름을 덮어쓴 파일은 되돌리지 않는다고 파일로 알림 (15 지적)', (tester) async { File(p.join(right, 'doc.txt')).writeAsStringSync('old'); File(p.join(left, 'e.txt')).writeAsStringSync('e'); c.settings ..explorer.dropAction = 'move' ..copy.conflict = 'overwrite'; await open(tester); // 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지 await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty); await act(tester, () => tester.tap(find.text('선택').first)); await act(tester, () => tester.tap(markOf('doc.txt'))); await act(tester, () => tester.tap(markOf('e.txt'))); await calm(tester); await mouseDrag(tester, tester.getCenter(find.text('e.txt').first), rightOf(tester, 'right')); await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); expect(find.textContaining('같은 이름을 덮어쓴 파일 1개는 되돌리지 않습니다'), findsOneWidget); expect(find.textContaining('합친 폴더'), findsNothing); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); await calm(tester); await tester.pump(const Duration(seconds: 2)); // 진행 막대가 내려가는 시간 (1.2초) 까지 }); testWidgets('이동 → [되돌리기]: 새로 생긴 것은 통째로, 이미 있던 폴더에 합친 것은 옮긴 파일만', (tester) async { // 오른쪽에 이미 sub 폴더 (원래 파일 하나) - 합치기 File(p.join(right, 'sub', 'mine.txt')) ..createSync(recursive: true) ..writeAsStringSync('mine'); c.settings ..explorer.dropAction = 'move' ..copy.conflict = 'overwrite'; await open(tester); await act(tester, () => tester.tap(find.text('선택').first)); await act(tester, () => tester.tap(markOf('doc.txt'))); await act(tester, () => tester.tap(markOf('sub'))); await calm(tester); await mouseDrag(tester, tester.getCenter(find.text('doc.txt').first), rightOf(tester, 'right')); // (놓은 뒤 대상에 이미 있는 이름을 실제 파일로 확인하므로 실제 시간을 흘려 기다린다) await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); // 지금의 이동 확인 창 그대로 await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue); expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isTrue); expect(Directory(p.join(left, 'sub')).existsSync(), isFalse); // 되돌리기: 원래 폴더마다 지금의 이동 (확인 창) await act(tester, () => tester.tap(find.text('되돌리기'))); // 원래 폴더가 둘 (left · left/sub) 이라 이동 확인 창도 둘 for (var i = 0; i < 2; i++) { await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); await settle(tester, () => find.byType(AlertDialog).evaluate().isEmpty, rounds: 50); } await settle(tester, () => File(p.join(left, 'doc.txt')).existsSync() && File(p.join(left, 'sub', 'inner.txt')).existsSync()); expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello'); expect(File(p.join(left, 'sub', 'inner.txt')).readAsStringSync(), 'inner'); expect(File(p.join(right, 'sub', 'mine.txt')).readAsStringSync(), 'mine', reason: '원래 있던 폴더 · 파일은 그대로 (폴더째 옮기지 않음)'); expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isFalse); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); await calm(tester); }); }); testWidgets('85: 진행 패널 [숨기기] → 아래 작은 표시 (누르면 다시) · 끝난 알림의 [폴더 열기]', (tester) async { await open(tester); await act(tester, () => tester.tap(find.text('doc.txt'))); await settle(tester, () => false, rounds: 15); await act(tester, () => tester.tap(find.text('복사').first)); await calm(tester); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); await settle(tester, () => File(p.join(right, 'doc.txt')).existsSync() && find.text('폴더 열기').evaluate().isNotEmpty); expect(find.text('폴더 열기'), findsOneWidget, reason: '끝난 알림에 [폴더 열기]'); // 패널은 끝난 뒤 잠깐 남는다 - [숨기기] expect(find.text('숨기기'), findsOneWidget); // 패널 · 작은 표시는 끝나고 1.2초 (시험 시계) 뒤 내려간다. pumpAndSettle (calm) 은 애니메이션이 있는 동안 시계를 그만큼 // 넘길 수 있어 (PC 가 바쁠 때 흔들림 - 관리자 지시), 정해진 횟수 대신 조건이 될 때까지 시계를 조금씩만 돌린다 Future until(bool Function() done) async { for (var i = 0; i < 100 && !done(); i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await tester.pump(const Duration(milliseconds: 10)); } } await act(tester, () => tester.tap(find.text('숨기기'))); await until(() => c.settings.explorer.progressHidden && find.text('복사 · 이동 끝').evaluate().isNotEmpty); expect(c.settings.explorer.progressHidden, isTrue, reason: '숨김은 기억'); expect(find.text('숨기기'), findsNothing); expect(find.text('복사 · 이동 끝'), findsOneWidget, reason: '아래 작은 표시'); // 작은 표시를 누름 (아래 알림이 덮고 있어도 - 알림이 닫히기를 기다리면 그사이 작은 표시가 내려갈 수 있다) tester.widget(find.byType(TransferProgressChip)).onTap(); await until(() => !c.settings.explorer.progressHidden); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); expect(c.settings.explorer.progressHidden, isFalse, reason: '누르면 다시 펼침'); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); await calm(tester); }); testWidgets('81: 빠른 보기 (글 파일 앞부분) - Windows 는 TC 키 F3 (규칙 1), Android 는 Space · 필터에 치는 중에는 그냥 빈칸', (tester) async { c.settings.explorer.style = 'windows'; await open(tester); await act(tester, () => tester.tap(find.text('doc.txt'))); await settle(tester, () => false, rounds: 15); await act(tester, () => tester.sendKeyEvent(Platform.isWindows ? LogicalKeyboardKey.f3 : LogicalKeyboardKey.space)); await settle(tester, () => find.byType(QuickLook).evaluate().isNotEmpty); expect(find.byType(QuickLook), findsOneWidget); await settle(tester, () => find.text('hello').evaluate().isNotEmpty); expect(find.text('hello'), findsOneWidget, reason: '글 파일 앞부분'); await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); await calm(tester); expect(find.byType(QuickLook), findsNothing); // 빠른 필터에 치는 중이면 Space 는 칸의 글자 (빠른 보기를 열지 않음) await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.keyD)); await calm(tester); expect(find.byType(TextField), findsOneWidget); await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.space)); await calm(tester); expect(find.byType(QuickLook), findsNothing); await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); await calm(tester); // TC: F3 await c.updateSettings((s) => s.explorer.style = '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.explorer.style = style; await open(tester); await act(tester, () => tester.tap(find.text(style == 'totalcmd' ? 'doc' : 'doc.txt'))); await settle(tester, () => false, rounds: 15); // Ctrl+F 로 필터 칸 열고 글자 Future ctrl(LogicalKeyboardKey k) => act(tester, () async { await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyEvent(k); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); }); await ctrl(LogicalKeyboardKey.keyF); await calm(tester); final field = find.byType(TextField); expect(field, findsOneWidget); await tester.enterText(field, 'do'); await calm(tester); await ctrl(LogicalKeyboardKey.keyA); await calm(tester); final ctl = tester.widget(field).controller!; expect(ctl.selection, const TextSelection(baseOffset: 0, extentOffset: 2), reason: 'Ctrl+A 는 칸의 글 전체 선택'); await ctrl(LogicalKeyboardKey.keyC); await calm(tester); await ctrl(LogicalKeyboardKey.keyV); await calm(tester); for (final k in [LogicalKeyboardKey.delete, LogicalKeyboardKey.space, LogicalKeyboardKey.f8, LogicalKeyboardKey.backspace]) { await act(tester, () => tester.sendKeyEvent(k)); await calm(tester); } expect(find.textContaining('표시함'), findsNothing, reason: '파일이 표시되지 않음'); expect(find.textContaining('담았습니다'), findsNothing, reason: '파일을 담지 않음'); expect(find.textContaining('휴지통'), findsNothing, reason: '지우기 창이 뜨지 않음'); expect(find.textContaining('지울까요'), findsNothing); expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); expect(field, findsOneWidget, reason: '칸은 그대로 열려 있음 (Backspace 로 위 폴더로 가지 않음)'); // Esc 는 지금처럼 필터를 닫는다 await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.escape)); await calm(tester); expect(find.byType(TextField), findsNothing); ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); await calm(tester); }); } testWidgets('87: 폴더 길게 누르기 [저장 공간 분석] → 큰 파일을 누르면 탐색기에서 그 자리로', (tester) async { File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(4000, 1)); await open(tester); await tester.longPress(find.text('sub').first); await calm(tester); await act(tester, () => tester.tap(find.text('저장 공간 분석'))); await settle(tester, () => find.text('큰 파일').evaluate().isNotEmpty); await act(tester, () => tester.tap(find.text('큰 파일'))); await calm(tester); await act(tester, () => tester.tap(find.text('big.bin'))); await settle(tester, () => find.byType(StorageAnalysisPage).evaluate().isEmpty && find.text('big.bin').evaluate().isNotEmpty); await calm(tester); expect(find.byType(StorageAnalysisPage), findsNothing); expect(find.text('big.bin'), findsWidgets, reason: '탐색기에서 그 자리 (sub 안) 가 보인다'); }); testWidgets('84: 열 머리를 누르면 그 기준으로 · 다시 누르면 거꾸로 (처음: 모든 폴더 같은 정렬)', (tester) async { File(p.join(left, 'a_big.txt')).writeAsStringSync('x' * 50); c.settings.explorer.style = '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.explorer.sort, c.settings.explorer.sortDesc), ('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.explorer.sortDesc, isTrue, reason: '같은 열을 다시 누르면 거꾸로'); // (거꾸로면 트리에 펼쳐진 위 폴더 (Temp 등) 의 차례도 바뀌어 시험 폴더가 화면 밖으로 갈 수 있어 줄 위치는 보지 않는다) expect(find.byIcon(Icons.arrow_drop_down), findsWidgets, reason: '지금 정렬 열에 ▼'); }); testWidgets('84: "정렬을 폴더마다 기억" 을 켜면 그 폴더에만 (전체 정렬 · 다른 창은 그대로)', (tester) async { c.settings ..explorer.style = 'totalcmd' ..explorer.sortPerFolder = true; await open(tester); // 왼쪽 창에서 날짜 열 await act(tester, () => tester.tap(find.text('날짜').first)); await calm(tester); expect(c.settings.explorer.sort, 'name', reason: '전체 정렬은 그대로'); expect(c.settings.explorer.folderSorts.values, contains('date|0')); expect(c.settings.explorer.folderSorts.length, 1); // 저장 · 읽기 expect(AppSettings.fromJson(c.settings.toJson()).explorer.folderSorts, c.settings.explorer.folderSorts); expect(AppSettings.fromJson(c.settings.toJson()).explorer.sortPerFolder, isTrue); expect(AppSettings.fromJson({'explorerFolderSorts': 'broken'}).explorer.folderSorts, isEmpty, reason: '틀린 값은 비움'); }); }