JJ_MKVMaker/test/explorer_flows_contract_tes...

200 lines
9.3 KiB
Dart

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_flows.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<void> settle(WidgetTester t, [bool Function()? until]) async {
for (var i = 0; i < 30 && !(until?.call() ?? false); i++) {
await t.runAsync(() => Future<void>.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 flows = ExplorerFlows(ctl, sel, androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => null, twoPanes: () => false);
addTearDown(() {
flows.dispose();
sel.dispose();
ctl.dispose();
});
await t.runAsync(() => ctl.goTo(ctl.panes[0], show));
final menu = ExplorerItemMenu(ctl, sel, host, flows, nomedia: () => false);
return _Flows(host, ctl, sel, flows.guard, flows.jobs, flows.transfers, flows.deletes, flows.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;
@override
Widget build(BuildContext context) => const Scaffold(body: SizedBox());
}