JJ_MKVMaker/test/vault_page_test.dart

441 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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/master_lock.dart';
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/services/secret_store.dart';
import 'package:jj_mkvmaker/core/shizuku.dart';
import 'package:jj_mkvmaker/core/vault_store.dart';
import 'package:jj_mkvmaker/core/vault_view.dart';
import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage;
import 'package:jj_mkvmaker/ui/vault_page.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 5-2 암호 보관함 화면 (관리자 결정 10/21): 만들기 창의 알림 · 넣기 (복사 / 원본 지우기) · 잠금 · 틀린 비밀번호 ·
/// 꺼내기 · 지우기 · [마스터로도 열기] (마스터를 초기화하면 그 길만 없어짐)
void main() {
late Directory tmp;
final was = vaultDirOf;
final wasPick = vaultPickFolder;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_vaultui_');
vaultDirOf = () async => Directory(p.join(tmp.path, 'vault'));
vaultPickFolder = () async => p.join(tmp.path, 'out');
Directory(p.join(tmp.path, 'out')).createSync();
vaultIterationsForTest = 1000;
});
tearDown(() async {
vaultDirOf = was;
vaultPickFolder = wasPick;
MasterLock.instance = null;
// Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있을 수 있다 (errno 32) - 몇 번 더
for (var i = 0; ; i++) {
try {
tmp.deleteSync(recursive: true);
break;
} on FileSystemException {
if (i >= 20) rethrow;
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
});
AppController ctl() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.vaultEnabled = true;
return c;
}
Future<void> until(WidgetTester t, bool Function() ok, {int rounds = 200}) async {
for (var i = 0; i < rounds && !ok(); i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await t.pump(const Duration(milliseconds: 20));
}
// 닫히는 창 · 메뉴의 막 (barrier) 이 사라질 때까지
for (var i = 0; i < 8; i++) {
await t.pump(const Duration(milliseconds: 50));
}
}
/// 진행 막대 · 글자 칸 깜빡임이 있으면 pumpAndSettle 이 끝나지 않는다 - 정한 만큼만
Future<void> settle(WidgetTester t) async {
for (var i = 0; i < 12; i++) {
await t.pump(const Duration(milliseconds: 50));
}
}
Future<void> menu(WidgetTester t, String row, String item) async {
// 꺼내기 · 넣기 중에는 메뉴 단추가 꺼져 있다
await until(t, () => find.byType(LinearProgressIndicator).evaluate().isEmpty);
await t.tap(find.descendant(of: find.widgetWithText(ListTile, row), matching: find.byType(PopupMenuButton<String>)));
await settle(t);
await t.tap(find.text(item).last);
await t.pump();
}
testWidgets('만들기 (알림 · 체크해야 켜짐) → 넣기 (원본 지우기 체크 → 탐색기 지우기 부름) → 잠금 → 틀린 비밀번호 → 열기 → 꺼내기 → 지우기', (t) async {
t.view.physicalSize = const Size(900, 1400);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final src = File(p.join(tmp.path, '비밀 문서.pdf'))..writeAsBytesSync(List.generate(3000, (i) => i % 256));
final deleted = <List<String>>[];
final permanents = <bool>[];
final c = ctl();
await t.pumpWidget(MaterialApp(
home: VaultPage(c: c, add: [src.path], deleteOriginals: (paths, {required permanent}) async {
deleted.add(paths);
permanents.add(permanent);
})));
await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty);
expect(find.textContaining('다른 앱도 이 폴더의 암호화된 파일을 볼 수 있습니다'), findsOneWidget);
expect(find.textContaining('이 폴더를 지우면 보관함도 사라집니다'), findsOneWidget);
expect(find.textContaining('되살릴 수 없습니다'), findsWidgets);
final fields = find.byType(TextField);
await t.enterText(fields.at(0), 'pw1');
await t.enterText(fields.at(1), 'pw1');
await t.pump();
expect(t.widget<FilledButton>(find.widgetWithText(FilledButton, '만들기')).onPressed, isNull, reason: '체크해야 켜짐');
await t.tap(find.text('비밀번호를 잊으면 되살릴 수 없음을 압니다'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '만들기'));
await until(t, () => find.textContaining('보관함에 넣기 ·').evaluate().isNotEmpty);
// 넣기 창: 원본 지우기 (처음 끔) 를 켜고 넣기
expect(t.widget<CheckboxListTile>(find.byType(CheckboxListTile)).value, isFalse, reason: '처음은 복사');
await t.tap(find.text('원본 지우기 (이동)'));
await t.pump();
// 관리자 지시 (10/21): 휴지통에 평문이 남는다는 알림 · 처음 값 휴지통 · 영구 삭제를 고를 수 있음
expect(find.textContaining('휴지통에 평문'), findsOneWidget);
expect(find.text('휴지통으로 (처음 값)'), findsOneWidget);
await t.tap(find.text('영구 삭제'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '넣기'));
await until(t, () => deleted.isNotEmpty);
expect(deleted.single, [src.path], reason: '다 넣은 뒤 탐색기의 지우기로');
expect(permanents.single, isTrue, reason: '영구 삭제를 골랐음 (탐색기가 영구 삭제 확인 창을 한 번 더)');
expect(find.text('비밀 문서.pdf'), findsOneWidget);
expect(Directory(p.join(tmp.path, 'vault')).listSync().map((f) => p.basename(f.path)).join(), isNot(contains('비밀')));
// 잠금 → 틀린 비밀번호 → 맞는 비밀번호
await t.tap(find.byTooltip('잠그기'));
await t.pump();
await t.enterText(find.byType(TextField), 'wrong');
await t.tap(find.widgetWithText(FilledButton, '열기'));
await until(t, () => find.text('비밀번호가 틀렸습니다').evaluate().isNotEmpty);
expect(find.text('비밀번호가 틀렸습니다'), findsOneWidget);
await t.enterText(find.byType(TextField), 'pw1');
await t.tap(find.widgetWithText(FilledButton, '열기'));
await until(t, () => find.text('비밀 문서.pdf').evaluate().isNotEmpty);
// 꺼내기 (원본과 같은 내용)
await menu(t, '비밀 문서.pdf', '꺼내기');
await until(t, () => File(p.join(tmp.path, 'out', '비밀 문서.pdf')).existsSync());
expect(File(p.join(tmp.path, 'out', '비밀 문서.pdf')).readAsBytesSync(), src.readAsBytesSync());
// 지우기 (확인)
await menu(t, '비밀 문서.pdf', '보관함에서 지우기');
await settle(t);
await t.tap(find.widgetWithText(FilledButton, '지우기'));
await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty);
expect(find.text('보관함이 비어 있습니다'), findsOneWidget);
// 목록 쓰기 (index.jjv.tmp → index.jjv) 가 끝날 때까지 (화면은 먼저 바뀔 수 있다)
await until(t, () => !Directory(p.join(tmp.path, 'vault')).listSync().any((f) => f.path.endsWith('.tmp')));
await t.pumpWidget(const SizedBox());
});
testWidgets('틀린 비밀번호 5번이면 30초 기다림 · [마스터로도 열기] 켜기 → 마스터로 열기 · 마스터를 초기화하면 그 길만 없어짐', (t) async {
t.view.physicalSize = const Size(900, 1400);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final secrets = MemorySecretStore();
final lock = MasterLock(secrets);
MasterLock.instance = lock;
await t.runAsync(() => lock.setMaster('master'));
final c = ctl();
// 먼저 만들어 둔다
await t.pumpWidget(MaterialApp(home: VaultPage(c: c)));
await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty);
await t.enterText(find.byType(TextField).at(0), 'pw');
await t.enterText(find.byType(TextField).at(1), 'pw');
await t.tap(find.text('비밀번호를 잊으면 되살릴 수 없음을 압니다'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '만들기'));
await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty);
// 마스터로도 열기 켜기
await t.tap(find.descendant(of: find.byType(AppBar), matching: find.byType(PopupMenuButton<String>)));
await settle(t);
await t.tap(find.text('마스터로도 열기'));
await settle(t);
expect(find.textContaining('마스터를 초기화하면 이 길만 없어지고'), findsOneWidget);
await t.tap(find.widgetWithText(FilledButton, '켜기'));
await until(t, () => c.settings.vaultMasterCopy);
expect((await t.runAsync(secrets.readAll))!.containsKey(MasterLock.vaultCopyKey), isTrue);
// 잠그고 마스터로 열기
await t.tap(find.byTooltip('잠그기'));
await t.pump();
await t.tap(find.text('마스터 비밀번호로 열기'));
await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty);
expect(find.text('보관함이 비어 있습니다'), findsOneWidget);
// 마스터 초기화 → 그 길만 없어짐 (보관함 비밀번호로는 열림)
await t.runAsync(lock.clearMaster);
expect((await t.runAsync(secrets.readAll))!.containsKey(MasterLock.vaultCopyKey), isFalse);
await t.tap(find.byTooltip('잠그기'));
await t.pump();
await t.tap(find.text('마스터 비밀번호로 열기'));
await until(t, () => find.textContaining('마스터로 여는 열쇠가 없습니다').evaluate().isNotEmpty);
expect(find.textContaining('마스터로 여는 열쇠가 없습니다'), findsOneWidget);
// 틀린 비밀번호 5번
for (var i = 0; i < 5; i++) {
await t.enterText(find.byType(TextField), 'nope$i');
await t.tap(find.widgetWithText(FilledButton, '열기'));
await t.pump();
// 여는 중 (비밀번호 확인) 이 끝나 다시 잠금 화면이 될 때까지
await until(t, () => find.byType(TextField).evaluate().isNotEmpty);
}
expect(find.textContaining('30초 뒤에 다시'), findsOneWidget);
expect(t.widget<FilledButton>(find.widgetWithText(FilledButton, '열기')).onPressed, isNull);
await t.pump(const Duration(seconds: 31));
await t.enterText(find.byType(TextField), 'pw');
await t.tap(find.widgetWithText(FilledButton, '열기'));
await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty);
expect(find.text('보관함이 비어 있습니다'), findsOneWidget);
// 목록 쓰기 (index.jjv.tmp → index.jjv) 가 끝날 때까지 (화면은 먼저 바뀔 수 있다)
await until(t, () => !Directory(p.join(tmp.path, 'vault')).listSync().any((f) => f.path.endsWith('.tmp')));
await t.pumpWidget(const SizedBox());
});
testWidgets('파일 탐색기: [보관함에 넣기] 는 보관함을 켰을 때 · 파일과 폴더 (2단계)', (t) async {
t.view.physicalSize = const Size(1400, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final dir = Directory(p.join(tmp.path, 'files'))..createSync();
File(p.join(dir.path, 'a.txt')).writeAsStringSync('a');
Directory(p.join(dir.path, 'sub')).createSync();
for (final on in [false, true]) {
final c = ctl()
..settings.vaultEnabled = on
..settings.explorerPaths = [dir.path, dir.path]
..settings.explorerLayout = 'split';
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: c))));
await until(t, () => find.text('a.txt').evaluate().isNotEmpty);
await t.longPress(find.text('a.txt').last);
await settle(t);
expect(find.text('보관함에 넣기'), on ? findsOneWidget : findsNothing, reason: '켬 = $on');
await t.tapAt(const Offset(5, 5));
await settle(t);
if (on) {
await t.longPress(find.text('sub').last);
await settle(t);
expect(find.text('보관함에 넣기'), findsOneWidget, reason: '2단계: 폴더째');
await t.tapAt(const Offset(5, 5));
await settle(t);
}
await t.pumpWidget(const SizedBox());
await t.pump(const Duration(seconds: 1));
}
});
test('앱을 켤 때 남은 보기 폴더 (jj_vault_view_…) 만 지움', () {
final root = Directory(p.join(tmp.path, 'root'))..createSync();
Directory(p.join(root.path, 'jj_vault_view_123')).createSync();
File(p.join(root.path, 'jj_vault_view_123', 'v.mp4')).writeAsStringSync('plain');
Directory(p.join(root.path, 'other')).createSync();
expect(removeStaleVaultViews(root.path), ['jj_vault_view_123']);
expect(Directory(root.path).listSync().map((e) => p.basename(e.path)), ['other']);
});
testWidgets('내용 파일이 빠진 항목 (예전 목록으로 되돌림 등): 꺼내기 → 이유 · [자세히] 원문 · [목록에서 빼기] (확인) - 28 지적 · 관리자 지시 10/21', (t) async {
t.view.physicalSize = const Size(900, 1400);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final dir = Directory(p.join(tmp.path, 'vault'));
final v = (await t.runAsync(() => Vault.create(dir, 'pw', iterations: 1000)))!;
final f = File(p.join(tmp.path, '사라질.pdf'))..writeAsBytesSync(List.filled(100, 3));
final e = (await t.runAsync(() => v.add(f.path)))!;
File(p.join(dir.path, '${e.blob}.jjv')).deleteSync();
await t.pumpWidget(MaterialApp(home: VaultPage(c: ctl())));
await until(t, () => find.widgetWithText(FilledButton, '열기').evaluate().isNotEmpty);
await t.enterText(find.byType(TextField), 'pw');
await t.tap(find.widgetWithText(FilledButton, '열기'));
await until(t, () => find.text('사라질.pdf').evaluate().isNotEmpty);
await menu(t, '사라질.pdf', '꺼내기');
await until(t, () => find.textContaining('보관함 파일이 바뀌었거나 빠졌습니다').evaluate().isNotEmpty);
expect(find.textContaining('보관함 폴더를 다른 곳에서 바꿨는지 확인하세요'), findsOneWidget);
expect(find.textContaining('${e.blob}.jjv'), findsNothing, reason: '원문은 [자세히] 를 눌러야');
await t.tap(find.text('자세히'));
await t.pump();
expect(find.textContaining('${e.blob}.jjv'), findsOneWidget);
expect(Directory(p.join(tmp.path, 'out')).listSync(), isEmpty, reason: '꺼낸 파일 · .jjvtmp 없음');
await t.tap(find.text('목록에서 빼기'));
await settle(t);
await t.tap(find.widgetWithText(FilledButton, '빼기'));
await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty);
expect(find.text('사라질.pdf'), findsNothing);
await until(t, () => !dir.listSync().any((x) => x.path.endsWith('.tmp')));
await t.pumpWidget(const SizedBox());
});
testWidgets('보기: 그림은 메모리에서 (파일로 풀지 않음) · 동영상은 앱 전용 폴더에 잠깐 · 잠그면 열린 보기를 닫고 지움 → 남은 것 0', (t) async {
t.view.physicalSize = const Size(900, 1400);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final viewRoot = Directory(p.join(tmp.path, 'viewroot'));
final wasRoot = vaultViewRootOf;
vaultViewRootOf = () async => viewRoot;
addTearDown(() => vaultViewRootOf = wasRoot);
List<String> leftovers() =>
viewRoot.existsSync() ? viewRoot.listSync(recursive: true).map((e) => e.path).toList() : const <String>[];
final photo = File(p.join(tmp.path, 'photo.png'))..writeAsBytesSync(img.encodePng(img.Image(width: 20, height: 10)));
final clip = File(p.join(tmp.path, 'clip.mp4'))..writeAsBytesSync(List.filled(500, 1));
final c = ctl()..settings.vaultAutoLockSeconds = 0;
await t.pumpWidget(MaterialApp(home: VaultPage(c: c, add: [photo.path, clip.path])));
await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty);
await t.enterText(find.byType(TextField).at(0), 'pw');
await t.enterText(find.byType(TextField).at(1), 'pw');
await t.tap(find.text('비밀번호를 잊으면 되살릴 수 없음을 압니다'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '만들기'));
await until(t, () => find.widgetWithText(FilledButton, '넣기').evaluate().isNotEmpty);
await t.tap(find.widgetWithText(FilledButton, '넣기'));
await until(t, () => find.text('photo.png').evaluate().isNotEmpty && find.text('clip.mp4').evaluate().isNotEmpty);
// 동영상 보기 (시험에는 플레이어가 없어 바로 돌아옴) → 푼 파일은 지워짐
await t.tap(find.text('clip.mp4'));
await until(t, () => find.byType(LinearProgressIndicator).evaluate().isEmpty);
expect(leftovers().where((x) => x.endsWith('.mp4')), isEmpty, reason: '보기를 닫으면 지움');
// 그림 보기: 파일로 풀지 않음
await t.tap(find.text('photo.png'));
await until(t, () => find.byType(ReaderPage).evaluate().isNotEmpty);
expect(find.byType(ReaderPage), findsOneWidget);
expect(leftovers().where((x) => x.endsWith('.png')), isEmpty, reason: '그림은 메모리에서');
// 앱이 뒤로 갔다 돌아옴 (자동 잠금 0초) → 보기 닫고 잠금 · 남은 것 0
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await t.pump();
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await until(t, () => find.byType(ReaderPage).evaluate().isEmpty);
expect(find.byType(ReaderPage), findsNothing, reason: '잠그면 열린 보기를 닫음');
expect(find.widgetWithText(FilledButton, '열기'), findsOneWidget, reason: '잠금 화면');
await until(t, () => leftovers().isEmpty);
expect(leftovers(), isEmpty, reason: '잠그면 잠깐 푼 폴더도 지움');
await t.pumpWidget(const SizedBox());
});
testWidgets('2단계: 폴더째 넣기 (안의 구조 · 빈 폴더 그대로) · 원격 파일 (받아 넣고 임시는 바로 지움) · 폴더 열기 · 새 폴더 · 옮기기 · ← 는 위로 · 폴더째 꺼내기 · 폴더째 지우기', (t) async {
t.view.physicalSize = const Size(900, 1400);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final viewRoot = Directory(p.join(tmp.path, 'viewroot'));
final wasRoot = vaultViewRootOf;
vaultViewRootOf = () async => viewRoot;
addTearDown(() => vaultViewRootOf = wasRoot);
final remote = _Remote({'/r/remote.txt': 'from nas'});
final wasBackend = ShizukuRemote.backend, wasTemp = ShizukuRemote.tempDir;
ShizukuRemote.backend = remote;
ShizukuRemote.tempDir = () async => tmp.path;
addTearDown(() {
ShizukuRemote.backend = wasBackend;
ShizukuRemote.tempDir = wasTemp;
});
final album = Directory(p.join(tmp.path, 'album'));
File(p.join(album.path, 'a.jpg'))
..createSync(recursive: true)
..writeAsStringSync('photo');
File(p.join(album.path, 'sub', 'b.txt'))
..createSync(recursive: true)
..writeAsStringSync('bee');
Directory(p.join(album.path, 'empty')).createSync();
const remotePath = '${shizukuScheme}sys/r/remote.txt';
final c = ctl();
final deleted = <List<String>>[];
await t.pumpWidget(MaterialApp(
home: VaultPage(c: c, add: [album.path, remotePath], deleteOriginals: (paths, {required permanent}) async => deleted.add(paths))));
await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty);
await t.enterText(find.byType(TextField).at(0), 'pw');
await t.enterText(find.byType(TextField).at(1), 'pw');
await t.tap(find.text('비밀번호를 잊으면 되살릴 수 없음을 압니다'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '만들기'));
await until(t, () => find.widgetWithText(FilledButton, '넣기').evaluate().isNotEmpty);
expect(find.text('파일 3개 · 폴더 3개 (폴더는 안의 구조 그대로)'), findsOneWidget);
expect(find.textContaining('받은 임시 파일은 바로 지웁니다'), findsOneWidget);
await t.tap(find.text('원본 지우기 (이동)'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '넣기'));
await until(t, () => deleted.isNotEmpty);
expect(deleted.single, [album.path, remotePath], reason: '다 넣은 것만 원본 지우기로');
expect(find.text('album'), findsOneWidget);
expect(find.text('remote.txt'), findsOneWidget);
expect(find.text('파일 2개'), findsOneWidget, reason: '폴더 줄에 안의 파일 수');
expect(viewRoot.existsSync() ? viewRoot.listSync() : const <FileSystemEntity>[], isEmpty, reason: '원격에서 받은 임시는 지움');
expect(tmp.listSync().where((e) => p.basename(e.path).startsWith('jj_shz_')), isEmpty);
expect(Directory(p.join(tmp.path, 'vault')).listSync().map((f) => p.basename(f.path)).join(), isNot(contains('album')));
// 폴더 열기 → 안의 구조
await t.tap(find.text('album'));
await until(t, () => find.text('a.jpg').evaluate().isNotEmpty);
expect(find.text('보관함 › album'), findsOneWidget);
expect(find.text('sub'), findsOneWidget);
expect(find.text('empty'), findsOneWidget);
// 새 폴더 (지금 폴더 안)
await t.tap(find.descendant(of: find.byType(AppBar), matching: find.byTooltip('더 보기')));
await settle(t);
await t.tap(find.text('새 폴더').last);
await settle(t);
await t.enterText(find.byType(TextField), '새것');
await t.tap(find.widgetWithText(FilledButton, '확인'));
await until(t, () => find.text('새것').evaluate().isNotEmpty);
// 파일 옮기기 → 새것
await menu(t, 'a.jpg', '옮기기');
await settle(t);
await t.tap(find.descendant(of: find.byType(SimpleDialog), matching: find.text('새것')));
await until(t, () => find.text('a.jpg').evaluate().isEmpty);
expect(find.text('파일 1개'), findsWidgets);
// ← 는 위 폴더로 (보관함 화면을 닫지 않음)
await t.binding.handlePopRoute();
await until(t, () => find.text('remote.txt').evaluate().isNotEmpty);
expect(find.byType(VaultPage), findsOneWidget);
// 폴더째 꺼내기: 같은 구조 (빈 폴더 포함)
await menu(t, 'album', '꺼내기 (폴더째)');
final out = p.join(tmp.path, 'out', 'album');
await until(t, () => File(p.join(out, '새것', 'a.jpg')).existsSync() && File(p.join(out, 'sub', 'b.txt')).existsSync());
expect(File(p.join(out, '새것', 'a.jpg')).readAsStringSync(), 'photo');
expect(File(p.join(out, 'sub', 'b.txt')).readAsStringSync(), 'bee');
expect(Directory(p.join(out, 'empty')).existsSync(), isTrue);
// 원격 파일 꺼내기도 같은 내용
await menu(t, 'remote.txt', '꺼내기');
await until(t, () => File(p.join(tmp.path, 'out', 'remote.txt')).existsSync());
expect(File(p.join(tmp.path, 'out', 'remote.txt')).readAsStringSync(), 'from nas');
// 폴더째 지우기 (확인 창에 파일 수)
await menu(t, 'album', '보관함에서 지우기');
await settle(t);
expect(find.textContaining('안의 파일 2개'), findsOneWidget);
await t.tap(find.widgetWithText(FilledButton, '지우기'));
await until(t, () => find.text('album').evaluate().isEmpty);
expect(find.text('remote.txt'), findsOneWidget);
await until(t, () => !Directory(p.join(tmp.path, 'vault')).listSync().any((f) => f.path.endsWith('.tmp')));
await t.pumpWidget(const SizedBox());
});
}
/// 원격 (시스템 폴더 길을 빌림) - 파일 몇 개만
class _Remote implements ShizukuBackend {
final Map<String, String> files;
_Remote(this.files);
@override
Future<({bool running, bool granted})> status() async => (running: true, granted: true);
@override
Future<bool> request() async => true;
@override
Future<String> list(String path) async => '';
@override
Future<String> stat(String path) async =>
files.containsKey(path) ? 'regular file|${files[path]!.length}|1700000000|${p.posix.basename(path)}' : '';
@override
Future<int> copyTo(String path, String dest) async {
File(dest).writeAsStringSync(files[path]!);
return files[path]!.length;
}
}