forked from Foxyz/sync_replay_project
Player: quick chat box, danmaku notifications, the player panel, smooth subtitle lift
- Quick chat (Enter in the full player, whatever plays): a box at the bottom centre in the control bar's place, compact and widening with the text, sharing its draft with the chat panel; Enter sends, Esc or an empty Enter closes, it closes by itself after 8 s without typing (the draft stays). Keys go to it; the Enter that opened it no longer reaches a focused button. Typing never shows the cursor or the controls: the frame wakes only on real mouse movement and holds the controls hidden while the box or its echo is up. Your sent line shows as an echo (or flies as your own danmaku line); your own chat toast is not repeated. - Subtitle lift: always for the box, for the control bar as a setting (off); 4 eased-out sub-pos steps 42 ms apart instead of one per frame. - Notifications: Toasts / Danmaku (off by default): chat and short notices fly over the full player, problems stay toasts; every knob of the look plus boldness, persisted, with a live preview strip in Settings. - Player panel: ⚙ → Player settings docks a foldable glass panel at the right (tracks, subtitle size and lift, picture, volume, notifications, controls) in place of the card; Esc closes it. - Danmaku preview: the mock dialogue follows the lift; the stand-in page has the chat box too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6048fbe7d1
commit
69fae18fdc
29 files changed
+2755
-581
No files matched your search
@@ -3,6 +3,8 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku_notices.dart';
|
||||
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
|
||||
import 'package:starsync/src/shell/avatar.dart' show AvatarInfo;
|
||||
|
||||
// Danmaku lanes: free lanes only, no two lines in a lane ever touch, the
|
||||
@@ -173,4 +175,87 @@ void main() {
|
||||
expect(c.waiting.value, 0);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
|
||||
|
||||
group('DanmakuStyle as a setting', () {
|
||||
test('round-trips through its map', () {
|
||||
const s = DanmakuStyle(
|
||||
crossSecs: 6, maxOnScreen: 5, bandTop: 0.2, bandBottom: 0.5, opacity: 0.7, size: 1.3, weight: 800,
|
||||
outline: 2, highlightOwn: false, avatars: false);
|
||||
expect(DanmakuStyle.fromJson(s.toJson()), s);
|
||||
});
|
||||
|
||||
test('junk and out-of-range values: defaults and the nearest ends', () {
|
||||
expect(DanmakuStyle.fromJson(const {}), const DanmakuStyle());
|
||||
final s = DanmakuStyle.fromJson(const {
|
||||
'crossSecs': 99,
|
||||
'maxOnScreen': 0,
|
||||
'opacity': 'x',
|
||||
'weight': 1234,
|
||||
'bandTop': 0.6,
|
||||
'bandBottom': 0.61,
|
||||
'avatars': 1,
|
||||
});
|
||||
expect(s.crossSecs, 16);
|
||||
expect(s.maxOnScreen, 1);
|
||||
expect(s.opacity, const DanmakuStyle().opacity);
|
||||
expect(s.weight, 900);
|
||||
expect((s.bandTop, s.bandBottom), (0.10, 0.45), reason: 'a band too thin takes the default');
|
||||
expect(s.avatars, isTrue);
|
||||
});
|
||||
|
||||
test('boldness: the text at its weight, the name a step bolder', () {
|
||||
expect(const DanmakuStyle(weight: 400).fontWeight, FontWeight.w400);
|
||||
expect(const DanmakuStyle(weight: 400).nameWeight, FontWeight.w500);
|
||||
expect(const DanmakuStyle(weight: 900).nameWeight, FontWeight.w900);
|
||||
});
|
||||
});
|
||||
|
||||
group('notifications as danmaku', () {
|
||||
ToastItem toast(String text,
|
||||
{String? actor, NoticeCategory category = NoticeCategory.app, bool chat = false, bool own = false}) =>
|
||||
ToastItem(id: 1, text: text, at: DateTime(2026), actor: actor, chat: chat, own: own, category: category);
|
||||
|
||||
test('chat and short notices fly; problems, nudges and the app stay toasts', () {
|
||||
expect(fliesAsDanmaku(toast('Bob: hi', actor: 'Bob', chat: true, category: NoticeCategory.chat)), isTrue);
|
||||
expect(fliesAsDanmaku(toast('Bob joined', actor: 'Bob', category: NoticeCategory.people)), isTrue);
|
||||
expect(fliesAsDanmaku(toast('Starting now', category: NoticeCategory.start)), isTrue);
|
||||
expect(fliesAsDanmaku(toast('Bob added X to Up next', actor: 'Bob', category: NoticeCategory.upNext)), isTrue);
|
||||
expect(fliesAsDanmaku(toast('Skipped X: gone', category: NoticeCategory.upNext)), isFalse);
|
||||
expect(fliesAsDanmaku(toast("Bob can't play this", actor: 'Bob', category: NoticeCategory.problem)), isFalse);
|
||||
expect(fliesAsDanmaku(toast('Bob nudged you', actor: 'Bob', category: NoticeCategory.nudge)), isFalse);
|
||||
expect(fliesAsDanmaku(toast('Link copied')), isFalse);
|
||||
expect(fliesAsDanmaku(toast('The server restarted', category: NoticeCategory.self)), isFalse);
|
||||
});
|
||||
|
||||
test('lines: the sender before the message, own chat framed', () {
|
||||
final chat = danmakuForToast(toast('Me: hello', actor: 'Me', chat: true, own: true, category: NoticeCategory.chat))!;
|
||||
expect((chat.kind, chat.sender, chat.text, chat.own), (DanmakuKind.chat, 'Me', 'hello', true));
|
||||
final left = danmakuForToast(toast('Bob lost connection', actor: 'Bob', category: NoticeCategory.people))!;
|
||||
expect((left.kind, left.text), (DanmakuKind.leave, 'lost connection'));
|
||||
final start = danmakuForToast(toast('Starting now: Frieren', category: NoticeCategory.start))!;
|
||||
expect((start.kind, start.sender), (DanmakuKind.starting, null));
|
||||
expect(danmakuForToast(toast('Copied')), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('an idle layer asks for no frames', (tester) async {
|
||||
final c = DanmakuController();
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SizedBox(width: 800, height: 450, child: DanmakuLayer(controller: c, accent: Colors.purple)),
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||
c.add(const DanmakuItem(kind: DanmakuKind.chat, sender: 'Bob', text: 'hi'));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 1);
|
||||
expect(tester.binding.hasScheduledFrame, isTrue);
|
||||
await tester.pump(const Duration(seconds: 9));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(c.onScreen.value, 0);
|
||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:signals/signals_flutter.dart' show signal;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/controls/frame.dart';
|
||||
import 'package:starsync/src/controls/model/controls_prefs.dart';
|
||||
import 'package:starsync/src/controls/player_settings.dart';
|
||||
import 'package:starsync/src/controls/player_panel.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku.dart' show DanmakuStyle;
|
||||
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
||||
import 'package:starsync/src/schedule/animeflow.dart';
|
||||
import 'package:starsync/src/shaders/shader_presets.dart';
|
||||
@@ -37,6 +37,8 @@ void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) {
|
||||
}
|
||||
|
||||
Future<void> pick(WidgetTester tester, String key, String item) async {
|
||||
await tester.ensureVisible(find.byKey(ValueKey(key)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(ValueKey(key)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(item).last);
|
||||
@@ -57,6 +59,8 @@ void main() {
|
||||
s.shaderPreset.value = ShaderPreset.off;
|
||||
s.shaderQuality.value = ShaderQuality.hq;
|
||||
s.hwdecMode.value = HwdecMode.auto;
|
||||
s.liftSubtitles.value = false;
|
||||
s.notifyStyle.value = NotifyStyle.toasts;
|
||||
coord.playback.tracks.value = const [];
|
||||
coord.serverState.value = null;
|
||||
coord.hostFile = null;
|
||||
@@ -64,76 +68,66 @@ void main() {
|
||||
_graph.forms.recents.value = const [];
|
||||
});
|
||||
|
||||
Widget opener() => Builder(
|
||||
builder: (context) => TextButton(
|
||||
key: const ValueKey('open-settings'),
|
||||
onPressed: () => showPlayerSettings(context),
|
||||
child: const Text('settings'),
|
||||
),
|
||||
);
|
||||
|
||||
group('player settings card', () {
|
||||
testWidgets('lists every player setting, changes apply live', (tester) async {
|
||||
group('player panel', () {
|
||||
testWidgets('tracks, subtitles, picture, sound, notifications and controls; all live', (tester) async {
|
||||
bigWindow(tester);
|
||||
coord.playback.tracks.value = const [
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
|
||||
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
|
||||
];
|
||||
await tester.pumpWidget(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener()))));
|
||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
||||
var volume = 80.0;
|
||||
var closed = 0;
|
||||
final vol = signal(volume);
|
||||
await tester.pumpWidget(scoped(
|
||||
_graph,
|
||||
app(Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: SizedBox(
|
||||
height: 1000,
|
||||
child: PlayerPanel(
|
||||
onClose: () => closed++,
|
||||
volume: vol,
|
||||
onVolume: (v) => vol.value = volume = v,
|
||||
),
|
||||
),
|
||||
))));
|
||||
await tester.pumpAndSettle();
|
||||
final card = find.byKey(const ValueKey('player-settings-card'));
|
||||
expect(card, findsOneWidget);
|
||||
for (final label in [
|
||||
'Player design',
|
||||
'Progress line when hidden',
|
||||
'Upscaling shader',
|
||||
'Hardware decoding',
|
||||
'Subtitles',
|
||||
'Audio',
|
||||
]) {
|
||||
expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label);
|
||||
final panel = find.byKey(const ValueKey('player-panel'));
|
||||
expect(panel, findsOneWidget);
|
||||
for (final label in ['Subtitles', 'Audio', 'Size', 'Upscaling', 'Hardware decoding', 'Volume']) {
|
||||
expect(find.descendant(of: panel, matching: find.text(label)), findsWidgets, reason: label);
|
||||
}
|
||||
// Subtitles off (none selected), audio on the selected track.
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')),
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('panel-subtitles')), matching: find.text('Off')),
|
||||
findsOneWidget);
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
|
||||
expect(find.descendant(of: find.byKey(const ValueKey('panel-audio')), matching: find.textContaining('Japanese')),
|
||||
findsOneWidget);
|
||||
|
||||
await pick(tester, 'player-design', 'Minimal');
|
||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||
await tester.tap(find.byKey(const ValueKey('progress-line')));
|
||||
await tester.pump();
|
||||
expect(s.progressLine.value, isFalse);
|
||||
await pick(tester, 'shader-preset', 'Mode B');
|
||||
await pick(tester, 'panel-shader', 'Mode B');
|
||||
expect(s.shaderPreset.value, ShaderPreset.b);
|
||||
await pick(tester, 'shader-quality', 'Fast');
|
||||
expect(s.shaderQuality.value, ShaderQuality.fast);
|
||||
await pick(tester, 'hwdec', 'Off');
|
||||
await pick(tester, 'panel-hwdec', 'Off');
|
||||
expect(s.hwdec.value, 'no');
|
||||
// Esc closes it.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.tap(find.descendant(of: find.byKey(const ValueKey('panel-lift')), matching: find.byType(Switch)));
|
||||
await tester.pump();
|
||||
expect(s.liftSubtitles.value, isTrue);
|
||||
await tester.drag(find.descendant(of: find.byKey(const ValueKey('panel-volume')), matching: find.byType(Slider)),
|
||||
const Offset(-60, 0));
|
||||
expect(volume, lessThan(80));
|
||||
// Folded sections open with a click: notifications, then danmaku's knobs.
|
||||
expect(find.byKey(const ValueKey('notify-style')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('panel-section-Notifications')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(card, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('keeps the controls up while open', (tester) async {
|
||||
bigWindow(tester);
|
||||
await tester.pumpWidget(scoped(_graph, app(ControlsFrame(
|
||||
hideAfter: const Duration(seconds: 1),
|
||||
builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()),
|
||||
))));
|
||||
final controls = tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
||||
await tester.tap(find.byKey(const ValueKey('open-settings')));
|
||||
await pick(tester, 'notify-style', 'Danmaku');
|
||||
expect(s.notifyStyle.value, NotifyStyle.danmaku);
|
||||
expect(find.byKey(const ValueKey('danmaku-weight')), findsOneWidget);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('panel-section-Controls')));
|
||||
await tester.tap(find.byKey(const ValueKey('panel-section-Controls')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
|
||||
expect(find.text('Standard'), findsOneWidget, reason: 'the current skin');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(controls.visible, isTrue, reason: 'held while the card is open');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await pick(tester, 'panel-design', 'Minimal');
|
||||
expect(s.controlsSkin.value, ControlsSkin.minimal);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('player-panel-close')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(controls.visible, isFalse);
|
||||
await tester.tap(find.byKey(const ValueKey('player-panel-close')));
|
||||
expect(closed, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +163,37 @@ void main() {
|
||||
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Notifications: Toasts / Danmaku, a preview strip and every knob of the look', (tester) async {
|
||||
addTearDown(() => s.danmakuStyle.value = const DanmakuStyle());
|
||||
await pumpSettings(tester, SettingsSection.notifications, size: const Size(1280, 2600));
|
||||
final card = find.byKey(const ValueKey('danmaku-settings'));
|
||||
expect(card, findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('danmaku-preview-strip')), findsOneWidget);
|
||||
expect(s.notifyStyle.value, NotifyStyle.toasts, reason: 'toasts by default');
|
||||
await pick(tester, 'notify-style', 'Danmaku');
|
||||
expect(s.notifyStyle.value, NotifyStyle.danmaku);
|
||||
for (final k in ['cross', 'cap', 'band', 'opacity', 'size', 'weight', 'outline', 'avatars', 'own']) {
|
||||
expect(find.byKey(ValueKey('danmaku-$k')), findsOneWidget, reason: k);
|
||||
}
|
||||
// Boldness: one step right of Semibold is Bold.
|
||||
final weight = find.byKey(const ValueKey('danmaku-weight'));
|
||||
final box = tester.getRect(weight);
|
||||
await tester.tapAt(Offset(box.left + box.width * 0.6, box.center.dy));
|
||||
await tester.pump();
|
||||
expect(s.danmakuStyle.value.weight, 700);
|
||||
expect(find.text('Bold'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('danmaku-avatars')));
|
||||
await tester.pump();
|
||||
expect(s.danmakuStyle.value.avatars, isFalse);
|
||||
await tester.tap(find.byKey(const ValueKey('danmaku-reset')));
|
||||
await tester.pump();
|
||||
expect(s.danmakuStyle.value, const DanmakuStyle());
|
||||
// The strip rests after a while and plays again on a click.
|
||||
await tester.pump(const Duration(seconds: 20));
|
||||
expect(find.byKey(const ValueKey('danmaku-preview-play')), findsOneWidget);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async {
|
||||
await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400));
|
||||
expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget);
|
||||
@@ -177,6 +202,8 @@ void main() {
|
||||
'progress-line',
|
||||
'shader-preset',
|
||||
'hwdec',
|
||||
'sub-scale',
|
||||
'lift-subtitles',
|
||||
'time-display',
|
||||
'controls-size',
|
||||
'controls-hide',
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
|
||||
import 'package:starsync/src/controls/frame.dart';
|
||||
import 'package:starsync/src/danmaku/danmaku.dart';
|
||||
import 'package:starsync/src/session/navigation.dart' show Place;
|
||||
import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem;
|
||||
import 'package:starsync/src/shell/quick_chat.dart';
|
||||
import 'package:starsync/src/shell/session_player.dart';
|
||||
import 'package:starsync/src/state/settings.dart';
|
||||
import 'package:starsync/src/widgets/app_keys.dart';
|
||||
|
||||
import 'test_graph.dart';
|
||||
|
||||
/// This file's composition root (see test_graph.dart).
|
||||
final _graph = testGraph();
|
||||
|
||||
// The quick chat box over the full player: Enter opens it whatever plays,
|
||||
// keys go to it (not to the player), the cursor and the controls stay as
|
||||
// the mouse left them, a send shows your own line (echo, or danmaku), Esc
|
||||
// and the idle time close it with the draft kept, the draft is the chat
|
||||
// panel's. The picture is a black box with the session's controls on it
|
||||
// (never a media_kit `Video`).
|
||||
|
||||
void main() {
|
||||
final coord = _graph.coordinator;
|
||||
final s = coord.settings;
|
||||
|
||||
setUpAll(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
await coord.media.start();
|
||||
_graph.navigation.place.value = Place.yours;
|
||||
});
|
||||
|
||||
late TextEditingController draft;
|
||||
late QuickChat quick;
|
||||
var fullscreenToggles = 0;
|
||||
var readyToggles = 0;
|
||||
|
||||
setUp(() {
|
||||
draft = TextEditingController();
|
||||
quick = QuickChat(draft: draft);
|
||||
fullscreenToggles = 0;
|
||||
readyToggles = 0;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
s.notifyStyle.value = NotifyStyle.toasts;
|
||||
});
|
||||
|
||||
Future<void> mount(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(scoped(
|
||||
_graph,
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: AppKeys(
|
||||
onToggleReady: () => readyToggles++,
|
||||
child: Scaffold(
|
||||
body: SessionPlayer(
|
||||
mode: PlayerMode.full,
|
||||
fullscreen: false,
|
||||
onExpand: () {},
|
||||
onCollapse: () {},
|
||||
onClose: () {},
|
||||
onToggleFullscreen: () => fullscreenToggles++,
|
||||
onToggleChat: () {},
|
||||
onChangeVideo: () {},
|
||||
onOpenIntegrations: () {},
|
||||
onLeave: () {},
|
||||
windowControls: false,
|
||||
quickChat: quick,
|
||||
videoBuilder: (context, controls) => Stack(fit: StackFit.expand, children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
if (controls != null) controls(VideoState()),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> unmount(WidgetTester tester) async {
|
||||
await tester.pumpWidget(scoped(_graph, const SizedBox()));
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
quick.dispose();
|
||||
draft.dispose();
|
||||
}
|
||||
|
||||
ControlsFrameState frame(WidgetTester tester) => tester.state<ControlsFrameState>(find.byType(ControlsFrame));
|
||||
MouseCursor frameCursor(WidgetTester tester) => tester
|
||||
.widget<MouseRegion>(find.descendant(of: find.byType(ControlsFrame), matching: find.byType(MouseRegion)).first)
|
||||
.cursor;
|
||||
final field = find.byKey(const ValueKey('quick-chat-field'));
|
||||
bool fieldFocused() => quick.focus.hasFocus;
|
||||
|
||||
Future<void> hideControls(WidgetTester tester) async {
|
||||
await tester.pump(Duration(seconds: s.controlsHideSecs.value + 1));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
testWidgets('Enter opens the box; keys go to it; the cursor and controls stay hidden', (tester) async {
|
||||
await mount(tester);
|
||||
// A still mouse over the bottom centre, where the box comes up.
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.addPointer(location: const Offset(640, 690));
|
||||
addTearDown(mouse.removePointer);
|
||||
await tester.pump();
|
||||
await hideControls(tester);
|
||||
expect(frame(tester).visible, isFalse);
|
||||
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(quick.open.value, isTrue);
|
||||
expect(fieldFocused(), isTrue, reason: 'the box has the keyboard at once');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(frame(tester).visible, isFalse, reason: 'typing is not mouse activity');
|
||||
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||
final fieldWidget = tester.widget<TextField>(field);
|
||||
expect(fieldWidget.mouseCursor, SystemMouseCursors.none, reason: 'no I-beam over a still mouse');
|
||||
|
||||
// Space and F reach the box, not ready or fullscreen.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||
await tester.pump();
|
||||
expect(readyToggles, 0);
|
||||
expect(fullscreenToggles, 0);
|
||||
|
||||
// Esc closes; the draft stays; the controls do not come back.
|
||||
await tester.enterText(field, 'half a thought');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(quick.open.value, isFalse);
|
||||
expect(draft.text, 'half a thought');
|
||||
expect(frame(tester).visible, isFalse, reason: 'the box going is not mouse movement');
|
||||
expect(frameCursor(tester), SystemMouseCursors.none);
|
||||
|
||||
// The player's keys are back.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
expect(readyToggles, 1);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||
expect(fullscreenToggles, 1);
|
||||
|
||||
// Enter brings the draft back.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(quick.open.value, isTrue);
|
||||
expect(tester.widget<TextField>(field).controller!.text, 'half a thought');
|
||||
await unmount(tester);
|
||||
});
|
||||
|
||||
testWidgets('a send shows your own line: the echo with toasts, and the controls stay hidden', (tester) async {
|
||||
await mount(tester);
|
||||
await hideControls(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.enterText(field, 'hello there');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(quick.open.value, isFalse);
|
||||
expect(draft.text, isEmpty, reason: 'sent: the shared draft is cleared');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
final echo = find.byKey(const ValueKey('quick-chat-echo'));
|
||||
expect(echo, findsOneWidget);
|
||||
expect(find.descendant(of: echo, matching: find.text('hello there')), findsOneWidget);
|
||||
expect(frame(tester).visible, isFalse, reason: 'held down while the echo is in their place');
|
||||
// Your own chat toast coming back is not shown again.
|
||||
final own = ToastItem(
|
||||
id: 99, text: 'Me: hello there', at: DateTime.now(), actor: 'Me', chat: true, own: true,
|
||||
category: NoticeCategory.chat);
|
||||
expect(quick.echoed(own), isTrue);
|
||||
await tester.pump(QuickChat.echoTime);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(echo, findsNothing);
|
||||
expect(frame(tester).visible, isFalse);
|
||||
// Empty Enter just closes.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(quick.open.value, isFalse);
|
||||
expect(echo, findsNothing);
|
||||
await unmount(tester);
|
||||
});
|
||||
|
||||
testWidgets('with danmaku your own line flies instead of the echo', (tester) async {
|
||||
s.notifyStyle.value = NotifyStyle.danmaku;
|
||||
await mount(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.enterText(field, 'flying');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(find.byKey(const ValueKey('quick-chat-echo')), findsNothing);
|
||||
final line = find.descendant(of: find.byType(DanmakuLayer), matching: find.textContaining('flying', findRichText: true));
|
||||
expect(line, findsWidgets);
|
||||
await tester.pump(const Duration(seconds: 10));
|
||||
await unmount(tester);
|
||||
});
|
||||
|
||||
testWidgets('the box hides by itself after a while without typing; the draft stays', (tester) async {
|
||||
await mount(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.enterText(field, 'later');
|
||||
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
|
||||
expect(quick.open.value, isTrue);
|
||||
await tester.enterText(field, 'later, maybe');
|
||||
await tester.pump(QuickChat.idleClose - const Duration(seconds: 1));
|
||||
expect(quick.open.value, isTrue, reason: 'typing restarts the wait');
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(quick.open.value, isFalse);
|
||||
expect(draft.text, 'later, maybe');
|
||||
await unmount(tester);
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// (`AppGraph`, widgets through `AppScope`); this list may only shrink.
|
||||
/// Lower a number when a use goes; never raise one or add a class.
|
||||
const baseline = {
|
||||
'AppSettings': 49,
|
||||
'AppSettings': 47,
|
||||
'JellyfinAccounts': 10,
|
||||
'UpdateController': 8,
|
||||
'Profile': 8,
|
||||
|
||||
@@ -45,6 +45,47 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('subtitle lift steps', () {
|
||||
test('a few large eased-out steps, the last exactly on target', () {
|
||||
final up = liftSteps(0, 100);
|
||||
expect(up, hasLength(liftStepCount));
|
||||
expect(up.last, 100);
|
||||
// Eased out: the first step moves most, each one less.
|
||||
for (var i = 1; i < up.length; i++) {
|
||||
expect(up[i], greaterThan(up[i - 1]));
|
||||
if (i > 1) expect(up[i] - up[i - 1], lessThan(up[i - 1] - (i > 1 ? up[i - 2] : 0)));
|
||||
}
|
||||
expect(up.first, greaterThan(30), reason: 'most of the way at once');
|
||||
final down = liftSteps(100, 0);
|
||||
expect(down.last, 0);
|
||||
expect(down.first, lessThan(70));
|
||||
expect(liftSteps(40, 40.2), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('SubtitleLift steps toward the latest target, liftStepGap apart', (tester) async {
|
||||
final lift = SubtitleLift();
|
||||
addTearDown(lift.dispose);
|
||||
final seen = <double>[];
|
||||
lift.addListener(() => seen.add(lift.value));
|
||||
lift.animateTo(80);
|
||||
expect(seen, hasLength(1), reason: 'the first step at once');
|
||||
await tester.pump(liftStepGap * (liftStepCount - 1));
|
||||
expect(seen, liftSteps(0, 80));
|
||||
expect(lift.value, 80);
|
||||
await tester.pump(liftStepGap * 4);
|
||||
expect(seen, hasLength(liftStepCount), reason: 'nothing after the target');
|
||||
// A new target mid-way starts from where it is.
|
||||
seen.clear();
|
||||
lift.animateTo(0);
|
||||
await tester.pump(liftStepGap);
|
||||
final from = lift.value, n = seen.length;
|
||||
expect(from, inExclusiveRange(0, 80));
|
||||
lift.animateTo(80);
|
||||
await tester.pump(liftStepGap * liftStepCount);
|
||||
expect(seen.sublist(n), liftSteps(from, 80));
|
||||
});
|
||||
});
|
||||
|
||||
test('dev tools: dev versions and debug runs only', () {
|
||||
expect(devToolsFor(version: '1.3.3-dev.2', debug: false), isTrue);
|
||||
expect(devToolsFor(version: '0.0.0-dev', debug: false), isTrue);
|
||||
|
||||
Reference in new issue
Block a user