import 'dart:async'; 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/core/playlist.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/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/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('중복 실행 방지: 나중 실행은 넘기고, 짧은 시간의 요청은 하나로', () async { final first = SingleInstance(port: 47999, gather: const Duration(milliseconds: 300)); expect(await first.claim(const LaunchRequest(LaunchAction.play, ['a.mp4'])), isTrue); final got = []; first.listen(got.add); // 탐색기가 파일마다 프로그램을 실행한 상황 for (final f in ['b.mp4', 'c.mp4']) { expect(await SingleInstance(port: 47999).claim(LaunchRequest(LaunchAction.play, [f])), isFalse); } expect(await SingleInstance(port: 47999).claim(const LaunchRequest(LaunchAction.subtitle, ['d.mp4'])), isFalse); 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']); }); 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개 await tester.tap(find.text('자막 보기')); await tester.pumpAndSettle(); expect(pl.state.value.subtitleId, isNull); await tester.pumpWidget(const SizedBox()); expect(pl.calls.last, 'dispose'); }); }