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())); void main() { deleteTempDirsAtEnd(); test('여러 개 선택: 체크 · 전체 선택 · 일괄 대상 · 선택 제거', () async { final dir = Directory.systemTemp.createTempSync('jj_batch_'); addTearDown(() => dir.deleteSync(recursive: true)); final files = [for (final n in ['a', 'b', 'c']) p.join(dir.path, '$n.mp4')]; for (final f in files) { File(f).writeAsStringSync('x'); } final c = _plain(); await c.addVideos(files); final [a, b, cc] = c.videos; // 체크한 것이 없으면 지금 보고 있는 한 개 expect(c.batchTargets, [a]); c.toggleChecked(cc); c.toggleChecked(a); expect(c.batchTargets, [a, cc]); // 목록 순서대로 c.toggleAllChecked(); expect(c.checked, hasLength(3)); c.toggleAllChecked(); expect(c.checked, isEmpty); c ..toggleChecked(a) ..toggleChecked(b); b.status = JobStatus.running; // 작업 중인 것은 남긴다 c.removeChecked(); expect(c.videos, [b, cc]); expect(c.selected, b); b.status = JobStatus.ready; c.removeVideo(b); expect(c.checked, isEmpty); expect(c.batchTargets, [cc]); }); test('목록 정렬 (파일 이름 · 날짜, 다시 누르면 반대) · 선택한 파일을 목록 순서대로 재생', () async { final dir = Directory.systemTemp.createTempSync('jj_sort_'); addTearDown(() => dir.deleteSync(recursive: true)); // 이름순: ep2 < ep10 < intro 날짜순: intro(가장 오래됨) < ep10 < ep2 final when = {'ep10.mp4': 2, 'intro.mp4': 1, 'ep2.mp4': 3}; for (final e in when.entries) { File(p.join(dir.path, e.key)) ..writeAsStringSync('x') ..setLastModifiedSync(DateTime(2026, 1, e.value)); } final c = _plain(); 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']); c.sortVideos('name'); expect(names(), ['ep2.mp4', 'ep10.mp4', 'intro.mp4']); // 숫자는 숫자로 비교 c.sortVideos('name'); expect(names(), ['intro.mp4', 'ep10.mp4', 'ep2.mp4']); c.sortVideos('date'); expect([c.sortedBy, c.sortAscending], ['date', true]); expect(names(), ['intro.mp4', 'ep10.mp4', 'ep2.mp4']); c.sortVideos('date'); expect(names(), ['ep2.mp4', 'ep10.mp4', 'intro.mp4']); // 재생 순서: 지금 목록에 보이는 순서 그대로 (이름순으로 다시 정렬하지 않음) c.sortVideos('date'); // intro, ep10, ep2 for (final v in c.videos) { c.toggleChecked(v); } final files = [for (final v in c.batchTargets) v.path]; final (kept, start) = (await c.preparePlayback(files, keepOrder: true))!; expect([for (final f in kept) p.basename(f)], ['intro.mp4', 'ep10.mp4', 'ep2.mp4']); expect(start, 0); final (sorted, _) = (await c.preparePlayback(files))!; expect([for (final f in sorted) p.basename(f)], ['ep2.mp4', 'ep10.mp4', 'intro.mp4']); }); test('자막 만들기 & MKV 만들기: 체크한 동영상만 자막 → MKV 까지 일괄', () async { final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); final dir = Directory.systemTemp.createTempSync('jj_batch2_'); addTearDown(() => dir.deleteSync(recursive: true)); final files = [for (final n in ['one', 'two', 'three']) p.join(dir.path, '$n.mp4')]; for (final f in files) { await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=160x120:rate=10', '-f', 'lavfi', '-i', 'sine', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', f]); } final models = ModelStore(p.join(dir.path, 'models')); for (final m in [whisperModels.first, nllbModel]) { for (var i = 0; i < m.files.length; i++) { File(await models.pathOf(m, i)) ..createSync(recursive: true) ..writeAsStringSync('x'); } } final rec = _Recognizer(); final c = AppController(PlatformServices( mediaTool: tool, storage: DesktopStorageService(), createRecognizer: () => rec, createTranslator: _Translator.new, models: models, )); await c.init(); await c.addVideos(files); final [one, two, three] = c.videos; c ..toggleChecked(one) ..toggleChecked(three); await c.aiThenBuild(c.batchTargets, AiOptions.defaults()); expect(rec.heard, hasLength(2)); expect([one.status, two.status, three.status], [JobStatus.done, JobStatus.ready, JobStatus.done], reason: '${one.message} ${three.message}'); expect(File(outputMkvPath(one.path)).existsSync(), isTrue); expect(File(outputMkvPath(two.path)).existsSync(), isFalse); final info = await tool.probe(outputMkvPath(three.path)); expect(info.ofType('subtitle').map((s) => s.language), containsAll(['kor', 'eng', 'jpn'])); expect(c.busy, isFalse); // 고른 것만 다시 만들기 (이미 만든 것도) await c.buildVideos([one]); expect(one.status, JobStatus.done); }); test('186 · 187: MKV 3개 중 v1 을 만드는 동안 "지금 것만 취소" - v1 은 취소됨 (실패 아님), v2 · v3 는 이어서 완성', () async { final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); final dir = Directory.systemTemp.createTempSync('jj_batch186_'); // 시간 초과로 끝나면 ffmpeg 가 아직 파일을 쥐고 있어 바로 지우지 못했다 (10/18 전체 시험) - 잠시 다시 시도 addTearDown(() async { for (var i = 0;; i++) { try { if (dir.existsSync()) dir.deleteSync(recursive: true); return; } on FileSystemException { if (i >= 50) rethrow; await Future.delayed(const Duration(milliseconds: 200)); } } }); // v1 은 다시 인코딩에 시간이 걸리게 길게, v2 · v3 는 짧게 final files = [for (final n in ['v1', 'v2', 'v3']) p.join(dir.path, '$n.mp4')]; for (final (i, f) in files.indexed) { await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-t', i == 0 ? '60' : '1', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', f]); } final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); c.settings.maxParallelJobs = 1; c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); await c.addVideos(files); final [v1, v2, v3] = c.videos; final building = c.buildVideos([v1, v2, v3]); // v1 이 만들어지는 중 · 묶음에 2개가 기다림. 관리자 지적 (10/18): PC 가 바쁘면 정해진 횟수 안에 오지 않아 30초 한도에 걸렸다 - 실제 시간으로 (최대 90초) final end = DateTime.now().add(const Duration(seconds: 90)); while (!(v1.status == JobStatus.running && v1.progress > 0.02) && DateTime.now().isBefore(end)) { await Future.delayed(const Duration(milliseconds: 20)); } expect(v1.status, JobStatus.running); expect(c.batchWaiting, 2, reason: '한 묶음 안의 기다리는 동영상도 대기로 센다'); expect(c.cancelVideos(c.runningVideos), isEmpty); await building; expect([v1.status, v2.status, v3.status], [JobStatus.cancelled, JobStatus.done, JobStatus.done], reason: '${v1.message} ${v2.message} ${v3.message}'); expect(v1.message, '취소됨'); expect(File(outputMkvPath(v1.path)).existsSync(), isFalse, reason: '만들다 만 파일은 지움'); expect(c.logs.join('\n'), contains('성공 2 · 취소 1 · 실패 0 / 전체 3')); }, timeout: const Timeout(Duration(minutes: 4))); group('P0 (931): 결과 MKV 가 이미 있을 때 · 취소해도 예전 완성본은 그대로', () { late Directory dir; late ProcessMediaTool tool; late AppController c; late List files; setUp(() async { tool = ProcessMediaTool('ffmpeg', 'ffprobe'); dir = Directory.systemTemp.createTempSync('jj_p0_'); files = [for (final n in ['b1', 'b2', 'b3']) p.join(dir.path, '$n.mp4')]; }); tearDown(() => dir.deleteSync(recursive: true)); Future prepare({List seconds = const [1, 1, 1]}) async { if (await tool.version() == null) return false; for (final (i, f) in files.indexed) { await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-t', '${seconds[i]}', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', f]); } c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); c.settings.maxParallelJobs = 1; c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); await c.addVideos(files); return true; } // 관리자 지적 (10/18): 전체 시험 중 PC 가 바쁘면 정해진 횟수 안에 오지 않아 30초 한도에 걸렸다 (240) - 239 처럼 될 때까지 기다린다 Future until(bool Function() ok, {Duration max = const Duration(seconds: 90)}) async { final end = DateTime.now().add(max); while (!ok() && DateTime.now().isBefore(end)) { await Future.delayed(const Duration(milliseconds: 20)); } } File out(int i) => File(outputMkvPath(files[i])); void writeOld(int i) => (out(i)..createSync(recursive: true)).writeAsStringSync('OLD COMPLETE MKV'); List parts() => [ for (final e in Directory(p.dirname(out(0).path)).listSync()) if (e.path.endsWith('.jjpart')) p.basename(e.path), ]; test('덮어쓰기로 다시 만들다 취소하면 예전 완성본이 그대로 (예전에는 지워졌다) · 임시 파일도 남지 않음', () async { if (!await prepare(seconds: [60, 1, 1])) return markTestSkipped('FFmpeg 없음'); final v1 = c.videos.first; writeOld(0); final building = c.buildVideos([v1], onExisting: 'overwrite'); await until(() => v1.status == JobStatus.running && v1.progress > 0.02); expect(out(0).readAsStringSync(), 'OLD COMPLETE MKV', reason: '만드는 동안에도 예전 것은 그대로 (임시 이름에 씀)'); c.cancel(); await building; expect(v1.status, JobStatus.cancelled); expect(out(0).readAsStringSync(), 'OLD COMPLETE MKV', reason: '취소해도 예전 완성본은 남는다'); expect(parts(), isEmpty, reason: '만들다 만 임시 파일은 지움'); }, timeout: const Timeout(Duration(minutes: 4))); test('덮어쓰기 · 건너뛰기 · 다른 이름', () async { if (!await prepare()) return markTestSkipped('FFmpeg 없음'); final [v1, v2, v3] = c.videos; for (final i in [0, 1, 2]) { writeOld(i); } await c.buildVideos([v1], onExisting: 'overwrite'); expect(out(0).lengthSync(), greaterThan(100), reason: '다 만든 뒤 바꿔 넣음'); await c.buildVideos([v2], onExisting: 'skip'); expect(out(1).readAsStringSync(), 'OLD COMPLETE MKV', reason: '건너뜀 - 그대로'); expect(c.logs.join('\n'), contains('건너뜀 (이미 있음): b2.mkv')); await c.buildVideos([v3], onExisting: 'rename'); expect(out(2).readAsStringSync(), 'OLD COMPLETE MKV', reason: '다른 이름 - 예전 것 그대로'); expect(File(p.join(p.dirname(out(2).path), 'b3 (2).mkv')).lengthSync(), greaterThan(100)); expect(v3.outputPath, endsWith('b3 (2).mkv')); expect(parts(), isEmpty); }, timeout: const Timeout(Duration(minutes: 4))); test('240 집계: 다시 만들다 [남은 것 모두 취소] - 앞 실행의 ✓ 가 섞이지 않고 시작 못 한 것은 취소 (성공 1 · 취소 2)', () async { if (!await prepare(seconds: [1, 60, 1])) return markTestSkipped('FFmpeg 없음'); final [v1, v2, v3] = c.videos; await c.buildVideos([v1, v2, v3].sublist(0, 1), onExisting: 'overwrite'); v2.status = JobStatus.done; // 앞 실행에서 ✓ 였던 것 v3.status = JobStatus.done; final building = c.buildVideos([v1, v2, v3], onExisting: 'overwrite'); await until(() => v2.status == JobStatus.running && v2.progress > 0.01); expect(v2.status, JobStatus.running, reason: '기다린 끝에도 v2 가 시작하지 않음'); c.cancel(); await building; expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.cancelled, JobStatus.cancelled]); expect(c.logs.last, contains('성공 1 · 취소 2 · 실패 0 / 전체 3')); }, timeout: const Timeout(Duration(minutes: 4))); }); test('239: 취소 창을 연 뒤 그 동영상이 끝나고 다음 것이 시작했으면 [지금 것만 취소] 는 다음 것을 취소하지 않는다', () async { final tool = ProcessMediaTool('ffmpeg', 'ffprobe'); if (await tool.version() == null) return markTestSkipped('FFmpeg 없음'); final dir = Directory.systemTemp.createTempSync('jj_batch239_'); addTearDown(() => dir.deleteSync(recursive: true)); // v1 은 짧게 (창을 보는 사이 끝남), v2 는 길게 final files = [for (final n in ['v1', 'v2', 'v3']) p.join(dir.path, '$n.mp4')]; for (final (i, f) in files.indexed) { await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-t', i == 1 ? '60' : '1', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', f]); } final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); c.settings.maxParallelJobs = 1; c.encode = const EncodeSettings(codec: VideoCodecChoice.h264, quality: QualityChoice.high); await c.addVideos(files); final [v1, v2, v3] = c.videos; final building = c.buildVideos([v1, v2, v3]); // 관리자 지적 (10/17): 전체 시험 중 PC 가 바쁘면 정해진 횟수 (4초 · 20초) 안에 오지 않아 한 번 실패했다 - 될 때까지 (넉넉히) 기다린다 Future until(bool Function() ok, {Duration max = const Duration(seconds: 90)}) async { final end = DateTime.now().add(max); while (!ok() && DateTime.now().isBefore(end)) { await Future.delayed(const Duration(milliseconds: 20)); } } await until(() => v1.status == JobStatus.running); final shown = c.runningVideos; // 창을 연 때: v1 expect(shown, [v1]); // 창을 보는 사이 v1 이 끝나고 v2 가 시작 await until(() => v2.status == JobStatus.running && v2.progress > 0.01); expect([v1.status, v2.status], [JobStatus.done, JobStatus.running]); expect(c.cancelVideos(shown), [v1], reason: '이미 끝남 - 알리고 다시 묻는다'); await Future.delayed(const Duration(milliseconds: 500)); expect(v2.status, JobStatus.running, reason: '창이 보여 준 것이 아닌 v2 는 취소하지 않음'); // 다시 묻는 창에서 v2 를 취소 → v3 은 이어서 expect(c.cancelVideos(c.runningVideos), isEmpty); await building; expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.cancelled, JobStatus.done], 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.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.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 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]; final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); await c.init(); c.settings ..maxParallelJobs = 1 ..mkvExisting = 'overwrite'; await c.addVideos(files); final [v1, v2, v3] = c.videos; File(outputMkvPath(v1.path)) ..createSync(recursive: true) ..writeAsStringSync('OLD'); final building = c.buildVideos([v1, v2, v3]); Future until(bool Function() ok) async { for (var i = 0; i < 300 && !ok(); i++) { await Future.delayed(const Duration(milliseconds: 5)); } } await until(() => tool.runs.containsKey(v1)); // FFmpeg 끝 (성공) 바로 뒤 · 결과 이름으로 바꾸기 전에 ■ tool.finish(v1); expect(v1.status, JobStatus.running); c.cancelVideos([v1]); // 끝난 바로 뒤 같은 자리의 ⟳ 를 눌러도 (손가락이 ■ 를 누르려던 것) 지금 묶음에서는 바뀌지 않는다 await until(() => v1.status == JobStatus.done); c.resetStatus(v1); expect(v1.status, JobStatus.done); for (final v in [v2, v3]) { await until(() => tool.runs.containsKey(v)); tool.finish(v); } await building; expect([v1.status, v2.status, v3.status], [JobStatus.done, JobStatus.done, JobStatus.done], reason: '${v1.message}'); expect(File(outputMkvPath(v1.path)).readAsStringSync(), 'NEW'); final log = c.logs.join('\n'); expect(log, contains('성공: batch1.mkv')); expect(log, contains('성공 3 · 취소 0 · 실패 0 / 전체 3')); // 묶음이 끝나면 ⟳ 로 다시 대기로 돌릴 수 있다 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.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.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.uiScale * 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(); }); }