import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; import 'package:jj_mkvmaker/app/download_manager.dart'; import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/core/bookmarks.dart'; import 'package:jj_mkvmaker/core/dns_resolver.dart'; import 'package:jj_mkvmaker/core/download_detect.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/downloader.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; import 'package:jj_mkvmaker/ui/downloads_page.dart'; import 'package:path/path.dart' as p; import 'package:jj_mkvmaker/ui/notice.dart'; import 'support/temp_dirs.dart'; import 'support/browser_doubles.dart'; void main() { deleteTempDirsAtEnd(); testWidgets('다운로드 알림: 브라우저 화면이 닫혀도 "목록 보기" 가 되고, 준비가 끝나면 사라진다', (tester) async { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final dir = Directory.systemTemp.createTempSync('jj_dlbar_'); addTearDown(() => dir.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); final d = DownloadManager( backends: [RecordingDownloadBackend(DownloadKind.video)], settings: () => c.settings, readClipboard: () async => null); final navKey = GlobalKey(); late BrowserHost host; Future settle() async { for (var i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 100)); } } await tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Scaffold(body: Text('MKV 화면')))); navKey.currentState!.push(MaterialPageRoute( builder: (_) => BrowserPage( c: c, bookmarks: bm, downloads: d, initialUrl: 'https://www.youtube.com/watch?v=abc', viewBuilder: (h, url) { host = h; h.attach(RecordingWebNav()); return const ColoredBox(color: Colors.white); }, ), )); await settle(); host.onUrl('https://www.youtube.com/watch?v=abc'); await tester.pump(); await tester.tap(find.text('다운로드')); await settle(); expect(find.text('목록 보기'), findsOneWidget); final t = d.tasks.single; // MKV 화면으로 (브라우저 화면이 닫힘) → 알림의 "목록 보기" 를 눌러도 다운로드 목록이 열린다 navKey.currentState!.popUntil((r) => r.isFirst); await settle(); expect(find.byType(BrowserPage), findsNothing); await tester.tap(find.text('목록 보기')); await settle(); expect(find.byType(DownloadsPage), findsOneWidget); expect(find.text('목록 보기'), findsNothing); // 누르면 알림은 닫힘 // 다시 열어도 (위쪽 버튼 등) 목록 화면이 겹쳐 쌓이지 않는다 await DownloadsPage.open(navKey.currentState!, d); await settle(); expect(find.byType(DownloadsPage, skipOffstage: false), findsOneWidget); expect(t.state, DownloadState.downloading); // 다른 동영상: 받기 준비가 끝나면 (진행률이 나오면) 알림이 저절로 사라진다 navKey.currentState!.popUntil((r) => r.isFirst); navKey.currentState!.push(MaterialPageRoute( builder: (_) => BrowserPage( c: c, bookmarks: bm, downloads: d, initialUrl: 'https://www.youtube.com/watch?v=def', viewBuilder: (h, url) { host = h; h.attach(RecordingWebNav()); return const ColoredBox(color: Colors.white); }, ), )); await settle(); host.onUrl('https://www.youtube.com/watch?v=def'); await tester.pump(); await tester.tap(find.text('다운로드')); await settle(); expect(find.text('목록 보기'), findsOneWidget); await tester.pump(const Duration(seconds: 30)); expect(find.text('목록 보기'), findsOneWidget, reason: '준비 중에는 그대로'); expect(find.byIcon(Icons.close), findsOneWidget); // [✕] 로 바로 닫을 수도 있다 d.tasks.first.progress = 0.1; d.refresh(); await settle(); expect(find.text('목록 보기'), findsNothing); // [✕] 누르면 바로 닫힘 host.onUrl('https://www.youtube.com/watch?v=ghi'); await tester.pump(); await tester.tap(find.text('다운로드')); await settle(); expect(find.text('목록 보기'), findsOneWidget); await tester.tap(find.byIcon(Icons.close)); await settle(); expect(find.text('목록 보기'), findsNothing); d.dispose(); }); testWidgets('250: DNS 서버에 닿지 않아 못 연 페이지는 엔진 오류 화면 대신 이유 · [시스템 DNS 로 바꾸기] [다시 시도]', (tester) async { tester.view.physicalSize = const Size(1400, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final dir = Directory.systemTemp.createTempSync('jj_browser_dns_'); addTearDown(() => dir.deleteSync(recursive: true)); final g = NetGuard.instance; addTearDown(() { g.failure.value = null; g.noticeBlockers.value = 0; }); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); late BrowserHost host; final nav = RecordingWebNav(); await tester.pumpWidget(MaterialApp( home: BrowserPage( c: c, bookmarks: bm, initialUrl: 'https://blocked.example/', viewBuilder: (h, url) { host = h; h.attach(nav); return const ViewProbe(); }, ), )); await tester.pump(); const msg = 'DNS 서버 (192.0.2.1) 에 닿지 않아 blocked.example 을(를) 열 수 없습니다.'; // 읽기 시작 → 프록시가 DNS 서버에 닿지 못함 → 끝 (엔진은 ERR_TUNNEL_CONNECTION_FAILED 화면) host.onProgress(0.1); host.onUrl('https://blocked.example/'); g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1')); ViewProbe.created = 0; host.onProgress(1); await tester.pump(); expect(find.text(msg), findsOneWidget); // 덮개를 띄워도 웹 뷰는 그대로 (새로 만들면 페이지를 다시 읽어 덮개가 걷혔다 - 931) expect(ViewProbe.created, 0); // 248: 덮개가 보이는 동안은 아래 알림을 막는다 expect(g.noticeBlockers.value, 1); // [다시 시도]: 덮개를 걷고 다시 읽기 await tester.tap(find.text('다시 시도')); await tester.pump(); expect(find.text(msg), findsNothing); expect(nav.reloads, 1); expect(ViewProbe.created, 0); expect(g.noticeBlockers.value, 0); // 다른 이름은 막지 않는다 · 서버가 다시 답한 뒤에는 지난 실패로 막지 않는다 host.onProgress(0.1); host.onUrl('https://ok.example/'); host.onProgress(1); await tester.pump(); expect(find.textContaining('에 닿지 않아'), findsNothing); host.onProgress(0.1); host.onUrl('https://blocked.example/'); g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1')); g.resolver.onAnswer?.call('blocked.example', '192.0.2.1'); host.onProgress(1); await tester.pump(); expect(find.text(msg), findsNothing); // [시스템 DNS 로 바꾸기] host.onProgress(0.1); g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1')); host.onProgress(1); await tester.pump(); await tester.tap(find.text('시스템 DNS 로 바꾸기')); await tester.pump(const Duration(milliseconds: 400)); expect(c.settings.network.mode, 'system'); expect(find.text(msg), findsNothing); expect(nav.reloads, 2); // 931 (노트 10+): 엔진이 본문 오류 (ERR_TUNNEL_CONNECTION_FAILED) 를 알림 - 그 이름의 실패 기록이 없어도 DNS 가 닿지 않는 중이면 덮개. // 오류 뒤에 오는 읽기 끝이 덮개를 지우지 않는다 g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now()); host.onProgress(0.1); host.onUrl('https://example.org/'); host.onMainFrameError!('https://example.org/', 'net::ERR_TUNNEL_CONNECTION_FAILED'); host.onProgress(1); await tester.pump(); expect(find.text('DNS 서버 (192.0.2.1) 에 닿지 않아 example.org 을(를) 열 수 없습니다.'), findsOneWidget); // 932 (노트 10+): Android 는 본문 오류 뒤에 엔진의 오류 페이지를 또 읽는다 (진행률 0 → 100) - 덮개가 걷히면 안 된다 host.onProgress(0.1); await tester.pump(); host.onProgress(1); await tester.pump(); expect(find.text('DNS 서버 (192.0.2.1) 에 닿지 않아 example.org 을(를) 열 수 없습니다.'), findsOneWidget); // DNS 와 상관없는 오류 (이름 기록도 없음) 는 엔진 화면 그대로 await tester.tap(find.text('다시 시도')); await tester.pump(); host.onProgress(0.1); host.onMainFrameError!('https://example.org/', 'net::ERR_CONNECTION_REFUSED'); host.onProgress(1); await tester.pump(); expect(find.textContaining('에 닿지 않아'), findsNothing); }); testWidgets('Android 뒤로 키: 앞 웹 페이지가 있으면 웹 뒤로, 없으면 화면 닫기 · 위쪽 ← 는 늘 닫기', (tester) async { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); debugDefaultTargetPlatformOverride = TargetPlatform.android; final dir = Directory.systemTemp.createTempSync('jj_back_'); addTearDown(() => dir.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); final navKey = GlobalKey(); late BrowserHost host; final nav = RecordingWebNav(); Future open() async { navKey.currentState!.push(MaterialPageRoute( builder: (_) => BrowserPage( c: c, bookmarks: bm, initialUrl: 'https://example.com/', viewBuilder: (h, url) { host = h; h.attach(nav); return const ColoredBox(color: Colors.white); }, ), )); await tester.pumpAndSettle(); } await tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Scaffold(body: Text('MKV 화면')))); await open(); host.onHistory(true, false); await tester.pump(); await tester.binding.handlePopRoute(); // 기기의 뒤로 키 await tester.pumpAndSettle(); expect(nav.backs, 1); expect(find.byType(BrowserPage), findsOneWidget); host.onHistory(false, true); await tester.pump(); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(nav.backs, 1); expect(find.byType(BrowserPage), findsNothing); // 위쪽 ← (화면 이동) 은 웹 기록이 있어도 브라우저 화면을 닫는다 await open(); host.onHistory(true, false); await tester.pump(); await tester.tap(find.byTooltip('뒤로')); await tester.pumpAndSettle(); expect(nav.backs, 1); expect(find.byType(BrowserPage), findsNothing); debugDefaultTargetPlatformOverride = null; // 끝나기 전에 되돌려야 한다 (테스트 검사) }); testWidgets('브라우저: ☆ 추가 · 표시줄 · 다운로드 버튼 · 관리 패널 (삭제 · 실행 취소)', (tester) async { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final dir = tempDirUntilEnd('jj_bm_'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); final video = RecordingDownloadBackend(DownloadKind.video); final d = DownloadManager(backends: [video], settings: () => c.settings, readClipboard: () async => null); final nav = RecordingWebNav(); late BrowserHost host; await tester.pumpWidget(MaterialApp( home: BrowserPage( c: c, bookmarks: bm, downloads: d, initialUrl: 'https://www.google.com/', viewBuilder: (h, url) { host = h; h.attach(nav); return const ColoredBox(color: Colors.white); }, ), )); // 기본 즐겨찾기 표시줄 expect(find.text('YouTube'), findsOneWidget); // 터치 화면: 즐겨찾기를 길게 누르면 오른쪽 클릭과 같은 메뉴 await tester.longPress(find.text('YouTube')); await tester.pumpAndSettle(); expect(find.text('수정 · 이동'), findsOneWidget); await tester.tapAt(const Offset(5, 880)); await tester.pumpAndSettle(); ButtonStyleButton dl() => tester.widget(find.ancestor(of: find.text('다운로드'), matching: find.byWidgetPredicate((w) => w is ButtonStyleButton))); // 27: 동영상 페이지가 아니어도 받아 볼 수 있다 (덜 눈에 띄는 버튼 · 안내). 137: 테두리만 있는 버튼 expect(dl().onPressed, isNotNull); expect(dl(), isA()); expect(find.byTooltip('다운로드 (이 페이지에서 동영상을 찾지 못했지만 받아 볼 수 있습니다)'), findsOneWidget); // 주소창 입력 → 이동 await tester.enterText(find.byType(TextField).first, 'youtube.com/watch?v=abc'); await tester.testTextInput.receiveAction(TextInputAction.done); expect(nav.loads.last, 'https://youtube.com/watch?v=abc'); host.onUrl('https://www.youtube.com/watch?v=abc'); host.onTitle('재미있는 영상'); await tester.pump(); expect(dl().onPressed, isNotNull); expect(dl(), isA(), reason: '137: 동영상 페이지는 진한 버튼'); await tester.tap(find.text('다운로드')); await tester.pump(); expect(video.started, ['https://www.youtube.com/watch?v=abc']); // ☆ → Chrome 처럼 "즐겨찾기 추가됨" 창: 이름 · 폴더 고르기, 그 자리에서 새 폴더 await tester.tap(find.byTooltip('즐겨찾기 추가 (Ctrl+D)')); await tester.pumpAndSettle(); expect(find.text('즐겨찾기 추가됨'), findsOneWidget); expect(bm.tree.parentOf(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc')!.id)!.id, BookmarkTree.barId); await tester.tap(find.text('새 폴더')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField).last, '음악'); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); await tester.tap(find.text('완료')); await tester.pumpAndSettle(); final saved = bm.tree.findByUrl('https://www.youtube.com/watch?v=abc')!; final folder = bm.tree.parentOf(saved.id)!; expect(folder.title, '음악'); expect(bm.tree.parentOf(folder.id)!.id, BookmarkTree.barId); // 표시줄에는 폴더가 보인다 expect(find.widgetWithText(TextButton, '음악'), findsOneWidget); // 관리 패널 → 삭제 → 실행 취소 await tester.tap(find.byTooltip('즐겨찾기 관리 (Ctrl+Shift+B)')); await tester.pumpAndSettle(); expect(find.text('즐겨찾기'), findsWidgets); await tester.tap(find.byTooltip('더 보기').last); await tester.pumpAndSettle(); await tester.tap(find.text('삭제').last); await tester.pumpAndSettle(); expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNull); await tester.tap(find.text('실행 취소')); await tester.pumpAndSettle(); expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull); await tester.tap(find.byTooltip('더 보기').last); // 실행 취소 알림은 noticeDuration (E4 U2) 뒤 저절로 사라진다 await tester.pumpAndSettle(); await tester.tap(find.text('삭제').last); await tester.pumpAndSettle(); expect(find.text('실행 취소'), findsOneWidget); await tester.pump(noticeDuration + const Duration(seconds: 1)); await tester.pumpAndSettle(); expect(find.text('실행 취소'), findsNothing); bm.undoRemove(); await tester.pumpAndSettle(); expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull); // 패널에서 누르면 그 주소로 이동 await tester.tap(find.text('OpenSubtitles').last); expect(nav.loads.last, 'https://www.opensubtitles.com/'); // 화면 분할: 작업 현황 (다운로드 진행 · 편집 목록) await tester.tap(find.byTooltip('작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)')); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('작업 현황'), findsOneWidget); expect(find.text('쉬는 중'), findsOneWidget); expect(find.text('다운로드 1개'), findsOneWidget); await tester.tap(find.byTooltip('작업 현황 닫기')); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('작업 현황'), findsNothing); d.dispose(); }); testWidgets('66-2: 처음 열면 위쪽 막대 아래에 화면 옮기기 안내 한 줄 (알림을 막지 않음) · ✕ 로 닫고 다시 열면 없음', (tester) async { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final dir = Directory.systemTemp.createTempSync('jj_hint_'); addTearDown(() => dir.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); Widget page() => MaterialApp( home: BrowserPage( c: c, bookmarks: bm, initialUrl: 'https://example.com/', viewBuilder: (h, url) { h.attach(RecordingWebNav()); return const ColoredBox(color: Colors.white); }, ), ); await tester.pumpWidget(page()); await tester.pumpAndSettle(); expect(find.textContaining('위쪽 막대를 좌우로 밀면'), findsOneWidget); expect(find.byType(SnackBar), findsNothing, reason: '알림 (다운로드 등) 을 뒤로 밀지 않게 알림이 아니라 한 줄'); expect(c.settings.browser.swipeHinted, isTrue); await tester.tap(find.byTooltip('안내 닫기')); await tester.pump(); expect(find.textContaining('위쪽 막대를 좌우로 밀면'), findsNothing); // 다시 열면 없음 (한 번만) await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(page()); await tester.pumpAndSettle(); expect(find.textContaining('위쪽 막대를 좌우로 밀면'), findsNothing); }); }