Files
sync_replay_project/apps/desktop/test/session_logic_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

138 lines
5.9 KiB
Dart

// Pure session logic: auto sit-out on a fake clock, link names, the
// unready-on-swap rule.
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
import 'package:starsync/src/platform/ports/shell_port.dart' show WindowActivity;
import 'package:starsync/src/session/auto_sit_out.dart';
import 'package:starsync/src/session/session_coordinator.dart';
void main() {
group('AutoSitOut', () {
final t0 = DateTime.utc(2026, 1, 1, 12);
const idle = AutoSitOutTimes.idle;
const minimised = AutoSitOutTimes.minimised;
/// Ticks once a second from [from] for [span]; the steps that were not
/// [AutoStay], at their second.
List<(int, String)> run(AutoSitOut a, DateTime from, Duration span,
{Presence self = Presence.watching, bool playing = false, bool alone = false, bool enabled = true}) {
final out = <(int, String)>[];
for (var s = 0; s <= span.inSeconds; s++) {
final step = a.tick(from.add(Duration(seconds: s)),
self: self, playing: playing, alone: alone, enabled: enabled);
switch (step) {
case AutoAway(:final reason):
out.add((s, 'away ${reason.name}'));
case AutoBack():
out.add((s, 'back'));
case AutoStay():
break;
}
}
return out;
}
test('not playing: idle for the idle time sets you away (idle)', () {
final a = AutoSitOut(t0);
expect(run(a, t0, idle), [(idle.inSeconds, 'away idle')]);
});
test('never when alone, never when switched off', () {
expect(run(AutoSitOut(t0), t0, const Duration(minutes: 30), alone: true), isEmpty);
expect(run(AutoSitOut(t0), t0, const Duration(minutes: 30), enabled: false), isEmpty);
final m = AutoSitOut(t0)..window(WindowActivity.minimised, t0);
expect(run(m, t0, const Duration(minutes: 30), alone: true, playing: true), isEmpty);
});
test('while playing idle input does not count, minimised for a while does', () {
final a = AutoSitOut(t0);
expect(run(a, t0, const Duration(minutes: 30), playing: true), isEmpty, reason: 'watching is activity');
final at = t0.add(const Duration(minutes: 30));
a.window(WindowActivity.minimised, at);
expect(run(a, at, minimised, playing: true), [(minimised.inSeconds, 'away minimised')]);
});
test('a pause after a long watch starts a fresh idle clock', () {
final a = AutoSitOut(t0);
run(a, t0, const Duration(minutes: 30), playing: true);
final paused = t0.add(const Duration(minutes: 30, seconds: 1));
expect(run(a, paused, idle - const Duration(seconds: 2)), isEmpty);
});
test('back on input or focus, only from an automatic away', () {
final a = AutoSitOut(t0);
expect(run(a, t0, idle), [(idle.inSeconds, 'away idle')]);
final at = t0.add(idle);
// The away shows in the next states; nothing until the user is back.
expect(run(a, at, const Duration(minutes: 5), self: Presence.away), isEmpty);
final later = at.add(const Duration(minutes: 5, seconds: 1));
a.input(later);
expect(run(a, later, const Duration(seconds: 1), self: Presence.away), [(0, 'back')]);
// Minimised away: restoring is not enough, the focus brings it back.
final b = AutoSitOut(t0)..window(WindowActivity.minimised, t0);
expect(run(b, t0, minimised, playing: true), [(minimised.inSeconds, 'away minimised')]);
final m = t0.add(minimised);
b.window(WindowActivity.unfocused, m);
expect(run(b, m, const Duration(seconds: 3), self: Presence.away), isEmpty);
b.window(WindowActivity.focused, m);
expect(run(b, m, const Duration(seconds: 1), self: Presence.away), [(0, 'back')]);
});
test('a manual away (own or someone else\'s) is never returned automatically', () {
// Someone else sat you out: activity does not bring you back.
final a = AutoSitOut(t0);
a.input(t0);
expect(run(a, t0, const Duration(minutes: 20), self: Presence.away), isEmpty);
// Auto away, then the user sits out by hand: it stays.
final b = AutoSitOut(t0);
expect(run(b, t0, idle), [(idle.inSeconds, 'away idle')]);
b.manual();
final at = t0.add(idle);
b.input(at);
expect(run(b, at, const Duration(minutes: 1), self: Presence.away), isEmpty);
});
test('an away not yet echoed is not sent again', () {
final a = AutoSitOut(t0);
// The state still says `in` for a few seconds after the away went out.
expect(run(a, t0, idle + const Duration(seconds: 8)), [(idle.inSeconds, 'away idle')]);
expect(a.autoAway, isTrue);
});
test('the reasons on the wire', () {
expect(PresenceReason.idle.name, 'idle');
expect(PresenceReason.minimised.name, 'minimised');
});
});
group('linkDisplayName', () {
test('drops query (api_key) and fragment, decodes last segment', () {
expect(
linkDisplayName(
'https://jf.example.com/Videos/abc/My%20Movie.mkv?static=true&api_key=SECRET#t=5'),
'My Movie.mkv');
});
test('trailing slash and bare host', () {
expect(linkDisplayName('http://host.lan/media/show/'), 'show');
expect(linkDisplayName('http://host.lan/?api_key=SECRET'), 'host.lan');
});
test('never contains the key', () {
final n = linkDisplayName(
'https://jf.example.com/Items/1/Download?api_key=SECRET');
expect(n, 'Download');
expect(n.contains('SECRET'), isFalse);
});
});
test('unreadyForNewMedia: only a real swap while ready, setting on', () {
bool u({bool enabled = true, int last = 2, int id = 3, bool ready = true}) =>
unreadyForNewMedia(enabled: enabled, lastMediaId: last, mediaId: id, ready: ready);
expect(u(), isTrue);
expect(u(enabled: false), isFalse);
expect(u(ready: false), isFalse);
expect(u(id: 2), isFalse, reason: 'same media');
expect(u(last: -1), isFalse, reason: 'first state of a session is no swap');
});
}