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/core/file_ops.dart' show FileEntry, samePath; 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/explorer_controller.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart'; import 'package:jj_mkvmaker/ui/explorer/pane_header.dart'; import 'package:jj_mkvmaker/ui/explorer/pane_view.dart'; import 'package:jj_mkvmaker/ui/explorer/path_dialog.dart'; import 'package:jj_mkvmaker/ui/explorer/server_flows.dart'; import 'package:jj_mkvmaker/ui/explorer_drag.dart' show DragLoad; import 'package:jj_mkvmaker/ui/explorer_look.dart' show rowSpacingFactor; import 'package:path/path.dart' as p; /// E5d-3 약속 시험: path_dialog · server_flows · pane_header · pane_view 맨 위 약속 (실제 시험 폴더 · 실제 화면 · 실제 창) void main() { late Directory tmp; late String root, show; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_pane_ui_'); root = tmp.path; show = p.join(root, 'Show'); Directory(show).createSync(); File(p.join(show, 'ep1.mkv')).writeAsStringSync('1'); File(p.join(root, 'a.txt')).writeAsStringSync('a'); }); tearDown(() => tmp.deleteSync(recursive: true)); /// 실제 I/O 를 기다린 뒤 그린다 Future settle(WidgetTester t) async { for (var i = 0; i < 10; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(const Duration(milliseconds: 100)); } } test('경로 확인: 빈 칸 · 있는 폴더 · 없는 폴더 · Android 의 SMB', () async { expect(await checkEnteredPath(' ', android: false), (null, null)); expect(await checkEnteredPath('"$show"', android: false), (show, null)); final (miss, why) = await checkEnteredPath(p.join(root, 'none'), android: false); expect([miss, why!.isNotEmpty], [null, true]); expect(await checkEnteredPath('smb://nas/v', android: true), (null, smbNotSupported)); }); Future<(ExplorerController, GlobalKey<_HostState>)> open(WidgetTester t, {double width = 800}) async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander final key = GlobalKey<_HostState>(); await t.binding.setSurfaceSize(Size(width, 600)); addTearDown(() => t.binding.setSurfaceSize(null)); await t.pumpWidget(MaterialApp(home: _Host(key: key))); final ctl = ExplorerController(c, rsync: false, host: key.currentState!)..local = [(root, 'T:')]; final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); addTearDown(() { sel.dispose(); ctl.dispose(); }); await t.runAsync(() => ctl.goTo(ctl.panes[0], root)); key.currentState!.attach(ctl, sel); await t.pump(); return (ctl, key); } testWidgets('경로 입력 창: 없는 폴더는 창을 닫지 않고 이유 · 있는 폴더는 창을 옮김 · Android SMB 는 서버 추가로', (t) async { final (ctl, key) = await open(t); final q = ctl.panes[0]; final servers = ExplorerServers(ctl); var done = false; servers.enterPath(key.currentContext!, q, android: false).then((_) => done = true); await t.pump(); await t.enterText(find.byType(TextField).last, p.join(root, 'none')); await t.tap(find.text('확인')); await settle(t); expect([find.byType(PathDialog).evaluate().length, find.text('폴더가 없거나 열 수 없습니다').evaluate().length], [1, 1]); await t.enterText(find.byType(TextField).last, show); await t.tap(find.text('확인')); await settle(t); expect([done, find.byType(PathDialog).evaluate().isEmpty, samePath(q.current, show)], [true, true, true]); String? smb; showDialog(context: key.currentContext!, builder: (_) => const PathDialog(initial: '', android: true)).then((v) => smb = v); await t.pump(); await t.enterText(find.byType(TextField).last, 'smb://nas/v'); await t.tap(find.text('확인')); await settle(t); await t.tap(find.text('SMB 서버 추가')); await settle(t); expect(smb, '${PathDialog.addSmb}smb://nas/v'); }); testWidgets('창 보기: 머리 · 줄 · 빠른 필터 수 · 좁은 창은 [⋮]', (t) async { final (ctl, key) = await open(t); final q = ctl.panes[0]; expect(find.text('a.txt'), findsOneWidget); expect(find.byIcon(Icons.more_vert), findsNothing, reason: '넓은 창은 끝 버튼을 펼침'); // 뒤로 · 앞으로: 처음은 갈 곳 없음 → 폴더를 옮기면 뒤로가 켜짐 expect(t.widget(find.widgetWithIcon(IconButton, Icons.arrow_back)).onPressed, isNull); await t.runAsync(() => ctl.goTo(q, show)); await t.pump(); expect(t.widget(find.widgetWithIcon(IconButton, Icons.arrow_back)).onPressed, isNotNull); await t.runAsync(() => ctl.goTo(q, root)); await t.pump(); // 빠른 필터: 머리 단추 → 화면 콜백 → 칸 · 보이는 수 await t.tap(find.widgetWithIcon(IconButton, Icons.filter_alt_outlined)); await t.pump(); expect(key.currentState!.calls, ['openFilter']); await t.enterText(find.byType(TextField), 'a.t'); await t.pump(); expect(find.text('1개 보임'), findsOneWidget); await t.tap(find.widgetWithIcon(IconButton, Icons.close)); await t.pump(); expect(key.currentState!.calls, ['openFilter', 'filterChanged', 'closeFilter']); await t.binding.setSurfaceSize(const Size(400, 600)); await t.pump(); expect(find.byIcon(Icons.more_vert), findsOneWidget, reason: '좁은 창 ($paneHeaderWideWidth 미만) 은 [⋮] 하나'); }); } class _Host extends StatefulWidget { const _Host({super.key}); @override State<_Host> createState() => _HostState(); } /// 실제 화면: ExplorerHost · ExplorerPaneParts 를 이 화면이 맡는다 (틀 · 안내 · 표시 막대는 비움, 부른 것은 [calls] 에) class _HostState extends State<_Host> implements ExplorerHost, ExplorerPaneParts { ExplorerController? ctl; ExplorerSelect? sel; final calls = []; void attach(ExplorerController c, ExplorerSelect s) { ctl = c..addListener(_redraw); sel = s; for (final q in c.panes) { q.addListener(_redraw); } setState(() {}); } void _redraw() { if (mounted) setState(() {}); } @override bool get alive => mounted; @override void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); @override void reveal(ExplorerPane pane, String path) {} @override void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); @override double get rowHeight => ctl!.look.rowHeightOf(context, textScale: 1, spacing: rowSpacingFactor('')); @override double get listTextScale => 1; @override bool get narrowSplit => false; @override bool get alwaysMarks => false; @override Widget frame(int i, List children) => Column(children: children); @override Future<(int, int)?> diskSpace(String path) async => null; @override Widget header(ExplorerPane pane) => ExplorerPaneHeader( ctl: ctl!, servers: ExplorerServers(ctl!), pane: pane, android: false, onBookmarks: () => calls.add('bookmarks'), onOpenFilter: () => setState(() { calls.add('openFilter'); pane.filterOpen = true; }), onCloseFilter: () => closeFilter(pane), onOpenRoot: () async => calls.add('root'), onOpenShizuku: () async => calls.add('shizuku'), onReconnect: (_) async => calls.add('reconnect'), ); @override List notices(ExplorerPane pane, String dir) => const []; @override Widget marksBar(ExplorerPane pane, {String? listDir}) => const SizedBox(); @override void onMenu(ExplorerPane pane, FileEntry e, Offset at) {} @override List dragPaths(ExplorerPane pane, FileEntry e) => [e.path]; @override Future onDrop(DragLoad load, int to, String dest) async {} @override void closeFilter(ExplorerPane pane) => setState(() { calls.add('closeFilter'); pane ..filterOpen = false ..filterCtl.clear(); }); @override void filterChanged() => setState(() => calls.add('filterChanged')); @override Widget build(BuildContext context) => Scaffold( body: ctl == null ? const SizedBox() : ExplorerPaneView(ctl: ctl!, sel: sel!, parts: this, index: 0)); }