diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 69fc878..76f12e3 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -40,6 +40,8 @@ "\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "\"{0}\" is empty, so syncing deletes all the items below from the target \"{1}\" (cannot be undone). Press only if you emptied the source on purpose.", "\"{0}\" 이동": "Move \"{0}\"", "\"{0}\" 찾기 · {1}개": "Search \"{0}\" · {1}", + "\"{0}\" 크기를 계산합니다.": "Calculating the size of \"{0}\".", + "\"{0}\" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.": "Calculating the size of \"{0}\". Everything inside is read from the server, so this can take a while.", "\"동영상 추가\" 로 파일을 선택하세요": "Choose files with \"Add videos\"", "\"모든 파일에 대한 접근\" 권한이 없어 읽을 수 없습니다. 허용하면 저절로 다시 분석합니다.": "Can't read this without the \"All files access\" permission. It will be analyzed again automatically once you allow it.", "\"백그라운드로 실행\" 이 꺼져 있어 앱을 끝내면 작업이 멈춥니다.": "\"Run in background\" is off, so closing the app stops these tasks.", @@ -420,6 +422,7 @@ "{0} / {1}장": "{0} / {1} images", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)", "{0} · {1}배 · {2}": "{0} · {1}× · {2}", + "{0} · 링크 {1}개 제외": "{0} · {1} links excluded", "{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%", "{0} 는 이미 끝났습니다": "{0} has already finished", "{0} 라이선스": "{0} license", @@ -2274,6 +2277,7 @@ "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "The folder name has characters that cannot be used (\\ / : * ? \" < > |)", "폴더 크기": "Folder size", "폴더 크기 계산": "Calculate folder size", + "폴더 크기 계산 멈추기": "Stop calculating folder size", "폴더: {0}": "Folder: {0}", "폴더가 들어 있을 때": "When folders are included", "폴더가 아닙니다: {0}": "Not a folder: {0}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index d47d05a..f6cf56a 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -40,6 +40,8 @@ "\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "「{0}」が空のため、同期すると対象「{1}」の以下の項目がすべて削除されます (元に戻せません)。元フォルダーを意図的に空にした場合のみ押してください。", "\"{0}\" 이동": "「{0}」を移動", "\"{0}\" 찾기 · {1}개": "「{0}」を検索 · {1}件", + "\"{0}\" 크기를 계산합니다.": "「{0}」のサイズを計算します。", + "\"{0}\" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.": "「{0}」のサイズを計算します。サーバーから中身をすべて読むため、時間がかかることがあります。", "\"동영상 추가\" 로 파일을 선택하세요": "「動画を追加」でファイルを選んでください", "\"모든 파일에 대한 접근\" 권한이 없어 읽을 수 없습니다. 허용하면 저절로 다시 분석합니다.": "「すべてのファイルへのアクセス」権限がないため読み取れません。許可すると自動的に再分析します。", "\"백그라운드로 실행\" 이 꺼져 있어 앱을 끝내면 작업이 멈춥니다.": "「バックグラウンドで実行」がオフなので、アプリを終了すると作業が止まります。", @@ -420,6 +422,7 @@ "{0} / {1}장": "{0} / {1} 枚", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", "{0} · {1}배 · {2}": "{0}・{1} 倍・{2}", + "{0} · 링크 {1}개 제외": "{0} · リンク {1} 件を除く", "{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%", "{0} 는 이미 끝났습니다": "{0} はすでに終わりました", "{0} 라이선스": "{0} ライセンス", @@ -2274,6 +2277,7 @@ "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "フォルダー名に使えない文字があります(\\ / : * ? \" < > |)", "폴더 크기": "フォルダーサイズ", "폴더 크기 계산": "フォルダーサイズを計算", + "폴더 크기 계산 멈추기": "フォルダーサイズの計算を止める", "폴더: {0}": "フォルダー: {0}", "폴더가 들어 있을 때": "フォルダーを含むとき", "폴더가 아닙니다: {0}": "フォルダーではありません: {0}", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 1ccce6d..83ce441 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -40,6 +40,8 @@ "\"{0}\" 이 비어 있어, 맞추면 대상 \"{1}\" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). 원본을 일부러 비운 경우에만 누르세요.": "“{0}”为空,同步后会删除目标“{1}”中的以下所有项目 (无法撤销)。仅在你有意清空源文件夹时按下。", "\"{0}\" 이동": "移动“{0}”", "\"{0}\" 찾기 · {1}개": "搜索\"{0}\" · {1}个", + "\"{0}\" 크기를 계산합니다.": "正在计算“{0}”的大小。", + "\"{0}\" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.": "正在计算“{0}”的大小。需要从服务器读取其中所有内容,可能需要较长时间。", "\"동영상 추가\" 로 파일을 선택하세요": "请用“添加视频”选择文件", "\"모든 파일에 대한 접근\" 권한이 없어 읽을 수 없습니다. 허용하면 저절로 다시 분석합니다.": "没有“所有文件访问权限”,无法读取。允许后会自动重新分析。", "\"백그라운드로 실행\" 이 꺼져 있어 앱을 끝내면 작업이 멈춥니다.": "“后台运行”已关闭,退出应用会停止这些任务。", @@ -420,6 +422,7 @@ "{0} / {1}장": "{0} / {1} 张", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", "{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}", + "{0} · 링크 {1}개 제외": "{0} · 已排除 {1} 个链接", "{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%", "{0} 는 이미 끝났습니다": "{0} 已经完成", "{0} 라이선스": "{0} 许可证", @@ -2274,6 +2277,7 @@ "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "文件夹名称中有不能使用的字符(\\ / : * ? \" < > |)", "폴더 크기": "文件夹大小", "폴더 크기 계산": "计算文件夹大小", + "폴더 크기 계산 멈추기": "停止计算文件夹大小", "폴더: {0}": "文件夹: {0}", "폴더가 들어 있을 때": "包含文件夹时", "폴더가 아닙니다: {0}": "不是文件夹: {0}", diff --git a/lib/core/folder_size.dart b/lib/core/folder_size.dart index fa10277..3bdeacd 100644 --- a/lib/core/folder_size.dart +++ b/lib/core/folder_size.dart @@ -1,27 +1,65 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; +import '../l10n/tr.dart'; import 'file_ops.dart'; import 'vfs.dart'; +/// 폴더 하나를 읽은 것: 안의 항목 (링크 · junction 은 빼고) · 뺀 링크 수 +typedef FolderListing = ({List entries, int links}); + /// 80: 폴더 크기 (안의 파일 크기 합) - 필요할 때 계산하고 이번 실행 동안 기억한다. -/// 한 번에 하나씩 (디스크 · NAS 를 몰아 읽지 않게). 바뀐 폴더는 [forget] 으로 그 폴더와 위 폴더들을 지운다. +/// 한 번에 하나씩 (디스크 · NAS 를 몰아 읽지 않게). 바뀐 폴더는 [forget] 으로 그 폴더 · 안쪽 · 위 폴더들을 지운다. +/// 80 보충 (15 지적 · 관리자 판단): +/// - 심볼릭 링크 · junction 은 따라가지 않고 0 으로 센다 (같은 것을 여러 번 세거나, 고리 링크면 끝나지 않았다). 뺀 수는 [FolderSize.links]. +/// 안전장치로 이미 센 폴더 (실제 경로) 는 다시 세지 않는다. +/// - 화면을 위해 걸어 둔 자동 계산 ([request] 의 [forDir]) 은 그 폴더가 화면에서 사라지면 ([retainAuto]) 멈추고 뺀다. +/// 사용자가 직접 시킨 것 ([forDir] 없음) 은 끝까지 하고 [cancel] 로만 멈춘다. class FolderSizes extends ChangeNotifier { final _known = {}; - final _waiting = []; - final _queued = {}; + + /// 기다리는 것: 경로 → 화면 폴더 (자동) · null (사용자가 시킴) + final _waiting = {}; bool _running = false; bool _disposed = false; + /// 지금 세는 것 · 멈추라는 표시 + String? _current; + String? _currentFor; + bool _stop = false; + /// 읽기 (시험에서 바꿔 끼움) - final Future> Function(String dir) list; + final Future Function(String dir) list; - FolderSizes({Future> Function(String dir)? list}) : list = list ?? _vList; + FolderSizes({Future Function(String dir)? list}) : list = list ?? listForSize; - static Future> _vList(String dir) async => [ - for (final e in await vList(dir, strict: true)) FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified), - ]; + /// 크기를 셀 때의 폴더 읽기: 로컬은 링크 · junction 을 따라가지 않고 (세지 않고 수만), WebDAV 는 그대로 + static Future listForSize(String dir) async { + if (isDav(dir)) { + return ( + entries: [for (final e in await vList(dir, strict: true)) FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified)], + links: 0, + ); + } + final out = []; + var links = 0; + await for (final e in Directory(dir).list(followLinks: false)) { + // Windows 의 junction 도 Dart 에서는 Link 로 나온다 (214 와 같은 판단) + if (e is Link || FileSystemEntity.isLinkSync(e.path)) { + links++; + } else if (e is Directory) { + out.add(FileEntry(e.path, isDir: true, modified: DateTime(0))); + } else if (e is File) { + try { + final st = await e.stat(); + out.add(FileEntry(e.path, isDir: false, size: st.size, modified: st.modified)); + } catch (_) {} + } + } + return (entries: out, links: links); + } static String _key(String path) => isDav(path) ? vNorm(path) : path.toLowerCase(); @@ -29,20 +67,51 @@ class FolderSizes extends ChangeNotifier { FolderSize? of(String path) => _known[_key(path)]; /// 계산 중이거나 기다리는 중인지 - bool busy(String path) => _queued.contains(_key(path)); + bool busy(String path) => _waiting.containsKey(_key(path)) || (_current != null && _key(_current!) == _key(path)); - /// 계산해 두기 (이미 알거나 기다리는 중이면 그대로) - void request(String path) { + /// 계산해 두기 (이미 알거나 기다리는 중이면 그대로). [forDir]: 그 폴더를 보여 주려고 자동으로 건 것 (화면에서 사라지면 멈춤). + /// 없으면 사용자가 직접 시킨 것 - 이미 자동으로 기다리던 것이면 끝까지 하도록 바꾼다 + void request(String path, {String? forDir}) { final k = _key(path); - if (_known.containsKey(k) || _queued.contains(k)) return; - _queued.add(k); - _waiting.add(path); + if (_known.containsKey(k)) return; + if (_current != null && _key(_current!) == k) { + if (forDir == null) _currentFor = null; + return; + } + if (_waiting.containsKey(k)) { + if (forDir == null) _waiting[k] = null; + return; + } + _waiting[k] = forDir; + _paths[k] = path; notifyListeners(); // 화면을 그리는 중 (프레임 뒤 콜백) 에 맡겨도 그 영역에 묶이지 않게 바깥 영역에서 읽는다 // (위젯 시험의 가짜 시간 영역에서는 실제 파일 읽기가 끝나지 않았다) Zone.root.run(() => unawaited(_pump())); } + final _paths = {}; + + /// 화면에 보이는 폴더들 ([dirs]) 을 위한 것이 아닌 자동 계산은 빼고, 세는 중이면 멈춘다 (알리지 않음 - 화면을 그리는 중에 부른다) + void retainAuto(Iterable dirs) { + final keep = {for (final d in dirs) _key(d)}; + _waiting.removeWhere((k, f) { + final drop = f != null && !keep.contains(_key(f)); + if (drop) _paths.remove(k); + return drop; + }); + if (_currentFor != null && !keep.contains(_key(_currentFor!))) _stop = true; + } + + /// 사용자가 멈춤 (기다리는 것 · 세는 것 모두) + void cancel(String path) { + final k = _key(path); + _waiting.remove(k); + _paths.remove(k); + if (_current != null && _key(_current!) == k) _stop = true; + notifyListeners(); + } + /// 이 폴더 · 그 안의 폴더들 · 위 폴더들의 크기를 잊는다 (안의 것이 바뀜 - 새로 고침 · 복사 · 이동 · 지우기) void forget(String path) { final k = _key(path); @@ -64,11 +133,17 @@ class FolderSizes extends ChangeNotifier { _running = true; try { while (_waiting.isNotEmpty && !_disposed) { - final path = _waiting.removeAt(0); + final k = _waiting.keys.first; + final forDir = _waiting.remove(k); + final path = _paths.remove(k) ?? k; + _current = path; + _currentFor = forDir; + _stop = false; final size = await _measure(path); + _current = null; + _currentFor = null; if (_disposed) return; - _queued.remove(_key(path)); - _known[_key(path)] = size; + if (size != null) _known[k] = size; notifyListeners(); } } finally { @@ -76,22 +151,36 @@ class FolderSizes extends ChangeNotifier { } } - /// 안의 파일 크기 합. 읽지 못한 폴더가 있으면 [FolderSize.partial] (화면에 "+") - Future _measure(String root) async { + /// 안의 파일 크기 합. 읽지 못한 폴더가 있으면 [FolderSize.partial] (화면에 "+"). 멈추면 null + Future _measure(String root) async { var bytes = 0; var partial = false; + var links = 0; + final seen = {}; final pending = [root]; - while (pending.isNotEmpty && !_disposed) { + while (pending.isNotEmpty) { + if (_disposed || _stop) return null; final dir = pending.removeLast(); + // 안전장치: 이미 센 폴더 (실제 경로) 는 다시 세지 않는다 + var real = dir; + if (!isDav(dir)) { + try { + real = Directory(dir).resolveSymbolicLinksSync(); + } catch (_) {} + } + if (!seen.add(_key(real))) continue; // 안쪽 폴더를 이미 알면 다시 읽지 않는다 final known = dir == root ? null : _known[_key(dir)]; if (known != null) { bytes += known.bytes; partial |= known.partial; + links += known.links; continue; } try { - for (final e in await list(dir)) { + final r = await list(dir); + links += r.links; + for (final e in r.entries) { if (e.isDir) { pending.add(e.path); } else if (e.size > 0) { @@ -102,7 +191,7 @@ class FolderSizes extends ChangeNotifier { partial = true; } } - return FolderSize(bytes, partial: partial); + return FolderSize(bytes, partial: partial, links: links); } @override @@ -117,8 +206,14 @@ class FolderSize { /// 읽지 못한 폴더가 있어 실제보다 작을 수 있음 final bool partial; - const FolderSize(this.bytes, {this.partial = false}); - /// "1.2GB" · 읽지 못한 곳이 있으면 "1.2GB+" - String get label => '${formatSize(bytes)}${partial ? '+' : ''}'; + /// 세지 않은 링크 · junction 수 + final int links; + const FolderSize(this.bytes, {this.partial = false, this.links = 0}); + + /// "1.2GB" · 읽지 못한 곳이 있으면 "1.2GB+" · 링크가 있으면 "1.2GB · 링크 3개 제외" + String get label { + final s = '${formatSize(bytes)}${partial ? '+' : ''}'; + return links > 0 ? trf('{0} · 링크 {1}개 제외', [s, links]) : s; + } } diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index e798ad9..1029c87 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -339,9 +339,13 @@ class _ExplorerPageState extends State { if (s != null) return s.label; if (_sizes.busy(e.path)) return '…'; final mode = c.settings.explorerFolderSize; - if ((mode == 'always' || (mode == 'sort' && _sortOf(pane.current).$1 == 'size')) && samePath(vDirname(e.path), pane.current)) { + // 80 보충 (관리자 판단): WebDAV 는 자동으로 하지 않는다 (서버에서 안의 것을 모두 읽음 - 길게 누르기로만) + if ((mode == 'always' || (mode == 'sort' && _sortOf(pane.current).$1 == 'size')) && + samePath(vDirname(e.path), pane.current) && + !isDav(e.path)) { + final forDir = pane.current; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _sizes.request(e.path); + if (mounted) _sizes.request(e.path, forDir: forDir); }); return '…'; } @@ -1390,7 +1394,7 @@ class _ExplorerPageState extends State { if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')), // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) - if (e.isDir) ('folderSize', Icons.data_usage, tr('폴더 크기 계산')), + if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), @@ -1477,9 +1481,22 @@ class _ExplorerPageState extends State { setState(() => _selecting = true); if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); case 'folderSize': + if (_sizes.busy(e.path)) { + _sizes.cancel(e.path); + break; + } + // 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴) _sizes ..forget(e.path) ..request(e.path); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(isDav(e.path) + ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) + : trf('"{0}" 크기를 계산합니다.', [e.name])), + action: SnackBarAction(label: tr('취소'), onPressed: () => _sizes.cancel(e.path)), + )); + } case 'rename': if (_isMarked(pane, e.path) && pane.marked.length > 1) { await _batchRename(pane, pane.marked.toList()); @@ -3192,6 +3209,8 @@ class _ExplorerPageState extends State { listenable: c, builder: (context, _) { _dropRemovedServers(); + // 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다 + _sizes.retainAuto([for (final x in _panes) x.current]); return Column(children: [ _topBar(), if (_searchBack != null) _searchBackBar(), diff --git a/test/folder_size_test.dart b/test/folder_size_test.dart index 8243dbd..e192279 100644 --- a/test/folder_size_test.dart +++ b/test/folder_size_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -5,7 +6,8 @@ import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/folder_size.dart'; import 'package:path/path.dart' as p; -/// 80: 폴더 크기 - 필요할 때 계산 · 기억, 읽지 못한 곳은 "+", 바뀌면 그 폴더와 위 폴더를 잊음, 크기순 정렬 +/// 80: 폴더 크기 - 필요할 때 계산 · 기억, 읽지 못한 곳은 "+", 바뀌면 그 폴더와 위 폴더를 잊음, 크기순 정렬. +/// 80 보충: 링크 · junction 은 따라가지 않음 (고리도 끝남), 화면에서 사라진 자동 계산은 멈춤, 사용자가 시킨 것은 [cancel] 로만 void main() { late Directory tmp; setUp(() { @@ -23,16 +25,16 @@ void main() { tearDown(() => tmp.deleteSync(recursive: true)); Future idle(FolderSizes s, String path) async { - for (var i = 0; i < 200 && s.of(path) == null; i++) { + for (var i = 0; i < 300 && s.of(path) == null; i++) { await Future.delayed(const Duration(milliseconds: 10)); } } - test('안쪽 폴더까지 합 · 기억 · 바뀌면 그 폴더와 위 폴더를 잊음', () async { + test('안쪽 폴더까지 합 · 기억 · 바뀌면 그 폴더 · 안쪽 · 위 폴더를 잊음', () async { var reads = 0; final s = FolderSizes(list: (d) { reads++; - return listEntries(d, showHidden: true); + return FolderSizes.listForSize(d); }); final a = p.join(tmp.path, 'a'); s.request(a); @@ -64,7 +66,7 @@ void main() { final a = p.join(tmp.path, 'a'); final s = FolderSizes(list: (d) { if (p.basename(d) == 'deep') throw const FileSystemException('권한 없음'); - return listEntries(d, showHidden: true); + return FolderSizes.listForSize(d); }); s.request(a); await idle(s, a); @@ -73,6 +75,71 @@ void main() { s.dispose(); }); + group('링크 · junction (Windows)', () { + Future junction(String link, String target) async => + (await Process.run('cmd', ['/c', 'mklink', '/J', link, target])).exitCode == 0; + + test('junction · 심볼릭 링크는 따라가지 않고 0 · "링크 n개 제외", 위 폴더를 가리키는 고리도 끝난다', () async { + final a = p.join(tmp.path, 'a'); + // b (5B) 를 가리키는 junction, 위 폴더 (tmp) 를 가리키는 고리 junction + expect(await junction(p.join(a, 'to_b'), p.join(tmp.path, 'b')), isTrue); + expect(await junction(p.join(a, 'deep', 'loop'), tmp.path), isTrue); + var symlink = false; + try { + Link(p.join(a, 'sym_b')).createSync(p.join(tmp.path, 'b')); + symlink = true; + } catch (_) {} // 개발자 모드가 아니면 심볼릭 링크를 만들 수 없다 - junction 만 본다 + final s = FolderSizes(); + s.request(a); + await idle(s, a); + expect(s.of(a)!.bytes, 1024, reason: '링크가 가리키는 b (5B) 를 더하지 않는다'); + expect(s.of(a)!.links, symlink ? 3 : 2); + expect(s.of(a)!.label, '1KB · 링크 ${symlink ? 3 : 2}개 제외'); + s.dispose(); + // tearDown 의 지우기가 junction 을 따라가 b · tmp 를 지우지 않게 링크부터 지운다 + for (final l in [p.join(a, 'to_b'), p.join(a, 'deep', 'loop'), if (symlink) p.join(a, 'sym_b')]) { + Link(l).deleteSync(); + } + expect(File(p.join(tmp.path, 'b', 'z.bin')).existsSync(), isTrue); + }); + }, skip: !Platform.isWindows); + + test('화면에서 사라진 폴더의 자동 계산은 멈추고 빠진다 · 사용자가 시킨 것은 남는다 · [cancel]', () async { + final gate = Completer(); + final read = []; + final s = FolderSizes(list: (d) async { + read.add(p.basename(d)); + await gate.future; + return FolderSizes.listForSize(d); + }); + final a = p.join(tmp.path, 'a'), b = p.join(tmp.path, 'b'); + s.request(a, forDir: tmp.path); // 세는 중 (gate 에서 기다림) + s.request(b, forDir: tmp.path); // 기다리는 중 + await Future.delayed(const Duration(milliseconds: 20)); + expect(s.busy(a) && s.busy(b), isTrue); + // 다른 폴더로 감 + s.retainAuto([p.join(tmp.path, 'elsewhere')]); + expect(s.busy(b), isFalse, reason: '기다리던 것은 빠진다'); + gate.complete(); + await Future.delayed(const Duration(milliseconds: 100)); + expect(s.of(a), isNull, reason: '세던 것은 멈추고 남기지 않는다'); + expect(read, isNot(contains('b'))); + + // 사용자가 시킨 것은 화면이 바뀌어도 끝까지 + s.request(b); + s.retainAuto([p.join(tmp.path, 'elsewhere')]); + await idle(s, b); + expect(s.of(b)!.bytes, 5); + + // [cancel] + s.request(a); + s.cancel(a); + await Future.delayed(const Duration(milliseconds: 100)); + expect(s.of(a), isNull); + expect(s.busy(a), isFalse); + s.dispose(); + }); + test('크기순 정렬: 아는 폴더는 그 크기로, 모르는 폴더는 가장 작게 (폴더는 늘 위)', () { final now = DateTime(2026); final list = [