forked from Foxyz/sync_replay_project
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:
1 parent
130c934b9b
commit
629be0d9ed
41 files changed
+2114
-741
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user