362 lines
18 KiB
Dart
362 lines
18 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:jj_mkvmaker/app/app_controller.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/platform/windows/desktop_storage_service.dart';
|
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
|
import 'package:jj_mkvmaker/services/model_store.dart';
|
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import 'support/temp_dirs.dart';
|
|
import 'support/batch_doubles.dart';
|
|
|
|
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 = plainController();
|
|
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 = plainController();
|
|
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 = TestRecognizer();
|
|
final c = AppController(PlatformServices(
|
|
mediaTool: tool,
|
|
storage: DesktopStorageService(),
|
|
createRecognizer: () => rec,
|
|
createTranslator: TestTranslator.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<void>.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.mkv.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<void>.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<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.mkv.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<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));
|
|
}
|
|
}
|
|
|
|
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');
|
|
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.mkv.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)));
|
|
|
|
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
|
|
..mkv.maxParallelJobs = 1
|
|
..mkv.existing = '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);
|
|
});
|
|
}
|