From 72af1894997b68c868472f5a3580ae42ae16944f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 9 Nov 2026 19:29:08 +0800 Subject: [PATCH 1/2] fix(eew): show every intensity the map paints in the legend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 修正地震速報的圖例最高只到警報回報的震度、地圖卻塗到更高一級時,顏色與圖例對不上的問題 Fix(en-US): fix the map legend stopping below an intensity the map does paint --- .../earthquake/domain/eew_town_levels.dart | 26 +++++++++++++ .../pages/report_replay_page.dart | 39 ++++++++++++++----- .../map/presentation/layers/rts_layer.dart | 22 ++++++++--- lib/shared/widgets/intensity_legend.dart | 5 +++ .../domain/eew_town_levels_test.dart | 20 ++++++++++ 5 files changed, 97 insertions(+), 15 deletions(-) diff --git a/lib/features/earthquake/domain/eew_town_levels.dart b/lib/features/earthquake/domain/eew_town_levels.dart index c5b046d88..3d576b649 100644 --- a/lib/features/earthquake/domain/eew_town_levels.dart +++ b/lib/features/earthquake/domain/eew_town_levels.dart @@ -52,3 +52,29 @@ Future<({Map levels, bool fromModel})> eewTownLevels( fromModel: false, ); } + +/// The highest level in [levels] that the wash actually paints, or null when +/// it paints none (every township at 0 is transparent). +/// +/// This is what the legend's ceiling has to follow, not the warning's reported +/// `max`: [eewTownLevels] scores townships itself — the ML model, or the +/// formula until it loads — and can put one higher than the round number the +/// alert reports. A colour painted on the map with no row beside it is read as +/// some other intensity, which is the one thing a legend may not do. +int? highestPaintedLevel(Map levels) { + int? highest; + for (final level in levels.values) { + if (level <= 0) continue; + if (highest == null || level > highest) highest = level; + } + return highest; +} + +/// The legend ceiling for a warning reporting [reportedMax] while its wash +/// paints up to [paintedMax] — whichever is higher, and null when neither is +/// known (the caller then shows the full scale). +int? legendCeiling({int? reportedMax, int? paintedMax}) { + if (reportedMax == null) return paintedMax; + if (paintedMax == null || reportedMax >= paintedMax) return reportedMax; + return paintedMax; +} diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index c553f3db2..95221792d 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -106,6 +106,12 @@ class _ReportReplayPageState extends State { /// digits four times out of five. Assigned only when the second changes. final ValueNotifier _clockSecond = ValueNotifier(0); + /// The highest level the replay's wash paints — the legend's ceiling. The + /// estimate belongs to the map ([_ReplayMapState._updateAreaFill]; the + /// legend lives up here beside the back button), so it is written back the + /// same way [_tick] travels down. + final ValueNotifier _paintedMaxLevel = ValueNotifier(null); + void _syncClockSecond() { final second = _session.clock.now().millisecondsSinceEpoch ~/ 1000; if (second == _clockSecond.value) return; @@ -281,6 +287,7 @@ class _ReportReplayPageState extends State { _speechSettings?.removeListener(_syncAnnouncement); _announcement?.dispose(); _clockSecond.dispose(); + _paintedMaxLevel.dispose(); _session.dispose(); super.dispose(); } @@ -304,6 +311,7 @@ class _ReportReplayPageState extends State { tick: _tick, clock: _session.clock, eewIndex: _eewIndex, + paintedMaxLevel: _paintedMaxLevel, ), ), Positioned( @@ -348,17 +356,23 @@ class _ReportReplayPageState extends State { // not whether the user asked to see it. CollapsibleMapLegend( legend: ListenableBuilder( - listenable: _session.eew, + listenable: Listenable.merge([ + _session.eew, + _paintedMaxLevel, + ]), builder: (context, _) { final alerts = _session.eew.alerts.uncancelled; - final maxEewLevel = alerts.isEmpty - ? null - : alerts - .map( - (alert) => - alert.info.max.clamp(0, 9), - ) - .reduce((a, b) => a > b ? a : b); + final maxEewLevel = legendCeiling( + reportedMax: alerts.isEmpty + ? null + : alerts + .map( + (alert) => + alert.info.max.clamp(0, 9), + ) + .reduce((a, b) => a > b ? a : b), + paintedMax: _paintedMaxLevel.value, + ); return MapLegendCard( child: IntensityLegend( mode: alerts.isNotEmpty @@ -464,6 +478,7 @@ class _ReplayMap extends StatefulWidget { required this.tick, required this.clock, required this.eewIndex, + required this.paintedMaxLevel, }); final TremStationRepository stationRepository; @@ -490,6 +505,10 @@ class _ReplayMap extends StatefulWidget { /// showing, not always the newest. final int eewIndex; + /// Where [_ReplayMapState] reports the highest level its wash paints, for + /// the legend the page draws above it — see `legendCeiling`. + final ValueNotifier paintedMaxLevel; + @override State<_ReplayMap> createState() => _ReplayMapState(); } @@ -1059,6 +1078,7 @@ class _ReplayMapState extends State<_ReplayMap> { // with no alert up it paints nothing, so the OSM detailed ground — // which mounts directly beneath it — keeps showing. The grey island is // [countyFillLayerId]'s job, restored just above. + widget.paintedMaxLevel.value = null; await controller.setLayerProperties( townFillLayerId, const FillLayerProperties(fillColor: '#00000000', fillOpacity: 0), @@ -1077,6 +1097,7 @@ class _ReplayMapState extends State<_ReplayMap> { // A newer alert, serial or the model itself took over while this one // was being scored — its own update paints, not this stale one. if (!mounted || key != _fillEewKey) return; + widget.paintedMaxLevel.value = highestPaintedLevel(estimate.levels); final entries = []; estimate.levels.forEach((code, level) { if (level > 0) { diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 5808bff91..d94501b38 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -216,6 +216,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// Bumped on every feed notification, so the ranking card rebuilds with it. final ValueNotifier _rankingTick = ValueNotifier(0); + /// The highest level the current wash paints — the legend's ceiling, which + /// [_updateAreaFill] writes and [buildLegend] reads. The warning's own + /// reported max is not enough on its own, see [legendCeiling]. + final ValueNotifier _paintedMaxLevel = ValueNotifier(null); + /// Brightness captured from the last context-bearing call ([buildLegend] / /// [buildSheet]) — the render/data-push methods get no `BuildContext`, but /// the intensity icons come in light/dark artwork and the area fill needs @@ -905,6 +910,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { // with no alert up it paints nothing, so the OSM detailed ground — // which mounts directly beneath it — keeps showing. The grey island is // [countyFillLayerId]'s job, restored just above. + _paintedMaxLevel.value = null; await controller.setLayerProperties( townFillLayerId, const FillLayerProperties(fillColor: '#00000000', fillOpacity: 0), @@ -923,6 +929,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { // A newer alert, serial or the model itself took over while this one // was being scored — its own update paints, not this stale one. if (key != _fillEewKey) return; + _paintedMaxLevel.value = highestPaintedLevel(estimate.levels); final entries = []; estimate.levels.forEach((code, level) { if (level > 0) { @@ -964,14 +971,17 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { Widget buildLegend(BuildContext context) { _captureBrightness(context); return ListenableBuilder( - listenable: _eew, + listenable: Listenable.merge([_eew, _paintedMaxLevel]), builder: (context, _) { final alerts = (_eew.state.data ?? const []).uncancelled; - final maxEewLevel = alerts.isEmpty - ? null - : alerts - .map((alert) => alert.info.max.clamp(0, 9)) - .reduce((a, b) => a > b ? a : b); + final maxEewLevel = legendCeiling( + reportedMax: alerts.isEmpty + ? null + : alerts + .map((alert) => alert.info.max.clamp(0, 9)) + .reduce((a, b) => a > b ? a : b), + paintedMax: _paintedMaxLevel.value, + ); return MapLegendCard( child: IntensityLegend( mode: _eewActive diff --git a/lib/shared/widgets/intensity_legend.dart b/lib/shared/widgets/intensity_legend.dart index c0a689347..01d740b11 100644 --- a/lib/shared/widgets/intensity_legend.dart +++ b/lib/shared/widgets/intensity_legend.dart @@ -29,6 +29,11 @@ class IntensityLegend extends StatelessWidget { /// Highest discrete EEW level to show (`0`–`9`). Null shows the full scale. /// Levels above it carry no information for the current warning and are /// omitted to preserve map space. + /// + /// This is the ceiling of the *wash*, not the warning's reported maximum — + /// see [legendCeiling] in `features/earthquake/domain/eew_town_levels.dart`. + /// A level the map paints but this scale omits would be read as another + /// intensity. final int? maxEewLevel; /// Height of one scale cell / gradient step (px). diff --git a/test/features/earthquake/domain/eew_town_levels_test.dart b/test/features/earthquake/domain/eew_town_levels_test.dart index ad37497f3..efd739c47 100644 --- a/test/features/earthquake/domain/eew_town_levels_test.dart +++ b/test/features/earthquake/domain/eew_town_levels_test.dart @@ -5,6 +5,10 @@ /// may be missing, still loading, or fail to score one quake — and in every /// one of those cases an alert must still colour the island rather than leave /// it blank. +/// +/// The legend's ceiling is the other property: whatever the wash paints has to +/// have a row beside it, and the wash is scored here rather than taken from the +/// warning's reported maximum. library; import 'package:dpip/core/models/lat_lng.dart'; @@ -64,4 +68,20 @@ void main() { expect(estimate.levels['970']!, greaterThan(estimate.levels['100']!)); } }); + + test('the highest painted level ignores townships left transparent', () { + expect(highestPaintedLevel(const {}), isNull); + expect(highestPaintedLevel(const {'970': 0, '100': 0}), isNull); + expect(highestPaintedLevel(const {'970': 7, '100': 3, '630': 0}), 7); + }); + + test('the legend ceiling covers every colour the wash paints', () { + expect(legendCeiling(reportedMax: null, paintedMax: null), isNull); + expect(legendCeiling(reportedMax: 5, paintedMax: null), 5); + expect(legendCeiling(reportedMax: null, paintedMax: 6), 6); + // The wash scoring a township above the warning's own maximum is the case + // this exists for: the map would otherwise paint a colour with no row. + expect(legendCeiling(reportedMax: 5, paintedMax: 6), 6); + expect(legendCeiling(reportedMax: 6, paintedMax: 5), 6); + }); } From 3e6b7d16d2e86f19738a6e73982a524188867a38 Mon Sep 17 00:00:00 2001 From: lowrt Date: Tue, 10 Nov 2026 10:59:28 +0800 Subject: [PATCH 2/2] fix(eew): use ML for the nationwide maximum intensity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 地震速報改用 ML 模型預測的全國最大震度 Fix(en-US): use the ML model's nationwide maximum intensity for EEW --- .../earthquake/data/eew_ml_max_source.dart | 74 ++++++++++++++ .../earthquake/domain/eew_town_levels.dart | 9 -- .../earthquake/earthquake_providers.dart | 8 +- .../pages/report_replay_page.dart | 29 +++--- lib/features/earthquake/replay_session.dart | 10 +- .../map/presentation/layers/rts_layer.dart | 25 ++--- lib/shared/widgets/intensity_legend.dart | 4 +- .../data/eew_ml_max_source_test.dart | 96 +++++++++++++++++++ .../domain/eew_town_levels_test.dart | 10 -- .../pages/report_replay_page_test.dart | 2 + 10 files changed, 219 insertions(+), 48 deletions(-) create mode 100644 lib/features/earthquake/data/eew_ml_max_source.dart create mode 100644 test/features/earthquake/data/eew_ml_max_source_test.dart diff --git a/lib/features/earthquake/data/eew_ml_max_source.dart b/lib/features/earthquake/data/eew_ml_max_source.dart new file mode 100644 index 000000000..6abcec3e3 --- /dev/null +++ b/lib/features/earthquake/data/eew_ml_max_source.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/realtime/realtime_source.dart'; +import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_town_levels.dart'; +import 'package:flutter/foundation.dart' show listEquals; + +/// Replaces each EEW payload's agency-reported `max` with the ML model's +/// nationwide township maximum before the alert reaches any app consumer. +/// +/// The raw value remains available while the model loads or when it cannot +/// score a solution. Loading never delays delivery of a safety-critical alert; +/// the channel's next fetch publishes the ML value once the model is ready. +class EewMlMaxSource extends RealtimeSource> { + EewMlMaxSource(this._source, this._model); + + final RealtimeSource> _source; + final MlIntensityEstimator _model; + + @override + Future>> fetch() async { + final result = await _source.fetch(); + final alerts = result.valueOrNull; + if (alerts == null || !alerts.any((alert) => !alert.isCancelled)) { + return result; + } + if (!_model.ready) { + unawaited(_model.prepare()); + return result; + } + return switch (result) { + Ok(:final value, :final stale) => Ok( + await Future.wait(value.map(_withMlMaximum)), + stale: stale, + ), + Err(:final failure) => Err(failure), + }; + } + + Future _withMlMaximum(Eew alert) async { + if (alert.isCancelled) return alert; + final levels = await _model.townLevels(alert.info); + if (levels == null || levels.isEmpty) return alert; + final maximum = levels.values.reduce((a, b) => a > b ? a : b); + return alert.copyWith(info: alert.info.copyWith(max: maximum)); + } + + @override + DateTime? timestampOf(List value) => _source.timestampOf(value); + + @override + bool sameData(List? a, List? b) => listEquals(a, b); + + @override + bool get servingFallback => _source.servingFallback; + + @override + bool isIgnorableFailure(Failure failure) => + _source.isIgnorableFailure(failure); + + @override + void pause() => _source.pause(); + + @override + void resume() => _source.resume(); + + @override + void networkChanged() => _source.networkChanged(); + + @override + void dispose() => _source.dispose(); +} diff --git a/lib/features/earthquake/domain/eew_town_levels.dart b/lib/features/earthquake/domain/eew_town_levels.dart index 3d576b649..cde1a4a3a 100644 --- a/lib/features/earthquake/domain/eew_town_levels.dart +++ b/lib/features/earthquake/domain/eew_town_levels.dart @@ -69,12 +69,3 @@ int? highestPaintedLevel(Map levels) { } return highest; } - -/// The legend ceiling for a warning reporting [reportedMax] while its wash -/// paints up to [paintedMax] — whichever is higher, and null when neither is -/// known (the caller then shows the full scale). -int? legendCeiling({int? reportedMax, int? paintedMax}) { - if (reportedMax == null) return paintedMax; - if (paintedMax == null || reportedMax >= paintedMax) return reportedMax; - return paintedMax; -} diff --git a/lib/features/earthquake/earthquake_providers.dart b/lib/features/earthquake/earthquake_providers.dart index 04bfea2f0..a55cd8d44 100644 --- a/lib/features/earthquake/earthquake_providers.dart +++ b/lib/features/earthquake/earthquake_providers.dart @@ -12,6 +12,7 @@ import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_source.dart'; import 'package:dpip/core/realtime/ticker.dart'; import 'package:dpip/features/earthquake/data/earthquake_api.dart'; +import 'package:dpip/features/earthquake/data/eew_ml_max_source.dart'; import 'package:dpip/features/earthquake/data/eew_realtime_source.dart'; import 'package:dpip/features/earthquake/data/eew_repository_impl.dart'; import 'package:dpip/features/earthquake/data/ml_intensity_service.dart'; @@ -57,8 +58,9 @@ List earthquakeProviders(SharedDeps deps) { // Held by the 強震監視器 while it is on screen: only then does the RTS feed // need every frame, rather than the alerting ones the stream's sleep sends. final rtsLiveDemand = RtsLiveDemand(); - // ML v1 township estimates. Nothing is fetched or built here: the monitor - // calls `prepare` the first time it opens. + // ML v1 township estimates. The EEW source starts loading it on the first + // alert, then republishes that alert with the model's nationwide maximum; + // the monitor reuses the same service for its township wash. final mlIntensity = MlIntensityService( client: deps.apiClient, store: MlModelStore(deps.database.durable), @@ -89,7 +91,7 @@ List earthquakeProviders(SharedDeps deps) { poll: api.getEewRealtime, ) as RealtimeSource>; final eewChannel = RealtimeChannel>( - source: eewSource, + source: EewMlMaxSource(eewSource, mlIntensity), clock: deps.serverClock, elapsed: SystemElapsed(), ticker: const SystemTicker(), diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 95221792d..bd7d498e2 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -157,6 +157,7 @@ class _ReportReplayPageState extends State { context.read().clock, widget.replayTimestamp, cwaOnly: () => cwaOnly.enabled, + mlIntensity: context.read(), )..start(); _session.eew.addListener(_syncAnnouncement); _startTicker(); @@ -362,17 +363,21 @@ class _ReportReplayPageState extends State { ]), builder: (context, _) { final alerts = _session.eew.alerts.uncancelled; - final maxEewLevel = legendCeiling( - reportedMax: alerts.isEmpty - ? null - : alerts - .map( - (alert) => - alert.info.max.clamp(0, 9), - ) - .reduce((a, b) => a > b ? a : b), - paintedMax: _paintedMaxLevel.value, - ); + final alertMax = alerts.isEmpty + ? null + : alerts + .map( + (alert) => + alert.info.max.clamp(0, 9), + ) + .reduce((a, b) => a > b ? a : b); + final paintedMax = _paintedMaxLevel.value; + final maxEewLevel = alertMax == null + ? paintedMax + : paintedMax == null || + alertMax >= paintedMax + ? alertMax + : paintedMax; return MapLegendCard( child: IntensityLegend( mode: alerts.isNotEmpty @@ -506,7 +511,7 @@ class _ReplayMap extends StatefulWidget { final int eewIndex; /// Where [_ReplayMapState] reports the highest level its wash paints, for - /// the legend the page draws above it — see `legendCeiling`. + /// the legend the page draws above it. final ValueNotifier paintedMaxLevel; @override diff --git a/lib/features/earthquake/replay_session.dart b/lib/features/earthquake/replay_session.dart index 372f47281..cc5646124 100644 --- a/lib/features/earthquake/replay_session.dart +++ b/lib/features/earthquake/replay_session.dart @@ -14,9 +14,11 @@ import 'package:dpip/core/realtime/realtime_config.dart'; import 'package:dpip/core/realtime/replay_clock.dart'; import 'package:dpip/core/realtime/ticker.dart'; import 'package:dpip/features/earthquake/data/earthquake_api.dart'; +import 'package:dpip/features/earthquake/data/eew_ml_max_source.dart'; import 'package:dpip/features/earthquake/data/eew_replay_source.dart'; import 'package:dpip/features/earthquake/data/rts_replay_source.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_town_levels.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; import 'package:dpip/features/earthquake/presentation/eew_realtime_controller.dart'; import 'package:dpip/features/earthquake/presentation/rts_realtime_controller.dart'; @@ -44,6 +46,7 @@ class ReplaySession { Clock serverClock, int replayTimestamp, { required bool Function() cwaOnly, + MlIntensityEstimator? mlIntensity, }) { final api = EarthquakeApi(apiClient); final clock = ReplayClock( @@ -58,7 +61,12 @@ class ReplaySession { label: 'rts-replay', ); final eewChannel = RealtimeChannel>( - source: EewReplaySource(api, clock, cwaOnly: cwaOnly), + source: mlIntensity == null + ? EewReplaySource(api, clock, cwaOnly: cwaOnly) + : EewMlMaxSource( + EewReplaySource(api, clock, cwaOnly: cwaOnly), + mlIntensity, + ), clock: serverClock, elapsed: SystemElapsed(), ticker: const SystemTicker(), diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index d94501b38..a1a51c95b 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -216,9 +216,9 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// Bumped on every feed notification, so the ranking card rebuilds with it. final ValueNotifier _rankingTick = ValueNotifier(0); - /// The highest level the current wash paints — the legend's ceiling, which - /// [_updateAreaFill] writes and [buildLegend] reads. The warning's own - /// reported max is not enough on its own, see [legendCeiling]. + /// The highest level the current wash paints — the legend must include it + /// even when the active alerts' model-derived maximum belongs to another + /// warning. final ValueNotifier _paintedMaxLevel = ValueNotifier(null); /// Brightness captured from the last context-bearing call ([buildLegend] / @@ -974,14 +974,17 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { listenable: Listenable.merge([_eew, _paintedMaxLevel]), builder: (context, _) { final alerts = (_eew.state.data ?? const []).uncancelled; - final maxEewLevel = legendCeiling( - reportedMax: alerts.isEmpty - ? null - : alerts - .map((alert) => alert.info.max.clamp(0, 9)) - .reduce((a, b) => a > b ? a : b), - paintedMax: _paintedMaxLevel.value, - ); + final alertMax = alerts.isEmpty + ? null + : alerts + .map((alert) => alert.info.max.clamp(0, 9)) + .reduce((a, b) => a > b ? a : b); + final paintedMax = _paintedMaxLevel.value; + final maxEewLevel = alertMax == null + ? paintedMax + : paintedMax == null || alertMax >= paintedMax + ? alertMax + : paintedMax; return MapLegendCard( child: IntensityLegend( mode: _eewActive diff --git a/lib/shared/widgets/intensity_legend.dart b/lib/shared/widgets/intensity_legend.dart index 01d740b11..02fccec5d 100644 --- a/lib/shared/widgets/intensity_legend.dart +++ b/lib/shared/widgets/intensity_legend.dart @@ -30,8 +30,8 @@ class IntensityLegend extends StatelessWidget { /// Levels above it carry no information for the current warning and are /// omitted to preserve map space. /// - /// This is the ceiling of the *wash*, not the warning's reported maximum — - /// see [legendCeiling] in `features/earthquake/domain/eew_town_levels.dart`. + /// This includes the maximum of the *wash*, which may differ from an + /// incoming warning's original reported maximum. /// A level the map paints but this scale omits would be read as another /// intensity. final int? maxEewLevel; diff --git a/test/features/earthquake/data/eew_ml_max_source_test.dart b/test/features/earthquake/data/eew_ml_max_source_test.dart new file mode 100644 index 000000000..d1345a4e7 --- /dev/null +++ b/test/features/earthquake/data/eew_ml_max_source_test.dart @@ -0,0 +1,96 @@ +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/realtime/realtime_source.dart'; +import 'package:dpip/features/earthquake/data/eew_ml_max_source.dart'; +import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_town_levels.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Source extends RealtimeSource> { + _Source(this.alerts); + + final List alerts; + + @override + Future>> fetch() async => Ok(alerts); + + @override + DateTime? timestampOf(List value) => null; +} + +class _Model implements MlIntensityEstimator { + _Model({required this.ready, this.levels}); + + @override + bool ready; + final Map? levels; + int prepareCalls = 0; + + @override + Future prepare() async { + prepareCalls++; + return ready; + } + + @override + Future?> townLevels(EewInfo info) async => levels; +} + +Eew _alert({int max = 7, int status = 0}) => Eew( + agency: 'cwa', + id: 'test', + serial: 1, + status: status, + isFinal: false, + info: EewInfo( + time: 1700000000000, + longitude: 121.5, + latitude: 23.5, + depth: 10, + magnitude: 6, + location: 'Test', + max: max, + ), +); + +void main() { + test('replaces the wire maximum with the ML nationwide maximum', () async { + final model = _Model(ready: true, levels: {'100': 3, '630': 5, '970': 1}); + final result = await EewMlMaxSource( + _Source([_alert(max: 7)]), + model, + ).fetch(); + + expect(result.valueOrNull!.single.info.max, 5); + }); + + test('publishes immediately and starts loading an unready model', () async { + final model = _Model(ready: false, levels: {'100': 5}); + final result = await EewMlMaxSource( + _Source([_alert(max: 7)]), + model, + ).fetch(); + + expect(result.valueOrNull!.single.info.max, 7); + expect(model.prepareCalls, 1); + }); + + test('keeps the wire maximum when ML cannot score the solution', () async { + final model = _Model(ready: true); + final result = await EewMlMaxSource( + _Source([_alert(max: 7)]), + model, + ).fetch(); + + expect(result.valueOrNull!.single.info.max, 7); + }); + + test('does not attach an estimate to a cancelled warning', () async { + final model = _Model(ready: true, levels: {'100': 5}); + final result = await EewMlMaxSource( + _Source([_alert(max: 7, status: 3)]), + model, + ).fetch(); + + expect(result.valueOrNull!.single.info.max, 7); + }); +} diff --git a/test/features/earthquake/domain/eew_town_levels_test.dart b/test/features/earthquake/domain/eew_town_levels_test.dart index efd739c47..38420cac2 100644 --- a/test/features/earthquake/domain/eew_town_levels_test.dart +++ b/test/features/earthquake/domain/eew_town_levels_test.dart @@ -74,14 +74,4 @@ void main() { expect(highestPaintedLevel(const {'970': 0, '100': 0}), isNull); expect(highestPaintedLevel(const {'970': 7, '100': 3, '630': 0}), 7); }); - - test('the legend ceiling covers every colour the wash paints', () { - expect(legendCeiling(reportedMax: null, paintedMax: null), isNull); - expect(legendCeiling(reportedMax: 5, paintedMax: null), 5); - expect(legendCeiling(reportedMax: null, paintedMax: 6), 6); - // The wash scoring a township above the warning's own maximum is the case - // this exists for: the map would otherwise paint a colour with no row. - expect(legendCeiling(reportedMax: 5, paintedMax: 6), 6); - expect(legendCeiling(reportedMax: 6, paintedMax: 5), 6); - }); } diff --git a/test/features/earthquake/presentation/pages/report_replay_page_test.dart b/test/features/earthquake/presentation/pages/report_replay_page_test.dart index 243a8429f..f1901bb52 100644 --- a/test/features/earthquake/presentation/pages/report_replay_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_replay_page_test.dart @@ -687,6 +687,7 @@ void main() { ChangeNotifierProvider.value( value: RegionStore(settings), ), + Provider.value(value: _Model()), ], child: MaterialApp.router( locale: const Locale('en'), @@ -864,6 +865,7 @@ void main() { ChangeNotifierProvider.value( value: RegionStore(settings), ), + Provider.value(value: _Model()), ], child: MaterialApp.router( locale: const Locale('en'),