284 lines
16 KiB
Dart
284 lines
16 KiB
Dart
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/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(() {
|
|
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<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('보관함에 넣기'), findsNothing, reason: '폴더는 아직');
|
|
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('보기: 그림은 메모리에서 (파일로 풀지 않음) · 동영상은 앱 전용 폴더에 잠깐 · 잠그면 열린 보기를 닫고 지움 → 남은 것 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());
|
|
});
|
|
}
|