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 agentNames = ['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': [], }, ]); } 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 settleApiCalls(WidgetTester tester) async { await tester.pump(); await tester.pump(); } /// Jumps to the next automatic refresh and lets its request complete. Future 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 [], ); 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); }); }