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/ui/explorer_page.dart'; 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(() { vaultDirOf = was; vaultPickFolder = wasPick; MasterLock.instance = null; tmp.deleteSync(recursive: true); }); AppController ctl() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.vaultEnabled = true; return c; } Future until(WidgetTester t, bool Function() ok, {int rounds = 200}) async { for (var i = 0; i < rounds && !ok(); i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await t.pump(); } // 닫히는 창 · 메뉴의 막 (barrier) 이 사라질 때까지 for (var i = 0; i < 8; i++) { await t.pump(const Duration(milliseconds: 50)); } } /// 진행 막대 · 글자 칸 깜빡임이 있으면 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 c = ctl(); await t.pumpWidget(MaterialApp( home: VaultPage(c: c, add: [src.path], deleteOriginals: (paths) async => deleted.add(paths)))); 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(); await t.tap(find.widgetWithText(FilledButton, '넣기')); await until(t, () => deleted.isNotEmpty); expect(deleted.single, [src.path], 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); 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))); 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(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); 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('보관함에 넣기'), findsNothing, reason: '폴더는 아직'); await t.tapAt(const Offset(5, 5)); await settle(t); } await t.pumpWidget(const SizedBox()); await t.pump(const Duration(seconds: 1)); } }); }