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/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'); for (final (w, h) in [(1920, 1040), (960, 600), (412, 900)]) { testWidgets('탐색기 사용 ${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(); // 캡처 비교가 흔들리지 않게 시험 폴더 이름 · 파일 시각을 고정 (관리자 지시 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)); } device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')]; final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final shotKey = GlobalKey(); await t.runAsync(() => t.pumpWidget(RepaintBoundary( key: shotKey, 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, '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('[$w] $step: ${texts.isEmpty ? '(뜬 창 없음)' : texts.join(' | ')}'); } // 복사 · 이동이 끝나 진행 패널이 다 내려갈 때까지 (실제 시간, 10초까지) - 캡처가 PC 빠르기에 따라 달라지지 않게 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(); } // 한 단계: 실패해도 다음 단계로 (못 찾은 것 자체가 점검 결과) Future step(String name, Future Function() act) async { try { await act(); await settle(); dump(name); } catch (e) { // ignore: avoid_print print('[$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; } Finder txt(String name) => leftOf(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('[$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'))); if (find.text('ep01.mkv').evaluate().isEmpty) await t.tap(txt('Show S01')); 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 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 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)); await step('report 누르고 Enter (열기 대신 무엇이)', () async { await t.tap(txt('report.pdf')); await settle(4); }); // 끝 상태: 실제 파일 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('[$w] 끝 파일: ${left.join(', ')}'); await t.pumpWidget(const SizedBox()); await settle(4); }); } }