import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/gestures.dart' show kSecondaryMouseButton; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/i18n_controller.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/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer/job_view.dart' show TransferJobView; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/notice.dart' show NoticeTiming; import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; import '../test/support/fake_recycle_bin.dart'; import 'support/temp_dir.dart'; import '../test/support/test_explorer_device.dart'; /// 탐색기 사용 점검 (관리자 c5 지시 10/22, 분석용 - 커밋하지 않음): 처음 설정 그대로 사용자처럼 써 보며 단계마다 PNG. /// 저장 장치는 시험 폴더 둘 (TestExplorerDevice), 휴지통은 가짜 (사용자 휴지통에 흔적을 남기지 않게), 설정은 메모리에만. void main() { late TestExplorerDevice device; setUp(() => device = TestExplorerDevice()); IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_use'); // E6: 두 모양 - X-plore (캡처 이름 use_너비_…, base7 과 견주는 회귀 확인) · Total Commander (Windows 처음 값, use_tc_너비_…) for (final style in ['xplore', 'totalcmd']) { for (final (w, h) in [(1920, 1040), (960, 600), (412, 900)]) { testWidgets('탐색기 사용 $style ${w}x$h', (t) async { t.view.physicalSize = Size(w.toDouble(), h.toDouble()); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); await t.runAsync(() => i18n.apply('ko', save: false)); useTestRecycleBin(device); // 캡처 비교가 흔들리지 않게 시험 폴더 이름 · 파일 시각을 고정 (관리자 지시 10/22) - 끝나면 지움 final tmp = Directory(p.join(Directory.systemTemp.path, 'jj_usechk_fixed_$w')); if (tmp.existsSync()) tmp.deleteSync(recursive: true); tmp.createSync(); addTearDown(() { if (tmp.existsSync()) tmp.deleteSync(recursive: true); }); appTempInto(tmp); final a = Directory(p.join(tmp.path, 'Videos'))..createSync(); final b = Directory(p.join(tmp.path, 'Backup'))..createSync(); for (final n in ['Show S01', 'Movies', 'Docs']) { Directory(p.join(a.path, n)).createSync(); } for (var i = 1; i <= 3; i++) { File(p.join(a.path, 'Show S01', 'ep0$i.mkv')).writeAsBytesSync(List.filled(5000 * i, 0)); } File(p.join(a.path, 'Movies', 'film.mp4')).writeAsBytesSync(List.filled(9000, 0)); File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo'); File(p.join(a.path, 'report.pdf')).writeAsStringSync('pdf'); for (final f in tmp.listSync(recursive: true).whereType()) { f.setLastModifiedSync(DateTime(2026, 10, 22, 9)); } // E6: TC 모양은 폴더 날짜 칸이 있다 - 폴더 시각도 고정 (Dart 는 폴더 시각을 못 바꿔 PowerShell 로) Process.runSync('powershell', [ '-NoProfile', '-Command', "Get-ChildItem -LiteralPath '${tmp.path}' -Recurse -Directory | ForEach-Object { \$_.LastWriteTime = [datetime]'2026-10-22 09:00' }", ]); device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')]; // E6: TC 모양 창 위의 남은 공간 - 이 PC 의 디스크 대신 정한 값 (실행마다 같게) device.space = (_) => (107374182400, 536870912000); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.explorer.style = style; final shotKey = GlobalKey(); await t.runAsync(() => t.pumpWidget(RepaintBoundary( key: shotKey, // 알림이 실제 시간으로 닫히지 않게 (정한 자리에서 noticeTimesOut 으로 닫는다) child: NoticeTiming( duration: const Duration(hours: 1), child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(device: device, c: c)), ), ))); Future settle([int n = 12]) async { for (var i = 0; i < n; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); await t.pump(); } } var no = 0; Future shot(String name) async { await settle(3); final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; final img = await t.runAsync(() => r.toImage()); final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); Directory(out).createSync(recursive: true); no++; File(p.join(out, '${style == 'totalcmd' ? 'use_tc' : 'use'}_${w}_${no.toString().padLeft(2, '0')}_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); } // 열린 창 (대화 상자 · 메뉴) 의 글을 기록 - 무엇이 떴는지 void dump(String step) { final texts = {}; for (final f in [find.byType(Dialog), find.byType(PopupMenuItem), find.byType(BottomSheet), find.byType(SnackBar)]) { for (final e in find.descendant(of: f, matching: find.byType(Text)).evaluate()) { final s = (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? ''; if (s.isNotEmpty) texts.add(s); } } // ignore: avoid_print print('[${style == 'totalcmd' ? 'tc ' : ''}$w] $step: ${texts.isEmpty ? '(뜬 창 없음)' : texts.join(' | ')}'); } // 복사 · 이동이 끝나 진행 패널이 다 내려갈 때까지 (실제 시간, 10초까지) - 캡처가 PC 빠르기에 따라 달라지지 않게 // 알림은 실제 시간 10초 (앱의 값) 로 닫히면 찍는 때와 겹쳐 장면이 실행마다 달라진다 (14 · 27) - // 이 시험은 NoticeTiming 으로 저절로 닫히지 않게 두고, 정한 자리에서 "시간이 다 되어 닫힘" 으로 닫는다 (앱의 알림 시간은 그대로) Future noticeTimesOut() async { t.state(find.byType(ScaffoldMessenger).first).removeCurrentSnackBar(); await settle(3); } // 복사 · 이동 · 지우기는 받는 폴더의 시각을 지금으로 바꾼다 - TC 는 폴더 날짜 칸이 있어 다시 고정하고 새로 읽는다 (Ctrl+R) Future fixDirTimes() async { await t.runAsync(() => Process.run('powershell', [ '-NoProfile', '-Command', "Get-ChildItem -LiteralPath '${tmp.path}' -Recurse -Directory | ForEach-Object { \$_.LastWriteTime = [datetime]'2026-10-22 09:00' }", ])); await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await t.sendKeyEvent(LogicalKeyboardKey.keyR); await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await settle(); } Future jobsGone() async { for (var i = 0; i < 100 && find.byType(TransferJobView).evaluate().isNotEmpty; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); await t.pump(const Duration(milliseconds: 100)); } await settle(); if (style == 'totalcmd') await fixDirTimes(); } // 한 단계: 실패해도 다음 단계로 (못 찾은 것 자체가 점검 결과) Future step(String name, Future Function() act) async { try { await act(); await settle(); dump(name); } catch (e) { // ignore: avoid_print print('[${style == 'totalcmd' ? 'tc ' : ''}$w] $name: 실패 - ${e.toString().split('\n').first}'); } await shot(name); } // 두 창이면 왼쪽 창의 것만 (같은 폴더가 두 창에 보여 오른쪽 것을 누르지 않게) Finder leftOf(Finder f) { final half = w / 2; // 규칙 7: 폰 세로도 두 창 final el = f.evaluate().where((e) => t.getCenter(find.byWidget(e.widget).first).dx < half).toList(); if (el.isEmpty) throw StateError('왼쪽 창에 없음'); return find.byWidget(el.first.widget).first; } // TC 는 폴더를 [이름], 파일은 확장자를 뺀 이름으로 보인다 (드라이브 탭 C: · D: 는 그대로) String shown(String name) => style != 'totalcmd' || name.endsWith(':') ? name : name.contains('.') ? name.substring(0, name.lastIndexOf('.')) : '[$name]'; // 좁은 TC 줄 (확장자 칸 없음) 은 이름에 확장자를 붙인다 - 두 꼴 가운데 보이는 것 Finder txt(String name) { final a = find.text(shown(name)); return leftOf(a.evaluate().isNotEmpty ? a : find.text(name)); } Finder row(String name) => find.ancestor(of: txt(name), matching: find.byType(InkWell)).first; Finder tip(String s) => find.byWidgetPredicate((x) => x is Tooltip && (x.message ?? '').startsWith(s)).first; // 줄 끝 표시 단추: 이름 글과 같은 높이의 것 Finder mark(String name) { final y = t.getCenter(txt(name)).dy; final half = w / 2; // 규칙 7: 폰 세로도 두 창 final all = find.byWidgetPredicate((x) => x is IconButton && (x.tooltip ?? '').startsWith('표시')); final best = all.evaluate().where((e) => t.getCenter(find.byWidget(e.widget)).dx < half).toList() ..sort((m, n) => (t.getCenter(find.byWidget(m.widget)).dy - y).abs().compareTo((t.getCenter(find.byWidget(n.widget)).dy - y).abs())); return find.byWidget(best.first.widget); } // 대화 상자의 확인 쪽 단추 (마지막 FilledButton) Future okDialog() => t.tap(find.descendant(of: find.byType(Dialog), matching: find.byType(FilledButton)).last); void files(String when) { final left = [ for (final e in tmp.listSync(recursive: true)) if (e is File) p.relative(e.path, from: tmp.path) ]..sort(); // ignore: avoid_print print('[${style == 'totalcmd' ? 'tc ' : ''}$w] 파일 ($when): ${left.join(', ')}'); } Finder paneBtn(String s) => find.byWidgetPredicate((x) => x is IconButton && (x.tooltip ?? '').startsWith(s)).first; await settle(30); await shot('처음'); dump('처음'); await step('Show S01 누르기', () => t.tap(txt('Show S01'))); await step('Show S01 다시 누르기 (접히나)', () => t.tap(txt('Show S01'))); // 펼쳐지지 않았으면 한 번 더 (TC 목록은 두 번 누르기로 폴더 안에 들어가 [Show S01] 이 없을 수 있다 - 그러면 그대로) if (find.text(shown('ep01.mkv')).evaluate().isEmpty) { try { await t.tap(txt('Show S01')); } on StateError catch (_) {} } await settle(); await step('ep01 한 번 누르기 (선택)', () => t.tap(txt('ep01.mkv'))); // 규칙 4 (E3): 표시는 선택 모드에서만 - [선택] 을 켜고 표시 Future selectOn() async { if (find.text('선택 끝').evaluate().isEmpty) await t.tap(tip('선택')); } await step('선택 켜기', selectOn); await step('ep02 표시 단추', () => t.tap(mark('ep02.mkv'))); await step('ep03 표시 단추', () => t.tap(mark('ep03.mkv'))); await step('복사 단추', () => t.tap(tip('복사'))); await step('복사 확인 누르기', () async { if (find.byType(Dialog).evaluate().isNotEmpty) { await okDialog(); } else { await t.tap(txt('D:')); await settle(); await t.tap(tip('붙여넣기')); await settle(); if (find.byType(Dialog).evaluate().isNotEmpty) await okDialog(); await settle(); await t.tap(txt('C:')); } await jobsGone(); }); await settle(20); files('복사 뒤'); await step('선택 모드 켜기 (켜져 있으면 그대로)', selectOn); await step('선택 모드 - Show S01 누르기', () => t.tap(txt('Show S01'))); await step('선택 모드 - 안의 ep01 누르기', () => t.tap(txt('ep01.mkv'))); await step('선택 모드 - Show S01 다시 누르기', () => t.tap(txt('Show S01'))); await step('선택 단추 (끄기)', () => t.tap(tip('선택'))); await noticeTimesOut(); // 복사 알림: 선택을 끈 뒤에는 닫혀 있음 await step('ep03 오른쪽 클릭', () => t.tap(row('ep03.mkv'), buttons: kSecondaryMouseButton)); await step('메뉴 이름 변경', () => t.tap(find.text('이름 변경').last)); await step('새 이름 넣고 확인', () async { await t.enterText(find.descendant(of: find.byType(Dialog), matching: find.byType(TextField)), 'ep03_new.mkv'); await settle(2); await okDialog(); }); files('이름 바꾼 뒤'); await step('ep01 오른쪽 클릭', () => t.tap(row('ep01.mkv'), buttons: kSecondaryMouseButton)); await step('메뉴 삭제', () => t.tap(find.text('삭제').last)); await step('삭제 확인 누르기', okDialog); files('지운 뒤'); await step('Movies 누르기', () => t.tap(txt('Movies'))); await step('창 뒤로', () => t.tap(paneBtn('뒤로'))); await step('창 앞으로', () => t.tap(paneBtn('앞으로'))); await step('선택 켜기 2', selectOn); await step('film 표시 단추', () => t.tap(mark('film.mp4'))); await step('이동 단추', () => t.tap(tip('이동'))); await noticeTimesOut(); // 휴지통 알림: 이동을 누르기 전에는 닫혀 있음 (이동 창 뒤에는 아직 보임) await step('이동 확인 누르기', () async { if (find.byType(Dialog).evaluate().isNotEmpty) { await okDialog(); } else { await t.tap(txt('D:')); await settle(); await t.tap(tip('붙여넣기')); } await jobsGone(); }); await settle(20); files('이동 뒤'); await step('notes 누르고 Space (TC 식 고르기)', () async { await t.tap(txt('notes.txt')); await settle(4); await t.sendKeyEvent(LogicalKeyboardKey.space); }); await step('Insert', () => t.sendKeyEvent(LogicalKeyboardKey.insert)); // TC: 앞 단계의 글만 있는 알림 (Flutter 기본 4초) 이 찍는 때에 따라 남거나 사라졌다 - 여기서 닫힌 것으로 (X-plore 는 base7 그대로) if (style == 'totalcmd') await noticeTimesOut(); await step('report 누르고 Enter (열기 대신 무엇이)', () async { await t.tap(txt('report.pdf')); await settle(4); }); // TC 전용 (관리자 지시 10/23): 목록 들어가고 나오기 (..) · 아래 단추 줄 F5 복사 · F6 이동 · F8 삭제 · 지운 뒤 초점 if (style == 'totalcmd') { Future fkey(String tip) async { await t.tap(find.byTooltip(tip)); await settle(); if (find.byType(Dialog).evaluate().isNotEmpty) await okDialog(); await jobsGone(); // TC 는 jobsGone 이 폴더 시각을 다시 고정한다 } await noticeTimesOut(); // 선택 모드면 끈다 (선택 모드의 누르기는 표시 - 들어가기를 보려면 끈다) await step('TC - 선택 끝', () async { if (find.text('선택 끝').evaluate().isNotEmpty) await t.tap(find.text('선택 끝').first); }); // 맨 위 (Videos) 로: [Docs] 가 왼쪽 창에 보일 때까지 Backspace (위 폴더 키) - 두 번 누르기는 위로 간 뒤 같은 자리의 다른 줄을 열었다 await step('TC - Backspace 로 맨 위까지', () async { // 왼쪽 창을 지금 창으로 (창 위 남은 공간 줄 - 누르기 동작이 없는 자리) await t.tap(leftOf(find.textContaining('남은'))); await settle(10); bool top() => find.text(shown('Docs')).evaluate().any((e) => t.getCenter(find.byWidget(e.widget).first).dx < w / 2); for (var i = 0; i < 4 && !top(); i++) { await t.sendKeyEvent(LogicalKeyboardKey.backspace); await settle(); } }); // 들어가기 · 나오기 (관리자 10/23: TC 는 한 번 = 고르기, 두 번 · Enter = 열기). 자리를 한 번만 재고 실제 시간 60ms 사이로 두 번 Future doubleTapOn(Finder f) async { final at = t.getCenter(f); await t.tapAt(at); await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); await t.tapAt(at); await settle(); } await step('TC - [Docs] 한 번 (고르기만)', () async { await t.tap(txt('Docs')); await settle(10); }); await step('TC - Enter (들어가기)', () async { await t.sendKeyEvent(LogicalKeyboardKey.enter); await settle(); }); await step('TC - .. 두 번 (나오기)', () => doubleTapOn(leftOf(find.text('..')))); await step('TC - [Docs] 두 번 (들어가기)', () => doubleTapOn(txt('Docs'))); await step('TC - .. 두 번 (다시 나오기)', () => doubleTapOn(leftOf(find.text('..')))); await step('TC - notes 고르고 F5 복사', () async { await t.tap(txt('notes.txt')); await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다 await fkey('F5 복사'); }); await noticeTimesOut(); await step('TC - report 고르고 F6 이동', () async { await t.tap(txt('report.pdf')); await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다 await fkey('F6 이동'); }); await noticeTimesOut(); await step('TC - notes 고르고 F8 삭제 (지운 뒤 초점)', () async { await t.tap(txt('notes.txt')); await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다 await fkey('F8 삭제'); }); files('TC 단계 뒤'); } // 관리자 10/23: "폴더 + 파일 목록" 배치 - 목록의 폴더는 한 번에 열리고, 습관처럼 곧바로 누른 두 번째 누르기는 버린다 (도로 나오지 않음) if (style == 'xplore' && w >= 960) { await step('목록 배치 - 폴더 + 파일 목록으로', () async { await c.updateSettings((x) => x.explorer.layout = 'split'); await settle(20); }); await step('목록 배치 - 오른쪽 목록 Movies 한 번 + 곧바로 두 번째', () async { final row = find.text('Movies').last; // 오른쪽 목록의 줄 final at = t.getCenter(row); await t.tapAt(at); await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); await t.tapAt(at); await settle(20); }); } // 끝 상태: 실제 파일 final left = [ for (final e in tmp.listSync(recursive: true)) if (e is File) p.relative(e.path, from: tmp.path) ]..sort(); // ignore: avoid_print print('[${style == 'totalcmd' ? 'tc ' : ''}$w] 끝 파일: ${left.join(', ')}'); await t.pumpWidget(const SizedBox()); await settle(4); }); } } }