240 lines
11 KiB
Dart
240 lines
11 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/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;
|
|
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend, RecycleResult;
|
|
|
|
/// 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<void> settle(WidgetTester t, [bool Function()? until]) async {
|
|
for (var i = 0; i < 40 && !(until?.call() ?? false); i++) {
|
|
await t.runAsync(() => Future<void>.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, recycleBin: _noRecycle, 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<Widget> 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<Widget?> 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<String> dragPaths(ExplorerPane pane, FileEntry e) => flows.drops.dragPaths(pane, e);
|
|
@override
|
|
Future<void> 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));
|
|
}
|
|
|
|
/// 이 시험은 휴지통으로 보내지 않는다 (영구 삭제만) - 혹시 보내면 사용자 휴지통에 닿지 않고 실패한다
|
|
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('이 시험은 휴지통으로 보내지 않음');
|
|
}
|