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; import 'package:jj_mkvmaker/app/vault_location.dart'; /// X-plore 비교 5-2 암호 보관함 화면 (관리자 결정 10/21): 만들기 창의 알림 · 넣기 (복사 / 원본 지우기) · 잠금 · 틀린 비밀번호 · /// 꺼내기 · 지우기 · [마스터로도 열기] (마스터를 초기화하면 그 길만 없어짐) /// 시험의 PBKDF2 반복 수 (빨리 - 실제 암호화 그대로) const _fastIterations = 1000; void main() { late Directory tmp; // 보관함 자리 (M3 VaultLocation 을 주입): 보관함 · [보기] 자리 · 꺼낼 폴더 모두 시험 폴더 late VaultLocation where; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_vaultui_'); where = VaultLocation( vaultDir: () async => Directory(p.join(tmp.path, 'vault')), viewRoot: () async => Directory(p.join(tmp.path, 'viewroot')), pickFolder: () async => p.join(tmp.path, 'out'), ); Directory(p.join(tmp.path, 'out')).createSync(); }); tearDown(() async { 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.delayed(const Duration(milliseconds: 250)); } } }); AppController ctl() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), vault: where)); c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander c.settings.vault.enabled = true; return c; } /// 실제 시간으로 기다린다 (암호화 · 파일 쓰기는 실제 IO - 시험을 여럿 함께 돌려 느릴 때 횟수로 끊으면 일이 덜 끝난 채 넘어가, /// 시험이 끝난 뒤 남은 쓰기가 파일을 연 채 멈춰 임시 폴더를 지우지 못했다 - errno 32). 끝내 안 되면 그 자리에서 실패 Future until(WidgetTester t, bool Function() ok, {Duration limit = const Duration(seconds: 60)}) async { final w = Stopwatch()..start(); while (!ok()) { if (w.elapsed > limit) fail('기다린 일이 ${limit.inSeconds}초 안에 끝나지 않음'); await t.runAsync(() => Future.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)); } } /// 보관함의 쓰기가 다 끝날 때까지: 쓰는 중인 파일 (.tmp) 이 없는 것이 10번 잇달아일 때 (사이마다 pump). /// 한 번만 보면 목록 저장 → 빈 폴더 목록 저장 사이에 맞아, 남은 쓰기가 파일을 연 채 시험이 끝났다 (errno 32) Future vaultQuiet(WidgetTester t) async { var calm = 0; await until(t, () { final busy = Directory(p.join(tmp.path, 'vault')).listSync().any((f) => f.path.endsWith('.tmp')); calm = busy ? 0 : calm + 1; return calm >= 10; }); } /// 진행 막대 · 글자 칸 깜빡임이 있으면 pumpAndSettle 이 끝나지 않는다 - 정한 만큼만 Future settle(WidgetTester t) async { for (var i = 0; i < 12; i++) { await t.pump(const Duration(milliseconds: 50)); } } Future 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))); 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 = >[]; final permanents = []; final c = ctl(); await t.pumpWidget(MaterialApp( home: VaultPage(iterations: _fastIterations, 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(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(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 vaultQuiet(t); 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(iterations: _fastIterations, 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))); 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.vault.masterCopy); 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(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 vaultQuiet(t); 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.vault.enabled = on ..settings.explorer.paths = [dir.path, dir.path] ..settings.explorer.layout = '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(iterations: _fastIterations, 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')); // where.viewRoot List leftovers() => viewRoot.existsSync() ? viewRoot.listSync(recursive: true).map((e) => e.path).toList() : const []; 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.vault.autoLockSeconds = 0; await t.pumpWidget(MaterialApp(home: VaultPage(iterations: _fastIterations, 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')); // where.viewRoot 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 = >[]; await t.pumpWidget(MaterialApp( home: VaultPage(iterations: _fastIterations, 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 [], 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 vaultQuiet(t); await t.pumpWidget(const SizedBox()); }); } /// 원격 (시스템 폴더 길을 빌림) - 파일 몇 개만 class _Remote implements ShizukuBackend { final Map files; _Remote(this.files); @override Future<({bool running, bool granted})> status() async => (running: true, granted: true); @override Future request() async => true; @override Future list(String path) async => ''; @override Future stat(String path) async => files.containsKey(path) ? 'regular file|${files[path]!.length}|1700000000|${p.posix.basename(path)}' : ''; @override Future copyTo(String path, String dest) async { File(dest).writeAsStringSync(files[path]!); return files[path]!.length; } }