import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nuvolari/core/region/region_config.dart'; import 'package:nuvolari/core/region/region_repository.dart'; import 'package:nuvolari/data/radar/radar_manifest.dart'; import 'package:nuvolari/data/radar/radar_source.dart'; import 'package:nuvolari/features/timeline/radar_timeline.dart'; /// A radar source with no assets and no network, so timeline behaviour can be /// driven precisely: which frames exist, which of them fail, and how slowly /// each one arrives. class FakeRadarSource implements RadarSource { FakeRadarSource({ this.frameCount = 12, this.failingPaths = const {}, this.manifestFails = false, this.frameDelay = Duration.zero, }); final int frameCount; final Set failingPaths; final bool manifestFails; final Duration frameDelay; final List requestedPaths = []; int manifestCalls = 0; static const int baseTimestamp = 1768478400000; RadarManifest _build() { final frames = >[ for (var i = 0; i < frameCount; i++) { 'ts': baseTimestamp + i * 5 * 60 * 1000, 'url': 'frames/$i.png', }, ]; return RadarManifest.parse( jsonEncode({ 'region': 'piemonte', 'product': 'VMI', 'generatedAt': baseTimestamp + (frameCount - 1) * 5 * 60 * 1000, 'bbox': [6.55, 43.95, 9.30, 46.55], 'crs': 'EPSG:3857', 'frames': frames, 'legend': { 'unit': 'dBZ', 'stops': [ {'value': 5, 'color': '#4FA3D1'}, ], }, 'attribution': 'Test data', }), ); } @override Future getLatestManifest() async { manifestCalls++; if (manifestFails) { throw const RadarUnavailableException('fake manifest failure'); } return _build(); } @override Future> getFrames() async => (await getLatestManifest()).frames; @override Future loadFrameBytes(RadarFrame frame) async { requestedPaths.add(frame.path); if (frameDelay > Duration.zero) { await Future.delayed(frameDelay); } if (failingPaths.contains(frame.path)) { throw RadarUnavailableException('fake failure for ${frame.path}'); } return Uint8List.fromList(List.filled(64, 1)); } } RegionConfig loadPiemonte() => RegionConfig.parse(File('assets/regions/piemonte.json').readAsStringSync()); ProviderContainer containerWith(RadarSource source, RegionConfig region) { final container = ProviderContainer( overrides: [ regionConfigProvider.overrideWith((ref) async => region), radarSourceProvider.overrideWith((ref) async => source), ], ); addTearDown(container.dispose); return container; } /// Waits until the timeline has finished its initial load. Future settled(ProviderContainer container) async { for (var i = 0; i < 200; i++) { final state = container.read(radarTimelineProvider); if (!state.isLoading) return state; await Future.delayed(const Duration(milliseconds: 5)); } fail('timeline never finished loading'); } void main() { late RegionConfig region; setUpAll(() { region = loadPiemonte(); }); group('initial load', () { test('loads the manifest and lands on the newest frame', () async { final source = FakeRadarSource(frameCount: 12); final container = containerWith(source, region); final state = await settled(container); expect(state.manifest, isNotNull); expect(state.frameCount, 12); // Someone opening the app wants to know what the sky is doing now. expect(state.index, 11); expect(state.isAtNewestFrame, isTrue); expect(state.error, isNull); }); test('prefetches the frames around the playhead', () async { final source = FakeRadarSource(frameCount: 12); final container = containerWith(source, region); await settled(container); await Future.delayed(const Duration(milliseconds: 50)); // Playhead at 11 with a radius of 4: frames 7..11, and nothing beyond. expect(source.requestedPaths, contains('frames/11.png')); expect(source.requestedPaths, contains('frames/7.png')); expect(source.requestedPaths, isNot(contains('frames/6.png'))); }); test('an empty manifest is not an error', () async { final source = FakeRadarSource(frameCount: 0); final container = containerWith(source, region); final state = await settled(container); expect(state.hasFrames, isFalse); expect(state.error, isNull); expect(state.currentFrame, isNull); }); }); group('degradation', () { test('a failed manifest load surfaces as an error', () async { final source = FakeRadarSource(manifestFails: true); final container = containerWith(source, region); final state = await settled(container); expect(state.error, isA()); expect(state.manifest, isNull); }); // The whole point of holding the last good manifest: stale frames with // their age stated beat an empty map. test('a later failure keeps the manifest already on screen', () async { final source = FakeRadarSource(frameCount: 6); final container = containerWith(source, region); await settled(container); final failing = FakeRadarSource(manifestFails: true); container.updateOverrides([ regionConfigProvider.overrideWith((ref) async => region), radarSourceProvider.overrideWith((ref) async => failing), ]); // The notifier memoises its source, so drive the failure through the // instance it already holds by refreshing after the override. final timeline = container.read(radarTimelineProvider.notifier); await timeline.refresh(); final state = container.read(radarTimelineProvider); expect(state.manifest, isNotNull, reason: 'stale data must be kept'); expect(state.frameCount, 6); }); // A gap mid-timeline is normal while the worker publishes. It must not // abort the surrounding prefetch. test('one missing frame does not stop the others loading', () async { final source = FakeRadarSource( frameCount: 8, failingPaths: const {'frames/5.png'}, ); final container = containerWith(source, region); await settled(container); await Future.delayed(const Duration(milliseconds: 50)); final timeline = container.read(radarTimelineProvider.notifier); expect(timeline.cache.contains('frames/5.png'), isFalse); expect(timeline.cache.contains('frames/7.png'), isTrue); expect(timeline.cache.contains('frames/4.png'), isTrue); }); }); group('playback', () { test('play advances the playhead and wraps around', () async { final source = FakeRadarSource(frameCount: 3); final container = containerWith(source, region); await settled(container); final timeline = container.read(radarTimelineProvider.notifier); expect(container.read(radarTimelineProvider).index, 2); timeline.play(); expect(container.read(radarTimelineProvider).isPlaying, isTrue); // Index 2 is the newest frame, so the longer dwell applies first. await Future.delayed( RadarTimeline.lastFrameDwell + const Duration(milliseconds: 150), ); expect(container.read(radarTimelineProvider).index, 0); await Future.delayed( RadarTimeline.framePeriod + const Duration(milliseconds: 150), ); expect(container.read(radarTimelineProvider).index, 1); timeline.pause(); }); test('pause stops the playhead', () async { final source = FakeRadarSource(frameCount: 6); final container = containerWith(source, region); await settled(container); final timeline = container.read(radarTimelineProvider.notifier); timeline.play(); timeline.pause(); final frozen = container.read(radarTimelineProvider).index; await Future.delayed( RadarTimeline.lastFrameDwell + const Duration(milliseconds: 200), ); expect(container.read(radarTimelineProvider).isPlaying, isFalse); expect(container.read(radarTimelineProvider).index, frozen); }); test('play does nothing when there are no frames', () async { final source = FakeRadarSource(frameCount: 0); final container = containerWith(source, region); await settled(container); container.read(radarTimelineProvider.notifier).play(); expect(container.read(radarTimelineProvider).isPlaying, isFalse); }); // Leaving a timer running and pushing textures behind another app is pure // battery drain with nobody watching. test('backgrounding the app pauses playback', () async { final source = FakeRadarSource(frameCount: 6); final container = containerWith(source, region); await settled(container); final timeline = container.read(radarTimelineProvider.notifier); timeline.play(); expect(container.read(radarTimelineProvider).isPlaying, isTrue); timeline.onAppBackgrounded(); expect(container.read(radarTimelineProvider).isPlaying, isFalse); }); }); group('scrubbing', () { test('seek moves the playhead and stops playback', () async { final source = FakeRadarSource(frameCount: 10); final container = containerWith(source, region); await settled(container); final timeline = container.read(radarTimelineProvider.notifier); timeline.play(); timeline.seek(3); final state = container.read(radarTimelineProvider); expect(state.index, 3); expect(state.isPlaying, isFalse, reason: 'a scrub takes over the frame'); }); test('seek clamps to the available range', () async { final source = FakeRadarSource(frameCount: 5); final container = containerWith(source, region); await settled(container); final timeline = container.read(radarTimelineProvider.notifier); timeline.seek(999); expect(container.read(radarTimelineProvider).index, 4); timeline.seek(-999); expect(container.read(radarTimelineProvider).index, 0); }); test('step moves one frame at a time', () async { final source = FakeRadarSource(frameCount: 10); final container = containerWith(source, region); await settled(container); final timeline = container.read(radarTimelineProvider.notifier); timeline.seek(5); timeline.step(1); expect(container.read(radarTimelineProvider).index, 6); timeline.step(-2); expect(container.read(radarTimelineProvider).index, 4); }); // Scrubbing across a long timeline would otherwise accumulate every frame // it passed over. test('the cache holds only the window around the playhead', () async { final source = FakeRadarSource(frameCount: 20); final container = containerWith(source, region); await settled(container); await Future.delayed(const Duration(milliseconds: 60)); final timeline = container.read(radarTimelineProvider.notifier); timeline.seek(2); await Future.delayed(const Duration(milliseconds: 60)); // Radius 4 around index 2 means frames 0..6 at most. expect(timeline.cache.length, lessThanOrEqualTo(7)); expect(timeline.cache.contains('frames/19.png'), isFalse); expect(timeline.cache.contains('frames/2.png'), isTrue); }); }); group('createRadarSource', () { test('uses the region default when nothing overrides it', () { // The test binary is built with no --dart-define, so Env.radarSource is // empty and the region's own default applies. expect(createRadarSource(region), isNotNull); }); }); }