import 'dart:async'; import 'dart:io'; import 'package:flutter/gestures.dart' show kBackMouseButton; 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/access_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_bars.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/key_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/pane_notices.dart'; import 'package:jj_mkvmaker/ui/explorer/server_flows.dart'; import 'package:path/path.dart' as p; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend, RecycleResult; /// E5d-6 약속 시험: explorer_layout · access_flow · pane_notices · view_dialogs · rename_flow · tools_flow · drop_flow · key_flow · /// explorer_bars 맨 위 약속 (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음) · 실제 이름 바꾸기) void main() { late Directory tmp; late String root, show, a; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_page_mod_'); root = tmp.path; show = p.join(root, 'Show'); Directory(show).createSync(); a = p.join(show, 'a.txt'); File(a).writeAsStringSync('a'); File(p.join(show, 'old.mkv.jjpart')) ..writeAsStringSync('x') ..setLastModifiedSync(DateTime(2026, 10, 1)); }); tearDown(() => tmp.deleteSync(recursive: true)); Future settle(WidgetTester t, [bool Function()? until]) async { for (var i = 0; i < 30 && !(until?.call() ?? false); i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); await t.pump(const Duration(milliseconds: 100)); } } Future<_M> open(WidgetTester t, {bool drive = true}) async { 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!; final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')]; final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); final layout = ExplorerLayout(ctl, context: () => host.context); final flows = ExplorerFlows(ctl, sel, androidTrash: () => false, recycleBin: _noRecycle, otherDirOf: (_) => null, otherDir: () => layout.other?.current, twoPanes: () => layout.dual || layout.split); final access = ExplorerAccess(ctl, probe: (_) async => drive); final input = ExplorerInput(ctl, sel, flows, ExplorerButtons(ctl, sel, host, flows), dual: () => layout.dual); addTearDown(() { input.dispose(); access.dispose(); flows.dispose(); sel.dispose(); ctl.dispose(); }); await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); await t.runAsync(() => ctl.goTo(ctl.panes[1], root)); return _M(host, ctl, sel, layout, flows, access, input); } testWidgets('배치 규칙: 두 창 · 한 창 + 숨은 갈 곳 · 폴더 + 파일 목록 (좁으면 잠깐 한 창) · 밀기 꺼짐 · 줄 높이', (t) async { final m = await open(t); final s = m.ctl.c.settings; m.ctl.active = 0; expect([m.layout.dual, m.layout.split, identical(m.layout.other, m.ctl.panes[1]), m.layout.rowHeight() > 0], [true, false, true, true]); s.explorer.layout = 'single'; expect([m.layout.dual, m.layout.hiddenTarget, m.layout.other], [false, false, null], reason: '처음 값은 담아 두기'); s.explorer.onePaneCopy = 'other'; expect([m.layout.hiddenTarget, identical(m.layout.other, m.ctl.panes[1])], [true, true]); s.explorer.layout = 'split'; expect(m.layout.split, isTrue); m.layout.splitTight = true; expect(m.layout.split, isFalse); expect(m.layout.switchPane(1), isFalse, reason: '설정이 꺼져 있으면 밀어도 창을 바꾸지 않음'); }); testWidgets('다시 연결: 열 수 없으면 까닭 알림 · 열 수 있으면 그 드라이브로 · 안내: 읽지 못함 [다시 시도] · 만들다 만 파일', (t) async { final m = await open(t, drive: false); final q = m.ctl.panes[0]; await t.runAsync(() => m.access.reconnect(q, root)); await t.pump(); expect([find.textContaining('연결할 수 없습니다').evaluate().isNotEmpty, samePath(q.current, show)], [true, true]); final notices = ExplorerNotices(m.ctl, m.access, ExplorerServers(m.ctl)); q.errors[show] = 'PathAccessException: Access denied'; m.host.child = () => Builder(builder: (ctx) => Column(children: notices.of(ctx, q, show).whereType().toList())); await t.pump(); expect([find.text('다시 시도').evaluate().length, find.textContaining('만들다 만 파일 1개').evaluate().length], [1, 1]); final ok = await open(t); await t.runAsync(() => ok.access.reconnect(ok.ctl.panes[0], root)); expect(samePath(ok.ctl.panes[0].current, root), isTrue); }); testWidgets('보기 창: 정렬 기준 고르고 확인 = 그 정렬 · 버튼 구성 [취소] = 그대로', (t) async { final m = await open(t); final before = [...m.ctl.c.settings.explorer.buttons]; unawaited(m.flows.dialogs.sortDialog(m.host.context)); await t.pump(); await t.tap(find.text('크기')); await t.pump(); await t.tap(find.text('확인')); await settle(t, () => m.ctl.sortOf(show).$1 == 'size'); expect(m.ctl.sortOf(show).$1, 'size'); unawaited(m.flows.dialogs.buttonsDialog(m.host.context)); await t.pump(); await t.tap(find.text('취소')); await t.pump(); expect(m.ctl.c.settings.explorer.buttons, before); }); testWidgets('이름 바꾸기: 이름 창 → 실제로 바뀜 · 초점이 새 이름으로 / Wi-Fi 공유는 원격 폴더면 알림', (t) async { final m = await open(t); final q = m.ctl.panes[0]; var done = false; unawaited(m.flows.renames.rename(m.host.context, q, a).then((_) => done = true)); await t.pump(); await t.enterText(find.byType(TextField), 'b.txt'); await t.tap(find.text('확인')); await settle(t, () => done); final b = p.join(show, 'b.txt'); expect([File(a).existsSync(), File(b).existsSync(), samePath(q.focused!, b)], [false, true, true]); await m.flows.tools.openWifiShare(m.host.context, 'dav://srv/x'); await t.pump(); expect(find.text('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다'), findsOneWidget); }); testWidgets('끌어 놓기 항목 · 빠른 필터 열고 닫기 · 마우스 뒤로 · 단축키 목록', (t) async { final m = await open(t); final q = m.ctl.panes[0]; FileEntry fe(String path, {bool dir = false}) => FileEntry(path, isDir: dir, modified: DateTime(0)); expect(m.flows.drops.dragPaths(q, fe(a)), [a]); m.sel.setSelecting(true); await t.runAsync(() => m.sel.toggleMark(q, fe(show, dir: true))); expect(m.flows.drops.dragPaths(q, fe(a)), [show], reason: '표시한 것 중 하나면 표시한 것의 바깥 것만'); m.input.openFilter(q, 'a'); expect([q.filterOpen, q.filterCtl.text, m.ctl.active], [true, 'a', 0]); m.input.closeFilter(q); expect([q.filterOpen, q.filterCtl.text], [false, '']); await t.runAsync(() => m.ctl.goTo(q, root)); m.input.mouseTravel(0, const PointerDownEvent(buttons: kBackMouseButton)); await settle(t, () => samePath(q.current, show)); expect(samePath(q.current, show), isTrue, reason: '4번 단추 = 그 창의 뒤로'); expect(m.input.bindings(m.host.context).isNotEmpty, isTrue); }); testWidgets('막대: [왼쪽 | 오른쪽] 누르면 그 창 · 갈 곳 줄 [지금 폴더로]', (t) async { final m = await open(t); m.host.child = () => Column(children: [RsyncPaneSwitch(ctl: m.ctl), CopyTargetBar(ctl: m.ctl)]); await t.pump(); await t.tap(find.textContaining('오른쪽')); expect(m.ctl.active, 1); m.ctl.active = 0; m.host.child = () => CopyTargetBar(ctl: m.ctl); await t.pump(); await t.tap(find.text('지금 폴더로')); await settle(t, () => samePath(m.ctl.panes[1].current, show)); expect(samePath(m.ctl.panes[1].current, show), isTrue); }); } class _M { _M(this.host, this.ctl, this.sel, this.layout, this.flows, this.access, this.input); final _HostState host; final ExplorerController ctl; final ExplorerSelect sel; final ExplorerLayout layout; final ExplorerFlows flows; final ExplorerAccess access; final ExplorerInput input; } class _Host extends StatefulWidget { const _Host({super.key}); @override State<_Host> createState() => _HostState(); } /// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands { Widget Function()? _child; set child(Widget Function() f) => setState(() => _child = f); @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 => null; @override bool get dual => true; @override Widget build(BuildContext context) => Scaffold(body: _child?.call() ?? const SizedBox()); } /// 이 시험은 휴지통으로 보내지 않는다 (영구 삭제만) - 혹시 보내면 사용자 휴지통에 닿지 않고 실패한다 RecycleBinBackend _noRecycle() => const _NoRecycle(); class _NoRecycle implements RecycleBinBackend { const _NoRecycle(); @override RecycleResult move(String path) => throw StateError('이 시험은 휴지통으로 보내지 않음'); @override void restore(String original, {required DateTime since}) => throw StateError('이 시험은 휴지통으로 보내지 않음'); }