feat: add flutter mobile console and refine login ui
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/core/network/api_exception.dart';
|
||||
|
||||
void main() {
|
||||
group('ApiClient', () {
|
||||
test('injects bearer token into request headers', () async {
|
||||
final client = ApiClient(
|
||||
baseUrl: 'https://example.com',
|
||||
tokenProvider: () => 'token-123',
|
||||
onUnauthorized: () async {},
|
||||
client: _FakeHttpClient((http.BaseRequest request) async {
|
||||
expect(request.headers['Authorization'], 'Bearer token-123');
|
||||
return _jsonResponse(<String, dynamic>{'ok': true});
|
||||
}),
|
||||
);
|
||||
|
||||
final response = await client.getJson('/api/test') as Map<String, dynamic>;
|
||||
|
||||
expect(response['ok'], isTrue);
|
||||
});
|
||||
|
||||
test('preserves backend subpath when building request URLs', () async {
|
||||
final client = ApiClient(
|
||||
baseUrl: 'https://example.com/live-recorder',
|
||||
tokenProvider: () => null,
|
||||
onUnauthorized: () async {},
|
||||
client: _FakeHttpClient((http.BaseRequest request) async {
|
||||
expect(
|
||||
request.url.toString(),
|
||||
'https://example.com/live-recorder/api/test',
|
||||
);
|
||||
return _jsonResponse(<String, dynamic>{'ok': true});
|
||||
}),
|
||||
);
|
||||
|
||||
await client.getJson('/api/test');
|
||||
});
|
||||
|
||||
test('triggers unauthorized callback on 401 response', () async {
|
||||
var unauthorizedCalled = false;
|
||||
|
||||
final client = ApiClient(
|
||||
baseUrl: 'https://example.com',
|
||||
tokenProvider: () => null,
|
||||
onUnauthorized: () async {
|
||||
unauthorizedCalled = true;
|
||||
},
|
||||
client: _FakeHttpClient((http.BaseRequest request) async {
|
||||
return _jsonResponse(
|
||||
<String, dynamic>{'message': 'unauthorized'},
|
||||
statusCode: 401,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.getJson('/api/test'),
|
||||
throwsA(
|
||||
isA<ApiException>().having(
|
||||
(ApiException error) => error.message,
|
||||
'message',
|
||||
'unauthorized',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(unauthorizedCalled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeHttpClient extends http.BaseClient {
|
||||
_FakeHttpClient(this._handler);
|
||||
|
||||
final Future<http.StreamedResponse> Function(http.BaseRequest request) _handler;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
return _handler(request);
|
||||
}
|
||||
}
|
||||
|
||||
http.StreamedResponse _jsonResponse(
|
||||
Map<String, dynamic> body, {
|
||||
int statusCode = 200,
|
||||
}) {
|
||||
final bytes = utf8.encode(jsonEncode(body));
|
||||
return http.StreamedResponse(
|
||||
Stream<List<int>>.value(bytes),
|
||||
statusCode,
|
||||
headers: const <String, String>{
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
|
||||
|
||||
void main() {
|
||||
group('AppBootstrapController', () {
|
||||
test('stays unconfigured when no backend address is stored', () async {
|
||||
final controller = AppBootstrapController<_FakeDependencyBundle>(
|
||||
config: const ApiConfig(seedBaseUrl: 'https://seed.example.com'),
|
||||
configStorage: _FakeBackendConfigStore(),
|
||||
dependenciesFactory: _FakeDependencyBundle.new,
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
|
||||
expect(controller.hasConfiguredBackend, isFalse);
|
||||
expect(controller.backendBaseUrl, isNull);
|
||||
expect(controller.dependencies, isNull);
|
||||
});
|
||||
|
||||
test('restores dependencies from stored backend address', () async {
|
||||
final store = _FakeBackendConfigStore(
|
||||
storedBaseUrl: 'https://example.com/live-recorder/',
|
||||
);
|
||||
final createdBundles = <_FakeDependencyBundle>[];
|
||||
final controller = AppBootstrapController<_FakeDependencyBundle>(
|
||||
config: const ApiConfig(seedBaseUrl: 'https://seed.example.com'),
|
||||
configStorage: store,
|
||||
dependenciesFactory: (String baseUrl) {
|
||||
final bundle = _FakeDependencyBundle(baseUrl);
|
||||
createdBundles.add(bundle);
|
||||
return bundle;
|
||||
},
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
|
||||
expect(controller.backendBaseUrl, 'https://example.com/live-recorder');
|
||||
expect(createdBundles, hasLength(1));
|
||||
expect(createdBundles.single.baseUrl, 'https://example.com/live-recorder');
|
||||
expect(createdBundles.single.session.restoreCount, 1);
|
||||
});
|
||||
|
||||
test('does not rebuild dependencies when backend address is unchanged', () async {
|
||||
final store = _FakeBackendConfigStore(
|
||||
storedBaseUrl: 'https://example.com/api',
|
||||
);
|
||||
final createdBundles = <_FakeDependencyBundle>[];
|
||||
final controller = AppBootstrapController<_FakeDependencyBundle>(
|
||||
config: const ApiConfig(),
|
||||
configStorage: store,
|
||||
dependenciesFactory: (String baseUrl) {
|
||||
final bundle = _FakeDependencyBundle(baseUrl);
|
||||
createdBundles.add(bundle);
|
||||
return bundle;
|
||||
},
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
final changed = await controller.updateBackendBaseUrl('https://example.com/api/');
|
||||
|
||||
expect(changed, isFalse);
|
||||
expect(createdBundles, hasLength(1));
|
||||
expect(createdBundles.single.session.clearLocalSessionCount, 0);
|
||||
});
|
||||
|
||||
test('rebuilds dependencies and clears session when backend changes', () async {
|
||||
final store = _FakeBackendConfigStore(
|
||||
storedBaseUrl: 'https://example.com/api',
|
||||
);
|
||||
final createdBundles = <_FakeDependencyBundle>[];
|
||||
final controller = AppBootstrapController<_FakeDependencyBundle>(
|
||||
config: const ApiConfig(),
|
||||
configStorage: store,
|
||||
dependenciesFactory: (String baseUrl) {
|
||||
final bundle = _FakeDependencyBundle(baseUrl);
|
||||
createdBundles.add(bundle);
|
||||
return bundle;
|
||||
},
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
final firstBundle = createdBundles.single;
|
||||
|
||||
final changed = await controller.updateBackendBaseUrl('https://new.example.com/root/');
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(store.storedBaseUrl, 'https://new.example.com/root');
|
||||
expect(firstBundle.session.clearLocalSessionCount, 1);
|
||||
expect(firstBundle.isDisposed, isTrue);
|
||||
expect(createdBundles, hasLength(2));
|
||||
expect(createdBundles.last.baseUrl, 'https://new.example.com/root');
|
||||
expect(createdBundles.last.session.restoreCount, 1);
|
||||
expect(controller.backendBaseUrl, 'https://new.example.com/root');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeBackendConfigStore implements BackendConfigStore {
|
||||
_FakeBackendConfigStore({
|
||||
this.storedBaseUrl,
|
||||
});
|
||||
|
||||
String? storedBaseUrl;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
storedBaseUrl = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readBackendBaseUrl() async => storedBaseUrl;
|
||||
|
||||
@override
|
||||
Future<void> writeBackendBaseUrl(String baseUrl) async {
|
||||
storedBaseUrl = baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeDependencyBundle implements AppDependencyBundle {
|
||||
_FakeDependencyBundle(this.baseUrl);
|
||||
|
||||
final String baseUrl;
|
||||
final _FakeSessionController session = _FakeSessionController();
|
||||
bool isDisposed = false;
|
||||
|
||||
@override
|
||||
SessionControllerHandle get sessionController => session;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
isDisposed = true;
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeSessionController extends ChangeNotifier implements SessionControllerHandle {
|
||||
int restoreCount = 0;
|
||||
int clearLocalSessionCount = 0;
|
||||
|
||||
@override
|
||||
bool get isLoggedIn => false;
|
||||
|
||||
@override
|
||||
bool get isRestoring => false;
|
||||
|
||||
@override
|
||||
Future<void> clearLocalSession() async {
|
||||
clearLocalSessionCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restore() async {
|
||||
restoreCount += 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
|
||||
|
||||
void main() {
|
||||
group('normalizeBackendBaseUrl', () {
|
||||
test('trims and removes trailing slash while preserving subpath', () {
|
||||
final value = normalizeBackendBaseUrl(' https://example.com/live-recorder/ ');
|
||||
|
||||
expect(value, 'https://example.com/live-recorder');
|
||||
});
|
||||
|
||||
test('rejects non-http schemes', () {
|
||||
expect(
|
||||
() => normalizeBackendBaseUrl('ftp://example.com'),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(FormatException error) => error.message,
|
||||
'message',
|
||||
'请输入以 http:// 或 https:// 开头的完整地址',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_setup_page.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('prefills setup page with seed backend address', (WidgetTester tester) async {
|
||||
final handle = _FakeBackendConfigHandle(
|
||||
seedBaseUrl: 'https://seed.example.com/live-recorder',
|
||||
currentBackendBaseUrl: '',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: BackendSetupPage(
|
||||
bootstrapController: handle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(textField.controller?.text, 'https://seed.example.com/live-recorder');
|
||||
});
|
||||
|
||||
testWidgets('shows validation error for invalid backend address', (WidgetTester tester) async {
|
||||
final handle = _FakeBackendConfigHandle();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: BackendSetupPage(
|
||||
bootstrapController: handle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ftp://example.com');
|
||||
await tester.tap(find.text('保存并继续'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('请输入以 http:// 或 https:// 开头的完整地址'), findsOneWidget);
|
||||
expect(handle.savedValues, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeBackendConfigHandle extends ChangeNotifier implements BackendConfigHandle {
|
||||
_FakeBackendConfigHandle({
|
||||
this.seedBaseUrl = '',
|
||||
this.currentBackendBaseUrl = '',
|
||||
});
|
||||
|
||||
final String currentBackendBaseUrl;
|
||||
final List<String> savedValues = <String>[];
|
||||
|
||||
@override
|
||||
final String seedBaseUrl;
|
||||
|
||||
@override
|
||||
String? get backendBaseUrl => currentBackendBaseUrl.isEmpty ? null : currentBackendBaseUrl;
|
||||
|
||||
@override
|
||||
bool get hasConfiguredBackend => backendBaseUrl != null;
|
||||
|
||||
@override
|
||||
String? get initializationErrorMessage => null;
|
||||
|
||||
@override
|
||||
bool get isInitializing => false;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> saveInitialBackendBaseUrl(String rawValue) async {
|
||||
savedValues.add(rawValue);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> updateBackendBaseUrl(String rawValue) async => false;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/recovery_formatters.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
void main() {
|
||||
group('deriveRelativeMediaPath', () {
|
||||
test('returns relative path when file is under output root', () {
|
||||
final value = deriveRelativeMediaPath(
|
||||
outputRoot: r'C:\records',
|
||||
outputFilePath: r'C:\records\2026\demo.mp4',
|
||||
);
|
||||
|
||||
expect(value, '2026/demo.mp4');
|
||||
});
|
||||
|
||||
test('blocks unsafe traversal paths', () {
|
||||
final value = deriveRelativeMediaPath(
|
||||
outputRoot: '/records',
|
||||
outputFilePath: '../secret.mp4',
|
||||
);
|
||||
|
||||
expect(value, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('SystemSettings', () {
|
||||
test('parses retentionTaskStatuses from int list', () {
|
||||
final settings = SystemSettings.fromJson(<String, dynamic>{
|
||||
'retentionTaskStatuses': <int>[4, 5, 6],
|
||||
});
|
||||
|
||||
expect(settings.retentionTaskStatuses, <int>[4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
group('storage labels', () {
|
||||
test('maps raw english storage health to friendly chinese label', () {
|
||||
const storage = StorageGuardStatus(
|
||||
isEnabled: true,
|
||||
hasEnoughSpace: true,
|
||||
checkedPath: '/records',
|
||||
availableBytes: 0,
|
||||
requiredBytes: 0,
|
||||
message: 'storage is available',
|
||||
);
|
||||
|
||||
expect(formatStorageHealthLabel(storage), '空间充足');
|
||||
expect(formatStorageUsageLabel(storage), '空间充足');
|
||||
});
|
||||
|
||||
test('formats available and required capacity when bytes exist', () {
|
||||
const storage = StorageGuardStatus(
|
||||
isEnabled: true,
|
||||
hasEnoughSpace: false,
|
||||
checkedPath: '/records',
|
||||
availableBytes: 1024 * 1024 * 1024,
|
||||
requiredBytes: 512 * 1024 * 1024,
|
||||
message: '',
|
||||
);
|
||||
|
||||
expect(formatStorageHealthLabel(storage), '空间不足');
|
||||
expect(
|
||||
formatStorageUsageLabel(storage),
|
||||
'可用 1.00 GB / 需保留 512.00 MB',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
void main() {
|
||||
group('resolveLiveRoomWatchUri', () {
|
||||
test('uses normalized url first', () {
|
||||
final room = _buildRoom(
|
||||
normalizedUrl: 'https://www.douyin.com/live/normalized',
|
||||
sourceUrl: 'https://www.douyin.com/live/source',
|
||||
originalLiveRoomUrl: 'https://www.douyin.com/live/original',
|
||||
);
|
||||
|
||||
final uri = resolveLiveRoomWatchUri(room);
|
||||
|
||||
expect(uri?.toString(), 'https://www.douyin.com/live/normalized');
|
||||
});
|
||||
|
||||
test('falls back to source and original room url', () {
|
||||
final room = _buildRoom(
|
||||
normalizedUrl: 'javascript:void(0)',
|
||||
sourceUrl: 'https://www.douyin.com/live/source',
|
||||
originalLiveRoomUrl: 'https://www.douyin.com/live/original',
|
||||
);
|
||||
|
||||
final uri = resolveLiveRoomWatchUri(room);
|
||||
|
||||
expect(uri?.toString(), 'https://www.douyin.com/live/source');
|
||||
});
|
||||
|
||||
test('returns null when no valid http url exists', () {
|
||||
final room = _buildRoom(
|
||||
normalizedUrl: 'file:///tmp/demo',
|
||||
sourceUrl: '',
|
||||
originalLiveRoomUrl: 'javascript:void(0)',
|
||||
);
|
||||
|
||||
expect(resolveLiveRoomWatchUri(room), isNull);
|
||||
expect(hasLiveRoomWatchSource(room), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('compareMonitorRooms', () {
|
||||
test('sorts live rooms before offline rooms', () {
|
||||
final liveRoom = _buildRoom(
|
||||
id: 'live',
|
||||
availabilityStatus: 2,
|
||||
currentRecordingState: 0,
|
||||
);
|
||||
final offlineRoom = _buildRoom(
|
||||
id: 'offline',
|
||||
availabilityStatus: 1,
|
||||
currentRecordingState: 2,
|
||||
);
|
||||
|
||||
final rooms = <LiveRoom>[offlineRoom, liveRoom]..sort(compareMonitorRooms);
|
||||
|
||||
expect(rooms.first.id, 'live');
|
||||
});
|
||||
|
||||
test('sorts recording rooms before non-recording rooms inside same live group', () {
|
||||
final recordingRoom = _buildRoom(
|
||||
id: 'recording',
|
||||
availabilityStatus: 2,
|
||||
currentRecordingState: 2,
|
||||
);
|
||||
final idleRoom = _buildRoom(
|
||||
id: 'idle',
|
||||
availabilityStatus: 2,
|
||||
currentRecordingState: 1,
|
||||
);
|
||||
|
||||
final rooms = <LiveRoom>[idleRoom, recordingRoom]..sort(compareMonitorRooms);
|
||||
|
||||
expect(rooms.first.id, 'recording');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
LiveRoom _buildRoom({
|
||||
String id = 'room-1',
|
||||
int availabilityStatus = 1,
|
||||
int currentRecordingState = 0,
|
||||
bool isPinned = false,
|
||||
bool isPriority = false,
|
||||
String normalizedUrl = '',
|
||||
String sourceUrl = '',
|
||||
String originalLiveRoomUrl = '',
|
||||
String updatedAt = '2026-05-14T10:00:00Z',
|
||||
}) {
|
||||
return LiveRoom(
|
||||
id: id,
|
||||
platform: 1,
|
||||
platformName: 'Douyin',
|
||||
sourceUrl: sourceUrl,
|
||||
roomId: '123456',
|
||||
normalizedUrl: normalizedUrl,
|
||||
title: 'Demo room',
|
||||
anchorName: 'Anchor',
|
||||
anchorId: 'anchor-1',
|
||||
avatarUrl: null,
|
||||
coverUrl: null,
|
||||
remark: null,
|
||||
isPinned: isPinned,
|
||||
alias: null,
|
||||
isPriority: isPriority,
|
||||
pollingIntervalSecondsOverride: null,
|
||||
originalLiveRoomUrl: originalLiveRoomUrl,
|
||||
overrides: LiveRoomSettingsOverrides.fromJson(const <String, dynamic>{}),
|
||||
effectiveSettings: LiveRoomEffectiveSettings.fromJson(const <String, dynamic>{}),
|
||||
isEnabled: true,
|
||||
availabilityStatus: availabilityStatus,
|
||||
currentRecordingState: currentRecordingState,
|
||||
lastAutoStartDecisionCode: null,
|
||||
lastAutoStartDecisionSummary: null,
|
||||
lastAutoStartDecisionDetail: null,
|
||||
lastAutoStartDecisionAt: null,
|
||||
lastCheckedAt: null,
|
||||
createdAt: '2026-05-14T08:00:00Z',
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('uses green palette for live status', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StatusBadge(
|
||||
status: 'live',
|
||||
label: '直播中',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(
|
||||
of: find.byType(StatusBadge),
|
||||
matching: find.byType(Container),
|
||||
),
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
|
||||
expect(decoration.color, const Color(0xFFECFDF5));
|
||||
expect(find.text('直播中'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('falls back to gray palette for unknown status', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StatusBadge(
|
||||
status: 'mystery',
|
||||
label: '未知',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(
|
||||
of: find.byType(StatusBadge),
|
||||
matching: find.byType(Container),
|
||||
),
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
|
||||
expect(decoration.color, const Color(0xFFF1F5F9));
|
||||
expect(find.text('未知'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user