Player: now-playing title with cover, start banner, one status pill; media art for everyone
The swapper sends the media's poster and banner once (`art`, Jellyfin images or AniList by parsed name); state carries their hashes and every member fetches them from /i/ like avatars. state.media.swapBy names who put the media up. uosc and the standard bar show cover, title and episode; a banner card shows before playback starts. The top-left health pill and the waiting pill become one frosted status pill in plain words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d092ea371a
commit
b534c8c7ea
29 files changed
+1892
-236
No files matched your search
@@ -56,4 +56,35 @@ void main() {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('media art arrives by hash over /i/; a swap clears it', () async {
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 's', name: 'art'));
|
||||
await server.start();
|
||||
final client = SyncClient();
|
||||
final cache = ArtCache(client);
|
||||
final poster = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List<int>.generate(197, (i) => i % 256)]);
|
||||
try {
|
||||
await client.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'),
|
||||
secret: 's',
|
||||
name: 'alice',
|
||||
);
|
||||
client.sendSwap(const HelloMedia(name: 'Show - 01.mkv', size: 0, duration: 0, url: 'http://example.com/a.mkv'));
|
||||
await _poll(() => client.lastState?.media?['swapBy'] == client.memberId, 'swap');
|
||||
client.sendArt(client.lastState!.mediaId, poster: base64Encode(poster));
|
||||
await _poll(() => ArtCache.artHashesOf(client.lastState!).isNotEmpty, 'art in state');
|
||||
final hash = ArtCache.artHashesOf(client.lastState!).single;
|
||||
expect(hash, avatarHashOf(poster));
|
||||
await _poll(() => cache.bytesFor(hash) != null, 'art bytes');
|
||||
expect(cache.bytesFor(hash), poster);
|
||||
|
||||
client.sendSwap(const HelloMedia(name: 'Other.mkv', size: 0, duration: 0, url: 'http://example.com/b.mkv'));
|
||||
await _poll(() => client.lastState?.media?['name'] == 'Other.mkv', 'second swap');
|
||||
expect(ArtCache.artHashesOf(client.lastState!), isEmpty);
|
||||
} finally {
|
||||
cache.dispose();
|
||||
await client.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/session/now_playing.dart';
|
||||
import 'package:watch_together/src/session/now_playing_info.dart';
|
||||
|
||||
void main() {
|
||||
group('jellyfinTitle', () {
|
||||
test('episodes: series on top, "S1 E2 · Name" below', () {
|
||||
expect(jellyfinTitle('The Pilot', 'Show · S1E2'), ('Show', 'S1 E2 · The Pilot'));
|
||||
expect(jellyfinTitle('The Pilot', 'Show - S01E02'), ('Show', 'S1 E2 · The Pilot'));
|
||||
expect(jellyfinTitle('Ep', 'Re:Zero - Starting Life · S2E10'), ('Re:Zero - Starting Life', 'S2 E10 · Ep'));
|
||||
});
|
||||
|
||||
test('movies and partial subtitles', () {
|
||||
expect(jellyfinTitle('Movie', '2019'), ('Movie', null));
|
||||
expect(jellyfinTitle('Movie', null), ('Movie', null));
|
||||
expect(jellyfinTitle('Pilot', 'S1E1'), ('Pilot', 'S1 E1'));
|
||||
expect(jellyfinTitle('Special', 'Show'), ('Show', 'Special'));
|
||||
});
|
||||
});
|
||||
|
||||
group('fileTitle', () {
|
||||
test('release names', () {
|
||||
expect(fileTitle('[SubsPlease] Frieren - 05 (1080p) [ABCD1234].mkv'), ('Frieren', 'E5'));
|
||||
expect(fileTitle('Show.Name.S01E03.1080p.WEB-DL.x264-GRP.mkv'), ('Show Name', 'S1 E3'));
|
||||
expect(fileTitle('[Grp] Show - NCOP1 [1080p].mkv'), ('Show', 'NCOP1'));
|
||||
expect(fileTitle('Some Movie (2019).mkv'), ('Some Movie', null));
|
||||
});
|
||||
|
||||
test('nothing sensible: the raw name minus extension', () {
|
||||
expect(fileTitle('[1080p].mkv').$1, '[1080p]');
|
||||
});
|
||||
});
|
||||
|
||||
test('nowPlayingOf: media map with ref and art', () {
|
||||
expect(nowPlayingOf(null), isNull);
|
||||
expect(
|
||||
nowPlayingOf({
|
||||
'name': 'The Pilot',
|
||||
'jellyfin': {'serverId': 's', 'itemId': 'i', 'title': 'The Pilot', 'subtitle': 'Show · S1E2'},
|
||||
'art': {'poster': 'aaaa', 'banner': 'bbbb'},
|
||||
}),
|
||||
const NowPlaying(title: 'Show', episode: 'S1 E2 · The Pilot', posterHash: 'aaaa', bannerHash: 'bbbb'));
|
||||
expect(nowPlayingOf({'name': 'Show - 12.mkv'}), const NowPlaying(title: 'Show', episode: 'E12'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/controls/uosc/buttons.dart';
|
||||
import 'package:watch_together/src/controls/uosc/controls.dart';
|
||||
import 'package:watch_together/src/controls/uosc/timeline.dart';
|
||||
import 'package:watch_together/src/session/health.dart';
|
||||
import 'package:watch_together/src/session/now_playing.dart';
|
||||
import 'package:watch_together/src/shell/now_playing_title.dart';
|
||||
import 'package:watch_together/src/shell/player_status.dart';
|
||||
import 'package:watch_together/src/shell/session_player.dart';
|
||||
import 'package:watch_together/src/shell/start_banner.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
|
||||
// The player's title block, start card and status pill (never a media_kit
|
||||
// `Video`).
|
||||
|
||||
/// A 1x1 transparent PNG.
|
||||
final png = Uint8List.fromList(const [
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, //
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
|
||||
0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) =>
|
||||
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale);
|
||||
|
||||
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
|
||||
StateBroadcast(
|
||||
eventId: 1,
|
||||
transport: transport,
|
||||
position: position,
|
||||
serverTime: DateTime.now().toUtc(),
|
||||
mediaId: mediaId,
|
||||
media: {'name': 'Movie.mkv', 'size': 1, 'duration': 0},
|
||||
members: members,
|
||||
);
|
||||
|
||||
const ok = HealthStatus(HealthSeverity.ok, 'connected');
|
||||
|
||||
void main() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
|
||||
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: removed, reconnecting, health', () {
|
||||
var v = playerStatusFor(
|
||||
health: const HealthStatus(HealthSeverity.error, 'kicked: spam'),
|
||||
kickReason: 'spam',
|
||||
reconnecting: true,
|
||||
state: held([me]));
|
||||
expect(v!.kind, StatusKind.kicked);
|
||||
expect(v.text, 'You were removed: spam');
|
||||
expect(v.tone, HealthSeverity.error);
|
||||
expect(v.action, isNull);
|
||||
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('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('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');
|
||||
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)!;
|
||||
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…');
|
||||
});
|
||||
|
||||
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)!;
|
||||
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', stale: true)]),
|
||||
self: me)!;
|
||||
expect(w.kind, StatusKind.waiting);
|
||||
expect(w.text, 'Waiting for Bob and Mika');
|
||||
expect(w.detail, '1/3 ready');
|
||||
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
||||
});
|
||||
|
||||
test('names read as a sentence', () {
|
||||
expect(nameList(['Bob']), 'Bob');
|
||||
expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
|
||||
expect(nameList(['Bob', 'Mika', 'Cleo']), 'Bob, Mika and Cleo');
|
||||
expect(nameList(['Bob', 'Mika', 'Cleo', 'Dan', 'Eve']), 'Bob, Mika and 3 others');
|
||||
});
|
||||
});
|
||||
|
||||
group('title block', () {
|
||||
Widget host(Widget child, {double width = 400}) => MaterialApp(
|
||||
home: Scaffold(backgroundColor: Colors.black, body: Center(child: SizedBox(width: width, child: child))),
|
||||
);
|
||||
const now = NowPlaying(title: 'Frieren', episode: 'S1 E4 · The Land Where Souls Rest', posterHash: 'p');
|
||||
|
||||
testWidgets('title, episode and a placeholder cover without art', (tester) async {
|
||||
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
|
||||
expect(find.text('Frieren'), findsOneWidget);
|
||||
expect(find.text('S1 E4 · The Land Where Souls Rest'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.movie_outlined), findsOneWidget);
|
||||
expect(find.byType(Image), findsNothing);
|
||||
final cover = tester.getSize(find.byType(ArtCover));
|
||||
expect(cover, const Size(40, 60));
|
||||
});
|
||||
|
||||
testWidgets('the cover image once its bytes are here', (tester) async {
|
||||
debugArtBytes = (hash) => hash == 'p' ? png : null;
|
||||
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
|
||||
final image = tester.widget<Image>(find.byType(Image));
|
||||
expect((image.image as MemoryImage).bytes, png);
|
||||
expect(image.gaplessPlayback, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('no cover, no episode: just the title; long titles ellipsize', (tester) async {
|
||||
await tester.pumpWidget(host(
|
||||
NowPlayingBlock(now: NowPlaying(title: 'A very long title ' * 10), cover: false, compact: true),
|
||||
width: 200));
|
||||
expect(find.byType(ArtCover), findsNothing);
|
||||
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a new media fades through', (tester) async {
|
||||
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
|
||||
await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune'))));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Frieren'), findsNothing);
|
||||
expect(find.text('Dune'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: SizedBox(width: 1280, height: 720, child: StartBanner())));
|
||||
final card = find.byKey(const ValueKey('start-card-title'));
|
||||
expect(card, findsNothing);
|
||||
coord.serverState.value = held([mv('a', 'Alex')]);
|
||||
await tester.pump();
|
||||
expect(card, findsOneWidget);
|
||||
expect(find.text(coord.nowPlaying.value!.title), findsWidgets);
|
||||
expect(tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('start-banner'))).opacity, 1);
|
||||
coord.serverState.value = held([mv('a', 'Alex', ready: true)], transport: 'playing');
|
||||
await tester.pump();
|
||||
expect(tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('start-banner'))).opacity, 0);
|
||||
await tester.pumpAndSettle();
|
||||
expect(card, findsNothing);
|
||||
// Paused back at the start: still gone.
|
||||
coord.serverState.value = held([mv('a', 'Alex')]);
|
||||
await tester.pumpAndSettle();
|
||||
expect(card, findsNothing);
|
||||
// The next media gets its own card.
|
||||
coord.serverState.value = held([mv('a', 'Alex')], mediaId: 2);
|
||||
await tester.pump();
|
||||
expect(card, findsOneWidget);
|
||||
// Joined mid-way: none.
|
||||
coord.serverState.value = held([mv('a', 'Alex')], mediaId: 3, position: 600);
|
||||
await tester.pumpAndSettle();
|
||||
expect(card, findsNothing);
|
||||
});
|
||||
|
||||
group('no overflow', () {
|
||||
const sizes = [Size(900, 600), Size(2560, 1080)];
|
||||
final long = NowPlaying(title: 'An Extremely Long Series Title ' * 4, episode: 'S12 E104 · ${'Episode ' * 20}',
|
||||
posterHash: 'p', bannerHash: 'b');
|
||||
final crowd = [
|
||||
for (var i = 0; i < 9; i++) mv('$i', 'Someone With A Long Name $i'),
|
||||
];
|
||||
|
||||
Future<void> sized(WidgetTester tester, Size size, Widget child) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
}
|
||||
|
||||
for (final size in sizes) {
|
||||
testWidgets('session player, status pill: ${size.width.toInt()}', (tester) async {
|
||||
coord.playback.alive.value = true;
|
||||
coord.serverState.value = held(crowd);
|
||||
await sized(
|
||||
tester,
|
||||
size,
|
||||
SessionPlayer(
|
||||
mode: PlayerMode.full,
|
||||
fullscreen: false,
|
||||
onExpand: () {},
|
||||
onCollapse: () {},
|
||||
onClose: () {},
|
||||
onToggleFullscreen: () {},
|
||||
onToggleChat: () {},
|
||||
onChangeVideo: () {},
|
||||
onOpenIntegrations: () {},
|
||||
onLeave: () {},
|
||||
windowControls: false,
|
||||
videoBuilder: (context, controls) => const ColoredBox(color: Colors.black),
|
||||
),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('player-status')), findsOneWidget);
|
||||
expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('uosc top bar and start card: ${size.width.toInt()}', (tester) async {
|
||||
debugArtBytes = (hash) => png;
|
||||
await sized(
|
||||
tester,
|
||||
size,
|
||||
Stack(fit: StackFit.expand, children: [
|
||||
StartCard(now: long),
|
||||
UoscControls(
|
||||
topLeft: NowPlayingBlock(now: long),
|
||||
top: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
for (final i in [Icons.chat_bubble_outline, Icons.fullscreen, Icons.close])
|
||||
UoscButton(icon: i, tooltip: '', onPressed: () {}),
|
||||
]),
|
||||
timeline: (context, expanded) =>
|
||||
UoscTimeline(position: Duration.zero, duration: const Duration(minutes: 24), expanded: expanded, onSeek: (_) {}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(find.byType(ArtCover), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('standard top bar row: ${size.width.toInt()}', (tester) async {
|
||||
await sized(
|
||||
tester,
|
||||
size,
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
height: 72,
|
||||
child: Row(children: [
|
||||
Expanded(child: NowPlayingBlock(now: long)),
|
||||
for (final i in [Icons.video_library_outlined, Icons.chat_bubble_outline, Icons.close_fullscreen])
|
||||
IconButton(onPressed: () {}, icon: Icon(i)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/schedule/anilist.dart';
|
||||
import 'package:watch_together/src/session/media_art.dart';
|
||||
|
||||
import 'schedule_fake_server.dart';
|
||||
|
||||
@@ -163,6 +165,52 @@ void main() {
|
||||
await expectLater(client().schedule(from, to), throwsA(isA<AniListException>()));
|
||||
});
|
||||
|
||||
test('searchMedia: one request per title, 404 is no match, art via aniListArt', () async {
|
||||
server.handler = (req, res) async {
|
||||
final search = ((jsonDecode(req.body) as Map)['variables'] as Map)['search'];
|
||||
if (search != 'Frieren') {
|
||||
json(res, {'errors': [{'message': 'Not Found.', 'status': 404}], 'data': {'Media': null}}, status: 404);
|
||||
return;
|
||||
}
|
||||
json(res, {
|
||||
'data': {
|
||||
'Media': {
|
||||
'id': 154587,
|
||||
'title': {'romaji': 'Sousou no Frieren', 'english': 'Frieren: Beyond Journey\'s End'},
|
||||
'coverImage': {'extraLarge': 'https://img/xl.jpg', 'large': 'https://img/l.jpg'},
|
||||
'bannerImage': 'https://img/banner.jpg',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
final c = client();
|
||||
final m = await c.searchMedia('Frieren');
|
||||
expect(m!.id, 154587);
|
||||
expect(m.title, "Frieren: Beyond Journey's End");
|
||||
expect(m.cover, 'https://img/xl.jpg');
|
||||
expect(m.coverSmall, 'https://img/l.jpg');
|
||||
expect(m.banner, 'https://img/banner.jpg');
|
||||
await c.searchMedia('Frieren');
|
||||
expect(server.seen, hasLength(1), reason: 'cached per title');
|
||||
expect(await c.searchMedia('Nothing Like It'), isNull);
|
||||
expect(await c.searchMedia(' '), isNull);
|
||||
expect(server.seen, hasLength(2));
|
||||
|
||||
// Files: the release name's series; the extraLarge cover too big falls
|
||||
// back to large.
|
||||
final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, 1, 2, 3]);
|
||||
final asked = <String>[];
|
||||
final art = await aniListArt(c, '[SubsPlease] Frieren - 05 (1080p) [ABCD1234].mkv', fetch: (url, headers, max) async {
|
||||
asked.add('$url');
|
||||
return url.path == '/xl.jpg' ? null : jpeg;
|
||||
});
|
||||
expect(art!.poster, jpeg);
|
||||
expect(art.banner, jpeg);
|
||||
expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg']));
|
||||
expect(await aniListArt(c, 'Nothing Like It.mkv', fetch: (_, _, _) async => jpeg), isNull);
|
||||
c.close();
|
||||
});
|
||||
|
||||
test('parseHexColor', () {
|
||||
expect(parseHexColor('#000000')?.toARGB32(), 0xff000000);
|
||||
expect(parseHexColor('e4a15d'), isNull);
|
||||
|
||||
@@ -290,12 +290,13 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('waiting pill: who is not ready, or you', (tester) async {
|
||||
testWidgets('status pill: who is not ready, with the ready count', (tester) async {
|
||||
coord.playback.alive.value = true;
|
||||
await pumpShell(tester, const Size(1280, 800));
|
||||
online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Waiting for Bob, Cleo'), findsOneWidget);
|
||||
expect(find.text('Waiting for Bob and Cleo'), findsOneWidget);
|
||||
expect(find.text('1/3 ready'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
|
||||
|
||||
Reference in new issue
Block a user