From 74b34be3127ca5dce48c3cd0a19b41a61a271cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Tue, 1 Sep 2026 09:03:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(test):=20=E6=A8=A1=E5=9E=8B=E6=B8=85?= =?UTF-8?q?=E5=8D=95=20widget=20=E6=B5=8B=E8=AF=95=E7=94=A8=20runAsync=20?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E7=9C=9F=E5=AE=9E=20IO=20=E9=93=BE=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20testWidgets=20=E6=8C=82=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testWidgets 的 fake-async 区中发起真实文件 IO(refresh/下载落盘)会因 fake 微任务队列无人冲刷而永久挂起;改为 runAsync 包裹准备阶段调用, 交互阶段用 pump/真实等待交替循环驱动异步链直至 UI 呈现目标状态。 取消测试改用真实流式 BaseClient,避免 MockClient 整体物化流导致 进度永远停在 0。 --- .../test/model_catalog_section_test.dart | 89 +++++++++++++------ 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/flutter_app/test/model_catalog_section_test.dart b/flutter_app/test/model_catalog_section_test.dart index c99403f..07926a9 100644 --- a/flutter_app/test/model_catalog_section_test.dart +++ b/flutter_app/test/model_catalog_section_test.dart @@ -12,10 +12,11 @@ import 'package:observer/models/model_manager.dart'; const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8]; String _shaHex(List bytes) => sha256.convert(bytes).toString(); -/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发) +/// 分两段到达的下载流(中间 500ms 停顿:足够「取消」按钮出现并点击, +/// 且第二次分块到达前取消标志必然已置位——每轮 pump 只推进 400ms fake 时钟) Stream> _delayedChunks() async* { yield [1, 2, 3, 4]; - await Future.delayed(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: 500)); yield [5, 6, 7, 8]; } @@ -30,6 +31,25 @@ Map _item() => { 'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE', }; +/// 真实流式下载客户端:send 立即返回分块流(MockClient 的 Response.fromStream +/// 会先消费完整流再返回,导致进度永远停在 0)。 +class _StreamingClient extends http.BaseClient { + @override + Future 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 pumpRealIo(WidgetTester tester, + {Duration fake = Duration.zero}) async { + await tester.pump(fake); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 5))); + await tester.pump(); + } + + /// 交替 pump/真实等待,直至 finder 命中(UI 呈现目标状态)。 + Future 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 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);