JJ_MKVMaker/test/player_test.dart

478 lines
21 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

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<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('탐색기에서 연 동영상: 새 재생 창 여부', () {
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.openFileAction, s.openFileWindow], ['play', 'same']);
expect(wantsNewPlayerWindow(open, s), isFalse); // 기본: 켜져 있는 창에서
s.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.openFileAction = 'add';
expect(wantsNewPlayerWindow(open, s), isFalse); // 목록에 추가는 원래 창
expect(wantsNewPlayerWindow(play, s), isTrue);
final back = AppSettings.fromJson(s.toJson());
expect([back.openFileAction, back.openFileWindow], ['add', 'new']);
});
test('Android 화면 방향: 기본 자동, 가로 · 세로 고정 저장, 예전 기본 (가로 고정) 은 한 번 자동으로', () {
final s = AppSettings();
expect(s.screenOrientation, 'auto');
for (final m in ['portrait', 'auto', 'landscape']) {
s.screenOrientation = m;
expect(AppSettings.fromJson(s.toJson()).screenOrientation, m);
}
expect(AppSettings.fromJson({'screenOrientation': 'sideways', 'orientationV2': true}).screenOrientation, 'auto');
// 예전 설정 파일 (기본 가로 고정 시절) → 자동
expect(AppSettings.fromJson({'screenOrientation': 'landscape'}).screenOrientation, '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 = <LaunchRequest>[];
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<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']);
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 = <LaunchRequest>[];
first.listen(got.add);
await Future<void>.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<void>.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 = <String>[];
final gate = Completer<void>();
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().closeAction, 'background');
expect(AppSettings.fromJson((AppSettings()..closeAction = 'quit').toJson()).closeAction, 'quit');
expect(AppSettings.fromJson({'closeAction': 'x'}).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);
});
}