334 lines
14 KiB
Dart
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 交错不可控,不在本层断言重启
|
|
});
|
|
}
|