Files
FlClash/test/widgets/connections_view_test.dart
chen08209 da6fb0a8a1 feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-17 17:21:43 +08:00

266 lines
7.3 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/connection/connections.dart';
import 'package:fl_clash/features/features.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import '../helpers/test_app.dart';
void main() {
late ProviderContainer container;
setUp(() {
container = ProviderContainer();
globalState.container = container;
});
tearDown(() {
container.dispose();
});
List<TrackerInfo> buildConnections(int count) {
return List.generate(
count,
(index) => TrackerInfo(
id: '$index',
start: DateTime(2024),
metadata: Metadata(
network: 'tcp',
host: 'host-$index.com',
destinationPort: '443',
),
chains: const ['proxy-a'],
rule: 'MATCH',
rulePayload: '',
),
);
}
Future<void> pumpConnections(
WidgetTester tester, {
required Future<List<TrackerInfo>> Function() connectionsReader,
bool isPageActive = true,
}) async {
tester.view.physicalSize = const Size(600, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: _TestApp(
child: PageActivityScope(
isActive: isPageActive,
child: ConnectionsView(connectionsReader: connectionsReader),
),
),
),
);
await tester.pump();
}
testWidgets('ConnectionsView lazily builds every connection', (tester) async {
final connections = buildConnections(100);
await pumpConnections(tester, connectionsReader: () async => connections);
await tester.pump(const Duration(seconds: 1));
final builtItems = find.byType(TrackerInfoItem).evaluate().length;
expect(builtItems, greaterThan(0));
expect(builtItems, lessThan(connections.length));
expect(find.textContaining('host-0.com'), findsOneWidget);
await tester.scrollUntilVisible(
find.textContaining('host-99.com'),
800,
scrollable: find
.descendant(
of: find.byType(TrackerInfoAnimatedList),
matching: find.byType(Scrollable),
)
.first,
);
expect(find.textContaining('host-99.com'), findsOneWidget);
expect(tester.takeException(), null);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a row blocks its own connection and refreshes the list', (
tester,
) async {
final core = _MockCoreHandlerInterface();
when(() => core.closeConnection(any())).thenAnswer((_) async => true);
container.dispose();
container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
],
);
globalState.container = container;
var connections = buildConnections(2);
var readCount = 0;
await pumpConnections(
tester,
connectionsReader: () async {
readCount++;
return connections;
},
);
await tester.pump(const Duration(seconds: 1));
final reads = readCount;
connections = connections.sublist(1);
await tester.tap(find.byTooltip('Block connection').first);
await tester.pumpAndSettle();
verify(() => core.closeConnection('0')).called(1);
expect(readCount, greaterThan(reads));
expect(find.textContaining('host-0.com'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a wide table shows its whole protocol header', (tester) async {
await pumpConnections(
tester,
connectionsReader: () async => buildConnections(2),
);
tester.view.physicalSize = const Size(1600, 900);
await tester.pump(const Duration(seconds: 1));
final header = tester.renderObject<RenderParagraph>(find.text('Protocol'));
expect(header.didExceedMaxLines, isFalse);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('ConnectionsView polls only while the page is active', (
tester,
) async {
var readCount = 0;
Future<List<TrackerInfo>> readConnections() async {
readCount++;
return const [];
}
await pumpConnections(
tester,
connectionsReader: readConnections,
isPageActive: false,
);
await tester.pump(const Duration(seconds: 3));
expect(readCount, 0);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: _TestApp(
child: PageActivityScope(
isActive: true,
child: ConnectionsView(connectionsReader: readConnections),
),
),
),
);
await tester.pump();
expect(readCount, 1);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(readCount, 2);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: _TestApp(
child: PageActivityScope(
isActive: false,
child: ConnectionsView(connectionsReader: readConnections),
),
),
),
);
await tester.pump(const Duration(seconds: 3));
expect(readCount, 2);
expect(tester.takeException(), null);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('ConnectionsView stops polling while the app is paused', (
tester,
) async {
var readCount = 0;
Future<List<TrackerInfo>> readConnections() async {
readCount++;
return const [];
}
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpConnections(tester, connectionsReader: readConnections);
await tester.pump();
expect(readCount, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump(const Duration(seconds: 4));
expect(readCount, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(readCount, 2);
expect(tester.takeException(), null);
await tester.pumpWidget(const SizedBox.shrink());
});
}
class _TestApp extends StatelessWidget {
final Widget child;
const _TestApp({required this.child});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
AppUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
builder: (context, child) {
return wrapAppScopes(context, child!);
},
home: child,
);
}
}
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}