JJ_MKVMaker/test/batch_test.dart

952 lines
45 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;
class _Recognizer implements SpeechRecognizer {
@override
void cancel() {}
final heard = <String>[];
@override
Future<List<Cue>> 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 = <Object?, Completer<void>>{};
@override
Future<String?> version() async => 'ffmpeg test';
@override
Future<Set<String>> encoders() async => {'libx264'};
@override
Future<MediaInfo> probe(String path) async => const MediaInfo(duration: Duration(seconds: 1));
@override
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
final c = runs[tag] = Completer<void>();
try {
await c.future;
} finally {
runs.remove(tag);
}
}
void _stop(Completer<void>? 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<void> load(String modelDir) async {}
@override
Future<List<String>> translate(List<String> lines,
{required String source, required String target, AiProgress? onProgress}) async =>
[for (final l in lines) '[$target] $l'];
@override
void cancel() {}
@override
Future<void> dispose() async {}
}
class _Backend implements DownloadBackend {
@override
DownloadKind get kind => DownloadKind.video;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async => t.state = DownloadState.downloading;
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {}
@override
Future<void> shutdown() async {}
}
/// 262: [_HoldTool] 과 같지만, 끝내면 (결과 이름으로 바꿀) 임시 출력 파일을 쓴다
class _EndTool extends _HoldTool {
final args = <Object?, List<String>>{};
@override
Future<void> runFfmpeg(List<String> 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() {
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<String> 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_');
addTearDown(() => dir.deleteSync(recursive: true));
// 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개가 기다림
for (var i = 0; i < 400 && !(v1.status == JobStatus.running && v1.progress > 0.02); i++) {
await Future<void>.delayed(const Duration(milliseconds: 25));
}
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'));
});
group('P0 (931): 결과 MKV 가 이미 있을 때 · 취소해도 예전 완성본은 그대로', () {
late Directory dir;
late ProcessMediaTool tool;
late AppController c;
late List<String> 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<bool> prepare({List<int> 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;
}
File out(int i) => File(outputMkvPath(files[i]));
void writeOld(int i) => (out(i)..createSync(recursive: true)).writeAsStringSync('OLD COMPLETE MKV');
List<String> 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');
for (var i = 0; i < 400 && !(v1.status == JobStatus.running && v1.progress > 0.02); i++) {
await Future<void>.delayed(const Duration(milliseconds: 25));
}
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: '만들다 만 임시 파일은 지움');
});
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);
});
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');
for (var i = 0; i < 800 && !(v2.status == JobStatus.running && v2.progress > 0.01); i++) {
await Future<void>.delayed(const Duration(milliseconds: 25));
}
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'));
});
});
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<void> 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<void>.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<void>.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()..downloadRoot = dir.path, readClipboard: () async => null);
final added = <String>[];
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<OutlinedButton>(
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()..addFinishedDownloads = false;
final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null);
d.addToEditList = (files) async => files.length;
d.setAutoAdd = (on) async {
s.addFinishedDownloads = on;
d.refresh();
};
final t = d.addPage('https://vimeo.com/1')!..title = '영상';
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
Checkbox box() => tester.widget<Checkbox>(
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.addFinishedDownloads, 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<String, String> dict(String code) =>
Map<String, String>.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.addFinishedDownloads = 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 = Directory.systemTemp.createTempSync('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<IconButton>(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<void> until(bool Function() ok) async {
for (var i = 0; i < 300 && !ok(); i++) {
await Future<void>.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<void> 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<void>.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<void>.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<void> 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<void>.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<void>.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<void> 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<void>.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<void> 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<void>.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();
});
}