Add the radar data layer with the offline mock adapter
Introduces the RadarSource seam and the manifest it speaks, plus the demo frames that make the app runnable with no network and no credentials. MockRadarSource is a real adapter rather than test scaffolding, and that is what makes the rest of milestone 3 testable: the timeline, prefetching, cache eviction and every degraded path can be exercised offline because the demo frames parse the same manifest document the Python worker will publish. Its assets come from tool/generate_mock_frames.py — committed so the app runs from a clone, generated by a script so they can be regenerated instead of hand-edited. Twenty-four 512x512 frames total 108 KB, and the generator is pure standard library so nobody needs Pillow to build the app. The manifest parser rejects three things that would otherwise fail silently and look plausible: - A CRS other than EPSG:3857. The map overlays each PNG on a lat/lng quad, which only lines up if the image is already in Web Mercator; anything else renders visibly skewed with no error to explain why. - A missing attribution. The frames are a derived product of CC BY-SA data, so the credit has to travel with them rather than be remembered at render time. - Legend stops that do not ascend, which would silently mislabel intensities. The legend travels in the manifest rather than living as a constant here, because the worker chose those colours when it rendered the PNGs and a local copy could drift. RadarLegend.colorFor returns null below the lowest stop: "no precipitation" has to be transparent, not the first colour of the ramp, or a dry region renders as drizzle everywhere. Every failure reaches the caller as a single RadarUnavailableException regardless of cause, because the app's response is the same in all of them — hold the last good frame and say how old it is — and branching on cause would only invite divergence. ArpaRadarSource is a stub whose every method throws. It is named by the region config as unavailable and must fail loudly: quietly serving something else would misreport where the data came from. Verified: analyze clean, 95 tests passing, including a check that every frame the manifest lists exists and is a real PNG, and that its bbox matches the region config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"region": "piemonte",
|
||||
"product": "VMI",
|
||||
"generatedAt": 1768485300000,
|
||||
"bbox": [
|
||||
6.55,
|
||||
43.95,
|
||||
9.3,
|
||||
46.55
|
||||
],
|
||||
"crs": "EPSG:3857",
|
||||
"frames": [
|
||||
{
|
||||
"ts": 1768478400000,
|
||||
"url": "frames/1768478400000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768478700000,
|
||||
"url": "frames/1768478700000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768479000000,
|
||||
"url": "frames/1768479000000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768479300000,
|
||||
"url": "frames/1768479300000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768479600000,
|
||||
"url": "frames/1768479600000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768479900000,
|
||||
"url": "frames/1768479900000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768480200000,
|
||||
"url": "frames/1768480200000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768480500000,
|
||||
"url": "frames/1768480500000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768480800000,
|
||||
"url": "frames/1768480800000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768481100000,
|
||||
"url": "frames/1768481100000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768481400000,
|
||||
"url": "frames/1768481400000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768481700000,
|
||||
"url": "frames/1768481700000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768482000000,
|
||||
"url": "frames/1768482000000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768482300000,
|
||||
"url": "frames/1768482300000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768482600000,
|
||||
"url": "frames/1768482600000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768482900000,
|
||||
"url": "frames/1768482900000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768483200000,
|
||||
"url": "frames/1768483200000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768483500000,
|
||||
"url": "frames/1768483500000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768483800000,
|
||||
"url": "frames/1768483800000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768484100000,
|
||||
"url": "frames/1768484100000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768484400000,
|
||||
"url": "frames/1768484400000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768484700000,
|
||||
"url": "frames/1768484700000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768485000000,
|
||||
"url": "frames/1768485000000.png"
|
||||
},
|
||||
{
|
||||
"ts": 1768485300000,
|
||||
"url": "frames/1768485300000.png"
|
||||
}
|
||||
],
|
||||
"legend": {
|
||||
"unit": "dBZ",
|
||||
"stops": [
|
||||
{
|
||||
"value": 5,
|
||||
"color": "#4FA3D1"
|
||||
},
|
||||
{
|
||||
"value": 10,
|
||||
"color": "#2E7DBE"
|
||||
},
|
||||
{
|
||||
"value": 20,
|
||||
"color": "#35A64A"
|
||||
},
|
||||
{
|
||||
"value": 30,
|
||||
"color": "#E9D22B"
|
||||
},
|
||||
{
|
||||
"value": 35,
|
||||
"color": "#EC8B2A"
|
||||
},
|
||||
{
|
||||
"value": 40,
|
||||
"color": "#D63B28"
|
||||
},
|
||||
{
|
||||
"value": 45,
|
||||
"color": "#A61E1E"
|
||||
},
|
||||
{
|
||||
"value": 50,
|
||||
"color": "#B028B0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"attribution": "Dati dimostrativi generati da tool/generate_mock_frames.py"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'radar_manifest.dart';
|
||||
import 'radar_source.dart';
|
||||
|
||||
/// Placeholder for direct ARPA Piemonte radar (HDF5 ODIM, 5-minute cadence from
|
||||
/// the Bric della Croce and Monte Settepani C-band radars).
|
||||
///
|
||||
/// **Disabled.** Access requires an authorization from ARPA Piemonte that this
|
||||
/// project does not have and has not requested. The class exists so the shape
|
||||
/// of the seam is visible and the region config can name the adapter, not
|
||||
/// because it is nearly ready.
|
||||
///
|
||||
/// When authorization arrives, the data is CC BY 4.0 and must be credited as
|
||||
/// "Fonte: Arpa Piemonte - www.arpa.piemonte.it".
|
||||
///
|
||||
/// Every method throws. Constructing it is not an error — the region config may
|
||||
/// legitimately list it as unavailable — but using it is, and failing loudly
|
||||
/// beats silently serving something else.
|
||||
class ArpaRadarSource implements RadarSource {
|
||||
const ArpaRadarSource();
|
||||
|
||||
static const String _reason =
|
||||
'the ARPA Piemonte radar adapter is disabled: it needs an authorization '
|
||||
'from ARPA that this project does not hold';
|
||||
|
||||
@override
|
||||
Future<RadarManifest> getLatestManifest() async =>
|
||||
throw const RadarUnavailableException(_reason);
|
||||
|
||||
@override
|
||||
Future<List<RadarFrame>> getFrames() async =>
|
||||
throw const RadarUnavailableException(_reason);
|
||||
|
||||
@override
|
||||
Future<Uint8List> loadFrameBytes(RadarFrame frame) async =>
|
||||
throw const RadarUnavailableException(_reason);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
|
||||
|
||||
import 'radar_manifest.dart';
|
||||
import 'radar_source.dart';
|
||||
|
||||
/// Radar frames bundled in the app, for demo mode and for tests.
|
||||
///
|
||||
/// This is a real adapter, not test scaffolding. It is what lets the app run on
|
||||
/// a fresh clone with no network and no credentials, which in turn means the
|
||||
/// timeline, the prefetching, the cache eviction and the degraded states are
|
||||
/// all exercisable offline.
|
||||
///
|
||||
/// The assets are generated by `tool/generate_mock_frames.py` and deliberately
|
||||
/// carry the same manifest shape the Python worker publishes, so nothing here
|
||||
/// is a special case.
|
||||
class MockRadarSource implements RadarSource {
|
||||
MockRadarSource({this.bundle, this.manifestAsset = defaultManifestAsset});
|
||||
|
||||
static const String defaultManifestAsset = 'assets/mock/manifest.json';
|
||||
|
||||
/// Overridden in tests; null means the real asset bundle.
|
||||
final AssetBundle? bundle;
|
||||
|
||||
/// Asset path of the manifest; frame paths are resolved relative to it.
|
||||
final String manifestAsset;
|
||||
|
||||
AssetBundle get _assets => bundle ?? rootBundle;
|
||||
|
||||
/// Parsed once and reused: the assets cannot change while the app runs, and
|
||||
/// re-parsing on every timeline tick would be pure waste.
|
||||
RadarManifest? _cached;
|
||||
|
||||
@override
|
||||
Future<RadarManifest> getLatestManifest() async {
|
||||
final cached = _cached;
|
||||
if (cached != null) return cached;
|
||||
|
||||
final String source;
|
||||
try {
|
||||
source = await _assets.loadString(manifestAsset);
|
||||
} on Object catch (error) {
|
||||
throw RadarUnavailableException(
|
||||
'mock manifest asset $manifestAsset could not be read',
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return _cached = RadarManifest.parse(source);
|
||||
} on FormatException catch (error) {
|
||||
// A malformed bundled asset is a packaging bug, but it still reaches the
|
||||
// caller as unavailability so demo mode degrades like any other source.
|
||||
throw RadarUnavailableException(
|
||||
'mock manifest asset $manifestAsset is malformed',
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RadarFrame>> getFrames() async =>
|
||||
(await getLatestManifest()).frames;
|
||||
|
||||
@override
|
||||
Future<Uint8List> loadFrameBytes(RadarFrame frame) async {
|
||||
final key = resolveAssetPath(frame);
|
||||
try {
|
||||
final data = await _assets.load(key);
|
||||
return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
} on Object catch (error) {
|
||||
throw RadarUnavailableException(
|
||||
'mock frame asset $key could not be read',
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a frame's manifest-relative path against [manifestAsset].
|
||||
String resolveAssetPath(RadarFrame frame) {
|
||||
final separator = manifestAsset.lastIndexOf('/');
|
||||
if (separator < 0) return frame.path;
|
||||
return '${manifestAsset.substring(0, separator + 1)}${frame.path}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import '../../core/region/geo.dart';
|
||||
|
||||
/// One radar image in the timeline.
|
||||
class RadarFrame {
|
||||
const RadarFrame({required this.timestamp, required this.path});
|
||||
|
||||
factory RadarFrame.fromJson(Map<String, Object?> json) {
|
||||
final ts = json['ts'];
|
||||
if (ts is! int) {
|
||||
throw const FormatException('frame ts must be an integer epoch in ms');
|
||||
}
|
||||
final url = json['url'];
|
||||
if (url is! String || url.isEmpty) {
|
||||
throw const FormatException('frame url must be a non-empty string');
|
||||
}
|
||||
return RadarFrame(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(ts, isUtc: true),
|
||||
path: url,
|
||||
);
|
||||
}
|
||||
|
||||
/// When the radar sweep was taken, in UTC.
|
||||
final DateTime timestamp;
|
||||
|
||||
/// Location of the PNG, relative to the manifest that listed it, so the whole
|
||||
/// published tree can be moved between hosts without rewriting the frames.
|
||||
final String path;
|
||||
|
||||
@override
|
||||
String toString() => 'RadarFrame(${timestamp.toIso8601String()}, $path)';
|
||||
}
|
||||
|
||||
/// One step of the colour ramp, at and above [value].
|
||||
class LegendStop {
|
||||
const LegendStop({required this.value, required this.color});
|
||||
|
||||
factory LegendStop.fromJson(Map<String, Object?> json) {
|
||||
final value = json['value'];
|
||||
if (value is! num) {
|
||||
throw const FormatException('legend stop value must be a number');
|
||||
}
|
||||
final color = json['color'];
|
||||
if (color is! String) {
|
||||
throw const FormatException('legend stop color must be a string');
|
||||
}
|
||||
return LegendStop(value: value.toDouble(), color: _parseHexColor(color));
|
||||
}
|
||||
|
||||
final double value;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
/// The colour ramp used to render the frames, published alongside them.
|
||||
///
|
||||
/// The legend travels with the data rather than living as a constant in the
|
||||
/// app: the worker chose these colours when it rendered the PNGs, so a
|
||||
/// hardcoded copy here could drift and mislabel what the user is looking at.
|
||||
class RadarLegend {
|
||||
const RadarLegend({required this.unit, required this.stops});
|
||||
|
||||
factory RadarLegend.fromJson(Map<String, Object?> json) {
|
||||
final unit = json['unit'];
|
||||
if (unit is! String || unit.isEmpty) {
|
||||
throw const FormatException('legend unit must be a non-empty string');
|
||||
}
|
||||
final rawStops = json['stops'];
|
||||
if (rawStops is! List || rawStops.isEmpty) {
|
||||
throw const FormatException('legend stops must be a non-empty list');
|
||||
}
|
||||
|
||||
final stops = rawStops
|
||||
.map((entry) {
|
||||
if (entry is! Map<String, Object?>) {
|
||||
throw const FormatException('legend stop must be an object');
|
||||
}
|
||||
return LegendStop.fromJson(entry);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
for (var i = 1; i < stops.length; i++) {
|
||||
if (stops[i].value <= stops[i - 1].value) {
|
||||
throw FormatException(
|
||||
'legend stops must ascend: ${stops[i - 1].value} then ${stops[i].value}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return RadarLegend(unit: unit, stops: stops);
|
||||
}
|
||||
|
||||
/// Physical unit of the values, for example `dBZ`.
|
||||
final String unit;
|
||||
|
||||
/// Ascending by [LegendStop.value].
|
||||
final List<LegendStop> stops;
|
||||
|
||||
double get minimumValue => stops.first.value;
|
||||
|
||||
/// The colour for [value], or null below the lowest stop — which means "no
|
||||
/// precipitation", and must render as transparent rather than as the first
|
||||
/// colour of the ramp.
|
||||
Color? colorFor(double value) {
|
||||
if (value < stops.first.value) return null;
|
||||
var chosen = stops.first.color;
|
||||
for (final stop in stops) {
|
||||
if (value >= stop.value) {
|
||||
chosen = stop.color;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of published radar frames, as written by the worker.
|
||||
///
|
||||
/// Shape is documented in docs/architecture.md and produced by both
|
||||
/// tool/generate_mock_frames.py and the Python worker, so the mock and live
|
||||
/// adapters parse exactly the same document.
|
||||
class RadarManifest {
|
||||
const RadarManifest({
|
||||
required this.regionId,
|
||||
required this.product,
|
||||
required this.generatedAt,
|
||||
required this.bounds,
|
||||
required this.frames,
|
||||
required this.legend,
|
||||
required this.attribution,
|
||||
});
|
||||
|
||||
/// The projection the frames are rendered in.
|
||||
///
|
||||
/// The map overlays each PNG on a lat/lng quad, which only lines up if the
|
||||
/// image is already in Web Mercator. Anything else would render visibly
|
||||
/// skewed, so it is rejected at parse time.
|
||||
static const String requiredCrs = 'EPSG:3857';
|
||||
|
||||
factory RadarManifest.fromJson(Map<String, Object?> json) {
|
||||
final crs = json['crs'];
|
||||
if (crs != requiredCrs) {
|
||||
throw FormatException(
|
||||
'manifest crs must be $requiredCrs, got ${crs ?? 'nothing'}',
|
||||
);
|
||||
}
|
||||
|
||||
final generatedAt = json['generatedAt'];
|
||||
if (generatedAt is! int) {
|
||||
throw const FormatException('generatedAt must be an integer epoch in ms');
|
||||
}
|
||||
|
||||
final rawFrames = json['frames'];
|
||||
if (rawFrames is! List) {
|
||||
throw const FormatException('frames must be a list');
|
||||
}
|
||||
final frames = rawFrames.map((entry) {
|
||||
if (entry is! Map<String, Object?>) {
|
||||
throw const FormatException('frame must be an object');
|
||||
}
|
||||
return RadarFrame.fromJson(entry);
|
||||
}).toList()..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
final legend = json['legend'];
|
||||
if (legend is! Map<String, Object?>) {
|
||||
throw const FormatException('legend must be an object');
|
||||
}
|
||||
|
||||
final region = json['region'];
|
||||
if (region is! String || region.isEmpty) {
|
||||
throw const FormatException('region must be a non-empty string');
|
||||
}
|
||||
final product = json['product'];
|
||||
if (product is! String || product.isEmpty) {
|
||||
throw const FormatException('product must be a non-empty string');
|
||||
}
|
||||
final attribution = json['attribution'];
|
||||
if (attribution is! String || attribution.isEmpty) {
|
||||
throw const FormatException(
|
||||
'attribution must be a non-empty string: the frames carry a credit '
|
||||
'obligation and the manifest is where it travels',
|
||||
);
|
||||
}
|
||||
|
||||
return RadarManifest(
|
||||
regionId: region,
|
||||
product: product,
|
||||
generatedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
generatedAt,
|
||||
isUtc: true,
|
||||
),
|
||||
bounds: GeoBounds.fromJson(json['bbox']),
|
||||
frames: List<RadarFrame>.unmodifiable(frames),
|
||||
legend: RadarLegend.fromJson(legend),
|
||||
attribution: attribution,
|
||||
);
|
||||
}
|
||||
|
||||
factory RadarManifest.parse(String source) {
|
||||
final decoded = jsonDecode(source);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const FormatException('manifest must be a JSON object');
|
||||
}
|
||||
return RadarManifest.fromJson(decoded);
|
||||
}
|
||||
|
||||
final String regionId;
|
||||
|
||||
/// Radar-DPC product code the frames were rendered from, for example `VMI`.
|
||||
final String product;
|
||||
|
||||
final DateTime generatedAt;
|
||||
|
||||
/// Geographic extent of every frame; all frames share it.
|
||||
final GeoBounds bounds;
|
||||
|
||||
/// Ascending by timestamp.
|
||||
final List<RadarFrame> frames;
|
||||
|
||||
final RadarLegend legend;
|
||||
|
||||
/// Credit line for the frames, shown wherever they are.
|
||||
final String attribution;
|
||||
|
||||
bool get isEmpty => frames.isEmpty;
|
||||
|
||||
RadarFrame? get newestFrame => frames.isEmpty ? null : frames.last;
|
||||
|
||||
/// How stale the newest frame is relative to [now].
|
||||
///
|
||||
/// Radar shown without its age is radar the user will assume is current, so
|
||||
/// every view that renders a frame also renders this.
|
||||
Duration ageAt(DateTime now) {
|
||||
final newest = newestFrame;
|
||||
if (newest == null) return Duration.zero;
|
||||
return now.toUtc().difference(newest.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
Color _parseHexColor(String value) {
|
||||
final hex = value.startsWith('#') ? value.substring(1) : value;
|
||||
if (hex.length != 6 && hex.length != 8) {
|
||||
throw FormatException('colour must be #RRGGBB or #AARRGGBB, got $value');
|
||||
}
|
||||
final parsed = int.tryParse(hex, radix: 16);
|
||||
if (parsed == null) {
|
||||
throw FormatException('colour is not hexadecimal: $value');
|
||||
}
|
||||
return Color(hex.length == 6 ? 0xFF000000 | parsed : parsed);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'radar_manifest.dart';
|
||||
|
||||
/// A source of radar frames.
|
||||
///
|
||||
/// The three implementations differ only in where the bytes come from, never in
|
||||
/// what they mean: all of them parse the same manifest document, so switching
|
||||
/// source is configuration rather than a separate code path.
|
||||
///
|
||||
/// See `MockRadarSource` (offline demo), `DpcRadarSource` (live, via our CDN)
|
||||
/// and `ArpaRadarSource` (disabled stub).
|
||||
abstract interface class RadarSource {
|
||||
/// The most recent published index of frames.
|
||||
///
|
||||
/// Throws [RadarUnavailableException] when the manifest cannot be reached or
|
||||
/// read. Callers are expected to fall back to the last good manifest rather
|
||||
/// than showing an empty map.
|
||||
Future<RadarManifest> getLatestManifest();
|
||||
|
||||
/// The frames of the latest manifest, ascending by timestamp.
|
||||
Future<List<RadarFrame>> getFrames();
|
||||
|
||||
/// The PNG bytes for [frame].
|
||||
///
|
||||
/// Throws [RadarUnavailableException] if that single frame is missing. A gap
|
||||
/// in the middle of a timeline is normal — the worker can be mid-publish — so
|
||||
/// callers hold the previous frame instead of blanking the map.
|
||||
Future<Uint8List> loadFrameBytes(RadarFrame frame);
|
||||
}
|
||||
|
||||
/// Radar data could not be obtained.
|
||||
///
|
||||
/// Deliberately one exception type for every cause — offline, HTTP error,
|
||||
/// malformed document, adapter disabled — because the app's response is the
|
||||
/// same in all of them: keep showing the last good frame and say how old it is.
|
||||
/// The [cause] is kept for logging, not for branching.
|
||||
class RadarUnavailableException implements Exception {
|
||||
const RadarUnavailableException(this.message, {this.cause});
|
||||
|
||||
final String message;
|
||||
final Object? cause;
|
||||
|
||||
@override
|
||||
String toString() => cause == null
|
||||
? 'RadarUnavailableException: $message'
|
||||
: 'RadarUnavailableException: $message ($cause)';
|
||||
}
|
||||
@@ -38,3 +38,7 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/regions/
|
||||
# Demo radar frames, generated by tool/generate_mock_frames.py. They make
|
||||
# the app runnable offline with no credentials.
|
||||
- assets/mock/
|
||||
- assets/mock/frames/
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuvolari/core/region/region_config.dart';
|
||||
import 'package:nuvolari/data/radar/arpa_radar_source.dart';
|
||||
import 'package:nuvolari/data/radar/mock_radar_source.dart';
|
||||
import 'package:nuvolari/data/radar/radar_manifest.dart';
|
||||
import 'package:nuvolari/data/radar/radar_source.dart';
|
||||
|
||||
/// Serves the repository's real asset files, since `flutter test` does not
|
||||
/// populate the asset bundle itself.
|
||||
class _DiskAssetBundle extends CachingAssetBundle {
|
||||
@override
|
||||
Future<ByteData> load(String key) async {
|
||||
final file = File(key);
|
||||
if (!file.existsSync()) {
|
||||
throw FlutterError('asset not found: $key');
|
||||
}
|
||||
return ByteData.sublistView(Uint8List.fromList(file.readAsBytesSync()));
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyAssetBundle extends CachingAssetBundle {
|
||||
@override
|
||||
Future<ByteData> load(String key) async =>
|
||||
throw FlutterError('asset not found: $key');
|
||||
}
|
||||
|
||||
class _FixedAssetBundle extends CachingAssetBundle {
|
||||
_FixedAssetBundle(this.content);
|
||||
|
||||
final String content;
|
||||
|
||||
@override
|
||||
Future<ByteData> load(String key) async =>
|
||||
ByteData.sublistView(Uint8List.fromList(utf8.encode(content)));
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MockRadarSource against the bundled assets', () {
|
||||
late MockRadarSource source;
|
||||
|
||||
setUp(() {
|
||||
source = MockRadarSource(bundle: _DiskAssetBundle());
|
||||
});
|
||||
|
||||
test('parses the generated manifest', () async {
|
||||
final manifest = await source.getLatestManifest();
|
||||
|
||||
expect(manifest.regionId, 'piemonte');
|
||||
expect(manifest.product, 'VMI');
|
||||
expect(manifest.frames, isNotEmpty);
|
||||
expect(manifest.legend.unit, 'dBZ');
|
||||
});
|
||||
|
||||
test('frames are evenly spaced and ascending', () async {
|
||||
final frames = await source.getFrames();
|
||||
|
||||
expect(frames.length, greaterThanOrEqualTo(12));
|
||||
for (var i = 1; i < frames.length; i++) {
|
||||
expect(
|
||||
frames[i].timestamp.difference(frames[i - 1].timestamp),
|
||||
const Duration(minutes: 5),
|
||||
reason: 'gap before frame $i',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every frame in the manifest actually exists', () async {
|
||||
final frames = await source.getFrames();
|
||||
|
||||
for (final frame in frames) {
|
||||
final bytes = await source.loadFrameBytes(frame);
|
||||
expect(bytes, isNotEmpty, reason: '${frame.path} is empty');
|
||||
// PNG magic number, so a truncated or misnamed file is caught here
|
||||
// rather than as a blank overlay on the map.
|
||||
expect(
|
||||
bytes.sublist(0, 8),
|
||||
orderedEquals(<int>[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
reason: '${frame.path} is not a PNG',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The demo frames are drawn for the region's extent. If the two drifted
|
||||
// apart the overlay would sit off the map with nothing to signal it.
|
||||
test('the manifest bbox matches the region config', () async {
|
||||
final manifest = await source.getLatestManifest();
|
||||
final region = RegionConfig.parse(
|
||||
File('assets/regions/piemonte.json').readAsStringSync(),
|
||||
);
|
||||
|
||||
expect(manifest.bounds, region.bounds);
|
||||
});
|
||||
|
||||
test('resolves frame paths relative to the manifest', () {
|
||||
final resolved = source.resolveAssetPath(
|
||||
RadarFrame(
|
||||
timestamp: DateTime.utc(2026),
|
||||
path: 'frames/1768478400000.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(resolved, 'assets/mock/frames/1768478400000.png');
|
||||
});
|
||||
|
||||
test('parses the manifest once and reuses it', () async {
|
||||
final first = await source.getLatestManifest();
|
||||
final second = await source.getLatestManifest();
|
||||
|
||||
expect(identical(first, second), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('MockRadarSource failure handling', () {
|
||||
// Demo mode degrades exactly like a live source: the caller sees
|
||||
// unavailability, not a packaging error it cannot act on.
|
||||
test('reports a missing manifest as unavailable', () async {
|
||||
final source = MockRadarSource(bundle: _EmptyAssetBundle());
|
||||
|
||||
await expectLater(
|
||||
source.getLatestManifest(),
|
||||
throwsA(isA<RadarUnavailableException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a malformed manifest as unavailable', () async {
|
||||
final source = MockRadarSource(bundle: _FixedAssetBundle('{"crs": 1}'));
|
||||
|
||||
await expectLater(
|
||||
source.getLatestManifest(),
|
||||
throwsA(isA<RadarUnavailableException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a missing frame as unavailable', () async {
|
||||
final source = MockRadarSource(bundle: _EmptyAssetBundle());
|
||||
|
||||
await expectLater(
|
||||
source.loadFrameBytes(
|
||||
RadarFrame(timestamp: DateTime.utc(2026), path: 'frames/gone.png'),
|
||||
),
|
||||
throwsA(isA<RadarUnavailableException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The adapter is named by configuration but has no authorization behind it.
|
||||
// Failing loudly is the point: silently serving something else would misreport
|
||||
// where the data came from.
|
||||
group('ArpaRadarSource', () {
|
||||
const source = ArpaRadarSource();
|
||||
|
||||
test('every entry point refuses', () async {
|
||||
await expectLater(
|
||||
source.getLatestManifest(),
|
||||
throwsA(isA<RadarUnavailableException>()),
|
||||
);
|
||||
await expectLater(
|
||||
source.getFrames(),
|
||||
throwsA(isA<RadarUnavailableException>()),
|
||||
);
|
||||
await expectLater(
|
||||
source.loadFrameBytes(
|
||||
RadarFrame(timestamp: DateTime.utc(2026), path: 'x.png'),
|
||||
),
|
||||
throwsA(isA<RadarUnavailableException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('says why it is disabled', () async {
|
||||
await expectLater(
|
||||
source.getLatestManifest(),
|
||||
throwsA(
|
||||
isA<RadarUnavailableException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('authorization'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuvolari/data/radar/radar_manifest.dart';
|
||||
|
||||
Map<String, Object?> validManifest() => <String, Object?>{
|
||||
'region': 'piemonte',
|
||||
'product': 'VMI',
|
||||
'generatedAt': 1768478400000,
|
||||
'bbox': <double>[6.55, 43.95, 9.30, 46.55],
|
||||
'crs': 'EPSG:3857',
|
||||
'frames': <Object?>[
|
||||
<String, Object?>{'ts': 1768478400000, 'url': 'frames/a.png'},
|
||||
<String, Object?>{'ts': 1768478700000, 'url': 'frames/b.png'},
|
||||
],
|
||||
'legend': <String, Object?>{
|
||||
'unit': 'dBZ',
|
||||
'stops': <Object?>[
|
||||
<String, Object?>{'value': 5, 'color': '#4FA3D1'},
|
||||
<String, Object?>{'value': 30, 'color': '#E9D22B'},
|
||||
],
|
||||
},
|
||||
'attribution': 'Radar-DPC — CC BY-SA',
|
||||
};
|
||||
|
||||
RadarManifest parseWith(void Function(Map<String, Object?> 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'] = <Object?>[
|
||||
<String, Object?>{'ts': 1768478700000, 'url': 'frames/b.png'},
|
||||
<String, Object?>{'ts': 1768478400000, 'url': 'frames/a.png'},
|
||||
];
|
||||
});
|
||||
|
||||
expect(
|
||||
manifest.frames.map((frame) => frame.path),
|
||||
orderedEquals(<String>['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<FormatException>().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'] = <Object?>[
|
||||
<String, Object?>{'ts': '1768478400000', 'url': 'frames/a.png'},
|
||||
];
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts an empty frame list', () {
|
||||
final manifest = parseWith((doc) => doc['frames'] = <Object?>[]);
|
||||
|
||||
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'] = <String, Object?>{
|
||||
'unit': 'dBZ',
|
||||
'stops': <Object?>[
|
||||
<String, Object?>{'value': 30, 'color': '#E9D22B'},
|
||||
<String, Object?>{'value': 5, 'color': '#4FA3D1'},
|
||||
],
|
||||
};
|
||||
}),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('ascend'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an empty stop list', () {
|
||||
expect(
|
||||
() => parseWith((doc) {
|
||||
doc['legend'] = <String, Object?>{
|
||||
'unit': 'dBZ',
|
||||
'stops': <Object?>[],
|
||||
};
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a malformed colour', () {
|
||||
expect(
|
||||
() => parseWith((doc) {
|
||||
doc['legend'] = <String, Object?>{
|
||||
'unit': 'dBZ',
|
||||
'stops': <Object?>[
|
||||
<String, Object?>{'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);
|
||||
});
|
||||
});
|
||||
}
|
||||