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/delete_flow.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_host.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_jobs.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/item_actions.dart'; import 'package:jj_mkvmaker/ui/explorer/item_menu.dart'; import 'package:jj_mkvmaker/ui/explorer/open_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/transfer_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/write_guard.dart'; import 'package:jj_mkvmaker/ui/text_viewer.dart'; import 'package:path/path.dart' as p; /// E5d-5 약속 시험: write_guard · explorer_jobs · transfer_flow · delete_flow · open_flow · item_menu · item_actions 맨 위 약속 /// (실제 시험 폴더 · 실제 화면 · 실제 복사 · 실제 지우기. 다른 앱으로 여는 길은 이 PC 의 앱을 열므로 보지 않는다) void main() { late Directory tmp; late String root, show, out, ep1, note; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_flow_ui_'); root = tmp.path; show = p.join(root, 'Show'); out = p.join(root, 'Out'); Directory(show).createSync(); Directory(out).createSync(); ep1 = p.join(show, 'ep1.mkv'); note = p.join(show, 'note.txt'); File(ep1).writeAsStringSync('1'); File(note).writeAsStringSync('글'); }); tearDown(() => tmp.deleteSync(recursive: true)); /// 실제 I/O · 창을 기다린다 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<_Flows> open(WidgetTester t) 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 guard = ExplorerGuard(ctl); final jobs = ExplorerJobs(ctl); final transfers = ExplorerTransfers(ctl, guard, jobs); final deletes = ExplorerDeleteFlow(ctl, sel, guard, androidTrash: () => false); final opener = ExplorerOpener(ctl, sel, guard, otherDirOf: (_) => null); addTearDown(() { transfers.dispose(); jobs.dispose(); sel.dispose(); ctl.dispose(); }); await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); final menu = ExplorerItemMenu(ctl, sel, host, guard: guard, transfers: transfers, deletes: deletes, opener: opener, nomedia: () => false); return _Flows(host, ctl, sel, guard, jobs, transfers, deletes, opener, menu); } testWidgets('막기: 시스템 폴더 · 루트는 읽기 전용 (까닭 알림) · 시스템 폴더로 옮기기 막음 · 보통 폴더는 그대로', (t) async { final f = await open(t); expect([f.guard.blockArchive([ep1]), f.guard.blockShizukuMove([ep1], out, move: true)], [false, false]); expect(f.guard.blockArchive(['shz://sys/a']), isTrue); expect(f.guard.blockShizukuMove(['shz://sys/a'], out, move: true), isTrue); expect(f.guard.blockShizukuMove(['shz://sys/a'], out, move: false), isFalse, reason: '복사 (꺼내기) 는 됨'); await t.pump(); expect(find.textContaining('읽기 전용'), findsWidgets); }); testWidgets('옮기기: 한 창이면 담기 → 붙여넣기 = 실제 복사 (확인 창 → 진행 목록 → 끝나면 내려감)', (t) async { final f = await open(t); final q = f.ctl.pane; await f.transfers.transfer(f.host.context, q, [], move: false); await t.pump(); expect(find.text(ExplorerTransfers.pickFirst(false)), findsOneWidget, reason: '고른 것이 없으면 알림'); await f.transfers.transfer(f.host.context, q, [ep1], move: false); expect([f.transfers.clip.items, f.transfers.clip.move], [ [ep1], false ]); var done = false; unawaited(f.transfers.paste(f.host.context, q, out).then((_) => done = true)); await settle(t, () => find.text('복사').evaluate().length > 1); await t.tap(find.widgetWithText(FilledButton, '복사')); await settle(t, () => f.jobs.list.isNotEmpty); expect(f.jobs.list.length, 1); await settle(t, () => done); expect([done, File(p.join(out, 'ep1.mkv')).existsSync(), File(ep1).existsSync(), f.transfers.clip.isEmpty], [true, true, true, false], reason: '복사는 여러 곳에 붙여넣게 남김'); expect(f.jobs.list, isEmpty, reason: '끝나면 잠깐 뒤 내려감'); }); testWidgets('지우기: 영구 삭제 (Shift+Delete) 확인 창 → 실제로 지움 · 표시 정리 · [취소] 는 그대로', (t) async { final f = await open(t); final q = f.ctl.pane; f.deletes.shiftDelete = true; unawaited(f.deletes.run(f.host.context, q, [note])); await t.pump(); await t.tap(find.text('취소')); await t.pump(); expect([File(note).existsSync(), f.deletes.shiftDelete], [true, false]); f.sel.setSelecting(true); await t.runAsync(() => f.sel.toggleMark(q, FileEntry(note, isDir: false, modified: DateTime(0)))); f.deletes.shiftDelete = true; var done = false; unawaited(f.deletes.run(f.host.context, q, [note]).then((_) => done = true)); await t.pump(); await t.tap(find.widgetWithText(FilledButton, '영구 삭제')); await settle(t, () => done); expect([File(note).existsSync(), q.sel.isEmpty, f.sel.selecting], [false, true, false]); }); testWidgets('열기: 글 뷰어 (앱 안) · 동영상 없는 폴더 재생은 알림 · 열 앱이 없을 때 알림 글', (t) async { final f = await open(t); unawaited(f.opener.viewText(f.host.context, note)); await settle(t, () => find.byType(TextViewerPage).evaluate().isNotEmpty); expect(find.byType(TextViewerPage), findsOneWidget); Navigator.of(f.host.context).pop(); await t.pump(const Duration(seconds: 1)); await settle(t); await t.runAsync(() => f.opener.playFolder(f.host.context, out)); await t.pump(); expect(find.text('이 폴더에 동영상이 없습니다.'), findsWidgets); expect(ExplorerOpener.noAppMessage(ep1), contains('내장 플레이어')); expect(ExplorerOpener.noAppMessage(note), isNot(contains('내장 플레이어'))); }); testWidgets('줄 메뉴: 파일 항목 · [정보] 창 · 고르지 않으면 아무것도 안 함 · 경로 복사 글', (t) async { final f = await open(t); final e = FileEntry(note, isDir: false, modified: DateTime(2026, 10, 22, 9), size: 3); unawaited(f.menu.show(f.host.context, f.ctl.pane, e, const Offset(10, 10))); await t.pump(); await t.pump(const Duration(milliseconds: 300)); expect([find.text('정보').evaluate().length, find.text('글 뷰어로 보기').evaluate().length, find.text('삭제').evaluate().isNotEmpty], [1, 1, true]); await t.tap(find.text('정보')); await settle(t, () => find.text('닫기').evaluate().isNotEmpty); expect(find.textContaining(note), findsOneWidget); await t.tap(find.text('닫기')); await t.pump(const Duration(milliseconds: 300)); unawaited(f.menu.show(f.host.context, f.ctl.pane, e, const Offset(10, 10))); await t.pump(const Duration(milliseconds: 300)); await t.tapAt(const Offset(790, 590)); await t.pump(const Duration(milliseconds: 300)); expect([File(note).existsSync(), find.text('정보').evaluate().isEmpty], [true, true]); expect(copyablePath(f.ctl, note), note, reason: '이 기기의 경로는 그대로'); }); } class _Flows { _Flows(this.host, this.ctl, this.sel, this.guard, this.jobs, this.transfers, this.deletes, this.opener, this.menu); final _HostState host; final ExplorerController ctl; final ExplorerSelect sel; final ExplorerGuard guard; final ExplorerJobs jobs; final ExplorerTransfers transfers; final ExplorerDeleteFlow deletes; final ExplorerOpener opener; final ExplorerItemMenu menu; } class _Host extends StatefulWidget { const _Host({super.key}); @override State<_Host> createState() => _HostState(); } /// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패) class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands { @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 => false; Never _no() => throw UnimplementedError('이 시험에서 부르지 않음'); @override Future quickLook(ExplorerPane pane, [String? path]) => _no(); @override Future addMkv(List paths) => _no(); @override Future renameItem(ExplorerPane pane, String path) => _no(); @override Future batchRename(ExplorerPane pane, List paths) => _no(); @override Future openSearch(ExplorerPane pane) => _no(); @override Future openStorage(ExplorerPane pane, String dir) => _no(); @override Future openWifiShare(String dir) => _no(); @override Future sortDialog() => _no(); @override Future historyDialog(ExplorerPane pane) => _no(); @override Future openBookmarks(ExplorerPane pane) => _no(); @override Future toggleOrientation() => _no(); @override Future layoutDialog() => _no(); @override Future buttonsDialog() => _no(); @override Widget build(BuildContext context) => const Scaffold(body: SizedBox()); }