iop/apps/client/test/control_plane_status_controller_test.dart
toki 9024c852df refactor: architecture refactor - fleet polling & client state split
- 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
2026-06-06 19:10:47 +09:00

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);
});
}