refactor(config): 服务地址改为 dev/prod 配置文件与单个环境开关

启动时按环回、局域网、公网顺序探测地址,换网络后会静默切到别处,排查
困难。改为代码内一个布尔开关选择环境,地址分别放在 dev_config.dart 与
prod_config.dart,不再探测;调试条改地址后直接重载,不再重新探测。
release 构建若仍是开发开关会启动即报错,避免线上包连开发机。
This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-29 10:44:50 +08:00
1 parent 57f84c45bd
commit a6d6b66d31
13 files changed
+101 -382

No files matched your search

+24 -153
View File
@@ -1,173 +1,51 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:io'; import 'dev_config.dart';
import 'prod_config.dart';
import 'package:flutter/foundation.dart'; /// 客户端运行时配置。
/// 客户端与后端约定的运行时配置。
/// ///
/// 接口地址优先级:运行时设置 > `--dart-define=API_BASE_URL=...` > 平台默认值。 /// 两套环境各一个文件:
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。 /// - `dev_config.dart`:[DevConfig](本机联调)
class AppConfig { /// - `prod_config.dart`:[ProdConfig](生产)
const AppConfig._(); ///
/// 切换环境只改下面这一个布尔值,不用传任何构建参数、也不做网络探测。
/// 开发调试默认服务地址,当前指向本机局域网 IP。 abstract final class AppConfig {
/// **环境开关**:true 用开发环境,false 用生产环境。
/// ///
/// 换网络时只改这一行,地址不要带结尾斜杠。 /// 打生产包前必须改成 false;release 构建若仍为 true 会在启动时直接报错。
static const String devHostBaseUrl = 'http://192.168.31.243:8081'; static const bool useDevConfig = true;
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机, /// 当前选中的环境地址。
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。 static String get baseUrl =>
static const String lanBaseUrl = 'http://127.0.0.1:8081'; useDevConfig ? DevConfig.baseUrl : ProdConfig.baseUrl;
/// 公网地址(远程/移动网络访问时使用)。
static const String publicBaseUrl = 'http://39.162.50.99:8081';
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
/// 调试面板手改的地址(见 `ServerEndpoint`);为空表示用上面的环境地址。
static String _runtimeBaseUrl = ''; static String _runtimeBaseUrl = '';
/// 启动期异步准备(iOS 无线数据权限弹窗),目录请求前等待它。
static Future<void>? _ready; static Future<void>? _ready;
/// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。 /// 启动准备完成信号;默认已完成,避免影响既有测试与调用方。
static Future<void> get ready => _ready ?? Future<void>.value(); static Future<void> get ready => _ready ?? Future<void>.value();
static set ready(Future<void> value) => _ready = value; static set ready(Future<void> value) => _ready = value;
static const Duration _probeTimeout = Duration(seconds: 2); static const Duration requestTimeout = Duration(seconds: 8);
static const List<String> _probeCandidates = [
lanBaseUrl,
devHostBaseUrl,
if (devHostBaseUrl != publicBaseUrl) publicBaseUrl,
];
/// Build-time server URL supplied through `--dart-define=API_BASE_URL`. /// 实际使用的服务地址:运行时手改优先,否则用环境开关选中的地址。
static String get compiledBaseUrl => _overrideBaseUrl; static String get apiBaseUrl =>
_runtimeBaseUrl.isNotEmpty ? _runtimeBaseUrl : baseUrl;
/// Existing platform-aware fallback used when no runtime or build override exists. /// 应用一个已经规范化的运行时地址;传 null 表示清除。
static String get defaultBaseUrl => _defaultBaseUrl;
/// Effective server URL, including the debug-only runtime override.
static String get apiBaseUrl => _runtimeBaseUrl.isNotEmpty
? _runtimeBaseUrl
: (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl);
/// 生产包固定走公网地址;仅 debug 才按平台默认到环回/开发机。
static final String _defaultBaseUrl = kReleaseMode
? publicBaseUrl
: (_runsOnDevMachine
? lanBaseUrl
: devHostBaseUrl);
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
/// Android 没有等价的探测方式,统一按真机处理(模拟器用
/// `--dart-define=API_BASE_URL=http://10.0.2.2:8081` 覆盖)。
static final bool _runsOnDevMachine =
kIsWeb ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
(defaultTargetPlatform == TargetPlatform.iOS &&
Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'));
/// Applies an already normalized runtime URL. Passing null clears it.
static void applyRuntimeBaseUrl(String? value) { static void applyRuntimeBaseUrl(String? value) {
_runtimeBaseUrl = value ?? ''; _runtimeBaseUrl = value ?? '';
} }
/// Clears the in-memory runtime override, mainly for tests and restarts. /// 清除运行时地址,主要给测试与重启流程用。
static void clearRuntimeBaseUrl() { static void clearRuntimeBaseUrl() {
_runtimeBaseUrl = ''; _runtimeBaseUrl = '';
} }
static const Duration requestTimeout = Duration(seconds: 8);
/// 按环回、开发机、公网顺序探测,写入首个返回 200 的地址。
///
/// 全部不可达时使用平台默认地址,真机保留开发机地址,避免退回公网。
static Future<void> probeAndApplyBaseUrl({
Future<bool> Function(String baseUrl)? probe,
String? fallbackBaseUrl,
/// 编译期地址测试注入;未传时读取 `API_BASE_URL`。
String? compiledOverride,
}) async {
final compiled = compiledOverride ?? compiledBaseUrl;
if (compiled.isNotEmpty) {
if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(compiled);
return;
}
// 生产包固定公网地址,不再探测环回/开发机局域网地址,避免请求到开发机。
if (kReleaseMode) {
if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(publicBaseUrl);
return;
}
final candidates = <String>[
if (_runtimeBaseUrl.isNotEmpty) _runtimeBaseUrl,
for (final candidate in _probeCandidates)
if (candidate != _runtimeBaseUrl) candidate,
];
final results = await Future.wait([
for (final candidate in candidates)
probe == null
? _probeBaseUrl(candidate)
: _runInjectedProbe(candidate, probe),
]);
for (var index = 0; index < candidates.length; index++) {
final candidate = candidates[index];
final result = results[index];
if (kDebugMode) {
final status = result.ok
? 'ok'
: 'fail(${result.reason ?? 'request failed'})';
debugPrint('[probe] $candidate -> $status');
}
if (result.ok) {
applyRuntimeBaseUrl(candidate);
return;
}
}
applyRuntimeBaseUrl(fallbackBaseUrl ?? defaultBaseUrl);
}
static Future<_ProbeResult> _runInjectedProbe(
String baseUrl,
Future<bool> Function(String baseUrl) probe,
) async {
try {
return _ProbeResult(ok: await probe(baseUrl));
} catch (error) {
return _ProbeResult(ok: false, reason: error.toString());
}
}
static Future<_ProbeResult> _probeBaseUrl(String baseUrl) async {
final client = HttpClient()..connectionTimeout = _probeTimeout;
try {
return await _requestCategories(
client,
Uri.parse('$baseUrl/app/categories'),
).timeout(_probeTimeout);
} catch (error) {
return _ProbeResult(ok: false, reason: error.toString());
} finally {
client.close(force: true);
}
}
static Future<_ProbeResult> _requestCategories(
HttpClient client,
Uri uri,
) async {
final request = await client.getUrl(uri);
final response = await request.close();
await response.drain<void>();
final ok = response.statusCode == 200;
return _ProbeResult(
ok: ok,
reason: ok ? null : 'HTTP ${response.statusCode}',
);
}
/// 后端返回的是相对路径(/files/...),这里补上服务地址。 /// 后端返回的是相对路径(/files/...),这里补上服务地址。
static String mediaUrl(String path) { static String mediaUrl(String path) {
if (path.isEmpty) return ''; if (path.isEmpty) return '';
@@ -175,10 +53,3 @@ class AppConfig {
return '$apiBaseUrl$path'; return '$apiBaseUrl$path';
} }
} }
class _ProbeResult {
const _ProbeResult({required this.ok, this.reason});
final bool ok;
final String? reason;
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
/// 开发环境配置。
///
/// 本机联调用:换 Wi-Fi、换网段后只改这里的 [baseUrl],地址不要带结尾斜杠。
abstract final class DevConfig {
/// 开发机后端地址。
static const String baseUrl = 'http://192.168.31.243:8081';
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
/// 生产环境配置。
///
/// 发版前确认这里的 [baseUrl],地址不要带结尾斜杠。
abstract final class ProdConfig {
/// 生产后端地址。
static const String baseUrl = 'http://39.162.50.99:8081';
}
+4 -15
View File
@@ -22,16 +22,9 @@ abstract final class ServerEndpoint {
return value.replaceFirst(RegExp(r'/+$'), ''); return value.replaceFirst(RegExp(r'/+$'), '');
} }
/// Resolves runtime > dart-define > default using normalized valid values. /// 运行时手改优先,其次构建期默认地址;两者都必须先通过规范校验。
static String resolve({ static String resolve({required String? stored, required String fallback}) {
required String? stored, return normalize(stored) ?? normalize(fallback) ?? fallback;
required String compiled,
required String fallback,
}) {
return normalize(stored) ??
normalize(compiled) ??
normalize(fallback) ??
fallback;
} }
/// Loads the persisted override before the app creates its ApiClient. /// Loads the persisted override before the app creates its ApiClient.
@@ -46,11 +39,7 @@ abstract final class ServerEndpoint {
final prefs = preferences ?? SharedPreferencesAsync(); final prefs = preferences ?? SharedPreferencesAsync();
final stored = await prefs.getString(preferenceKey); final stored = await prefs.getString(preferenceKey);
AppConfig.applyRuntimeBaseUrl( AppConfig.applyRuntimeBaseUrl(
resolve( resolve(stored: stored, fallback: AppConfig.baseUrl),
stored: stored,
compiled: AppConfig.compiledBaseUrl,
fallback: AppConfig.defaultBaseUrl,
),
); );
} catch (_) { } catch (_) {
AppConfig.clearRuntimeBaseUrl(); AppConfig.clearRuntimeBaseUrl();
-12
View File
@@ -1,12 +0,0 @@
// Author: fruit.com.cn
import '../../config/app_config.dart';
/// 重新探测服务地址后再加载指定目录数据。
Future<void> retryCatalogLoad({
required Future<void> Function() reload,
Future<void> Function()? probe,
}) async {
await (probe ?? AppConfig.probeAndApplyBaseUrl)();
await reload();
}
@@ -15,12 +15,11 @@ const String _invalid = '请输入合法地址,例如 http://192.168.1.10:8081
const String _saveFailed = '服务地址保存失败'; const String _saveFailed = '服务地址保存失败';
const String _localNetworkHint = '手机需与电脑同一 Wi-Fi,并在 iOS 设置-隐私与安全性-本地网络 中允许本应用'; const String _localNetworkHint = '手机需与电脑同一 Wi-Fi,并在 iOS 设置-隐私与安全性-本地网络 中允许本应用';
/// Debug 下编辑服务地址,保存后重新探测并加载目录。 /// Debug 下编辑服务地址,保存后立即按新地址重新加载目录。
Future<void> showCatalogEndpointEditor( Future<void> showCatalogEndpointEditor(
BuildContext context, { BuildContext context, {
required Future<void> Function() reload, required Future<void> Function() reload,
Future<bool> Function(String value)? save, Future<bool> Function(String value)? save,
Future<void> Function()? probe,
}) async { }) async {
if (!kDebugMode) return; if (!kDebugMode) return;
final value = await showDialog<String>( final value = await showDialog<String>(
@@ -37,7 +36,6 @@ Future<void> showCatalogEndpointEditor(
} }
return; return;
} }
await (probe ?? () => AppConfig.probeAndApplyBaseUrl())();
await reload(); await reload();
} }
+5 -1
View File
@@ -13,10 +13,14 @@ import 'core/platform/wireless_data_prompt.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// 生产包不能连开发机:忘了改开关时宁可启动即失败。
if (kReleaseMode && AppConfig.useDevConfig) {
throw StateError('release 构建请把 AppConfig.useDevConfig 改成 false');
}
await ServerEndpoint.load(); await ServerEndpoint.load();
AppConfig.ready = WirelessDataPrompt.awaitDecision( AppConfig.ready = WirelessDataPrompt.awaitDecision(
enabled: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS, enabled: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS,
).then((_) => AppConfig.probeAndApplyBaseUrl()); );
runApp(const LumaDramaApp()); runApp(const LumaDramaApp());
unawaited( unawaited(
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
+3 -8
View File
@@ -10,7 +10,6 @@ import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/search_repository.dart';
import '../../core/data/services/catalog_retry.dart';
import '../../core/data/services/category_controller.dart'; import '../../core/data/services/category_controller.dart';
import '../../core/data/services/theater_feed_controller.dart'; import '../../core/data/services/theater_feed_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
@@ -408,15 +407,11 @@ class _DiscoverPageState extends State<DiscoverPage> {
); );
Future<void> _retryCategories(CategoryController categories) { Future<void> _retryCategories(CategoryController categories) {
return retryCatalogLoad(reload: categories.load); return categories.load();
} }
Future<void> _retryTheater() async { Future<void> _retryTheater() {
await retryCatalogLoad( return theater.refreshCurrent();
reload: () async {
await theater.refreshCurrent();
},
);
} }
Future<void> _editTheaterEndpoint() { Future<void> _editTheaterEndpoint() {
+2 -6
View File
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/remote/catalog_api.dart'; import '../../core/data/remote/catalog_api.dart';
import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_controller.dart';
@@ -67,7 +66,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
) async { ) async {
_resumeRetryInFlight = true; _resumeRetryInFlight = true;
try { try {
await AppConfig.probeAndApplyBaseUrl(); // 地址是构建期常量,回前台只需要重试失败的数据请求。
await Future.wait([ await Future.wait([
if (catalog.error != null) catalog.load(), if (catalog.error != null) catalog.load(),
if (categories.error != null) categories.load(), if (categories.error != null) categories.load(),
@@ -80,10 +79,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final catalog = context.watch<CatalogController>(); final catalog = context.watch<CatalogController>();
return Theme( return Theme(data: AppTheme.dark(), child: _buildHome(context, catalog));
data: AppTheme.dark(),
child: _buildHome(context, catalog),
);
} }
Widget _buildHome(BuildContext context, CatalogController catalog) { Widget _buildHome(BuildContext context, CatalogController catalog) {
+35 -150
View File
@@ -1,4 +1,4 @@
import 'dart:async'; // Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -6,6 +6,8 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/config/app_config.dart'; import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/config/dev_config.dart';
import 'package:fruitshorts/core/config/prod_config.dart';
import 'package:fruitshorts/core/config/server_endpoint.dart'; import 'package:fruitshorts/core/config/server_endpoint.dart';
void main() { void main() {
@@ -35,11 +37,10 @@ void main() {
expect(ServerEndpoint.normalize('http://'), isNull); expect(ServerEndpoint.normalize('http://'), isNull);
}); });
test('should_prioritize_runtime_over_dart_define_over_default', () { test('should_prefer_valid_stored_url_over_build_default', () {
expect( expect(
ServerEndpoint.resolve( ServerEndpoint.resolve(
stored: ' http://runtime.test:8081/ ', stored: ' http://runtime.test:8081/ ',
compiled: 'http://define.test:8081',
fallback: 'http://default.test:8081', fallback: 'http://default.test:8081',
), ),
'http://runtime.test:8081', 'http://runtime.test:8081',
@@ -47,173 +48,57 @@ void main() {
expect( expect(
ServerEndpoint.resolve( ServerEndpoint.resolve(
stored: 'ftp://invalid', stored: 'ftp://invalid',
compiled: 'http://define.test:8081/', fallback: 'http://default.test:8081/',
fallback: 'http://default.test:8081',
), ),
'http://define.test:8081', 'http://default.test:8081',
); );
expect( expect(
ServerEndpoint.resolve( ServerEndpoint.resolve(
stored: null, stored: null,
compiled: '', fallback: 'http://default.test:8081',
fallback: 'http://default.test:8081/',
), ),
'http://default.test:8081', 'http://default.test:8081',
); );
}); });
test('should_use_the_configured_local_address_for_device_default', () { test('should_pick_address_by_environment_switch', () {
final uri = Uri.parse(AppConfig.devHostBaseUrl); // 仓库里的开关默认指向开发环境。
expect(uri.scheme, 'http'); expect(AppConfig.useDevConfig, isTrue);
expect(uri.host, isNotEmpty); expect(AppConfig.baseUrl, DevConfig.baseUrl);
expect(uri.port, 8081); expect(AppConfig.apiBaseUrl, DevConfig.baseUrl);
expect(uri.path, isEmpty); expect(Uri.parse(AppConfig.baseUrl).port, 8081);
}); });
test('should_skip_probe_when_compiled_override_is_set', () async { test('should_keep_dev_and_prod_addresses_in_separate_files', () {
const compiled = 'http://compiled.test:8081'; final dev = Uri.parse(DevConfig.baseUrl);
var probeCalls = 0; final prod = Uri.parse(ProdConfig.baseUrl);
await AppConfig.probeAndApplyBaseUrl( expect(dev.scheme, 'http');
compiledOverride: compiled, expect(dev.port, 8081);
probe: (_) async { expect(dev.path, isEmpty);
probeCalls++; expect(prod.scheme, 'http');
return true; expect(prod.port, 8081);
}, expect(prod.path, isEmpty);
); expect(DevConfig.baseUrl, isNot(ProdConfig.baseUrl));
expect(probeCalls, 0);
expect(AppConfig.apiBaseUrl, compiled);
}); });
test('should_keep_runtime_endpoint_when_compiled_override_is_set', () async { test('should_prefer_runtime_override_over_environment_switch', () {
const runtime = 'http://runtime.test:8081'; AppConfig.applyRuntimeBaseUrl('http://runtime.test:8081');
const compiled = 'http://compiled.test:8081';
var probeCalls = 0;
AppConfig.applyRuntimeBaseUrl(runtime);
await AppConfig.probeAndApplyBaseUrl( expect(AppConfig.apiBaseUrl, 'http://runtime.test:8081');
compiledOverride: compiled, expect(AppConfig.baseUrl, DevConfig.baseUrl);
probe: (_) async {
probeCalls++;
return true;
},
);
expect(probeCalls, 0);
expect(AppConfig.apiBaseUrl, runtime);
}); });
test('should_keep_probing_when_compiled_override_is_empty', () async { test('should_append_host_to_relative_media_paths', () {
final attempts = <String>[]; expect(AppConfig.mediaUrl(''), '');
expect(
await AppConfig.probeAndApplyBaseUrl( AppConfig.mediaUrl('https://cdn.test/a.mp4'),
compiledOverride: '', 'https://cdn.test/a.mp4',
probe: (baseUrl) async {
attempts.add(baseUrl);
return baseUrl == AppConfig.publicBaseUrl;
},
); );
expect(
expect(attempts, [ AppConfig.mediaUrl('/files/a.mp4'),
AppConfig.lanBaseUrl, '${AppConfig.apiBaseUrl}/files/a.mp4',
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, AppConfig.publicBaseUrl);
});
test(
'should_probe_all_candidates_in_parallel_before_applying_priority',
() async {
final attempts = <String>[];
var active = 0;
var maxActive = 0;
final allStarted = Completer<void>();
final probing = AppConfig.probeAndApplyBaseUrl(
fallbackBaseUrl: AppConfig.devHostBaseUrl,
probe: (baseUrl) async {
attempts.add(baseUrl);
active++;
if (active > maxActive) maxActive = active;
if (active == 3) allStarted.complete();
await allStarted.future;
active--;
return baseUrl == AppConfig.lanBaseUrl ||
baseUrl == AppConfig.devHostBaseUrl;
},
);
await probing.timeout(const Duration(seconds: 1));
expect(attempts, [
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(maxActive, 3);
expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl);
},
);
test('should_probe_loopback_then_public_in_order', () async {
final attempts = <String>[];
await AppConfig.probeAndApplyBaseUrl(
fallbackBaseUrl: AppConfig.devHostBaseUrl,
probe: (baseUrl) async {
attempts.add(baseUrl);
return baseUrl == AppConfig.publicBaseUrl;
},
); );
expect(attempts, [
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, AppConfig.publicBaseUrl);
});
test('should_use_platform_default_when_all_probes_fail', () async {
final attempts = <String>[];
await AppConfig.probeAndApplyBaseUrl(
fallbackBaseUrl: AppConfig.devHostBaseUrl,
probe: (baseUrl) async {
attempts.add(baseUrl);
return false;
},
);
expect(attempts, [
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, AppConfig.devHostBaseUrl);
});
test('should_probe_explicit_runtime_endpoint_first', () async {
const custom = 'http://custom.test:8081';
final attempts = <String>[];
AppConfig.applyRuntimeBaseUrl(custom);
await AppConfig.probeAndApplyBaseUrl(
probe: (baseUrl) async {
attempts.add(baseUrl);
return baseUrl == custom;
},
);
expect(attempts, [
custom,
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, custom);
}); });
test('should_apply_stored_url_only_when_debug_is_enabled', () async { test('should_apply_stored_url_only_when_debug_is_enabled', () async {
+4 -4
View File
@@ -136,19 +136,19 @@ void main() {
}); });
test( test(
'should_use_probed_endpoint_when_client_was_created_before_probe', 'should_follow_runtime_endpoint_applied_after_client_creation',
() async { () async {
const probedBaseUrl = 'http://probed.test:8081'; const runtimeBaseUrl = 'http://runtime.test:8081';
final adapter = FakeHttpAdapter( final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null)), (_) async => FakeHttpResponse(envelope(null)),
); );
final client = ApiClient(adapter: adapter); final client = ApiClient(adapter: adapter);
AppConfig.applyRuntimeBaseUrl(probedBaseUrl); AppConfig.applyRuntimeBaseUrl(runtimeBaseUrl);
addTearDown(AppConfig.clearRuntimeBaseUrl); addTearDown(AppConfig.clearRuntimeBaseUrl);
await client.get('/app/home/feed'); await client.get('/app/home/feed');
expect(adapter.requests.single.uri.origin, probedBaseUrl); expect(adapter.requests.single.uri.origin, runtimeBaseUrl);
}, },
); );
} }
+3 -27
View File
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/app_config.dart'; import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/data/services/catalog_retry.dart';
import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart'; import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart';
import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/l10n/app_localizations.dart';
@@ -55,20 +54,7 @@ void main() {
); );
}); });
test('should_probe_before_reloading_catalog', () async { testWidgets('should_save_and_reload_after_valid_endpoint', (tester) async {
final steps = <String>[];
await retryCatalogLoad(
probe: () async => steps.add('probe'),
reload: () async => steps.add('load'),
);
expect(steps, ['probe', 'load']);
});
testWidgets('should_save_probe_and_reload_after_valid_endpoint', (
tester,
) async {
AppConfig.applyRuntimeBaseUrl('http://127.0.0.1:8081'); AppConfig.applyRuntimeBaseUrl('http://127.0.0.1:8081');
final steps = <String>[]; final steps = <String>[];
await _pumpEndpointEditor( await _pumpEndpointEditor(
@@ -77,7 +63,6 @@ void main() {
steps.add('save:$value'); steps.add('save:$value');
return true; return true;
}, },
probe: () async => steps.add('probe'),
reload: () async => steps.add('load'), reload: () async => steps.add('load'),
); );
@@ -101,12 +86,11 @@ void main() {
await tester.tap(find.byKey(const Key('catalog-debug-endpoint-save'))); await tester.tap(find.byKey(const Key('catalog-debug-endpoint-save')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(steps, ['save:http://10.0.0.8:8081', 'probe', 'load']); expect(steps, ['save:http://10.0.0.8:8081', 'load']);
}); });
testWidgets('should_reject_invalid_endpoint_without_saving', (tester) async { testWidgets('should_reject_invalid_endpoint_without_saving', (tester) async {
var saves = 0; var saves = 0;
var probes = 0;
var loads = 0; var loads = 0;
await _pumpEndpointEditor( await _pumpEndpointEditor(
tester, tester,
@@ -114,7 +98,6 @@ void main() {
saves++; saves++;
return true; return true;
}, },
probe: () async => probes++,
reload: () async => loads++, reload: () async => loads++,
); );
@@ -134,7 +117,6 @@ void main() {
findsOneWidget, findsOneWidget,
); );
expect(saves, 0); expect(saves, 0);
expect(probes, 0);
expect(loads, 0); expect(loads, 0);
}); });
} }
@@ -142,7 +124,6 @@ void main() {
Future<void> _pumpEndpointEditor( Future<void> _pumpEndpointEditor(
WidgetTester tester, { WidgetTester tester, {
required Future<bool> Function(String value) save, required Future<bool> Function(String value) save,
required Future<void> Function() probe,
required Future<void> Function() reload, required Future<void> Function() reload,
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -152,12 +133,7 @@ Future<void> _pumpEndpointEditor(
body: TextButton( body: TextButton(
key: const Key('open-endpoint-editor'), key: const Key('open-endpoint-editor'),
onPressed: () => unawaited( onPressed: () => unawaited(
showCatalogEndpointEditor( showCatalogEndpointEditor(context, save: save, reload: reload),
context,
save: save,
probe: probe,
reload: reload,
),
), ),
child: const Text('open'), child: const Text('open'),
), ),
+2 -3
View File
@@ -18,9 +18,8 @@ void main() {
); );
expect(uri.isAbsolute, isTrue); expect(uri.isAbsolute, isTrue);
// Uri.parse().toString() 会把 host 规范成小写,而 devHostBaseUrl 里的 // Uri.parse().toString() 会把 host 规范成小写,而开发地址可能带大写
// Bonjour 名字带大写(jiangdeMacBook-Air.local)—— 比较时统一小写, // (Bonjour 名字),比较时统一小写,否则会依赖运行平台。
// 否则会依赖运行平台(flutter test 里 defaultTargetPlatform 默认是 android)
expect( expect(
uri.toString().toLowerCase(), uri.toString().toLowerCase(),
'${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(), '${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(),