diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b5fbc45 --- /dev/null +++ b/CLAUDE.md @@ -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 `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 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. diff --git a/ac_web/test/Api/ApiLayerTest.dart b/ac_web/test/Api/ApiLayer_test.dart similarity index 100% rename from ac_web/test/Api/ApiLayerTest.dart rename to ac_web/test/Api/ApiLayer_test.dart diff --git a/ac_web/test/Layouts/MainApp_test.dart b/ac_web/test/Layouts/MainApp_test.dart new file mode 100644 index 0000000..6040220 --- /dev/null +++ b/ac_web/test/Layouts/MainApp_test.dart @@ -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': [], + }, + ]), + 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 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); + }); +} diff --git a/ac_web/test/widget_test.dart b/ac_web/test/widget_test.dart deleted file mode 100644 index 78730c3..0000000 --- a/ac_web/test/widget_test.dart +++ /dev/null @@ -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); - }); -}