377 lines
20 KiB
Dart
377 lines
20 KiB
Dart
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.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/app/i18n_controller.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/job_view.dart' show TransferJobView;
|
|
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
|
import 'package:jj_mkvmaker/ui/notice.dart' show NoticeTiming;
|
|
import 'package:jj_mkvmaker/ui/theme.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../test/support/fake_recycle_bin.dart';
|
|
import 'support/temp_dir.dart';
|
|
import '../test/support/test_explorer_device.dart';
|
|
|
|
/// 탐색기 사용 점검 (관리자 c5 지시 10/22, 분석용 - 커밋하지 않음): 처음 설정 그대로 사용자처럼 써 보며 단계마다 PNG.
|
|
/// 저장 장치는 시험 폴더 둘 (TestExplorerDevice), 휴지통은 가짜 (사용자 휴지통에 흔적을 남기지 않게), 설정은 메모리에만.
|
|
void main() {
|
|
late TestExplorerDevice device;
|
|
setUp(() => device = TestExplorerDevice());
|
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_use');
|
|
|
|
// E6: 두 모양 - X-plore (캡처 이름 use_너비_…, base7 과 견주는 회귀 확인) · Total Commander (Windows 처음 값, use_tc_너비_…)
|
|
for (final style in ['xplore', 'totalcmd']) {
|
|
for (final (w, h) in [(1920, 1040), (960, 600), (412, 900)]) {
|
|
testWidgets('탐색기 사용 $style ${w}x$h', (t) async {
|
|
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
|
t.view.devicePixelRatio = 1;
|
|
addTearDown(t.view.reset);
|
|
await t.runAsync(() => i18n.apply('ko', save: false));
|
|
useTestRecycleBin(device);
|
|
// 캡처 비교가 흔들리지 않게 시험 폴더 이름 · 파일 시각을 고정 (관리자 지시 10/22) - 끝나면 지움
|
|
final tmp = Directory(p.join(Directory.systemTemp.path, 'jj_usechk_fixed_$w'));
|
|
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
|
|
tmp.createSync();
|
|
addTearDown(() {
|
|
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
|
|
});
|
|
appTempInto(tmp);
|
|
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
|
|
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
|
|
for (final n in ['Show S01', 'Movies', 'Docs']) {
|
|
Directory(p.join(a.path, n)).createSync();
|
|
}
|
|
for (var i = 1; i <= 3; i++) {
|
|
File(p.join(a.path, 'Show S01', 'ep0$i.mkv')).writeAsBytesSync(List.filled(5000 * i, 0));
|
|
}
|
|
File(p.join(a.path, 'Movies', 'film.mp4')).writeAsBytesSync(List.filled(9000, 0));
|
|
File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo');
|
|
File(p.join(a.path, 'report.pdf')).writeAsStringSync('pdf');
|
|
for (final f in tmp.listSync(recursive: true).whereType<File>()) {
|
|
f.setLastModifiedSync(DateTime(2026, 10, 22, 9));
|
|
}
|
|
// E6: TC 모양은 폴더 날짜 칸이 있다 - 폴더 시각도 고정 (Dart 는 폴더 시각을 못 바꿔 PowerShell 로)
|
|
Process.runSync('powershell', [
|
|
'-NoProfile',
|
|
'-Command',
|
|
"Get-ChildItem -LiteralPath '${tmp.path}' -Recurse -Directory | ForEach-Object { \$_.LastWriteTime = [datetime]'2026-10-22 09:00' }",
|
|
]);
|
|
device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')];
|
|
// E6: TC 모양 창 위의 남은 공간 - 이 PC 의 디스크 대신 정한 값 (실행마다 같게)
|
|
device.space = (_) => (107374182400, 536870912000);
|
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
|
c.settings.explorer.style = style;
|
|
final shotKey = GlobalKey();
|
|
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
|
|
key: shotKey,
|
|
// 알림이 실제 시간으로 닫히지 않게 (정한 자리에서 noticeTimesOut 으로 닫는다)
|
|
child: NoticeTiming(
|
|
duration: const Duration(hours: 1),
|
|
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(device: device, c: c)),
|
|
),
|
|
)));
|
|
Future<void> settle([int n = 12]) async {
|
|
for (var i = 0; i < n; i++) {
|
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
|
await t.pump();
|
|
}
|
|
}
|
|
|
|
var no = 0;
|
|
Future<void> shot(String name) async {
|
|
await settle(3);
|
|
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
|
final img = await t.runAsync(() => r.toImage());
|
|
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
|
Directory(out).createSync(recursive: true);
|
|
no++;
|
|
File(p.join(out, '${style == 'totalcmd' ? 'use_tc' : 'use'}_${w}_${no.toString().padLeft(2, '0')}_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
|
}
|
|
|
|
// 열린 창 (대화 상자 · 메뉴) 의 글을 기록 - 무엇이 떴는지
|
|
void dump(String step) {
|
|
final texts = <String>{};
|
|
for (final f in [find.byType(Dialog), find.byType(PopupMenuItem), find.byType(BottomSheet), find.byType(SnackBar)]) {
|
|
for (final e in find.descendant(of: f, matching: find.byType(Text)).evaluate()) {
|
|
final s = (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? '';
|
|
if (s.isNotEmpty) texts.add(s);
|
|
}
|
|
}
|
|
// ignore: avoid_print
|
|
print('[${style == 'totalcmd' ? 'tc ' : ''}$w] $step: ${texts.isEmpty ? '(뜬 창 없음)' : texts.join(' | ')}');
|
|
}
|
|
|
|
// 복사 · 이동이 끝나 진행 패널이 다 내려갈 때까지 (실제 시간, 10초까지) - 캡처가 PC 빠르기에 따라 달라지지 않게
|
|
// 알림은 실제 시간 10초 (앱의 값) 로 닫히면 찍는 때와 겹쳐 장면이 실행마다 달라진다 (14 · 27) -
|
|
// 이 시험은 NoticeTiming 으로 저절로 닫히지 않게 두고, 정한 자리에서 "시간이 다 되어 닫힘" 으로 닫는다 (앱의 알림 시간은 그대로)
|
|
Future<void> noticeTimesOut() async {
|
|
t.state<ScaffoldMessengerState>(find.byType(ScaffoldMessenger).first).removeCurrentSnackBar();
|
|
await settle(3);
|
|
}
|
|
|
|
// 복사 · 이동 · 지우기는 받는 폴더의 시각을 지금으로 바꾼다 - TC 는 폴더 날짜 칸이 있어 다시 고정하고 새로 읽는다 (Ctrl+R)
|
|
Future<void> fixDirTimes() async {
|
|
await t.runAsync(() => Process.run('powershell', [
|
|
'-NoProfile',
|
|
'-Command',
|
|
"Get-ChildItem -LiteralPath '${tmp.path}' -Recurse -Directory | ForEach-Object { \$_.LastWriteTime = [datetime]'2026-10-22 09:00' }",
|
|
]));
|
|
await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
|
await t.sendKeyEvent(LogicalKeyboardKey.keyR);
|
|
await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
|
await settle();
|
|
}
|
|
|
|
Future<void> jobsGone() async {
|
|
for (var i = 0; i < 100 && find.byType(TransferJobView).evaluate().isNotEmpty; i++) {
|
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
|
|
await t.pump(const Duration(milliseconds: 100));
|
|
}
|
|
await settle();
|
|
if (style == 'totalcmd') await fixDirTimes();
|
|
}
|
|
|
|
// 한 단계: 실패해도 다음 단계로 (못 찾은 것 자체가 점검 결과)
|
|
Future<void> step(String name, Future<void> Function() act) async {
|
|
try {
|
|
await act();
|
|
await settle();
|
|
dump(name);
|
|
} catch (e) {
|
|
// ignore: avoid_print
|
|
print('[${style == 'totalcmd' ? 'tc ' : ''}$w] $name: 실패 - ${e.toString().split('\n').first}');
|
|
}
|
|
await shot(name);
|
|
}
|
|
|
|
// 두 창이면 왼쪽 창의 것만 (같은 폴더가 두 창에 보여 오른쪽 것을 누르지 않게)
|
|
Finder leftOf(Finder f) {
|
|
final half = w / 2; // 규칙 7: 폰 세로도 두 창
|
|
final el = f.evaluate().where((e) => t.getCenter(find.byWidget(e.widget).first).dx < half).toList();
|
|
if (el.isEmpty) throw StateError('왼쪽 창에 없음');
|
|
return find.byWidget(el.first.widget).first;
|
|
}
|
|
// TC 는 폴더를 [이름], 파일은 확장자를 뺀 이름으로 보인다 (드라이브 탭 C: · D: 는 그대로)
|
|
String shown(String name) => style != 'totalcmd' || name.endsWith(':')
|
|
? name
|
|
: name.contains('.')
|
|
? name.substring(0, name.lastIndexOf('.'))
|
|
: '[$name]';
|
|
// 좁은 TC 줄 (확장자 칸 없음) 은 이름에 확장자를 붙인다 - 두 꼴 가운데 보이는 것
|
|
Finder txt(String name) {
|
|
final a = find.text(shown(name));
|
|
return leftOf(a.evaluate().isNotEmpty ? a : find.text(name));
|
|
}
|
|
Finder row(String name) => find.ancestor(of: txt(name), matching: find.byType(InkWell)).first;
|
|
Finder tip(String s) => find.byWidgetPredicate((x) => x is Tooltip && (x.message ?? '').startsWith(s)).first;
|
|
// 줄 끝 표시 단추: 이름 글과 같은 높이의 것
|
|
Finder mark(String name) {
|
|
final y = t.getCenter(txt(name)).dy;
|
|
final half = w / 2; // 규칙 7: 폰 세로도 두 창
|
|
final all = find.byWidgetPredicate((x) => x is IconButton && (x.tooltip ?? '').startsWith('표시'));
|
|
final best = all.evaluate().where((e) => t.getCenter(find.byWidget(e.widget)).dx < half).toList()
|
|
..sort((m, n) => (t.getCenter(find.byWidget(m.widget)).dy - y).abs().compareTo((t.getCenter(find.byWidget(n.widget)).dy - y).abs()));
|
|
return find.byWidget(best.first.widget);
|
|
}
|
|
// 대화 상자의 확인 쪽 단추 (마지막 FilledButton)
|
|
Future<void> okDialog() => t.tap(find.descendant(of: find.byType(Dialog), matching: find.byType(FilledButton)).last);
|
|
void files(String when) {
|
|
final left = [
|
|
for (final e in tmp.listSync(recursive: true))
|
|
if (e is File) p.relative(e.path, from: tmp.path)
|
|
]..sort();
|
|
// ignore: avoid_print
|
|
print('[${style == 'totalcmd' ? 'tc ' : ''}$w] 파일 ($when): ${left.join(', ')}');
|
|
}
|
|
Finder paneBtn(String s) => find.byWidgetPredicate((x) => x is IconButton && (x.tooltip ?? '').startsWith(s)).first;
|
|
|
|
await settle(30);
|
|
await shot('처음');
|
|
dump('처음');
|
|
await step('Show S01 누르기', () => t.tap(txt('Show S01')));
|
|
await step('Show S01 다시 누르기 (접히나)', () => t.tap(txt('Show S01')));
|
|
// 펼쳐지지 않았으면 한 번 더 (TC 목록은 두 번 누르기로 폴더 안에 들어가 [Show S01] 이 없을 수 있다 - 그러면 그대로)
|
|
if (find.text(shown('ep01.mkv')).evaluate().isEmpty) {
|
|
try {
|
|
await t.tap(txt('Show S01'));
|
|
} on StateError catch (_) {}
|
|
}
|
|
await settle();
|
|
await step('ep01 한 번 누르기 (선택)', () => t.tap(txt('ep01.mkv')));
|
|
// 규칙 4 (E3): 표시는 선택 모드에서만 - [선택] 을 켜고 표시
|
|
Future<void> selectOn() async {
|
|
if (find.text('선택 끝').evaluate().isEmpty) await t.tap(tip('선택'));
|
|
}
|
|
await step('선택 켜기', selectOn);
|
|
await step('ep02 표시 단추', () => t.tap(mark('ep02.mkv')));
|
|
await step('ep03 표시 단추', () => t.tap(mark('ep03.mkv')));
|
|
await step('복사 단추', () => t.tap(tip('복사')));
|
|
await step('복사 확인 누르기', () async {
|
|
if (find.byType(Dialog).evaluate().isNotEmpty) {
|
|
await okDialog();
|
|
} else {
|
|
await t.tap(txt('D:'));
|
|
await settle();
|
|
await t.tap(tip('붙여넣기'));
|
|
await settle();
|
|
if (find.byType(Dialog).evaluate().isNotEmpty) await okDialog();
|
|
await settle();
|
|
await t.tap(txt('C:'));
|
|
}
|
|
await jobsGone();
|
|
});
|
|
await settle(20);
|
|
files('복사 뒤');
|
|
await step('선택 모드 켜기 (켜져 있으면 그대로)', selectOn);
|
|
await step('선택 모드 - Show S01 누르기', () => t.tap(txt('Show S01')));
|
|
await step('선택 모드 - 안의 ep01 누르기', () => t.tap(txt('ep01.mkv')));
|
|
await step('선택 모드 - Show S01 다시 누르기', () => t.tap(txt('Show S01')));
|
|
await step('선택 단추 (끄기)', () => t.tap(tip('선택')));
|
|
await noticeTimesOut(); // 복사 알림: 선택을 끈 뒤에는 닫혀 있음
|
|
await step('ep03 오른쪽 클릭', () => t.tap(row('ep03.mkv'), buttons: kSecondaryMouseButton));
|
|
await step('메뉴 이름 변경', () => t.tap(find.text('이름 변경').last));
|
|
await step('새 이름 넣고 확인', () async {
|
|
await t.enterText(find.descendant(of: find.byType(Dialog), matching: find.byType(TextField)), 'ep03_new.mkv');
|
|
await settle(2);
|
|
await okDialog();
|
|
});
|
|
files('이름 바꾼 뒤');
|
|
await step('ep01 오른쪽 클릭', () => t.tap(row('ep01.mkv'), buttons: kSecondaryMouseButton));
|
|
await step('메뉴 삭제', () => t.tap(find.text('삭제').last));
|
|
await step('삭제 확인 누르기', okDialog);
|
|
files('지운 뒤');
|
|
await step('Movies 누르기', () => t.tap(txt('Movies')));
|
|
await step('창 뒤로', () => t.tap(paneBtn('뒤로')));
|
|
await step('창 앞으로', () => t.tap(paneBtn('앞으로')));
|
|
await step('선택 켜기 2', selectOn);
|
|
await step('film 표시 단추', () => t.tap(mark('film.mp4')));
|
|
await step('이동 단추', () => t.tap(tip('이동')));
|
|
await noticeTimesOut(); // 휴지통 알림: 이동을 누르기 전에는 닫혀 있음 (이동 창 뒤에는 아직 보임)
|
|
await step('이동 확인 누르기', () async {
|
|
if (find.byType(Dialog).evaluate().isNotEmpty) {
|
|
await okDialog();
|
|
} else {
|
|
await t.tap(txt('D:'));
|
|
await settle();
|
|
await t.tap(tip('붙여넣기'));
|
|
}
|
|
await jobsGone();
|
|
});
|
|
await settle(20);
|
|
files('이동 뒤');
|
|
await step('notes 누르고 Space (TC 식 고르기)', () async {
|
|
await t.tap(txt('notes.txt'));
|
|
await settle(4);
|
|
await t.sendKeyEvent(LogicalKeyboardKey.space);
|
|
});
|
|
await step('Insert', () => t.sendKeyEvent(LogicalKeyboardKey.insert));
|
|
// TC: 앞 단계의 글만 있는 알림 (Flutter 기본 4초) 이 찍는 때에 따라 남거나 사라졌다 - 여기서 닫힌 것으로 (X-plore 는 base7 그대로)
|
|
if (style == 'totalcmd') await noticeTimesOut();
|
|
await step('report 누르고 Enter (열기 대신 무엇이)', () async {
|
|
await t.tap(txt('report.pdf'));
|
|
await settle(4);
|
|
});
|
|
// TC 전용 (관리자 지시 10/23): 목록 들어가고 나오기 (..) · 아래 단추 줄 F5 복사 · F6 이동 · F8 삭제 · 지운 뒤 초점
|
|
if (style == 'totalcmd') {
|
|
Future<void> fkey(String tip) async {
|
|
await t.tap(find.byTooltip(tip));
|
|
await settle();
|
|
if (find.byType(Dialog).evaluate().isNotEmpty) await okDialog();
|
|
await jobsGone(); // TC 는 jobsGone 이 폴더 시각을 다시 고정한다
|
|
}
|
|
|
|
await noticeTimesOut();
|
|
// 선택 모드면 끈다 (선택 모드의 누르기는 표시 - 들어가기를 보려면 끈다)
|
|
await step('TC - 선택 끝', () async {
|
|
if (find.text('선택 끝').evaluate().isNotEmpty) await t.tap(find.text('선택 끝').first);
|
|
});
|
|
// 맨 위 (Videos) 로: [Docs] 가 왼쪽 창에 보일 때까지 Backspace (위 폴더 키) - 두 번 누르기는 위로 간 뒤 같은 자리의 다른 줄을 열었다
|
|
await step('TC - Backspace 로 맨 위까지', () async {
|
|
// 왼쪽 창을 지금 창으로 (창 위 남은 공간 줄 - 누르기 동작이 없는 자리)
|
|
await t.tap(leftOf(find.textContaining('남은')));
|
|
await settle(10);
|
|
bool top() => find.text(shown('Docs')).evaluate().any((e) => t.getCenter(find.byWidget(e.widget).first).dx < w / 2);
|
|
for (var i = 0; i < 4 && !top(); i++) {
|
|
await t.sendKeyEvent(LogicalKeyboardKey.backspace);
|
|
await settle();
|
|
}
|
|
});
|
|
// 들어가기 · 나오기 (관리자 10/23: TC 는 한 번 = 고르기, 두 번 · Enter = 열기). 자리를 한 번만 재고 실제 시간 60ms 사이로 두 번
|
|
Future<void> doubleTapOn(Finder f) async {
|
|
final at = t.getCenter(f);
|
|
await t.tapAt(at);
|
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
|
await t.tapAt(at);
|
|
await settle();
|
|
}
|
|
|
|
await step('TC - [Docs] 한 번 (고르기만)', () async {
|
|
await t.tap(txt('Docs'));
|
|
await settle(10);
|
|
});
|
|
await step('TC - Enter (들어가기)', () async {
|
|
await t.sendKeyEvent(LogicalKeyboardKey.enter);
|
|
await settle();
|
|
});
|
|
await step('TC - .. 두 번 (나오기)', () => doubleTapOn(leftOf(find.text('..'))));
|
|
await step('TC - [Docs] 두 번 (들어가기)', () => doubleTapOn(txt('Docs')));
|
|
await step('TC - .. 두 번 (다시 나오기)', () => doubleTapOn(leftOf(find.text('..'))));
|
|
await step('TC - notes 고르고 F5 복사', () async {
|
|
await t.tap(txt('notes.txt'));
|
|
await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다
|
|
await fkey('F5 복사');
|
|
});
|
|
await noticeTimesOut();
|
|
await step('TC - report 고르고 F6 이동', () async {
|
|
await t.tap(txt('report.pdf'));
|
|
await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다
|
|
await fkey('F6 이동');
|
|
});
|
|
await noticeTimesOut();
|
|
await step('TC - notes 고르고 F8 삭제 (지운 뒤 초점)', () async {
|
|
await t.tap(txt('notes.txt'));
|
|
await settle(10); // 한 번 누르기는 두 번 누르기 기다림 (약 300ms) 뒤에 처리된다
|
|
await fkey('F8 삭제');
|
|
});
|
|
files('TC 단계 뒤');
|
|
}
|
|
// 관리자 10/23: "폴더 + 파일 목록" 배치 - 목록의 폴더는 한 번에 열리고, 습관처럼 곧바로 누른 두 번째 누르기는 버린다 (도로 나오지 않음)
|
|
if (style == 'xplore' && w >= 960) {
|
|
await step('목록 배치 - 폴더 + 파일 목록으로', () async {
|
|
await c.updateSettings((x) => x.explorer.layout = 'split');
|
|
await settle(20);
|
|
});
|
|
await step('목록 배치 - 오른쪽 목록 Movies 한 번 + 곧바로 두 번째', () async {
|
|
final row = find.text('Movies').last; // 오른쪽 목록의 줄
|
|
final at = t.getCenter(row);
|
|
await t.tapAt(at);
|
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
|
await t.tapAt(at);
|
|
await settle(20);
|
|
});
|
|
}
|
|
// 끝 상태: 실제 파일
|
|
final left = [
|
|
for (final e in tmp.listSync(recursive: true))
|
|
if (e is File) p.relative(e.path, from: tmp.path)
|
|
]..sort();
|
|
// ignore: avoid_print
|
|
print('[${style == 'totalcmd' ? 'tc ' : ''}$w] 끝 파일: ${left.join(', ')}');
|
|
await t.pumpWidget(const SizedBox());
|
|
await settle(4);
|
|
});
|
|
}
|
|
}
|
|
}
|