import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/core/shizuku.dart'; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/settings_page.dart'; import 'package:path/path.dart' as p; import 'support/test_explorer_device.dart'; /// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 · 처음 끔 · 경고 두 번 - 관리자 결정 10/21) class _Fake implements ShizukuBackend { bool running = true, granted = false, allow = true; final dirs = {}; final files = {}; final calls = []; @override Future<({bool running, bool granted})> status() async => (running: running, granted: granted); @override Future request() async { calls.add('request'); granted = allow; return allow; } @override Future list(String path) async { calls.add('list:$path'); final d = dirs[path]; if (d == null) throw Exception('Permission denied'); return d; } @override Future stat(String path) async => files.containsKey(path) ? 'regular file|${files[path]!.length}|1700000000|${p.basename(path)}' : ''; @override Future copyTo(String path, String dest) async { calls.add('copy:$path'); File(dest).writeAsStringSync(files[path]!); return files[path]!.length; } } void main() { late TestExplorerDevice device; setUp(() => device = TestExplorerDevice()); late Directory tmp; late _Fake fake; final wasBackend = ShizukuRemote.backend; final wasTemp = ShizukuRemote.tempDir; final wasSupported = shizukuSupported; setUp(() { tmp = Directory.systemTemp.createTempSync('jj_shz_'); fake = _Fake(); ShizukuRemote.backend = fake; ShizukuRemote.tempDir = () async => tmp.path; shizukuSupported = () => true; ExplorerPage.shizukuWarnedThisRun = false; }); tearDown(() { ShizukuRemote.backend = wasBackend; ShizukuRemote.tempDir = wasTemp; shizukuSupported = wasSupported; tmp.deleteSync(recursive: true); }); test('stat 한 줄 읽기: 폴더 · 파일 · 이름에 | · 틀린 줄', () { expect(parseShizukuStat('directory|3452|1700000000|com.app'), (true, 3452, DateTime.fromMillisecondsSinceEpoch(1700000000000), 'com.app')); expect(parseShizukuStat('regular file|12|1700000001|a|b.txt')?.$4, 'a|b.txt'); expect(parseShizukuStat('regular empty file|0|1700000001|e'), isNotNull); expect(parseShizukuStat('stat: x: Permission denied'), isNull); expect(parseShizukuStat(''), isNull); }); test('경로 · 클라이언트: shz://sys/<절대 경로> → 읽기 전용 클라이언트, 목록 · 정보 · 내용 읽기 (임시는 지움) · 쓰기 · 지우기 · 옮기기 · 새 폴더는 막힘', () async { const dir = '/storage/emulated/0/Android/data'; fake.dirs[dir] = 'directory|4096|1700000000|com.game\nregular file|5|1700000000|note.txt\nbad line\n'; fake.files['$dir/note.txt'] = 'hello'; final path = '$shizukuScheme$shizukuServer$dir'; expect(isDav(path) && isShizuku(path) && noStreamUrl(path), isTrue); final d = DavPath.parse(path); expect((d.rel, d.mark, d.serverKnown), (dir, '🛡', true)); final c = d.client; expect(c, isA()); final items = await c.list(dir); expect(items.map((x) => (x.rel, x.isDir, x.size)), [('$dir/com.game', true, 0), ('$dir/note.txt', false, 5)]); expect((await c.stat('$dir/note.txt'))?.size, 5); expect(await c.stat('$dir/none'), isNull); final bytes = (await (await c.openRead('$dir/note.txt')).toList()).expand((x) => x).toList(); expect(String.fromCharCodes(bytes), 'hello'); await Future.delayed(const Duration(milliseconds: 50)); expect(tmp.listSync().where((e) => p.basename(e.path).startsWith('jj_shz_')), isEmpty, reason: '받아 둔 임시는 지움'); for (final f in Function()>[ () => c.write('$dir/x', const Stream.empty()), () => c.delete('$dir/note.txt'), () => c.move('$dir/note.txt', '$dir/y'), () => c.mkdirs('$dir/new'), ]) { await expectLater(f(), throwsA(isA())); } // 탐색기가 쓰는 vfs 길로도 final listed = await vList(path); expect(listed.map((e) => p.posix.basename(e.path)), ['com.game', 'note.txt']); expect(shizukuHome('/storage/emulated/0'), 'shz://sys/storage/emulated/0/Android'); }); AppController ctl({bool on = true}) { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..shizukuEnabled = on ..explorerPaths = [tmp.path, tmp.path] ..explorerLayout = 'single'; return c; } Future wait(WidgetTester t, bool Function() ok) async { for (var i = 0; i < 200 && !ok(); i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await t.pump(const Duration(milliseconds: 20)); } for (var i = 0; i < 8; i++) { await t.pump(const Duration(milliseconds: 50)); } } testWidgets('탐색기: 켰을 때만 [시스템 (읽기 전용)] 탭 · 처음 열 때 경고 · 허락 창 · Android 폴더 · 주황 띠 · 지우기는 막힘 · 다시 열 땐 경고 없음', (t) async { t.view.physicalSize = const Size(1400, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); // 탭이 여는 곳은 첫 저장 장치의 Android (기기 경로) - 그 저장 장치는 이 PC 에서 열리지 않아도 됨 device.volumes = () => [('/storage/emulated/0', 'T:')]; const home = '/storage/emulated/0/Android'; // 트리는 위 폴더부터 읽는다 (기기에서는 adb 셸이 / · /storage 를 읽을 수 있음) fake.dirs['/'] = 'directory|0|1700000000|storage\n'; fake.dirs['/storage'] = 'directory|0|1700000000|emulated\n'; fake.dirs['/storage/emulated'] = 'directory|0|1700000000|0\n'; fake.dirs['/storage/emulated/0'] = 'directory|0|1700000000|Android\n'; fake.dirs[home] = 'directory|4096|1700000000|data\ndirectory|4096|1700000000|obb\n'; fake.dirs['$home/data'] = 'directory|4096|1700000000|com.game\n'; // 꺼져 있으면 탭 없음 await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl(on: false))))); await wait(t, () => false); expect(find.text('시스템 (읽기 전용)'), findsNothing); // 켬 await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl())))); await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('시스템 (읽기 전용)').first); await wait(t, () => find.text('알고 봅니다').evaluate().isNotEmpty); expect(find.textContaining('다른 앱의 데이터'), findsOneWidget); await t.tap(find.text('알고 봅니다')); await wait(t, () => find.text('obb').evaluate().isNotEmpty); expect(fake.calls, contains('request'), reason: '허락을 받지 않았으면 Shizuku 허락 창'); expect(find.text('data'), findsWidgets); expect(find.text('시스템 폴더 (Shizuku) - 읽기 전용'), findsWidgets); // 지우기는 막힘 (꺼내기만) await t.tap(find.text('obb').last); await wait(t, () => false); await t.tap(find.text('삭제').first); await wait(t, () => find.textContaining('읽기 전용입니다').evaluate().isNotEmpty); expect(find.textContaining('시스템 폴더 (Shizuku) 는 읽기 전용입니다'), findsOneWidget); expect(find.text('obb'), findsWidgets, reason: '지워지지 않음'); await t.tap(find.text('이름 변경').first); await wait(t, () => false); expect(find.byType(TextField), findsNothing, reason: '이름 바꾸기 창도 열리지 않음'); // 28 지적 · 관리자 지시 (10/21): 이동은 시작 전에 막음 (예전에는 복사는 되고 원본 지우기에서 오류) ScaffoldMessenger.of(t.element(find.byType(ExplorerPage))).clearSnackBars(); await wait(t, () => false); await t.tap(find.text('이동').first); await wait(t, () => find.textContaining('옮길 수 없습니다').evaluate().isNotEmpty); expect(find.text('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요'), findsOneWidget); expect(find.textContaining('담았습니다'), findsNothing, reason: '이동으로 담지도 않음'); expect(fake.calls.where((x) => x.startsWith('copy:')), isEmpty); // 다시 열기: 경고 없이 (이번 실행에서 이미 봄) await t.pumpWidget(const SizedBox()); await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl())))); await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('시스템 (읽기 전용)').first); await wait(t, () => find.text('obb').evaluate().isNotEmpty); expect(find.text('알고 봅니다'), findsNothing); await t.pumpWidget(const SizedBox()); await t.pump(const Duration(seconds: 1)); }); testWidgets('탐색기: Shizuku 가 꺼져 있거나 허락하지 않으면 알리고 들어가지 않음', (t) async { t.view.physicalSize = const Size(1400, 900); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); device.volumes = () => [(tmp.path, 'T:')]; ExplorerPage.shizukuWarnedThisRun = true; fake.running = false; await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), device: device, c: ctl())))); await wait(t, () => find.text('시스템 (읽기 전용)').evaluate().isNotEmpty); await t.tap(find.text('시스템 (읽기 전용)').first); await wait(t, () => find.textContaining('Shizuku 가 켜져 있지 않습니다').evaluate().isNotEmpty); expect(find.textContaining('Shizuku 가 켜져 있지 않습니다'), findsOneWidget); fake ..running = true ..allow = false; await t.tap(find.text('시스템 (읽기 전용)').first); await wait(t, () => find.text('Shizuku 허락이 없어 볼 수 없습니다').evaluate().isNotEmpty); expect(find.text('Shizuku 허락이 없어 볼 수 없습니다'), findsOneWidget); expect(fake.calls.where((x) => x.startsWith('list:')), isEmpty, reason: '들어가지 않음'); await t.pumpWidget(const SizedBox()); await t.pump(const Duration(seconds: 1)); }); testWidgets('설정: 켤 때 경고 창 (체크해야 [켜기]) · 취소면 그대로 · 끄기는 바로', (t) async { t.view.physicalSize = const Size(1200, 4000); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); final c = ctl(on: false); await t.pumpWidget(MaterialApp(home: SettingsPage(c: c))); await t.pump(); final tile = find.text('시스템 폴더 보기 (Shizuku · 읽기 전용)'); await t.scrollUntilVisible(tile, 300, scrollable: find.byType(Scrollable).first); final sw = find.descendant(of: find.ancestor(of: tile, matching: find.byType(ListTile)).first, matching: find.byType(Switch)); Future settle() async { for (var i = 0; i < 10; i++) { await t.pump(const Duration(milliseconds: 50)); } } await t.tap(sw.first); await settle(); expect(find.text('시스템 폴더 보기를 켤까요?'), findsOneWidget); expect(t.widget(find.widgetWithText(FilledButton, '켜기')).onPressed, isNull, reason: '체크해야 켜짐'); await t.tap(find.text('취소')); await settle(); expect(c.settings.shizukuEnabled, isFalse); await t.tap(sw.first); await settle(); await t.tap(find.text('위 내용을 알고 켭니다')); await t.pump(); await t.tap(find.widgetWithText(FilledButton, '켜기')); await settle(); expect(c.settings.shizukuEnabled, isTrue); await t.tap(sw.first); await settle(); expect(c.settings.shizukuEnabled, isFalse, reason: '끄기는 묻지 않음'); }); }