import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/gestures.dart' show kSecondaryButton; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/playlist.dart'; import 'package:jj_mkvmaker/main.dart' show lastWorkText, wantsNewPlayerWindow; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/platform/windows/shell_integration.dart'; import 'package:jj_mkvmaker/platform/windows/single_instance.dart'; import 'package:jj_mkvmaker/services/media_player.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/exit_dialog.dart'; import 'package:jj_mkvmaker/ui/player_page.dart'; import 'package:path/path.dart' as p; class _FakePlayer implements MediaPlayer { @override final ValueNotifier state = ValueNotifier(const PlayerState( duration: Duration(minutes: 2), audioTracks: [TrackInfo('1', 'kor'), TrackInfo('2', 'eng')], subtitleTracks: [TrackInfo('3', '한국어')], width: 1920, height: 1080, )); final calls = []; List _files = []; void _set({bool? playing, int? index, String? sub, bool clearSub = false, double? volume}) { final s = state.value; state.value = PlayerState( position: s.position, duration: s.duration, playing: playing ?? s.playing, index: index ?? s.index, volume: volume ?? s.volume, width: s.width, height: s.height, audioTracks: s.audioTracks, subtitleTracks: s.subtitleTracks, subtitleId: clearSub ? null : (sub ?? s.subtitleId), ); } @override Stream get errors => const Stream.empty(); @override List get playlist => _files; @override Future open(List files, {int start = 0}) async { _files = List.of(files); calls.add('open ${files.length} $start'); _set(index: start, playing: true); } @override Future add(List files) async => _files.addAll(files); @override Future jump(int index) async => _set(index: index); @override Future next() async => _set(index: (state.value.index + 1).clamp(0, _files.length - 1)); @override Future previous() async => _set(index: (state.value.index - 1).clamp(0, _files.length - 1)); @override Future playOrPause() async => _set(playing: !state.value.playing); @override Future play() async => _set(playing: true); @override Future stop() async => calls.add('stop'); @override Future seek(Duration position) async => calls.add('seek ${position.inSeconds}'); @override Future setVolume(double volume) async => _set(volume: volume); @override Future setRate(double rate) async => calls.add('rate $rate'); @override Future setAudioTrack(TrackInfo t) async => calls.add('audio ${t.id}'); @override Future setSubtitleTrack(TrackInfo? t) async => t == null ? _set(clearSub: true) : _set(sub: t.id); @override Widget buildView() => const ColoredBox(color: Colors.black); @override Future dispose() async => calls.add('dispose'); } void main() { group('재생 목록 규칙', () { const dir = r'D:\v'; final files = [ for (final n in [ 'file_010.mp4', 'file_002.mp4', 'file_1.mp4', 'Show.S01E02.mkv', 'Show.S01E10.mkv', 'Show.S02E01.mkv', 'other.avi', 'notes.txt', 'Show.S01E01.srt', ]) p.join(dir, n), ]; test('시리즈 키 · 자연 정렬', () { expect(seriesKey('file_001.mp4'), seriesKey('file_0002.mp4')); expect(seriesKey('Show.S01E02.mkv'), seriesKey('Show.S02E10.mkv')); expect(seriesKey('other.avi') == seriesKey('file_1.mp4'), isFalse); final names = ['a10', 'a2', 'a1', 'b1']; names.sort(naturalCompare); expect(names, ['a1', 'a2', 'a10', 'b1']); }); test('하나 · 시리즈 · 폴더', () { final f = p.join(dir, 'file_002.mp4'); final (one, k) = buildPlaylist(f, files, PlaylistMode.single); expect([one, k], [[f], 0]); final (series, i) = buildPlaylist(f, files, PlaylistMode.series); expect(series.map(p.basename), ['file_1.mp4', 'file_002.mp4', 'file_010.mp4']); expect(i, 1); final (show, j) = buildPlaylist(p.join(dir, 'Show.S01E10.mkv'), files, PlaylistMode.series); expect(show.map(p.basename), ['Show.S01E02.mkv', 'Show.S01E10.mkv', 'Show.S02E01.mkv']); expect(j, 1); final (all, _) = buildPlaylist(f, files, PlaylistMode.folder); expect(all, hasLength(7)); // 자막·텍스트 제외 }); test('끌어다 놓기: 동영상만, 폴더 안쪽, jj_ 출력 폴더 제외', () async { final tree = { r'D:\drop': [r'D:\drop\a.mp4', r'D:\drop\b.txt', r'D:\drop\sub', r'D:\drop\jj_mkv'], r'D:\drop\sub': [r'D:\drop\sub\c.mkv'], r'D:\drop\jj_mkv': [r'D:\drop\jj_mkv\a.mkv'], }; final v = await collectVideos([r'D:\drop', r'D:\x.avi', r'D:\y.jpg'], isDirectory: (x) async => tree.containsKey(x), listDir: (x) async => List.of(tree[x]!)); expect(v, [r'D:\drop\a.mp4', r'D:\drop\sub\c.mkv', r'D:\x.avi']); }); test('실행 인수', () { final r = LaunchRequest.parse(['--play', r'D:\a.mp4', r'D:\b.mp4']); expect([r.action, r.files], [LaunchAction.play, [r'D:\a.mp4', r'D:\b.mp4']]); expect(LaunchRequest.parse([r'D:\a.mp4']).action, LaunchAction.add); expect(LaunchRequest.parse(['--subtitle', 'x']).toArgs(), ['--subtitle', 'x']); }); test('탐색기에서 연 동영상: 새 재생 창 여부', () { final open = LaunchRequest.parse([r'D:.mkv']); final play = LaunchRequest.parse(['--play', r'D:.mkv']); final sub = LaunchRequest.parse(['--subtitle', r'D:.mkv']); final s = AppSettings(); expect([s.playback.openFileAction, s.playback.openFileWindow], ['play', 'same']); expect(wantsNewPlayerWindow(open, s), isFalse); // 기본: 켜져 있는 창에서 s.playback.openFileWindow = 'new'; expect(wantsNewPlayerWindow(open, s), isTrue); expect(wantsNewPlayerWindow(play, s), isTrue); expect(wantsNewPlayerWindow(sub, s), isFalse); // 자막 만들기는 원래 창 expect(wantsNewPlayerWindow(LaunchRequest.parse(const []), s), isFalse); s.playback.openFileAction = 'add'; expect(wantsNewPlayerWindow(open, s), isFalse); // 목록에 추가는 원래 창 expect(wantsNewPlayerWindow(play, s), isTrue); final back = AppSettings.fromJson(s.toJson()); expect([back.playback.openFileAction, back.playback.openFileWindow], ['add', 'new']); }); test('Android 화면 방향: 기본 자동, 가로 · 세로 고정 저장, 예전 기본 (가로 고정) 은 한 번 자동으로', () { final s = AppSettings(); expect(s.display.orientation, 'auto'); for (final m in ['portrait', 'auto', 'landscape']) { s.display.orientation = m; expect(AppSettings.fromJson(s.toJson()).display.orientation, m); } expect(AppSettings.fromJson({'screenOrientation': 'sideways', 'orientationV2': true}).display.orientation, 'auto'); // 예전 설정 파일 (기본 가로 고정 시절) → 자동 expect(AppSettings.fromJson({'screenOrientation': 'landscape'}).display.orientation, 'auto'); }); }); group('중복 실행 방지 (228: 데이터 폴더마다 잠금 파일 · 포트 0 · 비밀값)', () { late Directory d1, d2; setUp(() { d1 = Directory.systemTemp.createTempSync('jj_inst1_'); d2 = Directory.systemTemp.createTempSync('jj_inst2_'); }); tearDown(() { d1.deleteSync(recursive: true); d2.deleteSync(recursive: true); }); test('나중 실행은 넘기고, 짧은 시간의 요청은 하나로 · 끝내면 instance.json 을 지운다', () async { final first = SingleInstance(dataDir: d1.path, gather: const Duration(milliseconds: 300)); expect(await first.claim(const LaunchRequest(LaunchAction.play, ['a.mp4'])), ClaimResult.first); final got = []; first.listen(got.add); expect(File(p.join(d1.path, 'instance.json')).existsSync(), isTrue); // 탐색기가 파일마다 프로그램을 실행한 상황 for (final f in ['b.mp4', 'c.mp4']) { expect(await SingleInstance(dataDir: d1.path).claim(LaunchRequest(LaunchAction.play, [f])), ClaimResult.forwarded); } expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.subtitle, ['d.mp4'])), ClaimResult.forwarded); // 새 재생 창으로 따로 뜰 때는 넘기지 않는다 expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, ['e.mp4']), forward: false), ClaimResult.running); await Future.delayed(const Duration(milliseconds: 600)); await first.close(); expect(got, hasLength(2)); final play = got.firstWhere((r) => r.action == LaunchAction.play); expect(play.files, ['a.mp4', 'b.mp4', 'c.mp4']); expect(got.firstWhere((r) => r.action == LaunchAction.subtitle).files, ['d.mp4']); expect(File(p.join(d1.path, 'instance.json')).existsSync(), isFalse); // 끝낸 뒤에는 새 실행이 첫 실행 final again = SingleInstance(dataDir: d1.path); expect(await again.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); await again.close(); }, skip: !Platform.isWindows); test('데이터 폴더가 다르면 따로 뜬다 (시험판 · 다른 데이터끼리 넘기지 않음)', () async { final a = SingleInstance(dataDir: d1.path); final b = SingleInstance(dataDir: d2.path); expect(a.lockPath, isNot(b.lockPath)); expect(await a.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); expect(await b.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); await a.close(); await b.close(); }, skip: !Platform.isWindows); test('비밀값이 틀린 요청은 받지 않는다 (같은 PC 의 다른 프로그램)', () async { final first = SingleInstance(dataDir: d1.path, gather: const Duration(milliseconds: 100)); expect(await first.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); final got = []; first.listen(got.add); await Future.delayed(const Duration(milliseconds: 300)); got.clear(); final port = (jsonDecode(File(p.join(d1.path, 'instance.json')).readAsStringSync()) as Map)['port'] as int; for (final line in ['JJMKVMAKER2 wrong ["--play","x.mp4"]', 'JJMKVMAKER1 ["--play","x.mp4"]']) { final s = await Socket.connect(InternetAddress.loopbackIPv4, port); s.write('$line\n'); await s.flush(); final reply = await utf8.decoder.bind(s).join(); expect(reply, isNot(startsWith('OK'))); s.destroy(); } await Future.delayed(const Duration(milliseconds: 300)); expect(got, isEmpty); await first.close(); }, skip: !Platform.isWindows); test('첫 창이 응답하지 않으면 unresponsive (묻기 - 같은 데이터 위에 창 두 개를 막는다)', () async { final holder = SingleInstance(dataDir: d1.path); expect(await holder.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); // 응답하지 않는 창: 받는 곳을 닫았다 await holder.debugStopListening(); expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.unresponsive); await holder.close(); }, skip: !Platform.isWindows); test('잠금을 쥔 다른 프로세스가 죽으면 풀려서 다음 실행이 첫 실행', () async { // 다른 프로세스가 instance.lock 을 잠근 채 응답하지 않는다 (instance.json 없음) final lock = p.join(d1.path, 'instance.lock'); final holder = await Process.start('powershell', [ '-NoProfile', '-Command', "\$f = [IO.File]::Open('$lock', 'OpenOrCreate', 'ReadWrite', 'ReadWrite'); \$f.Lock(0, 1); " "[Console]::Out.WriteLine('locked'); [Console]::Out.Flush(); Start-Sleep 60", ]); await holder.stdout.transform(utf8.decoder).firstWhere((l) => l.contains('locked')); expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.unresponsive); holder.kill(); await holder.exitCode; final next = SingleInstance(dataDir: d1.path); expect(await next.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); await next.close(); }, skip: !Platform.isWindows, timeout: const Timeout(Duration(minutes: 1))); }); test('탐색기 메뉴 등록 · 해제 (테스트 전용 확장자)', () async { if (!Platform.isWindows) return markTestSkipped('Windows 전용'); final s = ShellIntegration(exePath: r'C:\JJ Test\jj_mkvmaker.exe', extensions: ['jjtestvid']); await s.unregister(); expect(await s.isRegistered(), isFalse); expect(await s.register(), isTrue); expect(await s.isRegistered(), isTrue); final q = await Process.run('reg', [ 'query', r'HKCU\Software\Classes\SystemFileAssociations\.jjtestvid\shell\JJMKVMaker.Play\command', '/ve' ]); expect('${q.stdout}', contains(r'"C:\JJ Test\jj_mkvmaker.exe" --play "%1"')); final q2 = await Process.run('reg', [ 'query', r'HKCU\Software\Classes\SystemFileAssociations\.jjtestvid\shell\JJMKVMaker.Subtitle\command', '/ve' ]); expect('${q2.stdout}', contains('--subtitle "%1"')); await s.unregister(); expect(await s.isRegistered(), isFalse); }); testWidgets('플레이어 화면: 조작 · 단축키 · 오른쪽 메뉴 · 재생 목록', (tester) async { tester.view.physicalSize = const Size(1400, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pl = _FakePlayer(); await tester.pumpWidget(MaterialApp( home: PlayerPage(c: c, player: pl, files: const [r'D:\v\ep1.mp4', r'D:\v\ep2.mp4'], start: 1), )); await tester.pump(); expect(pl.calls.first, 'open 2 1'); expect(find.text('ep2.mp4 (2/2)'), findsOneWidget); // Space: 일시정지 await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pump(); expect(pl.state.value.playing, isFalse); // → 10초 앞으로, P 이전 파일 await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.sendKeyEvent(LogicalKeyboardKey.keyP); await tester.pump(); expect(pl.calls, contains('seek 10')); expect(find.text('ep1.mp4 (1/2)'), findsOneWidget); // S: 자막 켜기 → 첫 트랙 await tester.sendKeyEvent(LogicalKeyboardKey.keyS); await tester.pump(); expect(pl.state.value.subtitleId, '3'); // 재생 목록 (L) → 항목 누르면 이동 await tester.sendKeyEvent(LogicalKeyboardKey.keyL); await tester.pumpAndSettle(); expect(find.text('재생 목록 2개'), findsOneWidget); await tester.tap(find.text('ep2.mp4')); await tester.pump(); expect(pl.state.value.index, 1); // 오른쪽 클릭 메뉴 await tester.tap(find.byType(ColoredBox).first, buttons: kSecondaryButton); await tester.pumpAndSettle(); expect(find.text('전체 화면'), findsOneWidget); expect(find.text('팝업 보기'), findsOneWidget); expect(find.text('자막 보기'), findsOneWidget); expect(find.text('화면 크기'), findsOneWidget); expect(find.text('음성 선택'), findsOneWidget); // 음성 트랙 2개 expect(find.text('자막 크기'), findsOneWidget); await tester.tap(find.text('자막 보기')); await tester.pumpAndSettle(); expect(pl.state.value.subtitleId, isNull); // 터치 화면: 길게 누르면 같은 메뉴 await tester.longPress(find.byType(ColoredBox).first); await tester.pumpAndSettle(); expect(find.text('전체 화면'), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); await tester.pumpWidget(const SizedBox()); expect(pl.calls.last, 'dispose'); }); test('동영상 목록: 저장 → 다시 켜면 그대로, 창 두 개가 서로 맞춤 (추가 · 삭제)', () async { final dir = Directory.systemTemp.createTempSync('jj_list_'); addTearDown(() => dir.deleteSync(recursive: true)); final list = p.join(dir.path, 'videos.json'); Directory(p.join(dir.path, 'jj_mkv')).createSync(); final a = p.join(dir.path, 'a.mp4'), b = p.join(dir.path, 'jj_mkv', 'b.mkv'); for (final f in [a, b]) { File(f).writeAsStringSync('x'); } AppController make() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final w1 = make(); await w1.shareVideoList(list); await w1.addVideos([a]); // 탐색기에서 직접 연 파일은 jj_mkv 안이어도 들어간다 (폴더째 넣을 때는 제외) await w1.addVideos([b]); expect(w1.videos, hasLength(1)); await w1.addVideos([b], allowOutputFolder: true); expect(w1.videos.map((v) => v.fileName), ['a.mp4', 'b.mkv']); // 새 창: 같은 목록으로 시작 final w2 = make(); await w2.shareVideoList(list); expect(w2.videos.map((v) => v.fileName), ['a.mp4', 'b.mkv']); // 새 창에서 삭제 → 원래 창에서도 삭제 w2.removeVideo(w2.videos.first); await w1.syncVideoList(); expect(w1.videos.map((v) => v.fileName), ['b.mkv']); // 원래 창에서 추가 → 새 창에도 await w1.addVideos([a]); await w2.syncVideoList(); expect(w2.videos.map((v) => v.fileName), ['b.mkv', 'a.mp4']); // 없어진 파일은 다시 켤 때 빠진다 File(a).deleteSync(); final w3 = make(); await w3.shareVideoList(list); expect(w3.videos.map((v) => v.fileName), ['b.mkv']); for (final w in [w1, w2, w3]) { w.stopVideoListShare(); } }); testWidgets('종료 진행 창: 종료 중입니다 → 단계 → Have You Good Time', (tester) async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); expect(lastWorkText(c), '없음'); final ran = []; final gate = Completer(); late BuildContext ctx; await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) { ctx = x; return const SizedBox(); }))); var closed = false; unawaited(showExitProgress(ctx, lastWork: 'MKV 만들기', steps: [ ExitStep('다운로드 정리', () async => ran.add('dl')), ExitStep('쓰레드 확인', () async { await gate.future; ran.add('thread'); }), ExitStep('실패해도 계속', () async => throw StateError('x')), ExitStep('창 종료', () async => ran.add('close')), ]).then((_) => closed = true)); await tester.pump(); expect(find.text('종료 중입니다.'), findsOneWidget); expect(find.text('바로 전 작업: MKV 만들기'), findsOneWidget); await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200)); expect(ran, ['dl']); // 쓰레드 확인에서 기다리는 중 expect(find.byType(CircularProgressIndicator), findsOneWidget); gate.complete(); for (var i = 0; i < 5; i++) { await tester.pump(const Duration(milliseconds: 100)); } expect(ran, ['dl', 'thread', 'close']); expect(find.text('Have You Good Time'), findsOneWidget); expect(closed, isFalse); await tester.pump(const Duration(milliseconds: 800)); await tester.pump(); expect(closed, isTrue); }); testWidgets('종료를 누르면: 설정 기본값 · 고르기 창 (백그라운드로 / 모두 종료 / 묻지 않기)', (tester) async { expect(AppSettings().run.closeAction, 'background'); expect(AppSettings.fromJson((AppSettings()..run.closeAction = 'quit').toJson()).run.closeAction, 'quit'); expect(AppSettings.fromJson({'closeAction': 'x'}).run.closeAction, 'background'); late BuildContext ctx; await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) { ctx = x; return const SizedBox(); }))); Future<(String, bool)?> open() => showCloseChoice(ctx, running: '다운로드 2개', hotkey: 'Ctrl+Shift+X'); var r = open(); await tester.pumpAndSettle(); expect(find.text('지금: 다운로드 2개'), findsOneWidget); expect(find.textContaining('Ctrl+Shift+X'), findsOneWidget); await tester.tap(find.text('백그라운드로')); await tester.pumpAndSettle(); expect(await r, ('background', false)); r = open(); await tester.pumpAndSettle(); await tester.tap(find.byType(Checkbox)); await tester.pump(); await tester.tap(find.text('모두 종료')); await tester.pumpAndSettle(); expect(await r, ('quit', true)); r = open(); await tester.pumpAndSettle(); await tester.tap(find.text('취소')); await tester.pumpAndSettle(); expect(await r, isNull); }); }