diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index cd6b9f4..c8b3102 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -5,10 +5,11 @@ | Version | Name | Phases | Status | Completed | |---------|------|--------|--------|-----------| | v0.1 | Walking Skeleton (MTConnect) | 1 | ✅ Shipped | 2026-07-21 | -| v0.2 | Machine Management UX | 2 | 🚧 Complete (commit pending) | - | -| v0.3 | OPC UA Protocol | 3 | 📋 Planned | - | -| v0.4 | Fanuc FOCAS Protocol | 4 | 📋 Planned | - | -| v0.5 | History & Trends | 5 | 📋 Planned | - | +| v0.2 | Machine Management UX | 2, 2.1 | ✅ Shipped (2.1 commit pending) | 2026-07-21 | +| v0.3 | Data-Item Selection + UX | 3, 3.1 | 📋 Planned | - | +| v0.4 | OPC UA Protocol | 4 | 📋 Planned | - | +| v0.5 | Fanuc FOCAS Protocol | 5 | 📋 Planned | - | +| v0.6 | History & Trends | 6 | 📋 Planned | - | ## 🚧 Active Milestone: v0.2 Machine Management UX @@ -27,35 +28,57 @@ - [x] 02-B: App — machine detail screen (full snapshot) + dashboard row-click nav + back - [x] 02-C: App — config add/edit/delete screen + repo.Upsert + live monitor reload; Core AvailableProtocols -## 📋 Planned Milestone: v0.3 OPC UA Protocol +### Phase 2.1: Refinements [INSERTED] -**Goal:** Second protocol plugin (OPC UA) proving the multi-protocol architecture. -**Prerequisite:** v0.2 complete -**Estimated phases:** 1 +**Goal:** Post-v0.2 polish + repo hygiene. +**Depends on:** Phase 2 +**Reason:** UX/robustness feedback + tracked-artifact cleanup. + +**Plans:** +- [x] 02.1-R1: MTConnect shared static HttpClient + per-request timeout (linked CTS) — fix socket-exhaustion +- [x] 02.1-R2R3: disconnection UX (status color/dot/badge + LastSeen) + robust delete confirm overlay +- [x] 02.1-tablefix: dashboard → DataGrid (aligned columns, Details own column) + Avalonia.Controls.DataGrid 11.3.10 + +## 📋 Planned Milestone: v0.3 Data-Item Selection + UX + +**Goal:** Per-machine selection of which data items to monitor (probe catalog, opt-in, filters persistence); then themed/iconed UI polish. +**Prerequisite:** v0.2.1 committed +**Estimated phases:** 2 | Phase | Focus | Research | |-------|-------|----------| -| 3 | OPC UA driver plugin (endpoint/nodes → MachineSnapshot), config keys, tests | Likely (OPC UA SDK on net48) | +| 3 | Item selection: Domain Machine.MonitoredItemIds + DataItemDescriptor + IProtocolDriver.ProbeAsync; MTConnect probe + filter to selected; Persistence col; App config checklist + detail shows selected | Unlikely (probe parser exists) | +| 3.1 | Theming/UX: Fluent light/dark + accent, per-protocol icons, curated layout | Unlikely | -## 📋 Planned Milestone: v0.4 Fanuc FOCAS Protocol +## 📋 Planned Milestone: v0.4 OPC UA Protocol -**Goal:** Fanuc FOCAS plugin via native fwlib32 (P/Invoke, Windows-only). +**Goal:** Second protocol plugin (OPC UA) proving multi-protocol architecture. **Prerequisite:** v0.3 complete **Estimated phases:** 1 | Phase | Focus | Research | |-------|-------|----------| -| 4 | FOCAS P/Invoke driver, native lib lifetime/marshalling, Windows-only guard | Likely (native interop; use dotnet:dotnet-pinvoke) | +| 4 | OPC UA driver plugin (endpoint/nodes → MachineSnapshot), config keys, tests | Likely (OPC UA SDK on net48) | -## 📋 Planned Milestone: v0.5 History & Trends +## 📋 Planned Milestone: v0.5 Fanuc FOCAS Protocol -**Goal:** Persist time-series, show trends in detail page. -**Prerequisite:** v0.4 (or parallelizable) +**Goal:** Fanuc FOCAS plugin via native fwlib32 (P/Invoke, Windows-only). +**Prerequisite:** v0.4 complete **Estimated phases:** 1 | Phase | Focus | Research | |-------|-------|----------| -| 5 | History tables, retention, trend charts in detail view | Unlikely | +| 5 | FOCAS P/Invoke driver, native lib lifetime/marshalling, Windows-only guard | Likely (native interop; use dotnet:dotnet-pinvoke) | + +## 📋 Planned Milestone: v0.6 History & Trends + +**Goal:** Persist time-series, show trends in detail page. +**Prerequisite:** v0.5 (or parallelizable) +**Estimated phases:** 1 + +| Phase | Focus | Research | +|-------|-------|----------| +| 6 | History tables, retention, trend charts in detail view | Unlikely | ## ✅ Completed Milestones diff --git a/.paul/STATE.md b/.paul/STATE.md index 9724927..293f478 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,27 +5,31 @@ See: .paul/PROJECT.md (updated 2026-07-21) **Core value:** Operators see live state of every configured machine across heterogeneous protocols in one place, adding machines/protocols without code. -**Current focus:** v0.2 complete (commit pending) → next v0.3 OPC UA +**Current focus:** v0.2 + v0.2.1 shipped → starting v0.3 Data-Item Selection ## Current Position -Milestone: v0.2 Machine Management UX -Phase: 2 of 2 (Machine Management UX) — COMPLETE -Plan: 02-A ✓ 02-B ✓ 02-C ✓ -Status: Complete (commit pending) — build 0/0, 125 tests + 2 docker-skip -Last activity: 2026-07-21 — M2 done: detail screen, config CRUD, offline detection, dynamic monitor reload. 3 screens live. +Milestone: v0.2 Machine Management UX (+ 2.1 refinements) — SHIPPED +Phase: 2 ✓, 2.1 ✓ → next Phase 3 (Data-Item Selection) +Plan: 02-A/B/C ✓, 02.1 R1/R2R3/tablefix ✓ +Status: v0.2.1 committing; v0.3 backend next +Last activity: 2026-07-22 — v0.2.1 done: shared HttpClient, disconnection UX, delete-confirm overlay, dashboard DataGrid (aligned + Details column). Build 0/0, 127 tests + 2 docker-skip. Progress: -- Milestone v0.2: [██████████] 100% -- Phase 2: 3 of 3 plans done +- v0.2 (+2.1): [██████████] 100% +- Next: Phase 3 Data-Item Selection ## Loop Position ``` PLAN ──▶ APPLY ──▶ UNIFY - ✓ ✓ ◉ [Unifying — verified; awaiting commit] + ✓ ✓ ✓ [v0.2.1 complete → PLAN Phase 3] ``` +## Standing Authorization + +Auto-commit + push GREEN chunks (build 0-err + tests pass) to origin/main WITHOUT asking (user-granted 2026-07-22). Never commit red; no force-push; artifact leak-check each time. + ## Accumulated Context ### Decisions diff --git a/.paul/paul.json b/.paul/paul.json index 5496682..2e8ad49 100644 --- a/.paul/paul.json +++ b/.paul/paul.json @@ -1,10 +1,10 @@ { "name": "Junction", - "version": "0.1.0", + "version": "0.2.0", "milestone": { "name": "Machine Management UX", "version": "0.2.0", - "status": "complete_commit_pending" + "status": "shipped" }, "phase": { "number": 2, diff --git a/src/Junction.App/App.axaml b/src/Junction.App/App.axaml index ed9b70f..433d2b2 100644 --- a/src/Junction.App/App.axaml +++ b/src/Junction.App/App.axaml @@ -1,11 +1,16 @@ + + + + diff --git a/src/Junction.App/Converters/StatusKindToBrushConverter.cs b/src/Junction.App/Converters/StatusKindToBrushConverter.cs new file mode 100644 index 0000000..417a4d2 --- /dev/null +++ b/src/Junction.App/Converters/StatusKindToBrushConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Junction.App.Converters +{ + /// + /// Maps a token + /// ("online"/"offline"/"unknown") to a status brush. Keeps view-models free of + /// UI-framework types: the color decision lives entirely in the view layer. + /// + public sealed class StatusKindToBrushConverter : IValueConverter + { + public static readonly StatusKindToBrushConverter Instance = new StatusKindToBrushConverter(); + + private static readonly IBrush Online = new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32)); // green + private static readonly IBrush Offline = new SolidColorBrush(Color.FromRgb(0xC6, 0x28, 0x28)); // red + private static readonly IBrush Unknown = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + switch (value as string) + { + case "online": + return Online; + case "offline": + return Offline; + default: + return Unknown; + } + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); + } +} diff --git a/src/Junction.App/Junction.App.csproj b/src/Junction.App/Junction.App.csproj index 5491596..e14997f 100644 --- a/src/Junction.App/Junction.App.csproj +++ b/src/Junction.App/Junction.App.csproj @@ -14,6 +14,7 @@ + Shell reference used by to return to the dashboard. public MainWindowViewModel? Navigator { get; set; } - [ObservableProperty] private string _machineName = "—"; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DeleteConfirmPrompt))] + private string _machineName = "—"; + [ObservableProperty] private string _protocolId = "—"; [ObservableProperty] private string _machineIdText = "—"; [ObservableProperty] private string _pollInterval = "—"; [ObservableProperty] private string _agentUrl = "—"; - [ObservableProperty] private ConnectionState _connectionState = ConnectionState.Unknown; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsOnline))] + [NotifyPropertyChangedFor(nameof(IsDisconnected))] + [NotifyPropertyChangedFor(nameof(StatusKind))] + private ConnectionState _connectionState = ConnectionState.Unknown; + [ObservableProperty] private string _capturedAt = "—"; + + /// Timestamp of the last CONNECTED snapshot; "—" until first connect. + [ObservableProperty] private string _lastSeen = "—"; + [ObservableProperty] private int _itemCount; - /// Two-state delete guard: first click arms, second click confirms. - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(DeleteButtonText))] - private bool _confirmingDelete; + /// True only when currently connected. + public bool IsOnline => ConnectionState == ConnectionState.Connected; - public string DeleteButtonText => ConfirmingDelete ? "Confirm delete?" : "Delete"; + /// True when the machine is offline (disconnected or errored) — drives the red header badge. + public bool IsDisconnected => + ConnectionState == ConnectionState.Disconnected || ConnectionState == ConnectionState.Error; + + /// UI-agnostic status token ("online"/"offline"/"unknown") the View maps to a color. + public string StatusKind + { + get + { + switch (ConnectionState) + { + case ConnectionState.Connected: + return "online"; + case ConnectionState.Disconnected: + case ConnectionState.Error: + return "offline"; + default: + return "unknown"; + } + } + } + + /// Controls visibility of the delete-confirmation overlay. + [ObservableProperty] private bool _isDeleteConfirmVisible; + + /// Named confirmation prompt, e.g. "Delete 'VMC Sim (mock)'?". + public string DeleteConfirmPrompt => "Delete '" + MachineName + "'?"; /// The full set of current data items for this machine. public ObservableCollection Items { get; } = @@ -129,13 +166,23 @@ namespace Junction.App.ViewModels ConnectionState = snapshot.ConnectionState; CapturedAt = snapshot.CapturedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"); - Items.Clear(); - for (int i = 0; i < snapshot.Items.Count; i++) + if (snapshot.ConnectionState == ConnectionState.Connected) { - Items.Add(new DataItemRowViewModel(snapshot.Items[i])); + LastSeen = snapshot.CapturedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"); } - ItemCount = Items.Count; + // Synthetic Disconnected snapshots carry no items; keep the last-known values on + // screen (alongside the Disconnected badge + Last Seen) instead of blanking the table. + if (snapshot.Items.Count > 0 || snapshot.ConnectionState == ConnectionState.Connected) + { + Items.Clear(); + for (int i = 0; i < snapshot.Items.Count; i++) + { + Items.Add(new DataItemRowViewModel(snapshot.Items[i])); + } + + ItemCount = Items.Count; + } } private void OnSnapshotUpdated(object? sender, MachineSnapshot snapshot) @@ -172,25 +219,28 @@ namespace Junction.App.ViewModels Navigator?.ShowConfig(_machineId); } + /// Opens the named delete-confirmation overlay. + [RelayCommand] + private void RequestDelete() => IsDeleteConfirmVisible = true; + + /// Dismisses the delete-confirmation overlay without deleting. + [RelayCommand] + private void CancelDelete() => IsDeleteConfirmVisible = false; + /// - /// Two-state delete: the first invocation arms the confirm state; the second performs the - /// delete (repository + live monitor removal) and returns to the reloaded dashboard. + /// Confirmed delete: performs the delete (repository + live monitor removal) and returns to + /// the reloaded dashboard. Only reachable from the confirmation overlay. /// [RelayCommand] - private async Task Delete() + private async Task ConfirmDelete() { - if (!ConfirmingDelete) - { - ConfirmingDelete = true; - return; - } + IsDeleteConfirmVisible = false; var del = await _repository.DeleteAsync(_machineId, CancellationToken.None).ConfigureAwait(true); if (!del.IsSuccess) { var detail = del.Errors.Count > 0 ? del.Errors[0].Message : "unknown error"; _logger.LogError("Machine delete failed for {MachineId}: {Detail}", _machineId, detail); - ConfirmingDelete = false; return; } diff --git a/src/Junction.App/ViewModels/MachineRowViewModel.cs b/src/Junction.App/ViewModels/MachineRowViewModel.cs index f0a651a..b587f6c 100644 --- a/src/Junction.App/ViewModels/MachineRowViewModel.cs +++ b/src/Junction.App/ViewModels/MachineRowViewModel.cs @@ -17,10 +17,42 @@ namespace Junction.App.ViewModels [ObservableProperty] private string _name; [ObservableProperty] private string _protocolId; - [ObservableProperty] private ConnectionState _connectionState; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsOnline))] + [NotifyPropertyChangedFor(nameof(StatusKind))] + private ConnectionState _connectionState; + [ObservableProperty] private string _lastDatum; [ObservableProperty] private string _lastUpdated; + /// Wall-clock time (HH:mm:ss) of the last CONNECTED snapshot; "—" until first connect. + [ObservableProperty] private string _lastSeen; + + /// True only when currently connected. + public bool IsOnline => ConnectionState == ConnectionState.Connected; + + /// + /// UI-framework-agnostic status token the View maps to a color: + /// "online" (green), "offline" (red), "unknown" (grey). + /// + public string StatusKind + { + get + { + switch (ConnectionState) + { + case ConnectionState.Connected: + return "online"; + case ConnectionState.Disconnected: + case ConnectionState.Error: + return "offline"; + default: + return "unknown"; + } + } + } + public MachineRowViewModel(Machine machine, Action? onOpenDetail = null) { _onOpenDetail = onOpenDetail; @@ -30,6 +62,7 @@ namespace Junction.App.ViewModels _connectionState = ConnectionState.Unknown; _lastDatum = "—"; _lastUpdated = "—"; + _lastSeen = "—"; } /// Opens the detail screen for this machine via the dashboard-supplied callback. @@ -46,7 +79,19 @@ namespace Junction.App.ViewModels ConnectionState = snapshot.ConnectionState; LastUpdated = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss"); - LastDatum = Representative(snapshot); + + if (snapshot.ConnectionState == ConnectionState.Connected) + { + // A live read: advance the last-seen marker and refresh the shown datum. + LastSeen = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss"); + } + + // Synthetic Disconnected snapshots carry no items; keep the last-known datum + // rather than blanking it, and leave LastSeen at the last connected time. + if (snapshot.Items.Count > 0) + { + LastDatum = Representative(snapshot); + } } /// diff --git a/src/Junction.App/Views/DashboardView.axaml b/src/Junction.App/Views/DashboardView.axaml index 337f439..f2705cb 100644 --- a/src/Junction.App/Views/DashboardView.axaml +++ b/src/Junction.App/Views/DashboardView.axaml @@ -20,38 +20,54 @@ VerticalAlignment="Center" /> - - - - - - - - - + + + - - - - - - - - + + + + + + - - - - /// Machine this driver reads for; stamped onto the snapshot. /// Base MTConnect agent URL (e.g. "http://host:5000"). Non-null, absolute. - /// HTTP transport. Injected for testability; owned by the caller/factory. - public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient) + /// + /// HTTP transport. Injected for testability; owned by the caller/factory. In production a single + /// shared instance is reused across all drivers (its global is + /// ), so the per-request timeout is enforced here. + /// + /// + /// Per-request timeout enforced via a linked . Must be positive + /// or . + /// + public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient, TimeSpan requestTimeout) { if (agentUrl is null) throw new ArgumentNullException(nameof(agentUrl)); _http = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + if (requestTimeout <= TimeSpan.Zero && requestTimeout != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(requestTimeout), requestTimeout, "Request timeout must be positive or Timeout.InfiniteTimeSpan."); + } + _requestTimeout = requestTimeout; _machineId = machineId; if (!Uri.TryCreate(agentUrl, UriKind.Absolute, out var baseUri)) @@ -67,10 +81,16 @@ namespace Junction.Protocols.MTConnect return Result.Cancelled(); } + // Shared HttpClient has an infinite global timeout; enforce the per-request timeout via a + // linked CTS. Caller cancel and timeout both surface as OperationCanceledException on the + // linked token; they are told apart by inspecting the caller token. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(_requestTimeout); + HttpResponseMessage response; try { - response = await _http.GetAsync(_currentUri, cancellationToken).ConfigureAwait(false); + response = await _http.GetAsync(_currentUri, cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -79,7 +99,7 @@ namespace Junction.Protocols.MTConnect } catch (OperationCanceledException ex) { - // TaskCanceledException not tied to the caller token => client timeout. + // Cancelled by the linked CTS but not by the caller => per-request timeout. return Fail("CURRENT_TIMEOUT", "HTTP request to '" + _currentUri + "' timed out: " + ex.Message); } catch (HttpRequestException ex) @@ -100,7 +120,7 @@ namespace Junction.Protocols.MTConnect try { #if NET5_0_OR_GREATER - body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + body = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); #else body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); #endif @@ -109,6 +129,10 @@ namespace Junction.Protocols.MTConnect { return Result.Cancelled(); } + catch (OperationCanceledException ex) + { + return Fail("CURRENT_TIMEOUT", "Reading response body from '" + _currentUri + "' timed out: " + ex.Message); + } catch (HttpRequestException ex) { return Fail("CURRENT_HTTP_ERROR", "Reading response body from '" + _currentUri + "' failed: " + ex.Message); diff --git a/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs b/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs index 00349b9..d3f63de 100644 --- a/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs +++ b/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs @@ -12,8 +12,11 @@ namespace Junction.Protocols.MTConnect /// , so it MUST have a public parameterless /// constructor. /// - /// Owns MTConnect-specific config validation and builds a real - /// for the produced , keeping the Domain protocol-agnostic. + /// Owns MTConnect-specific config validation and hands each produced + /// a single shared , keeping the Domain protocol-agnostic. Drivers are + /// recreated on config edits (AddOrUpdateMachineAsync); a per-driver client would risk socket + /// exhaustion, so the client is a process-wide singleton with an infinite global timeout and the + /// per-machine timeout is enforced inside the driver. /// /// Expected keys (case-insensitive): /// @@ -28,6 +31,14 @@ namespace Junction.Protocols.MTConnect private const string TimeoutKey = "TimeoutSeconds"; private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + /// + /// Process-wide shared transport reused by every driver this factory creates. Its global + /// is infinite; each driver enforces its own per-request + /// timeout via a linked . Sharing one + /// client avoids socket exhaustion when drivers are recreated on config edits. + /// + private static readonly HttpClient SharedHttpClient = new HttpClient { Timeout = System.Threading.Timeout.InfiniteTimeSpan }; + /// Protocol identifier this factory produces drivers for. public string ProtocolId => "mtconnect"; @@ -75,8 +86,7 @@ namespace Junction.Protocols.MTConnect timeout = TimeSpan.FromSeconds(seconds); } - var http = new HttpClient { Timeout = timeout }; - var driver = new MtconnectDriver(machine.Id, agentUrl, http); + var driver = new MtconnectDriver(machine.Id, agentUrl, SharedHttpClient, timeout); return Result.Ok(driver); } diff --git a/tests/Junction.Tests/Unit/MtconnectDriverTests.cs b/tests/Junction.Tests/Unit/MtconnectDriverTests.cs index 8602b3b..5965b8a 100644 --- a/tests/Junction.Tests/Unit/MtconnectDriverTests.cs +++ b/tests/Junction.Tests/Unit/MtconnectDriverTests.cs @@ -50,8 +50,15 @@ namespace Junction.Tests.Unit } } - private static MtconnectDriver DriverWith(HttpMessageHandler handler) => - new MtconnectDriver(MachineId, AgentUrl, new HttpClient(handler)); + private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30); + + // Shared client with infinite global timeout mirrors production; per-request timeout enforced in driver. + private static MtconnectDriver DriverWith(HttpMessageHandler handler, TimeSpan? requestTimeout = null) => + new MtconnectDriver( + MachineId, + AgentUrl, + new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }, + requestTimeout ?? GenerousTimeout); // ---- ReadCurrentAsync: happy path ---- @@ -150,6 +157,48 @@ namespace Junction.Tests.Unit Assert.True(result.WasCancelled); } + // ---- ReadCurrentAsync: per-request timeout (linked CTS) ---- + + [Fact] + public async Task ReadCurrentAsync_RequestExceedsPerRequestTimeout_ReturnsFailCurrentTimeoutNotCancelled() + { + // Handler stalls until its token trips; only the driver's linked CTS (CancelAfter) can trip it. + var handler = new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var driver = DriverWith(handler, TimeSpan.FromMilliseconds(50)); + + // Caller token never cancelled: any cancellation here is the per-request timeout. + var result = await driver.ReadCurrentAsync(CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.False(result.WasCancelled); + Assert.NotEmpty(result.Errors); + Assert.Contains(result.Errors, e => e.Code == "CURRENT_TIMEOUT"); + } + + [Fact] + public async Task ReadCurrentAsync_CallerCancelBeatsTimeout_ReturnsCancelled() + { + var handler = new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + // Generous per-request timeout so the caller cancel wins the race, not the timeout. + var driver = DriverWith(handler, GenerousTimeout); + using var cts = new CancellationTokenSource(); + + var task = driver.ReadCurrentAsync(cts.Token); + cts.Cancel(); + var result = await task; + + Assert.False(result.IsSuccess); + Assert.True(result.WasCancelled); + } + // ---- Factory: config validation ---- private static Machine MachineWithConfig(IReadOnlyDictionary? config) =>