import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/controller/app_log.dart'; import 'package:jj_mkvmaker/app/controller/encode_choice.dart'; import 'package:jj_mkvmaker/app/controller/job_queue.dart'; import 'package:jj_mkvmaker/app/controller/subtitle_files.dart'; import 'package:jj_mkvmaker/app/controller/video_files.dart'; import 'package:jj_mkvmaker/app/controller/video_list.dart'; import 'package:jj_mkvmaker/app/controller/video_list_share.dart'; import 'package:jj_mkvmaker/core/encode_options.dart'; import 'package:jj_mkvmaker/core/languages.dart'; import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/srt.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:path/path.dart' as p; /// app_controller 를 나눈 모듈의 약속 (실제 파일 · 실제 Future 로) void main() { late Directory tmp; setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ctl_')); tearDown(() => tmp.deleteSync(recursive: true)); test('약속 JobQueue: 하나씩 차례로, 대기 중인 것은 빼면 바로 끝나고 지금 것은 그대로', () async { final logs = []; final q = JobQueue(onChanged: () {}, log: logs.add); final order = []; final gate = Completer(); final a = q.enqueue('A', () async { order.add('A 시작'); await gate.future; order.add('A 끝'); }); final b = q.enqueue('B', () async => order.add('B')); final c = q.enqueue('C', () async => order.add('C')); expect([q.busy, q.current, q.pending], [true, 'A', ['B', 'C']]); expect(logs.where((l) => l.contains('B')), isNotEmpty, reason: '대기열에 넣은 것을 기록'); expect(q.cancelPending(), 2); await Future.wait([b, c]); // 빠진 것은 바로 끝난다 gate.complete(); await a; expect(order, ['A 시작', 'A 끝']); expect([q.busy, q.current], [false, null]); await q.enqueue('D', () async => order.add('D')); expect(order.last, 'D'); }); test('약속 AppLog: 최근 500줄 · 파일에도 · 2MB 넘으면 .1 로 옮기고 새로', () { final log = AppLog()..file = p.join(tmp.path, 'app.log'); for (var i = 0; i < 510; i++) { log.add('줄 $i', at: DateTime(2026, 10, 23, 9, 5, 7)); } expect(log.lines.length, 500); expect(log.lines.first, '[09:05:07] 줄 10'); expect(File(log.file!).readAsLinesSync().last, '2026-10-23 09:05:07 줄 509'); File(log.file!).writeAsStringSync('x' * (AppLog.maxFileBytes + 1)); log.add('새 파일'); expect(File('${log.file}.1').lengthSync(), AppLog.maxFileBytes + 1); expect(File(log.file!).readAsStringSync(), contains('새 파일')); (AppLog()..file = p.join(tmp.path, 'no', 'dir', 'x.log')).add('파일을 못 써도 기록은 남는다'); }); test('약속 EncodeChoice: 크기 · 보정은 원본 유지 → H.264 로, 모두 되돌리면 코덱도 되돌림 · 직접 고른 코덱은 그대로', () { final e = EncodeChoice(); e.setResolution(ResolutionChoice.values.firstWhere((r) => r != ResolutionChoice.original)); expect([e.value.codec, e.notice != null], [VideoCodecChoice.h264, true]); e.notice = null; e.setResolution(ResolutionChoice.original); expect([e.value.codec, e.notice != null], [VideoCodecChoice.copy, true], reason: '저절로 바꾼 코덱은 되돌린다'); e.setCodec(VideoCodecChoice.h264); e.setResolution(ResolutionChoice.original); expect(e.value.codec, VideoCodecChoice.h264, reason: '직접 고른 코덱은 되돌리지 않는다'); e.setQuality(QualityChoice.high); expect(e.value.quality, QualityChoice.high); }); test('약속 VideoListShare: 한 창이 적은 목록을 다른 창이 따라 하고, 같으면 다시 적지 않는다', () async { final file = p.join(tmp.path, 'list.json'); final one = ['a.mp4', 'b.mp4']; final two = []; final a = VideoListShare(paths: () => one, apply: (w) async {}); final b = VideoListShare(paths: () => two, apply: (w) async => two..clear()..addAll(w)); await a.start(file); a.stop(); a.save(); await b.start(file); b.stop(); expect(two, ['a.mp4', 'b.mp4']); final before = File(file).lastModifiedSync(); b.save(); // 같은 목록 - 쓰지 않음 expect(File(file).lastModifiedSync(), before); expect(tmp.listSync().where((e) => e.path.endsWith('.tmp')), isEmpty, reason: '임시 파일은 이름을 바꿔 남지 않는다'); }); test('약속 VideoFiles: 같은 이름이면 "이름 (2)", 결과물 (이름.mkv · 이름_ko.srt) 만 새 jj_mkv 로', () async { final src = Directory(p.join(tmp.path, 'src'))..createSync(); final dst = Directory(p.join(tmp.path, 'dst'))..createSync(); File(p.join(src.path, 'a.mp4')).writeAsStringSync('v'); File(p.join(dst.path, 'a.mp4')).writeAsStringSync('old'); final to = await VideoFiles.moveFileInto(p.join(src.path, 'a.mp4'), dst.path); expect(p.basename(to), 'a (2).mp4'); expect(File(p.join(dst.path, 'a.mp4')).readAsStringSync(), 'old'); final out = Directory(p.join(src.path, 'jj_mkv'))..createSync(); for (final n in ['a.mkv', 'a_ko.srt', 'a_AI.srt', 'b.mkv', 'a.txt']) { File(p.join(out.path, n)).writeAsStringSync(n); } final n = await VideoFiles.moveResults(p.join(src.path, 'a.mp4'), p.join(dst.path, 'a.mp4'), log: (_) {}); expect(n, 3); expect(Directory(p.join(dst.path, 'jj_mkv')).listSync().map((e) => p.basename(e.path)).toSet(), {'a.mkv', 'a_ko.srt', 'a_AI.srt'}); expect(Directory(out.path).listSync().map((e) => p.basename(e.path)).toSet(), {'b.mkv', 'a.txt'}); }); test('약속 VideoList: 넣기 (같은 것 · jj_mkv 안은 건너뜀) · 정렬 · 체크 · 작업 중인 것은 빼지 않음 · 옮기기', () async { final saved = []; final loaded = []; final list = VideoList(onChanged: () {}, onSave: () => saved.add(1), load: (v) async => loaded.add(v.fileName), log: (_) {}); final dir = Directory(p.join(tmp.path, 'v'))..createSync(); String f(String n) => (File(p.join(dir.path, n))..createSync(recursive: true)).path; await list.add([f('b.mp4'), f('a10.mp4'), f('a2.mp4'), f('b.mp4'), f(p.join('jj_mkv', 'x.mkv'))]); expect(list.items.map((v) => v.fileName), ['b.mp4', 'a10.mp4', 'a2.mp4']); expect([list.selected?.fileName, loaded.length, saved.isNotEmpty], ['b.mp4', 3, true]); list.sort('name'); expect(list.items.map((v) => v.fileName), ['a2.mp4', 'a10.mp4', 'b.mp4'], reason: '숫자는 크기대로'); list.toggleChecked(list.items[2]); expect(list.batchTargets.map((v) => v.fileName), ['b.mp4']); list.items[2].status = JobStatus.running; list.removeChecked(); expect(list.items.length, 3, reason: '작업 중인 것은 빼지 않는다'); list.items[2].status = JobStatus.ready; final dest = p.join(tmp.path, 'moved'); final (moved, errors) = await list.move([list.items.first], dest); expect([moved, errors], [1, isEmpty]); expect(File(p.join(dest, 'a2.mp4')).existsSync(), isTrue); expect(list.items.map((v) => v.fileName), ['a10.mp4', 'b.mp4']); expect((await list.move(list.items, '')).$1, 0); list.clear(); expect([list.items, list.selected], [isEmpty, null]); }); test('약속 SubtitleFiles: 고른 자막 더하기 (있는 것 건너뜀 · 이름으로 언어) · 다른 이름 저장 · 편집할 수 있는지', () async { final video = p.join(tmp.path, 'movie.mkv'); File(video).writeAsStringSync('v'); final ko = File(p.join(tmp.path, 'movie.ko.srt'))..writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); final storage = _ChosenStorage([ko.path, ko.path]); final files = SubtitleFiles(storage, ProcessMediaTool('x', 'y')); final v = VideoItem(video); expect(await files.pickFor(v), 1, reason: '같은 경로는 한 번만'); expect([v.subtitles.single.path, v.subtitles.single.language.code], [ko.path, 'ko']); expect(await files.pickFor(v), 0, reason: '이미 있는 경로는 건너뜀'); // 다른 이름 저장: 실제 SRT 바이트 · 표현할 수 없는 글자 수 storage.saveTo = p.join(tmp.path, 'out.srt'); final (path, lost) = await files.saveAs(v, [Cue(const Duration(seconds: 1), const Duration(seconds: 2), '안녕')], 'UTF-8', 'out.srt'); expect([path, lost], [storage.saveTo, 0]); expect(File(path!).readAsStringSync(), contains('00:00:01,000 --> 00:00:02,000')); storage.saveTo = null; expect((await files.saveAs(v, const [], 'UTF-8', 'x.srt')).$1, isNull, reason: '취소'); // 편집: 외부 자막은 늘 · 내장 글 자막은 FFmpeg 가 있을 때만 · 이미지 자막은 아님 final emb = SubtitleEntry.embedded(streamIndex: 2, codec: 'subrip', language: undetermined); final pgs = SubtitleEntry.embedded(streamIndex: 3, codec: 'hdmv_pgs_subtitle', language: undetermined); expect([ SubtitleFiles.canEdit(v.subtitles.single, hasFfmpeg: false), SubtitleFiles.canEdit(emb, hasFfmpeg: true), SubtitleFiles.canEdit(emb, hasFfmpeg: false), SubtitleFiles.canEdit(pgs, hasFfmpeg: true), ], [true, true, false, false]); }); } /// 고르기 · 저장 창 대신 정한 것을 돌려주는 저장소 (사용자가 고른 것) - 나머지는 실제 데스크톱 저장소 class _ChosenStorage extends DesktopStorageService { _ChosenStorage(this.picked); final List picked; String? saveTo; @override Future> pickSubtitles({String? initialDirectory}) async => picked; @override Future saveAs({required String fileName, required Uint8List bytes, String? initialDirectory}) async { if (saveTo == null) return null; File(saveTo!).writeAsBytesSync(bytes); return saveTo; } }