Files
SpotiFLAC-Mobile/test/download_queue_sparse_update_test.dart
T

216 lines
7.6 KiB
Dart

import 'dart:collection';
import 'dart:math';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_state.dart';
import 'package:spotiflac_android/utils/chunked_list.dart';
import 'package:flutter_test/flutter_test.dart';
class _CountingList<T> extends ListBase<T> {
_CountingList(this.source);
final List<T> source;
int reads = 0;
@override
int get length => source.length;
@override
set length(int value) => throw UnsupportedError('read only');
@override
T operator [](int index) {
reads++;
return source[index];
}
@override
void operator []=(int index, T value) => throw UnsupportedError('read only');
}
DownloadItem _item(int index) => DownloadItem(
id: 'item-$index',
track: Track(
id: 'track-${index ~/ 2}',
name: 'Song',
artistName: 'Artist',
albumName: 'Album',
duration: 1000,
),
service: 'extension.test',
createdAt: DateTime.utc(2026),
status: DownloadStatus.downloading,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'sparse updates preserve snapshots and do not revisit the source list',
() {
final source = _CountingList(List.generate(10000, (i) => i));
final initial = ChunkedList<int>.from(source);
source.reads = 0;
var current = initial;
final expected = List<int>.of(initial);
final random = Random(42);
for (var iteration = 0; iteration < 1000; iteration++) {
final index = random.nextInt(initial.length);
final previous = current;
final oldValue = previous[index];
current = current.updated({index: -iteration - 1});
expected[index] = -iteration - 1;
expect(previous[index], oldValue);
}
expect(current, expected);
expect(initial, List.generate(10000, (i) => i));
expect(source.reads, 0);
expect(() => current[0] = 1, throwsUnsupportedError);
expect(() => current.updated({10000: 1}), throwsRangeError);
},
);
test(
'queue indexes match a full rebuild through sparse status transitions',
() {
var state = const DownloadQueueState().copyWith(
items: List.generate(130, _item),
);
final original = state;
final random = Random(17);
for (var tick = 0; tick < 150; tick++) {
final index = random.nextInt(state.items.length);
final old = state;
final next = ChunkedList<DownloadItem>.from(state.items).updated({
index: state.items[index].copyWith(
progress: tick / 150,
status: DownloadStatus.values[tick % DownloadStatus.values.length],
),
});
state = state.copyWith(
items: next,
lookup: state.lookup.updatedForIndices(
previousItems: state.items,
nextItems: next,
changedIndices: [index, index],
),
);
final rebuilt = DownloadQueueLookup.fromItems(next);
expect(state.lookup.byItemId, rebuilt.byItemId);
expect(state.lookup.byTrackId, rebuilt.byTrackId);
expect(state.lookup.queuedCount, rebuilt.queuedCount);
expect(state.lookup.completedCount, rebuilt.completedCount);
expect(state.lookup.failedCount, rebuilt.failedCount);
expect(state.lookup.activeDownloadsCount, rebuilt.activeDownloadsCount);
expect(state.lookup.finalizingCount, rebuilt.finalizingCount);
expect(state.lookup.firstDownloading, same(rebuilt.firstDownloading));
expect(state.lookup.firstFinalizing, same(rebuilt.firstFinalizing));
expect(state.lookup.notCompletedItemIds, rebuilt.notCompletedItemIds);
expect(
old.lookup.byItemId[old.items[index].id],
same(old.items[index]),
);
}
expect(original.items.every((item) => item.progress == 0), isTrue);
expect(
original.lookup.byItemId.values.every((item) => item.progress == 0),
isTrue,
);
expect(() => state.lookup.byItemId.clear(), throwsUnsupportedError);
},
);
test(
'notification candidates follow batch transitions and keep old values',
() {
void check(DownloadQueueState state) {
expect(
state.lookup.firstDownloading,
same(
state.items
.where((item) => item.status == DownloadStatus.downloading)
.firstOrNull,
),
);
expect(
state.lookup.firstFinalizing,
same(
state.items
.where((item) => item.status == DownloadStatus.finalizing)
.firstOrNull,
),
);
}
var state = const DownloadQueueState().copyWith(
items: List.generate(200, _item),
);
void update(Map<int, DownloadItem> changes) {
final old = state;
final next = ChunkedList<DownloadItem>.from(old.items).updated(changes);
state = old.copyWith(
items: next,
lookup: old.lookup.updatedForIndices(
previousItems: old.items,
nextItems: next,
changedIndices: changes.keys.toList().reversed,
),
);
check(state);
check(old);
}
final random = Random(506);
for (var tick = 0; tick < 300; tick++) {
update({
for (var i = 0; i < 7; i++)
random.nextInt(200): state.items[random.nextInt(200)],
});
// Above exercises identity/reordering fallback. Keep IDs stable here to
// exercise the incremental path with multiple simultaneous transitions.
final changes = <int, DownloadItem>{};
for (var i = 0; i < 7; i++) {
final index = random.nextInt(200);
changes[index] = state.items[index].copyWith(
status: DownloadStatus
.values[random.nextInt(DownloadStatus.values.length)],
progress: tick / 300,
track: state.items[index].track.copyWith(name: 'Updated $tick'),
);
}
// Restore unique request IDs after the intentionally malformed batch.
state = state.copyWith(
items: [
for (var i = 0; i < state.items.length; i++)
state.items[i].copyWith(id: 'item-$i'),
],
);
update({
for (final entry in changes.entries)
entry.key: entry.value.copyWith(id: 'item-${entry.key}'),
});
}
update({
for (var i = 0; i < state.items.length; i++)
i: state.items[i].copyWith(status: DownloadStatus.completed),
});
expect(state.lookup.firstDownloading, isNull);
expect(state.lookup.firstFinalizing, isNull);
update({
190: state.items[190].copyWith(status: DownloadStatus.downloading),
195: state.items[195].copyWith(status: DownloadStatus.finalizing),
});
update({
195: state.items[195].copyWith(status: DownloadStatus.downloading),
190: state.items[190].copyWith(status: DownloadStatus.finalizing),
1: state.items[1].copyWith(status: DownloadStatus.downloading),
0: state.items[0].copyWith(status: DownloadStatus.finalizing),
});
expect(state.lookup.firstDownloading, same(state.items[1]));
expect(state.lookup.firstFinalizing, same(state.items[0]));
update({
0: state.items[0].copyWith(status: DownloadStatus.completed),
1: state.items[1].copyWith(status: DownloadStatus.completed),
});
expect(state.lookup.firstDownloading, same(state.items[195]));
expect(state.lookup.firstFinalizing, same(state.items[190]));
check(const DownloadQueueState());
},
);
}