app scaffold
This commit is contained in:
@@ -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 在重建时会把上一次的值带进新的 AsyncError(hasValue 仍为 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, (_, _) {});
|
||||
|
||||
// runAsync:testWidgets 默认跑在 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 天前');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user