WIP 80 보충 (15 지적 · 관리자 판단 ① ②): 폴더 크기에서 링크 · junction 은 따라가지 않음 · 화면에서 사라진 자동 계산은 멈춤 (검토 대기)

① 링크 · junction 을 따라가 세던 것 (같은 것을 여러 번 세어 크게 나오고, 위 폴더를 가리키는 고리면 끝나지 않을 수 있었다 -
   실제 예: wt_*\third_party (junction) · .plugin_symlinks):
  - 크기를 셀 때 로컬 폴더는 list(followLinks: false) 로 직접 읽고, 심볼릭 링크 · junction 은 0 으로 세고 수만 센다.
    "1.2GB · 링크 3개 제외" 로 보인다 (214 의 링크 처리와 같은 판단).
  - 안전장치: 이미 센 폴더 (실제 경로) 는 다시 세지 않는다.
② 다른 폴더로 가도 기다리던 계산이 끝까지 돌던 것:
  - 화면을 위해 걸어 둔 자동 계산은 그 폴더가 어느 창에도 보이지 않으면 대기 줄에서 빼고, 세는 중이면 멈춘다 (결과는 남기지 않음).
  - 길게 누르기 [폴더 크기 계산] 으로 직접 시킨 것만 끝까지 - 아래 알림 [취소] · 같은 메뉴 "폴더 크기 계산 멈추기".
  - WebDAV 는 자동으로 하지 않는다. 직접 시키면 "서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다" 를 알린다 (관리자 판단).
- 시험: folder_size_test (Windows 에 실제 junction · 심볼릭 링크 · 위 폴더 고리 junction, 화면이 바뀌면 자동 계산 멈춤 · 사용자가 시킨 것은 끝까지 · 취소).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-17 12:01:06 +09:00
parent 1b988cd84c
commit 5007d30afa
6 changed files with 226 additions and 33 deletions

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

@ -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<FileEntry> 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 = <String, FolderSize>{};
final _waiting = <String>[];
final _queued = <String>{};
/// 기다리는 것: 경로 → 화면 폴더 (자동) · null (사용자가 시킴)
final _waiting = <String, String?>{};
bool _running = false;
bool _disposed = false;
/// 지금 세는 것 · 멈추라는 표시
String? _current;
String? _currentFor;
bool _stop = false;
/// 읽기 (시험에서 바꿔 끼움)
final Future<List<FileEntry>> Function(String dir) list;
final Future<FolderListing> Function(String dir) list;
FolderSizes({Future<List<FileEntry>> Function(String dir)? list}) : list = list ?? _vList;
FolderSizes({Future<FolderListing> Function(String dir)? list}) : list = list ?? listForSize;
static Future<List<FileEntry>> _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<FolderListing> 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 = <FileEntry>[];
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 = <String, String>{};
/// 화면에 보이는 폴더들 ([dirs]) 을 위한 것이 아닌 자동 계산은 빼고, 세는 중이면 멈춘다 (알리지 않음 - 화면을 그리는 중에 부른다)
void retainAuto(Iterable<String> 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<FolderSize> _measure(String root) async {
/// 안의 파일 크기 합. 읽지 못한 폴더가 있으면 [FolderSize.partial] (화면에 "+"). 멈추면 null
Future<FolderSize?> _measure(String root) async {
var bytes = 0;
var partial = false;
var links = 0;
final seen = <String>{};
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;
}
}

View File

@ -339,9 +339,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<ExplorerPage> {
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<ExplorerPage> {
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<ExplorerPage> {
listenable: c,
builder: (context, _) {
_dropRemovedServers();
// 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다
_sizes.retainAuto([for (final x in _panes) x.current]);
return Column(children: [
_topBar(),
if (_searchBack != null) _searchBackBar(),

View File

@ -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<void> 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<void>.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<bool> 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<void>();
final read = <String>[];
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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 100));
expect(s.of(a), isNull);
expect(s.busy(a), isFalse);
s.dispose();
});
test('크기순 정렬: 아는 폴더는 그 크기로, 모르는 폴더는 가장 작게 (폴더는 늘 위)', () {
final now = DateTime(2026);
final list = [