묶음 3 Windows 확인 캡처 · 휴지통 시험 흔적 스스로 확인 · 꺼내는 드라이브가 비면 "비어 있음"

- integration_test/windows_b3_shot_test: 165 · 48 · 154 · 52 · 148 · 144 를 실제 화면으로 눌러 PNG (JJ_SHOT_DIR)
- 휴지통을 건드리는 시험은 끝에 사용자 휴지통 전체에서 시험 폴더 아래 (하위 포함) 가 원래 위치인 항목이 0 인지 확인
  (test/support/recycle_leftovers.dart)
- 165 보충: 카드 리더 · USB · CD (GetDriveType 2 · 5) 가 열리지 않으면 "연결 안 됨" 이 아니라 "비어 있음" (회색)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-09 19:55:29 +09:00
parent f97f63df79
commit 1450b51316
9 changed files with 236 additions and 9 deletions

View File

@ -301,6 +301,7 @@
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "Picked up {0}. Open the target folder and tap [Paste] ({1}).", "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "Picked up {0}. Open the target folder and tap [Paste] ({1}).",
"{0} 을(를) 설치했습니다.": "Installed {0}.", "{0} 을(를) 설치했습니다.": "Installed {0}.",
"{0} 을(를) 지울까요?": "Delete {0}?", "{0} 을(를) 지울까요?": "Delete {0}?",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
"{0} 전체": "All of {0}", "{0} 전체": "All of {0}",
"{0} 제거": "Remove {0}", "{0} 제거": "Remove {0}",

View File

@ -301,6 +301,7 @@
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。", "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。",
"{0} 을(를) 설치했습니다.": "{0} をインストールしました。", "{0} 을(를) 설치했습니다.": "{0} をインストールしました。",
"{0} 을(를) 지울까요?": "{0} を削除しますか?", "{0} 을(를) 지울까요?": "{0} を削除しますか?",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
"{0} 전체": "{0} 全体", "{0} 전체": "{0} 全体",
"{0} 제거": "{0} の削除", "{0} 제거": "{0} の削除",

View File

@ -301,6 +301,7 @@
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0}。请在目标文件夹中点按 [粘贴] ({1})。", "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0}。请在目标文件夹中点按 [粘贴] ({1})。",
"{0} 을(를) 설치했습니다.": "已安装 {0}。", "{0} 을(를) 설치했습니다.": "已安装 {0}。",
"{0} 을(를) 지울까요?": "要删除 {0} 吗?", "{0} 을(를) 지울까요?": "要删除 {0} 吗?",
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
"{0} 전체": "{0} 全部", "{0} 전체": "{0} 全部",
"{0} 제거": "移除 {0}", "{0} 제거": "移除 {0}",

View File

@ -0,0 +1,180 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import '../test/support/recycle_leftovers.dart';
/// 묶음 3 (데이터 쪽) 기기 감독 확인용 (Windows): 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다.
/// 165 (드라이브 · 연결 안 됨) · 48 (같은 이름) · 52 (돌아와도 진행 막대) · 148 (휴지통 되돌리기) · 144 (긴 경로).
/// 시험 폴더 (JJ_SHOT_TMP) 만 쓰고, 설정은 메모리에만 (저장하지 않음). 휴지통에 넣은 것은 되돌리므로 남지 않는다.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_b3');
Directory tempDir(String prefix) {
final base = Platform.environment['JJ_SHOT_TMP'];
if (base == null || base.isEmpty) return Directory.systemTemp.createTempSync(prefix);
Directory(base).createSync(recursive: true);
return Directory(base).createTempSync(prefix);
}
final key = GlobalKey();
Future<void> settle(WidgetTester t, [int rounds = 12]) async {
for (var i = 0; i < rounds; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 120)));
await t.pump();
}
}
Future<void> until(WidgetTester t, bool Function() done, {int rounds = 100}) async {
for (var i = 0; i < rounds && !done(); i++) {
await settle(t, 1);
}
}
Future<void> shot(WidgetTester t, String name) async {
await settle(t, 4);
final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, '$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
Future<void> show(WidgetTester t, Widget home, {Size size = const Size(1280, 800)}) async {
t.view.physicalSize = size;
t.view.devicePixelRatio = 1;
await t.runAsync(() => t.pumpWidget(MaterialApp(
theme: buildTheme(),
builder: (_, child) => RepaintBoundary(key: key, child: child),
home: KeyedSubtree(key: UniqueKey(), child: home),
)));
await settle(t);
}
late Directory tmp;
late String left, right;
late AppController c;
setUp(() {
tmp = tempDir('jj_shot_b3_');
left = p.join(tmp.path, 'left');
right = p.join(tmp.path, 'right');
File(p.join(left, 'doc.txt'))
..createSync(recursive: true)
..writeAsStringSync('new');
File(p.join(right, 'doc.txt'))
..createSync(recursive: true)
..writeAsStringSync('old');
c = AppController(PlatformServices.create());
c.settings.explorerPaths = [left, right];
});
tearDown(() async {
ExplorerPage.debugVolumes = null;
ExplorerPage.probeDrive = (path) => Directory(path).exists();
expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)');
try {
Directory(r'\\?\' + tmp.absolute.path).deleteSync(recursive: true);
} catch (_) {}
});
Future<void> selectAndPress(WidgetTester t, String item, String button) async {
await until(t, () => find.text(item).evaluate().isNotEmpty);
await t.tap(find.text(item).first);
await settle(t, 3);
await t.tap(find.text(button).first);
await settle(t, 4);
}
testWidgets('165: 드라이브 탭 - 응답 없는 드라이브 · 연결 안 됨 (첫 화면은 바로)', (t) async {
final root = p.rootPrefix(tmp.path);
final stuck = Completer<bool>();
ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), '')), (r'Q:\', 'Q:'), (r'R:\', 'R:')];
ExplorerPage.probeDrive = (path) => path == r'Q:\' ? stuck.future : Future.value(path != r'R:\');
await show(t, ExplorerPage(c: c));
await until(t, () => find.textContaining('연결 안 됨').evaluate().isNotEmpty);
await shot(t, 'b3_165_drives');
await t.tap(find.textContaining('R: · 연결 안 됨').first);
await settle(t, 6);
await shot(t, 'b3_165_reconnect_fail');
stuck.complete(false);
});
testWidgets('48: 같은 이름 - 확인 창 (항목 이름 · 고르기) → 덮어쓰기', (t) async {
await show(t, ExplorerPage(c: c));
await selectAndPress(t, 'doc.txt', '복사');
await until(t, () => find.textContaining('같은 이름이 이미 있습니다').evaluate().isNotEmpty);
await shot(t, 'b3_48_dialog');
await t.tap(find.text('덮어쓰기 (원래 파일은 없어집니다)'));
await settle(t, 2);
await shot(t, 'b3_48_dialog_overwrite');
await t.tap(find.widgetWithText(FilledButton, '복사'));
await until(t, () => find.byType(SnackBar).evaluate().isNotEmpty);
await shot(t, 'b3_48_done');
expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'new');
// 처음 열고 아무것도 고르지 않고 [복사]
await show(t, ExplorerPage(c: c));
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty);
await t.tap(find.text('복사').first);
await settle(t, 3);
await shot(t, 'b3_154_nothing_selected');
});
testWidgets('52: 복사 중에 다른 화면에 갔다 와도 진행 막대', (t) async {
// 다른 화면에 갔다 와도 아직 복사 중이게 (약 25초)
File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(2 * 1024 * 1024, 7));
c.settings.copyBandwidthKBps = 80;
await show(t, ExplorerPage(c: c));
await selectAndPress(t, 'big.bin', '복사');
await t.tap(find.widgetWithText(FilledButton, '복사'));
await until(t, () => find.byType(LinearProgressIndicator).evaluate().isNotEmpty);
await shot(t, 'b3_52_progress');
await show(t, const Scaffold(body: Center(child: Text('다른 화면'))));
await show(t, ExplorerPage(c: c));
await until(t, () => find.byType(LinearProgressIndicator).evaluate().isNotEmpty, rounds: 20);
await shot(t, 'b3_52_back_progress');
await until(t, () => File(p.join(right, 'big.bin')).existsSync() && find.text('big.bin').evaluate().length >= 2, rounds: 150);
await shot(t, 'b3_52_done_refreshed');
});
testWidgets('148: 휴지통으로 → [되돌리기]', (t) async {
await show(t, ExplorerPage(c: c));
await selectAndPress(t, 'doc.txt', '삭제');
await shot(t, 'b3_148_confirm');
await t.tap(find.widgetWithText(FilledButton, '휴지통으로'));
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
await shot(t, 'b3_148_snack_undo');
await t.tap(find.text('되돌리기'));
await until(t, () => find.textContaining('되돌렸습니다').evaluate().isNotEmpty);
await shot(t, 'b3_148_undone');
expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue);
ScaffoldMessenger.of(t.element(find.byType(ExplorerPage))).clearSnackBars();
await settle(t, 3);
});
testWidgets('144: 긴 경로 - 이유 한 줄 · [그대로 두기] / [영구 삭제]', (t) async {
var deep = p.join(left, 'deep');
while (deep.length < 250) {
deep = p.join(deep, 'd' * 30);
}
Directory(r'\\?\' + deep).createSync(recursive: true);
File(r'\\?\' + p.join(deep, 'a_file_name_that_makes_it_longer.txt')).writeAsStringSync('x');
await show(t, ExplorerPage(c: c));
await selectAndPress(t, 'deep', '삭제');
await t.tap(find.widgetWithText(FilledButton, '휴지통으로'));
await until(t, () => find.textContaining('경로가 너무 길어').evaluate().isNotEmpty);
await shot(t, 'b3_144_long_path');
await t.tap(find.textContaining('영구 삭제 (1개'));
await until(t, () => find.textContaining('영구 삭제했습니다').evaluate().isNotEmpty);
await shot(t, 'b3_144_deleted');
});
}

View File

@ -86,21 +86,24 @@ List<(String path, String label)> windowsDrives() {
]; ];
} }
/// 165: 네트워크 드라이브인지 (GetDriveTypeW = DRIVE_REMOTE). 드라이브를 열어 보지 않는다 /// 165: 드라이브 종류 (GetDriveTypeW: 2 꺼내는 장치 (카드 리더 · USB) · 3 고정 · 4 네트워크 · 5 CD). 드라이브를 열어 보지 않는다. 모르면 0
bool isNetworkDrive(String root) { int driveType(String root) {
if (!Platform.isWindows) return false; if (!Platform.isWindows) return 0;
final w = root.toNativeUtf16(); final w = root.toNativeUtf16();
try { try {
final f = DynamicLibrary.open('kernel32.dll') final f = DynamicLibrary.open('kernel32.dll')
.lookupFunction<Uint32 Function(Pointer<Utf16>), int Function(Pointer<Utf16>)>('GetDriveTypeW'); .lookupFunction<Uint32 Function(Pointer<Utf16>), int Function(Pointer<Utf16>)>('GetDriveTypeW');
return f(w) == 4; return f(w);
} catch (_) { } catch (_) {
return false; return 0;
} finally { } finally {
calloc.free(w); calloc.free(w);
} }
} }
/// 네트워크 드라이브인지
bool isNetworkDrive(String root) => driveType(root) == 4;
/// 48: 받는 폴더에 같은 이름이 있을 때 /// 48: 받는 폴더에 같은 이름이 있을 때
enum NameConflict { enum NameConflict {
/// "이름 (2).확장자" 로 (예전 동작) /// "이름 (2).확장자" 로 (예전 동작)

View File

@ -200,6 +200,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
final Map<String, bool?> _driveOk = {}; final Map<String, bool?> _driveOk = {};
final Set<String> _networkDrives = {}; final Set<String> _networkDrives = {};
/// 꺼내는 장치 · CD (카드 리더 · USB): 열 수 없으면 "비어 있음" (연결 안 됨이 아니라 매체가 없는 것)
final Set<String> _removableDrives = {};
Future<void> _probeDrive(String path) async { Future<void> _probeDrive(String path) async {
if (mounted) setState(() => _driveOk[path] = null); if (mounted) setState(() => _driveOk[path] = null);
bool ok; bool ok;
@ -219,7 +222,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
pane.cache.remove(path); pane.cache.remove(path);
await _goTo(pane, path); await _goTo(pane, path);
} else { } else {
_snack(trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path])); _snack(_removableDrives.contains(path)
? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path])
: trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path]));
} }
} }
@ -295,7 +300,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로) // 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로)
if (Platform.isWindows) { if (Platform.isWindows) {
for (final (path, _) in _local) { for (final (path, _) in _local) {
if (isNetworkDrive(path)) _networkDrives.add(path); final type = driveType(path);
if (type == 4) _networkDrives.add(path);
if (type == 2 || type == 5) _removableDrives.add(path);
unawaited(_probeDrive(path)); unawaited(_probeDrive(path));
} }
} }
@ -2620,7 +2627,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
size: 16), size: 16),
// 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기) // 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기)
label: _driveOk[path] == false label: _driveOk[path] == false
? Text('$label · ${tr('연결 안 됨')} ⟳', style: const TextStyle(color: Colors.orangeAccent)) ? Text('$label · ${_removableDrives.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳',
style: TextStyle(color: _removableDrives.contains(path) ? JjColors.textDim : Colors.orangeAccent))
: Text(label), : Text(label),
onPressed: () { onPressed: () {
setState(() => _active = _panes.indexOf(pane)); setState(() => _active = _panes.indexOf(pane));

View File

@ -9,6 +9,8 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/recycle_leftovers.dart';
/// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다 /// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다
void main() { void main() {
late Directory tmp; late Directory tmp;
@ -30,8 +32,10 @@ void main() {
final root = p.rootPrefix(tmp.path); final root = p.rootPrefix(tmp.path);
ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
}); });
tearDown(() { tearDown(() async {
ExplorerPage.debugVolumes = null; ExplorerPage.debugVolumes = null;
// 휴지통을 건드리는 시험 (148 · 144): 사용자 휴지통에 이 시험 폴더 아래의 흔적이 없는지 스스로 확인
expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)');
tmp.deleteSync(recursive: true); tmp.deleteSync(recursive: true);
}); });

View File

@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart'; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/recycle_leftovers.dart';
/// 시험이 휴지통에 넣은 것만 (원래 위치가 이 시험의 임시 폴더인 항목) 휴지통에서 지운다. 사용자 항목은 건드리지 않는다. /// 시험이 휴지통에 넣은 것만 (원래 위치가 이 시험의 임시 폴더인 항목) 휴지통에서 지운다. 사용자 항목은 건드리지 않는다.
Future<int> _purgeFromRecycleBin(String fromDir) async { Future<int> _purgeFromRecycleBin(String fromDir) async {
final dir = fromDir.replaceAll("'", "''"); final dir = fromDir.replaceAll("'", "''");
@ -33,6 +35,7 @@ void main() {
test('파일 · 폴더를 휴지통으로 (실제로 들어갔는지까지) · 없는 것은 그냥 성공', () async { test('파일 · 폴더를 휴지통으로 (실제로 들어갔는지까지) · 없는 것은 그냥 성공', () async {
final tmp = Directory.systemTemp.createTempSync('jj_recycle_'); final tmp = Directory.systemTemp.createTempSync('jj_recycle_');
addTearDown(() async { addTearDown(() async {
expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)');
if (tmp.existsSync()) tmp.deleteSync(recursive: true); if (tmp.existsSync()) tmp.deleteSync(recursive: true);
}); });
final f = File(p.join(tmp.path, 'jj_recycle_test.txt'))..writeAsStringSync('x'); final f = File(p.join(tmp.path, 'jj_recycle_test.txt'))..writeAsStringSync('x');
@ -57,6 +60,7 @@ void main() {
final tmp = Directory.systemTemp.createTempSync('jj_undo_'); final tmp = Directory.systemTemp.createTempSync('jj_undo_');
addTearDown(() async { addTearDown(() async {
await _purgeFromRecycleBin(tmp.absolute.path); // 실패해 남은 것이 있으면 사용자 휴지통에서 지운다 await _purgeFromRecycleBin(tmp.absolute.path); // 실패해 남은 것이 있으면 사용자 휴지통에서 지운다
expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)');
if (tmp.existsSync()) tmp.deleteSync(recursive: true); if (tmp.existsSync()) tmp.deleteSync(recursive: true);
}); });
final since = DateTime.now().subtract(const Duration(seconds: 2)); final since = DateTime.now().subtract(const Duration(seconds: 2));
@ -88,6 +92,7 @@ void main() {
addTearDown(() async { addTearDown(() async {
// 휴지통에 남긴 x.txt 의 원래 위치는 gone 폴더 (정확히 그 폴더로 찾아 지운다) // 휴지통에 남긴 x.txt 의 원래 위치는 gone 폴더 (정확히 그 폴더로 찾아 지운다)
expect(await _purgeFromRecycleBin(folder.path), 1); expect(await _purgeFromRecycleBin(folder.path), 1);
expect(await recycleLeftovers(tmp.path), 0, reason: '사용자 휴지통에 시험 흔적 없음 (하위 폴더 포함)');
if (tmp.existsSync()) tmp.deleteSync(recursive: true); if (tmp.existsSync()) tmp.deleteSync(recursive: true);
}); });
final since = DateTime.now().subtract(const Duration(seconds: 2)); final since = DateTime.now().subtract(const Duration(seconds: 2));

View File

@ -0,0 +1,24 @@
import 'dart:io';
/// 휴지통을 건드리는 시험이 끝날 때: 사용자 휴지통 전체에서, 원래 위치가 [underDir] 아래 (하위 폴더 포함) 인 항목 수.
/// 시험 흔적이 남지 않았는지 시험이 스스로 확인한다 (0 이어야 함). 알 수 없으면 -1.
Future<int> recycleLeftovers(String underDir) async {
if (!Platform.isWindows) return 0;
final dir = File(underDir).absolute.path.replaceAll("'", "''");
final r = await Process.run('powershell.exe', [
'-NoProfile',
'-NonInteractive',
'-Command',
'''
\$rb = (New-Object -ComObject Shell.Application).Namespace(10)
\$root = '$dir'.TrimEnd('\\')
\$n = 0
foreach (\$it in @(\$rb.Items())) {
\$loc = \$rb.GetDetailsOf(\$it, 1)
if (\$loc -ieq \$root -or \$loc.StartsWith(\$root + '\\', [System.StringComparison]::OrdinalIgnoreCase)) { \$n++ }
}
\$n
''',
]);
return int.tryParse('${r.stdout}'.trim().split('\n').last.trim()) ?? -1;
}