refactor(config): 服务地址改为 dev/prod 配置文件与单个环境开关
启动时按环回、局域网、公网顺序探测地址,换网络后会静默切到别处,排查 困难。改为代码内一个布尔开关选择环境,地址分别放在 dev_config.dart 与 prod_config.dart,不再探测;调试条改地址后直接重载,不再重新探测。 release 构建若仍是开发开关会启动即报错,避免线上包连开发机。
This commit is contained in:
1 parent
57f84c45bd
commit
a6d6b66d31
13 files changed
+101
-382
No files matched your search
+24
-153
@@ -1,173 +1,51 @@
|
||||
// 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] 即可。
|
||||
class AppConfig {
|
||||
const AppConfig._();
|
||||
|
||||
/// 开发调试默认服务地址,当前指向本机局域网 IP。
|
||||
/// 两套环境各一个文件:
|
||||
/// - `dev_config.dart`:[DevConfig](本机联调)
|
||||
/// - `prod_config.dart`:[ProdConfig](生产)
|
||||
///
|
||||
/// 切换环境只改下面这一个布尔值,不用传任何构建参数、也不做网络探测。
|
||||
abstract final class AppConfig {
|
||||
/// **环境开关**:true 用开发环境,false 用生产环境。
|
||||
///
|
||||
/// 换网络时只改这一行,地址不要带结尾斜杠。
|
||||
static const String devHostBaseUrl = 'http://192.168.31.243:8081';
|
||||
/// 打生产包前必须改成 false;release 构建若仍为 true 会在启动时直接报错。
|
||||
static const bool useDevConfig = true;
|
||||
|
||||
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
|
||||
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
|
||||
static const String lanBaseUrl = 'http://127.0.0.1:8081';
|
||||
|
||||
/// 公网地址(远程/移动网络访问时使用)。
|
||||
static const String publicBaseUrl = 'http://39.162.50.99:8081';
|
||||
|
||||
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
|
||||
/// 当前选中的环境地址。
|
||||
static String get baseUrl =>
|
||||
useDevConfig ? DevConfig.baseUrl : ProdConfig.baseUrl;
|
||||
|
||||
/// 调试面板手改的地址(见 `ServerEndpoint`);为空表示用上面的环境地址。
|
||||
static String _runtimeBaseUrl = '';
|
||||
|
||||
/// 启动期异步准备(iOS 无线数据权限弹窗),目录请求前等待它。
|
||||
static Future<void>? _ready;
|
||||
|
||||
/// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。
|
||||
/// 启动准备完成信号;默认已完成,避免影响既有测试与调用方。
|
||||
static Future<void> get ready => _ready ?? Future<void>.value();
|
||||
static set ready(Future<void> value) => _ready = value;
|
||||
|
||||
static const Duration _probeTimeout = Duration(seconds: 2);
|
||||
static const List<String> _probeCandidates = [
|
||||
lanBaseUrl,
|
||||
devHostBaseUrl,
|
||||
if (devHostBaseUrl != publicBaseUrl) publicBaseUrl,
|
||||
];
|
||||
static const Duration requestTimeout = Duration(seconds: 8);
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
/// 应用一个已经规范化的运行时地址;传 null 表示清除。
|
||||
static void applyRuntimeBaseUrl(String? value) {
|
||||
_runtimeBaseUrl = value ?? '';
|
||||
}
|
||||
|
||||
/// Clears the in-memory runtime override, mainly for tests and restarts.
|
||||
/// 清除运行时地址,主要给测试与重启流程用。
|
||||
static void clearRuntimeBaseUrl() {
|
||||
_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/...),这里补上服务地址。
|
||||
static String mediaUrl(String path) {
|
||||
if (path.isEmpty) return '';
|
||||
@@ -175,10 +53,3 @@ class AppConfig {
|
||||
return '$apiBaseUrl$path';
|
||||
}
|
||||
}
|
||||
|
||||
class _ProbeResult {
|
||||
const _ProbeResult({required this.ok, this.reason});
|
||||
|
||||
final bool ok;
|
||||
final String? reason;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 生产环境配置。
|
||||
///
|
||||
/// 发版前确认这里的 [baseUrl],地址不要带结尾斜杠。
|
||||
abstract final class ProdConfig {
|
||||
/// 生产后端地址。
|
||||
static const String baseUrl = 'http://39.162.50.99:8081';
|
||||
}
|
||||
@@ -22,16 +22,9 @@ abstract final class ServerEndpoint {
|
||||
return value.replaceFirst(RegExp(r'/+$'), '');
|
||||
}
|
||||
|
||||
/// Resolves runtime > dart-define > default using normalized valid values.
|
||||
static String resolve({
|
||||
required String? stored,
|
||||
required String compiled,
|
||||
required String fallback,
|
||||
}) {
|
||||
return normalize(stored) ??
|
||||
normalize(compiled) ??
|
||||
normalize(fallback) ??
|
||||
fallback;
|
||||
/// 运行时手改优先,其次构建期默认地址;两者都必须先通过规范校验。
|
||||
static String resolve({required String? stored, required String fallback}) {
|
||||
return normalize(stored) ?? normalize(fallback) ?? fallback;
|
||||
}
|
||||
|
||||
/// Loads the persisted override before the app creates its ApiClient.
|
||||
@@ -46,11 +39,7 @@ abstract final class ServerEndpoint {
|
||||
final prefs = preferences ?? SharedPreferencesAsync();
|
||||
final stored = await prefs.getString(preferenceKey);
|
||||
AppConfig.applyRuntimeBaseUrl(
|
||||
resolve(
|
||||
stored: stored,
|
||||
compiled: AppConfig.compiledBaseUrl,
|
||||
fallback: AppConfig.defaultBaseUrl,
|
||||
),
|
||||
resolve(stored: stored, fallback: AppConfig.baseUrl),
|
||||
);
|
||||
} catch (_) {
|
||||
AppConfig.clearRuntimeBaseUrl();
|
||||
|
||||
@@ -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 _localNetworkHint = '手机需与电脑同一 Wi-Fi,并在 iOS 设置-隐私与安全性-本地网络 中允许本应用';
|
||||
|
||||
/// Debug 下编辑服务地址,保存后重新探测并加载目录。
|
||||
/// Debug 下编辑服务地址,保存后立即按新地址重新加载目录。
|
||||
Future<void> showCatalogEndpointEditor(
|
||||
BuildContext context, {
|
||||
required Future<void> Function() reload,
|
||||
Future<bool> Function(String value)? save,
|
||||
Future<void> Function()? probe,
|
||||
}) async {
|
||||
if (!kDebugMode) return;
|
||||
final value = await showDialog<String>(
|
||||
@@ -37,7 +36,6 @@ Future<void> showCatalogEndpointEditor(
|
||||
}
|
||||
return;
|
||||
}
|
||||
await (probe ?? () => AppConfig.probeAndApplyBaseUrl())();
|
||||
await reload();
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -13,10 +13,14 @@ import 'core/platform/wireless_data_prompt.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 生产包不能连开发机:忘了改开关时宁可启动即失败。
|
||||
if (kReleaseMode && AppConfig.useDevConfig) {
|
||||
throw StateError('release 构建请把 AppConfig.useDevConfig 改成 false');
|
||||
}
|
||||
await ServerEndpoint.load();
|
||||
AppConfig.ready = WirelessDataPrompt.awaitDecision(
|
||||
enabled: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS,
|
||||
).then((_) => AppConfig.probeAndApplyBaseUrl());
|
||||
);
|
||||
runApp(const LumaDramaApp());
|
||||
unawaited(
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:provider/provider.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/catalog_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/theater_feed_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
@@ -408,15 +407,11 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
|
||||
Future<void> _retryCategories(CategoryController categories) {
|
||||
return retryCatalogLoad(reload: categories.load);
|
||||
return categories.load();
|
||||
}
|
||||
|
||||
Future<void> _retryTheater() async {
|
||||
await retryCatalogLoad(
|
||||
reload: () async {
|
||||
await theater.refreshCurrent();
|
||||
},
|
||||
);
|
||||
Future<void> _retryTheater() {
|
||||
return theater.refreshCurrent();
|
||||
}
|
||||
|
||||
Future<void> _editTheaterEndpoint() {
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/remote/catalog_api.dart';
|
||||
import '../../core/data/services/home_feed_controller.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
@@ -67,7 +66,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
) async {
|
||||
_resumeRetryInFlight = true;
|
||||
try {
|
||||
await AppConfig.probeAndApplyBaseUrl();
|
||||
// 地址是构建期常量,回前台只需要重试失败的数据请求。
|
||||
await Future.wait([
|
||||
if (catalog.error != null) catalog.load(),
|
||||
if (categories.error != null) categories.load(),
|
||||
@@ -80,10 +79,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final catalog = context.watch<CatalogController>();
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: _buildHome(context, catalog),
|
||||
);
|
||||
return Theme(data: AppTheme.dark(), child: _buildHome(context, catalog));
|
||||
}
|
||||
|
||||
Widget _buildHome(BuildContext context, CatalogController catalog) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:async';
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.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: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';
|
||||
|
||||
void main() {
|
||||
@@ -35,11 +37,10 @@ void main() {
|
||||
expect(ServerEndpoint.normalize('http://'), isNull);
|
||||
});
|
||||
|
||||
test('should_prioritize_runtime_over_dart_define_over_default', () {
|
||||
test('should_prefer_valid_stored_url_over_build_default', () {
|
||||
expect(
|
||||
ServerEndpoint.resolve(
|
||||
stored: ' http://runtime.test:8081/ ',
|
||||
compiled: 'http://define.test:8081',
|
||||
fallback: 'http://default.test:8081',
|
||||
),
|
||||
'http://runtime.test:8081',
|
||||
@@ -47,173 +48,57 @@ void main() {
|
||||
expect(
|
||||
ServerEndpoint.resolve(
|
||||
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(
|
||||
ServerEndpoint.resolve(
|
||||
stored: null,
|
||||
compiled: '',
|
||||
fallback: 'http://default.test:8081/',
|
||||
fallback: 'http://default.test:8081',
|
||||
),
|
||||
'http://default.test:8081',
|
||||
);
|
||||
});
|
||||
|
||||
test('should_use_the_configured_local_address_for_device_default', () {
|
||||
final uri = Uri.parse(AppConfig.devHostBaseUrl);
|
||||
expect(uri.scheme, 'http');
|
||||
expect(uri.host, isNotEmpty);
|
||||
expect(uri.port, 8081);
|
||||
expect(uri.path, isEmpty);
|
||||
test('should_pick_address_by_environment_switch', () {
|
||||
// 仓库里的开关默认指向开发环境。
|
||||
expect(AppConfig.useDevConfig, isTrue);
|
||||
expect(AppConfig.baseUrl, DevConfig.baseUrl);
|
||||
expect(AppConfig.apiBaseUrl, DevConfig.baseUrl);
|
||||
expect(Uri.parse(AppConfig.baseUrl).port, 8081);
|
||||
});
|
||||
|
||||
test('should_skip_probe_when_compiled_override_is_set', () async {
|
||||
const compiled = 'http://compiled.test:8081';
|
||||
var probeCalls = 0;
|
||||
test('should_keep_dev_and_prod_addresses_in_separate_files', () {
|
||||
final dev = Uri.parse(DevConfig.baseUrl);
|
||||
final prod = Uri.parse(ProdConfig.baseUrl);
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
compiledOverride: compiled,
|
||||
probe: (_) async {
|
||||
probeCalls++;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(probeCalls, 0);
|
||||
expect(AppConfig.apiBaseUrl, compiled);
|
||||
expect(dev.scheme, 'http');
|
||||
expect(dev.port, 8081);
|
||||
expect(dev.path, isEmpty);
|
||||
expect(prod.scheme, 'http');
|
||||
expect(prod.port, 8081);
|
||||
expect(prod.path, isEmpty);
|
||||
expect(DevConfig.baseUrl, isNot(ProdConfig.baseUrl));
|
||||
});
|
||||
|
||||
test('should_keep_runtime_endpoint_when_compiled_override_is_set', () async {
|
||||
const runtime = 'http://runtime.test:8081';
|
||||
const compiled = 'http://compiled.test:8081';
|
||||
var probeCalls = 0;
|
||||
AppConfig.applyRuntimeBaseUrl(runtime);
|
||||
test('should_prefer_runtime_override_over_environment_switch', () {
|
||||
AppConfig.applyRuntimeBaseUrl('http://runtime.test:8081');
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
compiledOverride: compiled,
|
||||
probe: (_) async {
|
||||
probeCalls++;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(probeCalls, 0);
|
||||
expect(AppConfig.apiBaseUrl, runtime);
|
||||
expect(AppConfig.apiBaseUrl, 'http://runtime.test:8081');
|
||||
expect(AppConfig.baseUrl, DevConfig.baseUrl);
|
||||
});
|
||||
|
||||
test('should_keep_probing_when_compiled_override_is_empty', () async {
|
||||
final attempts = <String>[];
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
compiledOverride: '',
|
||||
probe: (baseUrl) async {
|
||||
attempts.add(baseUrl);
|
||||
return baseUrl == AppConfig.publicBaseUrl;
|
||||
},
|
||||
test('should_append_host_to_relative_media_paths', () {
|
||||
expect(AppConfig.mediaUrl(''), '');
|
||||
expect(
|
||||
AppConfig.mediaUrl('https://cdn.test/a.mp4'),
|
||||
'https://cdn.test/a.mp4',
|
||||
);
|
||||
|
||||
expect(attempts, [
|
||||
AppConfig.lanBaseUrl,
|
||||
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(
|
||||
AppConfig.mediaUrl('/files/a.mp4'),
|
||||
'${AppConfig.apiBaseUrl}/files/a.mp4',
|
||||
);
|
||||
|
||||
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 {
|
||||
|
||||
@@ -136,19 +136,19 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'should_use_probed_endpoint_when_client_was_created_before_probe',
|
||||
'should_follow_runtime_endpoint_applied_after_client_creation',
|
||||
() async {
|
||||
const probedBaseUrl = 'http://probed.test:8081';
|
||||
const runtimeBaseUrl = 'http://runtime.test:8081';
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(envelope(null)),
|
||||
);
|
||||
final client = ApiClient(adapter: adapter);
|
||||
|
||||
AppConfig.applyRuntimeBaseUrl(probedBaseUrl);
|
||||
AppConfig.applyRuntimeBaseUrl(runtimeBaseUrl);
|
||||
addTearDown(AppConfig.clearRuntimeBaseUrl);
|
||||
await client.get('/app/home/feed');
|
||||
|
||||
expect(adapter.requests.single.uri.origin, probedBaseUrl);
|
||||
expect(adapter.requests.single.uri.origin, runtimeBaseUrl);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.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/widgets/catalog_endpoint_editor.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
@@ -55,20 +54,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('should_probe_before_reloading_catalog', () 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 {
|
||||
testWidgets('should_save_and_reload_after_valid_endpoint', (tester) async {
|
||||
AppConfig.applyRuntimeBaseUrl('http://127.0.0.1:8081');
|
||||
final steps = <String>[];
|
||||
await _pumpEndpointEditor(
|
||||
@@ -77,7 +63,6 @@ void main() {
|
||||
steps.add('save:$value');
|
||||
return true;
|
||||
},
|
||||
probe: () async => steps.add('probe'),
|
||||
reload: () async => steps.add('load'),
|
||||
);
|
||||
|
||||
@@ -101,12 +86,11 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('catalog-debug-endpoint-save')));
|
||||
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 {
|
||||
var saves = 0;
|
||||
var probes = 0;
|
||||
var loads = 0;
|
||||
await _pumpEndpointEditor(
|
||||
tester,
|
||||
@@ -114,7 +98,6 @@ void main() {
|
||||
saves++;
|
||||
return true;
|
||||
},
|
||||
probe: () async => probes++,
|
||||
reload: () async => loads++,
|
||||
);
|
||||
|
||||
@@ -134,7 +117,6 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(saves, 0);
|
||||
expect(probes, 0);
|
||||
expect(loads, 0);
|
||||
});
|
||||
}
|
||||
@@ -142,7 +124,6 @@ void main() {
|
||||
Future<void> _pumpEndpointEditor(
|
||||
WidgetTester tester, {
|
||||
required Future<bool> Function(String value) save,
|
||||
required Future<void> Function() probe,
|
||||
required Future<void> Function() reload,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -152,12 +133,7 @@ Future<void> _pumpEndpointEditor(
|
||||
body: TextButton(
|
||||
key: const Key('open-endpoint-editor'),
|
||||
onPressed: () => unawaited(
|
||||
showCatalogEndpointEditor(
|
||||
context,
|
||||
save: save,
|
||||
probe: probe,
|
||||
reload: reload,
|
||||
),
|
||||
showCatalogEndpointEditor(context, save: save, reload: reload),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
|
||||
@@ -18,9 +18,8 @@ void main() {
|
||||
);
|
||||
|
||||
expect(uri.isAbsolute, isTrue);
|
||||
// Uri.parse().toString() 会把 host 规范成小写,而 devHostBaseUrl 里的
|
||||
// Bonjour 名字带大写(jiangdeMacBook-Air.local)—— 比较时统一小写,
|
||||
// 否则会依赖运行平台(flutter test 里 defaultTargetPlatform 默认是 android)
|
||||
// Uri.parse().toString() 会把 host 规范成小写,而开发地址可能带大写
|
||||
// (Bonjour 名字),比较时统一小写,否则会依赖运行平台。
|
||||
expect(
|
||||
uri.toString().toLowerCase(),
|
||||
'${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(),
|
||||
|
||||
Reference in new issue
Block a user