import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/auth_api.dart'; import 'package:miaoji_zhang/shared/services/local_database.dart'; import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart'; void main() { tearDown(() { ApiClient.availability.value = BackendAvailability.unknown; }); testWidgets('离线状态只显示可点击且可访问的云朵重试图标', (tester) async { var retries = 0; ApiClient.availability.value = BackendAvailability.offline; await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar( actions: [ BackendStatusIcon( onRetry: () async { retries++; }, ), ], ), ), ), ); final retry = find.byTooltip('当前显示本地数据,点击重试'); expect(retry, findsOneWidget); await tester.tap(retry); await tester.pump(); expect(retries, 1); ApiClient.availability.value = BackendAvailability.online; await tester.pump(); expect(retry, findsNothing); }); test('连接、发送和接收失败都会被识别为后端不可达', () { DioException failure(DioExceptionType type) => DioException( requestOptions: RequestOptions(path: '/test'), type: type, ); expect( isConnectivityError(failure(DioExceptionType.connectionError)), isTrue, ); expect( isConnectivityError(failure(DioExceptionType.connectionTimeout)), isTrue, ); expect(isConnectivityError(failure(DioExceptionType.sendTimeout)), isTrue); expect( isConnectivityError(failure(DioExceptionType.receiveTimeout)), isTrue, ); expect(isConnectivityError(failure(DioExceptionType.badResponse)), isFalse); }); test('AI 伙伴缓存 DTO 可无损往返', () { final source = { 'avatarKey': 'cat', 'personaKey': 'gentle', 'customName': '小记', 'roastLevel': 20, 'stickerFrequency': 40, 'proactiveLevel': 60, }; final companion = AiCompanion.fromJson(source); expect(companion.toJson(), source); }); test('账号分类缓存为空时会补齐支出和收入兜底并允许本地记账', () { final database = LocalDatabase.inMemoryForTesting(seedDefaults: false); addTearDown(database.close); database.ensureDefaultCategories(); final expense = database.categories('expense'); final income = database.categories('income'); expect(expense, hasLength(12)); expect(income, hasLength(7)); expect(expense.first['name'], '餐饮'); expect(income.first['name'], '工资'); expect(income.first['id'], 13); final transaction = database.createTransaction({ 'ledgerId': 1, 'categoryId': expense.first['id'], 'type': 'expense', 'amount': 18.5, 'occurredAt': DateTime.utc(2026, 8, 21).toIso8601String(), }); expect(transaction['amount'], 18.5); expect(transaction['categoryName'], '餐饮'); }); test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () { final contracts = >{ 'lib/features/home/pages/home_page.dart': [ 'monthLocal', 'monthRemote', 'BudgetApi.getLocal', 'BudgetApi.getRemote', 'refreshRemote', ], 'lib/features/add/add_page.dart': [ "categoriesLocal('expense')", "categoriesRemote('expense')", "categoriesRemote('income')", 'localFirst: true', ], 'lib/features/stats/stats_page.dart': [ 'periodStatsLocal', 'periodStatsRemote', ], 'lib/features/stats/report_page.dart': [ 'ReportApi.local', 'ReportApi.remote', ], 'lib/features/settings/category_manage_page.dart': [ 'categoriesLocal', 'categoriesRemote', 'localFirst: true', ], 'lib/features/settings/recycle_bin_page.dart': [ 'recycleBinLocal', 'recycleBinRemote', ], 'lib/features/settings/companion_page.dart': [ 'cachedCompanion', 'cachedAvatars', 'cachedPersonas', 'forceRemote: true', ], }; for (final entry in contracts.entries) { final source = File(entry.key).readAsStringSync(); expect(source, contains('_loadRevision'), reason: entry.key); expect(source, contains('BackendStatusIcon'), reason: entry.key); for (final token in entry.value) { expect(source, contains(token), reason: '${entry.key}: $token'); } } final companion = File( 'lib/features/settings/companion_page.dart', ).readAsStringSync(); expect(companion, isNot(contains('if (!_loaded)'))); expect(companion, contains('BackendAvailability.online')); }); test('AI 缓存按后端和账号隔离,回收站破坏性操作只允许在线执行', () { final auth = File('lib/shared/api/auth_api.dart').readAsStringSync(); final config = File('lib/shared/api/config_api.dart').readAsStringSync(); final recycle = File( 'lib/features/settings/recycle_bin_page.dart', ).readAsStringSync(); expect(auth, contains("'ai_companion_\${BackendIdentity.scope}_\$userId'")); expect(config, contains("'public_avatars_\${BackendIdentity.scope}'")); expect(config, contains("'public_personas_\${BackendIdentity.scope}'")); expect(recycle, contains('availability != BackendAvailability.online')); expect(recycle, contains('if (online)')); }); }