Files
sync_replay_project/apps/desktop/test/seek_drag_e2e_test.dart
T
foxyzandClaude Opus 5.5 51c1c5ab78
ci / core (push) Successful in 31s
ci / server (push) Successful in 1m3s
ci / updater-windows (push) Successful in 6m6s
Restructure step R: the app is Starsync (dev.foxyz.starsync)
- Linux app id dev.foxyz.starsync, binary starsync; Windows starsync.exe,
  company foxyz; server starsync-server; Dart package starsync; archives
  starsync-<ver>-*.
- 1.2.x compatibility: watch_together and watch-together-server
  launchers (Linux) / copies (Windows) in the archive; the updater
  accepts both names and relaunches the new binary; update.sh/.bat use
  whichever server exists.
- First start copies (never moves) the old support folder and this
  app's Hive boxes out of ~/Documents into <support>/hive; a marker
  makes it run once; AppDirs is a port; settings schema version with
  ordered migrations.
- Launcher entry dev.foxyz.starsync.desktop; the old one removed.
- Repository name and URLs unchanged.

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

165 lines
6.5 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:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sync/state_diff.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
/// 120 s, keyframe every second (seeks land fast). Needs an audio track:
/// without a video output attached (headless) mpv refuses a video-only file.
Future<String> _media() async {
final path = '$_tmp/seek_drag_120s.mp4';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=320x240:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '120', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', 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(
'seek bar drag: one seek to the release point, no yank back',
timeout: const Timeout(Duration(minutes: 3)),
() async {
MediaKit.ensureInitialized();
final media = await _media();
final server = SyncServer(AppConfig(
port: 0, secret: 'drag', name: 'drag', mediaPath: media));
await server.start();
final c = SessionCoordinator();
try {
await c.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
secret: 'drag',
name: 'solo'));
await c.playback.player.setVolume(0);
await _poll(() => c.serverState.peek() != null, 'server state');
c.toggleReady();
await _poll(
() => c.playback.player.state.playing && c.playback.positionSecs > 1,
'solo playback');
// What media_kit's MaterialDesktopSeekBar does: player.seek on every
// pointer move (here 2.5 s of dragging from 20 s to 80 s), then once
// more on release.
final positions = <double>[];
final sampler = Timer.periodic(const Duration(milliseconds: 100),
(_) => positions.add(c.playback.positionSecs));
const moves = 130; // ~4 s of dragging
for (var i = 0; i <= moves; i++) {
final at = 20 + 60 * i / moves;
unawaited(c.playback.player.seek(Duration(milliseconds: (at * 1000).round())));
await Future.delayed(const Duration(milliseconds: 30));
}
await c.playback.player.seek(const Duration(seconds: 80));
final releasedAt = positions.length;
await Future.delayed(const Duration(seconds: 5));
sampler.cancel();
final seeks = c.feedLog.peek().where((f) => f.action == 'seek').length;
final r = c.serverState.peek()!;
final serverPos = serverPositionAt(r, DateTime.now().toUtc());
final during = positions.sublist(0, releasedAt);
final after = positions.sublist(releasedAt);
// ignore: avoid_print
print('seek feeds=$seeks server=${serverPos.toStringAsFixed(2)} '
'local=${c.playback.positionSecs.toStringAsFixed(2)} '
'during=${during.map((p) => p.toStringAsFixed(1)).join(' ')} '
'after release=${after.map((p) => p.toStringAsFixed(1)).join(' ')}');
for (var i = 1; i < during.length; i++) {
expect(during[i], greaterThan(during[i - 1] - 2),
reason: 'drag never yanked backwards (sample $i)');
}
expect(seeks, 1, reason: 'one seek per drag, sent on release');
expect(serverPos, inInclusiveRange(80, 87),
reason: 'server follows the release point');
expect((c.playback.positionSecs - serverPos).abs(), lessThan(1.0));
for (final p in after) {
expect(p, greaterThanOrEqualTo(79), reason: 'no yank back after release');
}
} finally {
await c.dispose();
await server.stop();
}
},
);
test(
'drag on one client: the other follows with exactly one seek',
timeout: const Timeout(Duration(minutes: 3)),
() async {
MediaKit.ensureInitialized();
final media = await _media();
final server = SyncServer(AppConfig(
port: 0, secret: 'drag2', name: 'drag2', 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: 'drag2',
name: name));
await c.playback.player.setVolume(0);
}
await _poll(() => a.serverState.peek()?.members.length == 2, 'both in');
a.toggleReady();
b.toggleReady();
await _poll(
() => a.playback.player.state.playing && b.playback.player.state.playing,
'both playing');
final bSamples = <double>[];
final sampler = Timer.periodic(const Duration(milliseconds: 100),
(_) => bSamples.add(b.playback.positionSecs));
for (var i = 0; i <= 100; i++) {
unawaited(a.playback.player
.seek(Duration(milliseconds: (30000 + 400 * i))));
await Future.delayed(const Duration(milliseconds: 30));
}
await a.playback.player.seek(const Duration(seconds: 70));
await Future.delayed(const Duration(seconds: 4));
sampler.cancel();
final seeks = b.feedLog.peek().where((f) => f.action == 'seek').length;
// ignore: avoid_print
print('b saw $seeks seek(s); a=${a.playback.positionSecs.toStringAsFixed(2)} '
'b=${b.playback.positionSecs.toStringAsFixed(2)}');
expect(seeks, 1);
expect(b.playback.positionSecs, greaterThan(70));
expect((a.playback.positionSecs - b.playback.positionSecs).abs(),
lessThan(1.0));
expect(bSamples.where((p) => p > 31 && p < 69), isEmpty,
reason: 'b never jumps to mid-drag positions');
} finally {
await a.dispose();
await b.dispose();
await server.stop();
}
},
);
}