213 lines
10 KiB
Dart
213 lines
10 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;
|
|
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show RecycleBinBackend, RecycleResult;
|
|
|
|
/// 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, recycleBin: _noRecycle, 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();
|
|
// 82a87da (재생 목록 모듈): 동영상이 없으면 소리 파일도 트므로 알림 글이 "동영상 · 소리 파일" 로 바뀜 (틀 것이 없으면 알림은 그대로)
|
|
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());
|
|
}
|
|
|
|
/// 이 시험은 휴지통으로 보내지 않는다 (영구 삭제만) - 혹시 보내면 사용자 휴지통에 닿지 않고 실패한다
|
|
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('이 시험은 휴지통으로 보내지 않음');
|
|
}
|