2026-09-05 20:09:39 +08:00
|
|
|
import 'dart:io';
|
|
|
|
|
|
2026-08-05 10:55:33 +08:00
|
|
|
import 'package:fl_clash/core/core.dart';
|
|
|
|
|
import 'package:fl_clash/core/interface.dart';
|
|
|
|
|
import 'package:fl_clash/enum/enum.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import 'package:fl_clash/l10n/l10n.dart';
|
2026-08-05 10:55:33 +08:00
|
|
|
import 'package:fl_clash/manager/core_manager.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import 'package:fl_clash/manager/status_manager.dart';
|
2026-08-05 10:55:33 +08:00
|
|
|
import 'package:fl_clash/models/models.dart';
|
2026-10-08 16:09:04 +08:00
|
|
|
import 'package:fl_clash/providers/action.dart';
|
2026-08-05 10:55:33 +08:00
|
|
|
import 'package:fl_clash/providers/app.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import 'package:fl_clash/providers/config.dart';
|
|
|
|
|
import 'package:fl_clash/providers/core.dart';
|
|
|
|
|
import 'package:fl_clash/providers/database.dart';
|
2026-09-25 03:56:58 +08:00
|
|
|
import 'package:fl_clash/providers/route_state.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import 'package:fl_clash/providers/state.dart';
|
|
|
|
|
import 'package:fl_clash/state.dart';
|
2026-10-13 17:01:19 +08:00
|
|
|
import 'package:fl_clash/ui/ui.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import 'package:material_ui/material_ui.dart';
|
2026-08-05 10:55:33 +08:00
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
import 'package:mocktail/mocktail.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
|
|
|
|
2026-10-13 17:01:19 +08:00
|
|
|
import '../helpers/app_paths.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import '../helpers/test_profiles.dart';
|
2026-08-05 10:55:33 +08:00
|
|
|
|
|
|
|
|
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
const _nullProfileSetupState = SetupState(
|
|
|
|
|
profileId: null,
|
|
|
|
|
profileLastUpdateDate: null,
|
2026-10-08 16:09:04 +08:00
|
|
|
profileType: ProfileType.file,
|
|
|
|
|
extendType: ExtendType.standard,
|
2026-09-05 20:09:39 +08:00
|
|
|
rules: [],
|
|
|
|
|
proxyGroups: [],
|
|
|
|
|
addedRules: [],
|
|
|
|
|
script: null,
|
2026-10-08 16:09:04 +08:00
|
|
|
overrides: ProfileOverrides(),
|
2026-09-05 20:09:39 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const _crash = CoreEvent(type: CoreEventType.crash, data: 'boom');
|
|
|
|
|
|
|
|
|
|
CoreEvent _geoUpdate({
|
|
|
|
|
bool updating = false,
|
|
|
|
|
bool skipped = false,
|
|
|
|
|
String? error,
|
|
|
|
|
}) {
|
|
|
|
|
return CoreEvent(
|
|
|
|
|
type: CoreEventType.geoUpdate,
|
|
|
|
|
data: <String, dynamic>{
|
|
|
|
|
'type': 'MMDB',
|
|
|
|
|
'updating': updating,
|
|
|
|
|
'skipped': skipped,
|
|
|
|
|
'error': error,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 16:09:04 +08:00
|
|
|
class _ApplyRecordingSetupAction extends SetupAction {
|
|
|
|
|
int updates = 0;
|
|
|
|
|
int applies = 0;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> updateConfigDebounce() async {
|
|
|
|
|
updates++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void applyProfileDebounce({bool silence = false, bool force = false}) {
|
|
|
|
|
applies++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
_MockCoreHandlerInterface _coreInterface() {
|
|
|
|
|
final coreInterface = _MockCoreHandlerInterface();
|
|
|
|
|
when(() => coreInterface.startLog()).thenAnswer((_) {});
|
|
|
|
|
when(() => coreInterface.stopLog()).thenAnswer((_) {});
|
|
|
|
|
return coreInterface;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<ProviderContainer> _pumpCoreManager(
|
|
|
|
|
WidgetTester tester,
|
|
|
|
|
CoreHandlerInterface coreInterface, {
|
|
|
|
|
List<Override> overrides = const [],
|
|
|
|
|
}) async {
|
|
|
|
|
final container = ProviderContainer(
|
|
|
|
|
overrides: [
|
|
|
|
|
coreHandlerProvider.overrideWithValue(
|
|
|
|
|
CoreController.scoped(coreInterface),
|
|
|
|
|
),
|
|
|
|
|
...overrides,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
addTearDown(container.dispose);
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
UncontrolledProviderScope(
|
|
|
|
|
container: container,
|
|
|
|
|
child: MaterialApp(
|
|
|
|
|
navigatorKey: globalState.navigatorKey,
|
|
|
|
|
localizationsDelegates: const [AppLocalizations.delegate],
|
|
|
|
|
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
|
|
|
|
builder: (context, child) {
|
|
|
|
|
globalState.measure = Measure.of(context, 1);
|
|
|
|
|
return StatusManager(child: child!);
|
|
|
|
|
},
|
|
|
|
|
home: const CoreManager(child: SizedBox()),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return container;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Loading.stop leaves a minDuration timer (up to 1000ms) pending after
|
|
|
|
|
// setupConfig resolves. Polling on the actual outcome instead of a fixed
|
|
|
|
|
// pump budget avoids racing it, and draining past it here keeps its
|
|
|
|
|
// callback from firing on a disposed container.
|
|
|
|
|
Future<void> _waitForSetupToSettle(
|
|
|
|
|
WidgetTester tester,
|
|
|
|
|
bool Function() condition,
|
|
|
|
|
) async {
|
|
|
|
|
await tester.runAsync(() async {
|
|
|
|
|
final deadline = DateTime.now().add(const Duration(seconds: 10));
|
|
|
|
|
while (!condition() && DateTime.now().isBefore(deadline)) {
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
|
|
|
}
|
|
|
|
|
final drainDeadline = DateTime.now().add(
|
|
|
|
|
const Duration(milliseconds: 1100),
|
|
|
|
|
);
|
|
|
|
|
while (DateTime.now().isBefore(drainDeadline)) {
|
|
|
|
|
await tester.pump();
|
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 10:55:33 +08:00
|
|
|
void main() {
|
2026-09-05 20:09:39 +08:00
|
|
|
late Directory profileSwitchTempDir;
|
|
|
|
|
|
|
|
|
|
setUpAll(() async {
|
|
|
|
|
registerFallbackValue(const SetupParams(selectedMap: {}, testUrl: ''));
|
|
|
|
|
await AppLocalizations.load(const Locale('en'));
|
|
|
|
|
profileSwitchTempDir = Directory.systemTemp.createTempSync(
|
|
|
|
|
'core_manager_test',
|
|
|
|
|
);
|
2026-10-13 17:01:19 +08:00
|
|
|
await useAppPaths(profileSwitchTempDir);
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tearDownAll(() {
|
|
|
|
|
try {
|
|
|
|
|
profileSwitchTempDir.deleteSync(recursive: true);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-05 10:55:33 +08:00
|
|
|
testWidgets('duplicate crash events disconnect the core only once', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final coreInterface = _MockCoreHandlerInterface();
|
|
|
|
|
when(() => coreInterface.stopLog()).thenAnswer((_) {});
|
2026-09-05 20:09:39 +08:00
|
|
|
final container = ProviderContainer(
|
|
|
|
|
overrides: [
|
|
|
|
|
coreHandlerProvider.overrideWithValue(
|
|
|
|
|
CoreController.scoped(coreInterface),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
2026-08-05 10:55:33 +08:00
|
|
|
addTearDown(container.dispose);
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
UncontrolledProviderScope(
|
|
|
|
|
container: container,
|
2026-09-05 20:09:39 +08:00
|
|
|
child: const MaterialApp(home: CoreManager(child: SizedBox())),
|
2026-08-05 10:55:33 +08:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
|
|
|
|
|
final transitions = <CoreStatus>[];
|
|
|
|
|
final subscription = container.listen<CoreStatus>(
|
|
|
|
|
coreStatusProvider,
|
|
|
|
|
(_, next) => transitions.add(next),
|
|
|
|
|
);
|
|
|
|
|
addTearDown(subscription.close);
|
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
coreEventManager.sendEvent(_crash);
|
|
|
|
|
coreEventManager.sendEvent(_crash);
|
2026-08-05 10:55:33 +08:00
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(container.read(coreStatusProvider), CoreStatus.disconnected);
|
|
|
|
|
expect(transitions, [CoreStatus.disconnected]);
|
|
|
|
|
verifyNever(() => coreInterface.stop());
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox());
|
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
|
|
|
});
|
2026-09-05 20:09:39 +08:00
|
|
|
|
|
|
|
|
testWidgets('a crash while the app is visible surfaces the message', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
final container = await _pumpCoreManager(tester, coreInterface);
|
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
|
|
|
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
|
|
|
|
|
|
|
|
|
|
coreEventManager.sendEvent(_crash);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(container.read(coreStatusProvider), CoreStatus.disconnected);
|
|
|
|
|
expect(find.text('boom'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a crash is ignored when the core is not connected', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
final container = await _pumpCoreManager(tester, coreInterface);
|
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
|
|
|
container.read(coreStatusProvider.notifier).value = CoreStatus.connecting;
|
|
|
|
|
final transitions = <CoreStatus>[];
|
|
|
|
|
final subscription = container.listen<CoreStatus>(
|
|
|
|
|
coreStatusProvider,
|
|
|
|
|
(_, next) => transitions.add(next),
|
|
|
|
|
);
|
|
|
|
|
addTearDown(subscription.close);
|
|
|
|
|
|
|
|
|
|
coreEventManager.sendEvent(_crash);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(container.read(coreStatusProvider), CoreStatus.connecting);
|
|
|
|
|
expect(transitions, isEmpty);
|
|
|
|
|
expect(find.text('boom'), findsNothing);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the log stream follows the openLogs setting', (tester) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
final container = await _pumpCoreManager(tester, coreInterface);
|
2026-10-08 16:09:04 +08:00
|
|
|
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
|
|
|
|
|
await tester.pump();
|
2026-09-05 20:09:39 +08:00
|
|
|
|
|
|
|
|
verify(() => coreInterface.stopLog()).called(1);
|
|
|
|
|
verifyNever(() => coreInterface.startLog());
|
|
|
|
|
|
|
|
|
|
container
|
|
|
|
|
.read(appSettingProvider.notifier)
|
|
|
|
|
.update((state) => state.copyWith(openLogs: true));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
verify(() => coreInterface.startLog()).called(1);
|
|
|
|
|
|
|
|
|
|
container
|
|
|
|
|
.read(appSettingProvider.notifier)
|
|
|
|
|
.update((state) => state.copyWith(openLogs: false));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
verify(() => coreInterface.stopLog()).called(1);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 16:09:04 +08:00
|
|
|
testWidgets('the log stream waits for the core and is restored on '
|
|
|
|
|
'every connection', (tester) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
final container = await _pumpCoreManager(tester, coreInterface);
|
|
|
|
|
container
|
|
|
|
|
.read(appSettingProvider.notifier)
|
|
|
|
|
.update((state) => state.copyWith(openLogs: true));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
verifyNever(() => coreInterface.startLog());
|
|
|
|
|
verifyNever(() => coreInterface.stopLog());
|
|
|
|
|
|
|
|
|
|
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
verify(() => coreInterface.startLog()).called(1);
|
|
|
|
|
|
|
|
|
|
container.read(coreStatusProvider.notifier).value = CoreStatus.disconnected;
|
|
|
|
|
await tester.pump();
|
|
|
|
|
container.read(coreStatusProvider.notifier).value = CoreStatus.connecting;
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
verifyNever(() => coreInterface.startLog());
|
|
|
|
|
|
|
|
|
|
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
verify(() => coreInterface.startLog()).called(1);
|
|
|
|
|
verifyNever(() => coreInterface.stopLog());
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a setting the Core patches live is not re-applied', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final setupAction = _ApplyRecordingSetupAction();
|
|
|
|
|
final container = await _pumpCoreManager(
|
|
|
|
|
tester,
|
|
|
|
|
_coreInterface(),
|
|
|
|
|
overrides: [setupActionProvider.overrideWith(() => setupAction)],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
container
|
|
|
|
|
.read(patchClashConfigProvider.notifier)
|
|
|
|
|
.update((state) => state.copyWith(mode: Mode.global, mixedPort: 8899));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(setupAction.updates, 1);
|
|
|
|
|
expect(setupAction.applies, 0);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a setting only a setup carries re-applies the profile', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final setupAction = _ApplyRecordingSetupAction();
|
|
|
|
|
final container = await _pumpCoreManager(
|
|
|
|
|
tester,
|
|
|
|
|
_coreInterface(),
|
|
|
|
|
overrides: [setupActionProvider.overrideWith(() => setupAction)],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
container
|
|
|
|
|
.read(patchClashConfigProvider.notifier)
|
|
|
|
|
.update((state) => state.copyWith(hosts: const {'a.test': '1.1.1.1'}));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
container
|
|
|
|
|
.read(networkSettingProvider.notifier)
|
|
|
|
|
.update(
|
|
|
|
|
(state) => state.copyWith(appendSystemDns: !state.appendSystemDns),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(setupAction.applies, 2);
|
|
|
|
|
expect(setupAction.updates, 0);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
testWidgets('core logs are recorded for the logs view', (tester) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
final container = await _pumpCoreManager(tester, coreInterface);
|
|
|
|
|
|
|
|
|
|
coreEventManager.sendEvent(
|
|
|
|
|
const CoreEvent(
|
|
|
|
|
type: CoreEventType.log,
|
|
|
|
|
data: {'LogLevel': 'info', 'Payload': 'hello'},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
container.read(logsProvider).list.map((log) => log.payload),
|
|
|
|
|
contains('hello'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
testWidgets('core dns queries are recorded and counted', (tester) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
final container = await _pumpCoreManager(tester, coreInterface);
|
|
|
|
|
|
|
|
|
|
for (final domain in ['alpha.test', 'beta.test']) {
|
|
|
|
|
coreEventManager.sendEvent(
|
|
|
|
|
CoreEvent(
|
|
|
|
|
type: CoreEventType.dns,
|
|
|
|
|
data: {
|
|
|
|
|
'domain': domain,
|
|
|
|
|
'type': 'A',
|
|
|
|
|
'answers': ['1.1.1.1'],
|
|
|
|
|
'delay': 3,
|
|
|
|
|
'time': '2026-09-18T04:30:01Z',
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
container.read(dnsQueriesProvider).list.map((query) => query.domain),
|
|
|
|
|
['alpha.test', 'beta.test'],
|
|
|
|
|
);
|
|
|
|
|
expect(container.read(dnsQueryCountProvider), 2);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
testWidgets('geo events are forwarded to the geo resource action', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
final container = await _pumpCoreManager(tester, coreInterface);
|
|
|
|
|
final key = GeoResource.MMDB.updatingKey;
|
|
|
|
|
final subscription = container.listen<bool>(
|
|
|
|
|
isUpdatingProvider(key),
|
|
|
|
|
(_, _) {},
|
|
|
|
|
);
|
|
|
|
|
addTearDown(subscription.close);
|
|
|
|
|
|
|
|
|
|
coreEventManager.sendEvent(_geoUpdate(updating: true));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(container.read(isUpdatingProvider(key)), isTrue);
|
|
|
|
|
|
|
|
|
|
coreEventManager.sendEvent(_geoUpdate(error: 'background failure'));
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(container.read(isUpdatingProvider(key)), isFalse);
|
|
|
|
|
expect(find.text('background failure'), findsNothing);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
testWidgets('route events reach the route tracker', (tester) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
final container = await _pumpCoreManager(tester, coreInterface);
|
|
|
|
|
|
|
|
|
|
coreEventManager.sendEvent(
|
|
|
|
|
const CoreEvent(
|
|
|
|
|
type: CoreEventType.routeChanged,
|
|
|
|
|
data: {
|
|
|
|
|
'core-epoch': 7,
|
|
|
|
|
'picks-version': 42,
|
|
|
|
|
'picks': {'Proxy': 'HK-01'},
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
final route = container.read(routeTrackerProvider);
|
|
|
|
|
expect(route.coreEpoch, 7);
|
|
|
|
|
expect(route.picksVersion, 42);
|
|
|
|
|
expect(route.picks, {'Proxy': 'HK-01'});
|
|
|
|
|
expect(route.synced, isTrue);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
testWidgets('non-geo Core errors retain global notifications', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
await _pumpCoreManager(tester, coreInterface);
|
|
|
|
|
|
|
|
|
|
coreEventManager.sendEvent(
|
|
|
|
|
const CoreEvent(
|
|
|
|
|
type: CoreEventType.log,
|
|
|
|
|
data: {'LogLevel': 'error', 'Payload': 'core failure'},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pump();
|
|
|
|
|
|
|
|
|
|
expect(find.text('core failure'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('profile switch failure', () {
|
|
|
|
|
late Profile previous;
|
|
|
|
|
late Profile next;
|
|
|
|
|
List<Override> profileOverrides() => [
|
|
|
|
|
initProvider.overrideWithBuild((_, _) => true),
|
|
|
|
|
profilesProvider.overrideWith(() => TestProfiles([previous, next])),
|
|
|
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => previous.id),
|
|
|
|
|
setupStateProvider.overrideWith((_, _) => _nullProfileSetupState),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
previous = Profile.normal(label: 'previous');
|
|
|
|
|
next = Profile.normal(label: 'next');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('keeps the selected profile when Core rejects it', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
var setupCalls = 0;
|
|
|
|
|
when(() => coreInterface.setupConfig(any())).thenAnswer((_) async {
|
|
|
|
|
setupCalls++;
|
|
|
|
|
return 'rejected';
|
|
|
|
|
});
|
|
|
|
|
final container = await _pumpCoreManager(
|
|
|
|
|
tester,
|
|
|
|
|
coreInterface,
|
|
|
|
|
overrides: profileOverrides(),
|
|
|
|
|
);
|
|
|
|
|
globalState.container = container;
|
|
|
|
|
|
|
|
|
|
container.read(currentProfileIdProvider.notifier).value = next.id;
|
|
|
|
|
await _waitForSetupToSettle(tester, () => setupCalls >= 1);
|
|
|
|
|
|
|
|
|
|
expect(container.read(currentProfileIdProvider), next.id);
|
|
|
|
|
expect(setupCalls, 1);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('rapid A -> B -> C settles on C even when both are rejected', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
var setupCalls = 0;
|
|
|
|
|
when(() => coreInterface.setupConfig(any())).thenAnswer((_) async {
|
|
|
|
|
setupCalls++;
|
|
|
|
|
return 'rejected';
|
|
|
|
|
});
|
|
|
|
|
final a = Profile.normal(label: 'a');
|
|
|
|
|
final b = Profile.normal(label: 'b');
|
|
|
|
|
final c = Profile.normal(label: 'c');
|
|
|
|
|
final container = await _pumpCoreManager(
|
|
|
|
|
tester,
|
|
|
|
|
coreInterface,
|
|
|
|
|
overrides: [
|
|
|
|
|
initProvider.overrideWithBuild((_, _) => true),
|
|
|
|
|
profilesProvider.overrideWith(() => TestProfiles([a, b, c])),
|
|
|
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => a.id),
|
|
|
|
|
setupStateProvider.overrideWith((_, _) => _nullProfileSetupState),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
globalState.container = container;
|
|
|
|
|
|
|
|
|
|
container.read(currentProfileIdProvider.notifier).value = b.id;
|
|
|
|
|
container.read(currentProfileIdProvider.notifier).value = c.id;
|
|
|
|
|
await _waitForSetupToSettle(tester, () => setupCalls >= 2);
|
|
|
|
|
|
|
|
|
|
expect(container.read(currentProfileIdProvider), c.id);
|
|
|
|
|
expect(setupCalls, 2);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets(
|
|
|
|
|
'a missing profile id recovers to a fallback without looping when '
|
|
|
|
|
'the fallback is also rejected',
|
|
|
|
|
(tester) async {
|
|
|
|
|
final coreInterface = _coreInterface();
|
|
|
|
|
var setupCalls = 0;
|
|
|
|
|
when(() => coreInterface.setupConfig(any())).thenAnswer((_) async {
|
|
|
|
|
setupCalls++;
|
|
|
|
|
return 'rejected';
|
|
|
|
|
});
|
|
|
|
|
const missingId = -1;
|
|
|
|
|
final container = await _pumpCoreManager(
|
|
|
|
|
tester,
|
|
|
|
|
coreInterface,
|
|
|
|
|
overrides: [
|
|
|
|
|
initProvider.overrideWithBuild((_, _) => true),
|
|
|
|
|
profilesProvider.overrideWith(() => TestProfiles([previous])),
|
|
|
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => null),
|
|
|
|
|
setupStateProvider.overrideWith((_, _) => _nullProfileSetupState),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
globalState.container = container;
|
|
|
|
|
|
|
|
|
|
container.read(currentProfileIdProvider.notifier).value = missingId;
|
|
|
|
|
await _waitForSetupToSettle(
|
|
|
|
|
tester,
|
|
|
|
|
() => container.read(currentProfileIdProvider) == previous.id,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(setupCalls, lessThanOrEqualTo(3));
|
|
|
|
|
expect(container.read(currentProfileIdProvider), previous.id);
|
|
|
|
|
|
|
|
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-08-05 10:55:33 +08:00
|
|
|
}
|