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 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 = []; 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 = []; 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(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.delayed(const Duration(milliseconds: 20)); } await v.mkdir('나'); await v.mkdir('가'); return v; }); final got = []; Future 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 titles() => [ for (final w in t.widgetList(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.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 = []; 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 until(bool Function() ok) async { for (var i = 0; i < 300 && !ok(); i++) { await t.runAsync(() => Future.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))); }