- Move fleet polling logic to control orchestrator - Split client state management into separate controller/repository - Add client bootstrap and home page components - Update HTTP fleet handlers and views - Add unit tests for client bootstrap and status controller - Archive completed subtask documents
244 lines
7.3 KiB
Dart
244 lines
7.3 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:iop_client/control_plane_status_controller.dart';
|
|
import 'package:iop_client/control_plane_status_client.dart';
|
|
import 'widget_test.dart';
|
|
|
|
class ControllableStatusRepository implements ControlPlaneStatusRepository {
|
|
final fleetCompleter = Completer<FleetStatusResponseView>();
|
|
final statusCompleter = Completer<EdgeStatusResponseView>();
|
|
final eventsCompleter = Completer<List<EdgeNodeEventView>>();
|
|
|
|
int fleetCallCount = 0;
|
|
int statusCallCount = 0;
|
|
int eventsCallCount = 0;
|
|
|
|
@override
|
|
Future<FleetStatusResponseView> fetchFleetStatus() {
|
|
fleetCallCount += 1;
|
|
return fleetCompleter.future;
|
|
}
|
|
|
|
@override
|
|
Future<EdgeStatusResponseView> fetchEdgeStatus(String edgeId) {
|
|
statusCallCount += 1;
|
|
return statusCompleter.future;
|
|
}
|
|
|
|
@override
|
|
Future<List<EdgeNodeEventView>> fetchEdgeEvents(String edgeId) {
|
|
eventsCallCount += 1;
|
|
return eventsCompleter.future;
|
|
}
|
|
|
|
@override
|
|
Future<List<EdgeRegistryView>> fetchEdges() async => [];
|
|
|
|
@override
|
|
Future<EdgeOperationsResponseView> fetchEdgeOperations(String edgeId) async {
|
|
return EdgeOperationsResponseView(edgeId: edgeId, operations: []);
|
|
}
|
|
|
|
@override
|
|
Future<EdgeCommandResponseView> sendEdgeCommand(
|
|
String edgeId,
|
|
String operation, {
|
|
String? targetSelector,
|
|
Map<String, String>? parameters,
|
|
}) async {
|
|
return EdgeCommandResponseView(
|
|
requestId: 'req-1',
|
|
commandId: 'cmd-1',
|
|
edgeId: edgeId,
|
|
status: 'success',
|
|
summary: '',
|
|
error: '',
|
|
);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
test(
|
|
'removes stale selected edge caches when fleet refresh drops edge',
|
|
() async {
|
|
final fakeStatusRepo = FakeControlPlaneStatusRepository();
|
|
final controller = ControlPlaneStatusController(
|
|
statusRepository: fakeStatusRepo,
|
|
);
|
|
|
|
// Initial fetch (edge-a and edge-b)
|
|
await controller.fetchEdges();
|
|
expect(controller.edges.length, 2);
|
|
expect(controller.selectedEdgeId, 'edge-a');
|
|
|
|
// Select edge-b and fetch details
|
|
controller.selectEdge('edge-b');
|
|
await controller.fetchEdgeDetails('edge-b');
|
|
|
|
expect(controller.selectedEdgeId, 'edge-b');
|
|
expect(controller.edgeStatuses.containsKey('edge-b'), true);
|
|
expect(controller.edgeEvents.containsKey('edge-b'), true);
|
|
|
|
// Drop edge-b from mock repository
|
|
fakeStatusRepo.mockEdges = [
|
|
EdgeRegistryView(
|
|
edgeId: 'edge-a',
|
|
edgeName: 'Edge Alpha',
|
|
version: '1.2.0',
|
|
capabilities: ['Serving', 'Automation'],
|
|
protocol: 'iop-wire-v1',
|
|
connected: true,
|
|
lastSeen: DateTime.now(),
|
|
),
|
|
];
|
|
|
|
// Refresh fleet status
|
|
await controller.fetchEdges();
|
|
|
|
// Verify selection falls back to edge-a and edge-b's cache is removed
|
|
expect(controller.selectedEdgeId, 'edge-a');
|
|
expect(controller.edgeStatuses.containsKey('edge-b'), false);
|
|
expect(controller.edgeEvents.containsKey('edge-b'), false);
|
|
},
|
|
);
|
|
|
|
test('ignores in-flight fetch completions after dispose', () async {
|
|
final fakeRepo = ControllableStatusRepository();
|
|
final controller = ControlPlaneStatusController(statusRepository: fakeRepo);
|
|
|
|
// Start fetching edges
|
|
final fetchFuture = controller.fetchEdges();
|
|
|
|
// Verify loading state is true
|
|
expect(controller.loadingEdges, true);
|
|
|
|
// Dispose controller
|
|
controller.dispose();
|
|
|
|
// Complete the future
|
|
fakeRepo.fleetCompleter.complete(
|
|
FleetStatusResponseView(
|
|
generatedAt: DateTime.now(),
|
|
edges: [
|
|
FleetEdgeView(
|
|
edgeId: 'edge-a',
|
|
edgeName: 'Edge Alpha',
|
|
version: '1.2.0',
|
|
protocol: 'iop-wire-v1',
|
|
connected: true,
|
|
health: 'healthy',
|
|
lastSeen: DateTime.now(),
|
|
nodeCount: 1,
|
|
capabilities: [],
|
|
domainAgents: [],
|
|
error: '',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// Await the future completion
|
|
await fetchFuture;
|
|
|
|
// Verify state was NOT updated (should remain default/initial values)
|
|
expect(controller.edges.isEmpty, true);
|
|
expect(
|
|
controller.loadingEdges,
|
|
true,
|
|
); // Loading edges is still true because mutation didn't happen after dispose
|
|
});
|
|
|
|
test('ignores in-flight fetch errors after dispose', () async {
|
|
final fakeRepo = ControllableStatusRepository();
|
|
final controller = ControlPlaneStatusController(statusRepository: fakeRepo);
|
|
|
|
final fetchFuture = controller.fetchEdges();
|
|
|
|
expect(controller.loadingEdges, true);
|
|
|
|
controller.dispose();
|
|
|
|
fakeRepo.fleetCompleter.completeError(StateError('fleet failed'));
|
|
|
|
await expectLater(fetchFuture, completes);
|
|
|
|
expect(controller.edges.isEmpty, true);
|
|
expect(controller.edgesError, isNull);
|
|
expect(controller.loadingEdges, true);
|
|
});
|
|
|
|
test('ignores status and events fetch completions after dispose', () async {
|
|
final fakeRepo = ControllableStatusRepository();
|
|
final controller = ControlPlaneStatusController(statusRepository: fakeRepo);
|
|
|
|
// Start fetching details
|
|
final detailsFuture = controller.fetchEdgeDetails('edge-a');
|
|
|
|
// Verify loading status is set to true
|
|
expect(controller.loadingStatuses['edge-a'], true);
|
|
expect(controller.loadingEvents['edge-a'], true);
|
|
|
|
// Dispose controller
|
|
controller.dispose();
|
|
|
|
// Complete the status and events
|
|
fakeRepo.statusCompleter.complete(
|
|
EdgeStatusResponseView(
|
|
requestId: 'req-1',
|
|
edgeId: 'edge-a',
|
|
edgeName: 'Edge Alpha',
|
|
observedTimeUnixNano: 0,
|
|
nodes: [],
|
|
capabilities: [],
|
|
domainAgents: [],
|
|
metadata: {},
|
|
error: '',
|
|
),
|
|
);
|
|
|
|
fakeRepo.eventsCompleter.complete([]);
|
|
|
|
// Await the future completion
|
|
await detailsFuture;
|
|
|
|
// Verify state was NOT updated
|
|
expect(controller.edgeStatuses.containsKey('edge-a'), false);
|
|
expect(controller.edgeEvents.containsKey('edge-a'), false);
|
|
});
|
|
|
|
test('ignores status and events fetch errors after dispose', () async {
|
|
final fakeRepo = ControllableStatusRepository();
|
|
final controller = ControlPlaneStatusController(statusRepository: fakeRepo);
|
|
|
|
final detailsFuture = controller.fetchEdgeDetails('edge-a');
|
|
|
|
expect(controller.loadingStatuses['edge-a'], true);
|
|
expect(controller.loadingEvents['edge-a'], true);
|
|
|
|
controller.dispose();
|
|
|
|
fakeRepo.statusCompleter.completeError(StateError('status failed'));
|
|
fakeRepo.eventsCompleter.completeError(StateError('events failed'));
|
|
|
|
await expectLater(detailsFuture, completes);
|
|
|
|
expect(controller.edgeStatuses.containsKey('edge-a'), false);
|
|
expect(controller.edgeEvents.containsKey('edge-a'), false);
|
|
expect(controller.statusErrors['edge-a'], isNull);
|
|
expect(controller.eventErrors['edge-a'], isNull);
|
|
expect(controller.loadingStatuses['edge-a'], true);
|
|
expect(controller.loadingEvents['edge-a'], true);
|
|
});
|
|
|
|
test('ignores selectEdge after dispose', () {
|
|
final fakeRepo = ControllableStatusRepository();
|
|
final controller = ControlPlaneStatusController(statusRepository: fakeRepo);
|
|
|
|
controller.dispose();
|
|
controller.selectEdge('edge-a');
|
|
|
|
expect(controller.selectedEdgeId, isNull);
|
|
expect(fakeRepo.statusCallCount, 0);
|
|
expect(fakeRepo.eventsCallCount, 0);
|
|
});
|
|
}
|