fix(test): 模型清单 widget 测试用 runAsync 驱动真实 IO 链,修复 testWidgets 挂死
testWidgets 的 fake-async 区中发起真实文件 IO(refresh/下载落盘)会因 fake 微任务队列无人冲刷而永久挂起;改为 runAsync 包裹准备阶段调用, 交互阶段用 pump/真实等待交替循环驱动异步链直至 UI 呈现目标状态。 取消测试改用真实流式 BaseClient,避免 MockClient 整体物化流导致 进度永远停在 0。
This commit is contained in:
@@ -12,10 +12,11 @@ import 'package:observer/models/model_manager.dart';
|
||||
const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
|
||||
/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发)
|
||||
/// 分两段到达的下载流(中间 500ms 停顿:足够「取消」按钮出现并点击,
|
||||
/// 且第二次分块到达前取消标志必然已置位——每轮 pump 只推进 400ms fake 时钟)
|
||||
Stream<List<int>> _delayedChunks() async* {
|
||||
yield [1, 2, 3, 4];
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
yield [5, 6, 7, 8];
|
||||
}
|
||||
|
||||
@@ -30,6 +31,25 @@ Map<String, dynamic> _item() => {
|
||||
'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE',
|
||||
};
|
||||
|
||||
/// 真实流式下载客户端:send 立即返回分块流(MockClient 的 Response.fromStream
|
||||
/// 会先消费完整流再返回,导致进度永远停在 0)。
|
||||
class _StreamingClient extends http.BaseClient {
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
if (request.url.path == '/api/v1/app/update') {
|
||||
return http.StreamedResponse(
|
||||
http.ByteStream.fromBytes(utf8.encode(jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'models': [_item()]}
|
||||
}))),
|
||||
200);
|
||||
}
|
||||
return http.StreamedResponse(_delayedChunks(), 200,
|
||||
contentLength: _modelBytes.length);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
|
||||
@@ -59,22 +79,44 @@ void main() {
|
||||
rootDir: () async => root,
|
||||
);
|
||||
|
||||
/// 推进真实异步链:pump 冲刷 fake 微任务队列(并推进 fake 时钟触发 fake 定时器),
|
||||
/// 再用 runAsync 让真实事件循环转动,送达文件 IO 完成事件。
|
||||
/// testWidgets 的 fake-async 区中发起的真实 IO 必须这样交替驱动,否则异步链卡死。
|
||||
Future<void> pumpRealIo(WidgetTester tester,
|
||||
{Duration fake = Duration.zero}) async {
|
||||
await tester.pump(fake);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 5)));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// 交替 pump/真实等待,直至 finder 命中(UI 呈现目标状态)。
|
||||
Future<void> pumpUntilFound(WidgetTester tester, Finder finder,
|
||||
{Duration fake = Duration.zero}) async {
|
||||
for (var i = 0; i < 100 && finder.evaluate().isEmpty; i++) {
|
||||
await pumpRealIo(tester, fake: fake);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pumpSection(WidgetTester tester, ModelManager m) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ModelCatalogSection(manager: m))));
|
||||
await tester.pumpAndSettle();
|
||||
home: Scaffold(body: ModelCatalogSection(manager: m))));
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 50),
|
||||
EnginePhase.sendSemanticsUpdate, const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async {
|
||||
final m = manager();
|
||||
await m.refresh();
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('环颈雉鸡数据集'), findsOneWidget);
|
||||
expect(find.text('使用'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('使用'));
|
||||
await tester.pump(); // 下载启动,进度条出现
|
||||
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「已使用」
|
||||
await pumpUntilFound(tester, find.text('已使用'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isDownloaded(7), isTrue);
|
||||
@@ -84,12 +126,14 @@ void main() {
|
||||
|
||||
testWidgets('已激活:再次点击取消使用', (tester) async {
|
||||
final m = manager();
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first);
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await tester.runAsync(() => m.downloadModel(m.catalog.first));
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('已使用'), findsOneWidget);
|
||||
await tester.tap(find.text('已使用'));
|
||||
// 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」
|
||||
await pumpUntilFound(tester, find.text('使用'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isActive(7), isFalse);
|
||||
@@ -105,7 +149,7 @@ void main() {
|
||||
200)),
|
||||
rootDir: () async => root,
|
||||
);
|
||||
await m.refresh();
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('暂无已发布模型'), findsOneWidget);
|
||||
@@ -114,33 +158,22 @@ void main() {
|
||||
testWidgets('下载中:显示进度和取消按钮,取消后恢复使用', (tester) async {
|
||||
final m = ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: MockClient((req) async {
|
||||
if (req.url.path == '/api/v1/app/update') {
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'models': [_item()]}
|
||||
})),
|
||||
200);
|
||||
}
|
||||
if (req.url.path.startsWith('/download/models/')) {
|
||||
return http.Response.fromStream(http.StreamedResponse(
|
||||
_delayedChunks(), 200,
|
||||
contentLength: _modelBytes.length));
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
}),
|
||||
client: _StreamingClient(),
|
||||
rootDir: () async => root,
|
||||
);
|
||||
await m.refresh();
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await pumpSection(tester, m);
|
||||
|
||||
await tester.tap(find.text('使用'));
|
||||
await tester.pump(); // 首个分块到达,进度条与取消按钮出现
|
||||
// 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动)
|
||||
await pumpUntilFound(tester, find.text('取消'),
|
||||
fake: const Duration(milliseconds: 400));
|
||||
expect(find.text('取消'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('取消'));
|
||||
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「使用」
|
||||
await pumpUntilFound(tester, find.text('使用'),
|
||||
fake: const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isDownloaded(7), isFalse);
|
||||
|
||||
Reference in New Issue
Block a user