Initialized Claude COde and let it fix some test stuff

This commit is contained in:
2026-09-01 14:15:44 +02:00
parent 233d9feb94
commit 3ddb9fa066
4 changed files with 201 additions and 30 deletions
+75
View File
@@ -0,0 +1,75 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Flutter frontend ("AC Control") for the AcAPI, a .NET Web API backend of the "Artificial Company".
The UI shows AI agents, their tasks/availability, and the reports they produce.
The Flutter project lives in `ac_web/`, not at the repository root. **All commands below must be run from `ac_web/`.**
## Commands
```bash
flutter pub get # install dependencies
flutter run -d chrome # run in the browser (primary target)
flutter run -d linux # run as a desktop app
flutter analyze # lint / static analysis (flutter_lints)
flutter test # run all tests
flutter test test/Api/ApiLayer_test.dart # single test file
flutter test test/Api/ApiLayer_test.dart --plain-name 'parses agents' # single test
flutter build web
```
Flutter 3.44 / Dart 3.12 (`sdk: ^3.12.2`).
### Tests
Test files **must end in `_test.dart`** or `flutter test` silently ignores them. The project keeps its PascalCase file naming in front of that suffix: `test/Api/ApiLayer_test.dart`, `test/Layouts/MainApp_test.dart`.
Widget tests run against a mocked backend: build a `MockClient` that answers by route, hand it to `AcApi.configure(..., httpClient: client)` in `setUp`, and `AcApi.reset()` in `tearDown`. Do **not** use `pumpAndSettle` on pages that load data — the loading state is a `CircularProgressIndicator`, whose animation never settles; pump a couple of frames instead (see `settleApiCalls` in `MainApp_test.dart`). Layout tests set `tester.view.physicalSize` to cross the 900px desktop/mobile breakpoint.
`flutter analyze` reports ~40 pre-existing `file_names` / `non_constant_identifier_names` infos caused by the naming conventions below. They are expected — only care about new warnings and errors.
## Backend connection
The backend host/port is configured once in `lib/main.dart` via `AcApi.configure(const ApiConfig(host: 'localhost', port: 5118))`. Plain http is intentional — the API is only reached inside a trusted network. There is no environment/config file; changing the backend address means editing `main.dart`.
## Architecture
### API layer (`lib/Api/`)
Three strictly separated levels — keep them that way:
1. **`ApiClient`** — transport only. Builds requests, applies the timeout, maps every failure onto a sealed `ApiException` subtype (`ApiHttpException`, `ApiNetworkException`, `ApiTimeoutException`, `ApiParsingException`), decodes json. Knows nothing about controllers.
2. **`Services/*Service extends ApiServiceBase`** — one class per backend controller, exposing `controllerRoute` (e.g. `agents`) and typed methods that call `client.getList` / `getObject` with a dto `fromJson`.
3. **`AcApi`** — the single entry point. Holds the shared instance (`AcApi.instance`), lazily creates services, and is reconfigurable via `AcApi.configure(...)` / `AcApi.reset()` (tests inject a `MockClient` through the `httpClient` parameter).
`lib/Api/Api.dart` is a barrel file; UI code should `import 'package:ac_web/Api/Api.dart';`.
**Adding a backend controller**: create the dtos under `Api/Dtos`, add a `<Name>Service extends ApiServiceBase`, expose it as a lazy getter on `AcApi`, and export both from `Api.dart`.
### DTOs and .NET interop (`lib/Api/Dtos/`, `lib/Api/JsonReader.dart`)
The backend is still in development, so parsing is deliberately defensive. Never call `json['key']` directly in a dto — always go through `JsonReader`, which resolves keys case-insensitively (PascalCase from .NET vs. camelCase), tolerates missing properties, and parses enums that may arrive as either a name or a numeric index.
Enums (`EAgentType`, `EAgentStatus`, `EReportType`) mirror .NET enums. **The declaration order must match the .NET enum** because values may be serialized as indices, and `unknown` is always the last value so it never shadows a real backend value and acts as the fallback for values this client does not know yet. `EReportType.wireName` carries the SCREAMING_SNAKE .NET spelling.
### UI (`lib/Layouts/`, `lib/Pages/`, `lib/Widgets/`)
- `MainApp` picks the layout by width: `>= DesktopView.minimumWidth` (900) → `DesktopView` (three columns side by side: Reports | LatestReports | Agents), otherwise `MobileView` (same three pages behind a `NavigationBar`). Both layouts reuse the *same* page widgets, so pages must not assume they own the whole screen.
- **No state management package and no routing package.** Data loading is consistently: `StatefulWidget` → `late Future<T> mXFuture` assigned in `initState` from `AcApi.instance...` → `FutureBuilder` with explicit loading / `snapshot.hasError` / empty-list branches. Reload = reassign the future inside `setState`. Navigation is plain `Navigator.of(context).push(MaterialPageRoute(...))`.
- Icons per enum value live in extensions (`AgentTypeIcons`, `ReportTypeIcons` — `type.icon`) so every page renders a type identically. Human labels come from `displayName` on the enum itself.
- `MarkdownText` is a hand-written markdown renderer (no `flutter_markdown` dependency) covering the subset the reports use: headings, lists, block quotes, fenced code, rules, and inline bold/italic/strikethrough/code/links. Extend it rather than adding a package.
- Dependencies are intentionally minimal (`http`, `cupertino_icons`). Prefer solving things with Flutter's own widgets before adding a package.
## Conventions
These deviate from stock Dart style; match the surrounding code rather than the default lints:
- Files and directories are **PascalCase** (`lib/Widgets/AgentTaskCard.dart`), one public class per file, named after the file.
- Instance fields are prefixed with `m` (`mAgent`, `mReportsFuture`, `mIsReloading`); private helpers keep the leading underscore (`_buildList`, `_openType`).
- Public methods on `State` classes are **PascalCase** (`ReloadAgents`, `ReloadEntries`); private build helpers are camelCase with `_`.
- Enums are prefixed `E`, dtos suffixed `Dto`.
- Comments explain *why* (e.g. why enum order matters, why http is fine) in full sentences — keep that density.
+126
View File
@@ -0,0 +1,126 @@
import 'dart:convert';
import 'package:ac_web/Api/Api.dart';
import 'package:ac_web/Layouts/DesktopView.dart';
import 'package:ac_web/Layouts/MainApp.dart';
import 'package:ac_web/Layouts/MobileView.dart';
import 'package:ac_web/Pages/AgentAvailabilities.dart';
import 'package:ac_web/Widgets/LatestReportEntry.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// The pages talk to `AcApi.instance`, so every test runs against a mocked
/// backend instead of a real one.
void configureMockedApi() {
final client = MockClient((request) async {
final String path = request.url.path;
if (path.endsWith('agents')) {
return http.Response(
jsonEncode([
{
'Name': 'Ada',
'Type': 'CODER',
'LastSeen': '2026-08-28T12:00:00Z',
'Status': 'WORKING',
'Tasks': <dynamic>[],
},
]),
200,
headers: {'content-type': 'application/json'},
);
}
if (path.endsWith('reports')) {
return http.Response(
jsonEncode([
{
'Id': 'report-1',
'Title': 'Nightly log',
'Author': 'Ada',
'Timestamp': '2026-08-27T10:00:00Z',
'Type': 'LOG',
'Text': 'All good.',
},
]),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('Not found', 404);
});
AcApi.configure(const ApiConfig(host: 'api.local', port: 5000), httpClient: client);
}
/// Lets the futures of the mocked api complete. `pumpAndSettle` cannot be used
/// here because the loading state shows an endlessly spinning indicator.
Future<void> settleApiCalls(WidgetTester tester) async {
await tester.pump();
await tester.pump();
}
/// Resizes the test window and restores it again after the test.
void setWindowSize(WidgetTester tester, Size size) {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
}
void main() {
setUp(configureMockedApi);
tearDown(AcApi.reset);
testWidgets('narrow windows show the mobile layout with its navigation bar', (tester) async {
setWindowSize(tester, const Size(400, 800));
await tester.pumpWidget(const MainApp());
expect(find.byType(MobileView), findsOneWidget);
expect(find.byType(DesktopView), findsNothing);
expect(find.byType(NavigationBar), findsOneWidget);
// The latest reports page is the one the mobile view starts on.
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await settleApiCalls(tester);
expect(find.byType(LatestReportEntry), findsOneWidget);
expect(find.text('Nightly log'), findsOneWidget);
});
testWidgets('wide windows show all three columns next to each other', (tester) async {
setWindowSize(tester, const Size(DesktopView.minimumWidth + 200, 900));
await tester.pumpWidget(const MainApp());
await settleApiCalls(tester);
expect(find.byType(DesktopView), findsOneWidget);
expect(find.byType(MobileView), findsNothing);
// Reports on the left, the latest reports in the center, agents on the right.
expect(find.text('Code Review'), findsOneWidget);
expect(find.byType(LatestReportEntry), findsOneWidget);
expect(find.byType(AgentAvailabilities), findsOneWidget);
expect(find.text('Ada'), findsOneWidget);
});
testWidgets('a failing api is reported instead of spinning forever', (tester) async {
AcApi.configure(
const ApiConfig(host: 'api.local', port: 5000),
httpClient: MockClient((request) async => throw http.ClientException('offline')),
);
setWindowSize(tester, const Size(400, 800));
await tester.pumpWidget(const MainApp());
await settleApiCalls(tester);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.textContaining('Could not load reports'), findsOneWidget);
});
}
-30
View File
@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ac_web/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}