2026-08-05 10:57:11 +08:00
|
|
|
import 'dart:convert';
|
|
|
|
|
|
|
|
|
|
import 'package:fl_clash/common/migration.dart';
|
2026-10-16 22:42:17 +08:00
|
|
|
import 'package:fl_clash/common/constant.dart';
|
|
|
|
|
import 'package:fl_clash/enum/enum.dart';
|
2026-08-05 10:57:11 +08:00
|
|
|
import 'package:fl_clash/models/models.dart';
|
2026-10-16 22:42:17 +08:00
|
|
|
import 'package:fl_clash/ui/color.dart';
|
|
|
|
|
import 'package:material_ui/material_ui.dart' show ThemeMode;
|
2026-08-05 10:57:11 +08:00
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
group('Migration', () {
|
|
|
|
|
test('returns current config without rewriting storage', () async {
|
|
|
|
|
final configMap = _createConfigMap(
|
|
|
|
|
davProps: const DAVProps(
|
|
|
|
|
uri: 'https://example.com/dav',
|
|
|
|
|
user: 'user',
|
|
|
|
|
password: 'secret',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: configMap,
|
|
|
|
|
version: Migration.currentVersion,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final config = await Migration(store: store).run();
|
|
|
|
|
|
|
|
|
|
expect(config, Config.realFromJson(configMap));
|
|
|
|
|
expect(config.davProps?.password, 'secret');
|
|
|
|
|
expect(store.events, ['getConfigMap', 'getVersion']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'obfuscates a compatible DAV password without a version migration',
|
|
|
|
|
() async {
|
|
|
|
|
final configMap = _createConfigMap(
|
|
|
|
|
davProps: const DAVProps(
|
|
|
|
|
uri: 'https://example.com/dav',
|
|
|
|
|
user: 'user',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final davProps = configMap['davProps']! as Map<String, Object?>;
|
|
|
|
|
davProps['password'] = 'secret';
|
|
|
|
|
final store = _FakeMigrationStore(configMap: configMap, version: 1);
|
|
|
|
|
|
|
|
|
|
final config = await Migration(store: store).run();
|
|
|
|
|
|
|
|
|
|
expect(config.davProps?.user, 'user');
|
|
|
|
|
expect(config.davProps?.password, 'secret');
|
|
|
|
|
expect(store.savedConfig, config);
|
|
|
|
|
expect(store.version, Migration.currentVersion);
|
|
|
|
|
expect(store.events, ['getConfigMap', 'getVersion', 'saveConfig']);
|
|
|
|
|
final savedConfigMap =
|
|
|
|
|
jsonDecode(jsonEncode(store.savedConfig)) as Map<String, Object?>;
|
|
|
|
|
final savedDavProps =
|
|
|
|
|
savedConfigMap['davProps']! as Map<String, Object?>;
|
|
|
|
|
expect(savedDavProps['password'], startsWith('v1.'));
|
|
|
|
|
expect(savedDavProps['password'], isNot(contains('secret')));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'commits v0 cleanup and version only after migrated data is saved',
|
|
|
|
|
() async {
|
|
|
|
|
final configMap = <String, Object?>{
|
|
|
|
|
'proxiesStyle': <String, Object?>{},
|
|
|
|
|
'dav': <String, Object?>{
|
|
|
|
|
'uri': 'https://example.com/dav',
|
|
|
|
|
'user': 'user',
|
|
|
|
|
'password': 'secret',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: configMap,
|
|
|
|
|
version: 0,
|
|
|
|
|
clashConfigMap: <String, Object?>{'mixed-port': 7890},
|
|
|
|
|
);
|
|
|
|
|
final migration = Migration(
|
|
|
|
|
store: store,
|
|
|
|
|
migrateV0: (configMap) async {
|
|
|
|
|
store.events.add('migrateV0');
|
|
|
|
|
expect(configMap['patchClashConfig'], store.clashConfigMap);
|
|
|
|
|
return MigrationData(
|
|
|
|
|
configMap: _createConfigMap(
|
|
|
|
|
davProps: const DAVProps(
|
|
|
|
|
uri: 'https://example.com/dav',
|
|
|
|
|
user: 'user',
|
|
|
|
|
password: 'secret',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await migration.run();
|
|
|
|
|
|
|
|
|
|
expect(store.events, [
|
|
|
|
|
'getConfigMap',
|
|
|
|
|
'getVersion',
|
|
|
|
|
'getClashConfigMap',
|
|
|
|
|
'migrateV0',
|
|
|
|
|
'restore',
|
|
|
|
|
'saveConfig',
|
|
|
|
|
'clearClashConfig',
|
|
|
|
|
'setVersion',
|
|
|
|
|
]);
|
|
|
|
|
expect(store.savedConfig?.davProps?.password, 'secret');
|
|
|
|
|
expect(store.didClearClashConfig, isTrue);
|
|
|
|
|
expect(store.version, Migration.currentVersion);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'preserves legacy clash config when current-shaped data has version zero',
|
|
|
|
|
() async {
|
|
|
|
|
final configMap = _createConfigMap()..remove('patchClashConfig');
|
|
|
|
|
final clashConfigMap = _createClashConfigMap(mixedPort: 1234);
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: configMap,
|
|
|
|
|
version: 0,
|
|
|
|
|
clashConfigMap: clashConfigMap,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final config = await Migration(store: store).run();
|
|
|
|
|
|
|
|
|
|
expect(config.patchClashConfig.mixedPort, 1234);
|
|
|
|
|
expect(store.savedConfig?.patchClashConfig.mixedPort, 1234);
|
|
|
|
|
expect(store.didClearClashConfig, isTrue);
|
|
|
|
|
expect(store.events, [
|
|
|
|
|
'getConfigMap',
|
|
|
|
|
'getVersion',
|
|
|
|
|
'getClashConfigMap',
|
|
|
|
|
'restore',
|
|
|
|
|
'saveConfig',
|
|
|
|
|
'clearClashConfig',
|
|
|
|
|
'setVersion',
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('does not clear legacy clash config when saving fails', () async {
|
|
|
|
|
final configMap = _createConfigMap()..remove('patchClashConfig');
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: configMap,
|
|
|
|
|
version: 0,
|
|
|
|
|
clashConfigMap: _createClashConfigMap(mixedPort: 1234),
|
|
|
|
|
configSaveResult: false,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
Migration(store: store).run(),
|
|
|
|
|
throwsA(isA<StateError>()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(store.didClearClashConfig, isFalse);
|
|
|
|
|
expect(store.version, 0);
|
|
|
|
|
expect(store.events, [
|
|
|
|
|
'getConfigMap',
|
|
|
|
|
'getVersion',
|
|
|
|
|
'getClashConfigMap',
|
|
|
|
|
'restore',
|
|
|
|
|
'saveConfig',
|
2026-09-05 20:09:39 +08:00
|
|
|
'isAvailable',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('starts with defaults when the store cannot be opened', () async {
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: null,
|
|
|
|
|
version: 0,
|
|
|
|
|
configSaveResult: false,
|
|
|
|
|
available: false,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final config = await Migration(store: store).run();
|
|
|
|
|
|
|
|
|
|
expect(config, isNotNull);
|
|
|
|
|
expect(store.didClearClashConfig, isFalse);
|
|
|
|
|
expect(store.version, 0);
|
|
|
|
|
expect(store.events, [
|
|
|
|
|
'getConfigMap',
|
|
|
|
|
'getVersion',
|
|
|
|
|
'getClashConfigMap',
|
|
|
|
|
'restore',
|
|
|
|
|
'saveConfig',
|
|
|
|
|
'isAvailable',
|
2026-08-05 10:57:11 +08:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-16 22:42:17 +08:00
|
|
|
group('upgrade', () {
|
|
|
|
|
Map<String, Object?> customized() =>
|
|
|
|
|
_createConfigMap()
|
|
|
|
|
..['themeProps'] = {
|
|
|
|
|
'primaryColor': 0xFF123456,
|
|
|
|
|
'primaryColors': [0xFF795548, 0xFF123456],
|
|
|
|
|
'schemeVariant': 'tonalSpot',
|
|
|
|
|
'themeMode': 'dark',
|
|
|
|
|
'pureBlack': true,
|
|
|
|
|
'liquidGlass': true,
|
|
|
|
|
'systemFontFamily': 'Beta Serif',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
test('starts theme colors over once and keeps the rest', () async {
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: customized(),
|
|
|
|
|
version: Migration.currentVersion,
|
|
|
|
|
upgradeVersion: 0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final config = await Migration(store: store).run();
|
|
|
|
|
|
|
|
|
|
final theme = config.themeProps;
|
|
|
|
|
expect(theme.primaryColor, defaultPrimaryColor);
|
|
|
|
|
expect(theme.primaryColors, defaultPrimaryColors);
|
|
|
|
|
expect(theme.colorStyle, ColorStyle.vivid);
|
|
|
|
|
expect(theme.themeMode, ThemeMode.dark);
|
|
|
|
|
expect(theme.pureBlack, isTrue);
|
|
|
|
|
expect(theme.liquidGlass, LiquidGlassMode.ultra);
|
|
|
|
|
expect(theme.fontFamily, 'Beta Serif');
|
|
|
|
|
expect(store.savedConfig, config);
|
|
|
|
|
expect(store.version, Migration.currentVersion);
|
|
|
|
|
expect(store.upgradeVersion, Migration.upgradeVersion);
|
|
|
|
|
expect(store.events, [
|
|
|
|
|
'getConfigMap',
|
|
|
|
|
'getVersion',
|
|
|
|
|
'saveConfig',
|
|
|
|
|
'setUpgradeVersion',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
final later = _FakeMigrationStore(
|
|
|
|
|
configMap: jsonDecode(
|
|
|
|
|
jsonEncode(config.copyWith.themeProps(primaryColor: 0xFF123456)),
|
|
|
|
|
),
|
|
|
|
|
version: store.version,
|
|
|
|
|
upgradeVersion: store.upgradeVersion,
|
|
|
|
|
);
|
|
|
|
|
final kept = await Migration(store: later).run();
|
|
|
|
|
|
|
|
|
|
expect(kept.themeProps.primaryColor, 0xFF123456);
|
|
|
|
|
expect(later.events, ['getConfigMap', 'getVersion']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeps standard glass on the ultra tier it became, once', () async {
|
|
|
|
|
Future<LiquidGlassMode> upgrade(String mode, int from) async {
|
|
|
|
|
final configMap = _createConfigMap();
|
|
|
|
|
(configMap['themeProps']! as Map<String, Object?>)['liquidGlass'] =
|
|
|
|
|
mode;
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: configMap,
|
|
|
|
|
version: Migration.currentVersion,
|
|
|
|
|
upgradeVersion: from,
|
|
|
|
|
);
|
|
|
|
|
return (await Migration(store: store).run()).themeProps.liquidGlass;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(await upgrade('standard', 1), LiquidGlassMode.ultra);
|
|
|
|
|
expect(await upgrade('none', 1), LiquidGlassMode.none);
|
|
|
|
|
expect(await upgrade('standard', 2), LiquidGlassMode.standard);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a new install only records it', () async {
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: null,
|
|
|
|
|
version: 0,
|
|
|
|
|
upgradeVersion: 0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final config = await Migration(store: store).run();
|
|
|
|
|
|
|
|
|
|
expect(config.themeProps, defaultThemeProps);
|
|
|
|
|
expect(store.upgradeVersion, Migration.upgradeVersion);
|
|
|
|
|
expect(
|
|
|
|
|
store.events.where((event) => event == 'saveConfig'),
|
|
|
|
|
hasLength(1),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('waits for the next launch when saving fails', () async {
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: customized(),
|
|
|
|
|
version: Migration.currentVersion,
|
|
|
|
|
upgradeVersion: 0,
|
|
|
|
|
configSaveResult: false,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final config = await Migration(store: store).run();
|
|
|
|
|
|
|
|
|
|
expect(config.themeProps.primaryColor, 0xFF123456);
|
|
|
|
|
expect(store.upgradeVersion, 0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 17:58:43 +08:00
|
|
|
test('keeps what it can read of a damaged current config', () async {
|
|
|
|
|
final configMap = _createConfigMap()
|
|
|
|
|
..['currentProfileId'] = 7
|
|
|
|
|
..['excludeSSIDs'] = 'home';
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: configMap,
|
|
|
|
|
version: Migration.currentVersion,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final config = await Migration(store: store).run();
|
|
|
|
|
|
|
|
|
|
expect(config.currentProfileId, 7);
|
|
|
|
|
expect(config.excludeSSIDs, isEmpty);
|
|
|
|
|
expect(store.events, ['getConfigMap', 'getVersion']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-05 10:57:11 +08:00
|
|
|
test('keeps the current version when password obfuscation fails', () async {
|
|
|
|
|
final configMap = _createConfigMap(
|
|
|
|
|
davProps: const DAVProps(uri: 'https://example.com/dav', user: 'user'),
|
|
|
|
|
);
|
|
|
|
|
final davProps = configMap['davProps']! as Map<String, Object?>;
|
|
|
|
|
davProps['password'] = 'secret';
|
|
|
|
|
final store = _FakeMigrationStore(
|
|
|
|
|
configMap: configMap,
|
|
|
|
|
version: Migration.currentVersion,
|
|
|
|
|
configSaveResult: false,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
Migration(store: store).run(),
|
|
|
|
|
throwsA(isA<StateError>()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(store.events, ['getConfigMap', 'getVersion', 'saveConfig']);
|
|
|
|
|
expect(store.version, Migration.currentVersion);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Map<String, Object?> _createConfigMap({DAVProps? davProps}) {
|
|
|
|
|
return jsonDecode(
|
|
|
|
|
jsonEncode(Config(themeProps: defaultThemeProps, davProps: davProps)),
|
|
|
|
|
)
|
|
|
|
|
as Map<String, Object?>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Map<String, Object?> _createClashConfigMap({required int mixedPort}) {
|
|
|
|
|
return jsonDecode(jsonEncode(PatchClashConfig(mixedPort: mixedPort)))
|
|
|
|
|
as Map<String, Object?>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _FakeMigrationStore implements MigrationStore {
|
|
|
|
|
final Map<String, Object?>? configMap;
|
|
|
|
|
final Map<String, Object?>? clashConfigMap;
|
|
|
|
|
final bool configSaveResult;
|
2026-09-05 20:09:39 +08:00
|
|
|
final bool available;
|
2026-08-05 10:57:11 +08:00
|
|
|
final List<String> events = [];
|
|
|
|
|
|
|
|
|
|
int version;
|
2026-10-16 22:42:17 +08:00
|
|
|
int upgradeVersion;
|
2026-08-05 10:57:11 +08:00
|
|
|
Config? savedConfig;
|
|
|
|
|
MigrationData? restoredData;
|
|
|
|
|
bool didClearClashConfig = false;
|
|
|
|
|
|
|
|
|
|
_FakeMigrationStore({
|
|
|
|
|
required this.configMap,
|
|
|
|
|
required this.version,
|
2026-10-16 22:42:17 +08:00
|
|
|
this.upgradeVersion = Migration.upgradeVersion,
|
2026-08-05 10:57:11 +08:00
|
|
|
this.clashConfigMap,
|
|
|
|
|
this.configSaveResult = true,
|
2026-09-05 20:09:39 +08:00
|
|
|
this.available = true,
|
2026-08-05 10:57:11 +08:00
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:09:39 +08:00
|
|
|
@override
|
|
|
|
|
Future<bool> get isAvailable async {
|
|
|
|
|
events.add('isAvailable');
|
|
|
|
|
return available;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 10:57:11 +08:00
|
|
|
@override
|
|
|
|
|
Future<void> clearClashConfig() async {
|
|
|
|
|
events.add('clearClashConfig');
|
|
|
|
|
didClearClashConfig = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<Map<String, Object?>?> getClashConfigMap() async {
|
|
|
|
|
events.add('getClashConfigMap');
|
|
|
|
|
return clashConfigMap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<Map<String, Object?>?> getConfigMap() async {
|
|
|
|
|
events.add('getConfigMap');
|
|
|
|
|
return configMap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<int> getVersion() async {
|
|
|
|
|
events.add('getVersion');
|
|
|
|
|
return version;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> restore(MigrationData data) async {
|
|
|
|
|
events.add('restore');
|
|
|
|
|
restoredData = data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<bool> saveConfig(Config config) async {
|
|
|
|
|
events.add('saveConfig');
|
|
|
|
|
savedConfig = config;
|
|
|
|
|
return configSaveResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> setVersion(int version) async {
|
|
|
|
|
events.add('setVersion');
|
|
|
|
|
this.version = version;
|
|
|
|
|
}
|
2026-10-16 22:42:17 +08:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<int> getUpgradeVersion() async => upgradeVersion;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> setUpgradeVersion(int version) async {
|
|
|
|
|
events.add('setUpgradeVersion');
|
|
|
|
|
upgradeVersion = version;
|
|
|
|
|
}
|
2026-08-05 10:57:11 +08:00
|
|
|
}
|