diff --git a/integration_test/windows_b6_input_test.dart b/integration_test/windows_b6_input_test.dart new file mode 100644 index 0000000..fda1d56 --- /dev/null +++ b/integration_test/windows_b6_input_test.dart @@ -0,0 +1,208 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind; +import 'package:flutter/material.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 '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; + + setUp(() async { + await i18n.apply('ko', save: false); + tmp = Directory.systemTemp.createTempSync('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 + ..explorerPaths = [left, right] + ..explorerDropAction = '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.delayed(const Duration(milliseconds: 100)); + } + } + }); + + /// 실제 시간으로 [done] 까지 (최대 약 [ms] 밀리초) + Future 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 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, 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; + + 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 t.tap(find.widgetWithText(OutlinedButton, '이동')); + // 지금의 이동 확인 창 + await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); + await t.tap(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 t.tap(find.text('되돌리기')); + await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); + await t.tap(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.explorerPaths[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 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]); + }); +}