JJ_MKVMaker/test/vault_modules_test.dart

228 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/vault_store.dart';
import 'package:jj_mkvmaker/core/vault_view.dart';
import 'package:jj_mkvmaker/ui/vault/vault_session.dart';
import 'package:jj_mkvmaker/ui/vault/vault_views.dart';
import 'package:path/path.dart' as p;
/// 보관함 나누기 (vault_page 1,095줄 → lib/ui/vault/) 의 모듈 약속 시험. 실제 시험 폴더 · 실제 보관함 (PBKDF2 1000번) 만 쓴다.
/// 화면 전체 흐름 (만들기 · 열기 · 넣기 · 보기 · 꺼내기 · 폴더 · 열쇠) 은 vault_page_test 가 본다
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory 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('가/나/다'), '가/나');
expect(vaultParentOf('가'), '');
expect(vaultJoin('', '가'), '가');
expect(vaultJoin('가', '나'), '가/나');
expect(vaultFolderLabel('가/나'), contains('가 › 나'));
var n = 0;
final said = <String>[];
final s = VaultUiState(changed: () => n++, snack: said.add);
s.setBusy(true);
s.setProgress('1 / 2');
expect((s.busy, s.progress, n), (true, '1 / 2', 2));
s.setBusy(false);
expect(s.progress, '1 / 2', reason: 'setBusy 는 진행 글을 그대로 둔다');
s.setBusyProgress(false, null);
expect((s.busy, s.progress, n), (false, null, 4));
});
test('vault_session (보기 폴더): 한 번 연 동안 같은 폴더 · 지우면 잊고 다음은 새 폴더 · 없는 폴더 지우기는 조용히', () async {
final v = VaultViewSession(viewRoot);
await v.clear(); // 만든 적 없음
final a = await v.dir(), b = await v.dir();
expect(a.path, b.path);
expect(p.basename(a.path), startsWith(vaultViewPrefix));
expect(p.dirname(a.path), p.join(tmp.path, 'view'));
File(p.join(a.path, 'x.bin')).writeAsBytesSync([1, 2, 3]);
await v.clear();
expect(a.existsSync(), isFalse);
final c = await v.dir();
expect(c.path == a.path, isFalse);
await removeTempDirRetry(Directory(p.join(tmp.path, '없음')));
});
test('vault_session (넣기): 펼치기 (폴더 구조 · 겹치는 이름 " (2)" · 읽지 못한 것은 빼고) → 넣기 → 잠그면 멈춤', () async {
final vault = await Vault.create(Directory(p.join(tmp.path, 'vault')), 'pw-for-test', iterations: 1000);
final src = Directory(p.join(tmp.path, 'src'))..createSync();
final one = File(p.join(src.path, 'a.txt'))..writeAsStringSync('하나');
final dir = Directory(p.join(src.path, '사진'))..createSync();
File(p.join(dir.path, 'b.txt')).writeAsStringSync('둘');
Directory(p.join(dir.path, '안')).createSync();
File(p.join(dir.path, '안', 'c.txt')).writeAsStringSync('셋');
Directory(p.join(dir.path, '빈')).createSync();
await vault.mkdir('사진'); // 맨 위에 같은 이름이 이미 있음
final missing = p.join(src.path, '없는 폴더');
Directory(missing).createSync();
Directory(missing).deleteSync();
final plan = await planVaultAdd(vault, '', [one.path, dir.path, missing]);
expect(plan.folders.first, '사진 (2)');
expect(plan.folders, unorderedEquals(['사진 (2)', '사진 (2)/안', '사진 (2)/빈']), reason: '안의 순서는 폴더 목록 순서');
// 없는 것은 폴더가 아니라 파일로 보여 펼치기에서는 실패하지 않는다 - 넣기에서 실패해 bad 에 남는다
expect([for (final x in plan.files) '${x.folder}|${p.basename(x.src)}'],
unorderedEquals(['|a.txt', '사진 (2)|b.txt', '사진 (2)/안|c.txt', '|없는 폴더']));
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, viewRoot: viewRoot);
expect(r.done, 3);
expect(r.bad, {missing}, reason: '넣지 못한 것만 (그 원본은 화면이 지우지 않음)');
expect(said, hasLength(1), reason: '처음 실패만 알림');
expect((s.busy, s.progress), (false, null));
expect([for (final e in vault.filesUnder('사진 (2)')) e.path], unorderedEquals(['사진 (2)/b.txt', '사진 (2)/안/c.txt']));
expect(vault.folders, contains('사진 (2)/빈'));
expect(one.existsSync(), isTrue, reason: '넣기는 원본을 지우지 않는다 (지우기는 화면이 deleteOriginals 로)');
// 보관함이 잠겨 있으면 (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, viewRoot: viewRoot);
expect(r3.done, 0);
});
testWidgets('vault_views (묻는 창): 옮길 곳은 자기와 그 안을 빼고 · 지금 있는 곳은 꺼짐 · 이름 창의 빈 칸은 null', (t) async {
String? picked = 'x', named = 'x';
late BuildContext ctx;
await t.pumpWidget(MaterialApp(home: Builder(builder: (c) {
ctx = c;
return const Scaffold();
})));
final f = pickVaultTarget(ctx, ['가', '가/나', '다'], except: '가', from: '');
await t.pumpAndSettle();
expect(find.text('나'), findsNothing);
expect(find.text('가'), findsNothing);
expect(t.widget<ListTile>(find.widgetWithText(ListTile, '보관함 맨 위')).enabled, isFalse);
await t.tap(find.text('다'));
await t.pumpAndSettle();
picked = await f;
expect(picked, '다');
final n = askVaultName(ctx, '새 폴더');
await t.pumpAndSettle();
await t.tap(find.text('확인'));
await t.pumpAndSettle();
named = await n;
expect(named, isNull);
});
testWidgets('vault_views (목록): 폴더 먼저 (이름 순) · 파일은 정렬 따라 · 바쁘면 누르기 꺼짐 · 누른 것은 콜백으로', (t) async {
final vault = await t.runAsync(() async {
final v = await Vault.create(Directory(p.join(tmp.path, 'vault')), 'pw-for-test', iterations: 1000);
final src = Directory(p.join(tmp.path, 'src'))..createSync();
for (final n in ['A.txt', 'b.txt']) {
File(p.join(src.path, n)).writeAsStringSync(n);
await v.add(p.join(src.path, n), name: n, folder: '');
await Future<void>.delayed(const Duration(milliseconds: 20));
}
await v.mkdir('나');
await v.mkdir('가');
return v;
});
final got = <String>[];
Future<void> show(String sort, {bool busy = false}) => t.pumpWidget(MaterialApp(
home: Scaffold(
body: VaultListView(
vault: vault!,
cwd: '',
sort: sort,
busy: busy,
onCwd: (f) => got.add('cwd $f'),
onSort: (x) => got.add('sort $x'),
onFolder: (f, w) => got.add('$w $f'),
onEntry: (e, w) => got.add('$w ${e.name}')))));
List<String> titles() => [
for (final w in t.widgetList<ListTile>(find.byType(ListTile))) ((w.title as Text).data ?? ''),
];
await show('name');
expect(titles(), ['가', '나', 'A.txt', 'b.txt']);
await show('added');
expect(titles(), ['가', '나', 'b.txt', 'A.txt'], reason: '넣은 날 새것 먼저 (b 를 나중에 넣음)');
await t.tap(find.text('가'));
await t.tap(find.text('b.txt'));
expect(got, ['cwd 가', 'view b.txt']);
await show('added', busy: true);
await t.tap(find.text('나'));
expect(got, hasLength(2), reason: '바쁜 동안은 누르기가 꺼짐');
});
testWidgets('vault_session (넣기): [원본 지우기 (이동)] 로 넣다가 중간에 잠그면 넣은 것만 원본을 지우고, 넣지 못한 원본은 바이트까지 그대로', (t) async {
final src = Directory(p.join(tmp.path, 'src'));
final names = ['1.bin', '2.bin', '3.bin', '4.bin'];
final bytes = {for (final n in names) n: List<int>.generate(4096, (i) => (i * 7 + n.codeUnitAt(0)) % 256)};
final vault = await t.runAsync(() async {
src.createSync();
for (final n in names) {
File(p.join(src.path, n)).writeAsBytesSync(bytes[n]!);
}
Directory(p.join(tmp.path, 'thumbs')).createSync();
return Vault.create(Directory(p.join(tmp.path, 'vault')), 'pw-for-test', iterations: 1000);
});
final paths = [for (final n in names) p.join(src.path, n)];
late VaultUiState s;
// 화면의 잠그기와 같게: 열쇠를 버린다 (vault = null). "3 / 4" 를 보이는 때 = 1 · 2 를 넣은 뒤
s = VaultUiState(
changed: () {
if (s.progress?.startsWith('3 / ') ?? false) s.vault = null;
},
snack: (_) {})
..vault = vault;
final deleted = <String>[];
late BuildContext ctx;
await t.pumpWidget(MaterialApp(home: Builder(builder: (c) {
ctx = c;
return const Scaffold();
})));
var finished = false;
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(); // 실제로 지운다 (탐색기의 지우기 대신)
}
}).whenComplete(() => finished = true));
Future<void> until(bool Function() ok) async {
for (var i = 0; i < 300 && !ok(); i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await t.pump(const Duration(milliseconds: 20));
}
}
await until(() => find.text('원본 지우기 (이동)').evaluate().isNotEmpty);
await t.tap(find.text('원본 지우기 (이동)'));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '넣기'));
await until(() => finished);
expect(finished, isTrue);
expect(s.vault, isNull, reason: '중간에 잠금');
expect([for (final e in vault!.entries) e.name], unorderedEquals(['1.bin', '2.bin']), reason: '잠그기 전 둘만 들어감');
expect(deleted, unorderedEquals([paths[0], paths[1]]), reason: '넣은 것만 원본 지우기로');
for (final n in ['3.bin', '4.bin']) {
final f = File(p.join(src.path, n));
expect(f.existsSync(), isTrue, reason: '넣지 못한 원본 $n 은 남음');
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)));
}