import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/update/update_api.dart'; import 'package:miaoji_zhang/shared/update/update_coordinator.dart'; import 'package:miaoji_zhang/shared/update/update_downloader.dart'; import 'package:miaoji_zhang/shared/update/update_models.dart'; import 'package:miaoji_zhang/shared/update/update_prompt.dart'; import 'package:miaoji_zhang/shared/version.dart'; import 'package:package_info_plus/package_info_plus.dart'; void main() { const release = AppRelease( id: 'release-7', versionName: '1.2.2', buildNumber: 7, downloadUrl: 'https://downloads.example.com/jizhi-7.apk', releaseNotes: '## 本次更新\n\n- 修复识别问题\n- 优化更新体验', sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', fileSize: 1024, publishedAt: null, ); test('使用原生 versionName 和 buildNumber 展示版本', () async { PackageInfo.setMockInitialValues( appName: '记之', packageName: 'com.nx.miaoji.internal', version: '1.2.1-internal', buildNumber: '6', buildSignature: '', ); await AppVersion.initialize(); expect(AppVersion.versionName, '1.2.1-internal'); expect(AppVersion.buildNumber, 6); expect(AppVersion.display, 'v1.2.1-internal (6)'); }); test('更新响应保留发布信息并且只按 buildNumber 判断新旧', () { final response = UpdateCheckResponse.fromJson({ 'hasUpdate': true, 'forceUpdate': false, 'release': { 'id': 'release-7', 'versionName': '0.1.0', 'buildNumber': 7, 'downloadUrl': 'https://downloads.example.com/jizhi-7.apk', 'releaseNotes': '测试版本', 'sha256': release.sha256, 'fileSize': 1024, 'publishedAt': '2026-07-21T12:00:00Z', }, }); expect(UpdatePolicy.availableRelease(response, currentBuild: 6), isNotNull); expect(UpdatePolicy.availableRelease(response, currentBuild: 7), isNull); expect(response.release?.versionName, '0.1.0'); }); test('忽略普通版本但不影响手动检查和强制更新', () { expect( UpdatePolicy.shouldPresent( release: release, forced: false, manual: false, ignoredReleaseId: release.id, ), isFalse, ); expect( UpdatePolicy.shouldPresent( release: release, forced: false, manual: true, ignoredReleaseId: release.id, ), isTrue, ); expect( UpdatePolicy.shouldPresent( release: release, forced: true, manual: false, ignoredReleaseId: release.id, ), isTrue, ); }); test('UpdateApi 发送公开参数且不附带登录请求配置', () async { final adapter = _FakeAdapter( statusCode: 200, body: jsonEncode({ 'hasUpdate': true, 'forceUpdate': false, 'release': { 'id': release.id, 'versionName': release.versionName, 'buildNumber': release.buildNumber, 'downloadUrl': release.downloadUrl, 'releaseNotes': release.releaseNotes, 'sha256': release.sha256, 'fileSize': release.fileSize, 'publishedAt': '2026-07-21T12:00:00Z', }, }), ); final dio = Dio(BaseOptions(baseUrl: 'https://version.test')) ..httpClientAdapter = adapter; final result = await UpdateApi( dio: dio, ).check(platform: 'android', channel: 'beta', currentBuild: 6); expect(result.release?.buildNumber, 7); expect(adapter.request?.path, '/api/client/v1/update'); expect(adapter.request?.queryParameters['platform'], 'android'); expect(adapter.request?.queryParameters['channel'], 'beta'); expect(adapter.request?.queryParameters['currentBuild'], 6); expect(adapter.request?.headers['Authorization'], isNull); }); test('协调器合并并发检查请求', () async { final adapter = _FakeAdapter( statusCode: 200, body: '{"hasUpdate":false,"forceUpdate":false,"release":null}', delay: const Duration(milliseconds: 20), ); final dio = Dio(BaseOptions(baseUrl: 'https://version.test')) ..httpClientAdapter = adapter; final coordinator = UpdateCoordinator(api: UpdateApi(dio: dio)); await Future.wait([ coordinator.debugCheck(platform: 'android'), coordinator.debugCheck(platform: 'android'), ]); expect(adapter.callCount, 1); }); test('Internal 更新缺少 SHA-256 时在下载前拒绝', () async { const unsafeRelease = AppRelease( id: 'unsafe', versionName: '1.2.2', buildNumber: 7, downloadUrl: 'https://downloads.example.com/jizhi-7.apk', releaseNotes: '', sha256: null, fileSize: null, publishedAt: null, ); await expectLater( UpdateDownloader().downloadAndVerify(unsafeRelease, onProgress: (_) {}), throwsA( isA().having( (error) => error.message, 'message', contains('SHA-256'), ), ), ); }); test('UpdateApi 将 404 和 429 转换为稳定错误', () async { for (final entry in { 404: UpdateFailureKind.unavailable, 429: UpdateFailureKind.rateLimited, }.entries) { final dio = Dio(BaseOptions(baseUrl: 'https://version.test')) ..httpClientAdapter = _FakeAdapter(statusCode: entry.key, body: '{}'); await expectLater( UpdateApi( dio: dio, ).check(platform: 'ios', channel: 'stable', currentBuild: 6), throwsA( isA().having( (error) => error.kind, 'kind', entry.value, ), ), ); } }); testWidgets('普通更新面板展示说明并允许忽略', (tester) async { var ignored = false; await tester.pumpWidget( MaterialApp( theme: AppTheme.light, home: Scaffold( body: UpdatePromptSheet( release: release, forced: false, onIgnore: () async => ignored = true, ), ), ), ); expect(find.text('发现新版本'), findsOneWidget); expect(find.textContaining('修复识别问题'), findsOneWidget); expect(find.text('本次更新'), findsOneWidget); expect(find.byType(MarkdownBody), findsOneWidget); expect(find.text('忽略此版本'), findsOneWidget); await tester.tap(find.text('忽略此版本')); await tester.pump(); expect(ignored, isTrue); }); testWidgets('强制更新面板没有忽略入口并适配深色主题', (tester) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.dark, home: Scaffold( body: UpdatePromptSheet( release: release, forced: true, onIgnore: () async {}, ), ), ), ); expect(find.text('必须更新后继续使用'), findsOneWidget); expect(find.text('忽略此版本'), findsNothing); expect(find.text('立即更新'), findsOneWidget); expect(tester.takeException(), isNull); }); } class _FakeAdapter implements HttpClientAdapter { final int statusCode; final String body; RequestOptions? request; int callCount = 0; final Duration delay; _FakeAdapter({ required this.statusCode, required this.body, this.delay = Duration.zero, }); @override Future fetch( RequestOptions options, Stream? requestStream, Future? cancelFuture, ) async { request = options; callCount++; if (delay > Duration.zero) await Future.delayed(delay); return ResponseBody.fromString( body, statusCode, headers: { Headers.contentTypeHeader: ['application/json'], }, ); } @override void close({bool force = false}) {} }