240 lines
9.2 KiB
Dart
240 lines
9.2 KiB
Dart
import 'dart:ui' show SemanticsAction, Tristate;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:intl/date_symbol_data_local.dart';
|
|
import 'package:plezy/i18n/strings.g.dart';
|
|
import 'package:plezy/models/livetv_capture_buffer.dart';
|
|
import 'package:plezy/utils/formatters.dart';
|
|
import 'package:plezy/widgets/video_controls/widgets/live_timeline_bar.dart';
|
|
|
|
import '../test_helpers/watch_together_fakes.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUpAll(() async {
|
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
|
await initializeDateFormatting('en');
|
|
});
|
|
|
|
group('LiveTimelineBar semantics', () {
|
|
testWidgets('exposes one named adjustable node without a duplicate timestamp', (tester) async {
|
|
final semantics = tester.ensureSemantics();
|
|
for (final horizontalLayout in [true, false]) {
|
|
final seeks = <int>[];
|
|
final harness = await _pumpTimeline(
|
|
tester,
|
|
seeks: seeks,
|
|
currentOffset: 60,
|
|
horizontalLayout: horizontalLayout,
|
|
);
|
|
|
|
final timeline = find.bySemanticsLabel(t.videoControls.timelineSlider);
|
|
expect(timeline, findsOneWidget, reason: 'horizontalLayout=$horizontalLayout');
|
|
|
|
final node = tester.getSemantics(timeline);
|
|
final data = node.getSemanticsData();
|
|
expect(data.label, t.videoControls.timelineSlider);
|
|
expect(data.flagsCollection.isSlider, isTrue);
|
|
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
|
|
expect(data.value, _clock(harness.startEpoch + 60));
|
|
expect(data.increasedValue, _clock(harness.startEpoch + 70));
|
|
expect(data.decreasedValue, _clock(harness.startEpoch + 50));
|
|
expect(data.hasAction(SemanticsAction.increase), isTrue);
|
|
expect(data.hasAction(SemanticsAction.decrease), isTrue);
|
|
expect(data.hasAction(SemanticsAction.scrollLeft), isFalse);
|
|
expect(data.hasAction(SemanticsAction.scrollRight), isFalse);
|
|
|
|
expect(
|
|
find.bySemanticsLabel(_clock(harness.startEpoch + 60)),
|
|
findsNothing,
|
|
reason: 'the visual timestamp must not be announced separately when horizontalLayout=$horizontalLayout',
|
|
);
|
|
}
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('increase and decrease each emit one bounded absolute seek', (tester) async {
|
|
final semantics = tester.ensureSemantics();
|
|
final seeks = <int>[];
|
|
final harness = await _pumpTimeline(tester, seeks: seeks, currentOffset: 60);
|
|
final node = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider));
|
|
|
|
node.owner!.performAction(node.id, SemanticsAction.increase);
|
|
expect(seeks, [harness.startEpoch + 70]);
|
|
|
|
node.owner!.performAction(node.id, SemanticsAction.decrease);
|
|
expect(seeks, [harness.startEpoch + 70, harness.startEpoch + 50]);
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('short live window clamps actions and omits boundary no-ops', (tester) async {
|
|
final semantics = tester.ensureSemantics();
|
|
final seeks = <int>[];
|
|
final startHarness = await _pumpTimeline(
|
|
tester,
|
|
seeks: seeks,
|
|
currentOffset: 0,
|
|
rangeEndOffset: 6,
|
|
isAtLiveEdge: false,
|
|
);
|
|
|
|
var node = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider));
|
|
var data = node.getSemanticsData();
|
|
expect(data.hasAction(SemanticsAction.decrease), isFalse);
|
|
expect(data.decreasedValue, isEmpty);
|
|
expect(data.hasAction(SemanticsAction.increase), isTrue);
|
|
expect(data.increasedValue, t.liveTv.live);
|
|
|
|
node.owner!.performAction(node.id, SemanticsAction.increase);
|
|
expect(seeks, [startHarness.startEpoch + 6]);
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await _pumpTimeline(tester, seeks: seeks, currentOffset: 6, rangeEndOffset: 6, isAtLiveEdge: true);
|
|
node = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider));
|
|
data = node.getSemanticsData();
|
|
expect(data.value, t.liveTv.live);
|
|
expect(data.hasAction(SemanticsAction.increase), isFalse);
|
|
expect(data.increasedValue, isEmpty);
|
|
expect(data.hasAction(SemanticsAction.decrease), isTrue);
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('supplied live-edge policy announces LIVE before the exact range end', (tester) async {
|
|
final semantics = tester.ensureSemantics();
|
|
await _pumpTimeline(tester, seeks: <int>[], currentOffset: 116, rangeEndOffset: 120, isAtLiveEdge: true);
|
|
|
|
final data = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider)).getSemanticsData();
|
|
expect(data.value, t.liveTv.live);
|
|
expect(data.hasAction(SemanticsAction.increase), isTrue);
|
|
expect(data.increasedValue, t.liveTv.live);
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('disabled, callback-less, and invalid timelines expose no adjustments', (tester) async {
|
|
final semantics = tester.ensureSemantics();
|
|
|
|
for (final scenario in <({bool enabled, bool provideCallback, int rangeEnd})>[
|
|
(enabled: false, provideCallback: true, rangeEnd: 120),
|
|
(enabled: true, provideCallback: false, rangeEnd: 120),
|
|
(enabled: true, provideCallback: true, rangeEnd: 0),
|
|
]) {
|
|
final seeks = <int>[];
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
await _pumpTimeline(
|
|
tester,
|
|
seeks: seeks,
|
|
currentOffset: 0,
|
|
rangeEndOffset: scenario.rangeEnd,
|
|
enabled: scenario.enabled,
|
|
provideSeekCallback: scenario.provideCallback,
|
|
isAtLiveEdge: false,
|
|
);
|
|
|
|
final data = tester.getSemantics(find.bySemanticsLabel(t.videoControls.timelineSlider)).getSemanticsData();
|
|
expect(data.flagsCollection.isEnabled, Tristate.isFalse, reason: '$scenario');
|
|
expect(data.hasAction(SemanticsAction.increase), isFalse, reason: '$scenario');
|
|
expect(data.hasAction(SemanticsAction.decrease), isFalse, reason: '$scenario');
|
|
expect(data.increasedValue, isEmpty, reason: '$scenario');
|
|
expect(data.decreasedValue, isEmpty, reason: '$scenario');
|
|
expect(seeks, isEmpty, reason: '$scenario');
|
|
}
|
|
semantics.dispose();
|
|
});
|
|
});
|
|
|
|
testWidgets('pointer seek and desktop key routing remain intact', (tester) async {
|
|
final seeks = <int>[];
|
|
final focusNode = FocusNode();
|
|
addTearDown(focusNode.dispose);
|
|
var keyEvents = 0;
|
|
final harness = await _pumpTimeline(
|
|
tester,
|
|
seeks: seeks,
|
|
currentOffset: 60,
|
|
focusNode: focusNode,
|
|
onKeyEvent: (_, event) {
|
|
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
|
keyEvents++;
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
);
|
|
|
|
final paint = find.descendant(of: find.byType(LiveTimelineBar), matching: find.byType(CustomPaint));
|
|
final topLeft = tester.getTopLeft(paint);
|
|
final size = tester.getSize(paint);
|
|
final gesture = await tester.startGesture(Offset(topLeft.dx + size.width * 0.75, topLeft.dy + size.height / 2));
|
|
await tester.pump();
|
|
await gesture.up();
|
|
await tester.pump();
|
|
expect(seeks, [harness.startEpoch + 90]);
|
|
|
|
focusNode.requestFocus();
|
|
await tester.pump();
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pump();
|
|
expect(keyEvents, 1);
|
|
expect(seeks, [harness.startEpoch + 90]);
|
|
});
|
|
}
|
|
|
|
String _clock(int epochSeconds) {
|
|
return formatClockTime(DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000), is24Hour: true);
|
|
}
|
|
|
|
Future<({int startEpoch, FakeSyncPlayer player})> _pumpTimeline(
|
|
WidgetTester tester, {
|
|
required List<int> seeks,
|
|
required int currentOffset,
|
|
int rangeEndOffset = 120,
|
|
bool isAtLiveEdge = false,
|
|
bool enabled = true,
|
|
bool provideSeekCallback = true,
|
|
bool horizontalLayout = true,
|
|
FocusNode? focusNode,
|
|
KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent,
|
|
}) async {
|
|
final startEpoch = DateTime(2026, 1, 1, 12).millisecondsSinceEpoch ~/ 1000;
|
|
final player = FakeSyncPlayer(position: Duration(seconds: currentOffset));
|
|
addTearDown(player.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
TranslationProvider(
|
|
child: MaterialApp(
|
|
home: MediaQuery(
|
|
data: const MediaQueryData(alwaysUse24HourFormat: true),
|
|
child: Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Center(
|
|
child: SizedBox(
|
|
width: 400,
|
|
child: LiveTimelineBar(
|
|
player: player,
|
|
captureBuffer: CaptureBuffer(
|
|
startedAt: startEpoch.toDouble(),
|
|
seekStartSeconds: 0,
|
|
seekEndSeconds: rangeEndOffset.toDouble(),
|
|
),
|
|
streamStartEpoch: startEpoch.toDouble(),
|
|
isAtLiveEdge: isAtLiveEdge,
|
|
onSeekEnd: provideSeekCallback ? seeks.add : null,
|
|
focusNode: focusNode,
|
|
onKeyEvent: onKeyEvent,
|
|
horizontalLayout: horizontalLayout,
|
|
enabled: enabled,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
return (startEpoch: startEpoch, player: player);
|
|
}
|