initial commit

This commit is contained in:
2026-07-27 14:42:23 +02:00
commit 833c68a189
257 changed files with 17947 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/models/models.dart';
Map<String, dynamic> _loadFixture(String name) {
final raw = File('test/fixtures/$name').readAsStringSync();
return jsonDecode(raw) as Map<String, dynamic>;
}
void main() {
group('Service.fromJson', () {
test('parses a fully-populated service including nested backup config', () {
final service = Service.fromJson(_loadFixture('service_full.json'));
expect(service.id, 'svc_123');
expect(service.name, 'postgres');
expect(service.composeFile, '/opt/compose/postgres/docker-compose.yml');
expect(service.composeType, 'docker compose');
expect(service.backupFolder, '/opt/data/containers/postgres');
expect(service.status, 'up');
expect(service.stopOnBackup, isTrue);
expect(service.externalBackupStopMinutes, 5);
expect(service.externalBackupStartMinutes, 2);
final backup = service.backup;
expect(backup, isNotNull);
expect(backup!.sourcePath, '/opt/data/containers/postgres');
expect(backup.nextRun, DateTime.parse('2026-07-27T02:00:00Z'));
expect(backup.lastRun, DateTime.parse('2026-07-26T02:00:00Z'));
expect(backup.targets, hasLength(1));
final target = backup.targets.single;
expect(target.id, 't1');
expect(target.method, BackupMethod.restic);
expect(target.remote, 'sftp:nas-cold:/backups/edge-01');
expect(target.params, {'transfers': '4'});
expect(target.schedule, '0 2 * * *');
expect(target.lastRun, DateTime.parse('2026-07-26T02:00:00Z'));
expect(target.nextRun, DateTime.parse('2026-07-27T02:00:00Z'));
expect(backup.history, hasLength(1));
final execution = backup.history.single;
expect(execution.success, isTrue);
expect(execution.durationSeconds, 12);
expect(execution.targetId, 't1');
});
test('fills in defaults for every omitted/nullable field', () {
final service = Service.fromJson(_loadFixture('service_minimal.json'));
expect(service.id, 'svc_456');
expect(service.name, 'n8n');
expect(service.status, 'down');
expect(service.composeType, isNull);
expect(service.backupFolder, isNull);
expect(service.stopOnBackup, isFalse);
expect(service.externalBackupStopMinutes, 0);
expect(service.externalBackupStartMinutes, 0);
expect(service.backup, isNull);
});
test('round-trips through toJson/fromJson without losing data', () {
final original = Service.fromJson(_loadFixture('service_full.json'));
final roundTripped = Service.fromJson(original.toJson());
expect(roundTripped, original);
});
});
}
@@ -0,0 +1,28 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/models/severity_mapping.dart';
import 'package:manager/core/theme/status_colors.dart';
void main() {
group('severityForServiceStatus', () {
test('maps up/down to good/critical and anything else to warning', () {
expect(severityForServiceStatus('up'), Severity.good);
expect(severityForServiceStatus('down'), Severity.critical);
expect(severityForServiceStatus('unknown'), Severity.warning);
expect(severityForServiceStatus('anything-else'), Severity.warning);
});
});
group('severityForSuccess', () {
test('maps true/false to good/critical', () {
expect(severityForSuccess(true), Severity.good);
expect(severityForSuccess(false), Severity.critical);
});
});
group('severityForNodeReachable', () {
test('maps true/false to good/critical', () {
expect(severityForNodeReachable(true), Severity.good);
expect(severityForNodeReachable(false), Severity.critical);
});
});
}
@@ -0,0 +1,92 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/network/api_exception.dart';
import 'package:manager/core/network/dio_error_mapper.dart';
RequestOptions _options() => RequestOptions(path: '/v1/services/');
void main() {
group('mapDioException', () {
test('maps timeout variants to ApiException.timeout', () {
for (final type in [
DioExceptionType.connectionTimeout,
DioExceptionType.sendTimeout,
DioExceptionType.receiveTimeout,
]) {
final result = mapDioException(DioException(requestOptions: _options(), type: type));
expect(result, isA<ApiTimeoutException>());
}
});
test('maps connectionError to ApiException.network', () {
final result = mapDioException(
DioException(
requestOptions: _options(),
type: DioExceptionType.connectionError,
message: 'Failed host lookup',
),
);
expect(result, isA<ApiNetworkException>());
expect((result as ApiNetworkException).message, contains('Failed host lookup'));
});
test('maps cancel to ApiException.cancelled', () {
final result = mapDioException(
DioException(requestOptions: _options(), type: DioExceptionType.cancel),
);
expect(result, isA<ApiCancelledException>());
});
test('maps badResponse with a {"error": msg} body to ApiException.server, extracting the message', () {
final response = Response<dynamic>(
requestOptions: _options(),
statusCode: 404,
data: {'error': 'not found'},
);
final result = mapDioException(
DioException(requestOptions: _options(), type: DioExceptionType.badResponse, response: response),
);
expect(result, isA<ApiServerException>());
final server = result as ApiServerException;
expect(server.statusCode, 404);
expect(server.message, 'not found');
});
test('maps badResponse without a parseable body to the status message', () {
final response = Response<dynamic>(
requestOptions: _options(),
statusCode: 500,
statusMessage: 'Internal Server Error',
data: null,
);
final result = mapDioException(
DioException(requestOptions: _options(), type: DioExceptionType.badResponse, response: response),
);
expect(result, isA<ApiServerException>());
expect((result as ApiServerException).message, 'Internal Server Error');
});
test('passes an already-typed ApiException through unchanged', () {
const original = ApiException.timeout();
expect(mapDioException(original), same(original));
});
test('maps a non-Dio, non-ApiException error to ApiException.unknown', () {
final result = mapDioException(FormatException('bad json'));
expect(result, isA<ApiUnknownException>());
});
});
group('ApiExceptionUserMessage', () {
test('server(404) gets a generic "not found" message regardless of body text', () {
const exception = ApiException.server(404, 'service not found');
expect(exception.userMessage, 'Not found.');
});
test('server(non-404) includes the status code and body message', () {
const exception = ApiException.server(400, 'no backup targets configured');
expect(exception.userMessage, contains('400'));
expect(exception.userMessage, contains('no backup targets configured'));
});
});
}
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/theme/app_theme.dart';
import 'package:manager/core/widgets/data_table_scaffold.dart';
import 'package:manager/core/widgets/empty_state.dart';
void main() {
const columns = [DataColumn(label: Text('Name')), DataColumn(label: Text('Status'))];
testWidgets('renders the given rows', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: DataTableScaffold(
columns: columns,
rows: const [
DataRow(cells: [DataCell(Text('postgres')), DataCell(Text('up'))]),
DataRow(cells: [DataCell(Text('grafana')), DataCell(Text('up'))]),
],
),
),
),
);
expect(find.text('postgres'), findsOneWidget);
expect(find.text('grafana'), findsOneWidget);
expect(find.byType(DataTable), findsOneWidget);
});
testWidgets('shows the empty-state fallback instead of an empty table', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: DataTableScaffold(
columns: columns,
rows: const [],
empty: const EmptyState(icon: Icons.layers_outlined, title: 'No services yet'),
),
),
),
);
expect(find.text('No services yet'), findsOneWidget);
expect(find.byType(DataTable), findsNothing);
});
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/theme/app_theme.dart';
import 'package:manager/core/widgets/empty_state.dart';
void main() {
testWidgets('renders title/message and invokes the action callback', (tester) async {
var tapped = false;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: EmptyState(
icon: Icons.layers_outlined,
title: 'No services yet',
message: 'Scan a compose folder to discover services.',
actionLabel: 'Scan now',
onAction: () => tapped = true,
),
),
),
);
expect(find.text('No services yet'), findsOneWidget);
expect(find.text('Scan a compose folder to discover services.'), findsOneWidget);
await tester.tap(find.widgetWithText(OutlinedButton, 'Scan now'));
await tester.pump();
expect(tapped, isTrue);
});
testWidgets('omits the action button when none is given', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: EmptyState(icon: Icons.inbox_outlined, title: 'Nothing here'),
),
),
);
expect(find.byType(OutlinedButton), findsNothing);
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/theme/app_theme.dart';
import 'package:manager/core/widgets/error_banner.dart';
void main() {
testWidgets('renders the message and invokes onRetry', (tester) async {
var retried = false;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ErrorBanner(
message: "Can't reach this node.",
onRetry: () => retried = true,
),
),
),
);
expect(find.text("Can't reach this node."), findsOneWidget);
await tester.tap(find.text('Retry'));
await tester.pump();
expect(retried, isTrue);
});
testWidgets('omits the retry button when onRetry is null', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(body: ErrorBanner(message: 'Something broke.')),
),
);
expect(find.text('Retry'), findsNothing);
});
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/theme/app_theme.dart';
import 'package:manager/core/widgets/mono_text.dart';
void main() {
testWidgets('renders in the JetBrains Mono family, small variant uses a smaller size', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: Column(
children: [
MonoText('/opt/compose/postgres/docker-compose.yml'),
MonoText('svc_123', small: true),
],
),
),
),
);
final regular = tester.widget<Text>(find.text('/opt/compose/postgres/docker-compose.yml'));
final small = tester.widget<Text>(find.text('svc_123'));
expect(regular.style?.fontFamily, 'JetBrains Mono');
expect(small.style?.fontFamily, 'JetBrains Mono');
expect(small.style!.fontSize!, lessThan(regular.style!.fontSize!));
});
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/theme/app_theme.dart';
import 'package:manager/core/widgets/slide_over_panel.dart';
void main() {
Widget wrap({required bool open, required VoidCallback onClose}) {
return MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Stack(
children: [
const Center(child: Text('List content')),
SlideOverPanel(
open: open,
onClose: onClose,
title: 'vaultwarden',
body: const Text('Detail body'),
actions: [OutlinedButton(onPressed: () {}, child: const Text('Stop'))],
),
],
),
),
);
}
testWidgets('closed panel ignores taps on its (invisible) close button', (tester) async {
var closed = false;
await tester.pumpWidget(wrap(open: false, onClose: () => closed = true));
await tester.pumpAndSettle();
// IgnorePointer(ignoring: true) means the close icon exists in the tree
// but isn't hit-testable — tapping it must not fire onClose.
await tester.tap(find.byIcon(Icons.close), warnIfMissed: false);
await tester.pump();
expect(closed, isFalse);
});
testWidgets('open panel responds to the close button and the scrim tap', (tester) async {
var closed = false;
await tester.pumpWidget(wrap(open: true, onClose: () => closed = true));
await tester.pumpAndSettle();
expect(find.text('vaultwarden'), findsOneWidget);
expect(find.text('Detail body'), findsOneWidget);
expect(find.text('Stop'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close));
expect(closed, isTrue);
});
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/theme/app_theme.dart';
import 'package:manager/core/widgets/sparkline.dart';
void main() {
testWidgets('paints without error for multiple points, one point, and empty data', (tester) async {
for (final values in [
[12.4, 16.0, 4.1, 19.8, 9.2],
[12.4],
<double>[],
]) {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: Center(child: Sparkline(values: values))),
),
);
expect(tester.takeException(), isNull);
expect(find.byType(CustomPaint), findsWidgets);
}
});
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/theme/app_theme.dart';
import 'package:manager/core/theme/status_colors.dart';
import 'package:manager/core/widgets/stat_tile.dart';
void main() {
testWidgets('renders label, value, qualifier and caption', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: StatTile(
label: 'Services',
value: '7',
valueQualifier: '/ 9 up',
caption: '1 down · 1 unknown',
severity: Severity.critical,
),
),
),
);
expect(find.text('Services'), findsOneWidget);
expect(find.textContaining('7'), findsOneWidget);
expect(find.textContaining('/ 9 up'), findsOneWidget);
expect(find.text('1 down · 1 unknown'), findsOneWidget);
});
testWidgets('severity stripe color matches the resolved status color', (tester) async {
final key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: StatTile(key: key, label: 'X', value: '1', severity: Severity.critical),
),
),
);
final context = key.currentContext!;
final statusColors = Theme.of(context).extension<StatusColors>()!;
final stripe = tester.widgetList<Container>(find.byType(Container)).firstWhere(
(c) => c.color == statusColors.critical,
orElse: () => throw StateError('No stripe container with critical color found'),
);
expect(stripe.color, statusColors.critical);
});
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/core/theme/app_theme.dart';
import 'package:manager/core/theme/status_colors.dart';
import 'package:manager/core/widgets/status_pill.dart';
Widget _wrap(Widget child) => MaterialApp(theme: AppTheme.light(), home: Scaffold(body: Center(child: child)));
void main() {
testWidgets('renders its label', (tester) async {
await tester.pumpWidget(_wrap(const StatusPill(label: 'up', severity: Severity.good)));
expect(find.text('up'), findsOneWidget);
});
testWidgets('good/critical/neutral each resolve to a distinct pill color', (tester) async {
Future<Color> pillColorFor(Severity severity) async {
await tester.pumpWidget(_wrap(StatusPill(label: severity.name, severity: severity)));
final container = tester.widget<Container>(
find.descendant(of: find.byType(StatusPill), matching: find.byType(Container)).first,
);
final decoration = container.decoration as BoxDecoration;
return decoration.color!;
}
final good = await pillColorFor(Severity.good);
final critical = await pillColorFor(Severity.critical);
final neutral = await pillColorFor(Severity.neutral);
expect(good, isNot(equals(critical)));
expect(good, isNot(equals(neutral)));
expect(critical, isNot(equals(neutral)));
});
}
+38
View File
@@ -0,0 +1,38 @@
{
"id": "svc_123",
"name": "postgres",
"compose_file": "/opt/compose/postgres/docker-compose.yml",
"compose_type": "docker compose",
"backup_folder": "/opt/data/containers/postgres",
"status": "up",
"stop_on_backup": true,
"external_backup_stop_minutes": 5,
"external_backup_start_minutes": 2,
"backup": {
"source_path": "/opt/data/containers/postgres",
"targets": [
{
"id": "t1",
"name": "nas-cold",
"method": "restic",
"remote": "sftp:nas-cold:/backups/edge-01",
"params": { "transfers": "4" },
"schedule": "0 2 * * *",
"last_run": "2026-07-26T02:00:00Z",
"next_run": "2026-07-27T02:00:00Z"
}
],
"next_run": "2026-07-27T02:00:00Z",
"last_run": "2026-07-26T02:00:00Z",
"history": [
{
"id": "bex_1",
"timestamp": "2026-07-26T02:00:00Z",
"success": true,
"duration_seconds": 12,
"message": "snapshot 4f2a9c1e created",
"target_id": "t1"
}
]
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "svc_456",
"name": "n8n",
"compose_file": "/opt/compose/n8n/docker-compose.yml",
"status": "down"
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:manager/app.dart';
import 'package:manager/core/utils/shared_preferences_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> _pumpApp(WidgetTester tester, Map<String, Object> prefsValues) async {
// Wide enough that NavRailShell renders its expanded (non-narrow) rail,
// which is what shows the "NodeMaster" wordmark asserted on below.
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
SharedPreferences.setMockInitialValues(prefsValues);
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
// Matches main.dart: no container-wide auto-retry, so a deliberately
// unreachable test connection settles into AsyncError deterministically
// instead of retrying with backoff mid-test.
retry: (retryCount, error) => null,
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
child: const App(),
),
);
await tester.pumpAndSettle();
}
void main() {
testWidgets('redirects to Settings when no connection is configured', (tester) async {
await _pumpApp(tester, {});
expect(find.text('NodeMaster'), findsOneWidget);
// The router redirect lands on Settings — its "Connections" block
// header (unique to that screen) proves we're actually there, not
// just that the nav rail lists the label.
expect(find.text('Connections'), findsOneWidget);
});
testWidgets('launches on the Dashboard when a connection is configured', (tester) async {
await _pumpApp(tester, {
// Port 1 refuses immediately rather than hanging until a timeout,
// so the test doesn't depend on real network access or reachability
// — only on routing/shell behavior once a connection exists.
'connections': '[{"id":"conn_test","name":"Test","baseUrl":"http://127.0.0.1:1"}]',
'active_connection_id': 'conn_test',
});
expect(find.text('NodeMaster'), findsOneWidget);
expect(find.text('Dashboard'), findsWidgets);
expect(find.text('Services'), findsOneWidget);
expect(find.text('Settings'), findsOneWidget);
});
}