Files
sync_replay_project/apps/desktop/test/delivery_test.dart
T
foxyzandClaude Opus 5.5 9f2586b7ff
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s
Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a
client id maps to the same member and token for the server's life; a
drop is `gone` for a 10-minute grace keeping ready, presence, queue
items, relay and avatar; `bye` leaves at once; ordered takeover (seq,
replaced/stale); presence joining/in/away/gone with one PresencePolicy;
joiners never pause playback and hold a held gate at most ~15 s;
state.hold; feed ids, seq and replay; the relay lives through its
source's grace and continues on re-dial; queue `by` lasting, auto
items and cancel; MediaRef carried; client ids never logged.

App: persisted client id and seq; Connection with unstable/lost/
replaced states, backoff with jitter, no lost/back pair per flap; a drop
keeps the session (state, player, media, relay source); presence in the
strip and members card; sit out = away intent; feed matched by id;
auto sit-out (idle when not playing, minimised for a while; never when
alone) replaces auto-unready; bye on close and Leave, not on update
restart; relay source re-dials through its grace.

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

144 lines
7.0 KiB
Dart

// restructure: rewrite in step 2 (Status + Notifier): delivery lines become
// part of the one computed status (and the source-gone half of P2).
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/shell/delivery_line.dart';
MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) =>
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, host: host, rate: rate);
StateBroadcast _state(Map<String, Object?>? media, List<MemberView> members, {String transport = 'playing'}) =>
StateBroadcast(
eventId: 1,
transport: transport,
position: 10,
serverTime: DateTime.now().toUtc(),
mediaId: 2,
media: media,
members: members,
);
Map<String, Object?> _relayed(MediaRelay relay) =>
{'name': 'x.mkv', 'size': 0, 'duration': 1000, 'relay': relay.toJson()};
void main() {
final foxyz = _m('m1', 'Foxyz');
final bob = _m('m2', 'Bob', rate: 3200000);
final ann = _m('m3', 'Ann');
test('a member relay: who passes it on, how many read it, how fast', () {
final r = _state(
_relayed(const MediaRelay(source: 'm1', live: true, rate: 4100000, need: 2300000)), [foxyz, bob, ann]);
final d = deliveryOf(r, selfId: 'm2')!;
expect(d.kind, DeliveryKind.memberRelay);
expect(d.state, SourceState.ok);
expect(d.title, 'Foxyz passes it on through the server');
expect(d.detail, '2 through the server · 4.1 MB/s, needs 2.3 MB/s');
expect(d.headroom, closeTo(4.1 / 2.3, 0.01));
expect(deliveryOf(r, selfId: 'm1')!.title, 'You pass it on through the server');
final mb = memberDeliveryOf(bob, r, d);
expect(mb.route, DeliveryRoute.server);
expect(mb.detail, 'via server · 3.2 MB/s · 30 s buffered');
expect(memberDeliveryOf(foxyz, r, d).route, DeliveryRoute.source);
});
test('slow source: amber for everyone, the warning for the source only', () {
final r = _state(
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob]);
expect(deliveryOf(r, selfId: 'm2')!.state, SourceState.slow);
expect(deliveryOf(r, selfId: 'm2')!.title, "Foxyz's upload is slower than the video");
expect(slowSourceWarning(r, 'm1'), contains('Your upload (1.2 MB/s) is slower than this video needs (2.3 MB/s)'));
expect(slowSourceWarning(r, 'm2'), isNull);
// Bitrate unknown: rates without a comparison, no warning.
final unknown = _state(_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000)), [foxyz, bob]);
expect(deliveryOf(unknown, selfId: 'm2')!.state, SourceState.ok);
expect(deliveryOf(unknown, selfId: 'm2')!.detail, '1 through the server · 1.2 MB/s');
expect(slowSourceWarning(unknown, 'm1'), isNull);
});
test('the source left: named from before, no rates', () {
final names = MemberNames()..observe(_state(null, [foxyz, bob]));
final r = _state(_relayed(const MediaRelay(source: 'm1', rate: 4100000, need: 2300000)), [bob, ann]);
final d = deliveryOf(r, selfId: 'm2', names: names)!;
expect(d.state, SourceState.left);
expect(d.title, 'Foxyz left: playing what the server has');
expect(d.detail, '2 through the server');
});
test('channel down: waiting', () {
final r = _state(_relayed(const MediaRelay(source: 'm1')), [foxyz, bob]);
expect(deliveryOf(r, selfId: 'm2')!.title, 'Waiting for Foxyz to reconnect');
});
test('links are direct, the host file is the host\'s, solo shows nothing', () {
final link = _state({'name': 'v', 'url': 'https://example.com/v.mp4'}, [foxyz, bob]);
final d = deliveryOf(link, selfId: 'm2')!;
expect(d.title, 'Everyone plays the link directly');
expect(d.detail, isEmpty);
expect(memberDeliveryOf(bob, link, d).short, 'direct');
final file = _state({'name': 'f.mkv'}, [_m('m1', 'Foxyz', host: true), bob]);
expect(deliveryOf(file, selfId: 'm2')!.title, 'Foxyz hosts the file');
expect(deliveryOf(_state({'name': 'f.mkv'}, [bob]), selfId: 'm2'), isNull);
expect(deliveryOf(_state(null, [foxyz, bob]), selfId: 'm2'), isNull);
});
test('Jellyfin: own accounts direct, the rest via server', () {
const ref = {'serverId': 'srv', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'};
MemberView jf(String id, String name, bool own) =>
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, jf: {'srv': own});
final all = [jf('m1', 'Foxyz', true), jf('m2', 'Bob', true)];
final media = {'name': 'Pilot', 'jellyfin': ref, 'relay': const MediaRelay(source: 'm1', live: true).toJson()};
final d = deliveryOf(_state(media, all), selfId: 'm2')!;
expect(d.title, 'Everyone streams it from Jellyfin', reason: 'the relay stands by');
final mixed = _state(media, [...all, jf('m3', 'Guest', false)]);
final m = deliveryOf(mixed, selfId: 'm2')!;
expect(m.kind, DeliveryKind.memberRelay);
expect(m.detail, '1 with their own account · 1 through the server');
expect(memberDeliveryOf(jf('m2', 'Bob', true), mixed, m).short, 'own Jellyfin account');
});
test('buffer health: enough, low, empty while playing', () {
final r = _state({'name': 'v', 'url': 'https://e/v', 'duration': 1000}, [foxyz, bob]);
final d = deliveryOf(r, selfId: 'm2')!;
expect(memberDeliveryOf(_m('a', 'A', buffered: 30), r, d).health, BufferHealth.good);
expect(memberDeliveryOf(_m('a', 'A', buffered: 2), r, d).health, BufferHealth.low);
expect(memberDeliveryOf(_m('a', 'A', buffered: 0), r, d).health, BufferHealth.starving);
final held = _state(r.media, [foxyz, bob], transport: 'held');
expect(memberDeliveryOf(_m('a', 'A', buffered: 0), held, d).health, BufferHealth.low);
});
test('rates read as MB/s or kB/s', () {
expect(formatRate(4100000), '4.1 MB/s');
expect(formatRate(12000000), '12 MB/s');
expect(formatRate(850000), '850 kB/s');
});
testWidgets('the source line and a member\'s status fit the members card', (tester) async {
final r = _state(
_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob, ann]);
final d = deliveryOf(r, selfId: 'm2')!;
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: Center(
child: SizedBox(
width: 340,
child: Column(mainAxisSize: MainAxisSize.min, children: [
SourceLine(delivery: d),
DeliveryStatus(status: 'ready', delivery: memberDeliveryOf(bob, r, d)),
]),
),
),
),
));
await tester.pumpAndSettle();
expect(find.text("Foxyz's upload is slower than the video"), findsOneWidget);
expect(find.byKey(const ValueKey('headroom-bar')), findsOneWidget);
expect(find.text('ready · via server · 3.2 MB/s'), findsOneWidget);
expect(find.byKey(const ValueKey('health-dot')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}