탐색기 시험용 정적 이름 5개를 주입으로 (ExplorerDevice)

- lib/app/explorer_device.dart: ExplorerDevice 약속 (저장 장치 목록 · 드라이브 열림 · Android 경로 · 앱 휴지통 · .nomedia) + RealExplorerDevice (이 기기 그대로)
- ExplorerPage 생성자 인자 device (clock 처럼, 처음 값은 RealExplorerDevice)
- 지움: ExplorerPage.probeDrive · debugVolumes · debugAndroidPaths · debugAndroidTrash · debugNomedia
- 시험: test/support/test_explorer_device.dart (정한 것만 바꿈), harness 가 시험마다 새로 만들어 넘김
- 약속 시험: test/explorer_device_contract_test.dart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 15:04:38 +09:00
parent fad723355f
commit de4ba076b8
21 changed files with 224 additions and 135 deletions

View File

@ -18,10 +18,13 @@ import 'package:path/path.dart' as p;
import '../test/support/fake_recycle_bin.dart';
import 'support/temp_dir.dart';
import '../test/support/test_explorer_device.dart';
/// 탐색기 사용 점검 (관리자 c5 지시 10/22, 분석용 - 커밋하지 않음): 처음 설정 그대로 사용자처럼 써 보며 단계마다 PNG.
/// 저장 장치는 시험 폴더 둘 (debugVolumes), 휴지통은 가짜 (사용자 휴지통에 흔적을 남기지 않게), 설정은 메모리에만.
/// 저장 장치는 시험 폴더 둘 (TestExplorerDevice), 휴지통은 가짜 (사용자 휴지통에 흔적을 남기지 않게), 설정은 메모리에만.
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_use');
@ -54,13 +57,12 @@ void main() {
for (final f in tmp.listSync(recursive: true).whereType<File>()) {
f.setLastModifiedSync(DateTime(2026, 10, 22, 9));
}
ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final shotKey = GlobalKey();
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)),
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(device: device, c: c)),
)));
Future<void> settle([int n = 12]) async {
for (var i = 0; i < n; i++) {

View File

@ -15,11 +15,14 @@ import 'package:path/path.dart' as p;
import '../test/support/fake_recycle_bin.dart';
import '../test/support/recycle_leftovers.dart';
import 'support/temp_dir.dart';
import '../test/support/test_explorer_device.dart';
/// 묶음 3 (데이터 쪽) 기기 감독 확인용 (Windows): 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다.
/// 165 (드라이브 · 연결 안 됨) · 48 (같은 이름) · 52 (돌아와도 진행 막대) · 148 (휴지통 되돌리기) · 144 (긴 경로).
/// 시험 폴더 (JJ_SHOT_TMP) 만 쓰고, 설정은 메모리에만 (저장하지 않음). 휴지통에 넣은 것은 되돌리므로 남지 않는다.
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_b3');
Directory tempDir(String prefix) {
@ -81,8 +84,6 @@ void main() {
useTestRecycleBin();
});
tearDown(() async {
ExplorerPage.debugVolumes = null;
ExplorerPage.probeDrive = (path) => Directory(path).exists();
expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)');
try {
Directory(r'\\?\' + tmp.absolute.path).deleteSync(recursive: true);
@ -100,9 +101,9 @@ void main() {
testWidgets('165: 드라이브 탭 - 응답 없는 드라이브 · 연결 안 됨 (첫 화면은 바로)', (t) async {
final root = p.rootPrefix(tmp.path);
final stuck = Completer<bool>();
ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), '')), (r'Q:\', 'Q:'), (r'R:\', 'R:')];
ExplorerPage.probeDrive = (path) => path == r'Q:\' ? stuck.future : Future.value(path != r'R:\');
await show(t, ExplorerPage(c: c));
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), '')), (r'Q:\', 'Q:'), (r'R:\', 'R:')];
device.probe = (path) => path == r'Q:\' ? stuck.future : Future.value(path != r'R:\');
await show(t, ExplorerPage(device: device, c: c));
await until(t, () => find.textContaining('연결 안 됨').evaluate().isNotEmpty);
await shot(t, 'b3_165_drives');
await t.tap(find.textContaining('R: · 연결 안 됨').first);
@ -112,7 +113,7 @@ void main() {
});
testWidgets('48: 같은 이름 - 확인 창 (항목 이름 · 고르기) → 덮어쓰기', (t) async {
await show(t, ExplorerPage(c: c));
await show(t, ExplorerPage(device: device, c: c));
await selectAndPress(t, 'doc.txt', '복사');
await until(t, () => find.textContaining('같은 이름이 이미 있습니다').evaluate().isNotEmpty);
await shot(t, 'b3_48_dialog');
@ -126,7 +127,7 @@ void main() {
});
testWidgets('154 · 155: 처음 열고 아무것도 고르지 않고 [복사] · [이동] → "…할 항목을 고르세요"', (t) async {
await show(t, ExplorerPage(c: c));
await show(t, ExplorerPage(device: device, c: c));
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty);
final messenger = ScaffoldMessenger.of(t.element(find.byType(ExplorerPage)));
messenger.clearSnackBars(); // 앞 단계의 알림이 남지 않게
@ -149,13 +150,13 @@ void main() {
// 다른 화면에 갔다 와도 아직 복사 중이게 (약 25초)
File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(2 * 1024 * 1024, 7));
c.settings.copyBandwidthKBps = 80;
await show(t, ExplorerPage(c: c));
await show(t, ExplorerPage(device: device, c: c));
await selectAndPress(t, 'big.bin', '복사');
await t.tap(find.widgetWithText(FilledButton, '복사'));
await until(t, () => find.byType(LinearProgressIndicator).evaluate().isNotEmpty);
await shot(t, 'b3_52_progress');
await show(t, const Scaffold(body: Center(child: Text('다른 화면'))));
await show(t, ExplorerPage(c: c));
await show(t, ExplorerPage(device: device, c: c));
await until(t, () => find.byType(LinearProgressIndicator).evaluate().isNotEmpty, rounds: 20);
await shot(t, 'b3_52_back_progress');
await until(t, () => File(p.join(right, 'big.bin')).existsSync() && find.text('big.bin').evaluate().length >= 2, rounds: 150);
@ -163,7 +164,7 @@ void main() {
});
testWidgets('148: 휴지통으로 → [되돌리기]', (t) async {
await show(t, ExplorerPage(c: c));
await show(t, ExplorerPage(device: device, c: c));
await selectAndPress(t, 'doc.txt', '삭제');
await shot(t, 'b3_148_confirm');
await t.tap(find.widgetWithText(FilledButton, '휴지통으로'));
@ -184,7 +185,7 @@ void main() {
}
Directory(r'\\?\' + deep).createSync(recursive: true);
File(r'\\?\' + p.join(deep, 'a_file_name_that_makes_it_longer.txt')).writeAsStringSync('x');
await show(t, ExplorerPage(c: c));
await show(t, ExplorerPage(device: device, c: c));
await selectAndPress(t, 'deep', '삭제');
await t.tap(find.widgetWithText(FilledButton, '휴지통으로'));
await until(t, () => find.textContaining('경로가 너무 길어').evaluate().isNotEmpty);

View File

@ -15,12 +15,15 @@ import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
import '../test/support/test_explorer_device.dart';
/// 관리자 지시 (10/21, 28 지적): Windows · TC 스타일의 날짜 열이 시간까지 들어가지 않으면 "2026-10-2…" 로 잘리던 것 -
/// 모자라면 짧은 꼴 (올해 "10-21 14:05" · 아니면 "2025-10-21"), 그래도 모자라면 숨김. 실제 글꼴로 312 조합
/// (폭 13 × 스타일 2 × 언어 3 × 글자 배율 4) 에서 날짜 칸이 잘리지 않는지 (RenderParagraph.didExceedMaxLines) 와
/// 날짜를 보일 수 있는 넓은 창에서는 다 쓴 꼴이 나오는지 본다. 좁은 몇 개는 PNG (JJ_SHOT_DIR).
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light);
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_datecol');
@ -38,8 +41,7 @@ void main() {
..writeAsStringSync('x')
..setLastModifiedSync(at);
}
ExplorerPage.debugVolumes = () => [(dir.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(dir.path, 'T:')];
final key = GlobalKey();
var checked = 0, short = 0, hidden = 0;
final emptyOnce = <String>[];
@ -64,7 +66,7 @@ void main() {
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(dev)), child: child!),
home: ExplorerPage(key: UniqueKey(), c: c),
home: ExplorerPage(key: UniqueKey(), device: device, c: c),
),
)));
for (var i = 0; i < 200 && find.textContaining('작년 문서').evaluate().isEmpty; i++) {

View File

@ -16,10 +16,13 @@ import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
import '../test/support/test_explorer_device.dart';
/// 264 · 265 (관리자 지시 10/21): 폴더를 합쳐 옮긴 뒤 - 알림에 "n개는 원본에 남음 (건너뜀)" · 펼쳐 둔 원본 폴더를 다시 읽어
/// 옮겨진 파일은 보이지 않고 남은 것만 보인다. ko · en · ja × 960 · 1400 (두 창). 시험 폴더 안에서만 (실제 이동, 휴지통 쓰지 않음)
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
@ -47,12 +50,11 @@ void main() {
..explorerPaths = [left, right]
..copyConflict = 'overwrite';
final root = p.rootPrefix(tmp.path);
ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)),
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(device: device, c: c)),
));
Future<void> settle(bool Function() done, {int rounds = 150}) async {
for (var i = 0; i < rounds && !done(); i++) {

View File

@ -16,11 +16,14 @@ import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
import '../test/support/test_explorer_device.dart';
/// 28 확인 (10/20): 좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 그 창의 지금 폴더가 보이는지 · 확인 창의 체크 제목
/// "(--remove-source-files)" 가 "(--" 에서 갈리지 않는지를 412 · 960, ko · en · ja 로 찍는다 (시험 폴더 안에서만, [취소]).
/// 실행: flutter test integration_test\windows_rsync_narrow_shot_test.dart -d windows (JJ_SHOT_DIR 에 PNG)
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_rsync_narrow');
for (final lang in ['ko', 'en', 'ja']) {
@ -44,8 +47,7 @@ void main() {
for (var i = 0; i < 20; i++) {
Directory(p.join(tmp.path, 'zz${i.toString().padLeft(2, '0')}')).createSync();
}
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(tmp.path, 'T:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
@ -55,7 +57,7 @@ void main() {
theme: buildTheme(),
debugShowCheckedModeBanner: false,
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
home: ExplorerPage(c: c, rsync: true),
home: ExplorerPage(device: device, c: c, rsync: true),
));
Future<void> settle([int n = 10]) async {
for (var i = 0; i < n; i++) {

View File

@ -16,11 +16,14 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
import '../test/support/test_explorer_device.dart';
/// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더).
/// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a) · 압축을 폴더처럼 (f).
/// 크기 412 (폰 세로, 한 창) · 960 (두 창), 언어 ko · en. 시험 파일은 임시 폴더에만, 설정은 메모리에만.
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// ⑦ 밝은 화면 캡처: JJ_SHOT_THEME=light (JJ_SHOT_DIR 를 따로 두어 어두운 것과 견줌)
if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light);
@ -46,8 +49,7 @@ void main() {
File(p.join(a.path, 'Photos.zip')).writeAsBytesSync(ZipEncoder().encode(Archive()
..addFile(ArchiveFile.string('2026/beach.jpg', 'x'))
..addFile(ArchiveFile.string('readme.txt', 'readme'))));
ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerStyle = 'xplore'
@ -58,7 +60,7 @@ void main() {
final shotKey = GlobalKey();
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)),
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(device: device, c: c)),
)));
Future<void> settle([int n = 12]) async {
for (var i = 0; i < n; i++) {

View File

@ -0,0 +1,30 @@
import 'dart:io';
import 'volumes.dart';
/// 약속: 파일 탐색기가 기기에서 받는 것 - 저장 장치 목록 · 드라이브를 열 수 있는지 (화면을 멈추지 않는 비동기) ·
/// Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴).
/// 앱은 [RealExplorerDevice] (이 기기 그대로) 를 쓰고, 시험은 시험 폴더를 저장 장치로 주는 구현을 넘긴다
abstract interface class ExplorerDevice {
Future<List<(String, String)>> listVolumes();
Future<bool> probeDrive(String path);
bool get androidPaths;
bool get androidTrash;
bool get nomedia;
}
/// 이 기기 그대로
class RealExplorerDevice implements ExplorerDevice {
const RealExplorerDevice();
@override
Future<List<(String, String)>> listVolumes() => localVolumes();
@override
Future<bool> probeDrive(String path) => Directory(path).exists();
@override
bool get androidPaths => Platform.isAndroid;
@override
bool get androidTrash => Platform.isAndroid;
@override
bool get nomedia => Platform.isAndroid;
}

View File

@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../app/app_controller.dart';
import '../app/explorer_device.dart';
import '../app/copy_center.dart';
import '../core/file_ops.dart';
import 'app_actions.dart';
@ -60,27 +61,10 @@ class ExplorerPage extends StatefulWidget {
/// 지금 시각 (폴더 두 번 누르기의 두 번째인지 가르는 데). 시험은 정한 시각을 넘긴다 - 실제 시간에 기대 우연히 통과하지 않게
final DateTime Function() clock;
const ExplorerPage({super.key, required this.c, this.rsync = false, this.clock = DateTime.now});
/// 165: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기
@visibleForTesting
static Future<bool> Function(String path) probeDrive = (path) => Directory(path).exists();
/// 시험: 이 기기의 저장 장치 목록 (null 이면 실제 목록)
@visibleForTesting
static List<(String, String)> Function()? debugVolumes;
/// 시험: 경로 입력을 Android 처럼 (SMB 안내, 179). null 이면 실제 기기
@visibleForTesting
static bool? debugAndroidPaths;
/// 시험: Android 앱 휴지통 (X-plore e) 을 Windows 에서. null 이면 실제 기기
@visibleForTesting
static bool? debugAndroidTrash;
/// 시험: 폴더 .nomedia 넣기 · 지우기 (Android 갤러리 · 음악 앱에서 숨기기) 를 Windows 에서. null 이면 Android 만
@visibleForTesting
static bool? debugNomedia;
/// 저장 장치 · 드라이브 · Android 에서만 하는 것 (앱은 이 기기 그대로, 시험은 시험 폴더)
final ExplorerDevice device;
const ExplorerPage(
{super.key, required this.c, this.rsync = false, this.clock = DateTime.now, this.device = const RealExplorerDevice()});
/// 이번 실행에서 시스템 폴더 · 루트 경고를 보였는지 ([ExplorerAccess])
static bool get shizukuWarnedThisRun => ExplorerAccess.shizukuWarned;
@ -195,7 +179,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
}
Future<void> _init() async {
_ctl.local = ExplorerPage.debugVolumes?.call() ?? await localVolumes();
_ctl.local = await widget.device.listVolumes();
if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0));
@ -290,7 +274,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
ctl: _ctl,
servers: _servers,
pane: pane,
android: ExplorerPage.debugAndroidPaths ?? Platform.isAndroid,
android: widget.device.androidPaths,
onBookmarks: () => _flows.dialogs.openBookmarks(context, pane),
onOpenFilter: () => _input.openFilter(pane),
onCloseFilter: () => _input.closeFilter(pane),
@ -318,7 +302,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
/// 흐름 모듈 묶음 - 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 · 열기 · 이름 · 도구 · 보기 창 · 찾기 (E5d-5 · E5d-6)
late final ExplorerFlows _flows = ExplorerFlows(_ctl, _sel,
androidTrash: () => ExplorerPage.debugAndroidTrash ?? Platform.isAndroid,
androidTrash: () => widget.device.androidTrash,
otherDirOf: (pane) => _layout.dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null,
otherDir: () => _layout.other?.current,
twoPanes: () => _layout.dual || _layout.split)
@ -330,13 +314,13 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
String? get otherDir => _layout.other?.current;
/// 줄 메뉴 (E5d-5)
late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this, _flows, nomedia: () => ExplorerPage.debugNomedia ?? Platform.isAndroid);
late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this, _flows, nomedia: () => widget.device.nomedia);
@override
bool get dual => _layout.dual;
/// 권한 · 시스템 폴더 · 다시 연결 · 목록 위 안내 (E5d-6)
late final _access = ExplorerAccess(_ctl, probe: (path) => ExplorerPage.probeDrive(path))..addListener(_onPane);
late final _access = ExplorerAccess(_ctl, probe: widget.device.probeDrive)..addListener(_onPane);
late final _notices = ExplorerNotices(_ctl, _access, _servers);
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)

View File

@ -110,13 +110,11 @@ void main() {
testWidgets('X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기', (tester) async {
// 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게)
ExplorerPage.debugAndroidTrash = true;
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
device.trash = true;
device.volumes = () => [(tmp.path, 'T:')];
// [휴지통] 버튼이 줄에 보이게 (처음 구성은 좁으면 [더 보기] 로 간다)
c.settings.explorerButtons = ['trash', for (final b in ExplorerButton.defaultOrder) if (b != ExplorerButton.trash) b.name];
addTearDown(() {
ExplorerPage.debugAndroidTrash = null;
ExplorerPage.debugVolumes = null;
});
await open(tester);
final doc = p.join(left, 'doc.txt');

View File

@ -16,8 +16,11 @@ import 'support/recycle_leftovers.dart';
import 'support/elevated.dart';
import 'support/explorer_harness.dart' show calm;
import 'support/test_explorer_device.dart';
/// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
late Directory tmp;
late String left, right;
late AppController c;
@ -39,12 +42,11 @@ void main() {
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.explorerPaths = [left, right];
final root = p.rootPrefix(tmp.path);
ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
// 휴지통: 기본은 가짜 (흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통
bin = useTestRecycleBin();
});
tearDown(() async {
ExplorerPage.debugVolumes = null;
// 사용자 휴지통에 이 시험 폴더 아래의 흔적이 없는지 스스로 확인 (가짜일 때도 - 실제 휴지통에 닿지 않았는지)
expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)');
tmp.deleteSync(recursive: true);
@ -63,7 +65,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().length >= 2);
await tester.runAsync(() => tester.tap(find.text('doc.txt').first));
await settle(tester, () => false, rounds: 10);
@ -123,7 +125,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('D').evaluate().length >= 2);
// 왼쪽 D 를 눌러 펼치고 (고른 것) 안의 add.txt 가 보이게
await tester.runAsync(() => tester.tap(find.text('D').first));
@ -145,7 +147,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().length >= 2);
await tester.runAsync(() => tester.tap(find.text('복사').first));
await tester.pump();
@ -169,7 +171,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('big.bin').evaluate().isNotEmpty);
await tester.runAsync(() => tester.tap(find.text('big.bin')));
await settle(tester, () => false, rounds: 10);
@ -180,7 +182,7 @@ void main() {
// 다른 화면으로 (탐색기 화면이 없어짐) → 돌아옴
await tester.runAsync(() => tester.pumpWidget(const MaterialApp(home: Scaffold(body: Text('다른 화면')))));
await tester.pump();
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isNotEmpty, rounds: 30);
expect(find.byType(LinearProgressIndicator), findsWidgets, reason: '돌아와도 진행 막대');
// 끝나면 오른쪽 창에 big.bin 이 보이고 .jjpart 는 없다
@ -194,7 +196,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().length >= 2);
await tester.runAsync(() => tester.tap(find.text('doc.txt').first));
await settle(tester, () => false, rounds: 10);
@ -227,7 +229,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('deep').evaluate().isNotEmpty);
Future<void> deleteDeep() async {
await tester.runAsync(() => tester.tap(find.text('deep').first));
@ -259,10 +261,10 @@ void main() {
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
// 시험 폴더를 저장 장치 맨 위로 (left 에서 한 번 올라가면 맨 위)
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
device.volumes = () => [(tmp.path, 'T:')];
final navKey = GlobalKey<NavigatorState>();
await tester.runAsync(() => tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Text('앞 화면'))));
unawaited(navKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => ExplorerPage(c: c))));
unawaited(navKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
Future<void> back() async {
await tester.runAsync(() => tester.binding.handlePopRoute());
@ -293,11 +295,11 @@ void main() {
if (!Platform.isWindows || !Directory(locked).existsSync()) return;
if (skipIfReadable(locked)) return;
c.settings.explorerPaths = [locked, right];
ExplorerPage.debugVolumes = () => [(r'C:\', 'C:'), (p.rootPrefix(tmp.path), p.rootPrefix(tmp.path).substring(0, 2))];
device.volumes = () => [(r'C:\', 'C:'), (p.rootPrefix(tmp.path), p.rootPrefix(tmp.path).substring(0, 2))];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.textContaining('이 폴더를 읽을 권한이 없습니다').evaluate().isNotEmpty);
expect(find.textContaining('이 폴더를 읽을 권한이 없습니다'), findsOneWidget);
expect(find.text('다시 시도'), findsOneWidget);
@ -308,7 +310,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().length >= 2);
await tester.runAsync(() => tester.tap(find.text('선택').first));
await settle(tester, () => false, rounds: 5);
@ -345,7 +347,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('left').evaluate().isNotEmpty);
// 왼쪽 창에서 left · right 를 펼침
await tester.runAsync(() => tester.tap(find.text('left').first));
@ -374,7 +376,7 @@ void main() {
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final before = c.settings.explorerShowHidden;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
expect(tester.takeException(), isNull, reason: '넘침 없음');
expect(find.text('더 보기'), findsOneWidget);
@ -422,7 +424,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await tester.tap(find.byTooltip('경로 입력').first);
await calm(tester);
@ -456,13 +458,12 @@ void main() {
});
testWidgets('179 · 88: Android 에서 SMB 경로를 넣으면 안내와 [SMB 서버 추가] (넣은 주소로 채움) · [WebDAV 서버 추가]', (tester) async {
ExplorerPage.debugAndroidPaths = true;
addTearDown(() => ExplorerPage.debugAndroidPaths = null);
device.android = true;
expect([for (final x in ['smb://nas/v', r'\\nas\v', 'SMB://x', '/storage/x', 'dav://a/']) looksLikeSmb(x)], [true, true, true, false, false]);
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await tester.tap(find.byTooltip('경로 입력').first);
await calm(tester);
@ -502,7 +503,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.textContaining(deep.path).evaluate().isNotEmpty);
// 밖에서 a 폴더째 지움 → [새로 고침]
Directory(p.join(left, 'a')).deleteSync(recursive: true);

View File

@ -0,0 +1,26 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/explorer_device.dart';
import 'support/test_explorer_device.dart';
/// 약속 시험: lib/app/explorer_device.dart - 이 기기 그대로 ([RealExplorerDevice]) · 시험용은 정한 것만 바꿈
void main() {
test('이 기기: 저장 장치가 하나 이상 · 있는 폴더 = 열림 · 없는 폴더 = 안 열림 · Android 갈래는 이 기기대로', () async {
const d = RealExplorerDevice();
final tmp = Directory.systemTemp.createTempSync('jj_dev_');
addTearDown(() => tmp.deleteSync(recursive: true));
expect((await d.listVolumes()).isNotEmpty, isTrue);
expect([await d.probeDrive(tmp.path), await d.probeDrive('${tmp.path}${Platform.pathSeparator}none')], [true, false]);
expect([d.androidPaths, d.androidTrash, d.nomedia], [Platform.isAndroid, Platform.isAndroid, Platform.isAndroid]);
});
test('시험용: 정한 것만 바꾸고 나머지는 이 기기대로', () async {
final d = TestExplorerDevice()
..volumes = (() => [('T:\\', 'T:')])
..trash = true;
expect(await d.listVolumes(), [('T:\\', 'T:')]);
expect([d.androidTrash, d.androidPaths], [true, Platform.isAndroid]);
});
}

View File

@ -13,7 +13,10 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p;
import 'support/explorer_harness.dart' show calm;
import 'support/test_explorer_device.dart';
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
late Directory tmp;
late AppController c;
late Completer<bool> stuck;
@ -30,12 +33,10 @@ void main() {
stuck = Completer<bool>();
dead = {r'R:\'};
// T: 시험 폴더 (정상) · Q: 응답 없는 네트워크 드라이브 · R: 연결 안 된 드라이브
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:'), (r'Q:\', 'Q:'), (r'R:\', 'R:')];
ExplorerPage.probeDrive = (path) => path == r'Q:\' ? stuck.future : Future.value(!dead.contains(path));
device.volumes = () => [(tmp.path, 'T:'), (r'Q:\', 'Q:'), (r'R:\', 'R:')];
device.probe = (path) => path == r'Q:\' ? stuck.future : Future.value(!dead.contains(path));
});
tearDown(() {
ExplorerPage.debugVolumes = null;
ExplorerPage.probeDrive = (path) => Directory(path).exists();
if (!stuck.isCompleted) stuck.complete(false);
tmp.deleteSync(recursive: true);
});
@ -51,7 +52,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
// Q: 는 끝내 대답하지 않지만 목록 · 두 창은 바로 (Q: 를 기다리지 않음)
expect(find.text('doc.txt'), findsOneWidget);
@ -76,15 +77,15 @@ void main() {
// 빈 (연결 안 된) 드라이브가 앞쪽에 많은 PC
final empties = [for (final l in 'GHIJKLNOPS'.split('')) ('$l:\\', '$l:')];
dead = {for (final e in empties) e.$1};
ExplorerPage.debugVolumes = () => [...empties, (tmp.path, 'T:')];
ExplorerPage.probeDrive = (path) => Future.value(!dead.contains(path));
device.volumes = () => [...empties, (tmp.path, 'T:')];
device.probe = (path) => Future.value(!dead.contains(path));
c.settings
..explorerLayout = 'dual'
..explorerOrientation = 'side';
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await settle(tester, () => find.textContaining('S: ·').evaluate().isNotEmpty);
// 넓은 창: 끝 버튼이 화면 안에 (눌러서 창이 뜬다)

View File

@ -83,8 +83,7 @@ void main() {
});
testWidgets('X-plore 맞추기: 폴더 길게 누르기 [갤러리 · 음악 앱에서 숨기기 (.nomedia)] - 확인 창 뒤 빈 .nomedia, 다시 누르면 지우기 · 안의 파일은 그대로', (tester) async {
ExplorerPage.debugNomedia = true;
addTearDown(() => ExplorerPage.debugNomedia = null);
device.nomediaOn = true;
final mark = File(p.join(left, 'sub', '.nomedia'));
await open(tester);
expect(find.text('갤러리 · 음악 앱에서 숨기기 (.nomedia)'), findsNothing);
@ -196,7 +195,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('숨긴 항목 1개 보이기').evaluate().isNotEmpty);
expect(find.text('doc.txt'), findsNothing);
await act(tester, () => tester.tap(find.byTooltip('창 배치 · 버튼 구성')));

View File

@ -213,8 +213,7 @@ void main() {
});
testWidgets('X-plore c: X-plore 스타일 트리 맨 위에 다른 저장 장치 · 즐겨찾기 - 누르면 그곳으로, 즐겨찾기는 펼침', (tester) async {
ExplorerPage.debugVolumes = () => [(left, 'L:'), (right, 'R:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(left, 'L:'), (right, 'R:')];
c.settings
..explorerStyle = 'xplore'
..folderBookmarks = [FolderBookmark(name: '내 즐겨찾기', path: p.join(left, 'sub'))];

View File

@ -12,9 +12,12 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p;
import 'support/explorer_harness.dart' show act, settle, calm;
import 'support/test_explorer_device.dart';
/// 23: 앞의 복사가 끝나지 않아도 다음 복사를 함께 시작한다 (막지 않음, 진행 막대가 둘)
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
testWidgets('복사 중에 다른 파일 복사 → 둘 다 진행 · 둘 다 끝남', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_parallel_');
addTearDown(() => tmp.deleteSync(recursive: true));
@ -31,7 +34,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
Future<void> copy(String name) async {
@ -63,8 +66,7 @@ void main() {
final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R');
Directory(p.join(l, 'sub')).createSync(recursive: true);
Directory(p.join(r, 'rsub')).createSync(recursive: true);
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(tmp.path, 'T:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
@ -72,7 +74,7 @@ void main() {
tester.view.physicalSize = const Size(400, 860);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c, rsync: true))));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty);
// 왼쪽 창만 (오른쪽 폴더는 안 보임) · 방향 버튼은 보임
expect(find.text('왼쪽 · L'), findsOneWidget);
@ -104,8 +106,7 @@ void main() {
final l = p.join(tmp.path, 'zz', 'deep', 'L'), r = p.join(tmp.path, 'zz', 'deep', 'R');
Directory(p.join(l, 'sub')).createSync(recursive: true);
Directory(p.join(r, 'rsub')).createSync(recursive: true);
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(tmp.path, 'T:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
@ -113,7 +114,7 @@ void main() {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c, rsync: true))));
await settle(tester, () => find.text('sub').hitTestable().evaluate().isNotEmpty);
expect(find.text('sub').hitTestable(), findsOneWidget, reason: '왼쪽은 처음부터 지금 폴더');
// [오른쪽] 탭을 처음 보면 그 창의 지금 폴더 (R) 로 - 예전에는 맨 위 (a00 …) 에서 시작
@ -141,8 +142,7 @@ void main() {
final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R');
Directory(l).createSync(recursive: true);
Directory(r).createSync(recursive: true);
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(tmp.path, 'T:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
@ -150,7 +150,7 @@ void main() {
tester.view.physicalSize = const Size(860, 400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c, rsync: true))));
await settle(tester, () => find.text('좌 → 우').evaluate().isNotEmpty);
for (final t in ['좌 → 우', '좌 ← 우', '좌 ⇄ 우', '모니터링']) {
expect(find.text(t), findsOneWidget, reason: t);
@ -164,8 +164,7 @@ void main() {
final inner = p.join(tmp.path, 'inner'), sd = p.join(tmp.path, 'sd');
Directory(p.join(inner, 'L')).createSync(recursive: true);
Directory(p.join(sd, 'R')).createSync(recursive: true);
ExplorerPage.debugVolumes = () => [(inner, '내장 저장소'), (sd, 'SD 카드')];
addTearDown(() => ExplorerPage.debugVolumes = null);
device.volumes = () => [(inner, '내장 저장소'), (sd, 'SD 카드')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
@ -173,7 +172,7 @@ void main() {
tester.view.physicalSize = const Size(412, 880);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c, rsync: true))));
await settle(tester, () => find.text('SD 카드').evaluate().isNotEmpty);
// 탭 글은 눌리지 않는다 (글 폭 그대로) - 넘치면 옆으로 밀어서 다 보인다 (202 와 같은 방식)
final plain = TextPainter(text: const TextSpan(text: 'SD 카드'), textDirection: TextDirection.ltr)..layout();
@ -199,7 +198,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c, rsync: true))));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty);
await tester.longPress(find.text('sub'));
await calm(tester);
@ -232,7 +231,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('top.txt').evaluate().isNotEmpty);
await tester.tap(find.byTooltip('경로 입력').first);
await calm(tester);
@ -265,7 +264,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('a0.txt').evaluate().isNotEmpty);
// 앱이 Download 를 읽은 뒤에 다른 앱이 만든 폴더 (이름 순으로 맨 뒤)
final made = p.join(dl, 'zz_jjsup');
@ -300,7 +299,7 @@ void main() {
tester.view.physicalSize = const Size(420, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.textContaining('만들다 만 파일').evaluate().isNotEmpty);
expect(tester.takeException(), isNull); // 넘침 (overflow) 없음
// 목록 (창마다) 이 밀려 사라지지 않고 높이가 남아 있다
@ -341,7 +340,7 @@ void main() {
tester.view.physicalSize = const Size(500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('a.txt').evaluate().isNotEmpty);
// 그 버튼을 누른다: 버튼 줄에 있으면 바로, 다 들어가지 않아 [더 보기] 메뉴에 있으면 메뉴에서 (147)
Future<void> press(String tooltip, String label) async {

View File

@ -13,8 +13,11 @@ import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart';
import 'support/explorer_harness.dart' show calm;
import 'support/test_explorer_device.dart';
/// 180: 복사 · 이동이 시작 · 진행 · 끝나도 목록의 자리와 스크롤은 그대로 (다음 것을 누르려다 엉뚱한 것을 누르지 않게)
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
late Directory tmp;
late String left, right;
late AppController c;
@ -31,7 +34,7 @@ void main() {
}
Directory(right).createSync();
// 저장 장치는 시험 폴더 하나 (실제 Temp 폴더의 많은 항목 사이에서 찾지 않게)
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
device.volumes = () => [(tmp.path, 'T:')];
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: NoopShell()));
c.settings
..explorerPaths = [left, right]
@ -39,7 +42,6 @@ void main() {
..copyMethodFolder = 'builtin';
});
tearDown(() async {
ExplorerPage.debugVolumes = null;
for (var i = 0;; i++) {
try {
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
@ -58,7 +60,7 @@ void main() {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('f05.txt').evaluate().isNotEmpty);
// 왼쪽 목록을 조금 내려 둔다
final list = find.ancestor(of: find.text('f05.txt'), matching: find.byType(Scrollable)).first;
@ -123,7 +125,7 @@ void main() {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('f05.txt').evaluate().isNotEmpty);
final list = find.ancestor(of: find.text('f05.txt'), matching: find.byType(Scrollable)).first;
await tester.drag(find.text('f05.txt'), const Offset(0, -200));
@ -149,7 +151,7 @@ void main() {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('f03.txt').evaluate().length == 2);
expect(find.text('f03.txt'), findsNWidgets(2));
await tester.runAsync(() => File(p.join(left, 'f03.txt')).delete());
@ -165,7 +167,7 @@ void main() {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('f03.txt').evaluate().length == 2);
// 다른 앱으로 갔다가 (그사이 밖에서 지우고 만듦)
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);

View File

@ -15,6 +15,7 @@ import 'package:path/path.dart' as p;
import 'support/explorer_harness.dart' show act, doubleTap, settle, calm;
import 'support/dav_server.dart';
import 'support/test_explorer_device.dart';
class _Shell extends NoopShell {
final opened = <String>[];
@ -26,6 +27,8 @@ class _Shell extends NoopShell {
}
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
late Directory tmp, remote;
late String left;
late TestDavServer server;
@ -50,12 +53,11 @@ void main() {
shell = _Shell();
// 이 PC 의 드라이브 (네트워크 드라이브 등) 가 많으면 탭이 밀려 "WebDAV" 탭이 화면 밖으로 간다 - 시험 폴더의 드라이브만
final root = p.rootPrefix(tmp.path);
ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell));
});
tearDown(() async {
ExplorerPage.debugVolumes = null;
DavRegistry.configure([]);
await server.stop();
tmp.deleteSync(recursive: true);
@ -71,7 +73,7 @@ void main() {
testWidgets('파일 탐색기: WebDAV 탭 → 서버 추가 (연결 확인) → 목록 · 열기 (받아서) · 로컬 → WebDAV 복사', (tester) async {
c.settings.explorerPaths = [left, left];
await pump(tester, ExplorerPage(c: c));
await pump(tester, ExplorerPage(device: device, c: c));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
// 서버가 없으면 위쪽 저장 장치 옆에 "WebDAV" 탭 (창마다)
expect(find.text('WebDAV'), findsNWidgets(2));
@ -124,7 +126,7 @@ void main() {
..rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]);
// 1280×720 (낮은 창) 에서도 체크 · 경고가 버튼에 가려지지 않게
await pump(tester, ExplorerPage(c: c, rsync: true), size: const Size(1280, 720));
await pump(tester, ExplorerPage(device: device, c: c, rsync: true), size: const Size(1280, 720));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.text('sub')));
await act(tester, () => tester.tap(find.text('원격')));
@ -166,7 +168,7 @@ void main() {
..explorerPaths = [left, left]
..folderBookmarks = [const FolderBookmark(name: 'NAS 원격', path: 'dav://nas/원격')];
DavRegistry.configure([s]);
await pump(tester, ExplorerPage(c: c));
await pump(tester, ExplorerPage(device: device, c: c));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
Future<void> openBookmark() async {
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
@ -193,7 +195,7 @@ void main() {
..webdavServers = [s]
..rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]);
await pump(tester, ExplorerPage(c: c, rsync: true));
await pump(tester, ExplorerPage(device: device, c: c, rsync: true));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty);
expect(find.byIcon(Icons.cloud), findsOneWidget); // 오른쪽 창 맨 위 줄
await act(tester, () => tester.tap(find.text('sub')));

View File

@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
import 'support/test_explorer_device.dart';
/// X-plore 비교 5-2 루트 폴더 보기 (su, 읽기 전용 - 관리자 결정 10/21: Shizuku 와 같은 틀, 처음 여는 곳 "/")
class _Proc implements Process {
@ -51,6 +52,8 @@ class _Su {
}
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
late Directory tmp;
late _Su su;
final wasStart = SuRoot.start;
@ -72,7 +75,6 @@ void main() {
ShizukuRemote.rootBackend = wasRoot;
ShizukuRemote.tempDir = wasTemp;
rootSupported = wasSupported;
ExplorerPage.debugVolumes = null;
tmp.deleteSync(recursive: true);
});
@ -147,12 +149,12 @@ void main() {
t.view.physicalSize = const Size(1400, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
ExplorerPage.debugVolumes = () => [('/storage/emulated/0', 'T:')];
device.volumes = () => [('/storage/emulated/0', 'T:')];
su.answers[shellListScript('/')] = (0, utf8.encode('directory|0|1700000000|data\ndirectory|0|1700000000|system\n'));
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: ctl(on: false)))));
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl(on: false)))));
await wait(t, () => false);
expect(find.text('루트 (읽기 전용)'), findsNothing);
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: ctl()))));
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl()))));
await wait(t, () => find.text('루트 (읽기 전용)').evaluate().isNotEmpty);
await t.tap(find.text('루트 (읽기 전용)').first);
await wait(t, () => find.text('알고 봅니다').evaluate().isNotEmpty);
@ -175,10 +177,10 @@ void main() {
t.view.physicalSize = const Size(1400, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
ExplorerPage.debugVolumes = () => [('/storage/emulated/0', 'T:')];
device.volumes = () => [('/storage/emulated/0', 'T:')];
ExplorerPage.rootWarnedThisRun = true;
su.hasSu = false;
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: ctl()))));
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl()))));
await wait(t, () => find.text('루트 (읽기 전용)').evaluate().isNotEmpty);
await t.tap(find.text('루트 (읽기 전용)').first);
await wait(t, () => find.textContaining('su 가 없습니다').evaluate().isNotEmpty);

View File

@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
import 'package:path/path.dart' as p;
import 'support/test_explorer_device.dart';
/// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 · 처음 끔 · 경고 두 번 - 관리자 결정 10/21)
class _Fake implements ShizukuBackend {
@ -46,6 +47,8 @@ class _Fake implements ShizukuBackend {
}
void main() {
late TestExplorerDevice device;
setUp(() => device = TestExplorerDevice());
late Directory tmp;
late _Fake fake;
final wasBackend = ShizukuRemote.backend;
@ -63,7 +66,6 @@ void main() {
ShizukuRemote.backend = wasBackend;
ShizukuRemote.tempDir = wasTemp;
shizukuSupported = wasSupported;
ExplorerPage.debugVolumes = null;
tmp.deleteSync(recursive: true);
});
@ -131,7 +133,7 @@ void main() {
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
// 탭이 여는 곳은 첫 저장 장치의 Android (기기 경로) - 그 저장 장치는 이 PC 에서 열리지 않아도 됨
ExplorerPage.debugVolumes = () => [('/storage/emulated/0', 'T:')];
device.volumes = () => [('/storage/emulated/0', 'T:')];
const home = '/storage/emulated/0/Android';
// 트리는 위 폴더부터 읽는다 (기기에서는 adb 셸이 / · /storage 를 읽을 수 있음)
fake.dirs['/'] = 'directory|0|1700000000|storage\n';
@ -141,11 +143,11 @@ void main() {
fake.dirs[home] = 'directory|4096|1700000000|data\ndirectory|4096|1700000000|obb\n';
fake.dirs['$home/data'] = 'directory|4096|1700000000|com.game\n';
// 꺼져 있으면 탭 없음
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: ctl(on: false)))));
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl(on: false)))));
await wait(t, () => false);
expect(find.text('시스템 (읽기 전용)'), findsNothing);
// 켬
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: ctl()))));
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl()))));
await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty);
await t.tap(find.text('시스템 (읽기 전용)').first);
await wait(t, () => find.text('알고 봅니다').evaluate().isNotEmpty);
@ -175,7 +177,7 @@ void main() {
expect(fake.calls.where((x) => x.startsWith('copy:')), isEmpty);
// 다시 열기: 경고 없이 (이번 실행에서 이미 봄)
await t.pumpWidget(const SizedBox());
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: ctl()))));
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl()))));
await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty);
await t.tap(find.text('시스템 (읽기 전용)').first);
await wait(t, () => find.text('obb').evaluate().isNotEmpty);
@ -188,10 +190,10 @@ void main() {
t.view.physicalSize = const Size(1400, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
device.volumes = () => [(tmp.path, 'T:')];
ExplorerPage.shizukuWarnedThisRun = true;
fake.running = false;
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: ctl()))));
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl()))));
await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty);
await t.tap(find.text('시스템 (읽기 전용)').first);
await wait(t, () => find.textContaining('Shizuku 가 켜져 있지 않습니다').evaluate().isNotEmpty);

View File

@ -12,6 +12,7 @@ import 'package:path/path.dart' as p;
import 'explorer_clock_keys.dart';
import 'fake_recycle_bin.dart';
import 'test_explorer_device.dart';
/// E5e: 탐색기 화면 시험의 공용 준비 (예전 explorer_test.dart 의 setUp · tearDown · open · 도우미).
/// [useExplorerFixture] 를 main 맨 앞에서 부르면 시험마다 시험 폴더 (left: doc.txt · sub/inner.txt, right: 빈 폴더) 와
@ -33,6 +34,7 @@ class ExplorerShell extends NoopShell {
Directory? _tmp;
String? _left, _right;
ExplorerShell? _shell;
TestExplorerDevice? _device;
AppController? _c;
/// 이 시험의 시험 폴더 · 왼쪽 · 오른쪽 창 폴더 · 셸 · 앱
@ -42,6 +44,9 @@ String get right => _right!;
ExplorerShell get shell => _shell!;
AppController get c => _c!;
/// 이 시험의 기기 (저장 장치 · 드라이브 · Android 갈래) - [open] 이 화면에 넘긴다
TestExplorerDevice get device => _device!;
void useExplorerFixture() {
setUp(() {
// 휴지통: 기본은 가짜 (이 시험들은 영구 삭제를 고르지만 혹시라도 사용자 휴지통에 닿지 않게)
@ -57,6 +62,7 @@ void useExplorerFixture() {
..writeAsStringSync('inner');
Directory(right).createSync();
_shell = ExplorerShell();
_device = TestExplorerDevice();
_c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell));
c.settings.explorerPaths = [left, right];
@ -80,7 +86,7 @@ Future<void> open(WidgetTester tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, clock: () => testNow))));
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, clock: () => testNow, device: device))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
}

View File

@ -0,0 +1,27 @@
import 'package:jj_mkvmaker/app/explorer_device.dart';
/// 시험용 [ExplorerDevice]: 정한 것만 바꾸고 나머지는 이 기기 그대로.
/// [android] · [trash] · [nomediaOn] 은 Windows 에서 Android 의 갈래를 지나게 할 뿐이다 - 논리만 확인, Android 실제 확인은 노트 10+
class TestExplorerDevice implements ExplorerDevice {
static const _real = RealExplorerDevice();
/// 저장 장치 목록 (null 이면 이 기기)
List<(String, String)> Function()? volumes;
/// 드라이브를 열 수 있는지 (null 이면 이 기기)
Future<bool> Function(String path)? probe;
/// 경로 입력 SMB 안내 · 앱 휴지통 · .nomedia 메뉴 (null 이면 이 기기)
bool? android, trash, nomediaOn;
@override
Future<List<(String, String)>> listVolumes() async => volumes?.call() ?? await _real.listVolumes();
@override
Future<bool> probeDrive(String path) => (probe ?? _real.probeDrive)(path);
@override
bool get androidPaths => android ?? _real.androidPaths;
@override
bool get androidTrash => trash ?? _real.androidTrash;
@override
bool get nomedia => nomediaOn ?? _real.nomedia;
}