441 lines
24 KiB
Dart
441 lines
24 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/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;
|
||
}
|
||
}
|