import 'dart:async'; 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; 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_bars.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_body.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_buttons.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_flows.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_layout.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/server_flows.dart'; import 'package:jj_mkvmaker/ui/explorer/toolbar.dart'; import 'package:jj_mkvmaker/ui/explorer_drag.dart' show DragLoad; import 'package:jj_mkvmaker/ui/explorer_search.dart' show SearchResultsPage; import 'package:jj_mkvmaker/ui/storage_page.dart' show StorageAnalysisPage; import 'package:path/path.dart' as p; /// E5e 약속 시험: explorer_body · search_flow · tools_flow · rsync_flow 맨 위 약속 /// (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음). rsync 실행은 rsync 실행 파일이 있어야 해 확인 창까지) void main() { late Directory tmp; late String root, l, r; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_more_flow_'); root = tmp.path; l = p.join(root, 'L'); r = p.join(root, 'R'); File(p.join(l, 'doc.txt')) ..createSync(recursive: true) ..writeAsStringSync('hello'); Directory(r).createSync(); }); tearDown(() => tmp.deleteSync(recursive: true)); Future settle(WidgetTester t, [bool Function()? until]) async { for (var i = 0; i < 40 && !(until?.call() ?? false); i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(const Duration(milliseconds: 100)); } } Future<_HostState> open(WidgetTester t, {bool rsync = false, Size size = const Size(1200, 800)}) async { await t.binding.setSurfaceSize(size); addTearDown(() => t.binding.setSurfaceSize(null)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final key = GlobalKey<_HostState>(); await t.pumpWidget(MaterialApp(home: _Host(key: key))); final host = key.currentState!; host.make(c, rsync: rsync, root: root); addTearDown(host.drop); await t.runAsync(() => host.ctl.goTo(host.ctl.panes[0], l)); await t.runAsync(() => host.ctl.goTo(host.ctl.panes[1], r)); host.ctl.ready = true; host.redraw(); await t.pump(); return host; } testWidgets('창 배치: 두 창 = 창 둘 + 버튼 줄 · 한 창 = 창 하나 · 폴더 + 파일 목록 = 트리 + 목록 · 좁은 Rsync = [왼쪽 | 오른쪽]', (t) async { final h = await open(t); expect([find.byType(ExplorerPaneView).evaluate().length, find.byType(ExplorerToolbar).evaluate().length], [2, 1]); h.ctl.c.settings.explorer.layout = 'single'; h.redraw(); await t.pump(); expect(find.byType(ExplorerPaneView).evaluate().length, 1); h.ctl.c.settings.explorer.layout = 'split'; h.redraw(); await t.pump(); expect([find.byType(ExplorerPaneView).evaluate().length, find.byType(ExplorerListView).evaluate().length], [1, 1]); h.ctl.c.settings.explorer.toolbar = 'hidden'; h.redraw(); await t.pump(); expect(find.byType(ExplorerToolbar), findsNothing, reason: '버튼 줄 숨김'); final rs = await open(t, rsync: true, size: const Size(420, 800)); expect([find.byType(RsyncPaneSwitch).evaluate().length, find.byType(ExplorerPaneView).evaluate().length, rs.ctl.rsync], [1, 1, true]); }); testWidgets('찾기: 처음엔 돌아갈 결과 없음 · 조건 창 [찾기] → 결과 화면에 찾은 것 · 그냥 닫으면 돌아갈 결과도 없음', (t) async { final h = await open(t); expect(h.flows.search.backBar(h.context), isNull); unawaited(h.flows.search.open(h.context, h.ctl.panes[0])); await t.pump(); await t.enterText(find.byType(TextField).first, 'doc'); await t.pump(); await t.tap(find.widgetWithText(FilledButton, '찾기')); await settle(t, () => find.byType(SearchResultsPage).evaluate().isNotEmpty && find.text('doc.txt').evaluate().length > 1); expect(find.byType(SearchResultsPage), findsOneWidget); Navigator.of(t.element(find.byType(SearchResultsPage))).pop(); await settle(t); expect(h.flows.search.backBar(h.context), isNull); }); testWidgets('저장 공간: 원격은 먼저 묻고 [취소] 면 그대로 · 이 기기 폴더는 분석 화면 (고르지 않고 닫으면 그대로)', (t) async { final h = await open(t); final q = h.ctl.panes[0]; unawaited(h.flows.tools.openStorage(h.context, q, 'dav://srv/x/')); await t.pump(); expect(find.textContaining('오래 걸릴 수 있습니다'), findsOneWidget); await t.tap(find.text('취소')); await t.pump(); expect(find.byType(StorageAnalysisPage), findsNothing); var done = false; unawaited(h.flows.tools.openStorage(h.context, q, l).then((_) => done = true)); await settle(t, () => find.byType(StorageAnalysisPage).evaluate().isNotEmpty); expect(find.byType(StorageAnalysisPage), findsOneWidget); Navigator.of(t.element(find.byType(StorageAnalysisPage))).pop(); await settle(t, () => done); expect([done, q.current], [true, l]); }); testWidgets('Rsync: 고르지 않으면 까닭 알림 · 두 폴더를 고르면 확인 창 → [취소] 면 실행 안 함', (t) async { final h = await open(t, rsync: true); await t.runAsync(() => h.flows.rsync.run(h.context, right: true, left: false)); await t.pump(); expect(find.text('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.'), findsOneWidget); await t.runAsync(() => h.sel.pick(h.ctl.panes[0], FileEntry(l, isDir: true, modified: DateTime(0)))); await t.runAsync(() => h.sel.pick(h.ctl.panes[1], FileEntry(r, isDir: true, modified: DateTime(0)))); var done = false; unawaited(h.flows.rsync.run(h.context, right: true, left: false).then((_) => done = true)); await settle(t, () => find.text('취소').evaluate().isNotEmpty); await t.tap(find.text('취소').last); await settle(t, () => done); expect([done, File(p.join(r, 'doc.txt')).existsSync(), h.flows.jobs.list.isEmpty], [true, false, true]); }); } class _Host extends StatefulWidget { const _Host({super.key}); @override State<_Host> createState() => _HostState(); } /// 실제 화면: ExplorerHost · ExplorerCommands · ExplorerPaneParts 를 이 화면이 맡는다 (틀 · 안내 · 표시 막대는 단순하게) class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands, ExplorerPaneParts { late ExplorerController ctl; late ExplorerSelect sel; late ExplorerLayout layout; late ExplorerFlows flows; late ExplorerButtons buttons; bool _made = false; void make(AppController c, {required bool rsync, required String root}) { ctl = ExplorerController(c, rsync: rsync, host: this)..local = [(root, 'T:')]; sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); layout = ExplorerLayout(ctl, context: () => context); flows = ExplorerFlows(ctl, sel, androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => layout.other?.current, twoPanes: () => layout.dual || layout.split); buttons = ExplorerButtons(ctl, sel, this, flows); _made = true; } void drop() { flows.dispose(); sel.dispose(); ctl.dispose(); } void redraw() => 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 String? get otherDir => layout.other?.current; @override bool get dual => layout.dual; @override double get rowHeight => layout.rowHeight(); @override double get listTextScale => layout.listTextScale; @override bool get narrowSplit => layout.narrowSplit; @override bool get alwaysMarks => layout.alwaysMarks; @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: () {}, onOpenFilter: () {}, onCloseFilter: () {}, onOpenRoot: () async {}, onOpenShizuku: () async {}, onReconnect: (_) async {}, ); @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) => flows.drops.dragPaths(pane, e); @override Future onDrop(DragLoad load, int to, String dest) => flows.drops.drop(context, load, to, dest); @override void closeFilter(ExplorerPane pane) {} @override void filterChanged() => setState(() {}); @override Widget build(BuildContext context) => Scaffold( body: !_made ? const SizedBox() : ExplorerPanesBody(ctl: ctl, sel: sel, layout: layout, parts: this, buttons: buttons, flows: flows)); }