feat: M1.1 完成 + M1.2 启动 — 全量更新

M1.1 收尾:
- 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests)
- Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB
- 示例插件完善: manifest.json + 请求/响应示范 + 7个测试
- API 文档重写 (以 routes.rs 为唯一权威)
- MILESTONES.md 更新至 100%

M1.2 启动:
- P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states)
- ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs)
- M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景)
- 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护

总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-14 18:12:42 +08:00
parent 8ed9cb2d9d
commit d30c111c71
68 changed files with 8115 additions and 1201 deletions

View File

@@ -0,0 +1,183 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:showen_v2_flutter/models/api_response.dart';
import 'package:showen_v2_flutter/models/app_event.dart';
import 'package:showen_v2_flutter/models/ble_models.dart';
import 'package:showen_v2_flutter/models/ble_status.dart';
import 'package:showen_v2_flutter/models/device_status.dart';
import 'package:showen_v2_flutter/models/player_status.dart';
import 'package:showen_v2_flutter/models/video_item.dart';
import 'package:showen_v2_flutter/models/wifi_network.dart';
import 'package:showen_v2_flutter/models/wifi_status.dart';
void main() {
group('ApiResponse', () {
test('fromJson and toJson round trip', () {
final response = ApiResponse.fromJson(const <String, dynamic>{
'status': 'ok',
'message': 'done',
});
expect(response.isOk, isTrue);
expect(response.toJson(), <String, dynamic>{
'status': 'ok',
'message': 'done',
});
});
});
group('AppEvent', () {
test('fromJson prefers data payload map', () {
final event = AppEvent.fromJson(const <String, dynamic>{
'type': 'status',
'data': <String, dynamic>{'connected': true},
});
expect(event.type, 'status');
expect(event.payload, <String, dynamic>{'connected': true});
});
test('fromJson normalizes scalar payload', () {
final event = AppEvent.fromJson(const <String, dynamic>{
'type': 'progress',
'payload': 42,
});
expect(event.payload, <String, dynamic>{'value': 42});
});
});
group('Ble models', () {
test('BleDevice stores constructor fields', () {
const device = BleDevice(name: 'Showen', id: 'dev-1', rssi: -48);
expect(device.name, 'Showen');
expect(device.id, 'dev-1');
expect(device.rssi, -48);
});
test('BleStatus parses json and raw json', () {
final status = BleStatus.fromJson(const <String, dynamic>{
'ok': true,
'action': 'provision',
'state': 'queued',
});
final raw = BleStatus.fromRawJson(
'{"ok":false,"action":"scan","error":"failed"}',
);
expect(status.isQueued, isTrue);
expect(status.message, 'queued');
expect(raw.isSuccess, isFalse);
expect(raw.message, 'failed');
});
});
group('BleServiceStatus', () {
test('initial and fromJson', () {
final initial = BleServiceStatus.initial();
final status = BleServiceStatus.fromJson(const <String, dynamic>{
'running': true,
'embedded': true,
'device_name': 'Showen BLE',
});
expect(initial.running, isFalse);
expect(initial.embedded, isFalse);
expect(status.running, isTrue);
expect(status.embedded, isTrue);
expect(status.deviceName, 'Showen BLE');
});
});
group('DeviceStatus', () {
test('initial and copyWith preserve nested models', () {
final updated = DeviceStatus.initial().copyWith(
connected: true,
connectionType: 'wifi',
deviceName: 'Showen Box',
ipAddress: '192.168.1.20',
playerStatus: PlayerStatus.initial().copyWith(running: true),
wifiStatus: WifiStatus.fromJson(
const <String, dynamic>{'connected': true, 'ssid': 'Office'},
),
bleStatus: BleServiceStatus.fromJson(
const <String, dynamic>{'running': true, 'embedded': false},
),
);
expect(updated.connected, isTrue);
expect(updated.connectionType, 'wifi');
expect(updated.deviceName, 'Showen Box');
expect(updated.ipAddress, '192.168.1.20');
expect(updated.playerStatus?.running, isTrue);
expect(updated.wifiStatus?.ssid, 'Office');
expect(updated.bleStatus?.running, isTrue);
});
});
group('PlayerStatus', () {
test('fromJson and toJson round trip', () {
final status = PlayerStatus.fromJson(const <String, dynamic>{
'running': true,
'paused': false,
'in_transition': true,
'current_index': 3,
'playlist_length': 9,
'current_video': 'intro.mp4',
});
expect(status.toJson(), <String, dynamic>{
'running': true,
'paused': false,
'in_transition': true,
'current_index': 3,
'playlist_length': 9,
'current_video': 'intro.mp4',
});
expect(status.copyWith(paused: true).paused, isTrue);
});
});
group('VideoItem', () {
test('fromJson parses file metadata', () {
final video = VideoItem.fromJson(const <String, dynamic>{
'name': 'demo.mp4',
'size': 3145728,
});
expect(video.name, 'demo.mp4');
expect(video.size, 3145728);
expect(video.sizeLabel, '3.0 MB');
});
});
group('WifiNetwork', () {
test('fromJson parses network metadata', () {
final network = WifiNetwork.fromJson(const <String, dynamic>{
'ssid': 'ShowenLab',
'signal': -51,
'security': 'WPA2',
});
expect(network.ssid, 'ShowenLab');
expect(network.signalLabel, '-51 dBm');
expect(network.security, 'WPA2');
});
});
group('WifiStatus', () {
test('disconnected and fromJson', () {
final disconnected = WifiStatus.disconnected();
final status = WifiStatus.fromJson(const <String, dynamic>{
'connected': true,
'ssid': 'ShowenLab',
'ip': '192.168.1.10',
});
expect(disconnected.connected, isFalse);
expect(status.connected, isTrue);
expect(status.ssid, 'ShowenLab');
expect(status.ip, '192.168.1.10');
});
});
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:showen_v2_flutter/services/http_api_service.dart';
void main() {
group('HttpApiService.normalizeBaseUrl', () {
test('trims whitespace, adds scheme, and removes trailing slash', () {
expect(
HttpApiService.normalizeBaseUrl(' 192.168.1.8:5000/ '),
'http://192.168.1.8:5000',
);
});
test('preserves explicit https scheme', () {
expect(
HttpApiService.normalizeBaseUrl('https://showen.local/'),
'https://showen.local',
);
});
test('throws for empty baseUrl', () {
expect(
() => HttpApiService.normalizeBaseUrl(' '),
throwsA(
isA<ApiException>().having(
(error) => error.message,
'message',
'baseUrl 不能为空',
),
),
);
});
});
group('ApiException', () {
test('stores message and optional status code', () {
const exception = ApiException('upload failed', statusCode: 413);
expect(exception.message, 'upload failed');
expect(exception.statusCode, 413);
expect(exception.toString(), 'upload failed');
});
});
}