M2-a: LocalFs (목록 · 저장 장치 · 드라이브) 주입 + 빈 목록 경합 고침
- lib/app/local_fs.dart: LocalFs 약속 (list · volumes · probe), RealLocalFs = 이 기기 그대로 - ExplorerDevice.listVolumes/probeDrive → ExplorerDevice.fs (LocalFs) - ExplorerController(fs:) → loadDir(list:) (처음 값 listEntries) - 빈 목록 경합: reloadOpen 이 끝날 때, 시작할 때 읽어 둔 것 중 닫힌 것만 잊음 (예전: 시작할 때 찍은 열린 목록으로 지워, 다시 읽는 동안 펼친 폴더가 비어 보였다) - 결정적 시험 explorer_reload_race_test: 다시 읽기를 붙잡는 느린 LocalFs 를 생성자로 넘김 (실제 폴더 · 실제 목록). 예전 판에서는 실패 (펼친 폴더 [] ), 고친 판에서는 통과 확인: analyze 0 · 단위 1471 ×2 · 통합 6/6 ×2 · base10 214장 0px Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>modular
parent
3434ad4937
commit
beb8107d81
|
|
@ -1,14 +1,13 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'copy_center.dart' as copy show diskSpace;
|
import 'copy_center.dart' as copy show diskSpace;
|
||||||
import 'volumes.dart';
|
import 'local_fs.dart';
|
||||||
|
|
||||||
/// 약속: 파일 탐색기가 기기에서 받는 것 - 저장 장치 목록 · 드라이브를 열 수 있는지 (화면을 멈추지 않는 비동기) ·
|
/// 약속: 파일 탐색기가 기기에서 받는 것 - 폴더 목록 · 저장 장치 목록 · 드라이브를 열 수 있는지 ([fs], M2 LocalFs) ·
|
||||||
/// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양, 못 재면 null) · Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴).
|
/// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양, 못 재면 null) · Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴).
|
||||||
/// 앱은 [RealExplorerDevice] (이 기기 그대로) 를 쓰고, 시험은 시험 폴더를 저장 장치로 주는 구현을 넘긴다
|
/// 앱은 [RealExplorerDevice] (이 기기 그대로) 를 쓰고, 시험은 시험 폴더를 저장 장치로 주는 구현을 넘긴다
|
||||||
abstract interface class ExplorerDevice {
|
abstract interface class ExplorerDevice {
|
||||||
Future<List<(String, String)>> listVolumes();
|
LocalFs get fs;
|
||||||
Future<bool> probeDrive(String path);
|
|
||||||
Future<(int, int)?> diskSpace(String path);
|
Future<(int, int)?> diskSpace(String path);
|
||||||
bool get androidPaths;
|
bool get androidPaths;
|
||||||
bool get androidTrash;
|
bool get androidTrash;
|
||||||
|
|
@ -20,9 +19,7 @@ class RealExplorerDevice implements ExplorerDevice {
|
||||||
const RealExplorerDevice();
|
const RealExplorerDevice();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<(String, String)>> listVolumes() => localVolumes();
|
LocalFs get fs => const RealLocalFs();
|
||||||
@override
|
|
||||||
Future<bool> probeDrive(String path) => Directory(path).exists();
|
|
||||||
@override
|
@override
|
||||||
Future<(int, int)?> diskSpace(String path) => copy.diskSpace(path);
|
Future<(int, int)?> diskSpace(String path) => copy.diskSpace(path);
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/// 약속 (M2): 폴더 목록 · 저장 장치 · 드라이브를 열 수 있는지 - 파일 탐색기가 기기에서 읽는 것.
|
||||||
|
/// - [list]: 폴더 → 항목들. 하나도 읽지 못하면 그 오류를 던진다 (빈 목록으로 숨기지 않음 - file_listing 의 listEntries 약속 그대로)
|
||||||
|
/// - [volumes]: 저장 장치 (경로, 이름) 목록
|
||||||
|
/// - [probe]: 그 경로를 열 수 있는지 (화면을 멈추지 않는 비동기)
|
||||||
|
/// 앱은 [RealLocalFs] (이 기기 그대로) 를 쓰고, 시험은 생성자로 다른 것을 넘긴다 (예: 느린 목록 - 빈 목록 경합 시험)
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../core/file_ops.dart' show FileEntry, listEntries;
|
||||||
|
import 'volumes.dart';
|
||||||
|
|
||||||
|
abstract interface class LocalFs {
|
||||||
|
Future<List<FileEntry>> list(String dir, {bool showHidden = false});
|
||||||
|
Future<List<(String, String)>> volumes();
|
||||||
|
Future<bool> probe(String path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 기기 그대로
|
||||||
|
class RealLocalFs implements LocalFs {
|
||||||
|
const RealLocalFs();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<FileEntry>> list(String dir, {bool showHidden = false}) => listEntries(dir, showHidden: showHidden);
|
||||||
|
@override
|
||||||
|
Future<List<(String, String)>> volumes() => localVolumes();
|
||||||
|
@override
|
||||||
|
Future<bool> probe(String path) => Directory(path).exists();
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ import 'pane_state.dart';
|
||||||
/// 약속 (E5a): 폴더 하나를 읽어 창 상태에 넣는다 ([loadDir]). 입력 = 창 · 폴더 · 새로 읽을지 · 숨김 파일 · 정렬,
|
/// 약속 (E5a): 폴더 하나를 읽어 창 상태에 넣는다 ([loadDir]). 입력 = 창 · 폴더 · 새로 읽을지 · 숨김 파일 · 정렬,
|
||||||
/// 출력 = [LoadOutcome] (읽어 둔 것을 씀 · 읽음 · 오류 · 보던 폴더가 없어짐 (갈 상위 폴더)).
|
/// 출력 = [LoadOutcome] (읽어 둔 것을 씀 · 읽음 · 오류 · 보던 폴더가 없어짐 (갈 상위 폴더)).
|
||||||
/// 오류: 읽지 못하면 [PaneState.errors] 에 이유를 남기고 목록은 비운다 (빈 목록으로 숨기지 않음 - 화면이 이유를 보임).
|
/// 오류: 읽지 못하면 [PaneState.errors] 에 이유를 남기고 목록은 비운다 (빈 목록으로 숨기지 않음 - 화면이 이유를 보임).
|
||||||
/// - 목록 읽기는 file_ops 의 [listEntries] (로컬 · 원격 모두), 정렬은 부르는 쪽 [sort] (정렬 설정 · 폴더 크기를 아는 곳).
|
/// - 목록 읽기는 [list] (처음 값 = file_ops 의 [listEntries], 로컬 · 원격 모두 - 탐색기는 LocalFs 를 넘김), 정렬은 부르는 쪽 [sort] (정렬 설정 · 폴더 크기를 아는 곳).
|
||||||
/// - [inside] 가 목록을 주면 (압축 안처럼 읽어 둔 것) 그것을 쓴다. null 이면 보통 폴더로 읽는다.
|
/// - [inside] 가 목록을 주면 (압축 안처럼 읽어 둔 것) 그것을 쓴다. null 이면 보통 폴더로 읽는다.
|
||||||
/// - 읽는 동안 [PaneState.loading] 에 그 폴더가 있고, 앞뒤로 [PaneState.changed] 를 알린다.
|
/// - 읽는 동안 [PaneState.loading] 에 그 폴더가 있고, 앞뒤로 [PaneState.changed] 를 알린다.
|
||||||
/// - OS 가 막아 비어 보이는 폴더 ([osBlocked] 가 이유를 줌 - Android/data 등) 는 그 이유를 오류로 남긴다.
|
/// - OS 가 막아 비어 보이는 폴더 ([osBlocked] 가 이유를 줌 - Android/data 등) 는 그 이유를 오류로 남긴다.
|
||||||
|
|
@ -35,6 +35,7 @@ Future<LoadOutcome> loadDir(
|
||||||
Future<List<FileEntry>?> Function(String dir, bool force)? inside,
|
Future<List<FileEntry>?> Function(String dir, bool force)? inside,
|
||||||
String? Function(String dir)? osBlocked,
|
String? Function(String dir)? osBlocked,
|
||||||
void Function(String dir)? onFresh,
|
void Function(String dir)? onFresh,
|
||||||
|
Future<List<FileEntry>> Function(String dir, {bool showHidden}) list = listEntries,
|
||||||
}) async {
|
}) async {
|
||||||
if (!force && pane.cache.containsKey(dir)) return LoadOutcome.cached;
|
if (!force && pane.cache.containsKey(dir)) return LoadOutcome.cached;
|
||||||
final kept = await inside?.call(dir, force);
|
final kept = await inside?.call(dir, force);
|
||||||
|
|
@ -48,10 +49,10 @@ Future<LoadOutcome> loadDir(
|
||||||
pane.loading.add(dir);
|
pane.loading.add(dir);
|
||||||
pane.changed();
|
pane.changed();
|
||||||
try {
|
try {
|
||||||
final list = await listEntries(dir, showHidden: showHidden);
|
final got = await list(dir, showHidden: showHidden);
|
||||||
pane.cache[dir] = sort(list, dir);
|
pane.cache[dir] = sort(got, dir);
|
||||||
// 50: OS 가 막은 폴더는 오류 없이 비어 보인다 - 비어 있는 것이 아니라 막힌 것이라고
|
// 50: OS 가 막은 폴더는 오류 없이 비어 보인다 - 비어 있는 것이 아니라 막힌 것이라고
|
||||||
final blocked = list.isEmpty ? osBlocked?.call(dir) : null;
|
final blocked = got.isEmpty ? osBlocked?.call(dir) : null;
|
||||||
if (blocked != null) {
|
if (blocked != null) {
|
||||||
pane.errors[dir] = blocked;
|
pane.errors[dir] = blocked;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import '../explorer_look.dart';
|
||||||
import '../file_error.dart';
|
import '../file_error.dart';
|
||||||
import 'explorer_host.dart';
|
import 'explorer_host.dart';
|
||||||
import 'explorer_pane.dart';
|
import 'explorer_pane.dart';
|
||||||
|
import '../../app/local_fs.dart';
|
||||||
|
|
||||||
/// 약속 (E5d-1): 탐색기 화면 하나의 상태와 화면 밖의 일 - 창 두 개 · 지금 창 · 저장 장치 · 드라이브 · 폴더 크기 · 압축 ·
|
/// 약속 (E5d-1): 탐색기 화면 하나의 상태와 화면 밖의 일 - 창 두 개 · 지금 창 · 저장 장치 · 드라이브 · 폴더 크기 · 압축 ·
|
||||||
/// 목록에서 숨기기 · 읽기 · 정렬 · 이동 · 뒤로/앞으로 · 기억 · 새로 고침.
|
/// 목록에서 숨기기 · 읽기 · 정렬 · 이동 · 뒤로/앞으로 · 기억 · 새로 고침.
|
||||||
|
|
@ -30,7 +31,7 @@ import 'explorer_pane.dart';
|
||||||
/// archive_browse · app_hidden · folder_size). 화면에 부탁할 것 (알림 · 스크롤 · 다음 그리기) 은 [ExplorerHost] 로.
|
/// archive_browse · app_hidden · folder_size). 화면에 부탁할 것 (알림 · 스크롤 · 다음 그리기) 은 [ExplorerHost] 로.
|
||||||
/// 바뀌면 [notifyListeners] (화면이 다시 그림). 오류는 던지지 않고 창의 오류 · 알림으로.
|
/// 바뀌면 [notifyListeners] (화면이 다시 그림). 오류는 던지지 않고 창의 오류 · 알림으로.
|
||||||
class ExplorerController extends ChangeNotifier {
|
class ExplorerController extends ChangeNotifier {
|
||||||
ExplorerController(this.c, {required this.rsync, required this.host}) {
|
ExplorerController(this.c, {required this.rsync, required this.host, this.fs = const RealLocalFs()}) {
|
||||||
sizes.addListener(_onSizes);
|
sizes.addListener(_onSizes);
|
||||||
drives.addListener(notifyListeners);
|
drives.addListener(notifyListeners);
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +40,9 @@ class ExplorerController extends ChangeNotifier {
|
||||||
final bool rsync;
|
final bool rsync;
|
||||||
final ExplorerHost host;
|
final ExplorerHost host;
|
||||||
|
|
||||||
|
/// 폴더 목록을 읽는 곳 (M2 - 처음 값 = 이 기기)
|
||||||
|
final LocalFs fs;
|
||||||
|
|
||||||
late final List<ExplorerPane> panes = [ExplorerPane('', rsync: rsync), ExplorerPane('', rsync: rsync)];
|
late final List<ExplorerPane> panes = [ExplorerPane('', rsync: rsync), ExplorerPane('', rsync: rsync)];
|
||||||
int active = 0;
|
int active = 0;
|
||||||
bool ready = false;
|
bool ready = false;
|
||||||
|
|
@ -169,7 +173,8 @@ class ExplorerController extends ChangeNotifier {
|
||||||
sort: sorted,
|
sort: sorted,
|
||||||
inside: _insideArchiveList,
|
inside: _insideArchiveList,
|
||||||
osBlocked: (d) => isAndroidRestricted(d) ? androidRestrictedError : null,
|
osBlocked: (d) => isAndroidRestricted(d) ? androidRestrictedError : null,
|
||||||
onFresh: sizes.forget);
|
onFresh: sizes.forget,
|
||||||
|
list: fs.list);
|
||||||
// 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로
|
// 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로
|
||||||
if (r.vanishedUpTo case final up?) {
|
if (r.vanishedUpTo case final up?) {
|
||||||
host.afterFrame(() {
|
host.afterFrame(() {
|
||||||
|
|
@ -246,12 +251,16 @@ class ExplorerController extends ChangeNotifier {
|
||||||
try {
|
try {
|
||||||
for (final x in panes) {
|
for (final x in panes) {
|
||||||
final open = {...x.expanded, x.current};
|
final open = {...x.expanded, x.current};
|
||||||
|
final had = {...x.cache.keys};
|
||||||
for (final d in open) {
|
for (final d in open) {
|
||||||
if (!host.alive) return;
|
if (!host.alive) return;
|
||||||
if (localOnly && isDav(d)) continue;
|
if (localOnly && isDav(d)) continue;
|
||||||
await load(x, d, force: true);
|
await load(x, d, force: true);
|
||||||
}
|
}
|
||||||
x.cache.removeWhere((k, _) => !open.contains(k));
|
// 빈 목록 경합 (10/21): 다시 읽는 동안 사용자가 펼친 폴더는 시작할 때 찍은 [open] 에 없다 - 그것까지 지우면 펼친 폴더가 비어 보였다.
|
||||||
|
// 시작할 때 읽어 둔 것 중, 끝날 때 열려 있지 않은 것만 잊는다
|
||||||
|
final still = {...x.expanded, x.current};
|
||||||
|
x.cache.removeWhere((k, _) => had.contains(k) && !still.contains(k));
|
||||||
x.changed();
|
x.changed();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
|
||||||
AppController get c => widget.c;
|
AppController get c => widget.c;
|
||||||
|
|
||||||
/// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다
|
/// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다
|
||||||
late final _ctl = ExplorerController(c, rsync: widget.rsync, host: this)..addListener(_onPane);
|
late final _ctl = ExplorerController(c, rsync: widget.rsync, host: this, fs: widget.device.fs)..addListener(_onPane);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get alive => mounted;
|
bool get alive => mounted;
|
||||||
|
|
@ -182,7 +182,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
_ctl.local = await widget.device.listVolumes();
|
_ctl.local = await widget.device.fs.volumes();
|
||||||
if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
|
if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
|
||||||
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
|
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
|
||||||
if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0));
|
if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0));
|
||||||
|
|
@ -326,7 +326,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
|
||||||
bool get dual => _layout.dual;
|
bool get dual => _layout.dual;
|
||||||
|
|
||||||
/// 권한 · 시스템 폴더 · 다시 연결 · 목록 위 안내 (E5d-6)
|
/// 권한 · 시스템 폴더 · 다시 연결 · 목록 위 안내 (E5d-6)
|
||||||
late final _access = ExplorerAccess(_ctl, probe: widget.device.probeDrive)..addListener(_onPane);
|
late final _access = ExplorerAccess(_ctl, probe: widget.device.fs.probe)..addListener(_onPane);
|
||||||
late final _notices = ExplorerNotices(_ctl, _access, _servers);
|
late final _notices = ExplorerNotices(_ctl, _access, _servers);
|
||||||
|
|
||||||
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)
|
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ void main() {
|
||||||
const d = RealExplorerDevice();
|
const d = RealExplorerDevice();
|
||||||
final tmp = Directory.systemTemp.createTempSync('jj_dev_');
|
final tmp = Directory.systemTemp.createTempSync('jj_dev_');
|
||||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||||
expect((await d.listVolumes()).isNotEmpty, isTrue);
|
expect((await d.fs.volumes()).isNotEmpty, isTrue);
|
||||||
expect([await d.probeDrive(tmp.path), await d.probeDrive('${tmp.path}${Platform.pathSeparator}none')], [true, false]);
|
expect([await d.fs.probe(tmp.path), await d.fs.probe('${tmp.path}${Platform.pathSeparator}none')], [true, false]);
|
||||||
expect([d.androidPaths, d.androidTrash, d.nomedia], [Platform.isAndroid, Platform.isAndroid, Platform.isAndroid]);
|
expect([d.androidPaths, d.androidTrash, d.nomedia], [Platform.isAndroid, Platform.isAndroid, Platform.isAndroid]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ void main() {
|
||||||
final d = TestExplorerDevice()
|
final d = TestExplorerDevice()
|
||||||
..volumes = (() => [('T:\\', 'T:')])
|
..volumes = (() => [('T:\\', 'T:')])
|
||||||
..trash = true;
|
..trash = true;
|
||||||
expect(await d.listVolumes(), [('T:\\', 'T:')]);
|
expect(await d.fs.volumes(), [('T:\\', 'T:')]);
|
||||||
expect([d.androidTrash, d.androidPaths], [true, Platform.isAndroid]);
|
expect([d.androidTrash, d.androidPaths], [true, Platform.isAndroid]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
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/app/local_fs.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_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// M2 빈 목록 경합 (10/21): 앱으로 돌아와 다시 읽는 동안 (reloadOpen) 사용자가 펼친 폴더가, 다시 읽기가 끝날 때 잊혀 비어 보였다.
|
||||||
|
/// 다시 읽기를 붙잡는 느린 목록을 생성자 (ExplorerController.fs) 로 넘겨 겹침을 매번 만든다. 폴더 · 목록은 실제 (RealLocalFs)
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
late String a, b, show;
|
||||||
|
setUp(() {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('jj_race_');
|
||||||
|
a = p.join(tmp.path, 'A');
|
||||||
|
b = p.join(tmp.path, 'B');
|
||||||
|
show = p.join(a, 'Show');
|
||||||
|
Directory(show).createSync(recursive: true);
|
||||||
|
Directory(b).createSync();
|
||||||
|
File(p.join(show, 'ep1.mkv')).writeAsStringSync('1');
|
||||||
|
});
|
||||||
|
tearDown(() => tmp.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
testWidgets('다시 읽는 동안 펼친 폴더는 남는다 · 시작할 때 읽어 둔 것 중 닫힌 것만 잊는다', (t) async {
|
||||||
|
final fs = _HeldFs();
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
final key = GlobalKey<_HostState>();
|
||||||
|
await t.pumpWidget(MaterialApp(home: _Host(key: key)));
|
||||||
|
final ctl = ExplorerController(c, rsync: false, host: key.currentState!, fs: fs)..local = [(a, 'A:'), (b, 'B:')];
|
||||||
|
addTearDown(ctl.dispose);
|
||||||
|
final q = ctl.panes[0];
|
||||||
|
await t.runAsync(() async {
|
||||||
|
await ctl.goTo(q, a);
|
||||||
|
await ctl.load(q, b); // 읽어 두었지만 열려 있지 않음 - 다시 읽기 뒤 잊어야 함
|
||||||
|
expect(q.cache.containsKey(b), isTrue);
|
||||||
|
// 다시 읽기를 A 에서 붙잡는다
|
||||||
|
fs.hold(a);
|
||||||
|
final reload = ctl.reloadOpen();
|
||||||
|
await fs.reached.future;
|
||||||
|
// 그동안 사용자가 Show 를 펼침
|
||||||
|
q.expanded.add(show);
|
||||||
|
await ctl.load(q, show);
|
||||||
|
fs.release.complete();
|
||||||
|
await reload;
|
||||||
|
});
|
||||||
|
expect([for (final e in q.cache[show] ?? const <FileEntry>[]) p.basename(e.path)], ['ep1.mkv'], reason: '펼친 폴더가 비어 보이지 않음');
|
||||||
|
expect(q.cache.containsKey(b), isFalse, reason: '시작할 때 있던 것 중 닫힌 것은 잊음');
|
||||||
|
expect(q.cache.containsKey(a), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 실제 목록 ([RealLocalFs]) 인데 [hold] 로 정한 폴더 한 번은 [release] 까지 붙잡는다
|
||||||
|
class _HeldFs implements LocalFs {
|
||||||
|
static const _real = RealLocalFs();
|
||||||
|
String? holdAt;
|
||||||
|
late Completer<void> reached, release;
|
||||||
|
|
||||||
|
/// [dir] 를 다음 한 번 붙잡는다 (Completer 는 실제 시간 영역 - runAsync 안에서 부른다)
|
||||||
|
void hold(String dir) {
|
||||||
|
holdAt = dir;
|
||||||
|
reached = Completer<void>();
|
||||||
|
release = Completer<void>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<FileEntry>> list(String dir, {bool showHidden = false}) async {
|
||||||
|
if (dir == holdAt) {
|
||||||
|
holdAt = null;
|
||||||
|
reached.complete();
|
||||||
|
await release.future;
|
||||||
|
}
|
||||||
|
return _real.list(dir, showHidden: showHidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<(String, String)>> volumes() => _real.volumes();
|
||||||
|
@override
|
||||||
|
Future<bool> probe(String path) => _real.probe(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Host extends StatefulWidget {
|
||||||
|
const _Host({super.key});
|
||||||
|
@override
|
||||||
|
State<_Host> createState() => _HostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HostState extends State<_Host> implements ExplorerHost {
|
||||||
|
@override
|
||||||
|
bool get alive => mounted;
|
||||||
|
@override
|
||||||
|
void snack(String text) {}
|
||||||
|
@override
|
||||||
|
void reveal(ExplorerPane pane, String path) {}
|
||||||
|
@override
|
||||||
|
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const Scaffold(body: SizedBox());
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import 'package:jj_mkvmaker/app/explorer_device.dart';
|
import 'package:jj_mkvmaker/app/explorer_device.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/local_fs.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry;
|
||||||
|
|
||||||
/// 시험용 [ExplorerDevice]: 정한 것만 바꾸고 나머지는 이 기기 그대로.
|
/// 시험용 [ExplorerDevice]: 정한 것만 바꾸고 나머지는 이 기기 그대로.
|
||||||
/// [android] · [trash] · [nomediaOn] 은 Windows 에서 Android 의 갈래를 지나게 할 뿐이다 - 논리만 확인, Android 실제 확인은 노트 10+
|
/// [android] · [trash] · [nomediaOn] 은 Windows 에서 Android 의 갈래를 지나게 할 뿐이다 - 논리만 확인, Android 실제 확인은 노트 10+
|
||||||
class TestExplorerDevice implements ExplorerDevice {
|
class TestExplorerDevice implements ExplorerDevice {
|
||||||
static const _real = RealExplorerDevice();
|
static const _real = RealExplorerDevice();
|
||||||
|
static const _fs = RealLocalFs();
|
||||||
|
|
||||||
/// 저장 장치 목록 (null 이면 이 기기)
|
/// 저장 장치 목록 (null 이면 이 기기)
|
||||||
List<(String, String)> Function()? volumes;
|
List<(String, String)> Function()? volumes;
|
||||||
|
|
@ -11,6 +14,9 @@ class TestExplorerDevice implements ExplorerDevice {
|
||||||
/// 드라이브를 열 수 있는지 (null 이면 이 기기)
|
/// 드라이브를 열 수 있는지 (null 이면 이 기기)
|
||||||
Future<bool> Function(String path)? probe;
|
Future<bool> Function(String path)? probe;
|
||||||
|
|
||||||
|
/// 폴더 목록 (null 이면 이 기기) - 느린 목록 등
|
||||||
|
Future<List<FileEntry>> Function(String dir, {bool showHidden})? list;
|
||||||
|
|
||||||
/// 남은 공간 · 전체 (null 이면 이 기기) - 캡처가 실행마다 같게 정한다
|
/// 남은 공간 · 전체 (null 이면 이 기기) - 캡처가 실행마다 같게 정한다
|
||||||
(int, int)? Function(String path)? space;
|
(int, int)? Function(String path)? space;
|
||||||
|
|
||||||
|
|
@ -18,9 +24,7 @@ class TestExplorerDevice implements ExplorerDevice {
|
||||||
bool? android, trash, nomediaOn;
|
bool? android, trash, nomediaOn;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<(String, String)>> listVolumes() async => volumes?.call() ?? await _real.listVolumes();
|
LocalFs get fs => _TestFs(this);
|
||||||
@override
|
|
||||||
Future<bool> probeDrive(String path) => (probe ?? _real.probeDrive)(path);
|
|
||||||
@override
|
@override
|
||||||
Future<(int, int)?> diskSpace(String path) async => space != null ? space!(path) : await _real.diskSpace(path);
|
Future<(int, int)?> diskSpace(String path) async => space != null ? space!(path) : await _real.diskSpace(path);
|
||||||
@override
|
@override
|
||||||
|
|
@ -30,3 +34,15 @@ class TestExplorerDevice implements ExplorerDevice {
|
||||||
@override
|
@override
|
||||||
bool get nomedia => nomediaOn ?? _real.nomedia;
|
bool get nomedia => nomediaOn ?? _real.nomedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _TestFs implements LocalFs {
|
||||||
|
final TestExplorerDevice d;
|
||||||
|
_TestFs(this.d);
|
||||||
|
@override
|
||||||
|
Future<List<FileEntry>> list(String dir, {bool showHidden = false}) =>
|
||||||
|
(d.list ?? TestExplorerDevice._fs.list)(dir, showHidden: showHidden);
|
||||||
|
@override
|
||||||
|
Future<List<(String, String)>> volumes() async => d.volumes?.call() ?? await TestExplorerDevice._fs.volumes();
|
||||||
|
@override
|
||||||
|
Future<bool> probe(String path) => (d.probe ?? TestExplorerDevice._fs.probe)(path);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue