2026-09-05 20:09:39 +08:00
|
|
|
import 'dart:async';
|
|
|
|
|
|
|
|
|
|
import 'package:drift/native.dart';
|
|
|
|
|
// `Profiles`, `Scripts` and `ProxyGroups` name both a drift table and a
|
|
|
|
|
// notifier, so the schema side is imported behind a prefix.
|
|
|
|
|
import 'package:fl_clash/database/database.dart' as db;
|
|
|
|
|
import 'package:fl_clash/enum/enum.dart';
|
2026-10-13 17:01:19 +08:00
|
|
|
import 'package:fl_clash/l10n/l10n.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import 'package:fl_clash/models/models.dart';
|
2026-02-02 10:14:13 +08:00
|
|
|
import 'package:fl_clash/providers/database.dart';
|
2026-10-13 17:01:19 +08:00
|
|
|
import 'package:material_ui/material_ui.dart';
|
2026-09-05 20:09:39 +08:00
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
|
2026-02-02 10:14:13 +08:00
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
/// Every notifier in `lib/providers/database.dart` writes optimistically: the
|
|
|
|
|
/// in-memory state is mutated first and the row is persisted afterwards through
|
|
|
|
|
/// [withRollback], which restores the pre-mutation snapshot when the write
|
|
|
|
|
/// fails. These tests drive the real drift schema on an in-memory executor so
|
|
|
|
|
/// both halves — the optimistic value and the rollback — are observable.
|
2026-02-02 10:14:13 +08:00
|
|
|
void main() {
|
2026-10-13 17:01:19 +08:00
|
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
2026-09-05 20:09:39 +08:00
|
|
|
const profileId = 1;
|
|
|
|
|
|
|
|
|
|
late db.Database testDatabase;
|
|
|
|
|
late ProviderContainer container;
|
|
|
|
|
|
|
|
|
|
Profile profile(int id, {String label = '', int? order}) => Profile(
|
|
|
|
|
id: id,
|
|
|
|
|
label: label,
|
|
|
|
|
autoUpdateDuration: Duration.zero,
|
|
|
|
|
order: order,
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-13 17:01:19 +08:00
|
|
|
setUpAll(() => AppLocalizations.load(const Locale('en')));
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
setUp(() async {
|
|
|
|
|
testDatabase = db.Database(NativeDatabase.memory());
|
|
|
|
|
db.database = testDatabase;
|
|
|
|
|
container = ProviderContainer();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tearDown(() async {
|
|
|
|
|
container.dispose();
|
|
|
|
|
await testDatabase.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Subscribes to [provider] so it survives auto-dispose for the whole test,
|
|
|
|
|
/// then waits for the backing drift stream to deliver its first row set.
|
|
|
|
|
Future<void> keepAlive(ProviderListenable<Object?> provider) async {
|
|
|
|
|
container.listen(provider, (_, _) {});
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Most call sites wrap the persist step in `unawaited(...)`, so the failure
|
2026-10-13 17:01:19 +08:00
|
|
|
/// never reaches the caller: the notifier logs it, and only after the
|
|
|
|
|
/// rollback has already restored the snapshot.
|
|
|
|
|
Future<void> captureWriteFailure(void Function() body) {
|
|
|
|
|
final reported = Completer<void>();
|
|
|
|
|
final print = debugPrint;
|
|
|
|
|
debugPrint = (message, {wrapWidth}) {
|
|
|
|
|
if (message?.contains('Optimistic database write failed') ?? false) {
|
|
|
|
|
if (!reported.isCompleted) reported.complete();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
addTearDown(() => debugPrint = print);
|
|
|
|
|
body();
|
|
|
|
|
return reported.future.timeout(const Duration(seconds: 5));
|
2026-09-05 20:09:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `customStatement` does not raise a drift table-update notification, so the
|
|
|
|
|
/// streams the notifiers watch keep serving their last good rows — which is
|
|
|
|
|
/// what lets a test observe the rolled-back state rather than an error state.
|
|
|
|
|
Future<void> breakTable(String table) =>
|
|
|
|
|
testDatabase.customStatement('DROP TABLE $table');
|
|
|
|
|
|
2026-02-02 10:14:13 +08:00
|
|
|
group('withRollback', () {
|
|
|
|
|
test('rolls back with snapshot and rethrows async errors', () async {
|
|
|
|
|
final error = StateError('write failed');
|
|
|
|
|
final previous = [1, 2, 3];
|
|
|
|
|
List<int>? rolledBack;
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
withRollback(
|
|
|
|
|
snapshot: previous,
|
|
|
|
|
action: () async {
|
|
|
|
|
throw error;
|
|
|
|
|
},
|
|
|
|
|
rollback: (value) => rolledBack = value,
|
|
|
|
|
),
|
|
|
|
|
throwsA(same(error)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(rolledBack, previous);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('does not roll back when action succeeds', () async {
|
|
|
|
|
var rollbackCalled = false;
|
|
|
|
|
|
|
|
|
|
await withRollback(
|
|
|
|
|
snapshot: [1, 2, 3],
|
|
|
|
|
action: () async {},
|
|
|
|
|
rollback: (_) => rollbackCalled = true,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(rollbackCalled, false);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-05 20:09:39 +08:00
|
|
|
|
|
|
|
|
group('Profiles', () {
|
|
|
|
|
late Profiles notifier;
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
await keepAlive(profilesProvider);
|
|
|
|
|
notifier = container.read(profilesProvider.notifier);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
List<Profile> read() => container.read(profilesProvider);
|
|
|
|
|
|
|
|
|
|
test('put persists the row and the stream echoes it back', () async {
|
|
|
|
|
notifier.put(profile(1, label: 'First'));
|
|
|
|
|
|
|
|
|
|
expect(read().single.label, 'First');
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.profilesDao.query().get();
|
|
|
|
|
expect(rows.single.label, 'First');
|
|
|
|
|
expect(read().single.label, 'First');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'put de-duplicates a label already taken by another profile',
|
|
|
|
|
() async {
|
|
|
|
|
notifier.put(profile(1, label: 'Shared'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.put(profile(2, label: 'Shared'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final labels = read().map((item) => item.label).toSet();
|
|
|
|
|
expect(labels, {'Shared', 'Shared(1)'});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('a read-back during a write keeps the optimistic profile', () async {
|
|
|
|
|
final rows = StreamController<List<Profile>>();
|
|
|
|
|
addTearDown(rows.close);
|
|
|
|
|
final scoped = ProviderContainer(
|
|
|
|
|
overrides: [profilesStreamProvider.overrideWith((_) => rows.stream)],
|
|
|
|
|
);
|
|
|
|
|
addTearDown(scoped.dispose);
|
|
|
|
|
scoped.listen(profilesProvider, (_, _) {});
|
|
|
|
|
rows.add(const []);
|
2026-09-25 03:56:58 +08:00
|
|
|
await pumpEventQueue();
|
2026-10-08 17:58:43 +08:00
|
|
|
final landing = Completer<void>();
|
|
|
|
|
final held = testDatabase.transaction(() => landing.future);
|
2026-09-25 03:56:58 +08:00
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
scoped.read(profilesProvider.notifier).put(profile(1, label: 'Mine'));
|
|
|
|
|
rows.add([profile(2, label: 'Theirs')]);
|
2026-09-25 03:56:59 +08:00
|
|
|
await pumpEventQueue();
|
2026-10-08 17:58:43 +08:00
|
|
|
expect(scoped.read(profilesProvider).map((item) => item.id), [1]);
|
2026-09-25 03:56:59 +08:00
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
landing.complete();
|
|
|
|
|
await held;
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
rows.add([profile(1, label: 'Mine'), profile(2, label: 'Theirs')]);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(scoped.read(profilesProvider).map((item) => item.id), [1, 2]);
|
2026-09-25 03:56:59 +08:00
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
test('put falls back to the id when the profile has no label', () async {
|
|
|
|
|
// Profile.normal() leaves the label empty when the download exposed no
|
|
|
|
|
// filename, and optimizeLabel is the only thing that names it.
|
|
|
|
|
notifier.put(profile(42));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().single.label, '42');
|
|
|
|
|
final rows = await testDatabase.profilesDao.query().get();
|
|
|
|
|
expect(rows.single.label, '42');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'put keeps the label when the same profile is written again',
|
|
|
|
|
() async {
|
|
|
|
|
notifier.put(profile(1, label: 'Stable'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.put(profile(1, label: 'Stable'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().single.label, 'Stable');
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
Profile custom(int id) =>
|
|
|
|
|
profile(id, label: 'Custom $id').copyWith(type: ProfileType.custom);
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'a new label follows into the groups of every custom profile',
|
|
|
|
|
() async {
|
|
|
|
|
notifier.put(profile(1, label: 'Home'));
|
|
|
|
|
for (final id in [2, 3]) {
|
|
|
|
|
notifier.put(custom(id));
|
|
|
|
|
}
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
for (final id in [2, 3]) {
|
|
|
|
|
await testDatabase.proxyGroups.put(
|
|
|
|
|
ProxyGroup(
|
|
|
|
|
id: id,
|
|
|
|
|
name: 'Group',
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
use: const ['Home', 'Other'],
|
|
|
|
|
).toCompanion(id),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
notifier.put(profile(1, label: 'Away'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
Future<List<String>?> use(int id) async =>
|
|
|
|
|
(await testDatabase.proxyGroupsDao.query(id).get()).single.use;
|
|
|
|
|
expect(await use(2), ['Away', 'Other']);
|
|
|
|
|
expect(await use(3), ['Away', 'Other']);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('a new label follows into the provider options of every custom '
|
|
|
|
|
'profile', () async {
|
|
|
|
|
const options = ProxyProviderOptions(filter: 'HK');
|
|
|
|
|
notifier.put(profile(1, label: 'Home'));
|
2026-09-25 03:56:59 +08:00
|
|
|
for (final id in [2, 3]) {
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier.put(
|
|
|
|
|
custom(id).copyWith(
|
|
|
|
|
overrides: const ProfileOverrides(
|
|
|
|
|
proxyProviders: {'Home': options},
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-09-25 03:56:59 +08:00
|
|
|
);
|
|
|
|
|
}
|
2026-10-08 17:58:43 +08:00
|
|
|
await pumpEventQueue();
|
2026-09-25 03:56:59 +08:00
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier.put(profile(1, label: 'Away'));
|
2026-09-25 03:56:59 +08:00
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
Map<String, ProxyProviderOptions> optionsOf(int id) =>
|
|
|
|
|
read().firstWhere((item) => item.id == id).overrides.proxyProviders;
|
|
|
|
|
expect(optionsOf(2), {'Away': options});
|
|
|
|
|
expect(optionsOf(3), {'Away': options});
|
|
|
|
|
final rows = await testDatabase.profilesDao.query().get();
|
|
|
|
|
expect(rows.firstWhere((item) => item.id == 3).overrides.proxyProviders, {
|
|
|
|
|
'Away': options,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a custom profile is never a provider, so its label renames '
|
|
|
|
|
'nothing', () async {
|
|
|
|
|
notifier.put(custom(2));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await testDatabase.proxyGroups.put(
|
|
|
|
|
const ProxyGroup(
|
|
|
|
|
id: 9,
|
|
|
|
|
name: 'Group',
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
use: ['Custom 2'],
|
|
|
|
|
).toCompanion(2),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
notifier.put(custom(2).copyWith(label: 'Renamed'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final group = (await testDatabase.proxyGroupsDao.query(2).get()).single;
|
|
|
|
|
expect(group.use, ['Custom 2']);
|
2026-09-25 03:56:59 +08:00
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
test('put restores the previous list when the write fails', () async {
|
|
|
|
|
notifier.put(profile(1, label: 'Kept'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('profiles');
|
|
|
|
|
|
|
|
|
|
final failure = captureWriteFailure(
|
|
|
|
|
() => notifier.put(profile(2, label: 'Lost')),
|
|
|
|
|
);
|
|
|
|
|
expect(read().map((item) => item.id), [2, 1], reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().map((item) => item.id), [1], reason: 'rolled back');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('del awaits the write and rethrows after rolling back', () async {
|
|
|
|
|
notifier.put(profile(1, label: 'Kept'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('profiles');
|
|
|
|
|
|
|
|
|
|
await expectLater(notifier.del(1), throwsA(isA<Exception>()));
|
|
|
|
|
expect(read().map((item) => item.id), [1]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('del removes the row when the write succeeds', () async {
|
|
|
|
|
notifier.put(profile(1, label: 'Gone'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
await notifier.del(1);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read(), isEmpty);
|
|
|
|
|
expect(await testDatabase.profilesDao.query().get(), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('updateProfile ignores an id that is not in state', () async {
|
|
|
|
|
notifier.put(profile(1, label: 'Only'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.updateProfile(404, (item) => item.copyWith(label: 'Never'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().single.label, 'Only');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('updateProfile applies the builder and persists it', () async {
|
|
|
|
|
notifier.put(profile(1, label: 'Before'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.updateProfile(1, (item) => item.copyWith(label: 'After'));
|
|
|
|
|
expect(read().single.label, 'After');
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.profilesDao.query().get();
|
|
|
|
|
expect(rows.single.label, 'After');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'updateProfile restores the previous list when the write fails',
|
|
|
|
|
() async {
|
|
|
|
|
notifier.put(profile(1, label: 'Before'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('profiles');
|
|
|
|
|
|
|
|
|
|
final failure = captureWriteFailure(
|
|
|
|
|
() => notifier.updateProfile(
|
|
|
|
|
1,
|
|
|
|
|
(item) => item.copyWith(label: 'After'),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(read().single.label, 'After', reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().single.label, 'Before', reason: 'rolled back');
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('reorder only writes the rows whose order actually changed', () async {
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier
|
|
|
|
|
..put(profile(1, label: 'One', order: 0))
|
|
|
|
|
..put(profile(2, label: 'Two', order: 1));
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.reorder([
|
|
|
|
|
profile(2, label: 'Two', order: 1),
|
|
|
|
|
profile(1, label: 'One', order: 0),
|
|
|
|
|
]);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.profilesDao.query().get();
|
|
|
|
|
expect(rows.map((item) => item.id), [2, 1]);
|
|
|
|
|
expect(rows.map((item) => item.order), [0, 1]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('reorder restores the previous order when the write fails', () async {
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier
|
|
|
|
|
..put(profile(1, label: 'One', order: 0))
|
|
|
|
|
..put(profile(2, label: 'Two', order: 1));
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('profiles');
|
|
|
|
|
|
|
|
|
|
final failure = captureWriteFailure(
|
|
|
|
|
() => notifier.reorder([
|
|
|
|
|
profile(2, label: 'Two', order: 1),
|
|
|
|
|
profile(1, label: 'One', order: 0),
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
expect(read().map((item) => item.id), [2, 1], reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().map((item) => item.id), [1, 2], reason: 'rolled back');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('Scripts', () {
|
|
|
|
|
late Scripts notifier;
|
|
|
|
|
|
|
|
|
|
Script script(int id, String label) =>
|
|
|
|
|
Script(id: id, label: label, lastUpdateTime: DateTime(2026));
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
await keepAlive(scriptsProvider);
|
|
|
|
|
notifier = container.read(scriptsProvider.notifier);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
List<Script> read() => notifier.value;
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
test('order persists the new positions and lists by them', () async {
|
|
|
|
|
notifier.put(script(1, 'First'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(script(2, 'Second'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(script(3, 'Third'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.order(2, 0);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().map((item) => item.label), ['Third', 'First', 'Second']);
|
|
|
|
|
final rows = await testDatabase.scriptsDao.query().get();
|
|
|
|
|
expect(rows.map((item) => item.label), ['Third', 'First', 'Second']);
|
|
|
|
|
expect(rows.map((item) => item.order), [0, 1, 2]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('a row written around the notifier during a write is read back '
|
|
|
|
|
'once the write settles', () async {
|
|
|
|
|
final landing = Completer<void>();
|
|
|
|
|
final write = notifier.optimisticAsync(read(), () => landing.future);
|
|
|
|
|
await testDatabase.scripts.put(script(1, 'Direct').toCompanion());
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(read(), isEmpty);
|
|
|
|
|
|
|
|
|
|
landing.complete();
|
|
|
|
|
await write;
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().map((item) => item.label), ['Direct']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
test('put appends a new script and replaces an existing one', () async {
|
|
|
|
|
notifier.put(script(1, 'First'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(script(2, 'Second'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().map((item) => item.label), ['First', 'Second']);
|
|
|
|
|
|
|
|
|
|
notifier.put(script(1, 'Renamed'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.scriptsDao.query().get();
|
|
|
|
|
expect(
|
|
|
|
|
rows.map((item) => item.label),
|
|
|
|
|
containsAll(['Renamed', 'Second']),
|
|
|
|
|
);
|
|
|
|
|
expect(rows, hasLength(2));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('put restores the previous list when the write fails', () async {
|
|
|
|
|
notifier.put(script(1, 'Kept'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('scripts');
|
|
|
|
|
|
|
|
|
|
final failure = captureWriteFailure(
|
|
|
|
|
() => notifier.put(script(2, 'Lost')),
|
|
|
|
|
);
|
|
|
|
|
expect(read(), hasLength(2), reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().map((item) => item.label), ['Kept'], reason: 'rolled back');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('del ignores an id that is not in state', () async {
|
|
|
|
|
notifier.put(script(1, 'Only'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('scripts');
|
|
|
|
|
|
|
|
|
|
notifier.del(404);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().map((item) => item.label), ['Only']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('del removes the row and rolls back a failed write', () async {
|
|
|
|
|
notifier.put(script(1, 'First'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(script(2, 'Second'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.del(1);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(read().map((item) => item.id), [2]);
|
|
|
|
|
|
|
|
|
|
await breakTable('scripts');
|
|
|
|
|
final failure = captureWriteFailure(() => notifier.del(2));
|
|
|
|
|
expect(read(), isEmpty, reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().map((item) => item.id), [2], reason: 'rolled back');
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
test('put persists the source url and clears it again', () async {
|
|
|
|
|
const url = 'https://example.com/override.js';
|
|
|
|
|
notifier.put(script(1, 'Remote').copyWith(url: url));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect((await testDatabase.scriptsDao.get(1).getSingle()).url, url);
|
|
|
|
|
|
|
|
|
|
notifier.put(script(1, 'Remote'));
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
expect((await testDatabase.scriptsDao.get(1).getSingle()).url, isNull);
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('GlobalRules', () {
|
|
|
|
|
late GlobalRules notifier;
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
await keepAlive(globalRulesProvider);
|
|
|
|
|
notifier = container.read(globalRulesProvider.notifier);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
List<Rule> read() => notifier.value;
|
|
|
|
|
|
|
|
|
|
test('put assigns an order to a rule that has none', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'first'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().single.order, isNotNull);
|
2026-10-08 17:58:43 +08:00
|
|
|
final rows = await testDatabase.rulesDao.queryGlobalRules().get();
|
2026-09-05 20:09:39 +08:00
|
|
|
expect(rows.single.order, isNotNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('put keeps an order the caller already supplied', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'first', order: 'm'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().single.order, 'm');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('put prepends so the newest rule sorts first', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'first'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(const Rule(id: 2, content: 'second'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
final rows = await testDatabase.rulesDao.queryGlobalRules().get();
|
2026-09-05 20:09:39 +08:00
|
|
|
expect(rows.map((item) => item.id), [2, 1]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
test(
|
|
|
|
|
'putAll puts the batch ahead of existing rules in its order',
|
|
|
|
|
() async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'existing'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.putAll(const [
|
|
|
|
|
Rule(id: 2, content: 'second'),
|
|
|
|
|
Rule(id: 3, content: 'third'),
|
|
|
|
|
]);
|
|
|
|
|
expect(read().map((item) => item.id), [2, 3, 1], reason: 'optimistic');
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
final rows = await testDatabase.rulesDao.queryGlobalRules().get();
|
2026-09-25 03:56:58 +08:00
|
|
|
expect(rows.map((item) => item.id), [2, 3, 1]);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
test('delAll removes every listed rule', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'first'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(const Rule(id: 2, content: 'second'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.delAll([1, 2]);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read(), isEmpty);
|
2026-10-08 17:58:43 +08:00
|
|
|
expect(await testDatabase.rulesDao.queryGlobalRules().get(), isEmpty);
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('delAll restores the previous list when the write fails', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'first'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('rules');
|
|
|
|
|
|
|
|
|
|
final failure = captureWriteFailure(() => notifier.delAll([1]));
|
|
|
|
|
expect(read(), isEmpty, reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().map((item) => item.id), [1], reason: 'rolled back');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('order moves a rule and rewrites only its order key', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'first'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(const Rule(id: 2, content: 'second'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final before = read().map((item) => item.id).toList();
|
|
|
|
|
notifier.order(0, 1);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
final rows = await testDatabase.rulesDao.queryGlobalRules().get();
|
2026-09-05 20:09:39 +08:00
|
|
|
expect(rows.map((item) => item.id), before.reversed);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
group('ProfileRules', () {
|
|
|
|
|
late ProfileRules notifier;
|
2026-09-05 20:09:39 +08:00
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
await testDatabase.profilesDao.putAll([profile(profileId).toCompanion()]);
|
2026-10-08 17:58:43 +08:00
|
|
|
await keepAlive(profileRulesProvider(profileId));
|
|
|
|
|
notifier = container.read(profileRulesProvider(profileId).notifier);
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
List<Rule> read() => notifier.value;
|
|
|
|
|
|
|
|
|
|
test('put scopes the rule to its profile', () async {
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier.put(const Rule(id: 1, content: 'own'));
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().single.id, 1);
|
|
|
|
|
final scoped = await testDatabase.rulesDao
|
2026-10-08 17:58:43 +08:00
|
|
|
.queryProfileRules(profileId)
|
2026-09-05 20:09:39 +08:00
|
|
|
.get();
|
|
|
|
|
expect(scoped.single.id, 1);
|
|
|
|
|
expect(
|
2026-10-08 17:58:43 +08:00
|
|
|
await testDatabase.rulesDao.queryGlobalRules().get(),
|
2026-09-05 20:09:39 +08:00
|
|
|
isEmpty,
|
2026-10-08 17:58:43 +08:00
|
|
|
reason: 'a profile rule must not leak into the global rules',
|
2026-09-05 20:09:39 +08:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('delAll and order round-trip through the profile rules', () async {
|
2026-09-05 20:09:39 +08:00
|
|
|
notifier.put(const Rule(id: 1, content: 'one'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(const Rule(id: 2, content: 'two'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final before = read().map((item) => item.id).toList();
|
|
|
|
|
notifier.order(0, 1);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(
|
2026-10-08 17:58:43 +08:00
|
|
|
(await testDatabase.rulesDao.queryProfileRules(profileId).get()).map(
|
|
|
|
|
(item) => item.id,
|
|
|
|
|
),
|
2026-09-05 20:09:39 +08:00
|
|
|
before.reversed,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
notifier.delAll([1]);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(read().map((item) => item.id), [2]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('put restores the previous list when the write fails', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'one'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('rules');
|
|
|
|
|
|
|
|
|
|
final failure = captureWriteFailure(
|
|
|
|
|
() => notifier.put(const Rule(id: 2, content: 'two')),
|
|
|
|
|
);
|
|
|
|
|
expect(read(), hasLength(2), reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().map((item) => item.id), [1], reason: 'rolled back');
|
|
|
|
|
});
|
2026-10-08 17:58:43 +08:00
|
|
|
|
|
|
|
|
test('setAll replaces the list in the order given', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'one'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(const Rule(id: 2, content: 'two'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
notifier.setAll(const [
|
|
|
|
|
Rule(id: 3, content: 'three'),
|
|
|
|
|
Rule(id: 1, content: 'one'),
|
|
|
|
|
]);
|
|
|
|
|
expect(read().map((item) => item.id), [3, 1], reason: 'optimistic');
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.rulesDao
|
|
|
|
|
.queryProfileRules(profileId)
|
|
|
|
|
.get();
|
|
|
|
|
expect(rows.map((item) => item.id), [3, 1]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('delAll restores the previous list when the write fails', () async {
|
|
|
|
|
notifier.put(const Rule(id: 1, content: 'one'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('rules');
|
|
|
|
|
|
|
|
|
|
final failure = captureWriteFailure(() => notifier.delAll([1]));
|
|
|
|
|
expect(read(), isEmpty, reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().map((item) => item.id), [1], reason: 'rolled back');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a read-back during a write keeps the optimistic rule', () async {
|
|
|
|
|
final gated = _GatedProfileRules();
|
|
|
|
|
final scoped = ProviderContainer(
|
|
|
|
|
overrides: [profileRulesProvider.overrideWith2((_) => gated)],
|
|
|
|
|
);
|
|
|
|
|
addTearDown(scoped.dispose);
|
|
|
|
|
scoped.listen(profileRulesProvider(profileId), (_, _) {});
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
gated.put(const Rule(id: 1, content: 'mine'));
|
|
|
|
|
await testDatabase.rulesDao.putRule(
|
|
|
|
|
const Rule(id: 2, content: 'theirs'),
|
|
|
|
|
profileId: profileId,
|
|
|
|
|
);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(gated.value.map((item) => item.id), [1]);
|
|
|
|
|
|
|
|
|
|
gated.landing.complete();
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(gated.value.map((item) => item.id), unorderedEquals([1, 2]));
|
|
|
|
|
});
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
group('CustomProxies', () {
|
|
|
|
|
late CustomProxies notifier;
|
2026-09-05 20:09:39 +08:00
|
|
|
|
|
|
|
|
setUp(() async {
|
2026-10-08 17:58:43 +08:00
|
|
|
await keepAlive(customProxiesProvider);
|
|
|
|
|
notifier = container.read(customProxiesProvider.notifier);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
CustomProxy proxy(int id) => CustomProxy(
|
|
|
|
|
id: id,
|
|
|
|
|
definition: {'name': 'Proxy $id', 'type': 'socks5'},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('setAll keeps the dialers of the proxies it keeps', () async {
|
2026-09-05 20:09:39 +08:00
|
|
|
await testDatabase.profilesDao.putAll([profile(profileId).toCompanion()]);
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier.put(proxy(1));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(proxy(2));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await testDatabase.proxyDialersDao.set(profileId, 1, 'Relay');
|
|
|
|
|
await testDatabase.proxyDialersDao.set(profileId, 2, 'Relay');
|
|
|
|
|
|
|
|
|
|
notifier.setAll([proxy(3), proxy(1)]);
|
|
|
|
|
expect(notifier.value.map((item) => item.id), [
|
|
|
|
|
3,
|
|
|
|
|
1,
|
|
|
|
|
], reason: 'optimistic');
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.customProxiesDao.query().get();
|
|
|
|
|
expect(rows.map((item) => item.id), [3, 1]);
|
|
|
|
|
final dialers = await testDatabase.proxyDialersDao.query(profileId).get();
|
|
|
|
|
expect(dialers.map((item) => item.proxyId), [1]);
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('renaming a proxy rewrites what custom profiles name it by', () async {
|
|
|
|
|
const standardId = 2;
|
|
|
|
|
const overrides = ProfileOverrides(
|
|
|
|
|
dns: Dns(
|
|
|
|
|
nameserver: [
|
|
|
|
|
'https://dns.example/dns-query#Proxy 1',
|
|
|
|
|
'tls://1.1.1.1#Proxy 2',
|
|
|
|
|
],
|
|
|
|
|
nameserverPolicy: {
|
|
|
|
|
'+.example.com': '1.1.1.1#Proxy%201, 8.8.8.8',
|
|
|
|
|
'+.other.com': '8.8.8.8;9.9.9.9',
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
ntp: Ntp(dialerProxy: 'Proxy 1'),
|
|
|
|
|
);
|
|
|
|
|
await testDatabase.profilesDao.putAll([
|
|
|
|
|
profile(profileId)
|
|
|
|
|
.copyWith(type: ProfileType.custom, overrides: overrides)
|
|
|
|
|
.toCompanion(),
|
|
|
|
|
profile(standardId).copyWith(overrides: overrides).toCompanion(),
|
|
|
|
|
]);
|
|
|
|
|
notifier.put(proxy(1));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(proxy(2));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await testDatabase.proxyGroups.put(
|
|
|
|
|
const ProxyGroup(
|
|
|
|
|
id: 7,
|
|
|
|
|
name: 'Landing',
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
proxies: ['Proxy 1', 'Proxy 2'],
|
|
|
|
|
defaultSelected: 'Proxy 1',
|
|
|
|
|
order: 'a',
|
|
|
|
|
).toCompanion(profileId),
|
|
|
|
|
);
|
|
|
|
|
for (final (id, owner) in [(8, profileId), (9, standardId)]) {
|
|
|
|
|
await testDatabase.rulesDao.putRule(
|
|
|
|
|
Rule(id: id, content: 'x', ruleTarget: 'Proxy 1', order: 'a'),
|
|
|
|
|
profileId: owner,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await testDatabase.proxyDialersDao.set(profileId, 2, 'Proxy 1');
|
2026-09-05 20:09:39 +08:00
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier.put(
|
|
|
|
|
proxy(1).copyWith(definition: {'name': 'Renamed', 'type': 'socks5'}),
|
|
|
|
|
);
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
final group =
|
|
|
|
|
(await testDatabase.proxyGroupsDao.query(profileId).get()).single;
|
|
|
|
|
expect(group.proxies, ['Renamed', 'Proxy 2']);
|
|
|
|
|
expect(group.defaultSelected, 'Renamed');
|
|
|
|
|
Future<String?> ruleTarget(int owner) async =>
|
|
|
|
|
(await testDatabase.rulesDao.queryProfileRules(owner).get())
|
|
|
|
|
.single
|
|
|
|
|
.ruleTarget;
|
|
|
|
|
expect(await ruleTarget(profileId), 'Renamed');
|
|
|
|
|
expect(await ruleTarget(standardId), 'Proxy 1');
|
|
|
|
|
final dialers = await testDatabase.proxyDialersDao.query(profileId).get();
|
|
|
|
|
expect(dialers.single.target, 'Renamed');
|
|
|
|
|
final [custom, standard] = await testDatabase.profilesDao.query().get();
|
|
|
|
|
expect(custom.overrides.dns.nameserver, [
|
|
|
|
|
'https://dns.example/dns-query#Renamed',
|
|
|
|
|
'tls://1.1.1.1#Proxy 2',
|
|
|
|
|
]);
|
|
|
|
|
expect(custom.overrides.dns.nameserverPolicy, {
|
|
|
|
|
'+.example.com': '1.1.1.1#Renamed, 8.8.8.8',
|
|
|
|
|
'+.other.com': '8.8.8.8;9.9.9.9',
|
|
|
|
|
});
|
|
|
|
|
expect(custom.overrides.ntp.dialerProxy, 'Renamed');
|
|
|
|
|
expect(standard.overrides, overrides);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Profile custom(int id, {ProfileOverrides? overrides}) =>
|
|
|
|
|
profile(id, label: 'Custom $id').copyWith(
|
|
|
|
|
type: ProfileType.custom,
|
|
|
|
|
overrides: overrides ?? const ProfileOverrides(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Future<void> putGroup(int owner, ProxyGroup group) =>
|
|
|
|
|
testDatabase.proxyGroups.put(group.toCompanion(owner));
|
|
|
|
|
|
|
|
|
|
test('renaming a proxy leaves the profiles where a group takes either '
|
|
|
|
|
'name', () async {
|
|
|
|
|
const ntp = ProfileOverrides(
|
|
|
|
|
ntp: Ntp(dialerProxy: 'Proxy 1'),
|
|
|
|
|
ntpOverrideKeys: {NtpOverrideKey.dialerProxy},
|
2026-09-05 20:09:39 +08:00
|
|
|
);
|
2026-10-08 17:58:43 +08:00
|
|
|
await testDatabase.profilesDao.putAll([
|
|
|
|
|
for (final id in [1, 2, 3]) custom(id, overrides: ntp).toCompanion(),
|
|
|
|
|
]);
|
|
|
|
|
notifier.put(proxy(1));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await putGroup(
|
|
|
|
|
1,
|
|
|
|
|
const ProxyGroup(
|
|
|
|
|
id: 11,
|
|
|
|
|
name: 'Landing',
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
proxies: ['Proxy 1'],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await putGroup(
|
|
|
|
|
2,
|
|
|
|
|
const ProxyGroup(
|
|
|
|
|
id: 21,
|
|
|
|
|
name: 'Proxy 1',
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
proxies: ['DIRECT'],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await putGroup(
|
|
|
|
|
3,
|
|
|
|
|
const ProxyGroup(
|
|
|
|
|
id: 31,
|
|
|
|
|
name: 'Renamed',
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
proxies: ['DIRECT'],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
for (final owner in [1, 2, 3]) {
|
|
|
|
|
await testDatabase.rulesDao.putRule(
|
|
|
|
|
Rule(
|
|
|
|
|
id: 100 + owner,
|
|
|
|
|
content: 'x',
|
|
|
|
|
ruleTarget: 'Proxy 1',
|
|
|
|
|
order: 'a',
|
|
|
|
|
),
|
|
|
|
|
profileId: owner,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
notifier.put(
|
|
|
|
|
proxy(1).copyWith(definition: {'name': 'Renamed', 'type': 'socks5'}),
|
|
|
|
|
);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
Future<String?> ruleTarget(int owner) async =>
|
|
|
|
|
(await testDatabase.rulesDao.queryProfileRules(owner).get())
|
|
|
|
|
.single
|
|
|
|
|
.ruleTarget;
|
|
|
|
|
final rows = {
|
|
|
|
|
for (final row in await testDatabase.profilesDao.query().get())
|
|
|
|
|
row.id: row,
|
|
|
|
|
};
|
|
|
|
|
expect(await ruleTarget(1), 'Renamed');
|
|
|
|
|
expect(rows[1]!.overrides.ntp.dialerProxy, 'Renamed');
|
|
|
|
|
for (final owner in [2, 3]) {
|
|
|
|
|
expect(await ruleTarget(owner), 'Proxy 1', reason: '$owner');
|
|
|
|
|
expect(rows[owner]!.overrides, ntp, reason: '$owner');
|
|
|
|
|
}
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('renaming a proxy moves the picks of the groups listing it', () async {
|
|
|
|
|
await keepAlive(profilesProvider);
|
|
|
|
|
container
|
|
|
|
|
.read(profilesProvider.notifier)
|
|
|
|
|
.put(
|
|
|
|
|
custom(1).copyWith(
|
|
|
|
|
selectedMap: const {'Landing': 'Proxy 1', 'Pool': 'Proxy 1'},
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier.put(proxy(1));
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
2026-10-08 17:58:43 +08:00
|
|
|
await putGroup(
|
|
|
|
|
1,
|
|
|
|
|
const ProxyGroup(
|
|
|
|
|
id: 11,
|
|
|
|
|
name: 'Landing',
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
proxies: ['Proxy 1'],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await putGroup(
|
|
|
|
|
1,
|
|
|
|
|
const ProxyGroup(
|
|
|
|
|
id: 12,
|
|
|
|
|
name: 'Pool',
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
use: ['Subscription'],
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-09-05 20:09:39 +08:00
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier.put(
|
|
|
|
|
proxy(1).copyWith(definition: {'name': 'Renamed', 'type': 'socks5'}),
|
|
|
|
|
);
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
const picks = {'Landing': 'Renamed', 'Pool': 'Proxy 1'};
|
|
|
|
|
expect(container.read(profilesProvider).single.selectedMap, picks);
|
2026-09-05 20:09:39 +08:00
|
|
|
expect(
|
2026-10-08 17:58:43 +08:00
|
|
|
(await testDatabase.profilesDao.query().get()).single.selectedMap,
|
|
|
|
|
picks,
|
2026-09-05 20:09:39 +08:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test(
|
|
|
|
|
'a profile written as a proxy is renamed keeps both changes',
|
|
|
|
|
() async {
|
|
|
|
|
const dns = ProfileOverrides(
|
|
|
|
|
dns: Dns(nameserver: ['1.1.1.1#Proxy 1']),
|
|
|
|
|
dnsOverrideKeys: {DnsOverrideKey.nameserver},
|
|
|
|
|
);
|
|
|
|
|
await keepAlive(profilesProvider);
|
|
|
|
|
final profiles = container.read(profilesProvider.notifier);
|
|
|
|
|
profiles.put(custom(1, overrides: dns));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(proxy(1));
|
|
|
|
|
await pumpEventQueue();
|
2026-09-05 20:09:39 +08:00
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
notifier.put(
|
|
|
|
|
proxy(1).copyWith(definition: {'name': 'Renamed', 'type': 'socks5'}),
|
|
|
|
|
);
|
|
|
|
|
profiles.put(
|
|
|
|
|
container
|
|
|
|
|
.read(profilesProvider)
|
|
|
|
|
.single
|
|
|
|
|
.copyWith(currentGroupName: 'Landing'),
|
|
|
|
|
);
|
|
|
|
|
await pumpEventQueue();
|
2026-09-05 20:09:39 +08:00
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
final row = (await testDatabase.profilesDao.query().get()).single;
|
|
|
|
|
expect(row.overrides.dns.nameserver, ['1.1.1.1#Renamed']);
|
|
|
|
|
expect(row.currentGroupName, 'Landing');
|
|
|
|
|
expect(container.read(profilesProvider).single, row);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('ClashProviders', () {
|
|
|
|
|
test(
|
|
|
|
|
'renaming a set renames it wherever a custom profile names it',
|
|
|
|
|
() async {
|
|
|
|
|
const overrides = ProfileOverrides(
|
|
|
|
|
dns: Dns(
|
|
|
|
|
nameserverPolicy: {'rule-set:ads,cn': '223.5.5.5'},
|
|
|
|
|
fakeIpFilter: ['rule-set:ads'],
|
|
|
|
|
),
|
|
|
|
|
dnsOverrideKeys: {
|
|
|
|
|
DnsOverrideKey.nameserverPolicy,
|
|
|
|
|
DnsOverrideKey.fakeIpFilter,
|
|
|
|
|
},
|
|
|
|
|
sniffer: Sniffer(skipDomain: ['rule-set:ads']),
|
|
|
|
|
snifferOverrideKeys: {SnifferOverrideKey.skipDomain},
|
|
|
|
|
);
|
|
|
|
|
await keepAlive(profilesProvider);
|
|
|
|
|
await keepAlive(clashProvidersProvider);
|
|
|
|
|
container
|
|
|
|
|
.read(profilesProvider.notifier)
|
|
|
|
|
.put(
|
|
|
|
|
profile(
|
|
|
|
|
profileId,
|
|
|
|
|
label: 'Custom',
|
|
|
|
|
).copyWith(type: ProfileType.custom, overrides: overrides),
|
|
|
|
|
);
|
|
|
|
|
final providers = container.read(clashProvidersProvider.notifier);
|
|
|
|
|
providers.put(const ClashProvider(id: 5, label: 'ads'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await testDatabase.rulesDao.putRule(
|
|
|
|
|
const Rule(
|
|
|
|
|
id: 9,
|
|
|
|
|
ruleAction: RuleAction.RULE_SET,
|
|
|
|
|
ruleProvider: 'ads',
|
|
|
|
|
ruleTarget: 'REJECT',
|
|
|
|
|
order: 'a',
|
|
|
|
|
),
|
|
|
|
|
profileId: profileId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
providers.put(const ClashProvider(id: 5, label: 'block'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rule =
|
|
|
|
|
(await testDatabase.rulesDao.queryProfileRules(profileId).get())
|
|
|
|
|
.single;
|
|
|
|
|
expect(rule.ruleProvider, 'block');
|
|
|
|
|
final row = (await testDatabase.profilesDao.query().get()).single;
|
|
|
|
|
expect(row.overrides.ruleSets, {'block', 'cn'});
|
|
|
|
|
expect(row.overrides.dns.nameserverPolicy.keys, ['rule-set:block,cn']);
|
|
|
|
|
expect(row.overrides.dns.fakeIpFilter, ['rule-set:block']);
|
|
|
|
|
expect(row.overrides.sniffer.skipDomain, ['rule-set:block']);
|
|
|
|
|
expect(container.read(profilesProvider).single, row);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('ProfileDisabledRuleIds', () {
|
|
|
|
|
late ProfileDisabledRuleIds notifier;
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
await testDatabase.profilesDao.putAll([profile(profileId).toCompanion()]);
|
2026-10-08 17:58:43 +08:00
|
|
|
await testDatabase.rulesDao.putRule(
|
2026-09-05 20:09:39 +08:00
|
|
|
const Rule(id: 7, content: 'toggled', order: 'a'),
|
|
|
|
|
);
|
|
|
|
|
await keepAlive(profileDisabledRuleIdsProvider(profileId));
|
|
|
|
|
notifier = container.read(
|
|
|
|
|
profileDisabledRuleIdsProvider(profileId).notifier,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
List<int> read() => notifier.value;
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('put disables the rule and del enables it again', () async {
|
2026-09-05 20:09:39 +08:00
|
|
|
notifier.put(7);
|
|
|
|
|
expect(read(), [7]);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(
|
2026-10-08 17:58:43 +08:00
|
|
|
await testDatabase.rulesDao.queryDisabledRuleIds(profileId).get(),
|
2026-09-05 20:09:39 +08:00
|
|
|
[7],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
notifier.del(7);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(read(), isEmpty);
|
|
|
|
|
expect(
|
2026-10-08 17:58:43 +08:00
|
|
|
await testDatabase.rulesDao.queryDisabledRuleIds(profileId).get(),
|
2026-09-05 20:09:39 +08:00
|
|
|
isEmpty,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('put is idempotent for an id that is already disabled', () async {
|
|
|
|
|
notifier.put(7);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
notifier.put(7);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read(), [7]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('put restores the previous ids when the write fails', () async {
|
2026-10-08 17:58:43 +08:00
|
|
|
await breakTable('disabled_rules');
|
2026-09-05 20:09:39 +08:00
|
|
|
|
|
|
|
|
final failure = captureWriteFailure(() => notifier.put(7));
|
|
|
|
|
expect(read(), [7], reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read(), isEmpty, reason: 'rolled back');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('ProxyGroups', () {
|
|
|
|
|
late ProxyGroups notifier;
|
|
|
|
|
|
|
|
|
|
ProxyGroup group(int id, String name, {List<String>? proxies}) =>
|
|
|
|
|
ProxyGroup(
|
|
|
|
|
id: id,
|
|
|
|
|
name: name,
|
|
|
|
|
type: GroupType.Selector,
|
|
|
|
|
proxies: proxies,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
await testDatabase.profilesDao.putAll([profile(profileId).toCompanion()]);
|
|
|
|
|
await keepAlive(proxyGroupsProvider(profileId));
|
|
|
|
|
notifier = container.read(proxyGroupsProvider(profileId).notifier);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
List<ProxyGroup> read() => notifier.value;
|
|
|
|
|
|
|
|
|
|
test('put assigns an order to a newly added group', () async {
|
|
|
|
|
expect(notifier.put(group(1, 'First')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read().single.order, isNotNull);
|
|
|
|
|
final rows = await testDatabase.proxyGroupsDao.query(profileId).get();
|
|
|
|
|
expect(rows.single.name, 'First');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('consecutive additions get distinct persisted order keys', () async {
|
|
|
|
|
expect(notifier.put(group(1, 'One')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(notifier.put(group(2, 'Two')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.proxyGroupsDao.query(profileId).get();
|
|
|
|
|
final orders = rows.map((item) => item.order).toList();
|
|
|
|
|
expect(orders, everyElement(isNotNull));
|
|
|
|
|
expect(orders.toSet(), hasLength(2));
|
|
|
|
|
expect(rows.map((item) => item.id), [
|
|
|
|
|
1,
|
|
|
|
|
2,
|
|
|
|
|
], reason: 'new groups are appended, so the keys must be increasing');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('put rejects a second group that reuses an existing name', () async {
|
|
|
|
|
expect(notifier.put(group(1, 'Taken')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(notifier.put(group(2, 'Taken')), isFalse);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(read(), hasLength(1));
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('renaming a group rewrites the rules that target it', () async {
|
2026-09-05 20:09:39 +08:00
|
|
|
expect(notifier.put(group(1, 'Old')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
2026-10-08 17:58:43 +08:00
|
|
|
await testDatabase.rulesDao.putRule(
|
2026-09-05 20:09:39 +08:00
|
|
|
const Rule(id: 9, content: 'x', ruleTarget: 'Old', order: 'a'),
|
2026-10-08 17:58:43 +08:00
|
|
|
profileId: profileId,
|
2026-09-05 20:09:39 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(notifier.put(group(1, 'New')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rules = await testDatabase.rulesDao
|
2026-10-08 17:58:43 +08:00
|
|
|
.queryProfileRules(profileId)
|
2026-09-05 20:09:39 +08:00
|
|
|
.get();
|
|
|
|
|
expect(rules.single.ruleTarget, 'New');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('renaming a group rewrites references from sibling groups', () async {
|
|
|
|
|
expect(notifier.put(group(1, 'Old')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(notifier.put(group(2, 'Parent', proxies: ['Old'])), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(notifier.put(group(1, 'New')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.proxyGroupsDao.query(profileId).get();
|
|
|
|
|
final parent = rows.firstWhere((item) => item.id == 2);
|
|
|
|
|
expect(parent.proxies, ['New']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('renaming a group keeps every member list readable', () async {
|
|
|
|
|
expect(notifier.put(group(1, ',')), isTrue);
|
|
|
|
|
expect(notifier.put(group(2, 'Old')), isTrue);
|
|
|
|
|
expect(
|
|
|
|
|
notifier.put(group(3, 'Parent', proxies: ['x', 'Old', 'y'])),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(notifier.put(group(1, 'Comma')), isTrue);
|
|
|
|
|
expect(notifier.put(group(2, 'Say "hi" \\o/')), isTrue);
|
|
|
|
|
expect(read().firstWhere((item) => item.id == 3).proxies, [
|
|
|
|
|
'x',
|
|
|
|
|
'Say "hi" \\o/',
|
|
|
|
|
'y',
|
|
|
|
|
], reason: 'optimistic');
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.proxyGroupsDao.query(profileId).get();
|
|
|
|
|
expect(rows.firstWhere((item) => item.id == 3).proxies, [
|
|
|
|
|
'x',
|
|
|
|
|
'Say "hi" \\o/',
|
|
|
|
|
'y',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('renaming a group moves its picks, tab and unfolding', () async {
|
|
|
|
|
await testDatabase.profilesDao.putAll([
|
|
|
|
|
profile(profileId)
|
|
|
|
|
.copyWith(
|
|
|
|
|
type: ProfileType.custom,
|
|
|
|
|
selectedMap: const {'Old': 'DIRECT', 'Parent': 'Old'},
|
|
|
|
|
currentGroupName: 'Old',
|
|
|
|
|
unfoldSet: const {'Old', 'Parent'},
|
|
|
|
|
)
|
|
|
|
|
.toCompanion(),
|
|
|
|
|
]);
|
|
|
|
|
expect(notifier.put(group(1, 'Old')), isTrue);
|
|
|
|
|
expect(notifier.put(group(2, 'Parent', proxies: ['Old'])), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(notifier.put(group(1, 'New')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final row = (await testDatabase.profilesDao.query().get()).single;
|
|
|
|
|
expect(row.selectedMap, {'New': 'DIRECT', 'Parent': 'New'});
|
|
|
|
|
expect(row.currentGroupName, 'New');
|
|
|
|
|
expect(row.unfoldSet, {'New', 'Parent'});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
test('put records the icon so it can be reused later', () async {
|
|
|
|
|
expect(
|
|
|
|
|
notifier.put(group(1, 'Iconic').copyWith(icon: 'https://icon.example')),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await testDatabase.iconRecordsDao.get('https://icon.example'),
|
|
|
|
|
isNotNull,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
test('delAll removes every listed group', () async {
|
2026-09-05 20:09:39 +08:00
|
|
|
expect(notifier.put(group(1, 'Gone')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
2026-09-25 03:56:58 +08:00
|
|
|
expect(notifier.put(group(2, 'AlsoGone')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(notifier.put(group(3, 'Kept')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
2026-09-05 20:09:39 +08:00
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
notifier.delAll([1, 2]);
|
2026-09-05 20:09:39 +08:00
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
expect(read().map((item) => item.id), [3]);
|
|
|
|
|
final rows = await testDatabase.proxyGroupsDao.query(profileId).get();
|
|
|
|
|
expect(rows.map((item) => item.id), [3]);
|
2026-09-05 20:09:39 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('order moves a group and persists the new key', () async {
|
|
|
|
|
expect(notifier.put(group(1, 'One')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
expect(notifier.put(group(2, 'Two')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final before = read().map((item) => item.id).toList();
|
|
|
|
|
notifier.order(0, 1);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
final rows = await testDatabase.proxyGroupsDao.query(profileId).get();
|
|
|
|
|
expect(rows.map((item) => item.id), before.reversed);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
test('delAll restores the previous list when the write fails', () async {
|
2026-09-05 20:09:39 +08:00
|
|
|
expect(notifier.put(group(1, 'Kept')), isTrue);
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
await breakTable('proxy_groups');
|
|
|
|
|
|
2026-09-25 03:56:58 +08:00
|
|
|
final failure = captureWriteFailure(() => notifier.delAll([1]));
|
2026-09-05 20:09:39 +08:00
|
|
|
expect(read(), isEmpty, reason: 'optimistic');
|
|
|
|
|
|
|
|
|
|
await failure;
|
|
|
|
|
expect(read().map((item) => item.name), ['Kept'], reason: 'rolled back');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-02 10:14:13 +08:00
|
|
|
}
|
2026-10-08 17:58:43 +08:00
|
|
|
|
|
|
|
|
class _GatedProfileRules extends ProfileRules {
|
|
|
|
|
final landing = Completer<void>();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> persistRule(Rule rule) async {
|
|
|
|
|
await landing.future;
|
|
|
|
|
await super.persistRule(rule);
|
|
|
|
|
}
|
|
|
|
|
}
|