From 47144981d0dc19e5d98395a832531f7c61ac4bff Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:55:42 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2087=20=EC=A0=80=EC=9E=A5=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=20=EB=B6=84=EC=84=9D:=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=C2=B7=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EC=9D=98=20=ED=81=B0=20=ED=8F=B4=EB=8D=94=20=C2=B7=20?= =?UTF-8?q?=ED=81=B0=20=ED=8C=8C=EC=9D=BC=20=EC=B0=A8=EB=A1=80,=20?= =?UTF-8?q?=EB=88=84=EB=A5=B4=EB=A9=B4=20=ED=83=90=EC=83=89=EA=B8=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B7=B8=20=EC=9E=90=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 지시: 고른 폴더나 드라이브의 큰 폴더 · 큰 파일 차례. 80 의 폴더 크기 규칙 (링크 · junction 은 따라가지 않고 수만, 이미 센 폴더 (실제 경로) 는 다시 세지 않음, 취소) 을 그대로. - 여는 곳: 폴더 길게 누르기 [저장 공간 분석], ⋮ 메뉴 [저장 공간 분석] (지금 폴더 - 드라이브 맨 위면 드라이브 전체). - 한 번 훑으며 폴더마다 크기 (안쪽 합) · 파일 수와 큰 파일 200개를 모은다 → 폴더를 누르면 그 안으로 바로 (다시 읽지 않음), [위 폴더] · ← 로 나옴. "큰 폴더" (막대 = 지금 폴더 안 비율) · "큰 파일" (지금 폴더 아래). 읽지 못한 폴더 수 · 세지 않은 링크 수를 알린다. 읽는 동안 폴더 수 · 크기, [취소] (화면을 닫아도 멈춤). - 누르면 (파일) · [그 자리로] (폴더) 탐색기에서 그 자리로 - 지우기는 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단). - WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 먼저 "서버에서 모두 읽어 오래 걸릴 수 있습니다" 를 묻는다. - 시험: storage_scan_test (합 · 차례 · junction (위 폴더 고리 포함) 을 세지 않음 · 취소 · 화면 들어가기 · 큰 파일 · 돌려주는 경로), explorer_test 87 (길게 누르기 → 큰 파일 → 탐색기 그 자리). Co-Authored-By: Claude Opus 5.5 --- lib/core/storage_scan.dart | 115 ++++++++++++++++++++ lib/ui/explorer_page.dart | 39 ++++++- lib/ui/storage_page.dart | 210 ++++++++++++++++++++++++++++++++++++ test/explorer_test.dart | 17 +++ test/storage_scan_test.dart | 84 +++++++++++++++ 5 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 lib/core/storage_scan.dart create mode 100644 lib/ui/storage_page.dart create mode 100644 test/storage_scan_test.dart diff --git a/lib/core/storage_scan.dart b/lib/core/storage_scan.dart new file mode 100644 index 0000000..70b03ee --- /dev/null +++ b/lib/core/storage_scan.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'file_ops.dart'; +import 'folder_size.dart'; +import 'vfs.dart'; + +/// 87: 저장 공간 분석 결과 - 폴더마다 크기 (안쪽 모두 합) · 큰 파일 차례. +/// 80 의 폴더 크기 규칙을 그대로: 링크 · junction 은 따라가지 않고 (수만), 이미 센 폴더 (실제 경로) 는 다시 세지 않고, 취소된다 +class StorageScan { + final String root; + + /// 폴더 → (크기, 파일 수). 열쇠는 [key] 로 + final Map folders; + + /// 큰 파일 (큰 것부터, [StorageScanner.topFiles] 개까지) + final List bigFiles; + + /// 폴더 → 바로 안의 폴더들 (차례 없음) + final Map> children; + + /// 읽지 못한 폴더 수 (권한 등 - 크기가 실제보다 작을 수 있음) · 세지 않은 링크 수 + final int unreadable, links; + const StorageScan(this.root, this.folders, this.bigFiles, this.children, {this.unreadable = 0, this.links = 0}); + + static String key(String path) => isDav(path) ? vNorm(path) : path.toLowerCase(); + + int sizeOf(String dir) => folders[key(dir)]?.$1 ?? 0; + int filesOf(String dir) => folders[key(dir)]?.$2 ?? 0; + + /// [dir] 바로 안의 폴더를 큰 것부터 + List biggestIn(String dir) => [...?children[key(dir)]]..sort((a, b) => sizeOf(b).compareTo(sizeOf(a))); +} + +/// 87: 고른 폴더 (또는 드라이브) 아래를 한 번 훑어 [StorageScan] 을 만든다 +class StorageScanner { + /// 읽기 (시험에서 바꿔 끼움) - 80 과 같이 링크는 빼고 수만 + final Future Function(String dir) list; + final int topFiles; + StorageScanner({Future Function(String dir)? list, this.topFiles = 200}) : list = list ?? FolderSizes.listForSize; + + /// [onProgress]: (읽은 폴더 수, 지금까지 크기). [cancelled] 가 참이면 [StorageScanCancelled] + Future scan(String root, {void Function(int dirs, int bytes)? onProgress, bool Function()? cancelled}) async { + final key = StorageScan.key; + final own = {}; // 폴더 바로 안의 파일 크기 · 수 + final children = >{}; + final parentOf = {}; + final order = []; // 읽은 차례 (위 → 아래) + final seen = {}; + var big = []; + var bytes = 0, unreadable = 0, links = 0; + final pending = [root]; + while (pending.isNotEmpty) { + if (cancelled?.call() ?? false) throw const StorageScanCancelled(); + final dir = pending.removeLast(); + var real = dir; + if (!isDav(dir)) { + try { + real = Directory(dir).resolveSymbolicLinksSync(); + } catch (_) {} + } + if (!seen.add(key(real))) continue; + order.add(dir); + var size = 0, count = 0; + try { + final r = await list(dir); + links += r.links; + for (final e in r.entries) { + if (e.isDir) { + pending.add(e.path); + children.putIfAbsent(key(dir), () => []).add(e.path); + parentOf[key(e.path)] = key(dir); + } else { + final s = e.size > 0 ? e.size : 0; + size += s; + count++; + if (big.length < topFiles || s > big.last.size) { + big.add(e); + big.sort((a, b) => b.size.compareTo(a.size)); + if (big.length > topFiles) big = big.sublist(0, topFiles); + } + } + } + } catch (_) { + unreadable++; + } + own[key(dir)] = (size, count); + bytes += size; + if (order.length % 50 == 0) onProgress?.call(order.length, bytes); + // 큰 폴더를 오래 읽을 때도 화면이 멈추지 않게 + if (order.length % 200 == 0) await Future.delayed(Duration.zero); + } + // 아래부터 위로 더한다 + final total = {for (final e in own.entries) e.key: e.value}; + for (final d in order.reversed) { + final k = key(d); + final up = parentOf[k]; + if (up == null || !total.containsKey(up)) continue; + final (s, n) = total[k]!; + final (us, un) = total[up]!; + total[up] = (us + s, un + n); + } + onProgress?.call(order.length, bytes); + return StorageScan(root, total, big, children, unreadable: unreadable, links: links); + } +} + +class StorageScanCancelled implements Exception { + const StorageScanCancelled(); +} + +/// 이름만 (화면 줄) +String scanName(String path) => isDav(path) ? vBasename(path) : (p.basename(path).isEmpty ? path : p.basename(path)); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index f763cd1..d0f9288 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -47,6 +47,7 @@ import 'explorer_search.dart'; import 'quick_look.dart'; import 'explorer_drag.dart'; import 'archive_ui.dart'; +import 'storage_page.dart'; import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf; import 'batch_rename_dialog.dart'; import '../core/folder_size.dart'; @@ -161,7 +162,9 @@ enum ExplorerButton { // 118: 두 창을 좌우 ⇆ / 위아래 ⇅ 로 한 번에 바꾸기 (한 창이면 두 창 (좌우) 으로) orient(Icons.swap_horiz, '좌우 ⇆ / 위아래 ⇅'), layout(Icons.view_quilt_outlined, '창 배치'), - buttons(Icons.tune, '버튼 구성'); + buttons(Icons.tune, '버튼 구성'), + // 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일 + storage(Icons.pie_chart_outline, '저장 공간 분석'); final IconData icon; final String label; @@ -1334,6 +1337,33 @@ class _ExplorerPageState extends State { } /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 + /// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만). + /// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다 + Future _storage(_Pane pane, String dir) async { + setState(() => _active = _panes.indexOf(pane)); + if (isDav(dir)) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('저장 공간 분석')), + content: Text(tr('WebDAV 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('분석'))), + ], + ), + ); + if (ok != true || !mounted) return; + } + final path = await StorageAnalysisPage.open(context, dir); + if (path == null || !mounted) return; + final isDir = await vIsDir(path); + await _goTo(pane, isDir ? path : vDirname(path), fresh: true); + pane.focused = path; + pane.changed(); + _reveal(pane, path); + } + /// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침 Future _extractArchive(String path, _Pane pane) async { final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; @@ -1412,6 +1442,8 @@ class _ExplorerPageState extends State { // 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두) if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')), if (!dav) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')), + // 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일) + if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')), // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), @@ -1503,6 +1535,8 @@ class _ExplorerPageState extends State { await _quickLook(pane, e.path); case 'extract': await _extractArchive(e.path, pane); + case 'storage': + await _storage(pane, e.path); case 'zip': final items = outermostPaths(_isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]); final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; @@ -1719,6 +1753,8 @@ class _ExplorerPageState extends State { await _delete(pane, _targets(pane)); case ExplorerButton.search: await _search(pane); + case ExplorerButton.storage: + await _storage(pane, pane.current); case ExplorerButton.sort: await _sortDialog(); case ExplorerButton.hidden: @@ -3373,6 +3409,7 @@ class _ExplorerPageState extends State { if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons], ExplorerButton.sort, ExplorerButton.hidden, + if (!widget.rsync) ExplorerButton.storage, ]) PopupMenuItem( value: b, diff --git a/lib/ui/storage_page.dart b/lib/ui/storage_page.dart new file mode 100644 index 0000000..3dcf49c --- /dev/null +++ b/lib/ui/storage_page.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; + +import '../core/file_ops.dart'; +import '../core/storage_scan.dart'; +import '../core/vfs.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례. +/// 폴더를 누르면 그 안으로 (한 번 훑은 결과로 바로), [그 자리로] 는 탐색기에서 그 자리로 (돌려주는 값). +/// 지우기는 여기서 하지 않는다 - 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단) +class StorageAnalysisPage extends StatefulWidget { + final String root; + + /// 시험에서 바꿔 끼움 + final StorageScanner? scanner; + const StorageAnalysisPage({super.key, required this.root, this.scanner}); + + /// 연다. 탐색기에서 갈 자리 (없으면 null) + static Future open(BuildContext context, String root) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: root))); + + @override + State createState() => _StorageAnalysisPageState(); +} + +class _StorageAnalysisPageState extends State { + StorageScan? _scan; + Object? _error; + var _dirs = 0, _bytes = 0; + var _stop = false; + late String _cur = widget.root; + var _tab = 0; // 0 큰 폴더 · 1 큰 파일 + + @override + void initState() { + super.initState(); + _run(); + } + + @override + void dispose() { + _stop = true; // 화면을 닫으면 멈춘다 + super.dispose(); + } + + Future _run() async { + try { + final s = await (widget.scanner ?? StorageScanner()).scan(widget.root, cancelled: () => _stop, onProgress: (d, b) { + if (mounted) { + setState(() { + _dirs = d; + _bytes = b; + }); + } + }); + if (mounted) setState(() => _scan = s); + } on StorageScanCancelled { + // 닫음 + } catch (e) { + if (mounted) setState(() => _error = e); + } + } + + Widget _bar(double v) => ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator(value: v.clamp(0, 1), minHeight: 6, color: JjColors.accent, backgroundColor: JjColors.border), + ); + + @override + Widget build(BuildContext context) { + final s = _scan; + final canUp = s != null && !samePath(_cur, widget.root); + return PopScope( + canPop: !canUp, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && canUp) setState(() => _cur = vDirname(_cur)); + }, + child: Scaffold( + appBar: AppBar( + title: Text(tr('저장 공간 분석')), + actions: [ + if (s == null && _error == null) + TextButton( + onPressed: () { + _stop = true; + Navigator.pop(context); + }, + child: Text(tr('취소')), + ), + ], + ), + body: s == null + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + if (_error == null) const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(_error != null ? trf('분석하지 못했습니다: {0}', [_error!]) : trf('읽는 중 · 폴더 {0}개 · {1}', [_dirs, formatSize(_bytes)]), + textAlign: TextAlign.center), + const SizedBox(height: 4), + Text(vDisplay(widget.root), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + ), + ) + : Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row(children: [ + if (canUp) + IconButton( + tooltip: tr('위 폴더'), + icon: const Icon(Icons.arrow_upward), + onPressed: () => setState(() => _cur = vDirname(_cur)), + ), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(vDisplay(_cur), maxLines: 2, overflow: TextOverflow.ellipsis), + Text(trf('{0} · 파일 {1}개', [formatSize(s.sizeOf(_cur)), s.filesOf(_cur)]), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + ), + TextButton.icon( + onPressed: () => Navigator.pop(context, _cur), + icon: const Icon(Icons.folder_open, size: 18), + label: Text(tr('그 자리로')), + ), + ]), + ), + if (s.unreadable > 0 || s.links > 0) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + [ + if (s.unreadable > 0) trf('읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)', [s.unreadable]), + if (s.links > 0) trf('링크 {0}개는 세지 않음', [s.links]), + ].join(' · '), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Wrap(spacing: 6, children: [ + ChoiceChip(label: Text(tr('큰 폴더')), selected: _tab == 0, onSelected: (_) => setState(() => _tab = 0)), + ChoiceChip(label: Text(tr('큰 파일')), selected: _tab == 1, onSelected: (_) => setState(() => _tab = 1)), + ]), + ), + const Divider(height: 1), + Expanded(child: _tab == 0 ? _folders(s) : _files(s)), + ]), + ), + ); + } + + Widget _folders(StorageScan s) { + final list = s.biggestIn(_cur); + final total = s.sizeOf(_cur); + if (list.isEmpty) return Center(child: Text(tr('안에 폴더가 없습니다'))); + return ListView.builder( + itemCount: list.length, + itemBuilder: (_, i) { + final d = list[i]; + final size = s.sizeOf(d); + return ListTile( + dense: true, + leading: const Icon(Icons.folder, color: Colors.amber), + title: Text(scanName(d), maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 4), + _bar(total == 0 ? 0 : size / total), + const SizedBox(height: 2), + Text('${formatSize(size)} · ${trf('파일 {0}개', [s.filesOf(d)])}', style: const TextStyle(fontSize: 11)), + ]), + trailing: IconButton( + tooltip: tr('그 자리로'), + icon: const Icon(Icons.drive_file_move_outline, size: 20), + onPressed: () => Navigator.pop(context, d), + ), + onTap: () => setState(() => _cur = d), + ); + }, + ); + } + + Widget _files(StorageScan s) { + final list = [for (final f in s.bigFiles) if (isSameOrInside(f.path, _cur)) f]; + if (list.isEmpty) return Center(child: Text(tr('큰 파일이 없습니다'))); + final top = list.first.size; + return ListView.builder( + itemCount: list.length, + itemBuilder: (_, i) { + final f = list[i]; + return ListTile( + dense: true, + leading: const Icon(Icons.insert_drive_file_outlined), + title: Text(f.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 4), + _bar(top == 0 ? 0 : f.size / top), + const SizedBox(height: 2), + Text('${formatSize(f.size)} · ${vDisplay(vDirname(f.path))}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + ]), + // 누르면 탐색기에서 그 파일 자리로 (지우기 등은 탐색기에서) + onTap: () => Navigator.pop(context, f.path), + ); + }, + ); + } +} diff --git a/test/explorer_test.dart b/test/explorer_test.dart index d3ea565..b5de30c 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -16,6 +16,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_search.dart'; import 'package:jj_mkvmaker/ui/quick_look.dart'; import 'package:jj_mkvmaker/ui/explorer_drag.dart'; +import 'package:jj_mkvmaker/ui/storage_page.dart'; import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; @@ -1233,6 +1234,22 @@ void main() { }); } + testWidgets('87: 폴더 길게 누르기 [저장 공간 분석] → 큰 파일을 누르면 탐색기에서 그 자리로', (tester) async { + File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(4000, 1)); + await open(tester); + await tester.longPress(find.text('sub').first); + await calm(tester); + await act(tester, () => tester.tap(find.text('저장 공간 분석'))); + await settle(tester, () => find.text('큰 파일').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('큰 파일'))); + await calm(tester); + await act(tester, () => tester.tap(find.text('big.bin'))); + await settle(tester, () => find.byType(StorageAnalysisPage).evaluate().isEmpty && find.text('big.bin').evaluate().isNotEmpty); + await calm(tester); + expect(find.byType(StorageAnalysisPage), findsNothing); + expect(find.text('big.bin'), findsWidgets, reason: '탐색기에서 그 자리 (sub 안) 가 보인다'); + }); + testWidgets('84: 열 머리를 누르면 그 기준으로 · 다시 누르면 거꾸로 (처음: 모든 폴더 같은 정렬)', (tester) async { File(p.join(left, 'a_big.txt')).writeAsStringSync('x' * 50); c.settings.explorerStyle = 'windows'; diff --git a/test/storage_scan_test.dart b/test/storage_scan_test.dart new file mode 100644 index 0000000..8676682 --- /dev/null +++ b/test/storage_scan_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/storage_scan.dart'; +import 'package:jj_mkvmaker/ui/storage_page.dart'; +import 'package:path/path.dart' as p; + +/// 87: 저장 공간 분석 - 폴더마다 크기 (안쪽 합) · 큰 파일 차례, 링크는 세지 않음 (80 과 같게) · 취소, 화면은 들어가기 · 그 자리로 +void main() { + late Directory tmp; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_scan87_'); + File(p.join(tmp.path, 'movies', 'a.mkv')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(5000, 0)); + File(p.join(tmp.path, 'movies', 'old', 'b.mkv')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(3000, 0)); + File(p.join(tmp.path, 'docs', 'c.txt')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(100, 0)); + File(p.join(tmp.path, 'top.bin')).writeAsBytesSync(List.filled(10, 0)); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('폴더마다 안쪽 합 · 큰 폴더 차례 · 큰 파일 차례', () async { + final s = await StorageScanner().scan(tmp.path); + expect(s.sizeOf(tmp.path), 8110); + expect(s.filesOf(tmp.path), 4); + expect(s.sizeOf(p.join(tmp.path, 'movies')), 8000); + expect(s.biggestIn(tmp.path).map(p.basename), ['movies', 'docs']); + expect(s.bigFiles.map((f) => f.name).take(3), ['a.mkv', 'b.mkv', 'c.txt']); + }); + + test('링크 · junction 은 세지 않고 (위 폴더를 가리키는 고리도 끝남) · 취소', () async { + var junctions = 0; + if (Platform.isWindows) { + for (final (link, target) in [(p.join(tmp.path, 'docs', 'loop'), tmp.path), (p.join(tmp.path, 'tomovies'), p.join(tmp.path, 'movies'))]) { + if ((await Process.run('cmd', ['/c', 'mklink', '/J', link, target])).exitCode == 0) junctions++; + } + } + final s = await StorageScanner().scan(tmp.path); + expect(s.sizeOf(tmp.path), 8110, reason: 'junction 이 가리키는 것을 더하지 않는다'); + expect(s.links, junctions); + await expectLater(StorageScanner().scan(tmp.path, cancelled: () => true), throwsA(isA())); + for (final l in [p.join(tmp.path, 'docs', 'loop'), p.join(tmp.path, 'tomovies')]) { + if (Link(l).existsSync()) Link(l).deleteSync(); + } + }); + + testWidgets('화면: 큰 폴더 → 들어가기 · 위로 · 큰 파일 · [그 자리로] 는 그 경로를 돌려준다', (tester) async { + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final key = GlobalKey(); + await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold())); + String? picked; + var done = false; + Navigator.of(key.currentContext!) + .push(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: tmp.path))) + .then((v) { + picked = v; + done = true; + }); + for (var i = 0; i < 200 && find.text('movies').evaluate().isEmpty; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + expect(find.text('movies'), findsOneWidget); + await tester.tap(find.text('movies')); + await tester.pump(); + expect(find.text('old'), findsOneWidget, reason: '들어가기'); + await tester.tap(find.byTooltip('위 폴더')); + await tester.pump(); + await tester.tap(find.text('큰 파일')); + await tester.pump(); + expect(find.text('a.mkv'), findsOneWidget); + await tester.tap(find.text('a.mkv')); + await tester.pumpAndSettle(); + expect(done, isTrue); + expect(picked, p.join(tmp.path, 'movies', 'a.mkv')); + }); +}