app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
// core_ui 的高价值断言:
// 1. 取消/未授权必须静默——这两条一旦回归,用户每次返回上一页都会看到错误页。
// 2. 刷新失败时旧数据不能被错误页顶掉。
// 3. BusinessException 不给重试按钮。
import 'package:core_foundation/core_foundation.dart';
import 'package:core_ui/core_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _host(Widget child) => MaterialApp(home: Scaffold(body: child));
void main() {
group('ErrorPresenter', () {
test('BusinessException 不可重试——重试一个"库存不足"没有意义', () {
final ErrorDisplay d = ErrorPresenter.present(const BusinessException(40001, '库存不足'));
expect(d.title, '库存不足');
expect(d.retryable, isFalse);
expect(d.showTraceId, isFalse);
});
test('只有 ServerException 展示 traceId', () {
expect(ErrorPresenter.present(const ServerException('x')).showTraceId, isTrue);
// 请求没到后端,服务端日志里查不到这个 id。
expect(
ErrorPresenter.present(
const NetworkException('x', kind: NetworkErrorKind.noConnection),
).showTraceId,
isFalse,
);
});
test('取消与未授权必须静默', () {
expect(ErrorPresenter.isSilent(const RequestCancelledException()), isTrue);
expect(ErrorPresenter.isSilent(const UnauthorizedException()), isTrue);
expect(ErrorPresenter.isSilent(const ServerException('x')), isFalse);
});
test('非 AppException 走兜底,不泄露技术细节', () {
final ErrorDisplay d = ErrorPresenter.presentUnknown(StateError('null check'));
expect(d.title, '出了点问题');
});
});
group('AsyncValueView', () {
testWidgets('error 态渲染文案与重试按钮', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<int>(
value: AsyncValue<int>.error(const ServerException('x'), StackTrace.empty),
onRetry: () {},
data: (int v) => Text('$v'),
),
),
);
expect(find.text('系统繁忙'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
});
testWidgets('取消错误不显示错误态', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<int>(
value: AsyncValue<int>.error(const RequestCancelledException(), StackTrace.empty),
data: (int v) => Text('$v'),
),
),
);
expect(find.text('请求已取消'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('刷新失败但有旧数据时继续渲染旧数据,而不是换成整页错误', (WidgetTester tester) async {
// riverpod 在重建时会把上一次的值带进新的 AsyncErrorhasValue 仍为 true)。
// AsyncValueView 必须先看 hasValue——把用户正在看的列表换成一整页错误,
// 比什么都不做更糟。
bool shouldFail = false;
final FutureProvider<int> provider = FutureProvider<int>((Ref ref) async {
if (shouldFail) {
throw const ServerException('x');
}
return 7;
});
final ProviderContainer container = ProviderContainer();
addTearDown(container.dispose);
// riverpod 3 默认 autoDispose,没有监听者的话读完就被回收了。
container.listen<AsyncValue<int>>(provider, (_, _) {});
// runAsynctestWidgets 默认跑在 fake async 区里,真实的 Future 永远不会
// 完成(会挂到 10 分钟超时)。碰真 provider 生命周期必须包这一层。
final AsyncValue<int> state = (await tester.runAsync(() async {
expect(await container.read(provider.future), 7);
shouldFail = true;
await expectLater(container.refresh(provider.future), throwsA(isA<ServerException>()));
return container.read(provider);
}))!;
expect(state.hasError, isTrue);
expect(state.hasValue, isTrue, reason: '上一次的数据必须被保留');
await tester.pumpWidget(
_host(AsyncValueView<int>(value: state, data: (int v) => Text('$v'))),
);
expect(find.text('7'), findsOneWidget);
expect(find.text('系统繁忙'), findsNothing);
});
testWidgets('isEmpty 判定为真时走空态', (WidgetTester tester) async {
await tester.pumpWidget(
_host(
AsyncValueView<List<int>>(
value: const AsyncValue<List<int>>.data(<int>[]),
isEmpty: (List<int> v) => v.isEmpty,
data: (List<int> v) => Text('${v.length}'),
),
),
);
expect(find.text('暂无数据'), findsOneWidget);
});
});
test('formatElapsed 给出人类可读的相对时间', () {
final DateTime now = DateTime(2026, 8, 17, 12);
expect(formatElapsed(now.subtract(const Duration(seconds: 30)), now: now), '刚刚');
expect(formatElapsed(now.subtract(const Duration(minutes: 10)), now: now), '10 分钟前');
expect(formatElapsed(now.subtract(const Duration(hours: 3)), now: now), '3 小时前');
expect(formatElapsed(now.subtract(const Duration(days: 2)), now: now), '2 天前');
});
}