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