diff --git a/test/browser_page_test.dart b/test/browser_page_test.dart new file mode 100644 index 0000000..2245bdc --- /dev/null +++ b/test/browser_page_test.dart @@ -0,0 +1,419 @@ +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); + }); +} diff --git a/test/browser_test.dart b/test/browser_test.dart index 56932ce..beaaa0e 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -1,105 +1,17 @@ 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/app/settings.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/core/web_address.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 'support/temp_dirs.dart'; -import 'package:jj_mkvmaker/ui/notice.dart'; - -class _Backend implements DownloadBackend { - @override - final DownloadKind kind; - final started = []; - void Function()? changed; - _Backend(this.kind); - @override - Future<(String, List)?> expandPlaylist(String url) async => null; - @override - Future start(DownloadTask t, void Function() changed) async { - this.changed = changed; - started.add(t.source); - t.state = DownloadState.downloading; - } - - @override - Future pause(DownloadTask t) async {} - @override - Future cancel(DownloadTask t) async {} - @override - Future shutdown() async {} -} - -class _Nav implements WebNav { - int paused = 0; - @override - Future pauseMedia() async => paused++; - final scripts = []; - @override - Future runScript(String js) async => scripts.add(js); - final loads = []; - @override - Future load(String url) async => loads.add(url); - int backs = 0; - @override - Future back() async => backs++; - @override - Future forward() async {} - int reloads = 0; - @override - Future reload() async => reloads++; - @override - Future stop() async {} - var exported = 0; - @override - Future exportCookies() async => exported++; - @override - Future clearData() async {} - @override - Future evaluate(String js) async { - scripts.add(js); - return null; - } - - final javaScript = []; - @override - Future setJavaScript(bool on) async => javaScript.add(on); -} - -/// 웹 뷰 대신: 몇 번 새로 만들어졌는지 센다 (250 - 덮개를 띄워도 웹 뷰를 다시 만들지 않아야 함) -class _Probe extends StatefulWidget { - static int created = 0; - const _Probe(); - @override - State<_Probe> createState() => _ProbeState(); -} - -class _ProbeState extends State<_Probe> { - @override - void initState() { - super.initState(); - _Probe.created++; - } - - @override - Widget build(BuildContext context) => const ColoredBox(color: Colors.white); -} +import 'support/browser_doubles.dart'; void main() { deleteTempDirsAtEnd(); @@ -207,7 +119,7 @@ void main() { }); test('브라우저 페이지 받기 (YouTube 외 사이트도)', () { - final v = _Backend(DownloadKind.video); + final v = RecordingDownloadBackend(DownloadKind.video); final d = DownloadManager(backends: [v], settings: () => AppSettings()..download.root = r'D:\dl'); expect(d.addPage('https://vimeo.com/12345')!.kind, DownloadKind.video); expect(d.addPage('https://vimeo.com/12345'), isNull); // 이미 받는 중 @@ -217,7 +129,7 @@ void main() { }); test('다 받은 영상 → 편집 목록 알림 (한 번만, 조각 파일 제외)', () async { - final v = _Backend(DownloadKind.video); + final v = RecordingDownloadBackend(DownloadKind.video); final d = DownloadManager(backends: [v], settings: () => AppSettings()..download.root = r'D:\dl'); final got = []; d.onFinished.listen((t) => got.add(t.id)); @@ -233,365 +145,6 @@ void main() { }); }); - 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: [_Backend(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(_Nav()); - 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(_Nav()); - 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 = _Nav(); - await tester.pumpWidget(MaterialApp( - home: BrowserPage( - c: c, - bookmarks: bm, - initialUrl: 'https://blocked.example/', - viewBuilder: (h, url) { - host = h; - h.attach(nav); - return const _Probe(); - }, - ), - )); - 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')); - _Probe.created = 0; - host.onProgress(1); - await tester.pump(); - expect(find.text(msg), findsOneWidget); - // 덮개를 띄워도 웹 뷰는 그대로 (새로 만들면 페이지를 다시 읽어 덮개가 걷혔다 - 931) - expect(_Probe.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(_Probe.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 = _Nav(); - 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 = _Backend(DownloadKind.video); - final d = DownloadManager(backends: [video], settings: () => c.settings, readClipboard: () async => null); - final nav = _Nav(); - 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(); - }); - test('즐겨찾기 파일 저장 · 다시 불러오기 · 순서 바꾸기', () async { final dir = Directory.systemTemp.createTempSync('jj_bm2_'); addTearDown(() => dir.deleteSync(recursive: true)); @@ -609,38 +162,4 @@ void main() { expect(b.tree.bar.children!.first.title, 'B'); expect(b.tree.bar.children!.any((n) => n.isFolder && n.title == '영상'), isTrue); }); - - 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(_Nav()); - 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); - }); } diff --git a/test/support/browser_doubles.dart b/test/support/browser_doubles.dart new file mode 100644 index 0000000..1be137a --- /dev/null +++ b/test/support/browser_doubles.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/ui/browser_page.dart'; + +/// 브라우저 시험의 대역 (browser_test · browser_page_test 가 같이 쓴다) +/// 받기를 시작한 주소를 적어 두는 다운로드 엔진 (실제로 받지 않음) +class RecordingDownloadBackend implements DownloadBackend { + @override + final DownloadKind kind; + final started = []; + void Function()? changed; + RecordingDownloadBackend(this.kind); + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async { + this.changed = changed; + started.add(t.source); + t.state = DownloadState.downloading; + } + + @override + Future pause(DownloadTask t) async {} + @override + Future cancel(DownloadTask t) async {} + @override + Future shutdown() async {} +} + +/// 받은 조작 (멈춤 · 스크립트 · 주소 · 뒤로 · 쿠키 · JavaScript) 을 적어 두는 웹뷰 조작 +class RecordingWebNav implements WebNav { + int paused = 0; + @override + Future pauseMedia() async => paused++; + final scripts = []; + @override + Future runScript(String js) async => scripts.add(js); + final loads = []; + @override + Future load(String url) async => loads.add(url); + int backs = 0; + @override + Future back() async => backs++; + @override + Future forward() async {} + int reloads = 0; + @override + Future reload() async => reloads++; + @override + Future stop() async {} + var exported = 0; + @override + Future exportCookies() async => exported++; + @override + Future clearData() async {} + @override + Future evaluate(String js) async { + scripts.add(js); + return null; + } + + final javaScript = []; + @override + Future setJavaScript(bool on) async => javaScript.add(on); +} + +/// 웹 뷰 대신: 몇 번 새로 만들어졌는지 센다 (250 - 덮개를 띄워도 웹 뷰를 다시 만들지 않아야 함) +class ViewProbe extends StatefulWidget { + static int created = 0; + const ViewProbe({super.key}); + @override + State createState() => _ViewProbeState(); +} + +class _ViewProbeState extends State { + @override + void initState() { + super.initState(); + ViewProbe.created++; + } + + @override + Widget build(BuildContext context) => const ColoredBox(color: Colors.white); +}