Files
sync_replay_project/apps/desktop/test/chapters_mpv_e2e_test.dart
T
foxyzandClaude Opus 5.5 eac45746ce
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m6s
ci / updater-windows (push) Successful in 6m3s
Restructure step 0: Connection extracted from the coordinator
Connection owns the socket lifecycle, connect/dial with epochs,
reconnect, the clock offset, diagnostics upload and a sealed ConnState
signal; the hello is built by an injectable builder, the resume token
stays private, and the reconnect delay and silence limit are parameters.
The coordinator maps ConnState to the same health texts and keeps the
reconnect restore and drop handling (step 1 replaces them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:52:49 +03:00

151 lines
7.0 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sync/state_diff.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
/// 23 min mkv with chapters OP 0:30-2:00, Part A, ED 20:00-21:30, Part B,
/// two audio tracks (jpn, eng "Dub") and an English srt subtitle.
Future<String> _media() async {
final path = '$_tmp/chapters_23m.mkv';
if (await File(path).exists()) return path;
final meta = File('$_tmp/chapters_meta.txt');
String chapter(int start, int end, String title) =>
'[CHAPTER]\nTIMEBASE=1/1000\nSTART=${start * 1000}\nEND=${end * 1000}\ntitle=$title\n';
await meta.writeAsString(';FFMETADATA1\n'
'${chapter(30, 120, 'OP')}${chapter(120, 1200, 'Part A')}'
'${chapter(1200, 1290, 'ED')}${chapter(1290, 1380, 'Part B')}');
final srt = File('$_tmp/chapters_subs.srt');
await srt.writeAsString('1\n00:00:01,000 --> 00:00:05,000\nHello\n');
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=160x120:rate=10',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000',
'-i', srt.path, '-i', meta.path,
'-map', '0:v', '-map', '1:a', '-map', '2:a', '-map', '3:s',
'-map_metadata', '4', '-map_chapters', '4',
'-metadata:s:a:0', 'language=jpn',
'-metadata:s:a:1', 'language=eng', '-metadata:s:a:1', 'title=Dub',
'-metadata:s:s:0', 'language=eng',
'-t', '1380', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '10',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '32k', '-c:s', 'srt', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
Future<void> _poll(
FutureOr<bool> Function() cond,
String what, {
Duration timeout = const Duration(seconds: 30),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 100));
}
fail('timed out waiting for $what');
}
void main() {
test(
'chapters and tracks from mpv; Skip OP and PgDn go through the server',
timeout: const Timeout(Duration(minutes: 5)),
() async {
MediaKit.ensureInitialized();
final media = await _media();
final server = SyncServer(AppConfig(port: 0, secret: 'ch', name: 'ch', mediaPath: media));
await server.start();
final a = SessionCoordinator();
final b = SessionCoordinator();
try {
for (final (c, name) in [(a, 'a'), (b, 'b')]) {
await c.connect(ConnectParams(
host: '127.0.0.1', port: server.boundPort, secret: 'ch', name: name));
await c.playback.player.setVolume(0);
}
await _poll(() => a.serverState.peek()?.members.length == 2, 'both in');
// chapters signal: titles, starts, ends (last = duration).
await _poll(() => a.playback.chapters.peek().length == 4 && a.playback.duration.peek() > Duration.zero,
'chapters');
final ch = a.playback.chapters.peek();
// ignore: avoid_print
print('chapters: $ch');
expect(ch.map((c) => c.title), ['OP', 'Part A', 'ED', 'Part B']);
expect(ch.map((c) => c.start.inSeconds), [30, 120, 1200, 1290]);
expect(ch[2].end, ch[3].start);
expect(ch[3].end.inSeconds, closeTo(1380, 1));
expect(a.playback.opening.peek(),
SkipRange(const Duration(seconds: 30), ch[1].start, SkipSource.chapter, title: 'OP'));
expect(a.playback.ending.peek()!.start, const Duration(minutes: 20));
expect(a.playback.segments.peek(), isEmpty, reason: 'not a Jellyfin item');
// buffered signal: mpv's demuxer cache (the timeline's hatching)
// runs ahead of the position.
await _poll(() => a.playback.buffered.peek() > a.playback.position.peek() + const Duration(seconds: 5),
'cache ahead');
expect(a.playback.buffered.peek(), lessThanOrEqualTo(a.playback.duration.peek()));
// tracks signal: two audio, one subtitle; mpv's choice marked.
await _poll(() => a.playback.tracks.peek().length == 3, 'tracks');
final tracks = a.playback.tracks.peek();
// ignore: avoid_print
print('tracks: $tracks');
final audio = tracks.where((t) => t.type == JellyfinStreamType.audio).toList();
expect(audio.map((t) => t.label), ['Japanese · AAC 1.0', 'English · AAC 1.0 · Dub']);
expect(audio.first.selected, isTrue);
await a.selectTrack(aid: 2);
expect(a.playback.tracks.peek().where((t) => t.selected && t.type == JellyfinStreamType.audio).single.id, 2);
a.toggleReady();
b.toggleReady();
await _poll(() => a.playback.player.state.playing && b.playback.player.state.playing, 'both playing');
// Into the OP: the pill offers Skip OP to its end.
a.seekTo(40);
await _poll(() {
final r = a.serverState.peek();
return r != null && r.seekBy == a.connection.memberId && (r.position - 40).abs() < 2;
}, 'server at 0:40');
await _poll(() => a.skipOffer.peek()?.kind == SkipKind.opening, 'Skip OP offered');
expect(a.skipOffer.peek()!.target, const Duration(minutes: 2));
int seeks(SessionCoordinator c) => c.feedLog.peek().where((f) => f.action == 'seek').length;
await _poll(() => seeks(b) >= 1 && seeks(b) == seeks(a), 'b saw the seek to 0:40');
final seeksBefore = seeks(b);
a.skip(a.skipOffer.peek()!);
await _poll(() {
final r = a.serverState.peek();
return r != null &&
r.seekBy == a.connection.memberId &&
serverPositionAt(r, a.connection.serverNow()) >= 120 &&
serverPositionAt(r, a.connection.serverNow()) < 125;
}, 'server at the OP end');
await _poll(() => b.playback.positionSecs >= 119.5 && b.playback.positionSecs < 126, 'b follows');
await _poll(() => seeks(b) == seeksBefore + 1, 'b saw the Skip OP seek');
await Future.delayed(const Duration(seconds: 1));
expect(seeks(b), seeksBefore + 1, reason: 'Skip OP is one seek');
await _poll(() => a.skipOffer.peek() == null, 'pill gone after the OP');
// PgDn: next chapter (ED at 20:00), for everyone.
expect(a.stepChapter(1), isTrue);
await _poll(() => b.playback.positionSecs >= 1199.5 && b.playback.positionSecs < 1206, 'b at the ED');
await _poll(() => a.skipOffer.peek()?.kind == SkipKind.ending, 'Skip ED offered');
expect(a.skipOffer.peek()!.target, const Duration(minutes: 21, seconds: 30));
} finally {
await a.dispose();
await b.dispose();
await server.stop();
}
},
);
}