import 'dart:convert'; import 'dart:ui' show Color; import 'package:flutter_test/flutter_test.dart'; import 'package:nuvolari/data/radar/radar_manifest.dart'; Map validManifest() => { 'region': 'piemonte', 'product': 'VMI', 'generatedAt': 1768478400000, 'bbox': [6.55, 43.95, 9.30, 46.55], 'crs': 'EPSG:3857', 'frames': [ {'ts': 1768478400000, 'url': 'frames/a.png'}, {'ts': 1768478700000, 'url': 'frames/b.png'}, ], 'legend': { 'unit': 'dBZ', 'stops': [ {'value': 5, 'color': '#4FA3D1'}, {'value': 30, 'color': '#E9D22B'}, ], }, 'attribution': 'Radar-DPC — CC BY-SA', }; RadarManifest parseWith(void Function(Map doc) mutate) { final doc = validManifest(); mutate(doc); return RadarManifest.fromJson(doc); } void main() { group('RadarManifest.fromJson', () { test('parses a complete manifest', () { final manifest = RadarManifest.fromJson(validManifest()); expect(manifest.regionId, 'piemonte'); expect(manifest.product, 'VMI'); expect(manifest.bounds.west, 6.55); expect(manifest.frames, hasLength(2)); expect(manifest.legend.unit, 'dBZ'); expect(manifest.attribution, contains('Radar-DPC')); expect(manifest.generatedAt.isUtc, isTrue); }); test('frame timestamps are UTC', () { final manifest = RadarManifest.fromJson(validManifest()); expect(manifest.frames.first.timestamp.isUtc, isTrue); expect( manifest.frames.first.timestamp.millisecondsSinceEpoch, 1768478400000, ); }); test('sorts frames by timestamp regardless of publication order', () { final manifest = parseWith((doc) { doc['frames'] = [ {'ts': 1768478700000, 'url': 'frames/b.png'}, {'ts': 1768478400000, 'url': 'frames/a.png'}, ]; }); expect( manifest.frames.map((frame) => frame.path), orderedEquals(['frames/a.png', 'frames/b.png']), ); }); // The map overlays each PNG on a lat/lng quad, which only lines up if the // image is already in Web Mercator. A different CRS renders visibly skewed // with no error, so it has to be caught here. test('rejects a manifest that is not in EPSG:3857', () { expect( () => parseWith((doc) => doc['crs'] = 'EPSG:4326'), throwsA( isA().having( (e) => e.message, 'message', contains('EPSG:3857'), ), ), ); }); test('rejects a manifest with no CRS at all', () { expect( () => parseWith((doc) => doc.remove('crs')), throwsFormatException, ); }); // The frames are a derived product of CC BY-SA data. Publishing them // without the credit travelling alongside is a licence breach, so a // manifest that omits it must not load. test('rejects a manifest with no attribution', () { expect( () => parseWith((doc) => doc.remove('attribution')), throwsFormatException, ); expect( () => parseWith((doc) => doc['attribution'] = ''), throwsFormatException, ); }); test('rejects a non-integer timestamp', () { expect( () => parseWith((doc) { doc['frames'] = [ {'ts': '1768478400000', 'url': 'frames/a.png'}, ]; }), throwsFormatException, ); }); test('accepts an empty frame list', () { final manifest = parseWith((doc) => doc['frames'] = []); expect(manifest.isEmpty, isTrue); expect(manifest.newestFrame, isNull); expect(manifest.ageAt(DateTime.now()), Duration.zero); }); }); group('RadarManifest.ageAt', () { test('measures from the newest frame', () { final manifest = RadarManifest.fromJson(validManifest()); final now = DateTime.fromMillisecondsSinceEpoch( 1768478700000 + Duration.millisecondsPerMinute * 12, isUtc: true, ); expect(manifest.ageAt(now), const Duration(minutes: 12)); }); test('handles a local-time argument', () { final manifest = RadarManifest.fromJson(validManifest()); final now = DateTime.fromMillisecondsSinceEpoch( 1768478700000 + Duration.millisecondsPerMinute * 5, ); expect(manifest.ageAt(now), const Duration(minutes: 5)); }); }); group('RadarLegend', () { late RadarLegend legend; setUp(() { legend = RadarManifest.fromJson(validManifest()).legend; }); test('parses #RRGGBB as opaque', () { expect(legend.stops.first.color, const Color(0xFF4FA3D1)); }); test('picks the highest stop at or below the value', () { expect(legend.colorFor(5), const Color(0xFF4FA3D1)); expect(legend.colorFor(29.9), const Color(0xFF4FA3D1)); expect(legend.colorFor(30), const Color(0xFFE9D22B)); expect(legend.colorFor(120), const Color(0xFFE9D22B)); }); // Below the lowest stop means "no precipitation", which must be transparent // rather than the first colour of the ramp — otherwise a dry region renders // as light drizzle everywhere. test('returns null below the lowest stop', () { expect(legend.colorFor(4.9), isNull); expect(legend.colorFor(-10), isNull); expect(legend.minimumValue, 5); }); test('rejects stops that do not ascend', () { expect( () => parseWith((doc) { doc['legend'] = { 'unit': 'dBZ', 'stops': [ {'value': 30, 'color': '#E9D22B'}, {'value': 5, 'color': '#4FA3D1'}, ], }; }), throwsA( isA().having( (e) => e.message, 'message', contains('ascend'), ), ), ); }); test('rejects an empty stop list', () { expect( () => parseWith((doc) { doc['legend'] = { 'unit': 'dBZ', 'stops': [], }; }), throwsFormatException, ); }); test('rejects a malformed colour', () { expect( () => parseWith((doc) { doc['legend'] = { 'unit': 'dBZ', 'stops': [ {'value': 5, 'color': 'blue'}, ], }; }), throwsFormatException, ); }); }); group('RadarManifest.parse', () { test('parses JSON text', () { expect(RadarManifest.parse(jsonEncode(validManifest())).product, 'VMI'); }); test('rejects JSON that is not an object', () { expect(() => RadarManifest.parse('[]'), throwsFormatException); }); }); }