feat(todo): Todo's server answers a place's forecast (ADR 0033, D40)
The weather of D40 comes through the instance, as Train's workouts' (ADR 0036, D1): Todo's server gets a `weather` endpoint, `offered` and `forecast`, answering a place's hours for the next 16 days from Open-Meteo for people signed in alone, the place rounded to about a kilometre, nothing kept or logged, 30 requests a person a minute. Refusals are `WeatherRefused` (off, invalid, tooOften, unreachable). `WEATHER=off` and `OPEN_METEO_API_KEY` switch it as they switch Train's, and production's compose file passes them to Todo's server too. D40 records the developer's decisions of 10 Nov 2026 on the weather and the agent's details (what dry, sunny, a temperature range and calm mean over a task's hours). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
85a022af98
commit
a91aa98817
3 changed files with 1219 additions and 5 deletions
|
|
@ -21,6 +21,8 @@ import 'package:halkyon_todo_server/src/generated/search/place.dart'
|
|||
as _ih6de0l6;
|
||||
import 'package:halkyon_todo_server/src/generated/search/place_point.dart'
|
||||
as _iywv9say;
|
||||
import 'package:halkyon_todo_server/src/generated/weather/forecast_hour.dart'
|
||||
as _i9va4aku;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_test/serverpod_test.dart' as _ist;
|
||||
import 'package:halkyon_todo_server/src/generated/protocol.dart';
|
||||
|
|
@ -159,6 +161,8 @@ class TestEndpoints {
|
|||
late final futureCalls = _FutureCalls();
|
||||
|
||||
late final _SearchEndpoint search;
|
||||
|
||||
late final _WeatherEndpoint weather;
|
||||
}
|
||||
|
||||
class _InternalTestEndpoints extends TestEndpoints
|
||||
|
|
@ -172,6 +176,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
weather = _WeatherEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +298,80 @@ class _SearchEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _WeatherEndpoint {
|
||||
_WeatherEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<bool> offered(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'weather',
|
||||
method: 'offered',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'weather',
|
||||
methodName: 'offered',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<bool>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<List<_i9va4aku.ForecastHour>> forecast(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required double lat,
|
||||
required double lon,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'weather',
|
||||
method: 'forecast',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'weather',
|
||||
methodName: 'forecast',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<List<_i9va4aku.ForecastHour>>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _PruningFutureCall {
|
||||
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
var _localUniqueSession =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_todo_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_todo_server/src/weather/forecasts.dart';
|
||||
import 'package:halkyon_todo_server/src/weather/open_meteo.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../unit/weather/open_meteo_test.dart' show openMeteoForecast;
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// Open-Meteo as the tests want it: the hours of the days asked for from
|
||||
/// midnight, and what it was asked.
|
||||
class _FakeOpenMeteo implements ForecastSource {
|
||||
final asked = <({double lat, double lon, int days})>[];
|
||||
bool failing = false;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> forecast({
|
||||
required double lat,
|
||||
required double lon,
|
||||
required int days,
|
||||
}) async {
|
||||
asked.add((lat: lat, lon: lon, days: days));
|
||||
if (failing) {
|
||||
throw const OpenMeteoFailed('down');
|
||||
}
|
||||
return openMeteoForecast(DateTime.utc(2026, 11, 10), 24 * days);
|
||||
}
|
||||
}
|
||||
|
||||
Matcher _refused(String reason) =>
|
||||
throwsA(isA<WeatherRefused>().having((r) => r.reason, 'reason', reason));
|
||||
|
||||
/// A place's forecast from Open-Meteo (ADR 0033, D40): for the people signed
|
||||
/// in, unless the operator switched it off, its place rounded, within a
|
||||
/// limit a minute.
|
||||
void main() {
|
||||
withServerpod("A place's forecast", (sessionBuilder, endpoints) {
|
||||
late _FakeOpenMeteo openMeteo;
|
||||
final now = DateTime.utc(2026, 11, 10, 12);
|
||||
TestSessionBuilder signedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
final alice = signedIn();
|
||||
final bob = signedIn();
|
||||
|
||||
void useForecasts(Forecasts forecasts) =>
|
||||
Forecasts.use(sessionBuilder.build().serverpod, forecasts);
|
||||
|
||||
Future<List<ForecastHour>> ask(
|
||||
TestSessionBuilder session, {
|
||||
double lat = 47.54567,
|
||||
double lon = 9.68321,
|
||||
}) => withClock(
|
||||
Clock.fixed(now),
|
||||
() => endpoints.weather.forecast(session, lat: lat, lon: lon),
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
openMeteo = _FakeOpenMeteo();
|
||||
useForecasts(Forecasts(source: openMeteo));
|
||||
});
|
||||
|
||||
test("answers the next 16 days' hours, the place rounded to about a "
|
||||
'kilometre', () async {
|
||||
expect(await endpoints.weather.offered(alice), isTrue);
|
||||
final hours = await ask(alice);
|
||||
expect(hours, hasLength(24 * Forecasts.days));
|
||||
expect(hours.first.time, DateTime.utc(2026, 11, 10));
|
||||
final [asked] = openMeteo.asked;
|
||||
expect((asked.lat, asked.lon, asked.days), (47.55, 9.68, 16));
|
||||
});
|
||||
|
||||
test('answers nobody signed in', () async {
|
||||
final nobody = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
await expectLater(
|
||||
ask(nobody),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.weather.offered(nobody),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
expect(openMeteo.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses every request where the operator switched it off', () async {
|
||||
useForecasts(Forecasts());
|
||||
expect(await endpoints.weather.offered(alice), isFalse);
|
||||
await expectLater(ask(alice), _refused('off'));
|
||||
});
|
||||
|
||||
test('refuses a place off the earth without asking Open-Meteo', () async {
|
||||
// NaN and infinity can't be sent: the protocol's JSON refuses them.
|
||||
for (final (lat, lon) in [(91.0, 0.0), (0.0, -181.0)]) {
|
||||
await expectLater(ask(alice, lat: lat, lon: lon), _refused('invalid'));
|
||||
}
|
||||
expect(openMeteo.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses a person beyond the limit a minute, not others', () async {
|
||||
for (var i = 0; i < Forecasts.perMinute; i++) {
|
||||
await ask(alice);
|
||||
}
|
||||
await expectLater(ask(alice), _refused('tooOften'));
|
||||
await ask(bob);
|
||||
});
|
||||
|
||||
test("says Open-Meteo didn't answer where it fails", () async {
|
||||
openMeteo.failing = true;
|
||||
await expectLater(ask(alice), _refused('unreachable'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import 'package:halkyon_todo_server/src/weather/open_meteo.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Open-Meteo's forecast (`timeformat=unixtime`) for [hours] starting at
|
||||
/// [start], each hour's figures as [figures] gives them.
|
||||
Map<String, Object?> openMeteoForecast(
|
||||
DateTime start,
|
||||
int hours, {
|
||||
Object? Function(String variable, int hour)? figures,
|
||||
}) => {
|
||||
'latitude': 52.52,
|
||||
'longitude': 13.42,
|
||||
'utc_offset_seconds': 0,
|
||||
'timezone': 'GMT',
|
||||
'hourly': {
|
||||
'time': [
|
||||
for (var i = 0; i < hours; i++)
|
||||
start.add(Duration(hours: i)).millisecondsSinceEpoch ~/ 1000,
|
||||
],
|
||||
for (final variable in hourlyVariables)
|
||||
variable: [
|
||||
for (var i = 0; i < hours; i++) (figures ?? _figures)(variable, i),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Object? _figures(String variable, int hour) => switch (variable) {
|
||||
'temperature_2m' => 12.5 + hour,
|
||||
'precipitation' => 0.2,
|
||||
'precipitation_probability' => 65,
|
||||
'weather_code' => 61,
|
||||
'wind_speed_10m' => 4.2,
|
||||
'wind_gusts_10m' => 9.8,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Open-Meteo's forecast read (ADR 0033, D40).
|
||||
void main() {
|
||||
test('reads each hour with its figures, in UTC', () {
|
||||
final start = DateTime.utc(2026, 11, 10);
|
||||
final [first, second] = forecastOf(openMeteoForecast(start, 2));
|
||||
expect(first.time, start);
|
||||
expect(first.time.isUtc, isTrue);
|
||||
expect(first.temperature, 12.5);
|
||||
expect(first.precipitation, 0.2);
|
||||
expect(first.precipitationChance, 65);
|
||||
expect(first.code, 61);
|
||||
expect(first.wind, 4.2);
|
||||
expect(first.gusts, 9.8);
|
||||
expect(second.time, start.add(const Duration(hours: 1)));
|
||||
expect(second.temperature, 13.5);
|
||||
});
|
||||
|
||||
test('leaves a figure out where it is missing, short or not a number', () {
|
||||
final answer = openMeteoForecast(
|
||||
DateTime.utc(2026, 11, 10),
|
||||
2,
|
||||
figures: (variable, hour) => switch ((variable, hour)) {
|
||||
('temperature_2m', 0) => null,
|
||||
('weather_code', _) => 'rain',
|
||||
_ => 1,
|
||||
},
|
||||
);
|
||||
(answer['hourly']! as Map<String, Object?>)['wind_gusts_10m'] = [3];
|
||||
final [first, second] = forecastOf(answer);
|
||||
expect(first.temperature, isNull);
|
||||
expect(first.code, isNull);
|
||||
expect(first.gusts, 3);
|
||||
expect(second.gusts, isNull);
|
||||
expect(second.temperature, 1);
|
||||
});
|
||||
|
||||
test('has no hours where the answer has no times', () {
|
||||
expect(
|
||||
forecastOf({
|
||||
'hourly': {
|
||||
'temperature_2m': [1],
|
||||
},
|
||||
}),
|
||||
isEmpty,
|
||||
);
|
||||
expect(forecastOf({}), isEmpty);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue