212 lines
9.8 KiB
Dart
212 lines
9.8 KiB
Dart
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;
|
|
|
|
/// 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<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<_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, 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<Widget>().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());
|
|
}
|