From db38d0ddbf2c832a5f5f7e4539da309a2af80ff8 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:26:56 +0900 Subject: [PATCH] =?UTF-8?q?batch=5Ftest=20=EB=82=98=EB=88=84=EA=B8=B0=20(9?= =?UTF-8?q?71=20=E2=86=92=20361=20+=20536):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=EC=9D=80=20batch=5Fui=5Ftest,=20=EB=8C=80?= =?UTF-8?q?=EC=97=AD=EC=9D=80=20support/batch=5Fdoubles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시험 수 그대로 (나누기 전 29 = 나눈 뒤 29), 기대값 그대로 Co-Authored-By: Claude Opus 5.5 --- test/batch_test.dart | 622 +------------------------------- test/batch_ui_test.dart | 536 +++++++++++++++++++++++++++ test/support/batch_doubles.dart | 102 ++++++ 3 files changed, 644 insertions(+), 616 deletions(-) create mode 100644 test/batch_ui_test.dart create mode 100644 test/support/batch_doubles.dart diff --git a/test/batch_test.dart b/test/batch_test.dart index f4922d0..7216660 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -1,120 +1,18 @@ -import 'dart:async'; -import 'dart:convert'; import 'dart:io'; -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/settings.dart'; -import 'package:jj_mkvmaker/core/download_detect.dart'; import 'package:jj_mkvmaker/core/encode_options.dart'; import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/output_paths.dart'; -import 'package:jj_mkvmaker/core/srt.dart'; -import 'package:jj_mkvmaker/l10n/tr.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/ai_services.dart'; -import 'package:jj_mkvmaker/services/downloader.dart'; -import 'package:jj_mkvmaker/services/media_tool.dart'; import 'package:jj_mkvmaker/services/model_store.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/downloads_page.dart'; -import 'package:jj_mkvmaker/ui/home_page.dart'; -import 'package:jj_mkvmaker/ui/settings_page.dart'; import 'package:path/path.dart' as p; import 'support/temp_dirs.dart'; - -class _Recognizer implements SpeechRecognizer { - @override - void cancel() {} - final heard = []; - @override - Future> transcribe(String wavPath, - {required String modelPath, String language = 'auto', AiProgress? onProgress}) async { - heard.add(p.basename(wavPath)); - return [Cue(const Duration(seconds: 1), const Duration(seconds: 2), 'Hello everyone, this is a test.')]; - } -} - -/// 240: 취소될 때까지 끝나지 않는 FFmpeg (여럿이 동시에 도는 상태를 만든다) -class _HoldTool implements MediaTool { - final runs = >{}; - @override - Future version() async => 'ffmpeg test'; - @override - Future> encoders() async => {'libx264'}; - @override - Future probe(String path) async => const MediaInfo(duration: Duration(seconds: 1)); - @override - Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { - final c = runs[tag] = Completer(); - try { - await c.future; - } finally { - runs.remove(tag); - } - } - - void _stop(Completer? c) { - if (c != null && !c.isCompleted) c.completeError(MediaToolException('사용자가 취소했습니다.')); - } - - @override - void cancel() => runs.values.toList().forEach(_stop); - @override - void cancelTagged(Object tag) => _stop(runs[tag]); -} - -class _Translator implements Translator { - @override - Future load(String modelDir) async {} - @override - Future> translate(List lines, - {required String source, required String target, AiProgress? onProgress}) async => - [for (final l in lines) '[$target] $l']; - @override - void cancel() {} - @override - Future dispose() async {} -} - -class _Backend implements DownloadBackend { - @override - DownloadKind get kind => DownloadKind.video; - @override - Future<(String, List)?> expandPlaylist(String url) async => null; - @override - Future start(DownloadTask t, void Function() changed) async => t.state = DownloadState.downloading; - @override - Future pause(DownloadTask t) async {} - @override - Future cancel(DownloadTask t) async {} - @override - Future shutdown() async {} -} - -/// 262: [_HoldTool] 과 같지만, 끝내면 (결과 이름으로 바꿀) 임시 출력 파일을 쓴다 -class _EndTool extends _HoldTool { - final args = >{}; - @override - Future runFfmpeg(List a, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { - args[tag] = a; - await super.runFfmpeg(a, duration: duration, onProgress: onProgress, tag: tag); - File(a.last).writeAsStringSync('NEW'); - } - - /// [tag] 의 FFmpeg 를 성공으로 끝낸다 - void finish(Object tag) => runs[tag]!.complete(); -} - -AppController _plain() => - AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); +import 'support/batch_doubles.dart'; void main() { deleteTempDirsAtEnd(); @@ -125,7 +23,7 @@ void main() { for (final f in files) { File(f).writeAsStringSync('x'); } - final c = _plain(); + final c = plainController(); await c.addVideos(files); final [a, b, cc] = c.videos; @@ -162,7 +60,7 @@ void main() { ..writeAsStringSync('x') ..setLastModifiedSync(DateTime(2026, 1, e.value)); } - final c = _plain(); + final c = plainController(); await c.addVideos([for (final n in when.keys) p.join(dir.path, n)]); List names() => [for (final v in c.videos) v.fileName]; expect(names(), ['ep10.mp4', 'intro.mp4', 'ep2.mp4']); @@ -208,12 +106,12 @@ void main() { ..writeAsStringSync('x'); } } - final rec = _Recognizer(); + final rec = TestRecognizer(); final c = AppController(PlatformServices( mediaTool: tool, storage: DesktopStorageService(), createRecognizer: () => rec, - createTranslator: _Translator.new, + createTranslator: TestTranslator.new, models: models, )); await c.init(); @@ -415,335 +313,8 @@ void main() { reason: '${v1.message} ${v2.message} ${v3.message}'); }, timeout: const Timeout(Duration(minutes: 4))); - testWidgets('198: 원어를 자동 감지한 동영상은 세부 정보에 감지한 언어 · [다른 언어로 다시] (알림을 놓쳐도)', (tester) async { - tester.view.physicalSize = const Size(1600, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.videos.add(VideoItem(r'D:\v\a.mp4')..aiDetected = 'ko'); - c.selected = c.videos.first; - await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); - expect(find.text('감지한 언어: 한국어 (자동 감지)'), findsOneWidget); - expect(find.widgetWithText(TextButton, '다른 언어로 다시'), findsOneWidget); - // 원어를 직접 고르면 (null) 보이지 않는다 - c.videos.first.aiDetected = null; - c.selected = c.videos.first; - await tester.pumpWidget(MaterialApp(key: UniqueKey(), home: HomePage(c: c))); - expect(find.textContaining('감지한 언어'), findsNothing); - }); - - for (final (name, size, targets) in [ - ('폰 세로', const Size(412, 900), 0), - ('폰 세로 · 이동 버튼 3개', const Size(412, 900), 3), - ('넓은 화면 · 이동 버튼 3개', const Size(1600, 520), 3), - ]) { - testWidgets('206: MKV 자세히 칸의 [이동] 버튼이 칩 (음성 n개) 을 가리지 않는다 ($name)', (tester) async { - tester.view.physicalSize = size; - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.mkv.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')]; - c.videos.add(VideoItem(r'D:\v\a.mp4') - ..info = const MediaInfo(duration: Duration(minutes: 3), streams: [ - StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080), - StreamInfo(index: 1, type: 'audio', codec: 'aac'), - ])); - c.selected = c.videos.first; - await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); - await tester.pump(); - final chip = find.text('음성 1개'); - // 끝까지 내려 본다 (넓은 화면은 떠 있는 버튼 - 끝에서도 가리지 않아야) - await tester.dragUntilVisible(chip, find.byType(ListView).last, const Offset(0, -80)); - await tester.drag(find.byType(ListView).last, const Offset(0, -2000)); - await tester.pumpAndSettle(); - expect(chip, findsOneWidget); - final r = tester.getRect(chip); - for (final b in find.byType(FloatingActionButton).evaluate()) { - final br = tester.getRect(find.byWidget(b.widget)); - expect(br.overlaps(r), isFalse, reason: '버튼 $br · 칩 $r'); - } - }); - } - - testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async { - tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비 - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final c = AppController(PlatformServices( - mediaTool: ProcessMediaTool('x', 'y'), - storage: DesktopStorageService(), - createRecognizer: _Recognizer.new, - createTranslator: _Translator.new, - )); - for (final n in ['a', 'b', 'c']) { - c.videos.add(VideoItem('D:\\v\\$n.mp4')); - } - c.selected = c.videos.first; - await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); - expect(find.text('자막 만들기'), findsOneWidget); - expect(find.text('자막 만들기 & MKV 만들기'), findsOneWidget); - expect(find.text('동영상 3개 · 전체 선택'), findsOneWidget); - - // 줄의 체크 상자 (0 번째는 전체 선택) - // (줄에 더블클릭 = 재생이 있어 탭은 더블클릭 대기 시간 뒤에 확정된다) - await tester.tap(find.byType(Checkbox).at(1)); - await tester.pump(const Duration(milliseconds: 400)); - await tester.tap(find.byType(Checkbox).at(3)); - await tester.pump(const Duration(milliseconds: 400)); - expect(c.checked, {c.videos[0], c.videos[2]}); - expect(find.text('선택 2 / 3개'), findsOneWidget); - expect(find.text('자막 만들기 (2)'), findsOneWidget); - expect(find.text('자막 만들기 & MKV 만들기 (2)'), findsOneWidget); - expect(find.text('MKV 만들기 (2)'), findsOneWidget); - expect(find.byTooltip(RegExp('^파일 이름 순으로 정렬')), findsOneWidget); - expect(find.byTooltip(RegExp('^날짜 순으로 정렬')), findsOneWidget); - - await tester.tap(find.text('선택 2 / 3개')); - await tester.pump(); - expect(c.checked, hasLength(3)); - await tester.tap(find.byTooltip('선택한 동영상을 목록에서 제거')); - await tester.pump(); - expect(c.videos, isEmpty); - }); - - testWidgets('다운로드 화면: 다 받은 것을 골라 [동영상 추가] → MKV 목록', (tester) async { - tester.view.physicalSize = const Size(1500, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final dir = Directory.systemTemp.createTempSync('jj_dladd_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final file = p.join(dir.path, 'movie.mp4'); - File(file).writeAsStringSync('x'); - - final d = DownloadManager( - backends: [_Backend()], settings: () => AppSettings()..download.root = dir.path, readClipboard: () async => null); - final added = []; - d.addToEditList = (files) async { - added.addAll(files); - return files.length; - }; - final done = d.addPage('https://vimeo.com/1')! - ..title = '다 받은 영상' - ..files.add(file) - ..state = DownloadState.done; - d.addPage('https://vimeo.com/2')!.title = '받는 중인 영상'; - - await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); - OutlinedButton btn() => tester.widget( - find.ancestor(of: find.text('동영상 추가'), matching: find.byWidgetPredicate((w) => w is OutlinedButton))); - expect(btn().onPressed, isNull); // 고른 것이 없음 - - await tester.tap(find.text('받는 중인 영상')); - await tester.pump(); - expect(btn().onPressed, isNull); // 아직 다 받지 않음 - - await tester.tap(find.text('다 받은 영상')); - await tester.pump(); - expect(d.selectedVideoFiles, [file]); - await tester.tap(find.text('동영상 추가')); - await tester.pump(); - expect(added, [file]); - expect(find.textContaining('동영상 1개를 추가했습니다'), findsOneWidget); - expect(done.state, DownloadState.done); - d.dispose(); - }); - - testWidgets('다운로드 화면: "완료 시 자동 동영상 추가" 체크 → 설정 변경, 목록에서 빼기', (tester) async { - tester.view.physicalSize = const Size(1500, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final s = AppSettings()..download.addFinished = false; - final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null); - d.addToEditList = (files) async => files.length; - d.setAutoAdd = (on) async { - s.download.addFinished = on; - d.refresh(); - }; - final t = d.addPage('https://vimeo.com/1')!..title = '영상'; - - await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); - Checkbox box() => tester.widget( - find.descendant(of: find.byTooltip(RegExp('MKV 만들기의 동영상 목록에 자동으로')), matching: find.byType(Checkbox))); - expect(box().value, isFalse); - await tester.tap(find.text('완료 시 자동 동영상 추가')); - await tester.pump(); - expect(s.download.addFinished, isTrue); - expect(box().value, isTrue); - - // 다 받으면 목록에서 뺀다 (앱이 onFinished 에서 부름) - d.dropFromList(t); - await tester.pump(); - expect(d.tasks, isEmpty); - expect(find.text('영상'), findsNothing); - d.dispose(); - }); - - // 246 · P11 확인 (921): 영어 · 일본어는 글이 길어 체크가 아예 안 보였다 - 언어 · 폭마다 - for (final lang in ['ko', 'en', 'ja']) { - for (final w in [800.0, 1200.0]) { - testWidgets('246: 다운로드 화면 ($lang, 폭 $w) - 버튼 줄의 글이 잘리거나 "전체 선택" 과 겹치지 않는다', (tester) async { - Map dict(String code) => - Map.from(jsonDecode(File('assets/l10n/$code.json').readAsStringSync()) as Map); - setTranslations(lang, lang == 'ko' ? const {} : dict(lang), lang == 'ko' ? const {} : dict('en')); - addTearDown(() => setTranslations('ko', const {})); - tester.view.physicalSize = Size(w, 411); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final s = AppSettings(); - final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null); - addTearDown(d.dispose); - d.addToEditList = (files) async => files.length; - d.setAutoAdd = (on) async => s.download.addFinished = on; - d.addPage('https://vimeo.com/1')!.title = '영상'; - await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); - await tester.pump(); - expect(tester.takeException(), isNull); - final all = tester.getRect(find.text(tr('전체 선택'))); - final auto = tester.getRect(find.text(tr('완료 시 자동 동영상 추가'))); - expect(auto.overlaps(all), isFalse, reason: '$auto · $all'); - expect([auto.left >= 0, auto.right <= w], [true, true], reason: '화면 안에 다 보임 $auto'); - for (final t in ['일시정지', '재개', '다운로드 취소', '삭제', '완료 정리']) { - final r = tester.getRect(find.text(tr(t)).first); - expect([r.left >= 0, r.right <= w], [true, true], reason: '$t $r'); - } - }); - } - } - - testWidgets('환경 설정 · 종료 버튼: 모든 화면에서 같은 자리', (tester) async { - tester.view.physicalSize = const Size(1500, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final dir = Directory.systemTemp.createTempSync('jj_pos_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final c = _plain(); - final d = DownloadManager(backends: [_Backend()], settings: () => c.settings, readClipboard: () async => null); - final bm = BookmarksController(p.join(dir.path, 'bm.json')); - var exits = 0; - - Future<(Offset, Offset, Offset, Offset)> at(Widget page) async { - await tester.pumpWidget(MaterialApp( - key: UniqueKey(), - builder: (context, child) => - AppScope(controller: c, onExit: () => exits++, downloads: d, bookmarks: bm, child: child!), - home: page, - )); - await tester.pump(); - return ( - tester.getCenter(find.byTooltip('환경 설정')), - tester.getCenter(find.byTooltip('종료')), - // 왼쪽 공통 버튼: JJ (홈) · 다운로드 목록 - tester.getCenter(find.byTooltip(RegExp('^홈 화면'))), - tester.getCenter(find.byTooltip(RegExp('^다운로드 목록'))), - ); - } - - final home = await at(HomePage(c: c, downloads: d, bookmarks: bm, onExit: () => exits++)); - final pages = { - '다운로드': await at(DownloadsPage(d: d)), - '환경 설정': await at(SettingsPage(c: c)), - '브라우저': await at(BrowserPage( - c: c, bookmarks: bm, downloads: d, viewBuilder: (h, url) => const ColoredBox(color: Colors.white))), - }; - for (final e in pages.entries) { - expect(e.value, home, reason: '${e.key} 화면의 버튼 자리가 MKV 화면과 다릅니다'); - } - // 오른쪽 위 구석 - expect(home.$2.dx, greaterThan(1500 - 60)); - expect(home.$2.dy, 28); - // 왼쪽 위 구석: JJ 아이콘 (홈) - expect(home.$3.dx, lessThan(40)); - expect(home.$3.dy, 28); - - // 브라우저에서 종료 버튼 → 앱의 종료 동작 - await tester.tap(find.byTooltip('종료')); - expect(exits, 1); - d.dispose(); - }); - - testWidgets('MKV 만들기: 체크가 없으면 보고 있는 동영상만, 보고 있는 것도 없으면 "전체 만들기 / 취소" 를 묻는다', (tester) async { - tester.view.physicalSize = const Size(1500, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final c = _plain()..ffmpegVersion = 'test'; - c.videos.addAll([VideoItem(r'D:\v\a.mp4'), VideoItem(r'D:\v\b.mp4')]); - c.selected = null; - await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); - - await tester.tap(find.text('MKV 만들기')); - await tester.pumpAndSettle(); - expect(find.textContaining('선택한 동영상이 없습니다'), findsOneWidget); - expect(find.textContaining('목록 전체 2개'), findsOneWidget); - await tester.tap(find.text('취소')); - await tester.pumpAndSettle(); - expect(c.busy, isFalse); // 취소하면 아무것도 하지 않음 - - // 하나라도 고르면 묻지 않는다 (버튼 글에 개수) - c.toggleChecked(c.videos.last); - await tester.pump(); - expect(find.text('MKV 만들기 (1)'), findsOneWidget); - }); - - testWidgets('왼쪽 공통 버튼: JJ (홈) · MKV 화면으로 · 뒤로 · 다운로드 목록', (tester) async { - tester.view.physicalSize = const Size(1500, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final dir = tempDirUntilEnd('jj_nav_'); - final c = _plain(); - final d = DownloadManager(backends: [_Backend()], settings: () => c.settings, readClipboard: () async => null); - final bm = BookmarksController(p.join(dir.path, 'bm.json')); - await tester.pumpWidget(MaterialApp( - builder: (context, child) => AppScope(controller: c, downloads: d, bookmarks: bm, child: child!), - home: HomePage(c: c, downloads: d, bookmarks: bm), - )); - IconButton btn(String tip) => - tester.widget(find.ancestor(of: find.byTooltip(RegExp('^$tip')), matching: find.byType(IconButton)).first); - - // MKV 화면: 뒤로 · MKV 는 "지금 여기" 라 꺼짐, 다운로드 목록 열기 - expect(btn('뒤로').onPressed, isNull); - expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); - // 지금 화면 버튼 아래 밑줄 하나 (그 버튼 아래에) - final line = find.byKey(const ValueKey('nav-here-underline')); - expect(line, findsOneWidget); - final mkv = tester.getRect(find.byTooltip('MKV 화면 (지금 여기)')); - expect(tester.getCenter(line).dx, closeTo(mkv.center.dx, 2)); - await tester.tap(find.byTooltip('다운로드 목록')); - await tester.pumpAndSettle(); - expect(find.textContaining('다운로드 ('), findsOneWidget); - expect(find.byTooltip('다운로드 목록 (지금 여기)'), findsOneWidget); - - // 다운로드 → 환경 설정 → MKV 화면으로 (한 번에 처음까지) - await tester.tap(find.byTooltip('환경 설정').first); - await tester.pumpAndSettle(); - expect(find.text('환경 설정'), findsWidgets); - await tester.tap(find.byTooltip('MKV 화면으로')); - await tester.pumpAndSettle(); - expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); - - // 뒤로: 한 단계만 - await tester.tap(find.byTooltip('다운로드 목록')); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('뒤로')); - await tester.pumpAndSettle(); - expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); - - // 홈 화면을 웹 브라우저로 정하면 JJ 아이콘이 브라우저를 연다 (MKV 버튼은 그대로 있음) - c.settings.display.startScreen = 'browser'; - await tester.tap(find.byTooltip('다운로드 목록')); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip(RegExp('^홈 화면 \\(웹 브라우저\\)'))); - // 화면 전환 (닫히는 화면 · 새 화면) 이 끝날 때까지. 브라우저는 계속 움직여서 pumpAndSettle 은 쓰지 않는다. - for (var i = 0; i < 6; i++) { - await tester.pump(const Duration(milliseconds: 200)); - } - expect(find.byType(BrowserPage), findsOneWidget); - expect(find.byTooltip('MKV 화면으로'), findsOneWidget); - await tester.pumpWidget(const SizedBox()); - d.dispose(); - }); - test('262: 동영상이 끝나는 바로 그때 줄 ■ (지금 것만 취소) 가 와도 성공 그대로 - 줄 · 집계 · 파일', () async { - final tool = _EndTool(); + final tool = EndTool(); final dir = Directory.systemTemp.createTempSync('jj_batch262_'); addTearDown(() => dir.deleteSync(recursive: true)); final files = [for (final n in ['batch1', 'batch2', 'batch3']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path]; @@ -787,185 +358,4 @@ void main() { c.resetStatus(v1); expect(v1.status, JobStatus.ready); }); - - testWidgets('262: 끝나는 순간 줄의 ■ 자리에 오는 ⟳ 는 지금 묶음이 끝날 때까지 눌리지 않는다 (✕ 도 그 자리로 밀려오지 않음)', (tester) async { - tester.view.physicalSize = const Size(1600, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final tool = _EndTool(); - final dir = Directory.systemTemp.createTempSync('jj_batch262w_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final files = [for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path]; - final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); - late Future building; - await tester.runAsync(() async { - await c.init(); - c.settings.mkv.maxParallelJobs = 1; - await c.addVideos(files); - building = c.buildVideos([...c.videos]); - for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { - await Future.delayed(const Duration(milliseconds: 10)); - } - }); - final [a, b] = c.videos; - await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); - await tester.pump(); - final stopAt = tester.getCenter(find.byTooltip('이 동영상만 취소')); - // a 가 끝나고 b 가 시작 - await tester.runAsync(() async { - tool.finish(a); - for (var i = 0; i < 200 && !tool.runs.containsKey(b); i++) { - await Future.delayed(const Duration(milliseconds: 10)); - } - }); - await tester.pump(); - expect(a.status, JobStatus.done); - // ■ 를 누르려던 자리를 누른다 - await tester.tapAt(stopAt); - await tester.pump(); - expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음'); - expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음'); - await tester.runAsync(() async { - tool.finish(b); - await building; - }); - await tester.pump(); - expect(c.logs.join('\n'), contains('성공 2 · 취소 0 · 실패 0 / 전체 2')); - // 누른 자리의 풍선 도움말 타이머 정리 - await tester.pumpWidget(const SizedBox()); - await tester.pump(const Duration(seconds: 2)); - }); - - testWidgets('240: 여럿이 동시에 돌 때 ■ 는 묻는다 ([그대로 두기] · [모두 취소 (n개)]) · 목록 줄의 ■ 는 그 동영상만', (tester) async { - tester.view.physicalSize = const Size(1600, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final tool = _HoldTool(); - final dir = Directory.systemTemp.createTempSync('jj_batch240_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final files = [ - for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path, - ]; - final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); - late Future building; - await tester.runAsync(() async { - await c.init(); - c.settings.mkv.maxParallelJobs = 5; - await c.addVideos(files); - building = c.buildVideos([...c.videos]); - for (var i = 0; i < 200 && tool.runs.length < 2; i++) { - await Future.delayed(const Duration(milliseconds: 10)); - } - }); - final [a, b] = c.videos; - expect([a.status, b.status], [JobStatus.running, JobStatus.running]); - await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); - await tester.pump(); - // ■ (위 막대): 둘이 돌고 남은 것은 없음 → 묻는다 - await tester.tap(find.byTooltip('작업 취소').first); - await tester.pump(const Duration(milliseconds: 400)); // 도는 동안은 진행 막대가 움직여 pumpAndSettle 이 끝나지 않는다 - expect(find.text('모두 취소 (2개)'), findsOneWidget); - await tester.tap(find.text('그대로 두기')); - await tester.pump(const Duration(milliseconds: 400)); // 도는 동안은 진행 막대가 움직여 pumpAndSettle 이 끝나지 않는다 - expect(tool.runs.length, 2, reason: '[그대로 두기] 는 아무것도 취소하지 않음'); - // 목록 줄의 ■: 그 동영상만 - expect(find.byTooltip('이 동영상만 취소'), findsNWidgets(2)); - await tester.tap(find.byTooltip('이 동영상만 취소').first); - await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); - await tester.pump(); - expect([a.status, b.status], [JobStatus.cancelled, JobStatus.running]); - // 하나만 남으면 ■ 는 묻지 않고 바로 취소 - await tester.tap(find.byTooltip('작업 취소').first); - await tester.runAsync(() => building); - await tester.pump(); - expect(b.status, JobStatus.cancelled); - expect(find.text('모두 취소 (2개)'), findsNothing); - }); - - testWidgets('242: 폰 가로 (노트 10+ - 내비게이션 막대를 뺀 약 800×411) 에서 작업 중이어도 위 막대 버튼이 화면 크기 "100%" 를 덮지 않는다', (tester) async { - final w = double.parse(Platform.environment['JJ_W'] ?? '800'); - tester.view.physicalSize = Size(w, 411); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final tool = _HoldTool(); - final dir = Directory.systemTemp.createTempSync('jj_batch242_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final file = (File(p.join(dir.path, 'a.mp4'))..writeAsBytesSync([0])).path; - final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); - late Future building; - await tester.runAsync(() async { - await c.init(); - await c.addVideos([file]); - c.selected = c.videos.first; - building = c.buildVideos([...c.videos]); - for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { - await Future.delayed(const Duration(milliseconds: 10)); - } - }); - expect(c.busy, isTrue); - await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); - // 첫 그림에서 재어 보고 모자라면 다음 그림부터 두 줄 (첫 그림도 넘침 오류 없이 잘라 그림) - expect(tester.takeException(), isNull); - await tester.pump(); - await tester.pump(); - expect(tester.takeException(), isNull, reason: '두 줄로 바뀐 뒤에는 넘침 없음'); - final pct = tester.getRect(find.text('${(c.settings.display.scale * 100).round()}%')); - for (final tip in ['작업 취소', '결과 폴더 열기: ${outputDirFor(file)}']) { - final f = find.byTooltip(tip); - expect(f, findsWidgets, reason: tip); - final r = tester.getRect(f.first); - expect(r.overlaps(pct), isFalse, reason: '$tip 버튼 $r 이 "100%" $pct 를 덮음'); - } - c.cancel(); - await tester.runAsync(() => building); - await tester.pump(); - }); - - testWidgets('폰 세로 (노트 10+ 약 411×800): 여러 개를 만드는 동안 위 막대 진행은 "n/3 · 37%" 짧은 꼴 · [+ 동영상 추가] 를 가리지 않는다', (tester) async { - tester.view.physicalSize = const Size(411, 800); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final tool = _HoldTool(); - final dir = Directory.systemTemp.createTempSync('jj_batch_portrait_'); - addTearDown(() => dir.deleteSync(recursive: true)); - final files = [for (final n in ['a', 'b', 'c']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path]; - final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); - late Future building; - await tester.runAsync(() async { - await c.init(); - await c.addVideos(files); - c.selected = c.videos.first; - building = c.buildVideos([...c.videos]); - for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { - await Future.delayed(const Duration(milliseconds: 10)); - } - }); - expect(c.busy, isTrue); - expect(c.batchTotal, 3); - await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); - await tester.pump(); - await tester.pump(); - // 위 막대를 잰 뒤에 진행률이 생긴다 (예전에는 이때 글이 넓어져 [+] 를 가렸다) - for (final v in c.videos.where((v) => v.status == JobStatus.running)) { - v.progress = 0.37; - } - await c.updateSettings((_) {}); - await tester.pump(); - expect(tester.takeException(), isNull); - final prog = find.textContaining(RegExp(r'^\d+/3 · 37%$')); - expect(prog, findsOneWidget); - final add = find.byTooltip(Platform.isAndroid ? '동영상 추가' : '동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'); - expect(add, findsWidgets); - final r = tester.getRect(prog); - // [+] 는 옆으로 미는 줄 안에 있어 끝이 잘려 보일 수 있다 - 실제로 보이는 부분 (밀기 줄 안쪽) 이 진행 글과 겹치지 않아야 한다 - for (final e in add.evaluate()) { - final a = tester.getRect(find.byWidget(e.widget).first); - final view = tester.getRect(find.ancestor(of: find.byWidget(e.widget).first, matching: find.byType(Scrollable)).first); - final seen = a.intersect(view); - expect(seen.width > 0 && seen.overlaps(r), isFalse, reason: '[+] 의 보이는 부분 $seen 이 진행 글 $r 와 겹침'); - } - c.cancel(); - await tester.runAsync(() => building); - await tester.pump(); - }); } diff --git a/test/batch_ui_test.dart b/test/batch_ui_test.dart new file mode 100644 index 0000000..55dbc86 --- /dev/null +++ b/test/batch_ui_test.dart @@ -0,0 +1,536 @@ +import 'dart:convert'; +import 'dart:io'; + +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/settings.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/core/output_paths.dart'; +import 'package:jj_mkvmaker/l10n/tr.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/app_actions.dart'; +import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:jj_mkvmaker/ui/settings_page.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dirs.dart'; +import 'support/batch_doubles.dart'; + +void main() { + deleteTempDirsAtEnd(); + testWidgets('198: 원어를 자동 감지한 동영상은 세부 정보에 감지한 언어 · [다른 언어로 다시] (알림을 놓쳐도)', (tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.videos.add(VideoItem(r'D:\v\a.mp4')..aiDetected = 'ko'); + c.selected = c.videos.first; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + expect(find.text('감지한 언어: 한국어 (자동 감지)'), findsOneWidget); + expect(find.widgetWithText(TextButton, '다른 언어로 다시'), findsOneWidget); + // 원어를 직접 고르면 (null) 보이지 않는다 + c.videos.first.aiDetected = null; + c.selected = c.videos.first; + await tester.pumpWidget(MaterialApp(key: UniqueKey(), home: HomePage(c: c))); + expect(find.textContaining('감지한 언어'), findsNothing); + }); + + for (final (name, size, targets) in [ + ('폰 세로', const Size(412, 900), 0), + ('폰 세로 · 이동 버튼 3개', const Size(412, 900), 3), + ('넓은 화면 · 이동 버튼 3개', const Size(1600, 520), 3), + ]) { + testWidgets('206: MKV 자세히 칸의 [이동] 버튼이 칩 (음성 n개) 을 가리지 않는다 ($name)', (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.mkv.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')]; + c.videos.add(VideoItem(r'D:\v\a.mp4') + ..info = const MediaInfo(duration: Duration(minutes: 3), streams: [ + StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080), + StreamInfo(index: 1, type: 'audio', codec: 'aac'), + ])); + c.selected = c.videos.first; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + await tester.pump(); + final chip = find.text('음성 1개'); + // 끝까지 내려 본다 (넓은 화면은 떠 있는 버튼 - 끝에서도 가리지 않아야) + await tester.dragUntilVisible(chip, find.byType(ListView).last, const Offset(0, -80)); + await tester.drag(find.byType(ListView).last, const Offset(0, -2000)); + await tester.pumpAndSettle(); + expect(chip, findsOneWidget); + final r = tester.getRect(chip); + for (final b in find.byType(FloatingActionButton).evaluate()) { + final br = tester.getRect(find.byWidget(b.widget)); + expect(br.overlaps(r), isFalse, reason: '버튼 $br · 칩 $r'); + } + }); + } + + testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async { + tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비 + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), + storage: DesktopStorageService(), + createRecognizer: TestRecognizer.new, + createTranslator: TestTranslator.new, + )); + for (final n in ['a', 'b', 'c']) { + c.videos.add(VideoItem('D:\\v\\$n.mp4')); + } + c.selected = c.videos.first; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + expect(find.text('자막 만들기'), findsOneWidget); + expect(find.text('자막 만들기 & MKV 만들기'), findsOneWidget); + expect(find.text('동영상 3개 · 전체 선택'), findsOneWidget); + + // 줄의 체크 상자 (0 번째는 전체 선택) + // (줄에 더블클릭 = 재생이 있어 탭은 더블클릭 대기 시간 뒤에 확정된다) + await tester.tap(find.byType(Checkbox).at(1)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(find.byType(Checkbox).at(3)); + await tester.pump(const Duration(milliseconds: 400)); + expect(c.checked, {c.videos[0], c.videos[2]}); + expect(find.text('선택 2 / 3개'), findsOneWidget); + expect(find.text('자막 만들기 (2)'), findsOneWidget); + expect(find.text('자막 만들기 & MKV 만들기 (2)'), findsOneWidget); + expect(find.text('MKV 만들기 (2)'), findsOneWidget); + expect(find.byTooltip(RegExp('^파일 이름 순으로 정렬')), findsOneWidget); + expect(find.byTooltip(RegExp('^날짜 순으로 정렬')), findsOneWidget); + + await tester.tap(find.text('선택 2 / 3개')); + await tester.pump(); + expect(c.checked, hasLength(3)); + await tester.tap(find.byTooltip('선택한 동영상을 목록에서 제거')); + await tester.pump(); + expect(c.videos, isEmpty); + }); + + testWidgets('다운로드 화면: 다 받은 것을 골라 [동영상 추가] → MKV 목록', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final dir = Directory.systemTemp.createTempSync('jj_dladd_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = p.join(dir.path, 'movie.mp4'); + File(file).writeAsStringSync('x'); + + final d = DownloadManager( + backends: [TestDownloadBackend()], settings: () => AppSettings()..download.root = dir.path, readClipboard: () async => null); + final added = []; + d.addToEditList = (files) async { + added.addAll(files); + return files.length; + }; + final done = d.addPage('https://vimeo.com/1')! + ..title = '다 받은 영상' + ..files.add(file) + ..state = DownloadState.done; + d.addPage('https://vimeo.com/2')!.title = '받는 중인 영상'; + + await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); + OutlinedButton btn() => tester.widget( + find.ancestor(of: find.text('동영상 추가'), matching: find.byWidgetPredicate((w) => w is OutlinedButton))); + expect(btn().onPressed, isNull); // 고른 것이 없음 + + await tester.tap(find.text('받는 중인 영상')); + await tester.pump(); + expect(btn().onPressed, isNull); // 아직 다 받지 않음 + + await tester.tap(find.text('다 받은 영상')); + await tester.pump(); + expect(d.selectedVideoFiles, [file]); + await tester.tap(find.text('동영상 추가')); + await tester.pump(); + expect(added, [file]); + expect(find.textContaining('동영상 1개를 추가했습니다'), findsOneWidget); + expect(done.state, DownloadState.done); + d.dispose(); + }); + + testWidgets('다운로드 화면: "완료 시 자동 동영상 추가" 체크 → 설정 변경, 목록에서 빼기', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final s = AppSettings()..download.addFinished = false; + final d = DownloadManager(backends: [TestDownloadBackend()], settings: () => s, readClipboard: () async => null); + d.addToEditList = (files) async => files.length; + d.setAutoAdd = (on) async { + s.download.addFinished = on; + d.refresh(); + }; + final t = d.addPage('https://vimeo.com/1')!..title = '영상'; + + await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); + Checkbox box() => tester.widget( + find.descendant(of: find.byTooltip(RegExp('MKV 만들기의 동영상 목록에 자동으로')), matching: find.byType(Checkbox))); + expect(box().value, isFalse); + await tester.tap(find.text('완료 시 자동 동영상 추가')); + await tester.pump(); + expect(s.download.addFinished, isTrue); + expect(box().value, isTrue); + + // 다 받으면 목록에서 뺀다 (앱이 onFinished 에서 부름) + d.dropFromList(t); + await tester.pump(); + expect(d.tasks, isEmpty); + expect(find.text('영상'), findsNothing); + d.dispose(); + }); + + // 246 · P11 확인 (921): 영어 · 일본어는 글이 길어 체크가 아예 안 보였다 - 언어 · 폭마다 + for (final lang in ['ko', 'en', 'ja']) { + for (final w in [800.0, 1200.0]) { + testWidgets('246: 다운로드 화면 ($lang, 폭 $w) - 버튼 줄의 글이 잘리거나 "전체 선택" 과 겹치지 않는다', (tester) async { + Map dict(String code) => + Map.from(jsonDecode(File('assets/l10n/$code.json').readAsStringSync()) as Map); + setTranslations(lang, lang == 'ko' ? const {} : dict(lang), lang == 'ko' ? const {} : dict('en')); + addTearDown(() => setTranslations('ko', const {})); + tester.view.physicalSize = Size(w, 411); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final s = AppSettings(); + final d = DownloadManager(backends: [TestDownloadBackend()], settings: () => s, readClipboard: () async => null); + addTearDown(d.dispose); + d.addToEditList = (files) async => files.length; + d.setAutoAdd = (on) async => s.download.addFinished = on; + d.addPage('https://vimeo.com/1')!.title = '영상'; + await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); + await tester.pump(); + expect(tester.takeException(), isNull); + final all = tester.getRect(find.text(tr('전체 선택'))); + final auto = tester.getRect(find.text(tr('완료 시 자동 동영상 추가'))); + expect(auto.overlaps(all), isFalse, reason: '$auto · $all'); + expect([auto.left >= 0, auto.right <= w], [true, true], reason: '화면 안에 다 보임 $auto'); + for (final t in ['일시정지', '재개', '다운로드 취소', '삭제', '완료 정리']) { + final r = tester.getRect(find.text(tr(t)).first); + expect([r.left >= 0, r.right <= w], [true, true], reason: '$t $r'); + } + }); + } + } + + testWidgets('환경 설정 · 종료 버튼: 모든 화면에서 같은 자리', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final dir = Directory.systemTemp.createTempSync('jj_pos_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final c = plainController(); + final d = DownloadManager(backends: [TestDownloadBackend()], settings: () => c.settings, readClipboard: () async => null); + final bm = BookmarksController(p.join(dir.path, 'bm.json')); + var exits = 0; + + Future<(Offset, Offset, Offset, Offset)> at(Widget page) async { + await tester.pumpWidget(MaterialApp( + key: UniqueKey(), + builder: (context, child) => + AppScope(controller: c, onExit: () => exits++, downloads: d, bookmarks: bm, child: child!), + home: page, + )); + await tester.pump(); + return ( + tester.getCenter(find.byTooltip('환경 설정')), + tester.getCenter(find.byTooltip('종료')), + // 왼쪽 공통 버튼: JJ (홈) · 다운로드 목록 + tester.getCenter(find.byTooltip(RegExp('^홈 화면'))), + tester.getCenter(find.byTooltip(RegExp('^다운로드 목록'))), + ); + } + + final home = await at(HomePage(c: c, downloads: d, bookmarks: bm, onExit: () => exits++)); + final pages = { + '다운로드': await at(DownloadsPage(d: d)), + '환경 설정': await at(SettingsPage(c: c)), + '브라우저': await at(BrowserPage( + c: c, bookmarks: bm, downloads: d, viewBuilder: (h, url) => const ColoredBox(color: Colors.white))), + }; + for (final e in pages.entries) { + expect(e.value, home, reason: '${e.key} 화면의 버튼 자리가 MKV 화면과 다릅니다'); + } + // 오른쪽 위 구석 + expect(home.$2.dx, greaterThan(1500 - 60)); + expect(home.$2.dy, 28); + // 왼쪽 위 구석: JJ 아이콘 (홈) + expect(home.$3.dx, lessThan(40)); + expect(home.$3.dy, 28); + + // 브라우저에서 종료 버튼 → 앱의 종료 동작 + await tester.tap(find.byTooltip('종료')); + expect(exits, 1); + d.dispose(); + }); + + testWidgets('MKV 만들기: 체크가 없으면 보고 있는 동영상만, 보고 있는 것도 없으면 "전체 만들기 / 취소" 를 묻는다', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = plainController()..ffmpegVersion = 'test'; + c.videos.addAll([VideoItem(r'D:\v\a.mp4'), VideoItem(r'D:\v\b.mp4')]); + c.selected = null; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + + await tester.tap(find.text('MKV 만들기')); + await tester.pumpAndSettle(); + expect(find.textContaining('선택한 동영상이 없습니다'), findsOneWidget); + expect(find.textContaining('목록 전체 2개'), findsOneWidget); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(c.busy, isFalse); // 취소하면 아무것도 하지 않음 + + // 하나라도 고르면 묻지 않는다 (버튼 글에 개수) + c.toggleChecked(c.videos.last); + await tester.pump(); + expect(find.text('MKV 만들기 (1)'), findsOneWidget); + }); + + testWidgets('왼쪽 공통 버튼: JJ (홈) · MKV 화면으로 · 뒤로 · 다운로드 목록', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final dir = tempDirUntilEnd('jj_nav_'); + final c = plainController(); + final d = DownloadManager(backends: [TestDownloadBackend()], settings: () => c.settings, readClipboard: () async => null); + final bm = BookmarksController(p.join(dir.path, 'bm.json')); + await tester.pumpWidget(MaterialApp( + builder: (context, child) => AppScope(controller: c, downloads: d, bookmarks: bm, child: child!), + home: HomePage(c: c, downloads: d, bookmarks: bm), + )); + IconButton btn(String tip) => + tester.widget(find.ancestor(of: find.byTooltip(RegExp('^$tip')), matching: find.byType(IconButton)).first); + + // MKV 화면: 뒤로 · MKV 는 "지금 여기" 라 꺼짐, 다운로드 목록 열기 + expect(btn('뒤로').onPressed, isNull); + expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); + // 지금 화면 버튼 아래 밑줄 하나 (그 버튼 아래에) + final line = find.byKey(const ValueKey('nav-here-underline')); + expect(line, findsOneWidget); + final mkv = tester.getRect(find.byTooltip('MKV 화면 (지금 여기)')); + expect(tester.getCenter(line).dx, closeTo(mkv.center.dx, 2)); + await tester.tap(find.byTooltip('다운로드 목록')); + await tester.pumpAndSettle(); + expect(find.textContaining('다운로드 ('), findsOneWidget); + expect(find.byTooltip('다운로드 목록 (지금 여기)'), findsOneWidget); + + // 다운로드 → 환경 설정 → MKV 화면으로 (한 번에 처음까지) + await tester.tap(find.byTooltip('환경 설정').first); + await tester.pumpAndSettle(); + expect(find.text('환경 설정'), findsWidgets); + await tester.tap(find.byTooltip('MKV 화면으로')); + await tester.pumpAndSettle(); + expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); + + // 뒤로: 한 단계만 + await tester.tap(find.byTooltip('다운로드 목록')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('뒤로')); + await tester.pumpAndSettle(); + expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); + + // 홈 화면을 웹 브라우저로 정하면 JJ 아이콘이 브라우저를 연다 (MKV 버튼은 그대로 있음) + c.settings.display.startScreen = 'browser'; + await tester.tap(find.byTooltip('다운로드 목록')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(RegExp('^홈 화면 \\(웹 브라우저\\)'))); + // 화면 전환 (닫히는 화면 · 새 화면) 이 끝날 때까지. 브라우저는 계속 움직여서 pumpAndSettle 은 쓰지 않는다. + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(find.byType(BrowserPage), findsOneWidget); + expect(find.byTooltip('MKV 화면으로'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + d.dispose(); + }); + + testWidgets('262: 끝나는 순간 줄의 ■ 자리에 오는 ⟳ 는 지금 묶음이 끝날 때까지 눌리지 않는다 (✕ 도 그 자리로 밀려오지 않음)', (tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tool = EndTool(); + final dir = Directory.systemTemp.createTempSync('jj_batch262w_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final files = [for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path]; + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + late Future building; + await tester.runAsync(() async { + await c.init(); + c.settings.mkv.maxParallelJobs = 1; + await c.addVideos(files); + building = c.buildVideos([...c.videos]); + for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + final [a, b] = c.videos; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + await tester.pump(); + final stopAt = tester.getCenter(find.byTooltip('이 동영상만 취소')); + // a 가 끝나고 b 가 시작 + await tester.runAsync(() async { + tool.finish(a); + for (var i = 0; i < 200 && !tool.runs.containsKey(b); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + await tester.pump(); + expect(a.status, JobStatus.done); + // ■ 를 누르려던 자리를 누른다 + await tester.tapAt(stopAt); + await tester.pump(); + expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음'); + expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음'); + await tester.runAsync(() async { + tool.finish(b); + await building; + }); + await tester.pump(); + expect(c.logs.join('\n'), contains('성공 2 · 취소 0 · 실패 0 / 전체 2')); + // 누른 자리의 풍선 도움말 타이머 정리 + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('240: 여럿이 동시에 돌 때 ■ 는 묻는다 ([그대로 두기] · [모두 취소 (n개)]) · 목록 줄의 ■ 는 그 동영상만', (tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tool = HoldTool(); + final dir = Directory.systemTemp.createTempSync('jj_batch240_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final files = [ + for (final n in ['a', 'b']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path, + ]; + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + late Future building; + await tester.runAsync(() async { + await c.init(); + c.settings.mkv.maxParallelJobs = 5; + await c.addVideos(files); + building = c.buildVideos([...c.videos]); + for (var i = 0; i < 200 && tool.runs.length < 2; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + final [a, b] = c.videos; + expect([a.status, b.status], [JobStatus.running, JobStatus.running]); + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + await tester.pump(); + // ■ (위 막대): 둘이 돌고 남은 것은 없음 → 묻는다 + await tester.tap(find.byTooltip('작업 취소').first); + await tester.pump(const Duration(milliseconds: 400)); // 도는 동안은 진행 막대가 움직여 pumpAndSettle 이 끝나지 않는다 + expect(find.text('모두 취소 (2개)'), findsOneWidget); + await tester.tap(find.text('그대로 두기')); + await tester.pump(const Duration(milliseconds: 400)); // 도는 동안은 진행 막대가 움직여 pumpAndSettle 이 끝나지 않는다 + expect(tool.runs.length, 2, reason: '[그대로 두기] 는 아무것도 취소하지 않음'); + // 목록 줄의 ■: 그 동영상만 + expect(find.byTooltip('이 동영상만 취소'), findsNWidgets(2)); + await tester.tap(find.byTooltip('이 동영상만 취소').first); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + expect([a.status, b.status], [JobStatus.cancelled, JobStatus.running]); + // 하나만 남으면 ■ 는 묻지 않고 바로 취소 + await tester.tap(find.byTooltip('작업 취소').first); + await tester.runAsync(() => building); + await tester.pump(); + expect(b.status, JobStatus.cancelled); + expect(find.text('모두 취소 (2개)'), findsNothing); + }); + + testWidgets('242: 폰 가로 (노트 10+ - 내비게이션 막대를 뺀 약 800×411) 에서 작업 중이어도 위 막대 버튼이 화면 크기 "100%" 를 덮지 않는다', (tester) async { + final w = double.parse(Platform.environment['JJ_W'] ?? '800'); + tester.view.physicalSize = Size(w, 411); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tool = HoldTool(); + final dir = Directory.systemTemp.createTempSync('jj_batch242_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = (File(p.join(dir.path, 'a.mp4'))..writeAsBytesSync([0])).path; + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + late Future building; + await tester.runAsync(() async { + await c.init(); + await c.addVideos([file]); + c.selected = c.videos.first; + building = c.buildVideos([...c.videos]); + for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + expect(c.busy, isTrue); + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + // 첫 그림에서 재어 보고 모자라면 다음 그림부터 두 줄 (첫 그림도 넘침 오류 없이 잘라 그림) + expect(tester.takeException(), isNull); + await tester.pump(); + await tester.pump(); + expect(tester.takeException(), isNull, reason: '두 줄로 바뀐 뒤에는 넘침 없음'); + final pct = tester.getRect(find.text('${(c.settings.display.scale * 100).round()}%')); + for (final tip in ['작업 취소', '결과 폴더 열기: ${outputDirFor(file)}']) { + final f = find.byTooltip(tip); + expect(f, findsWidgets, reason: tip); + final r = tester.getRect(f.first); + expect(r.overlaps(pct), isFalse, reason: '$tip 버튼 $r 이 "100%" $pct 를 덮음'); + } + c.cancel(); + await tester.runAsync(() => building); + await tester.pump(); + }); + + testWidgets('폰 세로 (노트 10+ 약 411×800): 여러 개를 만드는 동안 위 막대 진행은 "n/3 · 37%" 짧은 꼴 · [+ 동영상 추가] 를 가리지 않는다', (tester) async { + tester.view.physicalSize = const Size(411, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tool = HoldTool(); + final dir = Directory.systemTemp.createTempSync('jj_batch_portrait_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final files = [for (final n in ['a', 'b', 'c']) (File(p.join(dir.path, '$n.mp4'))..writeAsBytesSync([0])).path]; + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + late Future building; + await tester.runAsync(() async { + await c.init(); + await c.addVideos(files); + c.selected = c.videos.first; + building = c.buildVideos([...c.videos]); + for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + expect(c.busy, isTrue); + expect(c.batchTotal, 3); + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + await tester.pump(); + await tester.pump(); + // 위 막대를 잰 뒤에 진행률이 생긴다 (예전에는 이때 글이 넓어져 [+] 를 가렸다) + for (final v in c.videos.where((v) => v.status == JobStatus.running)) { + v.progress = 0.37; + } + await c.updateSettings((_) {}); + await tester.pump(); + expect(tester.takeException(), isNull); + final prog = find.textContaining(RegExp(r'^\d+/3 · 37%$')); + expect(prog, findsOneWidget); + final add = find.byTooltip(Platform.isAndroid ? '동영상 추가' : '동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'); + expect(add, findsWidgets); + final r = tester.getRect(prog); + // [+] 는 옆으로 미는 줄 안에 있어 끝이 잘려 보일 수 있다 - 실제로 보이는 부분 (밀기 줄 안쪽) 이 진행 글과 겹치지 않아야 한다 + for (final e in add.evaluate()) { + final a = tester.getRect(find.byWidget(e.widget).first); + final view = tester.getRect(find.ancestor(of: find.byWidget(e.widget).first, matching: find.byType(Scrollable)).first); + final seen = a.intersect(view); + expect(seen.width > 0 && seen.overlaps(r), isFalse, reason: '[+] 의 보이는 부분 $seen 이 진행 글 $r 와 겹침'); + } + c.cancel(); + await tester.runAsync(() => building); + await tester.pump(); + }); +} diff --git a/test/support/batch_doubles.dart b/test/support/batch_doubles.dart new file mode 100644 index 0000000..8664499 --- /dev/null +++ b/test/support/batch_doubles.dart @@ -0,0 +1,102 @@ +/// MKV 묶음 · 다운로드 시험의 대역 (batch_test · batch_ui_test 가 같이 쓴다): 멈춰 두는 FFmpeg · 음성인식 · 번역 · 다운로드 엔진 +library; + +import 'dart:async'; +import 'dart:io'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/core/srt.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/ai_services.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/services/media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +class TestRecognizer implements SpeechRecognizer { + @override + void cancel() {} + final heard = []; + @override + Future> transcribe(String wavPath, + {required String modelPath, String language = 'auto', AiProgress? onProgress}) async { + heard.add(p.basename(wavPath)); + return [Cue(const Duration(seconds: 1), const Duration(seconds: 2), 'Hello everyone, this is a test.')]; + } +} + +/// 240: 취소될 때까지 끝나지 않는 FFmpeg (여럿이 동시에 도는 상태를 만든다) +class HoldTool implements MediaTool { + final runs = >{}; + @override + Future version() async => 'ffmpeg test'; + @override + Future> encoders() async => {'libx264'}; + @override + Future probe(String path) async => const MediaInfo(duration: Duration(seconds: 1)); + @override + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { + final c = runs[tag] = Completer(); + try { + await c.future; + } finally { + runs.remove(tag); + } + } + + void _stop(Completer? c) { + if (c != null && !c.isCompleted) c.completeError(MediaToolException('사용자가 취소했습니다.')); + } + + @override + void cancel() => runs.values.toList().forEach(_stop); + @override + void cancelTagged(Object tag) => _stop(runs[tag]); +} + +class TestTranslator implements Translator { + @override + Future load(String modelDir) async {} + @override + Future> translate(List lines, + {required String source, required String target, AiProgress? onProgress}) async => + [for (final l in lines) '[$target] $l']; + @override + void cancel() {} + @override + Future dispose() async {} +} + +class TestDownloadBackend implements DownloadBackend { + @override + DownloadKind get kind => DownloadKind.video; + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async => t.state = DownloadState.downloading; + @override + Future pause(DownloadTask t) async {} + @override + Future cancel(DownloadTask t) async {} + @override + Future shutdown() async {} +} + +/// 262: [HoldTool] 과 같지만, 끝내면 (결과 이름으로 바꿀) 임시 출력 파일을 쓴다 +class EndTool extends HoldTool { + final args = >{}; + @override + Future runFfmpeg(List a, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { + args[tag] = a; + await super.runFfmpeg(a, duration: duration, onProgress: onProgress, tag: tag); + File(a.last).writeAsStringSync('NEW'); + } + + /// [tag] 의 FFmpeg 를 성공으로 끝낸다 + void finish(Object tag) => runs[tag]!.complete(); +} + +AppController plainController() => + AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));