JJ_MKVMaker/test/explorer_pane_view_contract...

210 lines
8.8 KiB
Dart

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, 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/explorer_controller.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_host.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/path_dialog.dart';
import 'package:jj_mkvmaker/ui/explorer/server_flows.dart';
import 'package:jj_mkvmaker/ui/explorer_drag.dart' show DragLoad;
import 'package:jj_mkvmaker/ui/explorer_look.dart' show rowSpacingFactor;
import 'package:path/path.dart' as p;
/// E5d-3 약속 시험: path_dialog · server_flows · pane_header · pane_view 맨 위 약속 (실제 시험 폴더 · 실제 화면 · 실제 창)
void main() {
late Directory tmp;
late String root, show;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_pane_ui_');
root = tmp.path;
show = p.join(root, 'Show');
Directory(show).createSync();
File(p.join(show, 'ep1.mkv')).writeAsStringSync('1');
File(p.join(root, 'a.txt')).writeAsStringSync('a');
});
tearDown(() => tmp.deleteSync(recursive: true));
/// 실제 I/O 를 기다린 뒤 그린다
Future<void> settle(WidgetTester t) async {
for (var i = 0; i < 10; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump(const Duration(milliseconds: 100));
}
}
test('경로 확인: 빈 칸 · 있는 폴더 · 없는 폴더 · Android 의 SMB', () async {
expect(await checkEnteredPath(' ', android: false), (null, null));
expect(await checkEnteredPath('"$show"', android: false), (show, null));
final (miss, why) = await checkEnteredPath(p.join(root, 'none'), android: false);
expect([miss, why!.isNotEmpty], [null, true]);
expect(await checkEnteredPath('smb://nas/v', android: true), (null, smbNotSupported));
});
Future<(ExplorerController, GlobalKey<_HostState>)> open(WidgetTester t, {double width = 800}) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander
final key = GlobalKey<_HostState>();
await t.binding.setSurfaceSize(Size(width, 600));
addTearDown(() => t.binding.setSurfaceSize(null));
await t.pumpWidget(MaterialApp(home: _Host(key: key)));
final ctl = ExplorerController(c, rsync: false, host: key.currentState!)..local = [(root, 'T:')];
final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {});
addTearDown(() {
sel.dispose();
ctl.dispose();
});
await t.runAsync(() => ctl.goTo(ctl.panes[0], root));
key.currentState!.attach(ctl, sel);
await t.pump();
return (ctl, key);
}
testWidgets('경로 입력 창: 없는 폴더는 창을 닫지 않고 이유 · 있는 폴더는 창을 옮김 · Android SMB 는 서버 추가로', (t) async {
final (ctl, key) = await open(t);
final q = ctl.panes[0];
final servers = ExplorerServers(ctl);
var done = false;
servers.enterPath(key.currentContext!, q, android: false).then((_) => done = true);
await t.pump();
await t.enterText(find.byType(TextField).last, p.join(root, 'none'));
await t.tap(find.text('확인'));
await settle(t);
expect([find.byType(PathDialog).evaluate().length, find.text('폴더가 없거나 열 수 없습니다').evaluate().length], [1, 1]);
await t.enterText(find.byType(TextField).last, show);
await t.tap(find.text('확인'));
await settle(t);
expect([done, find.byType(PathDialog).evaluate().isEmpty, samePath(q.current, show)], [true, true, true]);
String? smb;
showDialog<String>(context: key.currentContext!, builder: (_) => const PathDialog(initial: '', android: true)).then((v) => smb = v);
await t.pump();
await t.enterText(find.byType(TextField).last, 'smb://nas/v');
await t.tap(find.text('확인'));
await settle(t);
await t.tap(find.text('SMB 서버 추가'));
await settle(t);
expect(smb, '${PathDialog.addSmb}smb://nas/v');
});
testWidgets('창 보기: 머리 · 줄 · 빠른 필터 수 · 좁은 창은 [⋮]', (t) async {
final (ctl, key) = await open(t);
final q = ctl.panes[0];
expect(find.text('a.txt'), findsOneWidget);
expect(find.byIcon(Icons.more_vert), findsNothing, reason: '넓은 창은 끝 버튼을 펼침');
// 뒤로 · 앞으로: 처음은 갈 곳 없음 → 폴더를 옮기면 뒤로가 켜짐
expect(t.widget<IconButton>(find.widgetWithIcon(IconButton, Icons.arrow_back)).onPressed, isNull);
await t.runAsync(() => ctl.goTo(q, show));
await t.pump();
expect(t.widget<IconButton>(find.widgetWithIcon(IconButton, Icons.arrow_back)).onPressed, isNotNull);
await t.runAsync(() => ctl.goTo(q, root));
await t.pump();
// 빠른 필터: 머리 단추 → 화면 콜백 → 칸 · 보이는 수
await t.tap(find.widgetWithIcon(IconButton, Icons.filter_alt_outlined));
await t.pump();
expect(key.currentState!.calls, ['openFilter']);
await t.enterText(find.byType(TextField), 'a.t');
await t.pump();
expect(find.text('1개 보임'), findsOneWidget);
await t.tap(find.widgetWithIcon(IconButton, Icons.close));
await t.pump();
expect(key.currentState!.calls, ['openFilter', 'filterChanged', 'closeFilter']);
await t.binding.setSurfaceSize(const Size(400, 600));
await t.pump();
expect(find.byIcon(Icons.more_vert), findsOneWidget, reason: '좁은 창 ($paneHeaderWideWidth 미만) 은 [⋮] 하나');
});
}
class _Host extends StatefulWidget {
const _Host({super.key});
@override
State<_Host> createState() => _HostState();
}
/// 실제 화면: ExplorerHost · ExplorerPaneParts 를 이 화면이 맡는다 (틀 · 안내 · 표시 막대는 비움, 부른 것은 [calls] 에)
class _HostState extends State<_Host> implements ExplorerHost, ExplorerPaneParts {
ExplorerController? ctl;
ExplorerSelect? sel;
final calls = <String>[];
void attach(ExplorerController c, ExplorerSelect s) {
ctl = c..addListener(_redraw);
sel = s;
for (final q in c.panes) {
q.addListener(_redraw);
}
setState(() {});
}
void _redraw() {
if (mounted) 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
double get rowHeight => ctl!.look.rowHeightOf(context, textScale: 1, spacing: rowSpacingFactor(''));
@override
double get listTextScale => 1;
@override
bool get narrowSplit => false;
@override
bool get alwaysMarks => false;
@override
Widget frame(int i, List<Widget> children) => Column(children: children);
@override
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
ctl: ctl!,
servers: ExplorerServers(ctl!),
pane: pane,
android: false,
onBookmarks: () => calls.add('bookmarks'),
onOpenFilter: () => setState(() {
calls.add('openFilter');
pane.filterOpen = true;
}),
onCloseFilter: () => closeFilter(pane),
onOpenRoot: () async => calls.add('root'),
onOpenShizuku: () async => calls.add('shizuku'),
onReconnect: (_) async => calls.add('reconnect'),
);
@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) => [e.path];
@override
Future<void> onDrop(DragLoad load, int to, String dest) async {}
@override
void closeFilter(ExplorerPane pane) => setState(() {
calls.add('closeFilter');
pane
..filterOpen = false
..filterCtl.clear();
});
@override
void filterChanged() => setState(() => calls.add('filterChanged'));
@override
Widget build(BuildContext context) => Scaffold(
body: ctl == null ? const SizedBox() : ExplorerPaneView(ctl: ctl!, sel: sel!, parts: this, index: 0));
}