M3 VaultLocation: 보관함 자리 전역 함수 셋 (vaultDirOf · vaultViewRootOf · vaultPickFolder) 을 주입으로 + 실제 60만 번 시험

- lib/app/vault_location.dart: 보관함 폴더 · [보기] 로 잠깐 푼 파일 자리 · 꺼낼 폴더 고르기. device = 지금과 같은 실제 자리
  (앱 조립 PlatformServices.create 가 씀), under(tempDir) = 앱 임시 폴더 아래 (손으로 조립할 때 처음 값 - 시험이 사용자 보관함 ·
  앱 데이터를 건드리지 않음. 예전 flutter_test_config 의 전역 덮어쓰기를 대신)
- 보관함 화면 · 보기 세션 · 넣기 · 꺼내기 · 켤 때 남은 것 정리 (app_runtime) 가 c.services.vault (또는 넘겨받은 자리) 를 부른다
- KdfParams 점검: 반복 수는 이미 화면 · 만들기에 넘기는 값 (static 아님) - 바꾸지 않음. 실제 반복 수 (60만) 로 만들기 · 다시 열기 ·
  읽기 · 틀린 비밀번호 시험을 vault_modules_test 에 (2회 통과, 4초)
- 시험: vault_location_contract_test (손 조립 처음 값 · 주입 그대로). 보관함 시험 · 캡처는 주입으로
- 사용자가 보게 될 동작: 없음 (자리는 그대로) - 보관함 캡처 앞 · 뒤 두 번씩 0px

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular test-991
RyuJongJin 2026-10-25 14:01:05 +09:00
parent cf69c200a1
commit f1624759ba
14 changed files with 170 additions and 79 deletions

View File

@ -29,6 +29,7 @@ PlatformServices shotServices() {
findLanSmb: r.findLanSmb,
diskSpace: (_) async => (107374182400, 536870912000),
clock: r.clock,
vault: r.vault,
updater: r.updater,
usage: r.usage == null ? null : const FixedUsage(),
models: r.models,

View File

@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/vault_location.dart';
import 'package:jj_mkvmaker/core/vault_store.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
@ -27,12 +28,22 @@ void main() {
Directory(out).createSync(recursive: true);
// 관리자 지시 (10/24): 폴더 이름을 실행마다 같게 (캡처의 "자리:" 줄이 흔들리지 않게 - 시험 쪽에서만)
final tmp = testFixedTempDir('jj_vaultshot_fixed');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 보관함 자리 (M3 VaultLocation 을 주입): 장면마다 [vaultHere] 를 바꾼다
var vaultHere = tmp;
final c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'),
storage: DesktopStorageService(),
vault: VaultLocation(
vaultDir: () async => vaultHere,
viewRoot: () async => Directory(p.join(tmp.path, 'view')),
pickFolder: () async => null,
),
));
c.settings.vault.enabled = true;
final key = GlobalKey();
Future<void> shot(String name, Size size, Directory dir) async {
vaultDirOf = () async => dir;
vaultHere = dir;
t.view.physicalSize = size;
t.view.devicePixelRatio = 1;
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
@ -61,7 +72,7 @@ void main() {
await v.mkdir('빈 폴더');
});
for (final (name, size) in [('open_412', const Size(412, 800)), ('open_960', const Size(960, 600))]) {
vaultDirOf = () async => dir;
vaultHere = dir;
t.view.physicalSize = size;
t.view.devicePixelRatio = 1;
await t.runAsync(() => t.pumpWidget(RepaintBoundary(

View File

@ -150,7 +150,7 @@ class AppController extends ChangeNotifier {
}
// 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들). 앱 시작을 기다리게 하지 않는다 (28 지적: 파일 IO 를
// 기다리면 시험의 가짜 시간 안에서 끝나지 않았다 - 시작을 늦출 이유도 없음)
unawaited(cleanLeftovers(services.storage.tempDirectory, _log));
unawaited(cleanLeftovers(services.storage.tempDirectory, services.vault.viewRoot, _log));
ffmpegVersion = await _tool.version();
if (ffmpegVersion == null) {
_log(tr('⚠ FFmpeg 를 찾을 수 없습니다. MKV 만들기를 사용할 수 없습니다.'));

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import '../../core/ftp.dart' show FtpRegistry;
@ -7,7 +9,7 @@ import '../../core/remote_idle.dart';
import '../../core/sftp.dart' show SftpRegistry;
import '../../core/smb.dart' show SmbRegistry;
import '../../core/languages.dart' show preferredSubtitleSetting;
import '../../core/vault_view.dart' show cleanVaultExportJournal, removeStaleVaultViews, vaultViewRootOf;
import '../../core/vault_view.dart' show cleanVaultExportJournal, removeStaleVaultViews;
import '../../core/webdav.dart';
import '../../l10n/tr.dart';
import '../../services/media_player.dart' show playerSubtitleScale;
@ -34,8 +36,8 @@ void applyRuntimeSettings(AppSettings settings) {
outputRootOverride = (settings.mkv.outputRoot?.isNotEmpty ?? false) ? settings.mkv.outputRoot : null;
}
/// [tempDir]: 앱의 임시 폴더
Future<void> cleanLeftovers(Future<String> Function() tempDir, void Function(String msg) log) async {
/// [tempDir]: 앱의 임시 폴더 · [viewRoot]: 보관함 [보기] 자리 (VaultLocation.viewRoot)
Future<void> cleanLeftovers(Future<String> Function() tempDir, Future<Directory> Function() viewRoot, void Function(String msg) log) async {
// 관리자 지시 (10/20): 강제로 끝나 남은 HWP 보기 작업 폴더 (바꾼 PDF 들)
try {
final hwp = removeStaleHwpWork(await tempDir());
@ -43,12 +45,12 @@ Future<void> cleanLeftovers(Future<String> Function() tempDir, void Function(Str
} catch (_) {}
// 관리자 지시 (10/21): 강제로 꺼져 남은 암호 보관함 보기 폴더 (잠깐 푼 평문)
try {
final vault = removeStaleVaultViews((await vaultViewRootOf()).path);
final vault = removeStaleVaultViews((await viewRoot()).path);
if (vault.isNotEmpty) log(trf('지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개', [vault.length]));
} catch (_) {}
// 관리자 지시 (10/21): 꺼내다 강제로 꺼져 받는 폴더에 남은 평문 "이름.jjvtmp" (기록에 있는 것만)
try {
final left = cleanVaultExportJournal((await vaultViewRootOf()).path);
final left = cleanVaultExportJournal((await viewRoot()).path);
if (left.isNotEmpty) log(trf('지난번에 보관함에서 꺼내다 남은 임시 파일을 지움: {0}개', [left.length]));
} catch (_) {}
}

View File

@ -0,0 +1,51 @@
/// 약속 (M3 VaultLocation): 암호 보관함이 쓰는 자리 셋 - 보관함 폴더 · [보기] 로 잠깐 푼 파일을 두는 곳 · [꺼내기] 받을 폴더 고르기.
/// 입력: 없음 (자리를 묻는 함수 셋). 출력: 폴더 (없으면 부르는 쪽이 만든다) · 고른 폴더 (취소면 null). 오류: 기기 자리를 못 얻으면 그 오류.
/// [VaultLocation.device] = 실제 자리 (앱 조립 PlatformServices.create 가 씀):
/// 보관함 - Android Download/JJ_MKVMaker/vault · Windows 문서\JJ_MKVMaker\vault,
/// 푼 파일 - 앱 전용 (Android getCacheDir · Windows 앱 데이터 폴더의 temp - 공유 폴더가 아님, 관리자 10/21), 고르기 - 파일 고르기 창.
/// [VaultLocation.under] = 앱 임시 폴더 아래 (PlatformServices 를 손으로 조립할 때의 처음 값 - 사용자 보관함 · 앱 데이터를 건드리지 않음).
/// 예전의 바꿀 수 있는 전역 함수 vaultDirOf · vaultViewRootOf · vaultPickFolder 를 대신한다 (설계 M3)
library;
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
class VaultLocation {
/// 보관함 폴더
final Future<Directory> Function() vaultDir;
/// [보기] 로 잠깐 푼 파일 · 꺼내기 기록을 두는 곳
final Future<Directory> Function() viewRoot;
/// [꺼내기] 받을 폴더 고르기 (취소면 null)
final Future<String?> Function() pickFolder;
const VaultLocation({required this.vaultDir, required this.viewRoot, required this.pickFolder});
/// 이 기기의 실제 자리
static final device = VaultLocation(
vaultDir: () async {
if (Platform.isAndroid) {
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot') ?? '/storage/emulated/0';
return Directory(p.join(root, 'Download', 'JJ_MKVMaker', 'vault'));
}
return Directory(p.join((await getApplicationDocumentsDirectory()).path, 'JJ_MKVMaker', 'vault'));
},
viewRoot: () async {
if (Platform.isAndroid) return getTemporaryDirectory();
return Directory(p.join((await getApplicationSupportDirectory()).path, 'temp'));
},
pickFolder: () => FilePicker.getDirectoryPath(),
);
/// [tempDir] (앱 임시 폴더) 아래의 vault · vault_view. 고르기는 없음 (null)
factory VaultLocation.under(Future<String> Function() tempDir) => VaultLocation(
vaultDir: () async => Directory(p.join(await tempDir(), 'vault')),
viewRoot: () async => Directory(p.join(await tempDir(), 'vault_view')),
pickFolder: () async => null,
);
}

View File

@ -1,14 +1,6 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// 암호 보관함 [보기] 에서 잠깐 푼 파일을 두는 곳 (관리자 지시 10/21): 공유 폴더 (Download · 시스템 TEMP) 가 아니라
/// 앱 전용 - Android getCacheDir (다른 앱이 못 봄), Windows 앱 데이터 폴더의 temp. 시험에서 바꾼다
Future<Directory> Function() vaultViewRootOf = () async {
if (Platform.isAndroid) return getTemporaryDirectory();
return Directory(p.join((await getApplicationSupportDirectory()).path, 'temp'));
};
/// 푼 파일을 두는 폴더 이름 앞 (실행마다 새 폴더)
const vaultViewPrefix = 'jj_vault_view_';

View File

@ -6,6 +6,7 @@ import '../platform/common/media_kit_full_player.dart';
import '../platform/common/media_kit_player.dart';
import '../app/ai_upscale.dart' show AiUpscaler;
import '../app/settings.dart';
import '../app/vault_location.dart';
import '../core/download_detect.dart';
import '../core/lan_scan.dart' show LanHost;
import '../platform/common/nllb_translator.dart';
@ -78,6 +79,10 @@ class PlatformServices {
/// 지금 시각 (없으면 실제 시계). 작업 기록의 [시:분:초]
final DateTime Function()? clock;
/// 암호 보관함 자리 (설계 M3). 앱 조립 ([PlatformServices.create]) 은 실제 자리 ([VaultLocation.device]),
/// 손으로 조립하면 처음 값은 앱 임시 폴더 아래 ([VaultLocation.under] - 사용자 보관함 · 앱 데이터를 건드리지 않음)
final VaultLocation vault;
/// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기)
final PlatformInfo platform;
@ -102,7 +107,9 @@ class PlatformServices {
ModelStore? models,
AppShell? shell,
PlatformInfo? platform,
VaultLocation? vault,
}) : models = models ?? ModelStore(),
vault = vault ?? VaultLocation.under(storage.tempDirectory),
platform = platform ?? PlatformInfo.host,
shell = shell ?? NoopShell();
@ -112,6 +119,7 @@ class PlatformServices {
final tool = ProcessMediaTool.locate();
final ffmpegDir = File(tool.ffmpeg).existsSync() ? p.dirname(tool.ffmpeg) : null;
return PlatformServices(
vault: VaultLocation.device,
mediaTool: tool,
storage: DesktopStorageService(),
createPlayer: MediaKitPreviewPlayer.new,
@ -150,6 +158,7 @@ class PlatformServices {
MediaKitPreviewPlayer.ensureInitialized();
final storage = AndroidStorageService();
return PlatformServices(
vault: VaultLocation.device,
mediaTool: FfmpegKitMediaTool(),
storage: storage,
updater: AndroidUpdater(storage),

View File

@ -21,6 +21,7 @@ import '../reader_page.dart' show openReader;
import '../theme.dart';
import 'vault_views.dart';
import 'vault_session.dart';
import '../../app/vault_location.dart';
/// 약속 (보관함 나누기): 보관함 안 파일 하나에 하는 일 - 보기 · 꺼내기 · 지우기 · 이름 변경 · 깨진 항목 알림.
/// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 진행 막대 · 알림) · 항목 [e]
@ -116,8 +117,8 @@ Future<void> vaultBrokenEntry(BuildContext context, VaultUiState s, VaultEntry e
}
/// 꺼내기: [pickFolder] 로 고른 곳에 (이름이 겹치면 " (2)"). 쓰는 동안의 평문 임시 파일은 꺼내기 기록에 남긴다
Future<void> vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e, Future<String?> Function() pickFolder) async {
final dir = await pickFolder();
Future<void> vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e, VaultLocation where) async {
final dir = await where.pickFolder();
if (dir == null || !context.mounted) return;
var out = p.join(dir, e.name);
for (var i = 2; await File(out).exists(); i++) {
@ -125,7 +126,7 @@ Future<void> vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e
}
s.setBusy(true);
try {
final j = VaultExportJournal((await vaultViewRootOf()).path);
final j = VaultExportJournal((await where.viewRoot()).path);
await s.vault!.export(e, out, onTemp: j.add, onTempDone: j.remove);
s.snack(trf('꺼냈습니다: {0}', [vDisplay(out)]));
} catch (err) {
@ -253,8 +254,8 @@ Future<void> vaultRemoveFolder(BuildContext context, VaultUiState s, String f) a
}
/// 폴더째 꺼내기 - [pickFolder] 로 고른 곳에 같은 구조로 (이름이 겹치면 " (2)"). 깨진 파일은 세어 알림 (나머지는 꺼냄)
Future<void> vaultExportFolder(BuildContext context, VaultUiState s, String f, Future<String?> Function() pickFolder) async {
final dir = await pickFolder();
Future<void> vaultExportFolder(BuildContext context, VaultUiState s, String f, VaultLocation where) async {
final dir = await where.pickFolder();
if (dir == null || !context.mounted) return;
final name = f.split('/').last;
var root = p.join(dir, name);
@ -269,7 +270,7 @@ Future<void> vaultExportFolder(BuildContext context, VaultUiState s, String f, F
await Directory(local(sub)).create(recursive: true);
}
final files = s.vault!.filesUnder(f);
final journal = VaultExportJournal((await vaultViewRootOf()).path);
final journal = VaultExportJournal((await where.viewRoot()).path);
for (var i = 0; i < files.length; i++) {
final e = files[i];
if (s.vault == null) break;

View File

@ -59,14 +59,18 @@ String vaultParentOf(String f) => f.contains('/') ? f.substring(0, f.lastIndexOf
String vaultJoin(String parent, String name) => parent.isEmpty ? name : '$parent/$name';
/// 약속 (보관함 나누기): 보관함을 한 번 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…)
/// - [dir]: 처음 부를 때 [vaultViewRootOf] 아래에 새 폴더를 만들고, 그 뒤로는 같은 폴더를 돌려준다
/// - [dir]: 처음 부를 때 [root] (VaultLocation.viewRoot) 아래에 새 폴더를 만들고, 그 뒤로는 같은 폴더를 돌려준다
/// - [clear]: 그 폴더를 지우고 잊는다 (다음 [dir] 은 새 폴더). 만든 적 없으면 아무것도 하지 않는다
/// - 오류: [dir] 은 폴더를 만들지 못하면 그 오류를 던진다. [clear] 는 던지지 않는다 (남으면 다음에 켤 때 지움)
class VaultViewSession {
VaultViewSession(this.root);
/// 푼 파일을 둘 앱 전용 폴더 (VaultLocation.viewRoot)
final Future<Directory> Function() root;
Directory? _session;
Future<Directory> dir() async {
final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}'));
final d = _session ??= Directory(p.join((await root()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}'));
await d.create(recursive: true);
return d;
}
@ -215,7 +219,7 @@ Future<({bool move, bool permanent})?> askVaultAdd(BuildContext context, List<St
/// - 원격은 앱 전용 폴더에 받아 넣고 바로 지운다. 넣은 원본의 썸네일 캐시 ([thumbTemp] 아래) 도 지운다
/// - 출력: 넣은 수 [done] · 다 넣지 못한 고른 것 [bad] (plan.failed · 잠가서 멈춘 뒤 남은 것 포함). 오류는 [VaultUiState.snack] 으로 (던지지 않음)
Future<({int done, Set<String> bad})> putVaultAdd(VaultUiState s, List<String> paths, VaultAddPlan plan,
{required Future<String> Function() thumbTemp}) async {
{required Future<String> Function() thumbTemp, required Future<Directory> Function() viewRoot}) async {
s.setBusy(true);
final bad = <String>{...plan.failed.keys};
var done = 0;
@ -241,7 +245,7 @@ Future<({int done, Set<String> bad})> putVaultAdd(VaultUiState s, List<String> p
final st = await vStat(x.src);
if (isDav(src)) {
// 원격은 앱 전용 폴더 (jj_vault_view_… - 강제로 꺼져 남아도 다음에 켤 때 지움) 에 받고, 넣은 뒤 바로 지운다
tmp ??= await Directory(p.join((await vaultViewRootOf()).path, '${vaultViewPrefix}in_${DateTime.now().microsecondsSinceEpoch}'))
tmp ??= await Directory(p.join((await viewRoot()).path, '${vaultViewPrefix}in_${DateTime.now().microsecondsSinceEpoch}'))
.create(recursive: true);
src = fetched = await vLocalCopy(x.src, tmp.path);
}
@ -275,6 +279,7 @@ Future<({int done, Set<String> bad})> putVaultAdd(VaultUiState s, List<String> p
/// 보관함이 잠겼거나 [paths] 가 비면 아무것도 하지 않는다
Future<void> vaultAddFlow(BuildContext context, VaultUiState s, List<String> paths,
{required Future<String> Function() thumbTemp,
required Future<Directory> Function() viewRoot,
Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals}) async {
if (paths.isEmpty || s.vault == null) return;
s.setBusyProgress(true, tr('넣을 것을 세는 중…'));
@ -283,7 +288,7 @@ Future<void> vaultAddFlow(BuildContext context, VaultUiState s, List<String> pat
s.setBusyProgress(false, null);
final pick = await askVaultAdd(context, paths, plan, cwd: s.cwd, canMove: deleteOriginals != null);
if (pick == null || !context.mounted) return;
final r = await putVaultAdd(s, paths, plan, thumbTemp: thumbTemp);
final r = await putVaultAdd(s, paths, plan, thumbTemp: thumbTemp, viewRoot: viewRoot);
final done = r.done, bad = r.bad;
if (done > 0 || bad.isEmpty) {
s.snack(bad.isEmpty

View File

@ -2,11 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../app/app_controller.dart';
import '../app/master_lock.dart';
@ -26,17 +22,7 @@ Future<void> openVault(BuildContext context, AppController c,
{List<String> add = const [], Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals}) =>
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals)));
/// 보관함 폴더 (Android: Download/JJ_MKVMaker/vault · Windows: 문서\JJ_MKVMaker\vault). 시험에서 바꿈
Future<Directory> Function() vaultDirOf = () async {
if (Platform.isAndroid) {
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot') ?? '/storage/emulated/0';
return Directory(p.join(root, 'Download', 'JJ_MKVMaker', 'vault'));
}
return Directory(p.join((await getApplicationDocumentsDirectory()).path, 'JJ_MKVMaker', 'vault'));
};
/// 꺼낼 폴더 고르기 (시험에서 바꿈)
Future<String?> Function() vaultPickFolder = () => FilePicker.getDirectoryPath();
// 보관함 폴더 · 푼 파일 자리 · 꺼낼 폴더 고르기는 c.services.vault (app/vault_location.dart - 설계 M3, 예전 전역 함수 vaultDirOf · vaultPickFolder)
class VaultPage extends StatefulWidget {
final AppController c;
@ -77,7 +63,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
);
/// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…)
final _session = VaultViewSession();
late final _session = VaultViewSession(widget.c.services.vault.viewRoot);
AppController get c => widget.c;
@ -125,7 +111,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
Future<void> _check() async {
try {
final d = await vaultDirOf();
final d = await c.services.vault.vaultDir();
final has = await Vault.exists(d);
if (mounted) {
setState(() {
@ -248,11 +234,12 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
final paths = _pendingAdd;
if (paths.isEmpty || _s.vault == null) return;
_pendingAdd = [];
await vaultAddFlow(context, _s, paths, thumbTemp: c.services.storage.tempDirectory, deleteOriginals: widget.deleteOriginals);
await vaultAddFlow(context, _s, paths,
thumbTemp: c.services.storage.tempDirectory, viewRoot: c.services.vault.viewRoot, deleteOriginals: widget.deleteOriginals);
}
void _onFolder(String f, String action) => switch (action) {
'export' => vaultExportFolder(context, _s, f, vaultPickFolder),
'export' => vaultExportFolder(context, _s, f, c.services.vault),
'rename' => vaultRenameFolder(context, _s, f),
'move' => vaultMoveFolder(context, _s, f),
'remove' => vaultRemoveFolder(context, _s, f),
@ -261,7 +248,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
void _onEntry(VaultEntry e, String action) => switch (action) {
'view' => vaultViewEntry(context, c, _s, _session, e),
'export' => vaultExportEntry(context, _s, e, vaultPickFolder),
'export' => vaultExportEntry(context, _s, e, c.services.vault),
'rename' => vaultRenameEntry(context, _s, e),
'move' => vaultMoveEntry(context, _s, e),
'remove' => vaultRemoveEntry(context, _s, e),

View File

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/vault_view.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
/// 모든 단위 시험 파일 앞에 돈다 (flutter_test 의 flutter_test_config).
@ -11,8 +10,7 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
final root = Directory.systemTemp.createTempSync('jj_store_');
DesktopStorageService.tempRoot = root.path;
// 암호 보관함 보기 임시 폴더도 (기본은 실제 앱 데이터 폴더 - 시험이 건드리지 않게)
vaultViewRootOf = () async => Directory('${root.path}${Platform.pathSeparator}vault_view');
// 암호 보관함 자리: 시험이 손으로 조립하는 PlatformServices 의 처음 값이 앱 임시 폴더 (위 tempRoot) 아래라 따로 바꾸지 않는다 (M3 VaultLocation)
tearDownAll(() {
try {
root.deleteSync(recursive: true);

View File

@ -0,0 +1,30 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/vault_location.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:path/path.dart' as p;
/// 약속 (M3 VaultLocation): 손으로 조립한 PlatformServices 의 보관함 자리는 앱 임시 폴더 아래 (사용자 보관함 · 앱 데이터를 건드리지 않음) ·
/// 주입한 자리를 그대로 씀 · under 는 고르기 없음. 실제 자리 (device) 는 앱 조립 (create) 만 쓰고, 기기 확인은 보관함 화면 시험이 본다
void main() {
test('손으로 조립하면 처음 값은 앱 임시 폴더 아래 vault · vault_view, 고르기는 없음', () async {
final s = PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService());
final temp = await s.storage.tempDirectory();
expect((await s.vault.vaultDir()).path, p.join(temp, 'vault'));
expect((await s.vault.viewRoot()).path, p.join(temp, 'vault_view'));
expect(await s.vault.pickFolder(), isNull);
});
test('주입한 자리를 그대로 씀', () async {
final where = VaultLocation(
vaultDir: () async => Directory('X:/v'),
viewRoot: () async => Directory('X:/view'),
pickFolder: () async => 'X:/out',
);
final s = PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), vault: where);
expect([(await s.vault.vaultDir()).path, (await s.vault.viewRoot()).path, await s.vault.pickFolder()], ['X:/v', 'X:/view', 'X:/out']);
});
}

View File

@ -14,15 +14,10 @@ import 'package:path/path.dart' as p;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tmp;
final wasView = vaultViewRootOf;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_vaultmod_');
vaultViewRootOf = () async => Directory(p.join(tmp.path, 'view'));
});
tearDown(() async {
vaultViewRootOf = wasView;
await removeTempDirRetry(tmp);
});
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_vaultmod_'));
tearDown(() async => removeTempDirRetry(tmp));
// 보관함 [보기] 자리 (M3 VaultLocation.viewRoot 를 주입)
Future<Directory> viewRoot() async => Directory(p.join(tmp.path, 'view'));
test('vault_session (상태): 폴더 글 · 위 폴더 · 잇기 · 상태를 바꾸면 다시 그림', () {
expect(vaultParentOf('가/나/다'), '가/나');
@ -43,7 +38,7 @@ void main() {
});
test('vault_session (보기 폴더): 한 번 연 동안 같은 폴더 · 지우면 잊고 다음은 새 폴더 · 없는 폴더 지우기는 조용히', () async {
final v = VaultViewSession();
final v = VaultViewSession(viewRoot);
await v.clear(); // 만든 적 없음
final a = await v.dir(), b = await v.dir();
expect(a.path, b.path);
@ -80,7 +75,7 @@ void main() {
final said = <String>[];
final s = VaultUiState(changed: () {}, snack: said.add)..vault = vault;
final thumbs = Directory(p.join(tmp.path, 'thumbs'))..createSync();
final r = await putVaultAdd(s, [one.path, dir.path, missing], plan, thumbTemp: () async => thumbs.path);
final r = await putVaultAdd(s, [one.path, dir.path, missing], plan, thumbTemp: () async => thumbs.path, viewRoot: viewRoot);
expect(r.done, 3);
expect(r.bad, {missing}, reason: '넣지 못한 것만 (그 원본은 화면이 지우지 않음)');
expect(said, hasLength(1), reason: '처음 실패만 알림');
@ -92,7 +87,7 @@ void main() {
// 보관함이 잠겨 있으면 (vault = null) 하나도 넣지 않고 멈춘다
final p3 = VaultAddPlan()..files.add((src: one.path, folder: '', top: one.path));
final s3 = VaultUiState(changed: () {}, snack: said.add)..vault = null;
final r3 = await putVaultAdd(s3, [one.path], p3, thumbTemp: () async => thumbs.path);
final r3 = await putVaultAdd(s3, [one.path], p3, thumbTemp: () async => thumbs.path, viewRoot: viewRoot);
expect(r3.done, 0);
});
@ -188,7 +183,7 @@ void main() {
return const Scaffold();
})));
var finished = false;
unawaited(vaultAddFlow(ctx, s, paths, thumbTemp: () async => p.join(tmp.path, 'thumbs'), deleteOriginals: (ok, {required permanent}) async {
unawaited(vaultAddFlow(ctx, s, paths, thumbTemp: () async => p.join(tmp.path, 'thumbs'), viewRoot: viewRoot, deleteOriginals: (ok, {required permanent}) async {
deleted.addAll(ok);
for (final x in ok) {
File(x).deleteSync(); // 실제로 지운다 (탐색기의 지우기 대신)
@ -216,4 +211,17 @@ void main() {
expect(f.readAsBytesSync(), bytes[n], reason: '$n 바이트 그대로');
}
});
// 설계 M3 (KdfParams): 반복 수는 화면 · 만들기에 넘기는 값 (처음 값 VaultCrypto.defaultIterations = 60만). 실제 반복 수로 한 번 -
// 실제 시험 폴더에 만들고 → 넣고 → 닫았다가 비밀번호로 다시 열어 → 같은 바이트 (다른 시험은 빨리 하려고 1000번)
test('실제 반복 수 (60만 번) 로 만들기 · 다시 열기 · 읽기 1회', () async {
final dir = Directory(p.join(tmp.path, 'real'));
final src = File(p.join(tmp.path, 'a.bin'))..writeAsBytesSync(List.generate(5000, (i) => i % 256));
final v = await Vault.create(dir, 'pw-real');
await v.add(src.path);
final again = await Vault.unlock(dir, 'pw-real');
expect(again.entries.single.name, 'a.bin');
expect(await again.readBytes(again.entries.single), src.readAsBytesSync());
await expectLater(Vault.unlock(dir, 'wrong'), throwsA(anything), reason: '틀린 비밀번호는 열리지 않음');
}, timeout: const Timeout(Duration(minutes: 3)));
}

View File

@ -16,6 +16,7 @@ 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): 만들기 창의 알림 · 넣기 (복사 / 원본 지우기) · 잠금 · 틀린 비밀번호 ·
/// 꺼내기 · 지우기 · [마스터로도 열기] (마스터를 초기화하면 그 길만 없어짐)
@ -24,17 +25,18 @@ const _fastIterations = 1000;
void main() {
late Directory tmp;
final was = vaultDirOf;
final wasPick = vaultPickFolder;
// 보관함 자리 (M3 VaultLocation 을 주입): 보관함 · [보기] 자리 · 꺼낼 폴더 모두 시험 폴더
late VaultLocation where;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_vaultui_');
vaultDirOf = () async => Directory(p.join(tmp.path, 'vault'));
vaultPickFolder = () async => p.join(tmp.path, 'out');
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 {
vaultDirOf = was;
vaultPickFolder = wasPick;
MasterLock.instance = null;
// Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있을 수 있다 (errno 32) - 몇 번 더
for (var i = 0; ; i++) {
@ -49,7 +51,7 @@ void main() {
});
AppController ctl() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
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;
@ -297,10 +299,7 @@ void main() {
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 viewRoot = Directory(p.join(tmp.path, 'viewroot')); // where.viewRoot
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)));
@ -345,10 +344,7 @@ void main() {
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 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;