262 lines
8.1 KiB
Dart
262 lines
8.1 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:fake_async/fake_async.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/services/live_seek_accumulator.dart';
|
|
|
|
void main() {
|
|
group('LiveSeekAccumulator', () {
|
|
late List<int> seeks; // recorded re-open targets
|
|
late int currentEpoch; // mutable "live" epoch (streamStart + position)
|
|
late int positionSeconds; // mutable player position, drives settle
|
|
late LiveSeekBounds? window; // mutable seekable window
|
|
late int changes; // onChanged call count
|
|
late bool seekThrows; // make the seek re-open fail
|
|
Completer<void>? gate; // optionally stalls a seek mid-flight
|
|
|
|
LiveSeekAccumulator build() => LiveSeekAccumulator(
|
|
seek: (target) async {
|
|
seeks.add(target);
|
|
if (gate != null) await gate!.future;
|
|
if (seekThrows) throw Exception('seek failed');
|
|
},
|
|
currentEpoch: () => currentEpoch,
|
|
positionSeconds: () => positionSeconds,
|
|
bounds: () => window,
|
|
onChanged: () => changes++,
|
|
debounce: const Duration(milliseconds: 300),
|
|
settleCeiling: const Duration(milliseconds: 1500),
|
|
settlePoll: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
setUp(() {
|
|
seeks = [];
|
|
currentEpoch = 1000;
|
|
positionSeconds = 0; // re-opened stream settles immediately by default
|
|
window = (start: 0, end: 1000000);
|
|
changes = 0;
|
|
seekThrows = false;
|
|
gate = null;
|
|
});
|
|
|
|
test('coalesces a rapid burst into a single seek at the summed target', () {
|
|
fakeAsync((async) {
|
|
final acc = build();
|
|
for (var i = 0; i < 14; i++) {
|
|
acc.seekBy(15);
|
|
}
|
|
// Nothing fires while the burst is still arriving.
|
|
expect(seeks, isEmpty);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
// 14 presses of 15s from epoch 1000 => one re-open at 1000 + 210.
|
|
expect(seeks, [1210]);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('accumulates off the pending target, not the laggy live epoch', () {
|
|
fakeAsync((async) {
|
|
final acc = build();
|
|
acc.seekBy(15); // base 1000 -> 1015
|
|
expect(acc.pendingEpoch, 1015);
|
|
|
|
// Simulate the post-reopen overshoot: the raw live epoch jumps wildly.
|
|
// The next press must still compound off the pending target.
|
|
currentEpoch = 99999;
|
|
acc.seekBy(15); // 1015 -> 1030, NOT 99999 + 15
|
|
expect(acc.pendingEpoch, 1030);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(seeks, [1030]);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('clamps the accumulated target to the live edge', () {
|
|
fakeAsync((async) {
|
|
window = (start: 950, end: 1050);
|
|
final acc = build();
|
|
acc.seekBy(100); // 1000 -> 1100, clamped to 1050
|
|
expect(acc.pendingEpoch, 1050);
|
|
acc.seekBy(100); // stays at the edge
|
|
expect(acc.pendingEpoch, 1050);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(seeks, [1050]);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('clamps backward skips to the window start', () {
|
|
fakeAsync((async) {
|
|
window = (start: 950, end: 1050);
|
|
final acc = build();
|
|
acc.seekBy(-100); // 1000 -> 900, clamped to 950
|
|
expect(acc.pendingEpoch, 950);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('does not reopen when a skip is clamped to the current boundary', () {
|
|
fakeAsync((async) {
|
|
window = (start: 950, end: 1050);
|
|
final acc = build();
|
|
|
|
currentEpoch = 1050;
|
|
acc.seekBy(15);
|
|
expect(acc.pendingEpoch, isNull);
|
|
|
|
currentEpoch = 950;
|
|
acc.seekBy(-15);
|
|
expect(acc.pendingEpoch, isNull);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(seeks, isEmpty);
|
|
expect(changes, 0);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('still seeks away from a capture-buffer boundary', () {
|
|
fakeAsync((async) {
|
|
window = (start: 950, end: 1050);
|
|
currentEpoch = 1050;
|
|
final acc = build();
|
|
|
|
acc.seekBy(-15);
|
|
expect(acc.pendingEpoch, 1035);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(seeks, [1035]);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('flushes the newer target when a press lands during the seek', () {
|
|
fakeAsync((async) {
|
|
gate = Completer<void>();
|
|
final acc = build();
|
|
acc.seekBy(15); // pending 1015
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(seeks, [1015]); // first seek in flight, awaiting the gate
|
|
|
|
acc.seekBy(15); // pending 1030 while the first seek is still open
|
|
gate!.complete(); // first seek resolves
|
|
gate = null; // later seeks resolve immediately
|
|
async.flushMicrotasks();
|
|
|
|
// The re-entrant flush picks up the newer target — no waiting for a
|
|
// second debounce, no lost press.
|
|
expect(seeks, [1015, 1030]);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('unpins the pending target once the re-opened stream settles', () {
|
|
fakeAsync((async) {
|
|
positionSeconds = 0; // settled
|
|
final acc = build();
|
|
acc.seekBy(15);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(acc.pendingEpoch, 1015); // still pinned right after the re-open
|
|
|
|
async.elapse(const Duration(milliseconds: 100)); // settle poll
|
|
expect(acc.pendingEpoch, isNull);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('unpins via the ceiling if the position never settles', () {
|
|
fakeAsync((async) {
|
|
positionSeconds = 100; // never below the settle threshold
|
|
final acc = build();
|
|
acc.seekBy(15);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(acc.pendingEpoch, 1015);
|
|
|
|
async.elapse(const Duration(milliseconds: 1500)); // ceiling
|
|
expect(acc.pendingEpoch, isNull);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('a fresh burst after settling re-seeds off the live epoch', () {
|
|
fakeAsync((async) {
|
|
final acc = build();
|
|
acc.seekBy(15); // 1000 -> 1015
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
async.elapse(const Duration(milliseconds: 100)); // settle clears pending
|
|
expect(acc.pendingEpoch, isNull);
|
|
|
|
// New stream origin: raw epoch now reflects the previous target.
|
|
currentEpoch = 1015;
|
|
acc.seekBy(15); // base 1015 -> 1030
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
|
|
expect(seeks, [1015, 1030]);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('releases the pending pin when the re-open fails', () {
|
|
fakeAsync((async) {
|
|
seekThrows = true;
|
|
final acc = build();
|
|
acc.seekBy(15);
|
|
expect(acc.pendingEpoch, 1015);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(seeks, [1015]); // the re-open was attempted
|
|
expect(acc.pendingEpoch, isNull); // pin released despite the failure
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('cancel drops the pending target and prevents the debounced seek', () {
|
|
fakeAsync((async) {
|
|
final acc = build();
|
|
acc.seekBy(15);
|
|
expect(acc.pendingEpoch, 1015);
|
|
|
|
acc.cancel();
|
|
expect(acc.pendingEpoch, isNull);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(seeks, isEmpty);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('is a no-op when there is no seekable window', () {
|
|
fakeAsync((async) {
|
|
window = null;
|
|
final acc = build();
|
|
acc.seekBy(15);
|
|
expect(acc.pendingEpoch, isNull);
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
expect(seeks, isEmpty);
|
|
acc.dispose();
|
|
});
|
|
});
|
|
|
|
test('notifies onChanged when the target changes and when it clears', () {
|
|
fakeAsync((async) {
|
|
positionSeconds = 0;
|
|
final acc = build();
|
|
acc.seekBy(15);
|
|
expect(changes, 1); // accumulate
|
|
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
async.elapse(const Duration(milliseconds: 100)); // settle clears
|
|
expect(changes, 2); // clear
|
|
acc.dispose();
|
|
});
|
|
});
|
|
});
|
|
}
|