Introduced auto refresh for API Data
This commit is contained in:
@@ -28,7 +28,7 @@ Flutter 3.44 / Dart 3.12 (`sdk: ^3.12.2`).
|
|||||||
|
|
||||||
Test files **must end in `_test.dart`** or `flutter test` silently ignores them. The project keeps its PascalCase file naming in front of that suffix: `test/Api/ApiLayer_test.dart`, `test/Layouts/MainApp_test.dart`.
|
Test files **must end in `_test.dart`** or `flutter test` silently ignores them. The project keeps its PascalCase file naming in front of that suffix: `test/Api/ApiLayer_test.dart`, `test/Layouts/MainApp_test.dart`.
|
||||||
|
|
||||||
Widget tests run against a mocked backend: build a `MockClient` that answers by route, hand it to `AcApi.configure(..., httpClient: client)` in `setUp`, and `AcApi.reset()` in `tearDown`. Do **not** use `pumpAndSettle` on pages that load data — the loading state is a `CircularProgressIndicator`, whose animation never settles; pump a couple of frames instead (see `settleApiCalls` in `MainApp_test.dart`). Layout tests set `tester.view.physicalSize` to cross the 900px desktop/mobile breakpoint.
|
Widget tests run against a mocked backend: build a `MockClient` that answers by route, hand it to `AcApi.configure(..., httpClient: client)` in `setUp`, and `AcApi.reset()` in `tearDown`. Do **not** use `pumpAndSettle` on pages that load data — the loading state is a `CircularProgressIndicator`, whose animation never settles; pump a couple of frames instead (see `settleApiCalls` in `MainApp_test.dart`). Layout tests set `tester.view.physicalSize` to cross the 900px desktop/mobile breakpoint. To test the automatic refresh, pump `AutoRefreshState.refreshInterval` and then a few frames (`test/Widgets/AutoRefresh_test.dart`).
|
||||||
|
|
||||||
`flutter analyze` reports ~40 pre-existing `file_names` / `non_constant_identifier_names` infos caused by the naming conventions below. They are expected — only care about new warnings and errors.
|
`flutter analyze` reports ~40 pre-existing `file_names` / `non_constant_identifier_names` infos caused by the naming conventions below. They are expected — only care about new warnings and errors.
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ Enums (`EAgentType`, `EAgentStatus`, `EReportType`) mirror .NET enums. **The dec
|
|||||||
|
|
||||||
- `MainApp` picks the layout by width: `>= DesktopView.minimumWidth` (900) → `DesktopView` (three columns side by side: Reports | LatestReports | Agents), otherwise `MobileView` (same three pages behind a `NavigationBar`). Both layouts reuse the *same* page widgets, so pages must not assume they own the whole screen.
|
- `MainApp` picks the layout by width: `>= DesktopView.minimumWidth` (900) → `DesktopView` (three columns side by side: Reports | LatestReports | Agents), otherwise `MobileView` (same three pages behind a `NavigationBar`). Both layouts reuse the *same* page widgets, so pages must not assume they own the whole screen.
|
||||||
- **No state management package and no routing package.** Data loading is consistently: `StatefulWidget` → `late Future<T> mXFuture` assigned in `initState` from `AcApi.instance...` → `FutureBuilder` with explicit loading / `snapshot.hasError` / empty-list branches. Reload = reassign the future inside `setState`. Navigation is plain `Navigator.of(context).push(MaterialPageRoute(...))`.
|
- **No state management package and no routing package.** Data loading is consistently: `StatefulWidget` → `late Future<T> mXFuture` assigned in `initState` from `AcApi.instance...` → `FutureBuilder` with explicit loading / `snapshot.hasError` / empty-list branches. Reload = reassign the future inside `setState`. Navigation is plain `Navigator.of(context).push(MaterialPageRoute(...))`.
|
||||||
|
- **Automatic refresh**: every widget that shows api data polls the backend (it has no push channel) through the `AutoRefreshState` mixin (`lib/Widgets/AutoRefreshState.dart`): mix it into the `State` and implement `RefreshData`, which fetches again and, only on success, assigns the result as an already completed future. The mixin owns the timer, guards against overlapping requests and swallows errors of a tick. The interval (30 s) lives once in `AutoRefreshState.refreshInterval`. Because a refresh must not flash the spinner or drop the list, the `FutureBuilder`s branch on `snapshot.hasData` / `hasError`, **not** on `connectionState`. A refresh the user did not ask for stays silent — no snackbars, no disabled buttons (see `AgentDetails._reloadAgent`).
|
||||||
- Icons per enum value live in extensions (`AgentTypeIcons`, `ReportTypeIcons` — `type.icon`) so every page renders a type identically. Human labels come from `displayName` on the enum itself.
|
- Icons per enum value live in extensions (`AgentTypeIcons`, `ReportTypeIcons` — `type.icon`) so every page renders a type identically. Human labels come from `displayName` on the enum itself.
|
||||||
- `MarkdownText` is a hand-written markdown renderer (no `flutter_markdown` dependency) covering the subset the reports use: headings, lists, block quotes, fenced code, rules, and inline bold/italic/strikethrough/code/links. Extend it rather than adding a package.
|
- `MarkdownText` is a hand-written markdown renderer (no `flutter_markdown` dependency) covering the subset the reports use: headings, lists, block quotes, fenced code, rules, and inline bold/italic/strikethrough/code/links. Extend it rather than adding a package.
|
||||||
- Dependencies are intentionally minimal (`http`, `cupertino_icons`). Prefer solving things with Flutter's own widgets before adding a package.
|
- Dependencies are intentionally minimal (`http`, `cupertino_icons`). Prefer solving things with Flutter's own widgets before adding a package.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:ac_web/Api/Dtos/AgentTaskDto.dart';
|
|||||||
import 'package:ac_web/Widgets/AgentStatusIndicator.dart';
|
import 'package:ac_web/Widgets/AgentStatusIndicator.dart';
|
||||||
import 'package:ac_web/Widgets/AgentTaskCard.dart';
|
import 'package:ac_web/Widgets/AgentTaskCard.dart';
|
||||||
import 'package:ac_web/Widgets/AgentTypeIcons.dart';
|
import 'package:ac_web/Widgets/AgentTypeIcons.dart';
|
||||||
|
import 'package:ac_web/Widgets/AutoRefreshState.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Page that shows every detail of a single agent, including all of its tasks
|
/// Page that shows every detail of a single agent, including all of its tasks
|
||||||
@@ -18,7 +19,8 @@ class AgentDetails extends StatefulWidget {
|
|||||||
State<AgentDetails> createState() => _AgentDetailsState();
|
State<AgentDetails> createState() => _AgentDetailsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AgentDetailsState extends State<AgentDetails> {
|
class _AgentDetailsState extends State<AgentDetails>
|
||||||
|
with AutoRefreshState<AgentDetails> {
|
||||||
|
|
||||||
late AgentDto mAgent;
|
late AgentDto mAgent;
|
||||||
|
|
||||||
@@ -30,17 +32,29 @@ class _AgentDetailsState extends State<AgentDetails> {
|
|||||||
mAgent = widget.agent;
|
mAgent = widget.agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reload triggered by the user through the app bar or by pulling the page
|
||||||
|
/// down. Reports what happened, even when nothing changed.
|
||||||
|
Future<void> ReloadAgent() => _reloadAgent(silent: false);
|
||||||
|
|
||||||
|
/// Automatic reload. It runs without the user asking for it, so it neither
|
||||||
|
/// disables the reload button nor pops up a message - the page simply keeps
|
||||||
|
/// showing the agent it already has.
|
||||||
|
@override
|
||||||
|
Future<void> RefreshData() => _reloadAgent(silent: true);
|
||||||
|
|
||||||
/// The api only offers the whole list of agents, so the agent this page
|
/// The api only offers the whole list of agents, so the agent this page
|
||||||
/// shows is looked up by its name again.
|
/// shows is looked up by its name again.
|
||||||
Future<void> ReloadAgent() async {
|
Future<void> _reloadAgent({required bool silent}) async {
|
||||||
|
|
||||||
if (mIsReloading) {
|
if (mIsReloading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
mIsReloading = true;
|
||||||
mIsReloading = true;
|
|
||||||
});
|
if (!silent) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final List<AgentDto> agents = await AcApi.instance.agents.getAgents();
|
final List<AgentDto> agents = await AcApi.instance.agents.getAgents();
|
||||||
@@ -60,7 +74,7 @@ class _AgentDetailsState extends State<AgentDetails> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updated == null) {
|
if (updated == null && !silent) {
|
||||||
_showMessage("This agent is no longer reported by the api.");
|
_showMessage("This agent is no longer reported by the api.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,7 +87,9 @@ class _AgentDetailsState extends State<AgentDetails> {
|
|||||||
mIsReloading = false;
|
mIsReloading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
_showMessage("Could not reload the agent: $error");
|
if (!silent) {
|
||||||
|
_showMessage("Could not reload the agent: $error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:ac_web/Api/Dtos/AgentDto.dart';
|
|||||||
import 'package:ac_web/Pages/AgentDetails.dart';
|
import 'package:ac_web/Pages/AgentDetails.dart';
|
||||||
import 'package:ac_web/Widgets/AgentStatusIndicator.dart';
|
import 'package:ac_web/Widgets/AgentStatusIndicator.dart';
|
||||||
import 'package:ac_web/Widgets/AgentTypeIcons.dart';
|
import 'package:ac_web/Widgets/AgentTypeIcons.dart';
|
||||||
|
import 'package:ac_web/Widgets/AutoRefreshState.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Fetches all agents from the AgentsController and lists them with a colored
|
/// Fetches all agents from the AgentsController and lists them with a colored
|
||||||
@@ -14,7 +15,8 @@ class AgentAvailabilityColumn extends StatefulWidget{
|
|||||||
State<AgentAvailabilityColumn> createState() => _AgentAvailabilityColumnState();
|
State<AgentAvailabilityColumn> createState() => _AgentAvailabilityColumnState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AgentAvailabilityColumnState extends State<AgentAvailabilityColumn>{
|
class _AgentAvailabilityColumnState extends State<AgentAvailabilityColumn>
|
||||||
|
with AutoRefreshState<AgentAvailabilityColumn> {
|
||||||
|
|
||||||
late Future<List<AgentDto>> mAgentsFuture;
|
late Future<List<AgentDto>> mAgentsFuture;
|
||||||
|
|
||||||
@@ -24,23 +26,44 @@ class _AgentAvailabilityColumnState extends State<AgentAvailabilityColumn>{
|
|||||||
mAgentsFuture = AcApi.instance.agents.getAgents();
|
mAgentsFuture = AcApi.instance.agents.getAgents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reload triggered by the user. Shows the loading indicator again because
|
||||||
|
/// the user asked for the request and should see that it is running.
|
||||||
void ReloadAgents(){
|
void ReloadAgents(){
|
||||||
setState(() {
|
setState(() {
|
||||||
mAgentsFuture = AcApi.instance.agents.getAgents();
|
mAgentsFuture = AcApi.instance.agents.getAgents();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Automatic reload. The agents that are already on screen stay visible
|
||||||
|
/// while the request runs, the future is only replaced once it succeeded.
|
||||||
|
@override
|
||||||
|
Future<void> RefreshData() async {
|
||||||
|
|
||||||
|
final List<AgentDto> agents = await AcApi.instance.agents.getAgents();
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
mAgentsFuture = Future<List<AgentDto>>.value(agents);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder<List<AgentDto>>(
|
return FutureBuilder<List<AgentDto>>(
|
||||||
future: mAgentsFuture,
|
future: mAgentsFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
// Not `connectionState`: a refresh puts the builder back into the
|
||||||
|
// waiting state although the previous agents are still available, and
|
||||||
|
// those should stay on screen instead of flashing a spinner.
|
||||||
|
if (!snapshot.hasData && !snapshot.hasError) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError && !snapshot.hasData) {
|
||||||
return Center(child: Text('Could not load agents: ${snapshot.error}'));
|
return Center(child: Text('Could not load agents: ${snapshot.error}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Keeps the data of a widget up to date by reloading it every
|
||||||
|
/// [refreshInterval].
|
||||||
|
///
|
||||||
|
/// Mix this into the [State] of every widget that shows data of the backend and
|
||||||
|
/// implement [RefreshData]. The mixin only owns the timer, fetching the data and
|
||||||
|
/// putting it on screen stays in the widget itself.
|
||||||
|
///
|
||||||
|
/// The backend has no push channel, so the only way to notice new agents,
|
||||||
|
/// tasks or reports is polling. The interval is therefore a compromise between
|
||||||
|
/// fresh data and load on the api.
|
||||||
|
///
|
||||||
|
/// A refresh happens quietly: implementations are expected to keep the data
|
||||||
|
/// that is already on screen while the request runs, so that the user neither
|
||||||
|
/// sees a spinner nor loses the scroll position every half minute. Failures of
|
||||||
|
/// a refresh are swallowed on purpose - the data on screen simply stays as it
|
||||||
|
/// is and the next tick tries again.
|
||||||
|
mixin AutoRefreshState<TWidget extends StatefulWidget> on State<TWidget> {
|
||||||
|
|
||||||
|
/// Interval in which the api data of the whole app is refreshed.
|
||||||
|
static const Duration refreshInterval = Duration(seconds: 30);
|
||||||
|
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
|
/// True while a refresh triggered by the timer is running. A slow api must
|
||||||
|
/// not pile up requests.
|
||||||
|
bool mIsAutoRefreshing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_refreshTimer = Timer.periodic(refreshInterval, (_) => _onTick());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
_refreshTimer = null;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the data of this widget again. Called every [refreshInterval] as
|
||||||
|
/// long as the widget is mounted.
|
||||||
|
///
|
||||||
|
/// Implementations may let api errors escape, [_onTick] catches them.
|
||||||
|
Future<void> RefreshData();
|
||||||
|
|
||||||
|
Future<void> _onTick() async {
|
||||||
|
|
||||||
|
if (mIsAutoRefreshing || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mIsAutoRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await RefreshData();
|
||||||
|
} catch (error) {
|
||||||
|
// An automatic refresh must never replace data that is already on screen
|
||||||
|
// with an error message, so a failed tick is ignored.
|
||||||
|
} finally {
|
||||||
|
mIsAutoRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:ac_web/Api/AcApi.dart';
|
|||||||
import 'package:ac_web/Api/Dtos/EReportType.dart';
|
import 'package:ac_web/Api/Dtos/EReportType.dart';
|
||||||
import 'package:ac_web/Api/Dtos/ReportDto.dart';
|
import 'package:ac_web/Api/Dtos/ReportDto.dart';
|
||||||
import 'package:ac_web/Pages/ReportDetails.dart';
|
import 'package:ac_web/Pages/ReportDetails.dart';
|
||||||
|
import 'package:ac_web/Widgets/AutoRefreshState.dart';
|
||||||
import 'package:ac_web/Widgets/LatestReportEntry.dart';
|
import 'package:ac_web/Widgets/LatestReportEntry.dart';
|
||||||
import 'package:ac_web/Widgets/LatestReportHeader.dart';
|
import 'package:ac_web/Widgets/LatestReportHeader.dart';
|
||||||
import 'package:ac_web/Widgets/ReportTypeIcons.dart';
|
import 'package:ac_web/Widgets/ReportTypeIcons.dart';
|
||||||
@@ -20,7 +21,8 @@ class LatestReportColumn extends StatefulWidget{
|
|||||||
State<LatestReportColumn> createState() => _LatestReportColumnState();
|
State<LatestReportColumn> createState() => _LatestReportColumnState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LatestReportColumnState extends State<LatestReportColumn>{
|
class _LatestReportColumnState extends State<LatestReportColumn>
|
||||||
|
with AutoRefreshState<LatestReportColumn> {
|
||||||
|
|
||||||
late Future<List<ReportDto>> mReportsFuture;
|
late Future<List<ReportDto>> mReportsFuture;
|
||||||
|
|
||||||
@@ -30,17 +32,36 @@ class _LatestReportColumnState extends State<LatestReportColumn>{
|
|||||||
mReportsFuture = AcApi.instance.reports.getReports();
|
mReportsFuture = AcApi.instance.reports.getReports();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Automatic reload. The reports that are already on screen stay visible
|
||||||
|
/// while the request runs, the future is only replaced once it succeeded.
|
||||||
|
@override
|
||||||
|
Future<void> RefreshData() async {
|
||||||
|
|
||||||
|
final List<ReportDto> reports = await AcApi.instance.reports.getReports();
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
mReportsFuture = Future<List<ReportDto>>.value(reports);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder<List<ReportDto>>(
|
return FutureBuilder<List<ReportDto>>(
|
||||||
future: mReportsFuture,
|
future: mReportsFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) {
|
// Not `connectionState`: a refresh puts the builder back into the
|
||||||
|
// waiting state although the previous reports are still available, and
|
||||||
|
// those should stay on screen instead of flashing a spinner.
|
||||||
|
if (!snapshot.hasData && !snapshot.hasError) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError && !snapshot.hasData) {
|
||||||
return Center(child: Text('Could not load reports: ${snapshot.error}'));
|
return Center(child: Text('Could not load reports: ${snapshot.error}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:ac_web/Api/Api.dart';
|
||||||
|
import 'package:ac_web/Pages/AgentDetails.dart';
|
||||||
|
import 'package:ac_web/Widgets/AgentAvailabilityColumn.dart';
|
||||||
|
import 'package:ac_web/Widgets/AutoRefreshState.dart';
|
||||||
|
import 'package:ac_web/Widgets/LatestReportColumn.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
/// Backend that answers every request with the agent and the report of the
|
||||||
|
/// current "round", so that a refresh can be told apart from the first load.
|
||||||
|
class _RoundBasedBackend {
|
||||||
|
|
||||||
|
int mRound = 0;
|
||||||
|
|
||||||
|
/// When set, every request fails instead of answering.
|
||||||
|
bool mIsOffline = false;
|
||||||
|
|
||||||
|
/// Number of requests the widgets sent, failing ones included.
|
||||||
|
int mRequestCount = 0;
|
||||||
|
|
||||||
|
/// Names handed out to the single agent, one per round.
|
||||||
|
static const List<String> agentNames = <String>['Ada', 'Grace', 'Alan'];
|
||||||
|
|
||||||
|
http.Client createClient() {
|
||||||
|
return MockClient((request) async {
|
||||||
|
|
||||||
|
mRequestCount++;
|
||||||
|
|
||||||
|
if (mIsOffline) {
|
||||||
|
throw http.ClientException('offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
final String name = agentNames[mRound % agentNames.length];
|
||||||
|
final String path = request.url.path;
|
||||||
|
|
||||||
|
if (path.endsWith('agents')) {
|
||||||
|
return _json([
|
||||||
|
{
|
||||||
|
'Name': name,
|
||||||
|
'Type': 'CODER',
|
||||||
|
'LastSeen': '2026-08-28T12:00:00Z',
|
||||||
|
'Status': 'WORKING',
|
||||||
|
'Tasks': <dynamic>[],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.endsWith('reports')) {
|
||||||
|
return _json([
|
||||||
|
{
|
||||||
|
'Id': 'report-1',
|
||||||
|
'Title': 'Report of $name',
|
||||||
|
'Author': name,
|
||||||
|
'Timestamp': '2026-08-27T10:00:00Z',
|
||||||
|
'Type': 'LOG',
|
||||||
|
'Text': 'All good.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.Response('Not found', 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response _json(Object body) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode(body),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets the futures of the mocked api complete. `pumpAndSettle` cannot be used
|
||||||
|
/// here because the loading state shows an endlessly spinning indicator.
|
||||||
|
Future<void> settleApiCalls(WidgetTester tester) async {
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jumps to the next automatic refresh and lets its request complete.
|
||||||
|
Future<void> passRefreshInterval(WidgetTester tester) async {
|
||||||
|
await tester.pump(AutoRefreshState.refreshInterval);
|
||||||
|
await settleApiCalls(tester);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget wrap(Widget child) {
|
||||||
|
return MaterialApp(home: Scaffold(body: child));
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
|
||||||
|
late _RoundBasedBackend backend;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
backend = _RoundBasedBackend();
|
||||||
|
AcApi.configure(
|
||||||
|
const ApiConfig(host: 'api.local', port: 5000),
|
||||||
|
httpClient: backend.createClient(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(AcApi.reset);
|
||||||
|
|
||||||
|
testWidgets('the agent list picks up changes of the backend on its own', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const AgentAvailabilityColumn()));
|
||||||
|
await settleApiCalls(tester);
|
||||||
|
|
||||||
|
expect(find.text('Ada'), findsOneWidget);
|
||||||
|
|
||||||
|
backend.mRound = 1;
|
||||||
|
await passRefreshInterval(tester);
|
||||||
|
|
||||||
|
expect(find.text('Grace'), findsOneWidget);
|
||||||
|
expect(find.text('Ada'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the reports pick up changes of the backend on its own', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const LatestReportColumn()));
|
||||||
|
await settleApiCalls(tester);
|
||||||
|
|
||||||
|
expect(find.text('Report of Ada'), findsOneWidget);
|
||||||
|
|
||||||
|
backend.mRound = 1;
|
||||||
|
await passRefreshInterval(tester);
|
||||||
|
|
||||||
|
expect(find.text('Report of Grace'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a refresh keeps the current data on screen instead of a spinner', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const AgentAvailabilityColumn()));
|
||||||
|
await settleApiCalls(tester);
|
||||||
|
|
||||||
|
backend.mRound = 1;
|
||||||
|
|
||||||
|
// The frame in which the refresh got triggered still shows the old agent,
|
||||||
|
// the new one only replaces it once the request came back.
|
||||||
|
await tester.pump(AutoRefreshState.refreshInterval);
|
||||||
|
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
expect(find.text('Ada'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a failing refresh keeps the data that is already shown', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const AgentAvailabilityColumn()));
|
||||||
|
await settleApiCalls(tester);
|
||||||
|
|
||||||
|
backend.mIsOffline = true;
|
||||||
|
await passRefreshInterval(tester);
|
||||||
|
|
||||||
|
expect(find.text('Ada'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Could not load agents'), findsNothing);
|
||||||
|
|
||||||
|
// As soon as the api answers again, the refresh brings in the new data.
|
||||||
|
backend.mIsOffline = false;
|
||||||
|
backend.mRound = 2;
|
||||||
|
await passRefreshInterval(tester);
|
||||||
|
|
||||||
|
expect(find.text('Alan'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the details page refreshes its agent without reporting anything', (tester) async {
|
||||||
|
final AgentDto agent = AgentDto(
|
||||||
|
name: 'Ada',
|
||||||
|
type: EAgentType.coder,
|
||||||
|
status: EAgentStatus.working,
|
||||||
|
lastSeen: DateTime.utc(2026, 8, 28, 12),
|
||||||
|
tasks: const <AgentTaskDto>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(MaterialApp(home: AgentDetails(agent: agent)));
|
||||||
|
await settleApiCalls(tester);
|
||||||
|
|
||||||
|
// The agent vanished from the api - the automatic refresh must not bother
|
||||||
|
// the user with a message about it.
|
||||||
|
backend.mRound = 1;
|
||||||
|
await passRefreshInterval(tester);
|
||||||
|
|
||||||
|
expect(find.byType(SnackBar), findsNothing);
|
||||||
|
|
||||||
|
// A reload the user asked for still reports it.
|
||||||
|
await tester.tap(find.byIcon(Icons.refresh));
|
||||||
|
await settleApiCalls(tester);
|
||||||
|
|
||||||
|
expect(find.byType(SnackBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the refresh stops when the widget is gone', (tester) async {
|
||||||
|
await tester.pumpWidget(wrap(const AgentAvailabilityColumn()));
|
||||||
|
await settleApiCalls(tester);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(const SizedBox()));
|
||||||
|
|
||||||
|
final int requestsBefore = backend.mRequestCount;
|
||||||
|
|
||||||
|
// The timer of a disposed widget must not keep asking the api.
|
||||||
|
await passRefreshInterval(tester);
|
||||||
|
|
||||||
|
expect(backend.mRequestCount, requestsBefore);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user