diff --git a/.gitignore b/.gitignore
index 4a6afa6..f776852 100644
--- a/.gitignore
+++ b/.gitignore
@@ -354,3 +354,6 @@ MigrationBackup/
# JetBrains IDEA files
.idea/
+
+# Serena MCP files
+.serena/
diff --git a/Directory.Build.props b/Directory.Build.props
index e92a1c5..7f7c68f 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -10,6 +10,7 @@
9999
True
True
+ high
$(NoWarn)
@@ -28,7 +29,7 @@
- 1.1.1
+ 1.2.0
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 676b3ee..fff288d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,6 +19,7 @@
+
diff --git a/README.md b/README.md
index b56be9e..bd748d5 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,7 @@ Each package provides HTTP clients for accessing external data sources with stro
|---------------------|-----------------------------------------------------------------------------------------------------------------------------|
| **RTSW Client** | NOAA real-time solar wind feeds — 1-minute magnetometer and plasma JSON data with `active` and `source` metadata |
| **KP-Index Client** | Geomagnetic activity indices — nowcast and forecast data (3-day and 27-day) |
+| **WSA-ENLIL Client** | Solar wind forecast animation — downloads WSA-ENLIL model frames and encodes them as MP4 (H.264) via FFmpeg |
| **ACE Client** | Legacy text feeds (deprecated; use RTSW instead — see [Integrations #3](https://github.com/Aurora-Science-Hub/Integrations/issues/3)) |
See [detailed documentation](src/NoaaClient/README.md) for usage examples and API reference.
@@ -107,6 +108,7 @@ This repository provides production-ready HTTP clients for external space weathe
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later
- IDE: [Visual Studio 2025+](https://visualstudio.microsoft.com/), [Rider 2025+](https://www.jetbrains.com/rider/), or [VS Code](https://code.visualstudio.com/)
+- **FFmpeg** — required for WSA-ENLIL animation encoding (see [installation instructions](#ffmpeg-installation))
### Building from Source
@@ -125,6 +127,35 @@ dotnet build
dotnet test
```
+### FFmpeg Installation
+
+The **WSA-ENLIL animation** feature requires FFmpeg to encode forecast frames into MP4 video. Install it for your platform:
+
+**Windows:**
+```powershell
+winget install ffmpeg
+```
+
+**macOS:**
+```bash
+brew install ffmpeg
+```
+
+**Linux (Ubuntu/Debian):**
+```bash
+sudo apt-get install ffmpeg
+```
+
+**Linux (Fedora/RHEL):**
+```bash
+sudo dnf install ffmpeg-free
+```
+
+Verify the installation:
+```bash
+ffmpeg -version
+```
+
## Code Style
The solution uses [EditorConfig](.editorconfig) based on [Azure SDK .NET](https://github.com/Azure/azure-sdk-for-net/blob/main/.editorconfig) to maintain consistent code style across all packages.
diff --git a/samples/NoaaClientSample/Program.cs b/samples/NoaaClientSample/Program.cs
index b698f3c..86629ae 100644
--- a/samples/NoaaClientSample/Program.cs
+++ b/samples/NoaaClientSample/Program.cs
@@ -2,6 +2,7 @@
using AuroraScienceHub.Integrations.NoaaClient;
using AuroraScienceHub.Integrations.NoaaClient.Ace;
+using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil;
using AuroraScienceHub.Integrations.NoaaClient.KpIndex;
using AuroraScienceHub.Integrations.NoaaClient.Rtsw;
using AuroraScienceHub.Integrations.Samples.NoaaClientSample;
@@ -20,6 +21,7 @@
var aceClient = host.Services.GetRequiredService();
var kpIndexClient = host.Services.GetRequiredService();
var rtswClient = host.Services.GetRequiredService();
+var wsaEnlilClient = host.Services.GetRequiredService();
// Display header
AnsiConsole.Write(new FigletText("NOAA Client").Color(Color.Blue));
@@ -40,6 +42,9 @@
"KP Index 27-Day Forecast",
"KP Index 3-Day Forecast",
"KP Index Nowcast",
+ "WSA-ENLIL Animation",
+ "WSA-ENLIL Animation (small, 320px)",
+ "WSA-ENLIL Last Frame Time",
new string('-', 30),
"Execute All Requests",
"Exit"));
@@ -99,6 +104,28 @@ await AnsiConsole.Status()
var data = await kpIndexClient.GetKpIndexNowcastAsync(CancellationToken.None);
OutputFormatter.DisplayKpNowcast(data, 10);
}),
+ "WSA-ENLIL Animation" => FetchAndDisplay(async () =>
+ {
+ await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync(
+ maxWidth: 480, cancellationToken: CancellationToken.None);
+ await SaveAnimationAsync(stream, "enlil_animation.mp4");
+ }),
+ "WSA-ENLIL Animation (small, 320px)" => FetchAndDisplay(async () =>
+ {
+ await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync(
+ maxWidth: 320, cancellationToken: CancellationToken.None);
+ await SaveAnimationAsync(stream, "enlil_animation_small.mp4");
+ }),
+ "WSA-ENLIL Last Frame Time" => FetchAndDisplay(async () =>
+ {
+ var lastFrameTime = await wsaEnlilClient.GetLastFrameTimeAsync(
+ CancellationToken.None);
+ if (lastFrameTime is null)
+ AnsiConsole.MarkupLine("[yellow]No data available.[/]");
+ else
+ AnsiConsole.MarkupLine(
+ $"[green]Last frame time:[/] {lastFrameTime:yyyy-MM-dd HH:mm:ss} UTC");
+ }),
"Execute All Requests" => ExecuteAllRequests(),
_ => Task.CompletedTask
});
@@ -148,6 +175,12 @@ async Task ExecuteAllRequests()
{
var data = await kpIndexClient.GetKpIndexNowcastAsync(CancellationToken.None);
OutputFormatter.DisplayKpNowcast(data, 5);
+ }),
+ ("WSA-ENLIL Animation", async () =>
+ {
+ await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync(
+ maxWidth: 480, cancellationToken: CancellationToken.None);
+ await SaveAnimationAsync(stream, "enlil_animation.mp4");
})
};
@@ -185,4 +218,21 @@ await AnsiConsole.Progress()
_ => "Extreme"
};
+// Saves MP4 animation stream to a temp file and displays the result
+static async Task SaveAnimationAsync(Stream stream, string fileName)
+{
+ if (stream.Length == 0)
+ {
+ AnsiConsole.MarkupLine("[red]No animation data returned.[/]");
+ return;
+ }
+
+ var outputPath = Path.Combine(Path.GetTempPath(), fileName);
+ await using var fileStream = File.Create(outputPath);
+ await stream.CopyToAsync(fileStream);
+
+ AnsiConsole.MarkupLine($"[green]Animation saved:[/] {outputPath}");
+ AnsiConsole.MarkupLine($"[dim]Size: {stream.Length / 1024} KB | Format: MP4[/]");
+}
+
diff --git a/src/NoaaClient/NoaaClient.csproj b/src/NoaaClient/NoaaClient.csproj
index ed48fd3..2f3462d 100644
--- a/src/NoaaClient/NoaaClient.csproj
+++ b/src/NoaaClient/NoaaClient.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/NoaaClient/README.md b/src/NoaaClient/README.md
index 423c5b6..1789b96 100644
--- a/src/NoaaClient/README.md
+++ b/src/NoaaClient/README.md
@@ -49,6 +49,28 @@ var forecast3Day = await kpIndexClient.GetKpIndex3DayForecastAsync(cancellationT
var forecast27Day = await kpIndexClient.GetKpIndex27DayForecastAsync(cancellationToken);
```
+## WSA-ENLIL Client
+
+Generates MP4 (H.264) animations from WSA-ENLIL solar wind forecast model frames provided by NOAA SWPC.
+
+**Prerequisites:** FFmpeg must be installed on the system. See [FFmpeg Installation](../../README.md#ffmpeg-installation) for platform-specific instructions.
+
+```csharp
+// Download and encode WSA-ENLIL animation frames as MP4 video
+await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync(
+ maxWidth: 480,
+ cancellationToken: cancellationToken);
+
+// Save to file
+await using var fileStream = File.Create("enlil_animation.mp4");
+await stream.CopyToAsync(fileStream);
+```
+
+- **maxWidth** — maximum output width in pixels; frames are scaled proportionally (default: 480)
+- **Return value** — `MemoryStream` containing the complete MP4 video; caller is responsible for disposal
+- Frame rate: 20 fps (50 ms per frame)
+- Encoder: H.264 (libx264), CRF 23, pixel format yuv420p
+
## Deprecated API
`IAceClient` is obsolete and scheduled for removal ([#3](https://github.com/Aurora-Science-Hub/Integrations/issues/3)). Use `IRtswClient` for magnetometer and solar wind plasma data.
diff --git a/src/NoaaClient/ServiceCollectionExtensions.cs b/src/NoaaClient/ServiceCollectionExtensions.cs
index 127aac5..bab307a 100644
--- a/src/NoaaClient/ServiceCollectionExtensions.cs
+++ b/src/NoaaClient/ServiceCollectionExtensions.cs
@@ -1,4 +1,5 @@
using AuroraScienceHub.Integrations.NoaaClient.Ace;
+using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil;
using AuroraScienceHub.Integrations.NoaaClient.KpIndex;
using AuroraScienceHub.Integrations.NoaaClient.Rtsw;
using Microsoft.Extensions.DependencyInjection;
@@ -26,6 +27,7 @@ public static IServiceCollection AddNoaaClients(this IServiceCollection services
#pragma warning restore CS0618
services.AddHttpClient();
services.AddHttpClient();
+ services.AddHttpClient();
return services;
}
diff --git a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs
new file mode 100644
index 0000000..93d96a2
--- /dev/null
+++ b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs
@@ -0,0 +1,38 @@
+namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil;
+
+///
+/// Client for creating WSA-ENLIL solar wind forecast animations from NOAA SWPC imagery.
+///
+///
+/// Requires FFmpeg to be installed on the system and available in PATH.
+/// See README for platform-specific installation instructions.
+///
+public interface IWsaEnlilClient
+{
+ ///
+ /// Downloads the WSA-ENLIL animation manifest and all frames, assembling them into an optimized MP4 (H.264) video.
+ ///
+ /// Maximum output width in pixels. Frames are resized proportionally. Default 480.
+ /// Cancellation token.
+ /// A stream containing the MP4 video data. The caller is responsible for disposing this stream.
+ ///
+ /// The returned stream is a MemoryStream containing the complete MP4 data.
+ /// Typical usage: await using var stream = await GetEnlilAnimationAsync(cancellationToken: cancellationToken);
+ ///
+ Task GetEnlilAnimationAsync(
+ int maxWidth = 480,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the timestamp of the last frame in the WSA-ENLIL animation, without downloading frames or encoding video.
+ ///
+ /// Cancellation token.
+ ///
+ /// The timestamp extracted from the last frame's URL, or if the manifest is empty or unavailable.
+ ///
+ ///
+ /// Fetches only the manifest JSON (~1 KB). Use this to check whether the animation has been updated
+ /// before calling .
+ ///
+ Task GetLastFrameTimeAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs b/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs
new file mode 100644
index 0000000..0b68114
--- /dev/null
+++ b/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses;
+
+///
+/// A single frame entry in the WSA-ENLIL animation manifest.
+///
+/// Relative frame URL, e.g. "/images/animations/enlil/enlil_com2_58426_20250118T120000.jpg".
+public sealed record WsaEnlilManifestEntry(
+ [property: JsonPropertyName("url")] string Url
+);
diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs
new file mode 100644
index 0000000..cf3e249
--- /dev/null
+++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs
@@ -0,0 +1,148 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using AuroraScienceHub.Framework.Http;
+using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses;
+using FFMpegCore;
+using FFMpegCore.Pipes;
+using Microsoft.Extensions.Options;
+
+namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil;
+
+internal sealed partial class WsaEnlilClient : IWsaEnlilClient
+{
+ private const string ManifestPath = "products/animations/enlil.json";
+ private const int Fps = 20;
+ private const int Crf = 23; // H.264 quality (0 = lossless, 51 = worst)
+ private const string TempDirPrefix = "enlil_";
+ private const string FrameFileFormat = "frame_{0:D4}.jpg";
+ private const string FrameSearchPattern = "frame_%04d.jpg";
+ private const int OutputStreamCapacity = 5 * 1024 * 1024; // 5 MB initial buffer
+
+ private readonly HttpClient _httpClient;
+ private readonly Uri _baseUrl;
+
+ [GeneratedRegex(@"(\d{8}T\d{6})\.jpg$")]
+ private static partial Regex FrameTimestampRegex();
+
+ public WsaEnlilClient(
+ HttpClient httpClient,
+ IOptions options)
+ {
+ _httpClient = httpClient;
+ _baseUrl = options.Value.RequiredServerUrl;
+ }
+
+ public async Task GetEnlilAnimationAsync(
+ int maxWidth,
+ CancellationToken cancellationToken)
+ {
+ if (maxWidth <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxWidth), "Max width must be greater than zero.");
+ }
+
+ var manifest = await FetchManifestAsync(cancellationToken).ConfigureAwait(false);
+ if (manifest is null || manifest.Count == 0)
+ {
+ return new MemoryStream();
+ }
+
+ string? tempDir = null;
+ try
+ {
+ tempDir = Directory.CreateTempSubdirectory(TempDirPrefix).FullName;
+ await DownloadFramesAsync(manifest, tempDir, cancellationToken).ConfigureAwait(false);
+ return await EncodeVideoAsync(tempDir, maxWidth, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (tempDir is not null)
+ {
+ try { Directory.Delete(tempDir, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+ }
+
+ public async Task GetLastFrameTimeAsync(CancellationToken cancellationToken)
+ {
+ var manifest = await FetchManifestAsync(cancellationToken).ConfigureAwait(false);
+ if (manifest is null || manifest.Count == 0)
+ {
+ return null;
+ }
+
+ var lastUrl = manifest.Last().Url;
+ var match = FrameTimestampRegex().Match(lastUrl);
+ if (!match.Success)
+ {
+ return null;
+ }
+
+ return DateTime.ParseExact(
+ match.Groups[1].Value,
+ "yyyyMMddTHHmmss",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
+ }
+
+ private async Task?> FetchManifestAsync(
+ CancellationToken cancellationToken)
+ {
+ var manifestUrl = new Uri(_baseUrl, ManifestPath);
+ return await _httpClient
+ .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private async Task DownloadFramesAsync(
+ IReadOnlyCollection manifest,
+ string tempDir,
+ CancellationToken cancellationToken)
+ {
+ var index = 0;
+ foreach (var entry in manifest)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var frameUrl = new Uri(_baseUrl, entry.Url);
+ await using var sourceStream = await _httpClient
+ .GetStreamAsync(frameUrl, cancellationToken)
+ .ConfigureAwait(false);
+
+ var framePath = Path.Combine(tempDir, string.Format(FrameFileFormat, index));
+ await using var fileStream = File.Create(framePath);
+ await sourceStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
+
+ index++;
+ }
+ }
+
+ private static async Task EncodeVideoAsync(
+ string tempDir,
+ int maxWidth,
+ CancellationToken cancellationToken)
+ {
+ var outputStream = new MemoryStream(OutputStreamCapacity);
+ var inputPattern = Path.Combine(tempDir, FrameSearchPattern);
+
+ await FFMpegArguments
+ .FromFileInput(inputPattern, verifyExists: false,
+ inputOptions => inputOptions
+ .WithCustomArgument($"-framerate {Fps}"))
+ .OutputToPipe(new StreamPipeSink(outputStream),
+ outputOptions => outputOptions
+ .WithCustomArgument($"-vf scale={maxWidth}:-2")
+ .WithVideoCodec("libx264")
+ .WithCustomArgument($"-crf {Crf}")
+ .WithCustomArgument("-pix_fmt yuv420p")
+ .WithCustomArgument("-movflags +frag_keyframe+empty_moov")
+ .ForceFormat("mp4"))
+ .CancellableThrough(cancellationToken)
+ .ProcessAsynchronously()
+ .ConfigureAwait(false);
+
+ outputStream.Position = 0;
+ return outputStream;
+ }
+}
diff --git a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs
new file mode 100644
index 0000000..7735106
--- /dev/null
+++ b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs
@@ -0,0 +1,254 @@
+using System.Net;
+using AuroraScienceHub.Integrations.NoaaClient;
+using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil;
+using Microsoft.Extensions.Options;
+using Shouldly;
+
+namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient.WsaEnlil;
+
+///
+/// Unit tests for .
+///
+///
+/// Tests cover the HTTP/download pipeline. ffmpeg encoding is an integration concern
+/// and is not covered by unit tests.
+///
+public sealed class WsaEnlilClientTests
+{
+ private static readonly Uri BaseUrl = new("https://noaa.test");
+
+ [Fact(DisplayName = "Returns empty stream when NOAA manifest is an empty JSON array")]
+ public async Task GetEnlilAnimationAsync_WhenManifestIsEmpty_ReturnsEmptyStream()
+ {
+ // Arrange
+ var sut = CreateSut("[]");
+
+ // Act
+ await using var result = await sut.GetEnlilAnimationAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Length.ShouldBe(0);
+ }
+
+ [Fact(DisplayName = "Returns empty stream when NOAA manifest deserializes to null")]
+ public async Task GetEnlilAnimationAsync_WhenManifestIsNull_ReturnsEmptyStream()
+ {
+ // Arrange
+ var sut = CreateSut("null");
+
+ // Act
+ await using var result = await sut.GetEnlilAnimationAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // Assert
+ result.Length.ShouldBe(0);
+ }
+
+ [Theory(DisplayName = "Throws ArgumentOutOfRangeException when maxWidth is zero or negative")]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public async Task GetEnlilAnimationAsync_WhenMaxWidthIsInvalid_ThrowsArgumentOutOfRangeException(int maxWidth)
+ {
+ // Arrange
+ var sut = CreateSut("[]");
+
+ // Act & Assert
+ await Should.ThrowAsync(
+ async () => await sut.GetEnlilAnimationAsync(
+ maxWidth: maxWidth,
+ cancellationToken: TestContext.Current.CancellationToken));
+ }
+
+ [Fact(DisplayName = "GetLastFrameTime returns null when manifest is empty")]
+ public async Task GetLastFrameTimeAsync_WhenManifestIsEmpty_ReturnsNull()
+ {
+ // Arrange
+ var sut = CreateSut("[]");
+
+ // Act
+ var result = await sut.GetLastFrameTimeAsync(
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ result.ShouldBeNull();
+ }
+
+ [Fact(DisplayName = "GetLastFrameTime returns null when manifest is null")]
+ public async Task GetLastFrameTimeAsync_WhenManifestIsNull_ReturnsNull()
+ {
+ // Arrange
+ var sut = CreateSut("null");
+
+ // Act
+ var result = await sut.GetLastFrameTimeAsync(
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ result.ShouldBeNull();
+ }
+
+ [Fact(DisplayName = "GetLastFrameTime extracts timestamp from last frame URL")]
+ public async Task GetLastFrameTimeAsync_ReturnsTimestampFromLastFrameUrl()
+ {
+ // Arrange
+ var manifestJson = """
+ [
+ {"url":"/images/animations/enlil/frame_20250117T120000.jpg"},
+ {"url":"/images/animations/enlil/frame_20250118T060000.jpg"},
+ {"url":"/images/animations/enlil/frame_20250118T120000.jpg"}
+ ]
+ """;
+ var sut = CreateSut(manifestJson);
+
+ // Act
+ var result = await sut.GetLastFrameTimeAsync(
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ result.ShouldBe(new DateTime(2025, 1, 18, 12, 0, 0, DateTimeKind.Utc));
+ }
+
+ [Fact(DisplayName = "GetLastFrameTime returns null when URL has no timestamp")]
+ public async Task GetLastFrameTimeAsync_WhenUrlHasNoTimestamp_ReturnsNull()
+ {
+ // Arrange
+ var manifestJson = """
+ [
+ {"url":"/images/animations/enlil/no_timestamp_here.jpg"}
+ ]
+ """;
+ var sut = CreateSut(manifestJson);
+
+ // Act
+ var result = await sut.GetLastFrameTimeAsync(
+ TestContext.Current.CancellationToken);
+
+ // Assert
+ result.ShouldBeNull();
+ }
+
+ [Fact(DisplayName = "Throws OperationCanceledException when token is pre-cancelled")]
+ public async Task GetEnlilAnimationAsync_WhenTokenIsCancelled_ThrowsOperationCanceledException()
+ {
+ // Arrange
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ var sut = CreateSut("[]");
+
+ // Act & Assert
+ await Should.ThrowAsync(
+ async () => await sut.GetEnlilAnimationAsync(cancellationToken: cts.Token));
+ }
+
+ [Fact(DisplayName = "Throws when token is cancelled mid-download")]
+ public async Task GetEnlilAnimationAsync_WhenCancelledDuringDownload_ThrowsOperationCanceledException()
+ {
+ // Arrange
+ using var cts = new CancellationTokenSource();
+
+ var manifestJson = """
+ [
+ {"url":"/images/animations/enlil/frame1.jpg"},
+ {"url":"/images/animations/enlil/frame2.jpg"},
+ {"url":"/images/animations/enlil/frame3.jpg"}
+ ]
+ """;
+
+ var handler = new CancellingHandler(manifestJson, cancelAfterRequests: 2, cts);
+ using var httpClient = new HttpClient(handler);
+ var options = Options.Create(new NoaaClientOptions { ServerUrl = BaseUrl });
+ var sut = new WsaEnlilClient(httpClient, options);
+
+ // Act & Assert
+ await Should.ThrowAsync(
+ async () => await sut.GetEnlilAnimationAsync(maxWidth: 420, cancellationToken: cts.Token));
+ }
+
+ private static IWsaEnlilClient CreateSut(string manifestJson)
+ {
+ var handler = new TestHttpMessageHandler(CreateJsonResponse(manifestJson));
+ var httpClient = new HttpClient(handler);
+ var options = Options.Create(new NoaaClientOptions { ServerUrl = BaseUrl });
+ return new WsaEnlilClient(httpClient, options);
+ }
+
+ private static HttpResponseMessage CreateJsonResponse(string json)
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
+ };
+ }
+
+ ///
+ /// Simple test double that returns pre-configured responses.
+ ///
+ private sealed class TestHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Queue _responses;
+
+ public TestHttpMessageHandler(params HttpResponseMessage[] responses)
+ {
+ _responses = new Queue(responses);
+ }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(_responses.Count > 0
+ ? _responses.Dequeue()
+ : new HttpResponseMessage(HttpStatusCode.NotFound));
+ }
+ }
+
+ ///
+ /// Handler that cancels the token after a configured number of requests.
+ ///
+ private sealed class CancellingHandler : HttpMessageHandler
+ {
+ private readonly string _manifestJson;
+ private readonly int _cancelAfterRequests;
+ private readonly CancellationTokenSource _cts;
+ private int _requestCount;
+
+ public CancellingHandler(string manifestJson, int cancelAfterRequests, CancellationTokenSource cts)
+ {
+ _manifestJson = manifestJson;
+ _cancelAfterRequests = cancelAfterRequests;
+ _cts = cts;
+ }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var count = Interlocked.Increment(ref _requestCount);
+ if (count > _cancelAfterRequests)
+ {
+ await _cts.CancelAsync();
+ // Give the cancellation a moment to propagate
+ await Task.Delay(50, CancellationToken.None);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ // Simulate network latency so cancellation can be observed
+ await Task.Delay(100, CancellationToken.None);
+
+ return count == 1
+ ? new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(_manifestJson, System.Text.Encoding.UTF8, "application/json")
+ }
+ : new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent("fake-jpeg-data"u8.ToArray())
+ };
+ }
+ }
+}