JJ_MKVMaker/test/shizuku_test.dart

246 lines
12 KiB
Dart

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';
import 'package:jj_mkvmaker/services/platform_info.dart';
/// X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 · 처음 끔 · 경고 두 번 - 관리자 결정 10/21)
class _Fake implements ShizukuBackend {
bool running = true, granted = false, allow = true;
final dirs = <String, String>{};
final files = <String, String>{};
final calls = <String>[];
@override
Future<({bool running, bool granted})> status() async => (running: running, granted: granted);
@override
Future<bool> request() async {
calls.add('request');
granted = allow;
return allow;
}
@override
Future<String> list(String path) async {
calls.add('list:$path');
final d = dirs[path];
if (d == null) throw Exception('Permission denied');
return d;
}
@override
Future<String> stat(String path) async => files.containsKey(path) ? 'regular file|${files[path]!.length}|1700000000|${p.basename(path)}' : '';
@override
Future<int> 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 was = PrivilegedFs.current;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_shz_');
fake = _Fake();
// 설계 M3: 가짜 Shizuku 뒤쪽 · 시험 임시 폴더를 PrivilegedFs 하나로 건다 (루트 쪽은 그대로)
PrivilegedFs.use(PrivilegedFs(shizuku: fake, root: was.root, tempDir: () async => tmp.path));
ExplorerPage.shizukuWarnedThisRun = false;
});
tearDown(() {
PrivilegedFs.use(was);
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<ShizukuRemote>());
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<void>.delayed(const Duration(milliseconds: 50));
expect(tmp.listSync().where((e) => p.basename(e.path).startsWith('jj_shz_')), isEmpty, reason: '받아 둔 임시는 지움');
for (final f in <Future<void> 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<ShizukuReadOnlyException>()));
}
// 탐색기가 쓰는 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}) {
// 시스템 폴더 · 루트 탭은 Android 에서만 (설계 M3 - 예전 전역 함수 대신 PlatformInfo 주입)
final c = AppController(
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android));
c.settings.explorer.style = 'xplore'; // E6: 이 시험은 X-plore 모양 (트리) 을 본다 - Windows 처음 값은 Total Commander
c.settings
..tools.shizukuEnabled = on
..explorer.paths = [tmp.path, tmp.path]
..explorer.layout = 'single';
return c;
}
Future<void> wait(WidgetTester t, bool Function() ok) async {
for (var i = 0; i < 200 && !ok(); i++) {
await t.runAsync(() => Future<void>.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<void> 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<FilledButton>(find.widgetWithText(FilledButton, '켜기')).onPressed, isNull, reason: '체크해야 켜짐');
await t.tap(find.text('취소'));
await settle();
expect(c.settings.tools.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.tools.shizukuEnabled, isTrue);
await t.tap(sw.first);
await settle();
expect(c.settings.tools.shizukuEnabled, isFalse, reason: '끄기는 묻지 않음');
});
}