JJ_MKVMaker/test/player_test.dart

234 lines
9.6 KiB
Dart

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<PlayerState> state = ValueNotifier(const PlayerState(
duration: Duration(minutes: 2),
audioTracks: [TrackInfo('1', 'kor'), TrackInfo('2', 'eng')],
subtitleTracks: [TrackInfo('3', '한국어')],
width: 1920,
height: 1080,
));
final calls = <String>[];
List<String> _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<String> get errors => const Stream.empty();
@override
List<String> get playlist => _files;
@override
Future<void> open(List<String> files, {int start = 0}) async {
_files = List.of(files);
calls.add('open ${files.length} $start');
_set(index: start, playing: true);
}
@override
Future<void> add(List<String> files) async => _files.addAll(files);
@override
Future<void> jump(int index) async => _set(index: index);
@override
Future<void> next() async => _set(index: (state.value.index + 1).clamp(0, _files.length - 1));
@override
Future<void> previous() async => _set(index: (state.value.index - 1).clamp(0, _files.length - 1));
@override
Future<void> playOrPause() async => _set(playing: !state.value.playing);
@override
Future<void> play() async => _set(playing: true);
@override
Future<void> stop() async => calls.add('stop');
@override
Future<void> seek(Duration position) async => calls.add('seek ${position.inSeconds}');
@override
Future<void> setVolume(double volume) async => _set(volume: volume);
@override
Future<void> setRate(double rate) async => calls.add('rate $rate');
@override
Future<void> setAudioTrack(TrackInfo t) async => calls.add('audio ${t.id}');
@override
Future<void> setSubtitleTrack(TrackInfo? t) async =>
t == null ? _set(clearSub: true) : _set(sub: t.id);
@override
Widget buildView() => const ColoredBox(color: Colors.black);
@override
Future<void> 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 = <LaunchRequest>[];
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<void>.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');
});
}