271 lines
13 KiB
Dart
271 lines
13 KiB
Dart
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind;
|
|
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_page.dart';
|
|
import 'package:jj_mkvmaker/ui/theme.dart';
|
|
import 'support/temp_dir.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
/// 관리자 지시 9 (10/17): 82 끌어 놓기 · 75 마우스 4 · 5 번을 Windows 실제 빌드에서 가짜 포인터로 (실제 마우스 · 키보드는 쓰지 않음).
|
|
/// 82: 8px 문턱 (안 넘으면 끌기 아님) · 놓을 수 있는 창 강조 / 같은 창은 빨간 테두리 · 묻기 창 · 이동 뒤 [되돌리기].
|
|
/// 75: 마우스가 올라간 창의 뒤로 · 앞으로 (4 · 5 번) · Alt+← / →.
|
|
/// 시험 파일은 임시 폴더에만 (끝나면 지움), 설정은 메모리에만. 실행: flutter test integration_test\windows_b6_input_test.dart -d windows
|
|
void main() {
|
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
late Directory tmp;
|
|
late String left, right;
|
|
late AppController c;
|
|
final shotKey = GlobalKey();
|
|
|
|
setUp(() async {
|
|
await i18n.apply('ko', save: false);
|
|
tmp = testTempDir('jj_b6input_');
|
|
left = p.join(tmp.path, 'left');
|
|
right = p.join(tmp.path, 'right');
|
|
File(p.join(left, 'doc.txt'))
|
|
..createSync(recursive: true)
|
|
..writeAsStringSync('hello');
|
|
File(p.join(left, 'sub', 'inner.txt'))
|
|
..createSync(recursive: true)
|
|
..writeAsStringSync('inner');
|
|
File(p.join(right, 'note.txt'))
|
|
..createSync(recursive: true)
|
|
..writeAsStringSync('n');
|
|
File(p.join(right, 'r1', 'x.txt'))
|
|
..createSync(recursive: true)
|
|
..writeAsStringSync('x');
|
|
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
|
c.settings
|
|
..explorer.style = 'xplore' // 이 시험은 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 폴더는 [이름], 파일은 이름 · 확장자가 다른 칸)
|
|
..explorer.paths = [left, right]
|
|
..explorer.dropAction = 'ask';
|
|
});
|
|
tearDown(() async {
|
|
for (var i = 0;; i++) {
|
|
try {
|
|
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
|
|
return;
|
|
} on FileSystemException {
|
|
if (i >= 50) rethrow;
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
}
|
|
}
|
|
});
|
|
|
|
/// 실제 시간으로 [done] 까지 (최대 약 [ms] 밀리초)
|
|
Future<void> until(WidgetTester t, bool Function() done, {int ms = 8000}) async {
|
|
for (var i = 0; i < ms ~/ 50 && !done(); i++) {
|
|
await t.pump(const Duration(milliseconds: 50));
|
|
}
|
|
}
|
|
|
|
Future<void> open(WidgetTester t) async {
|
|
t.view.physicalSize = const Size(1600, 900);
|
|
t.view.devicePixelRatio = 1;
|
|
addTearDown(t.view.reset);
|
|
await t.pumpWidget(MaterialApp(
|
|
theme: buildTheme(),
|
|
debugShowCheckedModeBanner: false,
|
|
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
|
|
home: ExplorerPage(c: c)));
|
|
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty && find.text('r1').evaluate().isNotEmpty);
|
|
expect(find.text('doc.txt'), findsOneWidget);
|
|
}
|
|
|
|
/// 창 테두리: 놓을 수 있음 (강조색 2px) · 못 놓음 (빨간 2px) 인 창 수
|
|
int frames(Color color) => find
|
|
.byWidgetPredicate((w) =>
|
|
w is Container &&
|
|
w.decoration is BoxDecoration &&
|
|
((w.decoration! as BoxDecoration).border as Border?)?.top.color == color &&
|
|
((w.decoration! as BoxDecoration).border as Border?)?.top.width == 2)
|
|
.evaluate()
|
|
.length;
|
|
|
|
/// 오른쪽 창의 [name] (두 창에 같은 이름이면 오른쪽 것)
|
|
Offset rightOf(WidgetTester t, String name) {
|
|
final all = find.text(name).evaluate().map((e) => t.getCenter(find.byWidget(e.widget).first)).toList()
|
|
..sort((x, y) => x.dx.compareTo(y.dx));
|
|
return all.last;
|
|
}
|
|
|
|
/// 폴더 줄 강조 (놓으면 그 폴더 안으로) 수
|
|
int rowLit() => find
|
|
.byWidgetPredicate((w) =>
|
|
w is DecoratedBox &&
|
|
w.decoration is BoxDecoration &&
|
|
((w.decoration as BoxDecoration).border as Border?)?.top.color == JjColors.accent &&
|
|
((w.decoration as BoxDecoration).border as Border?)?.top.width == 1.5)
|
|
.evaluate()
|
|
.length;
|
|
|
|
int feedback() => find.byIcon(Icons.drive_file_move_outline).evaluate().length;
|
|
|
|
/// 관리자 지시 (10/20): 실패 때의 화면 캡처 (JJ_SHOT_DIR) 와 그때 열린 창 · 단추 · 글 목록
|
|
Future<void> dump(WidgetTester t, String what) async {
|
|
final texts = [for (final e in find.byType(Text).evaluate()) (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? ''];
|
|
final buttons = [
|
|
for (final e in find.byWidgetPredicate((w) => w is ButtonStyleButton || w is SnackBarAction).evaluate())
|
|
'${e.widget.runtimeType}: ${[for (final x in find.descendant(of: find.byWidget(e.widget), matching: find.byType(Text)).evaluate()) (x.widget as Text).data].join(' ')}'
|
|
];
|
|
// ignore: avoid_print
|
|
print('== $what 실패 때: 창 ${find.byType(Dialog).evaluate().length} · 스낵바 ${find.byType(SnackBar).evaluate().length}\n단추: $buttons\n글: $texts');
|
|
final dir = Platform.environment['JJ_SHOT_DIR'];
|
|
final b = shotKey.currentContext?.findRenderObject();
|
|
if (dir == null || dir.isEmpty || b is! RenderRepaintBoundary) return;
|
|
final img = await t.runAsync(() => b.toImage());
|
|
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
|
File(p.join(dir, 'b6_input_fail_${what.hashCode.toRadixString(16)}.png'))
|
|
..createSync(recursive: true)
|
|
..writeAsBytesSync(bytes!.buffer.asUint8List());
|
|
}
|
|
|
|
/// 관리자 지시 (10/20): [f] 가 보이는 것만이 아니라 정말 눌리는 자리에 올 때까지 기다린 뒤 누른다.
|
|
/// 스낵바는 아래에서 올라오는 동안 (약 0.25초) 글은 이미 있지만 그 자리를 누르면 아무것도 맞지 않는다 - 예전에는 그때 눌러 [되돌리기] 가 빗나갔다.
|
|
/// 끝내 눌리지 않으면 캡처 · 목록을 남기고 실패
|
|
Future<void> tapReady(WidgetTester t, Finder f, String what, {int ms = 8000}) async {
|
|
final sw = Stopwatch()..start();
|
|
int? seen;
|
|
bool within(Object? target, RenderObject box) {
|
|
for (RenderObject? x = target is RenderObject ? target : null; x != null; x = x.parent) {
|
|
if (identical(x, box)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
for (var i = 0; i < ms ~/ 50; i++) {
|
|
final e = f.evaluate();
|
|
if (e.length == 1) {
|
|
seen ??= sw.elapsedMilliseconds;
|
|
final box = e.single.renderObject;
|
|
if (box is RenderBox && box.attached && box.hasSize) {
|
|
final at = box.localToGlobal(box.size.center(Offset.zero));
|
|
final hit = HitTestResult();
|
|
t.binding.hitTestInView(hit, at, t.view.viewId);
|
|
if (hit.path.any((x) => within(x.target, box))) {
|
|
// ignore: avoid_print
|
|
print('누를 수 있음: $what - 보인 뒤 ${sw.elapsedMilliseconds - seen}ms');
|
|
await t.tapAt(at);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
await t.pump(const Duration(milliseconds: 50));
|
|
}
|
|
await dump(t, what);
|
|
fail('$what 을 누를 수 없음 (보인 때: ${seen == null ? '안 보임' : '${seen}ms'})');
|
|
}
|
|
|
|
testWidgets('82: 8px 문턱 · 놓을 곳 강조 · 같은 창은 빨간 테두리 · 묻기 창 [이동] · [되돌리기]', (t) async {
|
|
await open(t);
|
|
final doc = t.getCenter(find.text('doc.txt'));
|
|
final before = feedback();
|
|
final lit0 = rowLit();
|
|
// 1) 8px 이하로 움직이면 끌기가 아니다 (끄는 그림 없음 · 놓아도 묻지 않음)
|
|
var g = await t.startGesture(doc, kind: PointerDeviceKind.mouse);
|
|
await g.moveBy(const Offset(6, 0));
|
|
await t.pump(const Duration(milliseconds: 100));
|
|
expect(feedback(), before, reason: '6px - 끌기 아님');
|
|
await g.up();
|
|
await until(t, () => false, ms: 400);
|
|
expect(find.byType(AlertDialog), findsNothing);
|
|
|
|
// 2) 8px 를 넘으면 끌기 - 같은 창 (sub) 위는 빨간 테두리, 오른쪽 창 위는 강조
|
|
g = await t.startGesture(doc, kind: PointerDeviceKind.mouse);
|
|
await g.moveBy(const Offset(12, 0));
|
|
await t.pump(const Duration(milliseconds: 100));
|
|
expect(feedback(), before + 1, reason: '12px - 끄는 그림');
|
|
await g.moveTo(t.getCenter(find.text('sub')));
|
|
await t.pump(const Duration(milliseconds: 100));
|
|
expect(frames(JjColors.danger), 1, reason: '같은 창 = 못 놓음');
|
|
// 오른쪽 창의 폴더 줄 (right) 위 = 그 줄만 강조 (그 폴더 안으로)
|
|
await g.moveTo(rightOf(t, 'right'));
|
|
await t.pump(const Duration(milliseconds: 100));
|
|
expect(rowLit(), greaterThan(lit0), reason: '폴더 줄 = 그 줄 강조 (lit0=$lit0)');
|
|
// 오른쪽 창의 파일 줄 위 = 창 전체 강조 (그 창의 지금 폴더로). 임시 폴더라 빈 곳이 없어 파일 줄로
|
|
await g.moveTo(rightOf(t, 'note.txt'));
|
|
await t.pump(const Duration(milliseconds: 100));
|
|
expect(rowLit(), lit0, reason: '파일 줄 = 줄 강조 없음');
|
|
expect(frames(JjColors.accent), 1, reason: '오른쪽 창 = 놓을 수 있음');
|
|
expect(frames(JjColors.danger), 0);
|
|
// 3) 놓으면 묻기 창 - [이동]
|
|
await g.up();
|
|
await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty);
|
|
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
|
|
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget, reason: '처음 고른 단추는 [복사]');
|
|
await tapReady(t, find.widgetWithText(OutlinedButton, '이동'), '묻기 창 [이동]');
|
|
// 지금의 이동 확인 창
|
|
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '이동 확인 창 [이동]');
|
|
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
|
|
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
|
|
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
|
|
// 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창)
|
|
await tapReady(t, find.text('되돌리기'), '[되돌리기]');
|
|
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '되돌리기의 이동 확인 창 [이동]');
|
|
await until(t, () => File(p.join(left, 'doc.txt')).existsSync() && !File(p.join(right, 'doc.txt')).existsSync());
|
|
expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello');
|
|
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);
|
|
ScaffoldMessenger.of(t.element(find.byType(ExplorerPage))).clearSnackBars();
|
|
await until(t, () => false, ms: 2000);
|
|
});
|
|
|
|
testWidgets('75: 마우스 4 · 5 번은 마우스가 올라간 창에 · Alt+← / →', (t) async {
|
|
await open(t);
|
|
String at(int i) => c.settings.explorer.paths[i];
|
|
final sub = p.join(left, 'sub'), r1 = p.join(right, 'r1');
|
|
await t.tap(find.text('r1'));
|
|
await until(t, () => at(1) == r1 && find.text('x.txt').evaluate().isNotEmpty);
|
|
await t.tap(find.text('sub'));
|
|
await until(t, () => at(0) == sub && find.text('inner.txt').evaluate().isNotEmpty);
|
|
expect([at(0), at(1)], [sub, r1]);
|
|
|
|
Future<void> mouse(int button, Offset at) async {
|
|
final g = await t.createGesture(kind: PointerDeviceKind.mouse, buttons: button);
|
|
await g.addPointer(location: at);
|
|
await g.down(at);
|
|
await t.pump(const Duration(milliseconds: 30));
|
|
await g.up();
|
|
await g.removePointer();
|
|
}
|
|
|
|
// 오른쪽 창 위에서 4 번 (뒤로) - 오른쪽만 (왼쪽이 활성이었어도)
|
|
await mouse(kBackMouseButton, t.getCenter(find.text('x.txt')));
|
|
await until(t, () => at(1) == right);
|
|
expect([at(0), at(1)], [sub, right]);
|
|
// 오른쪽 창 위에서 5 번 (앞으로)
|
|
await mouse(kForwardMouseButton, rightOf(t, 'r1'));
|
|
await until(t, () => at(1) == r1);
|
|
expect([at(0), at(1)], [sub, r1]);
|
|
// 왼쪽 창 위에서 4 번 - 왼쪽만
|
|
await mouse(kBackMouseButton, t.getCenter(find.text('doc.txt')));
|
|
await until(t, () => at(0) == left);
|
|
expect([at(0), at(1)], [left, r1]);
|
|
// Alt+→ / Alt+← - 활성 창 (방금 마우스를 쓴 왼쪽)
|
|
await t.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
|
|
await t.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
|
await t.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
|
|
await until(t, () => at(0) == sub);
|
|
expect([at(0), at(1)], [sub, r1]);
|
|
await t.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
|
|
await t.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
|
await t.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
|
|
await until(t, () => at(0) == left);
|
|
expect([at(0), at(1)], [left, r1]);
|
|
// 왼쪽 단추 (보통 누르기) 는 뒤로 · 앞으로가 아니다
|
|
await mouse(kPrimaryButton, t.getCenter(find.text('doc.txt')));
|
|
await until(t, () => false, ms: 400);
|
|
expect([at(0), at(1)], [left, r1]);
|
|
});
|
|
}
|