360 lines
12 KiB
Dart
360 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:iop_client/control_plane_status_client.dart';
|
|
import 'package:iop_client/main.dart';
|
|
import 'package:iop_client/widgets/edges_panel.dart';
|
|
import 'package:iop_client/widgets/nodes_panel.dart';
|
|
import 'support/client_test_harness.dart';
|
|
|
|
void main() {
|
|
testWidgets('Client App opens Edges panel and displays Edge details', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final fakeClient = FakeClientWireClient(shouldSuccess: true);
|
|
final fakeStatusRepo = FakeControlPlaneStatusRepository();
|
|
|
|
await tester.pumpWidget(
|
|
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
await tester.tap(find.byTooltip('Edges'));
|
|
await tester.pump();
|
|
|
|
// Verify list of edges
|
|
expect(find.text('Edge Alpha'), findsNWidgets(2));
|
|
expect(find.text('Edge Beta'), findsOneWidget);
|
|
|
|
// Verify detail pane for the first auto-selected edge (Edge Alpha)
|
|
expect(find.text('Edge ID'), findsOneWidget);
|
|
expect(find.text('edge-a'), findsOneWidget);
|
|
expect(find.text('1.2.0'), findsOneWidget);
|
|
expect(find.text('Serving'), findsOneWidget);
|
|
expect(find.text('Automation'), findsOneWidget);
|
|
|
|
// Verify selecting another Edge switches the detail pane instead of
|
|
// reusing the first Edge's status view.
|
|
await tester.tap(find.text('Edge Beta'));
|
|
await tester.pump();
|
|
|
|
expect(find.text('Edge Alpha'), findsOneWidget);
|
|
expect(find.text('Edge Beta'), findsNWidgets(2));
|
|
expect(find.text('edge-b'), findsOneWidget);
|
|
expect(find.text('1.2.1'), findsOneWidget);
|
|
expect(find.text('DISCONNECTED'), findsOneWidget);
|
|
expect(find.text('unhealthy'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'Client App opens Nodes panel and displays active Nodes and configurations',
|
|
(WidgetTester tester) async {
|
|
final fakeClient = FakeClientWireClient(shouldSuccess: true);
|
|
final fakeStatusRepo = FakeControlPlaneStatusRepository();
|
|
|
|
await tester.pumpWidget(
|
|
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
await tester.tap(find.byTooltip('Nodes'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify Nodes view header
|
|
expect(find.text('Nodes View'), findsOneWidget);
|
|
|
|
// Verify nodes listed
|
|
expect(find.text('Node One'), findsOneWidget);
|
|
expect(find.text('Label: GPU-T4'), findsOneWidget);
|
|
expect(find.text('Concurrency Limit: 4'), findsOneWidget);
|
|
expect(find.text('ollama'), findsOneWidget);
|
|
expect(find.text('custom'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'Client App Nodes panel displays Provider Catalog for nodes with snapshots',
|
|
(WidgetTester tester) async {
|
|
final fakeClient = FakeClientWireClient(shouldSuccess: true);
|
|
final fakeStatusRepo = FakeControlPlaneStatusRepository();
|
|
|
|
await tester.pumpWidget(
|
|
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
await tester.tap(find.byTooltip('Nodes'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Scroll to reveal second node's provider catalog before assertions
|
|
await tester.drag(find.byType(ListView), const Offset(0.0, -1500.0));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify Provider Catalog sections are present on both nodes
|
|
expect(find.textContaining('Provider Catalog'), findsNWidgets(2));
|
|
|
|
// Verify Provider Snapshot display for node-1 (ollama provider)
|
|
expect(find.text('provider-ollama'), findsOneWidget);
|
|
expect(
|
|
find.textContaining('llm / inference'),
|
|
findsNWidgets(2),
|
|
); // both providers on node-1
|
|
expect(
|
|
find.textContaining('HEALTHY'),
|
|
findsNWidgets(2),
|
|
); // both ollama and vllm are healthy
|
|
expect(find.textContaining('Load: 3/10'), findsOneWidget);
|
|
expect(find.textContaining('Q: 1'), findsOneWidget);
|
|
expect(find.textContaining('30.0%'), findsOneWidget);
|
|
expect(find.textContaining('Models: llama-3.1, mistral'), findsOneWidget);
|
|
|
|
// Verify Provider Snapshot display for node-2 (degraded state)
|
|
expect(find.text('provider-openai'), findsOneWidget);
|
|
expect(find.textContaining('Load: 50/100'), findsOneWidget);
|
|
expect(find.textContaining('Q: 5'), findsOneWidget);
|
|
expect(find.textContaining('50.0%'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'Client App opens Execution/Logs panel and displays lifecycle events',
|
|
(WidgetTester tester) async {
|
|
final fakeClient = FakeClientWireClient(shouldSuccess: true);
|
|
final fakeStatusRepo = FakeControlPlaneStatusRepository();
|
|
|
|
await tester.pumpWidget(
|
|
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
await tester.tap(find.byTooltip('Execution & Logs'));
|
|
await tester.pump();
|
|
|
|
// Verify logs header
|
|
expect(find.text('Lifecycle Events & Logs'), findsOneWidget);
|
|
|
|
// Verify captured events
|
|
expect(find.text('ONLINE'), findsOneWidget);
|
|
expect(find.text('WARN'), findsOneWidget);
|
|
expect(
|
|
find.text('Node connected and registered successfully'),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Execution concurrency limit reached'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('Client App refresh behavior when selected edge disappears', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final fakeClient = FakeClientWireClient(shouldSuccess: true);
|
|
final fakeStatusRepo = FakeControlPlaneStatusRepository();
|
|
|
|
await tester.pumpWidget(
|
|
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
// 1. Initial State: edge-a is selected, EdgesPanel has Edge Alpha and Edge Beta
|
|
await tester.tap(find.byTooltip('Edges'));
|
|
await tester.pump();
|
|
expect(find.text('Edge Alpha'), findsNWidgets(2)); // list & details
|
|
|
|
// 2. Change mockEdges so 'edge-a' and 'edge-b' disappear, only 'edge-c' is returned.
|
|
fakeStatusRepo.mockEdges = [
|
|
EdgeRegistryView(
|
|
edgeId: 'edge-c',
|
|
edgeName: 'Edge Gamma',
|
|
version: '1.2.2',
|
|
capabilities: ['Automation'],
|
|
protocol: 'iop-wire-v1',
|
|
connected: true,
|
|
lastSeen: DateTime.now(),
|
|
),
|
|
];
|
|
|
|
// Trigger refresh in overview
|
|
await tester.tap(find.byTooltip('Overview'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
// Verify route has navigated back to Overview successfully
|
|
expect(find.text('Operations Overview'), findsOneWidget);
|
|
|
|
// Drag the ListView upward to scroll down and mount the bottom refresh button
|
|
final listViewFinder = find.byType(ListView);
|
|
await tester.drag(listViewFinder.first, const Offset(0.0, -400.0));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
|
|
// Tap the refresh button safely by its text label
|
|
await tester.tap(find.text('Refresh Connection'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
// 3. Switch to Edges panel, verify edge-a disappears, edge-c appears and auto-selected
|
|
await tester.tap(find.byTooltip('Edges'));
|
|
await tester.pump();
|
|
|
|
expect(find.text('Edge Alpha'), findsNothing);
|
|
expect(
|
|
find.text('Edge Gamma'),
|
|
findsNWidgets(2),
|
|
); // list & details (auto-selected)
|
|
|
|
// 4. Switch to Nodes panel, verify dropdown Value works without assert crash
|
|
await tester.tap(find.byTooltip('Nodes'));
|
|
await tester.pump();
|
|
expect(find.text('Nodes View'), findsOneWidget);
|
|
// dropdown button value should be edge-c now
|
|
expect(find.text('Edge Gamma'), findsOneWidget);
|
|
|
|
// 5. Switch to Logs panel, verify dropdown Value works without assert crash
|
|
await tester.tap(find.byTooltip('Execution & Logs'));
|
|
await tester.pump();
|
|
expect(find.text('Lifecycle Events & Logs'), findsOneWidget);
|
|
expect(find.text('Edge Gamma'), findsOneWidget);
|
|
});
|
|
|
|
edgesPanelStatePreservationTests();
|
|
nodesPanelStatePreservationTests();
|
|
}
|
|
|
|
Widget _wrapPanel(Widget child) {
|
|
return MaterialApp(home: Scaffold(body: child));
|
|
}
|
|
|
|
FleetEdgeView _stateTestEdge() {
|
|
return FleetEdgeView(
|
|
edgeId: 'edge-a',
|
|
edgeName: 'Edge Alpha',
|
|
version: '1.0.0',
|
|
protocol: 'iop-wire-v1',
|
|
connected: true,
|
|
health: 'Serving',
|
|
lastSeen: DateTime(2026, 1, 1),
|
|
nodeCount: 0,
|
|
capabilities: const [],
|
|
domainAgents: const [],
|
|
error: '',
|
|
);
|
|
}
|
|
|
|
// Directly exercises the loading/error/empty branches that the split-out
|
|
// state widgets must preserve.
|
|
void edgesPanelStatePreservationTests() {
|
|
testWidgets('EdgesPanel preserves loading error and empty states', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_wrapPanel(
|
|
EdgesPanel(
|
|
edges: const [],
|
|
isLoading: true,
|
|
error: null,
|
|
selectedEdgeId: null,
|
|
onSelectEdge: (_) {},
|
|
onRefresh: () {},
|
|
),
|
|
),
|
|
);
|
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
|
|
|
var retried = 0;
|
|
await tester.pumpWidget(
|
|
_wrapPanel(
|
|
EdgesPanel(
|
|
edges: const [],
|
|
isLoading: false,
|
|
error: 'boom edges',
|
|
selectedEdgeId: null,
|
|
onSelectEdge: (_) {},
|
|
onRefresh: () => retried++,
|
|
),
|
|
),
|
|
);
|
|
expect(find.text('Failed to Load Edges'), findsOneWidget);
|
|
expect(find.text('boom edges'), findsOneWidget);
|
|
await tester.tap(find.text('Retry'));
|
|
await tester.pump();
|
|
expect(retried, 1);
|
|
|
|
var refreshed = 0;
|
|
await tester.pumpWidget(
|
|
_wrapPanel(
|
|
EdgesPanel(
|
|
edges: const [],
|
|
isLoading: false,
|
|
error: null,
|
|
selectedEdgeId: null,
|
|
onSelectEdge: (_) {},
|
|
onRefresh: () => refreshed++,
|
|
),
|
|
),
|
|
);
|
|
expect(find.text('No Edges Registered'), findsOneWidget);
|
|
await tester.tap(find.text('Refresh'));
|
|
await tester.pump();
|
|
expect(refreshed, 1);
|
|
});
|
|
}
|
|
|
|
// Directly exercises the loading/error/empty branches that the split-out
|
|
// state widgets must preserve.
|
|
void nodesPanelStatePreservationTests() {
|
|
testWidgets('NodesPanel preserves loading error and empty states', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final edges = [_stateTestEdge()];
|
|
|
|
await tester.pumpWidget(
|
|
_wrapPanel(
|
|
NodesPanel(
|
|
edges: edges,
|
|
selectedEdgeId: 'edge-a',
|
|
selectedEdgeStatus: null,
|
|
isLoading: true,
|
|
error: null,
|
|
onSelectEdge: (_) {},
|
|
onRefresh: () {},
|
|
),
|
|
),
|
|
);
|
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
|
|
|
await tester.pumpWidget(
|
|
_wrapPanel(
|
|
NodesPanel(
|
|
edges: edges,
|
|
selectedEdgeId: 'edge-a',
|
|
selectedEdgeStatus: null,
|
|
isLoading: false,
|
|
error: 'boom nodes',
|
|
onSelectEdge: (_) {},
|
|
onRefresh: () {},
|
|
),
|
|
),
|
|
);
|
|
expect(find.text('Failed to Load Nodes Status'), findsOneWidget);
|
|
expect(find.text('boom nodes'), findsOneWidget);
|
|
|
|
await tester.pumpWidget(
|
|
_wrapPanel(
|
|
NodesPanel(
|
|
edges: edges,
|
|
selectedEdgeId: 'edge-a',
|
|
selectedEdgeStatus: null,
|
|
isLoading: false,
|
|
error: null,
|
|
onSelectEdge: (_) {},
|
|
onRefresh: () {},
|
|
),
|
|
),
|
|
);
|
|
expect(find.text('No Nodes Registered'), findsOneWidget);
|
|
});
|
|
}
|