Files
observer/flutter_app/test/model_catalog_section_test.dart
T
2026-09-03 17:50:23 +08:00

334 lines
14 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart' show sha256;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:observer/camera/model_catalog_section.dart';
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();
/// 分两段到达的下载流(中间 500ms 停顿:足够「取消」按钮出现并点击,
/// 且第二次分块到达前取消标志必然已置位——每轮 pump 只推进 400ms fake 时钟)
Stream<List<int>> _delayedChunks() async* {
yield [1, 2, 3, 4];
await Future<void>.delayed(const Duration(milliseconds: 500));
yield [5, 6, 7, 8];
}
Map<String, dynamic> _item({
int datasetId = 7,
String variant = kVariantS,
String name = '数据集A',
}) =>
{
'datasetId': datasetId,
'datasetName': name,
if (variant.isNotEmpty) 'variant': variant,
'version': 'v1.0.0',
'labels': ['target', 'suspect'],
'sizeBytes': _modelBytes.length,
'sha256': _shaHex(_modelBytes),
'downloadUrl': '/download/models/$datasetId/$variant.tflite',
'coverUrl': '/api/v1/app/cover?namePrefix=DS001',
};
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
'code': 0,
'message': 'ok',
'data': {'models': models}
};
/// 真实流式下载客户端: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(_catalog([_item()])))),
200);
}
return http.StreamedResponse(_delayedChunks(), 200,
contentLength: _modelBytes.length);
}
}
void main() {
late Directory root;
setUp(() async {
root = await Directory.systemTemp.createTemp('catalog-section-test');
});
tearDown(() => root.delete(recursive: true));
ModelManager manager(List<Map<String, dynamic>> models) => ModelManager(
baseUrl: 'http://test.local',
client: MockClient((req) async {
if (req.url.path == '/api/v1/app/update') {
return http.Response.bytes(
utf8.encode(jsonEncode(_catalog(models))), 200);
}
if (req.url.path.startsWith('/download/models/')) {
return http.Response.bytes(_modelBytes, 200);
}
return http.Response('not found', 404);
}),
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 {
// 与设置弹层一致:区块置于可滚动容器(区块含识别模式行后超出测试视口,
// 点按前需 ensureVisible 滚动到目标)
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: ModelCatalogSection(manager: m)))));
await tester.pumpAndSettle(const Duration(milliseconds: 50),
EnginePhase.sendSemanticsUpdate, const Duration(seconds: 5));
}
/// 滚动到目标可见后点按(区块可超出视口)
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pump();
await tester.tap(finder);
}
testWidgets('未下载:显示下载按钮,点击后下载完成自动变为使用中', (tester) async {
final m = manager([_item()]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
expect(find.text('数据集A'), findsOneWidget);
expect(find.text('下载'), findsOneWidget);
await tapVisible(tester, find.text('下载'));
await tester.pump(); // 下载启动,进度条出现
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「使用中」
await pumpUntilFound(tester, find.text('使用中'));
await tester.pumpAndSettle();
expect(m.isDownloaded(7, kVariantS), isTrue);
expect(m.isActive(7, kVariantS), isTrue);
expect(find.text('使用中'), findsOneWidget);
});
testWidgets('已激活:点击使用中取消使用', (tester) async {
final m = manager([_item()]);
await tester.runAsync(() => m.refresh());
await tester.runAsync(() => m.downloadModel(m.catalog.first));
await pumpSection(tester, m);
expect(find.text('使用中'), findsOneWidget);
await tapVisible(tester, find.text('使用中'));
// 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
expect(m.isActive(7, kVariantS), isFalse);
expect(find.text('使用'), findsOneWidget);
});
testWidgets('目录为空:显示暂无已发布模型', (tester) async {
final m = ModelManager(
baseUrl: 'http://test.local',
client: MockClient((_) async => http.Response.bytes(
utf8.encode(jsonEncode(_catalog([]))),
200)),
rootDir: () async => root,
);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
expect(find.text('暂无已发布模型'), findsOneWidget);
});
testWidgets('下载中:显示进度和取消按钮,取消后恢复下载', (tester) async {
final m = ModelManager(
baseUrl: 'http://test.local',
client: _StreamingClient(),
rootDir: () async => root,
);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
await tapVisible(tester, find.text('下载'));
// 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动)
await pumpUntilFound(tester, find.text('取消'),
fake: const Duration(milliseconds: 400));
expect(find.text('取消'), findsOneWidget);
await tapVisible(tester, find.text('取消'));
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「下载」
await pumpUntilFound(tester, find.text('下载'),
fake: const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(m.isDownloaded(7, kVariantS), isFalse);
expect(m.isActive(7, kVariantS), isFalse);
expect(find.text('下载'), findsOneWidget);
});
testWidgets('双档位:同物种合并一张卡,下载只启用目标档,使用即停旧档', (tester) async {
final m = manager([_item(), _item(variant: kVariantN)]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
// 合并卡:同一数据集只一张卡,无双卡/无档位角标;双档行标注两档
expect(find.text('数据集A'), findsOneWidget);
expect(find.text('下载'), findsOneWidget, reason: '合并卡只有一个主按钮');
expect(find.textContaining('高精度 v1.0.0'), findsOneWidget);
expect(find.textContaining('高性能 v1.0.0'), findsOneWidget);
// 一次下载取回双档:同物种只启用目标档(默认高精度 s),n 档备好不启用。
// 双档下载收尾链交错,等 models 真正重载完成再断言
await tapVisible(tester, find.text('下载'));
await pumpUntilFound(tester, find.text('使用中'));
for (var i = 0;
i < 50 &&
!(m.isDownloaded(7, kVariantS) &&
m.isDownloaded(7, kVariantN) &&
m.isActive(7, kVariantS) &&
!m.isActive(7, kVariantN) &&
m.models.length == 1 &&
m.models.first.variant == kVariantS);
i++) {
await pumpRealIo(tester);
}
await tester.pumpAndSettle();
expect(m.isDownloaded(7, kVariantS), isTrue);
expect(m.isDownloaded(7, kVariantN), isTrue);
expect(m.isActive(7, kVariantS), isTrue);
expect(m.isActive(7, kVariantN), isFalse,
reason: '同物种同时只运行目标档,另一档备好不叠加启用');
expect(m.mode, kVariantS, reason: '下载双档不应改变目标档');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS);
expect(find.text('使用中'), findsOneWidget);
// 目标档切到高性能:运行中的 s 不受影响;n 已下载未启用 → 主按钮「使用」
// (保存目标档偏好是真实 IO,等按钮呈现才算 UI 落定;2026-09-03 简化:
// 不再叫「改用X」、不显示「当前使用 X」提示)
await tapVisible(tester, find.text('高性能'));
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
expect(m.mode, kVariantN);
expect(m.isActive(7, kVariantS), isTrue,
reason: '切换目标档不应改变运行中的模型');
expect(find.text('使用'), findsOneWidget);
expect(find.textContaining('当前使用'), findsNothing);
// 点「使用」:同物种 s 自动停用,n 热启用(setActive 尾部的落盘
// 与 notify 是真实 IO,等 UI 呈现「使用中」才算整链落定)
await tapVisible(tester, find.text('使用'));
await pumpUntilFound(tester, find.text('使用中'));
await tester.pumpAndSettle();
expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用另一档');
expect(m.isActive(7, kVariantN), isTrue);
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantN);
expect(find.text('使用中'), findsOneWidget, reason: '主按钮跟随当前档');
});
testWidgets('不同动物可用不同档位:使用只作用于点击的动物', (tester) async {
final m = manager([
_item(datasetId: 7, name: '数据集A'),
_item(datasetId: 7, variant: kVariantN, name: '数据集A'),
_item(datasetId: 8, name: '数据集B'),
_item(datasetId: 8, variant: kVariantN, name: '数据集B'),
]);
await tester.runAsync(() => m.refresh());
// 两个物种都下载并运行默认档(高精度 s),各自 n 档备好
for (final it in m.catalog) {
await tester.runAsync(() => m.downloadModel(it));
}
await pumpSection(tester, m);
expect(m.models.length, 2);
expect(find.text('使用中'), findsNWidgets(2));
// 目标档切高性能:两卡主按钮都变「使用」(各自 s 仍在运行、n 已备好)
await tapVisible(tester, find.text('高性能'));
await pumpUntilFound(tester, find.text('使用'));
expect(find.text('使用'), findsNWidgets(2));
// 只点第一张卡(数据集 7,目录序在前):该动物切到高性能,另一动物保持高精度
await tapVisible(tester, find.text('使用').first);
await pumpUntilFound(tester, find.text('使用中')); // 数据集 7 的卡先翻为使用中
for (var i = 0;
i < 50 &&
!(m.models.length == 2 &&
m.models.firstWhere((b) => b.datasetId == 7).variant ==
kVariantN &&
m.models.firstWhere((b) => b.datasetId == 8).variant ==
kVariantS);
i++) {
await pumpRealIo(tester);
}
await tester.pumpAndSettle();
expect(m.isActive(7, kVariantN), isTrue);
expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用同动物另一档');
expect(m.isActive(8, kVariantS), isTrue, reason: '未点击的动物保持原档位');
expect(m.isActive(8, kVariantN), isFalse);
expect(m.models.length, 2, reason: '两个动物仍同时识别');
expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantN);
expect(m.models.firstWhere((b) => b.datasetId == 8).variant, kVariantS);
expect(find.text('使用中'), findsOneWidget, reason: '只有数据集 7 的卡使用中');
expect(find.text('使用'), findsOneWidget,
reason: '数据集 8 目标档已备好:按钮就是使用(不叫改用)');
expect(find.textContaining('当前使用'), findsNothing);
});
testWidgets('识别模式分段控件:只改目标档偏好,不影响已在运行的模型', (tester) async {
final m = manager([_item(), _item(variant: kVariantN)]);
await tester.runAsync(() => m.refresh());
await tester.runAsync(() => m.downloadModel(m.catalog.first)); // s 启用
await pumpSection(tester, m);
expect(find.text('高精度'), findsOneWidget, reason: '分段控件选中默认档');
expect(find.text('高性能'), findsOneWidget);
expect(find.text('使用中'), findsOneWidget);
await tapVisible(tester, find.text('高性能'));
// 目标档 n 尚未下载(之前只下了 s)→ 主按钮变为「下载」:仍不切运行模型
await pumpUntilFound(tester, find.text('下载'));
await tester.pumpAndSettle();
expect(m.mode, kVariantN, reason: '点击分段控件应改目标档');
expect(m.isActive(7, kVariantS), isTrue, reason: '只改偏好不切运行模型');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS, reason: '没有热加载/切换');
expect(find.text('下载'), findsOneWidget, reason: '卡片按钮面向新目标档');
expect(find.textContaining('当前使用'), findsNothing);
// 目标档偏好持久化由 model_manager_test「识别目标档位偏好持久化」覆盖;
// widget 测试内 tap 触发的文件写与真实 IO 交错不可控,不在本层断言重启
});
}