import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/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/ui/explorer_page.dart'; import 'package:path/path.dart' as p; /// 다른 앱으로 열기를 기록만 하는 셸 (실제로 프로그램을 띄우지 않게) class _Shell extends NoopShell { final opened = <(String, bool)>[]; @override Future openWith(String path, {bool choose = false}) async { opened.add((path, choose)); return true; } } void main() { late Directory tmp; late String left, right; late _Shell shell; late AppController c; setUp(() { 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]; }); tearDown(() => tmp.deleteSync(recursive: true)); Future open(WidgetTester tester) async { tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); } testWidgets('두 창: 폴더 한 번 = 펼치기 · 파일 한 번 = 선택 · 두 번 = 실행 · 길게 = 메뉴 · 다른 창으로 복사', (tester) async { await open(tester); // 두 창 + 가운데 버튼 줄, 선택 동그라미는 평소에 안 보임 expect(find.text('상위 폴더'), findsOneWidget); expect(find.text('doc.txt'), findsOneWidget); expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); // 폴더를 한 번 누르면 바로 펼치고, 다시 누르면 접는다 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, () => find.text('inner.txt').evaluate().isEmpty); expect(find.text('inner.txt'), findsNothing); // › 화살표로도 펼친다 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 tester.pumpAndSettle(); 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 tester.pumpAndSettle(); 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('바로 실행 모드: 폴더를 누르면 펼치고, 파일을 누르면 바로 실행', (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 tester.pumpAndSettle(); expect(find.text('다른 창으로 복사'), findsNothing); await tester.tap(find.text('복사').last); await tester.pumpAndSettle(); await act(tester, () => tester.tap(find.text('..'))); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); await act(tester, () => tester.tap(find.text('붙여넣기'))); await tester.pumpAndSettle(); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); await settle(tester, () => File(p.join(left, 'inner.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); expect(File(p.join(left, 'inner.txt')).readAsStringSync(), 'inner'); expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue); // 복사이므로 원본은 그대로 await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); expect(find.text('inner.txt'), findsOneWidget); // 목록에 바로 보임 // Total Commander: [폴더] · · 확장자 칸 await c.updateSettings((s) => s.explorerStyle = 'totalcmd'); await settle(tester, () => find.text('[sub]').evaluate().length == 2); expect(find.text('[sub]'), findsNWidgets(2)); expect(find.text(''), findsOneWidget); expect(find.text('doc'), findsOneWidget); // 확장자를 뺀 이름 expect(find.text('txt'), findsNWidgets(2)); // 확장자 칸 (doc.txt · 붙여넣은 inner.txt) expect(find.text('확장자'), findsOneWidget); }); testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 고른 폴더 다시 누르기 = 펼쳐서 안의 것 선택', (tester) async { 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.text('1개 표시함'), findsOneWidget); await act(tester, () => tester.tap(find.text('doc.txt'))); expect(find.text('0개 표시함'), findsNWidgets(2)); // 두 창 모두 (각 창의 선택 수) expect(shell.opened, isEmpty); // 폴더: 처음엔 선택 (펼치지 않음) await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => false, rounds: 10); expect(find.text('1개 표시함'), findsOneWidget); expect(find.text('inner.txt'), findsNothing); // 고른 폴더를 다시 누르면: 폴더는 풀고, 펼쳐서 안의 파일을 선택 await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); expect(find.text('inner.txt'), findsOneWidget); expect(find.text('1개 표시함'), findsOneWidget); final inner = tester.widget(find.descendant( of: find.ancestor(of: find.text('inner.txt'), matching: find.byType(Material)).first, matching: find.byType(IconButton))); expect((inner.icon as Icon).icon, Icons.check_circle); // ✔ // [선택 끝] → 동그라미가 사라지고 표시도 지운다 await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음 expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); expect(find.text('1개 표시함'), findsNothing); }); 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 act(tester, () => tester.tap(markOf('doc.txt'))); expect(find.text('2개 표시함'), findsOneWidget); await act(tester, () => tester.tap(find.text('삭제').first)); await tester.pumpAndSettle(); // 지울 항목 이름이 확인 창에 보인다 expect(find.textContaining('2개 항목을 지울까요?'), findsOneWidget); expect(find.textContaining('· sub'), findsOneWidget); expect(find.textContaining('· doc.txt'), findsOneWidget); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '삭제'))); await settle(tester, () => find.text('doc.txt').evaluate().isEmpty); expect(Directory(left).listSync(), isEmpty); // 새 폴더 → 이름 변경 await act(tester, () => tester.tap(find.text('새 폴더').first)); await tester.pumpAndSettle(); 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 act(tester, () => tester.tap(find.text('이름 변경').first)); await tester.pumpAndSettle(); 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 tester.pumpAndSettle(); await tester.tap(find.text('창 배치').last); await tester.pumpAndSettle(); 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 tester.pumpAndSettle(); 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 tester.pumpAndSettle(); await tester.tap(find.byTooltip('창 배치 · 버튼 구성')); await tester.pumpAndSettle(); await tester.tap(find.text('버튼 구성').last); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(CheckboxListTile, 'MKV 목록에 추가')); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '확인')); await tester.pumpAndSettle(); 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]); }); } /// [name] 줄의 "표시" 동그라미 버튼 Finder markOf(String name) => find.descendant( of: find.ancestor(of: find.text(name), matching: find.byType(Material)).first, matching: find.byTooltip('표시 (여러 개 고르기)'), ); /// 누르기 등을 실제 비동기 구역에서 (그 때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) Future act(WidgetTester tester, Future Function() f) async { await tester.runAsync(f); await tester.pump(); } /// [name] 트리 줄의 › (펼치기) 화살표 Finder chevronOf(String name) => find.descendant( of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.chevron_right), ); /// 두 번 누르기 (실제 비동기 구역에서: 그때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) Future doubleTap(WidgetTester tester, Finder f) async { await tester.runAsync(() async { await tester.tap(f); await Future.delayed(const Duration(milliseconds: 80)); await tester.tap(f); }); await tester.pump(); } /// 실제 파일 작업 (비동기 IO) 이 끝나기를 기다리며 화면을 갱신 Future settle(WidgetTester tester, bool Function() done, {int rounds = 100}) async { for (var i = 0; i < rounds; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await tester.pump(); if (done()) break; } await tester.pump(const Duration(milliseconds: 300)); }