49 탐색기 뒤로 키: 먼저 고른 것 풀기 → 상위 폴더 → 맨 위에서 화면 닫기 (위쪽 ← 는 늘 닫기)

- 탐색기 · Rsync 화면에 PopScope: 시스템 뒤로 (Android 뒤로 키 · 몸짓 · 마우스 뒤로 · Esc) 만 차례를 거친다
- 위쪽 ← 는 브라우저처럼 늘 바로 닫기 (Navigator.pop)
- 시험: 선택 → 뒤로 (풀기) → 뒤로 (상위) → 뒤로 (닫기). 66-2 캡처 시험 (integration_test/windows_662_shot_test) 도 함께

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-09 20:27:23 +09:00
parent 01600ec69a
commit de967d190a
4 changed files with 169 additions and 1 deletions

View File

@ -0,0 +1,105 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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/bookmarks_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/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:jj_mkvmaker/ui/home_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// 66-2 기기 감독 확인용 (Windows): 지금 화면 아이콘 (넓은 · 좁은 화면) · 처음으로 알림 · 브라우저 안내 한 줄을 PNG (JJ_SHOT_DIR) 로.
/// 설정은 메모리에만 (저장하지 않음), 즐겨찾기는 시험 폴더 (JJ_SHOT_TMP).
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_662');
final key = GlobalKey();
Future<void> settle(WidgetTester t, [int rounds = 10]) async {
for (var i = 0; i < rounds; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await t.pump();
}
}
Future<void> shot(WidgetTester t, String name) async {
await settle(t, 3);
final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, '$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
AppController plain() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..components = ['mkv', 'explorer', 'rsync']
..navOrder = ['mkv', 'explorer', 'rsync'];
return c;
}
Future<void> showHome(WidgetTester t, AppController c, Size size) async {
t.view.physicalSize = size;
t.view.devicePixelRatio = 1;
await t.runAsync(() => t.pumpWidget(MaterialApp(
theme: buildTheme(),
builder: (_, child) => AppScope(controller: c, child: RepaintBoundary(key: key, child: child!)),
home: KeyedSubtree(key: UniqueKey(), child: HomePage(c: c)),
)));
await settle(t);
}
Future<void> swipe(WidgetTester t, Size size, double dx) async {
await t.flingFrom(Offset(size.width / 2, size.height / 2), Offset(dx, 0), 2500);
await settle(t, 8);
}
testWidgets('66-2: 지금 화면 아이콘 (넓은 · 좁은 화면) · 처음으로 알림', (t) async {
for (final (name, size) in [('wide', const Size(1280, 800)), ('narrow', const Size(420, 860))]) {
final c = plain();
await showHome(t, c, size);
await shot(t, 'b3_662_${name}_mkv_current');
await swipe(t, size, -size.width * 0.6); // 다음 (파일 탐색기)
await shot(t, 'b3_662_${name}_explorer_current');
await swipe(t, size, -size.width * 0.6); // Rsync
await swipe(t, size, -size.width * 0.6); // 끝 다음 = 처음
await shot(t, 'b3_662_${name}_wrap_to_first');
}
});
testWidgets('66-2: 웹 브라우저를 처음 열면 위쪽 막대 아래에 안내 한 줄 · 173 브라우저 아이콘도 지금 위치로 파랗게', (t) async {
final c = plain();
// 실제 앱처럼 웹 브라우저도 화면 목록에 (그래야 위쪽 막대에 브라우저 아이콘이 있다)
c.settings
..components = ['mkv', 'browser', 'explorer', 'rsync']
..navOrder = ['mkv', 'browser', 'explorer', 'rsync'];
final base = Platform.environment['JJ_SHOT_TMP'];
final dir = (base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true)))
.createTempSync('jj_shot_662_');
addTearDown(() => dir.deleteSync(recursive: true));
final bm = BookmarksController(p.join(dir.path, 'bookmarks.json'));
t.view.physicalSize = const Size(1280, 800);
t.view.devicePixelRatio = 1;
await t.runAsync(() => t.pumpWidget(MaterialApp(
theme: buildTheme(),
builder: (_, child) => AppScope(controller: c, bookmarks: bm, child: RepaintBoundary(key: key, child: child!)),
home: BrowserPage(
c: c,
bookmarks: bm,
initialUrl: 'https://example.com/',
viewBuilder: (BrowserHost h, String url) => const ColoredBox(color: Colors.white),
),
)));
await settle(t);
await shot(t, 'b3_662_browser_hint');
});
}

View File

@ -308,7 +308,8 @@ class AppNavButtons extends StatelessWidget {
),
btn(const Icon(Icons.arrow_back), tr('뒤로'),
// 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다
atRoot ? null : () => onBrowserPage ? Navigator.pop(context) : Navigator.maybePop(context),
// 위쪽 ← 는 늘 화면 닫기 (브라우저 · 탐색기는 뒤로 키가 웹 뒤로 · 선택 풀기 · 상위 폴더를 먼저 하므로 그것을 거치지 않게 - 49)
atRoot ? null : () => onBrowserPage || onExplorerPage || onRsyncPage ? Navigator.pop(context) : Navigator.maybePop(context),
label: tr('뒤로')),
]);
}

View File

@ -2356,8 +2356,35 @@ class _ExplorerPageState extends State<ExplorerPage> {
// ───────── 화면 ─────────
/// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님)
bool get _backHasWork =>
_selecting || _panes.any((x) => x.marked.isNotEmpty) || (_ready && !samePath(_pane.current, _pane.root));
/// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기)
Future<void> _onBack() async {
if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) {
_setSelecting(false);
return;
}
if (!samePath(_pane.current, _pane.root)) {
await _button(ExplorerButton.up);
return;
}
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_backHasWork,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_onBack());
},
child: _buildPage(context),
);
}
Widget _buildPage(BuildContext context) {
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh),

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -208,6 +209,40 @@ void main() {
expect(find.textContaining('1개를 영구 삭제했습니다.'), findsOneWidget);
}, skip: !Platform.isWindows);
testWidgets('49: 뒤로 키 - 먼저 고른 것 풀기 → 상위 폴더 → 맨 위에서 화면 닫기', (tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
// 시험 폴더를 저장 장치 맨 위로 (left 에서 한 번 올라가면 맨 위)
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
final navKey = GlobalKey<NavigatorState>();
await tester.runAsync(() => tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Text('앞 화면'))));
unawaited(navKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => ExplorerPage(c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
Future<void> back() async {
await tester.runAsync(() => tester.binding.handlePopRoute());
await settle(tester, () => false, rounds: 10);
}
// 표시 (선택 모드) 를 켜고 doc.txt 를 고름
await tester.runAsync(() => tester.tap(find.text('선택').first));
await settle(tester, () => false, rounds: 5);
await tester.runAsync(() => tester.tap(find.text('doc.txt').first));
await settle(tester, () => false, rounds: 5);
expect(find.textContaining('1개 표시함'), findsOneWidget);
await back(); // 1) 고른 것 풀기 (화면은 그대로)
expect(find.textContaining('표시함'), findsNothing);
expect(find.byType(ExplorerPage), findsOneWidget);
expect(find.textContaining(p.join(tmp.path, 'left')), findsWidgets, reason: '아직 left 폴더');
await back(); // 2) 상위 폴더 (= 맨 위)
expect(find.byType(ExplorerPage), findsOneWidget);
expect(find.textContaining(p.join(tmp.path, 'left')), findsNothing);
await back(); // 3) 맨 위에서는 화면 닫기
await tester.pumpAndSettle(); // 닫히는 전환 (가짜 시간을 흘려야 끝난다)
expect(find.byType(ExplorerPage), findsNothing);
expect(find.text('앞 화면'), findsOneWidget);
});
testWidgets('48: 건너뛰기를 고르면 그대로 두고 건너뛴 것을 알린다', (tester) async {
await startCopy(tester);
await tester.tap(find.text('건너뛰기'));