Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications

- Status: computed from connection, presence, hold, relay, player facts;
  no setters (health writes and HealthStatus gone); pill, strip and
  source line read it; transitions go to the Notifier.
- Notifier: noticeFor(event, context) with self presence, alone, window
  activity, playing and prefs; toasts for joins/leaves/returns/swaps/
  Up next adds (with picture)/problems/chat, the rest logged; while
  sitting out only chat, nudges, starting, Up next (+ joins if chosen);
  alone no social notices; replayed lines logged; owns its toast timer.
- System notifications (flutter_local_notifications: D-Bus on Linux,
  WinRT on Windows) when the window isn't focused; a click focuses it.
- Toasts sit above the control bar and move with it.
- Versions: each member's in the members card and avatar tooltip,
  the server's in the card header; the strip no longer shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 22:11:08 +03:00
1 parent 130c934b9b
commit 629be0d9ed
41 files changed
+2114 -741

No files matched your search

+2 -4
View File
@@ -1,6 +1,3 @@
// restructure: rewrite in step 2 (Status + Notifier): "feed and chat toasts
// name their actor exactly".
//
// The session through drops, takeovers and goodbyes: real coordinators
// (real mpv), a real server, and FlakyProxy between a member and it.
import 'dart:async';
@@ -242,7 +239,7 @@ void main() {
final second = await member(server.boundPort, 'me', identity: install);
expect(second.connection.memberId, id, reason: 'the newer connection took the member over');
await _poll(() => first.connection.state.peek() is ConnReplaced, 'the first one replaced');
expect(first.health.peek().message, replacedText);
expect(first.status.peek().line?.text, replacedText);
// It does not fight back.
await Future.delayed(const Duration(seconds: 4));
expect(first.connection.state.peek(), isA<ConnReplaced>());
@@ -412,6 +409,7 @@ void main() {
final toasts = coord.toasts.peek();
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
final chat = toasts.firstWhere((t) => t.text == 'Bob Jr: hi there');
expect(chat.chat, isTrue);
expect(chat.actor, 'Bob Jr');
expect(chat.actorId, friend.memberId, reason: 'matched by member id');
// Others' chat makes a sound, your own does not.
+18 -3
View File
@@ -1,5 +1,6 @@
// restructure: rewrite in step 2 (Status + Notifier): delivery lines become
// part of the one computed status (and the source-gone half of P2).
// Who fetches the current media and how it spreads (the members card's
// source line, part of the one status), and each member's route and
// buffer.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
@@ -63,10 +64,24 @@ void main() {
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.title, 'Foxyz left — playing what the server has');
expect(d.detail, '2 through the server');
});
test("the source's connection dropped: what the server has, for how long, no rates", () {
final since = DateTime.utc(2026, 1, 1, 12);
final gone = MemberView(id: 'm1', name: 'Foxyz', ready: true, inSet: false, buffered: 30, presence: Presence.gone);
final r = _state(
_relayed(MediaRelay(source: 'm1', live: true, rate: 4100000, need: 2300000, sourceGone: true, sourceGoneSince: since)),
[gone, bob, ann]);
final d = deliveryOf(r, selfId: 'm2', now: since.add(const Duration(seconds: 30)))!;
expect(d.state, SourceState.gone);
expect(d.title, "Foxyz's connection dropped — playing what the server has");
expect(d.detail, '2 through the server');
expect(deliveryOf(r, selfId: 'm2', now: since.add(const Duration(minutes: 3)))!.title,
"Foxyz's connection dropped — playing what the server has (3 min)");
});
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');
+1 -1
View File
@@ -58,7 +58,7 @@ void main() {
expect(c.serverState.peek()!.position, closeTo(13.77, 0.1));
await _poll(() => !c.playback.player.state.playing, 'player stopped');
expect(c.playback.positionSecs, greaterThan(12));
expect(c.starving.peek(), contains('finished'));
expect(c.status.peek().line?.kind, StatusKind.finished);
c.seekTo(2);
await _poll(
+3 -1
View File
@@ -80,9 +80,11 @@ void main() {
test('health parse: online, junk and protocol mismatch', () {
final ok = ServerHealth.parse(jsonEncode(
{'ok': true, 'name': 'Den', 'proto': RelayConstants.protoVersion, 'viewers': 2}));
{'ok': true, 'name': 'Den', 'proto': RelayConstants.protoVersion, 'viewers': 2, 'version': '1.3.0'}));
expect(ok.online, isTrue);
expect(ok.name, 'Den');
expect(ok.version, '1.3.0');
expect(ServerHealth.parse('{"ok":true}').version, '', reason: 'a server too old to say');
expect(ok.viewers, 2);
expect(ok.needsUpdate, isFalse);
expect(ServerHealth.parse('{"ok":true,"proto":-1}').needsUpdate, isTrue);
+224 -24
View File
@@ -1,13 +1,16 @@
// restructure: rewrite in step 2 (Status + Notifier): every row, when
// noticeFor takes a context.
//
// The Notifier policy as a table: one row per AppEvent subtype, what
// noticeFor makes of it (toast text and actor, sound, attention, activity-log
// line). A policy change edits a row here on purpose.
// The Notifier policy as a table: one row per AppEvent subtype in the
// plain context (in, with others, the window focused, paused, default
// choices), then the rows that change with the context (sitting out,
// alone, the window in the background, playing, a replayed line). What
// noticeFor makes of each: toast text and actor, sound, attention, system
// notification, activity-log line. A policy change edits a row here on
// purpose.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show FeedMessage;
import 'package:sync_relay_core/core.dart' show FeedMessage, Presence;
import 'package:starsync/src/platform/ports/system_notifications.dart';
import 'package:starsync/src/platform/ports/window_activity.dart';
import 'package:starsync/src/session/notifier.dart';
import 'package:starsync/src/sfx/sfx.dart';
@@ -29,11 +32,12 @@ FeedMessage _feed(String action,
const _ids = {'Ann': 'm1', 'Bob': 'm2', 'Me': 'm9', 'server': 'server'};
/// One row: the event, then what it must do.
/// One row: the event (in [context]), then what it must do.
class _Row {
final String name;
final AppEvent event;
final Type type;
final NoticeContext context;
final String? toast;
final String? actor;
final SfxKind? sound;
@@ -41,10 +45,30 @@ class _Row {
final bool attention;
/// Whether the event's own feed line goes into the activity log.
final bool logged;
/// The system notification's title and body, null for none.
final (String, String)? system;
const _Row(this.name, this.event, this.type,
{this.toast, this.actor, this.sound, this.readyIndex = 0, this.attention = false, this.logged = false});
{this.context = _in,
this.toast,
this.actor,
this.sound,
this.readyIndex = 0,
this.attention = false,
this.logged = false,
this.system});
}
// The contexts.
const _in = NoticeContext(self: Presence.watching);
const _away = NoticeContext(self: Presence.away);
const _awayPeople = NoticeContext(self: Presence.away, prefs: NotifyPrefs(people: true));
const _alone = NoticeContext(self: Presence.watching, alone: true);
const _background = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused);
const _minimisedAway = NoticeContext(self: Presence.away, window: WindowActivity.minimised);
const _playing = NoticeContext(self: Presence.watching, playing: true);
const _chatPaused = NoticeContext(self: Presence.watching, playing: true, prefs: NotifyPrefs(chat: ChatPopups.paused));
const _chatOff = NoticeContext(self: Presence.watching, window: WindowActivity.unfocused, prefs: NotifyPrefs(chat: ChatPopups.off));
/// The feed as the coordinator turns it into events: this member is m9
/// ('Me'), two members ready in the latest state.
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfId: 'm9', readyCount: 2);
@@ -57,8 +81,9 @@ final _rows = <_Row>[
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
_Row('leave (grace over)', _of(_feed('leave', reason: 'grace')), MemberLeft,
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
_Row('gone (lost signal)', _of(_feed('gone')), MemberGone, logged: true),
_Row('return', _of(_feed('return')), MemberReturned, logged: true),
_Row('gone (after the delayed notice)', _of(_feed('gone')), MemberGone,
toast: 'Ann lost connection', actor: 'Ann', logged: true),
_Row('return', _of(_feed('return')), MemberReturned, toast: 'Ann is back', actor: 'Ann', logged: true),
_Row('away, someone sat themselves out', _of(_feed('away')), MemberSatOut, logged: true),
_Row('away, someone else auto sat out', _of(_feed('away', reason: 'idle')), MemberSatOut, logged: true),
_Row('away, someone sat someone else out', _of(_feed('away', target: 'Bob')), MemberSatOut, logged: true),
@@ -83,6 +108,7 @@ final _rows = <_Row>[
_Row('force-hold', _of(_feed('force-hold')), ForcedTransport, logged: true),
_Row('swap', _of(_feed('swap')), MediaSwapped,
toast: 'Ann changed media', actor: 'Ann', sound: SfxKind.swap, logged: true),
_Row('own swap: logged only', _of(_feed('swap', actor: 'Me')), MediaSwapped, logged: true),
_Row('seek', _of(_feed('seek')), Seeked, logged: true),
_Row('pause', _of(_feed('pause', actor: 'server')), Paused, logged: true),
_Row('resume', _of(_feed('resume', actor: 'server')), Resumed, logged: true),
@@ -93,7 +119,10 @@ final _rows = <_Row>[
toast: 'Me: hi', actor: 'Me', logged: true),
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
_Row('avatar', _of(_feed('avatar')), AvatarChanged, logged: true),
_Row('queue-add', _of(_feed('queue-add', target: 'Ep 6')), QueueAdded, logged: true),
_Row('queue-add: title, picture, who', FeedEvent.of(_feed('queue-add', target: 'Ep 6'), selfId: 'm9', queueThumb: 'h1'),
QueueAdded, toast: 'Ann added Ep 6 to Up next', actor: 'Ann', logged: true),
_Row('own queue-add: logged (the app said "Added")', _of(_feed('queue-add', actor: 'Me', target: 'Ep 6')),
QueueAdded, logged: true),
_Row('queue-remove', _of(_feed('queue-remove', target: 'Ep 6')), QueueRemoved, logged: true),
_Row('queue-skip with a reason', _of(_feed('queue-skip', actor: 'server', target: 'Ep 6', reason: 'gone')),
QueueSkipped, toast: 'Skipped Ep 6: gone', logged: true),
@@ -108,6 +137,14 @@ final _rows = <_Row>[
toast: 'Someone nudged you', sound: SfxKind.nudge, attention: true),
_Row('member cannot play', const MemberCantPlay('Ann', 'no codec'), MemberCantPlay,
toast: "Ann can't play this: no codec", actor: 'Ann'),
_Row('starting now: nothing while you watch it start', const PlaybackStarted('Frieren'), PlaybackStarted),
_Row('the source dropped', const RelaySourceDropped('Ann'), RelaySourceDropped,
toast: "Ann's connection dropped: playing what the server has", actor: 'Ann'),
_Row('the source left', const RelaySourceDropped('Ann', left: true), RelaySourceDropped,
toast: 'Ann left: playing what the server has', actor: 'Ann'),
_Row('server restarted', const ServerRestarted(), ServerRestarted, toast: 'The server restarted; you are back in'),
_Row('replaced', const Replaced(), Replaced, toast: 'Starsync was opened in another window'),
_Row('server refused', const ServerRefused('Up next is full'), ServerRefused, toast: 'Up next is full'),
_Row('slow source', const SlowSource('Passing on slower than it plays'), SlowSource,
toast: 'Passing on slower than it plays'),
_Row('relay started', const RelayStarted(), RelayStarted,
@@ -130,26 +167,182 @@ final _rows = <_Row>[
toast: "Marked in this app's log"),
_Row('copied', const Copied('Link copied'), Copied, toast: 'Link copied'),
_Row('copy failed', const CopyFailed(), CopyFailed, toast: "Couldn't copy"),
_Row('update available', const UpdateAvailable('1.3.0'), UpdateAvailable),
_Row('update available: the band shows it', const UpdateAvailable('1.3.0'), UpdateAvailable),
];
/// The rows the context changes.
final _contextRows = <_Row>[
// ---- sitting out (D1, P8): chat, nudges, starting now, Up next only
_Row('away: chat pops up with its sound', _of(_feed('chat', reason: 'hi')), ChatPosted,
context: _away, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true),
_Row('away: a nudge', const Nudged('Ann'), Nudged,
context: _away, toast: 'Ann nudged you', actor: 'Ann', sound: SfxKind.nudge, attention: true),
_Row('away: starting now', const PlaybackStarted('Frieren'), PlaybackStarted,
context: _away, toast: 'Starting now: Frieren'),
_Row('away: an Up next add', _of(_feed('queue-add', target: 'Ep 6')), QueueAdded,
context: _away, toast: 'Ann added Ep 6 to Up next', actor: 'Ann', logged: true),
_Row('away: a swap', _of(_feed('swap')), MediaSwapped,
context: _away, toast: 'Ann changed media', actor: 'Ann', sound: SfxKind.swap, logged: true),
_Row('away: a join is logged only', _of(_feed('join')), MemberJoined, context: _away, logged: true),
_Row('away: a leave is logged only', _of(_feed('leave', reason: 'bye')), MemberLeft, context: _away, logged: true),
_Row('away: ready makes no sound', _of(_feed('ready')), ReadyChanged, context: _away, logged: true),
_Row("away: someone can't play: nothing", const MemberCantPlay('Ann', 'no codec'), MemberCantPlay, context: _away),
_Row('away, joins chosen: a join pops up', _of(_feed('join')), MemberJoined,
context: _awayPeople, toast: 'Ann joined', actor: 'Ann', sound: SfxKind.join, logged: true),
_Row('away: your own relay still tells you', const RelayFailed('closed'), RelayFailed,
context: _away, toast: 'Cannot pass the video on: closed'),
_Row('away: app notices still show', const Copied('Link copied'), Copied, context: _away, toast: 'Link copied'),
// ---- alone (D9): no social sounds or pop-ups, app notices show
_Row('alone: ready makes no sound', _of(_feed('ready')), ReadyChanged, context: _alone, logged: true),
_Row('alone: a join is quiet', _of(_feed('join')), MemberJoined, context: _alone, logged: true),
_Row('alone: own chat is quiet', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted,
context: _alone, logged: true),
_Row('alone: app notices show', const ShaderMessage('Shaders off'), ShaderMessage,
context: _alone, toast: 'Shaders off'),
_Row('alone: problems show', const RelayFailed('closed'), RelayFailed,
context: _alone, toast: 'Cannot pass the video on: closed'),
_Row('alone: connection sounds play', const ConnectionLost(), ConnectionLost, context: _alone, sound: SfxKind.lost),
// ---- replayed: the log only, whatever the context
_Row('replayed chat while away in the background', _of(_feed('chat', reason: 'hi', replayed: true)), ChatPosted,
context: _minimisedAway, logged: true),
// ---- the window in the background: a system notification too
_Row('background: chat goes out as a system notification', _of(_feed('chat', reason: 'hi')), ChatPosted,
context: _background, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true, system: ('Ann', 'hi')),
_Row('background: a nudge', const Nudged('Ann'), Nudged,
context: _background,
toast: 'Ann nudged you',
actor: 'Ann',
sound: SfxKind.nudge,
attention: true,
system: ('Ann', 'nudged you')),
_Row('background: starting now', const PlaybackStarted('Frieren'), PlaybackStarted,
context: _background, toast: 'Starting now: Frieren', system: ('Starsync', 'Starting now: Frieren')),
_Row('background: an Up next add', _of(_feed('queue-add', target: 'Ep 6')), QueueAdded,
context: _background,
toast: 'Ann added Ep 6 to Up next',
actor: 'Ann',
logged: true,
system: ('Ann', 'added Ep 6 to Up next')),
_Row('background: a join pops up in the app only (not chosen)', _of(_feed('join')), MemberJoined,
context: _background, toast: 'Ann joined', actor: 'Ann', sound: SfxKind.join, logged: true),
_Row('background: app notices stay in the app', const Copied('Link copied'), Copied,
context: _background, toast: 'Link copied'),
_Row('minimised, away: chat goes out', _of(_feed('chat', reason: 'hi')), ChatPosted,
context: _minimisedAway, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true, system: ('Ann', 'hi')),
_Row('minimised, away: a join does not', _of(_feed('join')), MemberJoined, context: _minimisedAway, logged: true),
_Row('background: own chat stays in the app', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted,
context: _background, toast: 'Me: hi', actor: 'Me', logged: true),
// ---- chat pop-ups (Settings): the sound has its own switch
_Row('playing, chat pop-ups All', _of(_feed('chat', reason: 'hi')), ChatPosted,
context: _playing, toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true),
_Row('playing, chat pop-ups only when paused', _of(_feed('chat', reason: 'hi')), ChatPosted,
context: _chatPaused, sound: SfxKind.chat, logged: true),
_Row('chat pop-ups off: no system notification either', _of(_feed('chat', reason: 'hi')), ChatPosted,
context: _chatOff, sound: SfxKind.chat, logged: true),
];
void _check(_Row r) {
expect(r.event.runtimeType, r.type, reason: 'event type');
final n = noticeFor(r.event, r.context);
expect(n.toast, r.toast, reason: 'toast');
expect(n.actor, r.actor, reason: 'actor');
expect(n.sound?.kind, r.sound, reason: 'sound');
if (r.sound != null) expect(n.sound!.readyIndex, r.readyIndex, reason: 'ready pitch');
expect(n.attention, r.attention, reason: 'attention');
expect(n.system == null ? null : (n.system!.title, n.system!.body), r.system, reason: 'system notification');
final e = r.event;
expect(n.log, r.logged ? same((e as FeedEvent).feed) : isNull, reason: 'activity log');
}
/// Records what the Notifier sends out of the window and lets a test
/// click it.
class _FakeSystem implements SystemNotifications {
final shown = <(String, String, String)>[];
void Function(String payload)? _click;
@override
set onClick(void Function(String payload)? handler) => _click = handler;
void click(String payload) => _click!(payload);
@override
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async =>
shown.add((title, body, payload));
}
void main() {
group('noticeFor', () {
for (final r in _rows) {
test(r.name, () {
expect(r.event.runtimeType, r.type, reason: 'event type');
final n = noticeFor(r.event);
expect(n.toast, r.toast, reason: 'toast');
expect(n.actor, r.actor, reason: 'actor');
expect(n.sound?.kind, r.sound, reason: 'sound');
if (r.sound != null) expect(n.sound!.readyIndex, r.readyIndex, reason: 'ready pitch');
expect(n.attention, r.attention, reason: 'attention');
final e = r.event;
expect(n.log, r.logged ? same((e as FeedEvent).feed) : isNull, reason: 'activity log');
});
test(r.name, () => _check(r));
}
});
group('noticeFor in context', () {
for (final r in _contextRows) {
test(r.name, () => _check(r));
}
});
test('the queue-add toast carries the item picture', () {
final n = noticeFor(FeedEvent.of(_feed('queue-add', target: 'Ep 6'), selfId: 'm9', queueThumb: 'h1'));
expect(n.thumb, 'h1');
});
group('Notifier', () {
test('in the background: a system notification; a click focuses the window and opens the chat', () async {
final system = _FakeSystem();
var focused = 0;
final n = Notifier(system: system, focusWindow: () async => focused++);
n.session = () => (self: Presence.watching, alone: false, playing: false);
n.window = WindowActivity.minimised;
n.notify(_of(_feed('chat', reason: 'hi')));
expect(system.shown, [('Ann', 'hi', SystemNote.payloadChat)]);
expect(n.toasts.peek().single.chat, isTrue);
system.click(SystemNote.payloadChat);
expect(focused, 1);
expect(n.chatRequests.peek(), 1);
// A click on any other one only brings the window up.
n.notify(const PlaybackStarted('Frieren'));
expect(system.shown.last, ('Starsync', 'Starting now: Frieren', ''));
system.click('');
expect(focused, 2);
expect(n.chatRequests.peek(), 1);
// Focused: nothing leaves the window.
n.window = WindowActivity.focused;
n.notify(_of(_feed('chat', reason: 'again')));
expect(system.shown, hasLength(2));
n.clear();
});
test('owns the toast timer: a toast goes after its time, a dismissed one at once', () async {
final n = Notifier(toastLife: const Duration(milliseconds: 300));
n.notify(const Copied('Link copied'));
n.notify(const ShaderMessage('Shaders off'));
expect(n.toasts.peek().map((t) => t.text), ['Link copied', 'Shaders off']);
n.dismiss(n.toasts.peek().last.id);
expect(n.toasts.peek().map((t) => t.text), ['Link copied']);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(n.toasts.peek(), hasLength(1));
await Future<void>.delayed(const Duration(milliseconds: 300));
expect(n.toasts.peek(), isEmpty);
});
test('reads the session: sitting out silences the party, alone too', () {
final sounds = <SfxKind>[];
final n = Notifier(sfx: _Sfx(sounds));
var self = Presence.watching;
var alone = false;
n.session = () => (self: self, alone: alone, playing: false);
n.notify(_of(_feed('join')));
self = Presence.away;
n.notify(_of(_feed('join', actor: 'Bob')));
self = Presence.watching;
alone = true;
n.notify(_of(_feed('ready')));
expect(n.toasts.peek().map((t) => t.text), ['Ann joined']);
expect(sounds, [SfxKind.join]);
expect(n.feedLog.peek(), hasLength(3), reason: 'everything is logged');
n.clear();
});
});
test('every concrete AppEvent subtype has a row', () {
// Read from the source, so a new event fails here until it gets a row.
final src = File('lib/src/session/app_events.dart').readAsStringSync();
@@ -161,3 +354,10 @@ void main() {
expect(_rows.map((r) => r.type.toString()).toSet(), declared);
});
}
class _Sfx implements SfxService {
final List<SfxKind> played;
_Sfx(this.played);
@override
Future<void> play(SfxCue cue) async => played.add(cue.kind);
}
+163 -46
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 2 (Status + Notifier): the "status" group
// (one computed level); the title block and overflow checks stay.
import 'dart:typed_data';
import 'package:flutter/material.dart';
@@ -8,16 +6,18 @@ import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/controls/frame.dart';
import 'package:starsync/src/controls/minimal/style.dart';
import 'package:starsync/src/controls/minimal/timeline.dart';
import 'package:starsync/src/session/health.dart';
import 'package:starsync/src/session/connection.dart';
import 'package:starsync/src/session/delivery.dart' show MemberNames;
import 'package:starsync/src/session/app_events.dart';
import 'package:starsync/src/session/status.dart';
import 'package:starsync/src/session/now_playing.dart';
import 'package:starsync/src/shell/now_playing_title.dart';
import 'package:starsync/src/shell/player_status.dart';
import 'package:starsync/src/shell/session_player.dart';
import 'package:starsync/src/shell/start_banner.dart';
import 'package:starsync/src/state/session_state.dart';
// The player's title block, start card and status pill (never a media_kit
// `Video`).
// The one computed status (each input → text and level), the player's
// title block, start card and status pill (never a media_kit `Video`).
/// A 1x1 transparent PNG.
final png = Uint8List.fromList(const [
@@ -38,18 +38,44 @@ MemberView mv(String id, String name,
buffered: buffered,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching);
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
StateBroadcast held(List<MemberView> members,
{String transport = 'held', double position = 0, int mediaId = 1, MediaRelay? relay}) =>
StateBroadcast(
eventId: 1,
transport: transport,
position: position,
serverTime: DateTime.now().toUtc(),
mediaId: mediaId,
media: {'name': 'Movie.mkv', 'size': 1, 'duration': 0},
media: {'name': 'Movie.mkv', 'size': 1, 'duration': 0, if (relay != null) 'relay': relay.toJson()},
members: members,
);
const ok = HealthStatus(HealthSeverity.ok, 'connected');
final _now = DateTime.utc(2026, 1, 1, 12);
/// The status of [state] as member [self] sees it, online unless [conn].
Status st(StateBroadcast? state,
{MemberView? self,
ConnState conn = const ConnOnline(),
String playerError = '',
String playerFault = '',
bool ownPlan = false,
int fetchFailedFor = -1,
MemberNames? names,
DateTime? now}) =>
statusOf(StatusFacts(
conn: conn,
state: state,
selfId: self?.id ?? '',
playerError: playerError,
playerFault: playerFault,
ownPlan: ownPlan,
fetchFailedFor: fetchFailedFor,
names: names,
now: now ?? _now,
));
StatusLine? line(StateBroadcast? state, {MemberView? self, ConnState conn = const ConnOnline(), String playerError = ''}) =>
st(state, self: self, conn: conn, playerError: playerError).line;
void main() {
final coord = SessionState.instance.coordinator;
@@ -57,82 +83,133 @@ void main() {
tearDown(() {
debugArtBytes = null;
coord.serverState.value = null;
coord.health.value = const HealthStatus(HealthSeverity.info, 'idle');
coord.playback.alive.value = false;
});
group('status', () {
final me = mv('me', 'Me', ready: true);
test('problems first: reconnecting, health', () {
var v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
reconnecting: true);
expect(v!.text, 'Reconnecting…');
expect(v.action, StatusAction.cancel);
v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'waiting for Bob to pass the video on'),
state: held([me]), self: me);
expect(v!.kind, StatusKind.problem);
expect(v.text, 'Waiting for Bob to pass the video on');
expect(v.tone, HealthSeverity.warn);
expect(v.action, StatusAction.retry);
test('the connection first: reconnecting, unstable, refused, replaced, connecting', () {
final r = held([me]);
for (final (conn, text, level, action) in [
(const ConnLost(), 'Reconnecting…', StatusLevel.warn, StatusAction.cancel),
(const ConnReconnecting(2), 'Reconnecting…', StatusLevel.warn, StatusAction.cancel),
(const ConnUnstable(), 'Connection unstable…', StatusLevel.warn, null),
(const ConnRefused('secret', 'wrong password'), 'Wrong password', StatusLevel.error, StatusAction.retry),
(const ConnReplaced(), replacedText, StatusLevel.error, StatusAction.retry),
(const ConnConnecting(), 'Connecting…', StatusLevel.info, null),
]) {
final v = line(r, self: me, conn: conn)!;
expect((v.text, v.level, v.action), (text, level, action), reason: '$conn');
}
// Sitting out does not hide the connection.
expect(line(held([mv('me', 'Me', away: true)]), self: mv('me', 'Me', away: true), conn: const ConnLost())!.text,
'Reconnecting…');
});
test('info never shows; connecting only once slow', () {
expect(playerStatusFor(health: const HealthStatus(HealthSeverity.info, 'connected — waiting for host media…')),
isNull);
const connecting = HealthStatus(HealthSeverity.info, 'connecting…');
expect(playerStatusFor(health: connecting), isNull);
expect(playerStatusFor(health: connecting, slowConnect: true)!.text, 'Connecting…');
expect(playerStatusFor(health: ok, state: held([me], transport: 'playing'), self: me), isNull);
expect(playerStatusFor(health: ok, state: held([me]), self: me), isNull, reason: 'everyone ready');
test('the party line: connecting, reconnecting with the state kept, the ready count', () {
expect(st(null, conn: const ConnConnecting()).party, 'connecting…');
final r = held([me, mv('b', 'Bob'), mv('z', 'Zed', gone: true)]);
expect(st(r, self: me, conn: const ConnLost()).party, 'reconnecting…');
expect(st(r, self: me).party, 'ready 1/2', reason: 'gone is not counted');
});
test('nothing to say: playing, everyone ready, nothing up', () {
expect(line(held([me], transport: 'playing'), self: me), isNull);
expect(line(held([me]), self: me), isNull, reason: 'everyone ready');
expect(line(null), isNull);
});
test('finished, then whoever is loading', () {
expect(playerStatusFor(health: ok, state: held([me], transport: 'finished'), self: me)!.text,
'Finished · seek back or pick the next video');
expect(line(held([me], transport: 'finished'), self: me)!.text, 'Finished · seek back or pick the next video');
final slow = mv('b', 'Bob', ready: true, buffered: RelayConstants.minBufferSecs / 2);
final v = playerStatusFor(health: ok, state: held([me, slow, mv('c', 'Cleo')]), self: me)!;
final v = line(held([me, slow, mv('c', 'Cleo')]), self: me)!;
expect(v.kind, StatusKind.loading);
expect(v.text, 'Bob is loading…');
expect(v.who.single.id, 'b');
expect(v.progress, closeTo(0.5, 1e-9));
final mine = mv('me', 'Me', ready: true, buffered: 0);
expect(playerStatusFor(health: ok, state: held([mine]), self: mine)!.text, 'Loading the video…');
expect(line(held([mine]), self: mine)!.text, 'Loading the video…');
});
test('you, then the others with a ready count', () {
final notMe = mv('me', 'Me');
final v = playerStatusFor(health: ok, state: held([notMe, mv('b', 'Bob')]), self: notMe)!;
final v = line(held([notMe, mv('b', 'Bob')]), self: notMe)!;
expect(v.kind, StatusKind.you);
expect(v.text, "Press Space when you're ready");
expect(v.action, StatusAction.ready);
expect(v.self, isTrue);
final w = playerStatusFor(
health: ok,
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]),
final w = line(held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]),
self: me)!;
expect(w.kind, StatusKind.waiting);
expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out');
expect(w.detail, '1/3 ready');
final outMe = mv('me', 'Me', away: true);
expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull,
reason: 'sitting out: the join-back card speaks');
expect(line(held([outMe, mv('b', 'Bob')]), self: outMe), isNull, reason: 'sitting out: the join-back card speaks');
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
});
test("can't play: yours with Retry (reload); others' instead of waiting", () {
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0,
error: 'Not allowed (403)');
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
test("can't play: yours with Reload, a broken player too; others' instead of waiting", () {
var v = line(held([me]), self: me, playerError: 'Not allowed (403)')!;
expect((v.kind, v.text, v.action, v.level),
(StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload, StatusLevel.error));
v = st(held([me]), self: me, playerFault: 'video reload failed: gone').line!;
expect((v.text, v.action), ('Video reload failed: gone', StatusAction.reload));
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, error: 'Not allowed (403)');
v = line(held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
expect(v.who.single.id, 'b');
final notMe = mv('me', 'Me');
v = playerStatusFor(health: ok, state: held([notMe, bob]), self: notMe)!;
v = line(held([notMe, bob]), self: notMe)!;
expect((v.kind, v.action), (StatusKind.cannotPlay, StatusAction.ready));
});
group('the relay', () {
final bob = mv('b', 'Bob', ready: true);
test('the server fetching, then failing', () {
final r = held([me, bob], relay: const MediaRelay(fetch: true));
expect((line(r, self: me)!.kind, line(r, self: me)!.text),
(StatusKind.fetching, 'The server is getting the video…'));
final failed = st(r, self: me, fetchFailedFor: 1).line!;
expect((failed.text, failed.level), ("The server couldn't fetch this video", StatusLevel.error));
});
test('waiting for the member passing it on', () {
final v = line(held([me, bob], relay: const MediaRelay(source: 'b')), self: me)!;
expect((v.text, v.level), ('Waiting for Bob to pass the video on', StatusLevel.warn));
expect(line(held([me, bob], relay: const MediaRelay(source: 'b', live: true)), self: me), isNull);
});
test("the source's connection dropped: what the server has, and for how long; even while playing", () {
final gone = MemberView(id: 'b', name: 'Bob', ready: true, inSet: false, buffered: 30, presence: Presence.gone);
final since = _now.subtract(const Duration(seconds: 20));
final r = held([me, gone],
transport: 'playing',
relay: MediaRelay(source: 'b', live: true, sourceGone: true, sourceGoneSince: since));
final v = st(r, self: me);
expect((v.line!.text, v.line!.level), ("Bob's connection dropped — playing what the server has", StatusLevel.warn));
expect(v.source!.left, isFalse);
expect(st(r, self: me, now: _now.add(const Duration(minutes: 2))).line!.text,
"Bob's connection dropped — playing what the server has (2 min)");
expect(v.delivery!.title, "Bob's connection dropped — playing what the server has");
// With its own Jellyfin account this viewer does not read the relay.
expect(st(r, self: me, ownPlan: true).line, isNull);
});
test('the source left: named from before', () {
final names = MemberNames()..observe(held([me, bob]));
final r = held([me], transport: 'playing', relay: const MediaRelay(source: 'b', sourceGone: true));
final v = st(r, self: me, names: names);
expect(v.line!.text, 'Bob left — playing what the server has');
expect(v.source!.left, isTrue);
});
test('the source itself sees none of it', () {
final r = held([me, bob], relay: const MediaRelay(source: 'me'));
expect(line(r, self: me), isNull);
});
});
test('names read as a sentence', () {
expect(nameList(['Bob']), 'Bob');
expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
@@ -141,6 +218,46 @@ void main() {
});
});
group('status transitions', () {
final me = mv('me', 'Me', ready: true);
final gone = MemberView(id: 'b', name: 'Bob', ready: true, inSet: false, buffered: 30, presence: Presence.gone);
test('the source gone a few seconds: one notice; left: another', () {
final w = StatusWatch();
final r = held([me, gone],
transport: 'playing', relay: MediaRelay(source: 'b', live: true, sourceGone: true, sourceGoneSince: _now));
expect(w.step(st(r, self: me), _now.add(const Duration(seconds: 2))), isEmpty, reason: 'a quick drop says nothing');
final at = _now.add(sourceDropNotice);
expect(w.step(st(r, self: me), at).single, isA<RelaySourceDropped>().having((e) => e.name, 'name', 'Bob'));
expect(w.step(st(r, self: me), at.add(const Duration(seconds: 1))), isEmpty, reason: 'once');
final names = MemberNames()..observe(r);
final left = held([me], transport: 'playing', relay: const MediaRelay(source: 'b', sourceGone: true));
expect(w.step(st(left, self: me, names: names), at).single,
isA<RelaySourceDropped>().having((e) => e.left, 'left', isTrue));
});
test('starting now: the first start of a media seen held, not a resume or a join mid-way', () {
final w = StatusWatch();
expect(w.step(st(held([me], transport: 'playing', mediaId: 7), self: me), _now), isEmpty,
reason: 'joined while it played');
w.step(st(held([me]), self: me), _now);
final started = w.step(st(held([me], transport: 'playing'), self: me), _now);
expect(started.single, isA<PlaybackStarted>().having((e) => e.title, 'title', isNotEmpty));
w.step(st(held([me]), self: me), _now);
expect(w.step(st(held([me], transport: 'playing'), self: me), _now), isEmpty, reason: 'a resume');
});
test('replaced and a restarted server, once each', () {
final w = StatusWatch();
w.step(statusOf(StatusFacts(conn: const ConnOnline(), now: _now)), _now);
expect(w.step(statusOf(StatusFacts(conn: const ConnReplaced(), now: _now)), _now).single, isA<Replaced>());
expect(w.step(statusOf(StatusFacts(conn: const ConnReplaced(), now: _now)), _now), isEmpty);
expect(w.step(statusOf(StatusFacts(conn: const ConnOnline(), restarts: 1, now: _now)), _now).single,
isA<ServerRestarted>());
expect(w.step(statusOf(StatusFacts(conn: const ConnOnline(), restarts: 1, now: _now)), _now), isEmpty);
});
});
group('title block', () {
Widget host(Widget child, {double width = 400}) => MaterialApp(
home: Scaffold(backgroundColor: Colors.black, body: Center(child: SizedBox(width: width, child: child))),
+1 -1
View File
@@ -206,7 +206,7 @@ void main() {
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
await Future.delayed(const Duration(seconds: 7));
expect(alice.playerError.peek(), isEmpty, reason: 'no "could not be opened" while the source is on its way');
expect(alice.health.peek().message, contains('waiting for bob'));
expect(alice.status.peek().line?.text, 'Waiting for bob to pass the video on');
final src = RelaySource(Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/$token/source'),
RelayMedia.single(FileRelayBytes(File(file))));
await src.start();
+88 -15
View File
@@ -1,9 +1,14 @@
// restructure: rewrite in step 2 (Status + Notifier): toasts and the status
// pill; in step 3 (Your server): the solo strip and Invite friends.
// restructure: rewrite in step 3 (Your server): the solo strip and Invite
// friends.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/controls/frame.dart' show ControlsFrame, ControlsFrameState;
import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/session_controls.dart' show controlsBottomHeight;
import 'package:starsync/src/session/notifier.dart' show Copied;
import 'package:starsync/src/session/identity.dart';
import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/shell/avatar.dart';
@@ -15,6 +20,7 @@ import 'package:starsync/src/shell/settings_tab.dart' show SettingsTab;
import 'package:starsync/src/shell/toasts.dart';
import 'package:starsync/src/session/session_coordinator.dart' show ToastItem;
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
/// Stands in for media_kit's `Video` (which hangs flutter_test) and
@@ -38,13 +44,14 @@ class _FakeVideoState extends State<FakeVideo> {
Widget build(BuildContext context) => const ColoredBox(color: Colors.black);
}
MemberView member(String id, String name, {bool ready = false, bool gone = false}) =>
MemberView member(String id, String name, {bool ready = false, bool gone = false, String app = ''}) =>
MemberView(
id: id,
name: name,
ready: ready,
inSet: ready,
buffered: 30,
app: app,
presence: gone ? Presence.gone : Presence.watching);
void main() {
@@ -73,10 +80,10 @@ void main() {
session.route.value = AppRoute.home;
coord.serverState.value = null;
coord.playback.alive.value = false;
coord.toasts.value = [];
session.notifier.clear();
});
Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates}) async {
Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates, VideoViewBuilder? video}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@@ -86,7 +93,7 @@ void main() {
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false,
windowControls: false,
videoBuilder: (context, controls) => const FakeVideo(),
videoBuilder: video ?? (context, controls) => const FakeVideo(),
// A real probe (network, yt-dlp) never answers on the test clock.
linkProbe: (url) async => LinkProbe.file(Uri.parse(url), 1000),
),
@@ -104,11 +111,18 @@ void main() {
];
group('version and update bar', () {
testWidgets('the version under Settings; the bar on the tabs, off the full player until the video ends',
(tester) async {
testWidgets('the version at the bottom of Settings, not in the strip; the bar on the tabs, off the full player '
'until the video ends', (tester) async {
final updates = UpdateController(currentVersion: '0.0.0-dev');
await pumpShell(tester, const Size(1280, 800), updates: updates);
expect(find.text('dev run'), findsOneWidget);
expect(find.byKey(const ValueKey('strip-version')), findsNothing);
expect(find.textContaining('dev run'), findsNothing);
await tester.tap(find.byKey(const ValueKey('tab-settings')));
await tester.pumpAndSettle();
expect(
tester.widget<SelectableText>(find.byKey(const ValueKey('settings-version'))).data, contains('dev run'));
await tester.tap(find.byKey(const ValueKey('tab-library')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
updates.status.value = const UpdateStatus(UpdatePhase.available,
manifest: UpdateManifest(version: '9.9.9', protocol: 1, url: 'http://x/a.tgz', notes: ''),
@@ -207,7 +221,9 @@ void main() {
group('popovers', () {
testWidgets('members card opens from the avatars and closes outside', (tester) async {
await pumpShell(tester, const Size(1280, 800));
online(party, media: false);
session.serverVersion.value = '1.3.0-dev.1';
addTearDown(() => session.serverVersion.value = '');
online([member('a', 'Alex', ready: true, app: '1.3.0-dev.1'), ...party.skip(1)], media: false);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('avatar-stack')));
await tester.pumpAndSettle();
@@ -217,6 +233,10 @@ void main() {
expect(find.descendant(of: card, matching: find.text(m.name)), findsOneWidget);
}
expect(find.descendant(of: card, matching: find.text('reconnecting…')), findsOneWidget);
// Each member's version in its row, the server's in the header.
expect(find.descendant(of: card, matching: find.byKey(const ValueKey('version-a'))), findsOneWidget);
expect(find.descendant(of: card, matching: find.text('1.3.0-dev.1')), findsOneWidget);
expect(find.descendant(of: card, matching: find.text('Server 1.3.0-dev.1')), findsOneWidget);
// Someone else: nudge or sit them out (no kicking).
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More')));
await tester.pumpAndSettle();
@@ -386,11 +406,18 @@ void main() {
expect(find.text('1/3 ready'), findsOneWidget);
});
test('a version that is not ours stands out (a dev run matches any)', () {
expect(versionDiffers('1.3.0', own: '1.3.0'), isFalse);
expect(versionDiffers('1.2.4', own: '1.3.0'), isTrue);
expect(versionDiffers('', own: '1.3.0'), isFalse);
expect(versionDiffers('1.2.4', own: '0.0.0-dev'), isFalse);
});
test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal);
final at = DateTime.now();
// The actor is exact: "Bob" is not mistaken for "Bob Jr".
final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at, actor: 'Bob Jr'), who);
final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at, actor: 'Bob Jr', chat: true), who);
expect(chat.who!.name, 'Bob Jr');
expect(chat.text, 'hi there');
expect(chat.chat, isTrue);
@@ -404,16 +431,62 @@ void main() {
expect(app.text, 'Bob joined');
final notice = shellToastFor(ToastItem(id: 4, text: 'Shaders off', at: at), who);
expect(notice.who, isNull);
// An Up next add: who, what, and the item's picture.
final added = shellToastFor(
ToastItem(id: 5, text: 'Bob added Ep 6 to Up next', at: at, actor: 'Bob', thumb: 'h1'), who);
expect((added.who!.name, added.text, added.icon, added.thumb),
('Bob', 'added Ep 6 to Up next', Icons.playlist_add_rounded, 'h1'));
});
testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1)))));
coord.toasts.value = [ToastItem(id: 1, text: 'Shaders off', at: DateTime.now())];
testWidgets("toasts slide in and leave when the Notifier's time is up", (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts())));
session.notifier.notify(const Copied('Shaders off'));
await tester.pump();
await tester.pump();
expect(find.text('Shaders off'), findsOneWidget);
await tester.pump(const Duration(seconds: 1));
await tester.pump(session.notifier.toastLife);
await tester.pumpAndSettle();
expect(find.text('Shaders off'), findsNothing);
});
for (final skin in ControlsSkin.values) {
testWidgets('toasts sit above the control bar and follow it (${skin.name})', (tester) async {
final s = AppSettings.instance;
final hide = s.controlsHideSecs.peek();
s.controlsSkin.value = skin;
s.controlsHideSecs.value = 2;
addTearDown(() {
s.controlsSkin.value = ControlsSkin.standard;
s.controlsHideSecs.value = hide;
});
coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800),
video: (context, controls) => Stack(fit: StackFit.expand, children: [
const ColoredBox(color: Colors.black),
if (controls != null) controls(VideoState()),
]));
online(party);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(tester.state<AppShellState>(find.byType(AppShell)).mode, PlayerMode.full);
expect(tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible, isTrue);
session.notifier.notify(const Copied('Link copied'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
final body = tester.getRect(find.byKey(const ValueKey('body')));
final bar = controlsBottomHeight(skin, s.controlsSize.peek());
final up = tester.getRect(find.byType(ToastCard));
expect(up.bottom, lessThanOrEqualTo(body.bottom - bar), reason: 'clear of the control bar');
// The controls hide: the toasts come down to the edge.
await tester.pump(const Duration(seconds: 2));
await tester.pump(const Duration(milliseconds: 600));
expect(tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible, isFalse);
final down = tester.getRect(find.byType(ToastCard));
expect(down.bottom, closeTo(body.bottom - AppShellState.toastInset, 1));
expect(down.bottom, greaterThan(up.bottom));
session.route.value = AppRoute.home;
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
});
}
}