From 578fdad1213288329f8f495cb2f4769d9da3fd0b Mon Sep 17 00:00:00 2001 From: Frank Ray <52075808+FrankRay78@users.noreply.github.com> Date: Fri, 22 May 2026 18:52:39 +0100 Subject: [PATCH] Add profile CLI switch --- .specify/feature.json | 2 +- README.md | 9 +- USER_GUIDE.md | 38 + .../download-upload-size-controls.md | 75 +- .../2026-05-15-profile-cli-switch.md | 70 ++ .../checklists/requirements.md | 38 + .../contracts/cli-flag.md | 66 + .../contracts/ooklasettings-ctors.md | 67 + .../contracts/profile-enum.md | 85 ++ .../contracts/speedtestservice-surface.md | 63 + specs/003-profile-cli-switch/data-model.md | 181 +++ specs/003-profile-cli-switch/plan.md | 102 ++ specs/003-profile-cli-switch/quickstart.md | 108 ++ specs/003-profile-cli-switch/research.md | 123 ++ specs/003-profile-cli-switch/spec.md | 203 +++ specs/003-profile-cli-switch/tasks.md | 248 ++++ specs/003-profile-cli-switch/test-plan.md | 173 +++ .../CommandLineTestHost.cs | 5 + .../CompositeAnsiConsoleTests.cs | 2 +- ...soleTests.Should_Display_Help.verified.txt | 1 + .../NetPaceConsoleTests.Profile.cs | 202 +++ .../NetPaceConsoleTests.cs | 7 - .../VerifyConfiguration.cs | 5 + .../Commands/SpeedTestCommandSettings.cs | 5 + src/NetPace.Console/CompositeAnsiConsole.cs | 2 +- .../ConsoleWriters/CSVConsoleWriter.cs | 4 +- .../ConsoleWriters/DefaultConsoleWriter.cs | 4 +- .../ConsoleWriters/JsonConsoleWriter.cs | 4 +- .../ConsoleWriters/MinimalConsoleWriter.cs | 4 +- src/NetPace.Console/FileConsole.cs | 2 +- .../OoklaSpeedtestSettingsAccessor.cs | 18 + src/NetPace.Console/Program.cs | 884 ++++++------- src/NetPace.Console/Properties/Usings.cs | 7 +- .../OoklaSpeedtestSettingsTests.Profiles.cs | 130 ++ .../OoklaSpeedtestSettingsTests.cs | 89 ++ .../OoklaSpeedtestTests.Guards.cs | 140 +-- src/NetPace.Core.Tests/OoklaSpeedtestTests.cs | 20 +- src/NetPace.Core.Tests/ProfileTests.cs | 112 ++ src/NetPace.Core.Tests/ProfileXmlDocTests.cs | 38 + .../Clients/Ookla/OoklaSpeedtest.cs | 1104 ++++++++--------- .../Clients/Ookla/OoklaSpeedtestSettings.cs | 44 +- .../Ookla/Settings/DownloadTestSettings.cs | 16 +- .../Ookla/Settings/UploadTestSettings.cs | 14 +- .../Clients/Testing/FaultySpeedTester.cs | 271 ++-- .../Clients/Testing/SpeedTestMock.cs | 294 ++--- .../Clients/Testing/SpeedTestStub.cs | 374 +++--- .../Clients/Testing/VariableSpeedTester.cs | 311 +++-- src/NetPace.Core/ISpeedTestService.cs | 246 ++-- src/NetPace.Core/Profile.cs | 29 + 49 files changed, 4038 insertions(+), 2001 deletions(-) create mode 100644 docs/change-intent-records/2026-05-15-profile-cli-switch.md create mode 100644 specs/003-profile-cli-switch/checklists/requirements.md create mode 100644 specs/003-profile-cli-switch/contracts/cli-flag.md create mode 100644 specs/003-profile-cli-switch/contracts/ooklasettings-ctors.md create mode 100644 specs/003-profile-cli-switch/contracts/profile-enum.md create mode 100644 specs/003-profile-cli-switch/contracts/speedtestservice-surface.md create mode 100644 specs/003-profile-cli-switch/data-model.md create mode 100644 specs/003-profile-cli-switch/plan.md create mode 100644 specs/003-profile-cli-switch/quickstart.md create mode 100644 specs/003-profile-cli-switch/research.md create mode 100644 specs/003-profile-cli-switch/spec.md create mode 100644 specs/003-profile-cli-switch/tasks.md create mode 100644 specs/003-profile-cli-switch/test-plan.md create mode 100644 src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs create mode 100644 src/NetPace.Console/OoklaSpeedtestSettingsAccessor.cs create mode 100644 src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs create mode 100644 src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs create mode 100644 src/NetPace.Core.Tests/ProfileTests.cs create mode 100644 src/NetPace.Core.Tests/ProfileXmlDocTests.cs create mode 100644 src/NetPace.Core/Profile.cs diff --git a/.specify/feature.json b/.specify/feature.json index bd80b98d..be7c91ac 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/002-win-aot-release" + "feature_directory": "specs/003-profile-cli-switch" } diff --git a/README.md b/README.md index 71c0e1ce..81f189b5 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,13 @@ For most users, running a simple speed test is as easy as: NetPace ``` -For common scenarios and advanced usage (such as scripting, restricting payload size, or customising output), see the [User Guide](https://github.com/FrankRay78/NetPace/blob/main/USER_GUIDE.md). +On a metered or IoT connection? Use the Tiny profile (≤ 1 MiB total per run): + +```bash +NetPace --profile tiny +``` + +For common scenarios and advanced usage (such as scripting, restricting payload size, choosing a traffic profile, or customising output), see the [User Guide](https://github.com/FrankRay78/NetPace/blob/main/USER_GUIDE.md). `NetPace --help` will display detailed usage instructions. @@ -109,6 +115,7 @@ OPTIONS: 'NetPace servers -l' will return your nearest servers. -t, --timestamp Include a timestamp in the output. --datetimeformat yyyy-MM-dd HH:mm:ss The datetime format string, as defined by Microsoft.Net. + --profile Medium Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega). --downloadsize Stop the download test after this many megabytes (IEC MiB). --uploadsize Stop the upload test after this many megabytes (IEC MiB). -u, --unit BitsPerSecond The speed unit. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f632ee62..a4e43ea3 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -62,6 +62,44 @@ NetPace --downloadsize 50 --uploadsize 20 --- +## Choosing a profile + +The `--profile` flag bundles per-request payload sizes, parallelism, and a total-byte +cap into one switch. Pick the profile that matches your link, and NetPace adapts the +traffic shape to suit it. `Medium` is the default. + +| Profile | Use it when… | Total per run (down + up, approx) | +|---|---|---| +| `Tiny` | IoT / 10 MB-month plans — minimal traffic, single small request | ≤ 1 MiB (~245 KB + ~50 KB) | +| `Small` | Cellular / metered — small budget, modest parallelism | ≤ 12 MiB (~10 MiB + ~2 MiB) | +| `Medium` | Typical home broadband — the default | ≤ 125 MiB (~100 MiB + ~25 MiB) | +| `Large` | Fibre / business — saturates gigabit links | ≤ 1.25 GiB (~1 GiB + ~256 MiB) | +| `Mega` | Inter-DC / 10 Gbps — saturates fibre, see warning below | ≤ 12 GiB (~10 GiB + ~2 GiB) | + +Decision guide: + +- Cellular or metered IoT? → `--profile small` (or `tiny` for the most miserly plans). +- Most users / home broadband? → no flag needed (Medium default). +- Gigabit fibre or business link? → `--profile large`. +- 10 Gbps inter-DC saturation? → `--profile mega`. + +You can still pin a hard cap on top of a profile — the profile sets per-request shape, +`--downloadsize` / `--uploadsize` override only the total cap: + +```bash +NetPace --profile large --downloadsize 200 +``` + +> [!WARNING]\ +> **`--profile mega` uses undocumented OoklaServer payloads** (`5000`, `6000`, `7000`) +> which are not part of the historic Speedtest.net Flash-client array. The selected +> server may not host them; future OoklaServer releases may break this profile. If +> Mega returns short reads or errors, fall back to `--profile large`. See +> [docs/architecture/download-upload-size-controls.md](https://github.com/FrankRay78/NetPace/blob/main/docs/architecture/download-upload-size-controls.md) +> for the per-request payload tables and the fallback strategy. + +--- + For more options and details, run: ```bash NetPace --help diff --git a/docs/architecture/download-upload-size-controls.md b/docs/architecture/download-upload-size-controls.md index b6db8c8d..657b6478 100644 --- a/docs/architecture/download-upload-size-controls.md +++ b/docs/architecture/download-upload-size-controls.md @@ -25,26 +25,32 @@ Source: [`OoklaSpeedtestSettings.cs`](../../src/NetPace.Core/Clients/Ookla/Ookla Source: [`Settings/DownloadTestSettings.cs`](../../src/NetPace.Core/Clients/Ookla/Settings/DownloadTestSettings.cs). -| Property | Default | Meaning | +Defaults below are the values supplied by `new OoklaSpeedtestSettings()` (which chains to `Profile.Medium`). A bare `new DownloadTestSettings()` uses the type's field initializers (`DownloadSizeIterations = 4`, `DownloadParallelTasks = 8`, `DownloadSizeMb = int.MaxValue`); see §5 for per-profile values. + +| Property | Default (Medium profile) | Meaning | | ------------------------ | -------------------------------- | -------------------------------------------------------------------------------------- | | `DownloadSizes` | `[1500, 2000, 3000, 3500, 4000]` | Pixel sizes used to build URLs of the form `random{N}x{N}.jpg`. Bigger N → bigger file. The default is the larger half of the historic ten-element Ookla Flash-client array (see §2.1). | -| `DownloadSizeIterations` | `4` | How many times each size is requested (URL gets a `?r={i}` cache-buster). | -| `DownloadParallelTasks` | `8` | Concurrent HTTP GETs. | +| `DownloadSizeIterations` | `2` | How many times each size is requested (URL gets a `?r={i}` cache-buster). | +| `DownloadParallelTasks` | `4` | Concurrent HTTP GETs. | +| `DownloadSizeMb` | `100` | Total-byte budget cap in IEC MiB. The download loop terminates once cumulative bytes received reach this threshold. Default sentinel for a bare record is `int.MaxValue` (no cap). | -Total candidate requests = `DownloadSizes.Length × DownloadSizeIterations` (default = 5 × 4 = **20**). +Total candidate requests = `DownloadSizes.Length × DownloadSizeIterations` (Medium default = 5 × 2 = **10**). ### 1.2 `UploadTestSettings` Source: [`Settings/UploadTestSettings.cs`](../../src/NetPace.Core/Clients/Ookla/Settings/UploadTestSettings.cs). -| Property | Default | Meaning | -| ----------------------- | ------- | ------------------------------------------------------------------------------------ | -| `UploadSizeIncrementKb` | `200` | Step size between successive increments, in KB (binary, ×1024). | -| `UploadIncrements` | `6` | Number of increments (200 KB, 400 KB, 600 KB, 800 KB, 1 MB, 1.2 MB). | -| `UploadSizeIterations` | `10` | How many times each increment is repeated. | -| `UploadParallelTasks` | `8` | Concurrent HTTP POSTs. | +Defaults below are the values supplied by `new OoklaSpeedtestSettings()` (Medium profile). A bare `new UploadTestSettings()` uses the type's field initializers (`UploadSizeIterations = 10`, `UploadParallelTasks = 8`, `UploadSizeMb = int.MaxValue`); see §5 for per-profile values. + +| Property | Default (Medium profile) | Meaning | +| ----------------------- | ------------------------ | ------------------------------------------------------------------------------------ | +| `UploadSizeIncrementKb` | `200` | Step size between successive increments, in KB (binary, ×1024). | +| `UploadIncrements` | `6` | Number of increments (200 KB, 400 KB, 600 KB, 800 KB, 1 MB, 1.2 MB). | +| `UploadSizeIterations` | `5` | How many times each increment is repeated. | +| `UploadParallelTasks` | `4` | Concurrent HTTP POSTs. | +| `UploadSizeMb` | `25` | Total-byte budget cap in IEC MiB. Default sentinel for a bare record is `int.MaxValue` (no cap). | -Total candidate requests = `UploadIncrements × UploadSizeIterations` (default = 6 × 10 = **60**). +Total candidate requests = `UploadIncrements × UploadSizeIterations` (Medium default = 6 × 5 = **30**). ## 2. How the settings shape network behaviour @@ -175,16 +181,19 @@ Unlike download, this total is fully deterministic — NetPace generates the pay ## 3. CLI surface -[`Program.cs`](../../src/NetPace.Console/Program.cs) exposes only the two budget caps, wired into [`SpeedTestCommandSettings`](../../src/NetPace.Console/Commands/SpeedTestCommandSettings.cs): +[`Program.cs`](../../src/NetPace.Console/Program.cs) exposes one profile selector and two budget-cap overrides, wired into [`SpeedTestCommandSettings`](../../src/NetPace.Console/Commands/SpeedTestCommandSettings.cs): -| Switch | Property | Default | Effect | -| ---------------- | ---------------- | -------------- | ------------------------------------------------------------------------------------- | -| `--downloadsize` | `DownloadSizeMb` | `int.MaxValue` | Stops the download test after N MiB total. Does **not** change per-request file size. | -| `--uploadsize` | `UploadSizeMb` | `int.MaxValue` | Stops the upload test after N MiB total. Does **not** change per-request payload. | +| Switch | Property | Default | Effect | +| ---------------- | ---------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `--profile` | `Profile` | `Medium` | Sets per-request shape (`DownloadSizes`, iterations, parallel tasks) and total-byte cap defaults via `new OoklaSpeedtestSettings(p)`. | +| `--downloadsize` | `DownloadSizeMb` | (profile-supplied) | Overrides the download cap via a `with`-expression. Does **not** change per-request file size — the profile remains authoritative for shape. | +| `--uploadsize` | `UploadSizeMb` | (profile-supplied) | Overrides the upload cap via a `with`-expression. Does **not** change per-request payload — the profile remains authoritative for shape. | -> **Important distinction:** these are **total-byte budget caps**, not per-request size controls. The per-request sizes (`DownloadSizes`, `UploadSizeIncrementKb`, etc.) are hard-wired to the defaults above and are reachable only via the `NetPace.Core` library API, not the CLI. +> **Important distinction:** `--downloadsize` / `--uploadsize` are **total-byte budget caps**, not per-request size controls. The per-request sizes (`DownloadSizes`, `UploadSizeIncrementKb`, etc.) are set by the chosen profile and are otherwise reachable only via the `NetPace.Core` library API, not the CLI. > -> Both defaults are `int.MaxValue` MiB, which is far larger than any iteration would ever transfer — so the cap is **inactive by default**. With defaults, NetPace will transfer the full totals computed in §2.1 and §2.2 (≈ 328 MiB down, ≈ 41 MiB up). +> When `--downloadsize` / `--uploadsize` are omitted, the profile-supplied caps apply (e.g. Medium = 100 MiB down + 25 MiB up). To see all five profile shapes at once, see §5. + +The library-level cap-removal default — a fresh `new DownloadTestSettings()` literal (no profile) — is `int.MaxValue` MiB, which is far larger than any iteration would ever transfer, so the cap is **inactive** in that bare-record case. ## 4. Local verification @@ -207,3 +216,33 @@ curl -sS -o /dev/null -w '%{size_download} bytes in %{time_total}s\n' \ head -c 1048576 /dev/urandom | curl -sS -o /dev/null -w '%{http_code}\n' \ --data-binary @- http://localhost:18080/speedtest/upload.php ``` + +## 5. Profile-driven defaults + +[`Profile`](../../src/NetPace.Core/Profile.cs) is the public, provider-agnostic vocabulary surfaced via `--profile`. [`OoklaSpeedtestSettings(Profile)`](../../src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs) maps each profile to a complete `DownloadTestSettings` / `UploadTestSettings` pair via an inline switch — the single source of truth for "what does Tiny mean, in Ookla terms?". + +### 5.1 Download (per profile) + +| Profile | `DownloadSizes` | Iterations | Parallel | `DownloadSizeMb` cap | +| -------- | ----------------------------------- | ---------- | -------- | -------------------- | +| `Tiny` | `[350]` | 1 | 1 | 1 | +| `Small` | `[1000, 1500]` | 2 | 2 | 10 | +| `Medium` | `[1500, 2000, 3000, 3500, 4000]` | 2 | 4 | 100 | +| `Large` | `[2000, 2500, 3000, 3500, 4000]` | 12 | 16 | 1024 | +| `Mega` | `[3000, 4000, 5000, 6000, 7000]` | 40 | 32 | 10240 | + +### 5.2 Upload (per profile) + +| Profile | `UploadSizeIncrementKb` | `UploadIncrements` | Iterations | Parallel | `UploadSizeMb` cap | +| -------- | ----------------------- | ------------------ | ---------- | -------- | ------------------ | +| `Tiny` | 50 | 1 | 1 | 1 | 1 | +| `Small` | 100 | 4 | 2 | 2 | 2 | +| `Medium` | 200 | 6 | 5 | 4 | 25 | +| `Large` | 500 | 8 | 12 | 16 | 256 | +| `Mega` | 1024 | 16 | 16 | 32 | 2048 | + +### 5.3 Mega is the only profile relying on the bonus payloads + +The `5000`, `6000`, `7000` pixel-size payloads identified in §2.1 are **only used by `Mega`**. The other four profiles stay within the historic Flash-client `random{N}x{N}.jpg` array, so they are guaranteed to work against any OoklaServer that ships those URLs (every server we've probed — see §2.1 Cross-server validation). + +If a future OoklaServer release drops the bonus payloads, Mega will see 404s on those URLs and fall back to whatever the surviving subset returns. The current Mega arm is tuned for the bonus payloads being present; the *documented fallback strategy* — revert Mega to the historic-10 array with higher iteration counts to keep total transfer in the ~10 GiB band — is tracked but **not implemented in the 003-profile-cli-switch change**. Users who hit Mega-specific 404s should switch to `--profile large` until the fallback lands. diff --git a/docs/change-intent-records/2026-05-15-profile-cli-switch.md b/docs/change-intent-records/2026-05-15-profile-cli-switch.md new file mode 100644 index 00000000..dfcd965b --- /dev/null +++ b/docs/change-intent-records/2026-05-15-profile-cli-switch.md @@ -0,0 +1,70 @@ +# `--profile` CLI Switch and `Profile` Enum (Public API) + +**Intent:** Introduce a public, provider-agnostic `Profile` enum (`Tiny`, `Small`, `Medium`, `Large`, `Mega`) on `NetPace.Core`, surfaced as a single `--profile` CLI switch and as two new public constructors on `OoklaSpeedtestSettings`. The profile bundles per-request shape (`DownloadSizes`, iterations, parallel tasks) and a total-byte cap into one knob — so `--profile tiny` keeps a constrained-plan user within their data budget without further tuning. Shift the default-run traffic from ~370 MiB to ~125 MiB (Medium = the new default), a ≥ 65 % reduction. Accept a pre-1.0 breaking change to `ISpeedTestService`: delete the four `int sizeMb` per-call overloads; the cap now lives on `DownloadTestSettings.DownloadSizeMb` / `UploadTestSettings.UploadSizeMb`. + +**Behaviour:** +- Given: a user runs `netpace --profile tiny` +- When: option binding completes +- Then: the constructed `OoklaSpeedtestSettings.DownloadTest.DownloadSizes` is exactly `[350]`, `DownloadSizeIterations == 1`, `DownloadParallelTasks == 1`, `DownloadSizeMb == 1`; the upload counterparts are `UploadSizeIncrementKb == 50`, `UploadIncrements == 1`, `UploadSizeIterations == 1`, `UploadParallelTasks == 1`, `UploadSizeMb == 1` (≤ 1 MiB total per run). +- Given: a user runs `netpace` with no flags +- When: option binding completes +- Then: the constructed `OoklaSpeedtestSettings` is field-for-field equal to `new OoklaSpeedtestSettings(Profile.Medium)` (the parameterless constructor chains to `Profile.Medium` as the single source of truth); the Medium cap is 100 MiB down + 25 MiB up, ≥ 65 % below the prior ~370 MiB baseline. +- Given: a user runs `netpace --profile tiny --downloadsize 5` +- When: option binding completes +- Then: Tiny's per-request shape is preserved (`DownloadSizes == [350]`, iterations and parallel tasks unchanged) and only `DownloadSizeMb` is overridden to `5` via a `with`-expression. `--no-download` / `--no-upload` continue to short-circuit phases independently of profile. +- Given: a NuGet consumer constructs `new OoklaSpeedtestSettings((Profile)999)` +- When: the constructor body runs +- Then: an `ArgumentOutOfRangeException` is thrown with `ParamName == "profile"`. +- Given: a NuGet consumer reflects on `typeof(NetPace.Core.Profile)` +- When: the inspection runs +- Then: `Namespace == "NetPace.Core"` (top-level, not under `Clients/*`); the `NetPace.Core` assembly contains no static method that takes `Profile` as its first parameter and returns a type under `NetPace.Core.Clients.*`; no `OoklaSpeedtestSettingsExtensions` or `OoklaProfileExtensions` type exists; the source file lives at `src/NetPace.Core/Profile.cs`. +- Given: the published `NetPace.Core.xml` documentation +- When: it is parsed for the `F:NetPace.Core.Profile.Mega` member +- Then: the summary contains `undocumented` (case-insensitive), `5000`, `6000`, `7000`, and a reference to `download-upload-size-controls` (the architecture doc), so consumers see the bonus-payload caveat in IntelliSense. + +**Constraints:** +- `Profile` lives at the root of `NetPace.Core` (`src/NetPace.Core/Profile.cs`), not under `Clients/Ookla/`. It carries no extension methods or helper types that reach into provider-specific namespaces. The dependency direction is one-way: `OoklaSpeedtestSettings(Profile)` knows `Profile`; `Profile` knows no provider. +- The entire profile → Ookla mapping is one inline `switch` expression in the new `OoklaSpeedtestSettings(Profile profile)` constructor. No `OoklaSpeedtestSettingsExtensions`, no `OoklaProfileExtensions`, no per-profile factory methods — one source of truth. +- The four `int sizeMb` overloads on `ISpeedTestService` (and the matching ones on `OoklaSpeedtest`) are **deleted**, not deprecated. NetPace is pre-1.0; this is an accepted breaking change to the public NuGet contract and is flagged in the PR title so auto-generated release notes pick it up. +- `DownloadSizeMb` and `UploadSizeMb` move from method parameters into `DownloadTestSettings` / `UploadTestSettings` (with sentinel default `int.MaxValue` for the bare-record case). `OoklaSpeedtest` reads the cap from `settings.DownloadTest.DownloadSizeMb` / `settings.UploadTest.UploadSizeMb` at run-time. +- `dotnet build src/NetPace.sln` must succeed with zero warnings; all tests must remain green. AOT-trim safety is preserved — the constructor switch is pure value-dispatch, no reflection. +- Every public addition carries XML documentation. `Profile.Mega`'s doc explicitly contains the word "undocumented", names the bonus payloads `5000`/`6000`/`7000`, and cross-references `docs/architecture/download-upload-size-controls.md` (enforced by `ProfileXmlDocTests.Profile_Mega_XmlDoc_DocumentsBonusPayloadDependency`). + +**Decisions:** + +1. **`Profile` at top level of `NetPace.Core`, not under `Clients/Ookla/`.** + - Rejected: place under `Clients/Ookla/Profile.cs` — couples a provider-agnostic vocabulary to one provider; blocks any second provider from translating the same labels into its own settings record. + - Rejected: separate `NetPace.Core.Profiles` namespace — over-namespaces for one type and breaks the "sibling-of-`SpeedUnit`" pattern already established for top-level public enums. + - Chose: top-level. File location is itself a grep-able invariant (verified by a reflection + file-existence test). + +2. **Two new constructors on `OoklaSpeedtestSettings` (parameterless + `Profile`-taking) — not an extension method and not a factory.** + - Rejected: `OoklaSpeedtestSettings.For(Profile)` static factory — introduces a second equally-valid construction path and risks drift if one is updated and the other isn't. + - Rejected: `Profile.ToOoklaSettings()` extension method — couples `Profile` to provider types (violates the placement invariant). + - Rejected: parameterless ctor with separate `Profile` property + per-property defaults — splits the mapping across N field initializers and a profile field; users could then construct an inconsistent record (e.g. `Profile = Mega` with Tiny field values). + - Chose: two ctors, parameterless chains to `Profile.Medium`. One inline switch expression is the single source of truth. `Profile` is consumed by the ctor and not stored on the record (reflection-verified by `OoklaSpeedtestSettings_HasNoProfileProperty`). + +3. **Delete the four `int sizeMb` overloads on `ISpeedTestService` rather than keeping them as deprecated wrappers.** + - Rejected: keep + `[Obsolete]` — adds two construction paths for the same intent and grows the public surface; pre-1.0 status makes the break acceptable. + - Rejected: keep as overloads that build a transient `OoklaSpeedtestSettings with { … }` per call — works mechanically but encourages per-call settings mutation against a long-lived service instance, which is exactly what this change is moving away from. + - Chose: delete. Cap variation is now "configure once, build a new `OoklaSpeedtest` instance", matching the existing pattern for proxy/latency/server-discovery settings. + +4. **Caps live on the per-phase records (`DownloadTestSettings.DownloadSizeMb`, `UploadTestSettings.UploadSizeMb`), not on `OoklaSpeedtestSettings` directly.** + - Rejected: top-level `DownloadSizeMb`/`UploadSizeMb` on `OoklaSpeedtestSettings` — splits the "everything about a download phase" surface across two record types; `with`-expression composition becomes awkward (`settings with { DownloadSizeMb = N }` is fine but doesn't co-locate with `DownloadSizes`). + - Chose: per-phase. `settings.DownloadTest with { DownloadSizeMb = N }` cleanly groups all download knobs together; `--downloadsize` overrides one field via `with` while leaving the profile-supplied `DownloadSizes` / iterations / parallel intact. + +5. **`Mega` ships against the bonus payloads (`5000`/`6000`/`7000`) without a runtime fallback.** + - Rejected: detect-404-and-fall-back-to-historic-10 at runtime — adds a probing round-trip + state machine to every Mega run; the bonus payloads are universal across the nine UK OoklaServer operators we probed (see `docs/architecture/download-upload-size-controls.md` Cross-server validation). + - Rejected: probe-on-first-use and cache — pushes runtime complexity into the per-server discovery flow. + - Chose: ship as-is, document the dependency explicitly on `Profile.Mega`'s XML doc, recommend users hit `--profile large` if Mega 404s. A future fallback (re-tune Mega to the historic-10 array with higher iterations) is tracked but out of scope for this change. + +6. **CLI option uses `System.CommandLine`'s built-in case-insensitive enum binding; no custom parser; no alias.** + - Rejected: add `-p` short alias — single-letter aliases are precious; reserve for top-tier options. The full `--profile` name is short enough. + - Rejected: custom enum-parser with friendlier error text — `System.CommandLine`'s built-in unknown-value message already names the option and lists valid values. + - Chose: built-in binding, default `Medium`, no alias. Matches the pattern already used by `--unit-system`, `--unit-scale`, `--verbosity`. + +7. **Test seam: an `OoklaSpeedtestSettingsAccessor` singleton holds the built settings, written by the action before `ISpeedTestService` is resolved.** + - Rejected: pass settings via `SpeedTestCommandSettings.OoklaSettings` and have the writers read it — couples the writers to Ookla-specific settings; they'd be unable to work against a different provider. + - Rejected: change `OoklaSpeedtest` registration to a `Func` factory invoked from the action — production code paths complicate when the action also has to decide test-vs-production wiring. + - Chose: a small `OoklaSpeedtestSettingsAccessor` (mutable holder) registered as singleton; production resolves `OoklaSpeedtest` via factory reading `accessor.Settings`; tests register an accessor instance and inspect `accessor.Settings` after `RunAsync`. + +**Date:** 2026-05-15 diff --git a/specs/003-profile-cli-switch/checklists/requirements.md b/specs/003-profile-cli-switch/checklists/requirements.md new file mode 100644 index 00000000..91bfdb87 --- /dev/null +++ b/specs/003-profile-cli-switch/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Add `--profile` CLI switch (Tiny/Small/Medium/Large/Mega) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-15 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +This spec deliberately retains some technical surface (`OoklaSpeedtestSettings`, `ISpeedTestService`, `DownloadTest.DownloadSizeMb`, etc.) because the source issue (#174) is itself a public-API design proposal — those identifiers are part of the user-observable contract for NetPace.Core's NuGet consumers, not internal implementation detail. The "Content Quality / No implementation details" check is interpreted in that light: the public API is the user surface here. The CLI surface and end-user outcomes (SC-001..SC-007) remain technology-agnostic. + +All confirmed decisions from the GitHub issue body's "Confirmed decisions" section are folded into either FR-XXX requirements or the Assumptions section. + +Items marked incomplete would require spec updates before `/speckit.clarify` or `/speckit.plan`. All items currently pass. diff --git a/specs/003-profile-cli-switch/contracts/cli-flag.md b/specs/003-profile-cli-switch/contracts/cli-flag.md new file mode 100644 index 00000000..ae0f9a2f --- /dev/null +++ b/specs/003-profile-cli-switch/contracts/cli-flag.md @@ -0,0 +1,66 @@ +# Contract — `--profile` CLI flag + +**Binding library**: `System.CommandLine` +**Surface owner**: `src/NetPace.Console/Program.cs` +**Settings target**: `src/NetPace.Console/Commands/SpeedTestCommandSettings.cs` + +## Option declaration + +```csharp +var profileOption = new Option("--profile") +{ + Description = "Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega).", + DefaultValueFactory = _ => Profile.Medium +}; +``` + +- No alias (no `-p`, no `--profile-name`). +- No custom parser — rely on `System.CommandLine`'s built-in case-insensitive enum binding. +- Default value source: `DefaultValueFactory` set to `Profile.Medium` (kept aligned with `OoklaSpeedtestSettings()` parameterless ctor's chain to `Profile.Medium`). + +## Wiring + +1. Add to the root command alongside `--unit-system`, `--unit-scale`, etc. +2. Bind onto `SpeedTestCommandSettings.Profile` (new `public Profile Profile { get; init; } = Profile.Medium;`). +3. `Program.RunAsync` constructs the settings record as: + +```csharp +var settings = new OoklaSpeedtestSettings(commandSettings.Profile); + +if (commandSettings.DownloadSizeMb is int dl) + settings = settings with { DownloadTest = settings.DownloadTest with { DownloadSizeMb = dl } }; + +if (commandSettings.UploadSizeMb is int ul) + settings = settings with { UploadTest = settings.UploadTest with { UploadSizeMb = ul } }; + +// proxy fields layered on top via the existing pattern… +``` + +## Behavioural contracts + +| ID | Behaviour | +|---|---| +| **C-CLI-1** | `netpace` (no flags) ⇒ `settings.Equals(new OoklaSpeedtestSettings(Profile.Medium))`. | +| **C-CLI-2** | `netpace --profile tiny` / `--profile Tiny` / `--profile TINY` all parse to `Profile.Tiny` (case-insensitive). | +| **C-CLI-3** | `netpace --profile huge` exits non-zero with `System.CommandLine`'s default unknown-enum-value error message. | +| **C-CLI-4** | `netpace --profile tiny --downloadsize 5` ⇒ `settings.DownloadTest` has Tiny's `DownloadSizes`/iterations/parallel **and** `DownloadSizeMb == 5`. Profile remains authoritative for per-request shape; only the cap is overridden. | +| **C-CLI-5** | `netpace --profile small --uploadsize 1` ⇒ `settings.UploadTest` has Small's upload-increment fields **and** `UploadSizeMb == 1`. | +| **C-CLI-6** | `netpace --no-download --profile large` short-circuits the download phase regardless of profile (existing `--no-download` semantics unchanged). | +| **C-CLI-7** | `netpace --downloadsize 50` (no `--profile`) ⇒ Medium's per-request shape + `DownloadSizeMb == 50`. | +| **C-CLI-8** | `netpace --help` shows the `--profile` option with the `Description` text above, lists all five enum values, and shows `Medium` as the default. (Verified via VerifyXunit snapshot under `NetPace.Console.Tests/Expectations`.) | + +## Help-output snapshot expectations + +The `--help` snapshot (Verify) must include a line containing: + +``` +--profile Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega). [default: Medium] +``` + +Exact rendering depends on `System.CommandLine`'s default help formatter — accept whatever it produces, but the snapshot must update in lock-step with this change. + +## Out of scope + +- Custom error message for unknown values. +- Localised descriptions. +- Per-profile sub-help (e.g. `netpace --profile mega --help` showing Mega's table). diff --git a/specs/003-profile-cli-switch/contracts/ooklasettings-ctors.md b/specs/003-profile-cli-switch/contracts/ooklasettings-ctors.md new file mode 100644 index 00000000..fa611221 --- /dev/null +++ b/specs/003-profile-cli-switch/contracts/ooklasettings-ctors.md @@ -0,0 +1,67 @@ +# Contract — `OoklaSpeedtestSettings` constructors + +**Namespace**: `NetPace.Core.Clients.Ookla` +**File**: `src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs` +**Stability**: pre-1.0; both constructors are part of the public NuGet contract. + +## Declaration + +```csharp +public sealed record OoklaSpeedtestSettings +{ + public ServerDiscoverySettings ServerDiscovery { get; init; } = new(); + public LatencyTestSettings LatencyTest { get; init; } = new(); + public DownloadTestSettings DownloadTest { get; init; } + public UploadTestSettings UploadTest { get; init; } + public NetworkCredential? ProxyCredential { get; init; } + public Uri? ProxyAddress { get; init; } + public bool UseProxy { get; init; } + + /// Builds settings for the default profile (). + public OoklaSpeedtestSettings() : this(Profile.Medium) { } + + /// Builds settings populated for the given profile. + /// The traffic-load profile to materialise. + /// Thrown when is not a defined value. + public OoklaSpeedtestSettings(Profile profile) + { + (DownloadTest, UploadTest) = profile switch + { + Profile.Tiny => ( + new DownloadTestSettings { DownloadSizes = new[] { 350 }, DownloadSizeIterations = 1, DownloadParallelTasks = 1, DownloadSizeMb = 1 }, + new UploadTestSettings { UploadSizeIncrementKb = 50, UploadIncrements = 1, UploadSizeIterations = 1, UploadParallelTasks = 1, UploadSizeMb = 1 }), + + Profile.Small => ( + new DownloadTestSettings { DownloadSizes = new[] { 1000, 1500 }, DownloadSizeIterations = 2, DownloadParallelTasks = 2, DownloadSizeMb = 10 }, + new UploadTestSettings { UploadSizeIncrementKb = 100, UploadIncrements = 4, UploadSizeIterations = 2, UploadParallelTasks = 2, UploadSizeMb = 2 }), + + Profile.Medium => ( + new DownloadTestSettings { DownloadSizes = new[] { 1500, 2000, 3000, 3500, 4000 }, DownloadSizeIterations = 2, DownloadParallelTasks = 4, DownloadSizeMb = 100 }, + new UploadTestSettings { UploadSizeIncrementKb = 200, UploadIncrements = 6, UploadSizeIterations = 5, UploadParallelTasks = 4, UploadSizeMb = 25 }), + + Profile.Large => ( + new DownloadTestSettings { DownloadSizes = new[] { 2000, 2500, 3000, 3500, 4000 }, DownloadSizeIterations = 12, DownloadParallelTasks = 16, DownloadSizeMb = 1024 }, + new UploadTestSettings { UploadSizeIncrementKb = 500, UploadIncrements = 8, UploadSizeIterations = 12, UploadParallelTasks = 16, UploadSizeMb = 256 }), + + Profile.Mega => ( + new DownloadTestSettings { DownloadSizes = new[] { 3000, 4000, 5000, 6000, 7000 }, DownloadSizeIterations = 40, DownloadParallelTasks = 32, DownloadSizeMb = 10240 }, + new UploadTestSettings { UploadSizeIncrementKb = 1024, UploadIncrements = 16, UploadSizeIterations = 16, UploadParallelTasks = 32, UploadSizeMb = 2048 }), + + _ => throw new ArgumentOutOfRangeException(nameof(profile)), + }; + } +} +``` + +## Behavioural contracts + +| ID | Behaviour | +|---|---| +| **C-OS-1** | `new OoklaSpeedtestSettings()` is field-for-field equal (record equality) to `new OoklaSpeedtestSettings(Profile.Medium)`. | +| **C-OS-2** | `new OoklaSpeedtestSettings(profile).DownloadTest` and `.UploadTest` contain exactly the values listed in the table above, for every defined `Profile`. | +| **C-OS-3** | `new OoklaSpeedtestSettings((Profile)999)` throws `ArgumentOutOfRangeException` with `ParamName == "profile"`. | +| **C-OS-4** | `new OoklaSpeedtestSettings(Profile.Mega).DownloadTest.DownloadSizes` includes `5000`, `6000`, and `7000` (regression guard). | +| **C-OS-5** | `with`-expressions compose normally: `new OoklaSpeedtestSettings(Profile.Tiny) with { UseProxy = true }` produces a record with Tiny's `DownloadTest`/`UploadTest` and `UseProxy == true`. | +| **C-OS-6** | `with`-expressions on per-phase settings compose: `var s = new OoklaSpeedtestSettings(Profile.Tiny); s = s with { DownloadTest = s.DownloadTest with { DownloadSizeMb = 5 } };` preserves all other `DownloadTest` fields from Tiny and changes only `DownloadSizeMb`. | +| **C-OS-7** | `OoklaSpeedtestSettings` instance state has no `Profile` property — verified by reflection test. | +| **C-OS-8** | The codebase contains no `OoklaSpeedtestSettingsExtensions` or `OoklaProfileExtensions` class — verified by grep test (FR-006). | diff --git a/specs/003-profile-cli-switch/contracts/profile-enum.md b/specs/003-profile-cli-switch/contracts/profile-enum.md new file mode 100644 index 00000000..4ccfe263 --- /dev/null +++ b/specs/003-profile-cli-switch/contracts/profile-enum.md @@ -0,0 +1,85 @@ +# Contract — `Profile` enum (public, NuGet-exposed) + +**Namespace**: `NetPace.Core` +**File**: `src/NetPace.Core/Profile.cs` +**Stability**: pre-1.0 — additive growth only after this introduction. + +## Declaration + +```csharp +namespace NetPace.Core; + +/// +/// Provider-agnostic vocabulary describing the intent of a speed-test run — +/// how much traffic to generate and how aggressively. Each provider's settings +/// record translates these labels into provider-specific values. +/// +public enum Profile +{ + /// IoT / 10 MB-month plans (≤ ~245 KB down + ~50 KB up per run). + Tiny, + + /// Cellular / metered (≤ ~10 MiB down + ~2 MiB up per run). + Small, + + /// Typical home broadband. Default profile (≤ ~100 MiB down + ~21 MiB up per run). + Medium, + + /// Fibre / business (≤ ~1 GiB down + ~211 MiB up per run). + Large, + + /// + /// Inter-DC / 10 Gbps saturation (≤ ~10 GiB down + ~2 GiB up per run). + /// Uses undocumented OoklaServer payloads (5000/6000/7000) which are not part + /// of the historic Speedtest.net Flash-client array. May break on future + /// OoklaServer releases — see docs/architecture/download-upload-size-controls.md. + /// + Mega +} +``` + +## Invariants + +| Invariant | Enforcement | +|---|---| +| Member names are exactly `Tiny`, `Small`, `Medium`, `Large`, `Mega`. | Compile-time. | +| Ordinals are `0..4` (ascending traffic load). | Default-int backing; no explicit values assigned. | +| `Profile` declares no extension methods that reference any provider type. | Grep test under `NetPace.Core.Tests` (FR-002): assert no file containing `static.*Profile` in scope references the `Clients/Ookla/` namespace. | +| File location is `src/NetPace.Core/Profile.cs`, NOT under `Clients/`. | Structural test (FR-001): assert file path exists at that location. | +| Every member carries XML documentation. | Build with `TreatWarningsAsErrors=true` and CS1591 enabled; missing-doc warning fails the build. | + +## Contract tests *(NetPace.Core.Tests/ProfileTests.cs)* + +```csharp +// Structural — top-level placement +[Fact] +public void Profile_IsLocatedAtTopLevelOfNetPaceCore_NotUnderClients() +{ + // Reflection on the assembly to find the type, then assert its declaring + // assembly's source-file path (via [CallerFilePath] or by string assertion + // on the type's namespace). + typeof(NetPace.Core.Profile).Namespace.Should().Be("NetPace.Core"); +} + +// Structural — no provider coupling +[Fact] +public void Profile_HasNoExtensionMethodReturningProviderType() +{ + // Reflect over the loaded NetPace.Core assembly; find any static method + // whose first parameter is Profile and whose return type lives under + // NetPace.Core.Clients.* — assert there are none. +} + +// Membership +[Theory] +[InlineData(Profile.Tiny), InlineData(Profile.Small), InlineData(Profile.Medium), + InlineData(Profile.Large), InlineData(Profile.Mega)] +public void Profile_AllExpectedMembers_AreDefined(Profile p) => + Enum.IsDefined(typeof(Profile), p).Should().BeTrue(); +``` + +## Out of scope for this contract + +- Per-provider mapping (lives on `OoklaSpeedtestSettings(Profile)` — see `ooklasettings-ctors.md`). +- Display strings for `--help` (the CLI Option's `Description` covers this). +- Localisation. diff --git a/specs/003-profile-cli-switch/contracts/speedtestservice-surface.md b/specs/003-profile-cli-switch/contracts/speedtestservice-surface.md new file mode 100644 index 00000000..8259b38f --- /dev/null +++ b/specs/003-profile-cli-switch/contracts/speedtestservice-surface.md @@ -0,0 +1,63 @@ +# Contract — `ISpeedTestService` surface change + +**Namespace**: `NetPace.Core` +**File**: `src/NetPace.Core/ISpeedTestService.cs` +**Stability**: pre-1.0 — **breaking change** to the public NuGet contract. Flagged in PR title so GitHub auto-generated release notes pick it up. + +## Methods REMOVED (breaking) + +```csharp +// All four overloads below are DELETED. + +Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, CancellationToken cancellationToken = default); +Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, IProgress progress, CancellationToken cancellationToken = default); +Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, CancellationToken cancellationToken = default); +Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, IProgress progress, CancellationToken cancellationToken = default); +``` + +## Methods PRESERVED + +```csharp +Task GetServersAsync(CancellationToken ct = default); + +Task GetServerLatencyAsync(IServer server, CancellationToken ct = default); +Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken ct = default); +Task GetServerLatencyAsync(string serverUrl, CancellationToken ct = default); +Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken ct = default); + +Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken ct = default); +Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken ct = default); + +Task GetDownloadSpeedAsync(IServer server, CancellationToken ct = default); +Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken ct = default); + +Task GetUploadSpeedAsync(IServer server, CancellationToken ct = default); +Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken ct = default); +``` + +## Behavioural contract + +| ID | Behaviour | +|---|---| +| **C-SS-1** | Total-byte budget cap is read from the settings record (`DownloadTestSettings.DownloadSizeMb` / `UploadTestSettings.UploadSizeMb`) at speed-test construction, not passed per-call. | +| **C-SS-2** | Per-call cap variation uses `settings with { DownloadTest = settings.DownloadTest with { DownloadSizeMb = N } }`, then a new `OoklaSpeedtest` instance — there is no longer a "vary the cap on this one call" overload. | +| **C-SS-3** | XML docs on surviving methods do not mention `int sizeMb`. | + +## Migration note for NuGet consumers (for release notes) + +Before: +```csharp +var result = await service.GetDownloadSpeedAsync(server, downloadSizeMb: 100, ct); +``` + +After: +```csharp +var settings = new OoklaSpeedtestSettings(Profile.Medium) with +{ + DownloadTest = new DownloadTestSettings { /* fields */, DownloadSizeMb = 100 } +}; +var service = new OoklaSpeedtest(settings); +var result = await service.GetDownloadSpeedAsync(server, ct); +``` + +The conceptual replacement is "configure once, run normally" instead of "configure per-call". This matches the rest of the settings surface (server discovery, latency, proxy) which has never had per-call overloads. diff --git a/specs/003-profile-cli-switch/data-model.md b/specs/003-profile-cli-switch/data-model.md new file mode 100644 index 00000000..5af74521 --- /dev/null +++ b/specs/003-profile-cli-switch/data-model.md @@ -0,0 +1,181 @@ +# Phase 1 — Data Model: `--profile` CLI switch + +**Feature**: 003-profile-cli-switch +**Date**: 2026-05-15 + +This feature changes type-level state on five existing types and adds one new enum. There is no persisted data and no state machine — all entities are immutable record / enum types built per-run. + +--- + +## Entities + +### `Profile` *(NEW · public enum · `NetPace.Core`)* + +**File**: `src/NetPace.Core/Profile.cs` +**Visibility**: `public` +**Kind**: enum (default `int` backing) +**Provider-coupled**: NO (validated by file location and grep — FR-001/FR-002) + +| Member | Ordinal | Intent | +|---|---|---| +| `Tiny` | 0 | IoT / 10 MB-month plans | +| `Small` | 1 | Cellular / metered | +| `Medium` | 2 | Typical home broadband (default) | +| `Large` | 3 | Fibre / business | +| `Mega` | 4 | Inter-DC / 10 Gbps saturation | + +Ordering is **ascending traffic-load** so future code that wants to compare "heaviness" can cast to `int` if needed (not done by this feature). + +**XML doc requirements**: every member documented. `Mega`'s doc must explicitly call out the undocumented-payload dependency and cross-reference `docs/architecture/download-upload-size-controls.md` (FR-021). + +**Validation rules**: no runtime validation on the enum itself; the receiving constructor (`OoklaSpeedtestSettings(Profile)`) throws `ArgumentOutOfRangeException` on unknown values (FR-007). + +--- + +### `OoklaSpeedtestSettings` *(EDIT · public sealed record · `NetPace.Core.Clients.Ookla`)* + +**File**: `src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs` +**State change**: no new fields; instance state stays pure data (FR-008 — no `Profile` property). + +**Constructors added** (both public, both XML-documented): + +| Signature | Behaviour | +|---|---| +| `OoklaSpeedtestSettings()` | Chains via `: this(Profile.Medium)`. Single source of truth for the default (FR-004). | +| `OoklaSpeedtestSettings(Profile profile)` | Inline `switch` expression that populates `DownloadTest` and `UploadTest` for the chosen profile. Throws `ArgumentOutOfRangeException(nameof(profile))` for unknown values (FR-007). | + +**Removed initializer**: `DownloadTest` and `UploadTest` lose their property initializers (`= new();`) because the constructor body now assigns them. + +**Existing fields unchanged**: `ServerDiscovery`, `LatencyTest`, `ProxyCredential`, `ProxyAddress`, `UseProxy`. + +**`with`-expression compatibility**: synthesised record copy-constructor is unaffected by user-defined constructors — `new OoklaSpeedtestSettings(Profile.Mega) with { UseProxy = true }` continues to work (FR — implicit, exercised by US-5 acceptance). + +--- + +### `DownloadTestSettings` *(EDIT · public sealed record · `NetPace.Core.Clients.Ookla.Settings`)* + +**File**: `src/NetPace.Core/Clients/Ookla/Settings/DownloadTestSettings.cs` + +**New property**: + +| Property | Type | Default | XML `` | +|---|---|---|---| +| `DownloadSizeMb` | `int` | `int.MaxValue` | Disambiguates from `DownloadSizes` (the per-request pixel array): this is the total-byte budget cap in IEC MiB. | + +**Existing properties unchanged**: `DownloadSizes`, `DownloadSizeIterations`, `DownloadParallelTasks`. + +**Per-profile values populated by `OoklaSpeedtestSettings(Profile)`**: + +| Profile | `DownloadSizes` | `DownloadSizeIterations` | `DownloadParallelTasks` | `DownloadSizeMb` | +|---|---|---|---|---| +| Tiny | `[350]` | 1 | 1 | 1 | +| Small | `[1000, 1500]` | 2 | 2 | 10 | +| Medium | `[1500, 2000, 3000, 3500, 4000]` | 2 | 4 | 100 | +| Large | `[2000, 2500, 3000, 3500, 4000]` | 12 | 16 | 1024 | +| Mega | `[3000, 4000, 5000, 6000, 7000]` | 40 | 32 | 10240 | + +--- + +### `UploadTestSettings` *(EDIT · public sealed record · `NetPace.Core.Clients.Ookla.Settings`)* + +**File**: `src/NetPace.Core/Clients/Ookla/Settings/UploadTestSettings.cs` + +**New property**: + +| Property | Type | Default | XML `` | +|---|---|---|---| +| `UploadSizeMb` | `int` | `int.MaxValue` | Total-byte budget cap in IEC MiB. Distinct from the per-request size derived from `UploadSizeIncrementKb` × `UploadIncrements`. | + +**Existing properties unchanged**: `UploadSizeIncrementKb`, `UploadIncrements`, `UploadSizeIterations`, `UploadParallelTasks`. + +**Per-profile values populated by `OoklaSpeedtestSettings(Profile)`**: + +| Profile | `UploadSizeIncrementKb` | `UploadIncrements` | `UploadSizeIterations` | `UploadParallelTasks` | `UploadSizeMb` | +|---|---|---|---|---|---| +| Tiny | 50 | 1 | 1 | 1 | 1 | +| Small | 100 | 4 | 2 | 2 | 2 | +| Medium | 200 | 6 | 5 | 4 | 25 | +| Large | 500 | 8 | 12 | 16 | 256 | +| Mega | 1024 | 16 | 16 | 32 | 2048 | + +--- + +### `ISpeedTestService` *(EDIT · public interface · `NetPace.Core`)* + +**File**: `src/NetPace.Core/ISpeedTestService.cs` + +**Breaking change — methods REMOVED** (D3/FR-010): + +- `GetDownloadSpeedAsync(IServer server, int downloadSizeMb, CancellationToken)` +- `GetDownloadSpeedAsync(IServer server, int downloadSizeMb, IProgress, CancellationToken)` +- `GetUploadSpeedAsync(IServer server, int uploadSizeMb, CancellationToken)` +- `GetUploadSpeedAsync(IServer server, int uploadSizeMb, IProgress, CancellationToken)` + +**Surviving signatures per direction**: + +- `Task GetDownloadSpeedAsync(IServer, CancellationToken)` +- `Task GetDownloadSpeedAsync(IServer, IProgress, CancellationToken)` +- `Task GetUploadSpeedAsync(IServer, CancellationToken)` +- `Task GetUploadSpeedAsync(IServer, IProgress, CancellationToken)` + +The byte-cap is now read from `DownloadTestSettings.DownloadSizeMb` / `UploadTestSettings.UploadSizeMb` on the settings record set at `OoklaSpeedtest` construction. + +--- + +### `OoklaSpeedtest` *(EDIT · public class · `NetPace.Core.Clients.Ookla`)* + +**File**: `src/NetPace.Core/Clients/Ookla/OoklaSpeedtest.cs` + +- Matching overload removals (mirror of the `ISpeedTestService` deletions). +- The internal `GenericTestSpeedAsync` / equivalent loop reads its `maxBytes` cap from `settings.DownloadTest.DownloadSizeMb` (resp. `UploadSizeMb`) at call-time, converted to bytes (× 1024 × 1024 for IEC MiB). +- XML docs updated on surviving methods to remove obsolete references to the `int sizeMb` parameter. + +--- + +### `SpeedTestCommandSettings` *(EDIT · CLI command-settings class · `NetPace.Console`)* + +**File**: `src/NetPace.Console/Commands/SpeedTestCommandSettings.cs` + +- New property: `public Profile Profile { get; init; } = Profile.Medium;` (kept aligned with the CLI flag default). + +### `Program.cs` *(EDIT · `NetPace.Console`)* + +**File**: `src/NetPace.Console/Program.cs` (call sites at L232-233 per issue body) + +- New `Option profileOption` declared next to the existing `--unit-system` option. +- Bound onto `SpeedTestCommandSettings.Profile`. +- The construction of `OoklaSpeedtestSettings` is rewired from "parameterless + with-override of `DownloadTest`/`UploadTest`" to `new OoklaSpeedtestSettings(settings.Profile) with { … }`, where the `with` block carries only: + - Explicit `--downloadsize` → `DownloadTest = previousDownloadTest with { DownloadSizeMb = N }`. + - Explicit `--uploadsize` → `UploadTest = previousUploadTest with { UploadSizeMb = N }`. + - Proxy fields unchanged. + +--- + +## Relationships + +``` +Profile (enum) ──read by──► OoklaSpeedtestSettings(Profile) ctor + │ + ▼ + { DownloadTest, UploadTest } ◄── DownloadTestSettings / UploadTestSettings + │ │ + │ └─ DownloadSizeMb / UploadSizeMb + ▼ + OoklaSpeedtest ──reads──► settings.DownloadTest.DownloadSizeMb + settings.UploadTest.UploadSizeMb +``` + +**Dependency direction**: arrows only flow inward into provider-specific code. `Profile` (top-level `NetPace.Core`) has zero references to anything under `Clients/`. `OoklaSpeedtestSettings` knows `Profile`; the reverse never holds. + +## State transitions + +None. All types are immutable records / enums. Settings are constructed once per run and never mutated; `with`-expressions produce new instances. + +## Validation rules + +| Where | Rule | Mechanism | +|---|---|---| +| `OoklaSpeedtestSettings(Profile)` | Unknown `Profile` value rejected | Switch-expression default arm throws `ArgumentOutOfRangeException(nameof(profile))` (FR-007). | +| `--profile` CLI binding | Unknown string rejected | `System.CommandLine` default enum-binding error (FR-013). | +| `--profile` case-insensitive parse | `tiny`, `Tiny`, `TINY` all parse | Existing `System.CommandLine` enum-binding behaviour (FR-012). | +| `DownloadSizeMb` / `UploadSizeMb` raw-record default | Sentinel `int.MaxValue` = "no cap" | Cap-check loop in `OoklaSpeedtest` never trips when cap exceeds natural transfer (FR-011, FR-017). | diff --git a/specs/003-profile-cli-switch/plan.md b/specs/003-profile-cli-switch/plan.md new file mode 100644 index 00000000..e4690161 --- /dev/null +++ b/specs/003-profile-cli-switch/plan.md @@ -0,0 +1,102 @@ +# Implementation Plan: Add `--profile` CLI switch (Tiny/Small/Medium/Large/Mega) + +**Branch**: `003-profile-cli-switch` | **Date**: 2026-05-15 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/003-profile-cli-switch/spec.md` + +## Summary + +Introduce a public, provider-agnostic `Profile` enum in `NetPace.Core` with five members (`Tiny`, `Small`, `Medium`, `Large`, `Mega`) and wire it through two new public constructors on `OoklaSpeedtestSettings` (parameterless → Medium; `Profile`-taking with an inline switch holding the entire profile → settings mapping). Move the existing `DownloadSizeMb` / `UploadSizeMb` total-byte-budget caps off `ISpeedTestService` method overloads (deleting them as a breaking change) and onto the corresponding per-phase settings records, so a single `--profile` CLI flag coherently bundles per-request shape (`DownloadSizes`, iterations, parallel tasks) and the cap. `Medium` becomes the new default; explicit `--downloadsize` / `--uploadsize` still override only the cap via `with`-expressions. + +Approach: edit existing files (no new project, no new abstraction layer). One inline switch in one constructor is the single source of truth for the profile → Ookla mapping; `Profile` itself stays a pure label with no provider knowledge so a second provider's settings record can supply its own translation later. Tests follow TDD per the constitution — RED-GREEN-REFACTOR on each new public surface — written into the existing `NetPace.Core.Tests` and `NetPace.Console.Tests` projects. + +## Technical Context + +**Language/Version**: C# 12 · .NET 8.0 (cross-platform) +**Primary Dependencies**: Spectre.Console (console UI), System.CommandLine (CLI binding), xUnit (tests), VerifyXunit (snapshot tests). No new dependencies introduced by this feature. +**Storage**: N/A — no persisted state; settings are constructed in-memory per-run. +**Testing**: xUnit · VerifyXunit (existing snapshot-test pattern under `NetPace.Console.Tests/Expectations/*.verified.txt`). Test conventions: file mirrors source (`OoklaSpeedtestSettings.cs` → `OoklaSpeedtestSettingsTests.cs`), partial-class split where appropriate (e.g. `NetPaceConsoleTests.Default.cs`), GIVEN-WHEN-THEN names (`MethodName_Scenario_ExpectedResult`). +**Target Platform**: Windows, Linux, macOS (.NET 8.0 cross-platform); no platform-specific code paths in scope. +**Project Type**: Library + CLI — `NetPace.Core` (NuGet-published library) and `NetPace.Console` (CLI consumer). +**Performance Goals**: Per-run transferred bytes must fall within ±10 % of each profile's published target (Tiny ~245 KB / Small ~10 MiB / Medium ~100 MiB / Large ~1 GiB / Mega ~10 GiB total down + up). Default-profile traffic must drop ≥ 65 % vs the prior ~370 MiB baseline (SC-002). +**Constraints**: AOT-trimmable — no reflection-heavy code; no runtime type discovery; pure constructor / switch-expression dispatch only. Public API additions require XML docs (constitution V). NetPace is pre-1.0 so breaking `ISpeedTestService` overload deletions are acceptable but must be flagged in PR title for auto-generated release notes. +**Scale/Scope**: One enum, two new constructors, two property moves (DownloadSizeMb / UploadSizeMb into per-phase records), one new CLI option, six method-overload deletions on `ISpeedTestService` (and matching `OoklaSpeedtest`), plus docs (README, USER_GUIDE, architecture, CIR). + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.* + +Constitution principles evaluated against this plan: + +| Principle | Check | Verdict | +|---|---|---| +| **I — TDD (non-negotiable)** | Every new public surface (Profile enum, both ctors, relocated properties, CLI flag) is added test-first. Tests already enumerated in spec FR-005..FR-007 and SC-005. | PASS | +| **II — Library-First** | `Profile` and the ctor-driven mapping live in `NetPace.Core`. CLI is a thin consumer that parses `--profile` and threads the value into a `new OoklaSpeedtestSettings(profile)` call. Library is independently usable by NuGet consumers (US-5). | PASS | +| **III — CLI Excellence** | `--profile` follows the established enum-flag pattern (`--unit-system`, `--unit-scale`). Default is sensible (`Medium`). `--help` shows the full enum value list. Output formats unaffected. | PASS | +| **IV — Cross-Platform** | No platform-specific APIs; no filesystem or process-level dependencies introduced. | PASS | +| **V — Code Quality (naming, XML docs, async/CT, nullable, no warnings)** | XML docs on every new public member (FR-021). Naming follows PascalCase enum convention. No new async surface; existing CT contracts preserved. Nullable already enabled project-wide. | PASS | +| **VI — Minimal Dependencies** | No new NuGet packages. All work is internal type/method additions on existing assemblies. | PASS | +| **VII — Semantic Versioning** | Breaking change: `ISpeedTestService` overload deletions and new default profile. Per pre-1.0 policy + spec Assumptions, flagged in PR title; auto-generated release notes pick up the breaking-change marker. | PASS (documented) | +| **VIII — AC-to-Test traceability** | Spec acceptance scenarios already carry `**Scenario:**` labels; test-plan generation will match them verbatim. | PASS | + +**Gate result: PASS.** No constitutional violations. Complexity Tracking section omitted (no violations to justify). + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-profile-cli-switch/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ ├── profile-enum.md # Public Profile enum contract +│ ├── ooklasettings-ctors.md # OoklaSpeedtestSettings ctor contract +│ ├── speedtestservice-surface.md # ISpeedTestService overload deletions +│ └── cli-flag.md # --profile CLI binding contract +├── checklists/ +│ └── requirements.md # Created by /speckit.specify +└── tasks.md # Created by /speckit.tasks (not by /speckit.plan) +``` + +### Source Code (repository root) + +```text +src/ +├── NetPace.Core/ +│ ├── Profile.cs ◄ NEW (public enum, sibling of SpeedUnit*) +│ ├── ISpeedTestService.cs ◄ EDIT (delete int sizeMb overloads — 4 methods) +│ ├── SpeedUnit.cs / SpeedScale.cs / SpeedUnitSystem.cs (pattern reference; unchanged) +│ └── Clients/Ookla/ +│ ├── OoklaSpeedtestSettings.cs ◄ EDIT (two new public ctors + inline switch) +│ ├── OoklaSpeedtest.cs ◄ EDIT (read DownloadSizeMb/UploadSizeMb off settings; remove method overloads) +│ └── Settings/ +│ ├── DownloadTestSettings.cs ◄ EDIT (add DownloadSizeMb property; default int.MaxValue) +│ └── UploadTestSettings.cs ◄ EDIT (add UploadSizeMb property; default int.MaxValue) +├── NetPace.Console/ +│ ├── Program.cs ◄ EDIT (add --profile Option, wire through; rewire call sites at L232-233) +│ └── Commands/ +│ └── SpeedTestCommandSettings.cs ◄ EDIT (Profile property) +└── NetPace.Core.Tests/ + ├── ProfileTests.cs ◄ NEW (enum-level structural tests; FR-001..FR-002) + └── OoklaSpeedtestSettingsTests.cs ◄ NEW (ctor mapping per profile; FR-003..FR-008) + └── OoklaSpeedtestSettingsTests.Profiles.cs ◄ NEW (partial — per-profile exact-equality assertions; SC-005) +src/NetPace.Console.Tests/ + ├── NetPaceConsoleTests.Profile.cs ◄ NEW (--profile binding, defaults, override interaction; FR-012..FR-017) + └── Expectations/ ◄ EDIT (refresh --help and any output snapshots affected) + +docs/ +├── architecture/download-upload-size-controls.md ◄ EDIT (cross-ref profiles → per-request tables; Mega warning) +└── change-intent-records/ + └── CIR-NNN-profile-cli-switch.md ◄ NEW (public-API addition record; per FR-025) + +README.md ◄ EDIT (refresh --help snapshot; --profile in options table) +USER_GUIDE.md ◄ EDIT (new "Choosing a profile" section with table; Mega warning callout) +``` + +**Structure Decision**: Single existing solution layout retained. No new project. All work lands as edits in `src/NetPace.Core/`, `src/NetPace.Console/`, and the two existing test projects, plus the three docs. The `Profile.cs` placement at the **top level** of `NetPace.Core` (sibling of `SpeedUnit.cs`) is load-bearing: it enforces FR-001 (top-level) and FR-002 (no provider import) by file location alone, making the "provider knows `Profile`; `Profile` knows no provider" rule grep-able and structurally enforced. + +## Complexity Tracking + +No constitutional violations. Section intentionally empty. diff --git a/specs/003-profile-cli-switch/quickstart.md b/specs/003-profile-cli-switch/quickstart.md new file mode 100644 index 00000000..4e67bf3b --- /dev/null +++ b/specs/003-profile-cli-switch/quickstart.md @@ -0,0 +1,108 @@ +# Quickstart — `--profile` CLI switch + +**Feature**: 003-profile-cli-switch +**Audience**: end users (CLI) and library consumers (NetPace.Core NuGet) + +## CLI + +```bash +# Default — Medium profile (~121 MiB total per run) +netpace + +# IoT / 10 MB-month data plan (~0.3 MiB total per run; ≥ 30 runs/month within cap) +netpace --profile tiny + +# Cellular / metered (~12 MiB total per run) +netpace --profile small + +# Fibre / business (~1.2 GiB total per run) +netpace --profile large + +# Inter-DC / 10 Gbps saturation (~12 GiB total per run; uses undocumented OoklaServer payloads) +netpace --profile mega + +# Profile + override the cap only (Tiny's per-request shape, stop after 5 MiB downloaded) +netpace --profile tiny --downloadsize 5 + +# Profile + skip upload phase +netpace --profile large --no-upload +``` + +Case-insensitive: `--profile TINY`, `--profile Tiny`, and `--profile tiny` are all valid. Unknown values produce a `System.CommandLine` error. + +## Library (NetPace.Core) + +```csharp +using NetPace.Core; +using NetPace.Core.Clients.Ookla; + +// Default — Medium +var defaultSettings = new OoklaSpeedtestSettings(); + +// Explicit profile +var tiny = new OoklaSpeedtestSettings(Profile.Tiny); + +// Profile + non-payload customisation +var withProxy = new OoklaSpeedtestSettings(Profile.Mega) with +{ + UseProxy = true, + ProxyAddress = new Uri("http://proxy.example.com:8080") +}; + +// Profile + cap override +var baseSettings = new OoklaSpeedtestSettings(Profile.Large); +var cappedAt100Mib = baseSettings with +{ + DownloadTest = baseSettings.DownloadTest with { DownloadSizeMb = 100 } +}; + +// Run +var service = new OoklaSpeedtest(cappedAt100Mib); +var result = await service.GetDownloadSpeedAsync(server, CancellationToken.None); +``` + +## Migration from prior `int sizeMb` overloads + +If you previously called: + +```csharp +await service.GetDownloadSpeedAsync(server, downloadSizeMb: 100, ct); +``` + +…replace with the settings-record approach: + +```csharp +var settings = new OoklaSpeedtestSettings(Profile.Medium) with +{ + DownloadTest = /* …current DownloadTest… */ with { DownloadSizeMb = 100 } +}; +var service = new OoklaSpeedtest(settings); +await service.GetDownloadSpeedAsync(server, ct); +``` + +The `int sizeMb` overloads on `ISpeedTestService` have been removed. + +## Verifying + +Quickest end-to-end check (uses the local Docker OoklaServer if you have one at `docker/ooklaserver/`, otherwise any public Ookla server): + +```bash +dotnet build +dotnet test --filter "FullyQualifiedName~Profile" + +# Tiny end-to-end (manual byte-budget check) +netpace --profile tiny --json +# Then inspect the run output's reported bytes and confirm ≤ 1 MiB total. +``` + +## Choosing a profile + +| If you are… | Pick | +|---|---| +| Running on IoT / a 10 MB-month data plan | `Tiny` | +| On cellular / a metered link | `Small` | +| On typical home broadband | `Medium` *(default — omit `--profile`)* | +| On fibre / a business link | `Large` | +| Saturating a 10 Gbps inter-DC link | `Mega` *(warning: depends on undocumented OoklaServer payloads — may fail on future server versions)* | + +The five values are deliberately coarse. If you need fine-grained per-knob control, use the library API directly and construct your own `OoklaSpeedtestSettings` record without going through `(Profile)`. diff --git a/specs/003-profile-cli-switch/research.md b/specs/003-profile-cli-switch/research.md new file mode 100644 index 00000000..6e889024 --- /dev/null +++ b/specs/003-profile-cli-switch/research.md @@ -0,0 +1,123 @@ +# Phase 0 — Research: `--profile` CLI switch + +**Feature**: 003-profile-cli-switch +**Date**: 2026-05-15 +**Status**: Complete — all `NEEDS CLARIFICATION` resolved before plan was filled + +## Scope of research + +The source GitHub issue (#174) is unusually detailed and already contains a `Confirmed decisions` block resolving every option that would normally surface as `NEEDS CLARIFICATION` in a fresh spec. Phase 0 therefore consolidates those decisions, validates them against the constitution and existing codebase, and resolves the remaining genuinely-open mechanical questions before Phase 1 design. + +No external web research was required; all inputs are repo-internal (issue body, `docs/architecture/download-upload-size-controls.md`, existing enum/CLI patterns in `src/`). + +--- + +## Decisions + +### D1 — `Profile` enum location and shape + +- **Decision**: Public enum `Profile` at `src/NetPace.Core/Profile.cs` with five members in ascending-size order: `Tiny`, `Small`, `Medium`, `Large`, `Mega`. Sibling of `SpeedUnit.cs`, `SpeedScale.cs`, `SpeedUnitSystem.cs`. No `[Flags]`, no underlying `byte`/`short` cast — default `int` backing. +- **Rationale**: Mirrors three existing precedents in `NetPace.Core`. Top-level placement structurally enforces "provider-agnostic" (no `Clients/Ookla/` namespace). +- **Alternatives rejected**: + - `Clients/Ookla/Profile.cs` — would couple the label to one provider; rejected by FR-001/FR-002 and the existing "dependency-direction" memory rule. + - `enum Profile : byte` — micro-optimisation; no measurable benefit; breaks symmetry with `SpeedUnit` etc. + - Six profiles (adding `XL`, `Custom`, etc.) — explicitly out-of-scope per issue. + +### D2 — Provider mapping lives in a constructor's inline switch, not a helper class + +- **Decision**: `OoklaSpeedtestSettings` gains two public constructors: + - `public OoklaSpeedtestSettings() : this(Profile.Medium) { }` + - `public OoklaSpeedtestSettings(Profile profile) { (DownloadTest, UploadTest) = profile switch { … }; }` + Entire profile → settings mapping is one switch expression in one file. No `OoklaSpeedtestSettingsExtensions`, no `OoklaProfileBuilder`, no factory method. +- **Rationale**: Confirmed in issue body. Maximum locality; reviewer sees all five profiles side-by-side; `with`-expression composes cleanly with the synthesised record copy-ctor. +- **Alternatives rejected**: + - Static factory `OoklaSpeedtestSettings.ForProfile(Profile p)` — added indirection with no benefit. + - Extension method `profile.ToOoklaSettings()` — would violate FR-002 (`Profile` would carry provider knowledge through its extension surface). + - Per-profile factory methods (`ForTiny()`, `ForSmall()`, …) — five times the API surface for no gain. + +### D3 — Move `DownloadSizeMb` / `UploadSizeMb` off method signatures, onto settings records + +- **Decision**: Add `int DownloadSizeMb { get; init; } = int.MaxValue;` to `DownloadTestSettings`; add `int UploadSizeMb { get; init; } = int.MaxValue;` to `UploadTestSettings`. Delete four `int sizeMb` overloads on `ISpeedTestService` and the matching `OoklaSpeedtest` methods. `OoklaSpeedtest.GetDownloadSpeedAsync(server, ct)` and `GetDownloadSpeedAsync(server, IProgress, ct)` survive (same shape for upload). +- **Rationale**: Confirmed in issue body. Profile must coherently bundle per-request shape **and** total-byte cap — splitting them across record state and method args defeats the bundle. `int.MaxValue` default preserves "no cap" semantics for raw-record consumers who don't go through `OoklaSpeedtestSettings(Profile)`. +- **Alternatives rejected**: + - Keep `int sizeMb` overloads in addition to record-state — two ways to set the same value; ambiguous precedence; rejected. + - Default to `0` instead of `int.MaxValue` — would require sentinel-value branch in cap-check loop; uglier than truncating-at-`int.MaxValue` natural behaviour. + +### D4 — Profile values for Ookla + +- **Decision**: Use exactly the table in spec FR-018..FR-020 (issue body's table). Tiny/Small/Medium/Large draw payloads only from `{350, 500, 750, 1000, 1500, 2000, 2500, 3000, 3500, 4000}`. Mega adds `5000, 6000, 7000`. +- **Rationale**: Per-request byte sizes are validated cross-server in `docs/architecture/download-upload-size-controls.md`. Quarantining the bonus-payload risk to one profile keeps four out of five profiles maximally resilient to upstream changes. +- **Alternatives rejected**: + - All profiles use bonus payloads — fragility leaks to ordinary users; rejected. + - Mega uses only historic-10 with higher iterations — cannot reach steady-state on 10 Gbps; documented as future fallback only if upstream removes bonus payloads. + +### D5 — Default profile and override interaction + +- **Decision**: `Medium` is the implicit default (`netpace` with no flags equals `netpace --profile medium`). Explicit `--downloadsize` / `--uploadsize` override only `DownloadTest.DownloadSizeMb` / `UploadTest.UploadSizeMb` via `with`-expression; all other per-request shape fields are profile-derived and not CLI-overridable in this feature. `--no-download` / `--no-upload` short-circuit regardless of profile. +- **Rationale**: Confirmed in issue body. Single user-visible decision; explicit overrides act as a backstop, not a directive. +- **Alternatives rejected**: + - `Tiny` default — too conservative for the typical home-broadband user. + - Auto-detect default ("pick a profile based on observed link speed") — explicit user choice only per "Out of scope". + +### D6 — CLI flag binding shape + +- **Decision**: `var profileOption = new Option("--profile") { Description = "Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega).", DefaultValueFactory = _ => Profile.Medium };`. No short alias, no custom error message — rely on `System.CommandLine`'s default unknown-value error. +- **Rationale**: Confirmed in issue body. Matches existing `--unit-system` precedent. +- **Alternatives rejected**: + - Short alias `-p` — none of the existing enum flags has a short alias; consistency wins. + - Custom error message ("did you mean Tiny?") — defer; `System.CommandLine`'s default is good enough. + +### D7 — CIR storage path + +- **Decision**: `docs/change-intent-records/CIR-NNN-profile-cli-switch.md` (using next sequential CIR number). +- **Rationale**: Confirmed in issue body. The `docs/cir/` reference in the original issue text is a typo; the actual repo directory is `docs/change-intent-records/`. +- **Alternatives rejected**: `docs/cir/` — does not exist in the repo. + +### D8 — Testing strategy + +- **Decision**: + - Profile → settings mapping covered by **unit tests only** in `NetPace.Core.Tests` (no Docker-backed integration test). + - CLI binding and `--profile` × `--downloadsize` override interaction covered in `NetPace.Console.Tests`, using the existing `CommandLineTestHost` pattern. + - `--help` snapshot refreshed via VerifyXunit pattern under `Expectations/`. + - End-to-end byte-budget verification (SC-001/SC-002/SC-003) is treated as a manual/operational check (run against a known server, observe transferred bytes) — not gated by automation, because spec confirmed decisions explicitly ruled out Docker integration tests. +- **Rationale**: Per spec confirmed decisions: "Docker integration tests considered an anti-pattern" for profile→settings wiring. The mapping is pure data; testing it via integration would just re-verify what unit tests already prove field-by-field. +- **Alternatives rejected**: + - Add Docker integration test against `docker/ooklaserver/` — rejected by confirmed decision. + - Skip the regression-guard test for Mega's bonus payloads — rejected by FR-019 / spec scenario "Mega regression guard". + +### D9 — Documentation scope + +- **Decision**: Update README (--help snapshot, options table, one example), USER_GUIDE (new "Choosing a profile" section + Mega warning callout), `docs/architecture/download-upload-size-controls.md` (cross-ref section), XML docs on every new public member, new CIR. No CHANGELOG.md (does not exist in repo); release notes auto-generated from PR title/body. +- **Rationale**: Per CLAUDE.md and the project memory rule `feedback_cli_feature_doc_scope` — every NetPace CLI feature must scope user-facing docs from the start; release-pipeline / docs/RELEASING.md not in scope here. +- **Alternatives rejected**: + - Add CHANGELOG.md to track this feature — explicitly counter to the repo convention. + +### D10 — Test naming for partial-class style + +- **Decision**: `NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs` (top-level entry point + shared helpers) + `OoklaSpeedtestSettingsTests.Profiles.cs` (per-profile exact-equality assertions). Mirrors the existing `OoklaSpeedtestTests.Guards.cs`, `.Memory.cs`, `.ServerListParsing.cs` partial-class split. +- **Rationale**: Established repo convention. + +--- + +## Resolved unknowns + +| Original area | Resolution | +|---|---| +| Where does `Profile` live? | `src/NetPace.Core/Profile.cs` (D1). | +| How is the profile → settings mapping expressed? | Inline switch in `OoklaSpeedtestSettings(Profile)` ctor (D2). | +| Do per-phase caps move? | Yes — `DownloadSizeMb` / `UploadSizeMb` move onto `DownloadTestSettings` / `UploadTestSettings`; method overloads deleted (D3). | +| Concrete profile field values? | Per-table in spec FR-018..FR-020 (D4). | +| Default profile? | `Medium`; both via parameterless ctor chaining and CLI `DefaultValueFactory` (D5). | +| CLI flag binding? | `Option` with default-value factory; no short alias (D6). | +| CIR path? | `docs/change-intent-records/` (D7). | +| Docker integration tests? | No (D8). | +| Docs scope? | README + USER_GUIDE + architecture doc + XML + CIR; no CHANGELOG (D9). | +| Test file naming? | Partial-class split per existing convention (D10). | + +**No `NEEDS CLARIFICATION` markers remain.** Phase 1 may proceed. + +--- + +## Constitution re-check after research + +All eight principles still PASS — research surfaced no contradictions. TDD obligations are concrete (each FR has a paired test in spec FR-005..FR-007 / SC-005). Library-First is reinforced (CLI is a thin consumer). Minimal-Dependencies is unchanged (zero new packages). diff --git a/specs/003-profile-cli-switch/spec.md b/specs/003-profile-cli-switch/spec.md new file mode 100644 index 00000000..db74d38e --- /dev/null +++ b/specs/003-profile-cli-switch/spec.md @@ -0,0 +1,203 @@ +# Feature Specification: Add `--profile` CLI switch (Tiny/Small/Medium/Large/Mega) + +**Feature Branch**: `003-profile-cli-switch` +**Created**: 2026-05-15 +**Status**: Draft +**Input**: GitHub issue #174 — "Add --profile CLI switch (Tiny/Small/Medium/Large/Mega)" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Run NetPace on a constrained data plan without busting the cap (Priority: P1) + +A user on a metered or IoT data plan (e.g. 10 MB/month) needs to run NetPace to check link health without blowing through their monthly allowance. Today the default run transfers ≈ 370 MiB and there is no single-knob way to ask for a lightweight run. The user picks `--profile tiny` (or `--profile small`) and NetPace runs end-to-end within a strict byte budget appropriate to their plan. + +**Why this priority**: Without this, an entire class of users (IoT, cellular, metered) cannot use NetPace at all. It is the largest reachability gap the feature closes and the most concrete user pain-point cited in the motivation. + +**Independent Test**: Run `netpace --profile tiny` against any reachable Ookla server (or the bundled local Docker OoklaServer) and confirm the total transferred bytes are within ±10 % of the published Tiny budget (~245 KB down + ~50 KB up). Delivers value on its own — even with no other profile, Tiny alone makes NetPace usable on a 10 MB/month plan. + +**Acceptance Scenarios**: + +1. **Scenario: Tiny profile stays within IoT budget** + Given a user runs NetPace with `--profile tiny`, When the run completes successfully against a reachable Ookla server, Then total transferred bytes are ≤ 1 MiB (well under the ~370 MiB default), and per-run download bytes fall within ±10 % of ~245 KB and upload bytes within ±10 % of ~50 KB. + +2. **Scenario: Small profile suits cellular** + Given a user runs NetPace with `--profile small`, When the run completes, Then total transferred bytes are ≤ ~12 MiB, suitable for a typical mobile data plan. + +3. **Scenario: Profile is authoritative for per-request shape** + Given a user runs `netpace --profile tiny`, When per-request HTTP traffic is observed, Then no individual download request fetches a payload larger than the Tiny profile's largest declared payload size (i.e. no full 4000-pixel JPEG is requested), and concurrent parallel requests do not exceed the profile's parallel-task count. + +--- + +### User Story 2 — Get a sensible default without thinking about it (Priority: P1) + +A user runs `netpace` with no flags. They expect a reasonable, well-tested test — not a power-user-grade saturation run. After this change, the implicit default is `--profile medium`, which transfers ≈ 121 MiB (≈ 100 MiB down + ≈ 21 MiB up) instead of the current ≈ 370 MiB. The user gets a faster, lighter, still-representative result with no flag change required. + +**Why this priority**: The default is the most-trodden path. Shifting it to `Medium` reduces per-run traffic for every user who never reads the docs. P1 because it is a behaviour change visible to 100 % of users. + +**Independent Test**: Run `netpace` with no arguments and confirm the settings record actually constructed inside `Program.RunAsync` is equal — field for field — to `new OoklaSpeedtestSettings(Profile.Medium)`. Total per-run transfer falls within the Medium budget. + +**Acceptance Scenarios**: + +1. **Scenario: Omitted --profile defaults to Medium** + Given a user invokes `netpace` with no `--profile` flag, When the CLI binds options and constructs `OoklaSpeedtestSettings`, Then the resulting record is field-for-field identical to `new OoklaSpeedtestSettings(Profile.Medium)`. + +2. **Scenario: Parameterless ctor chains to Medium** + Given library code calls `new OoklaSpeedtestSettings()` with no argument, When the record is constructed, Then it is field-for-field identical to `new OoklaSpeedtestSettings(Profile.Medium)` (single source of truth via `: this(Profile.Medium)`). + +3. **Scenario: Default-run traffic drops vs pre-change baseline** + Given a user runs `netpace` with no flags, When the run completes, Then total per-run transferred bytes are within ±10 % of ~121 MiB (down from the prior ~370 MiB default), and the user sees no functional regression in reported download/upload speeds. + +--- + +### User Story 3 — Saturate a 10 Gbps inter-DC link (Priority: P2) + +A power user running NetPace on a fibre or inter-data-centre link cannot currently push enough traffic to reach steady-state — the hardcoded payloads top out at the 4000-pixel image and parallelism is capped low. With `--profile mega`, the user opts into a deliberately heavyweight profile that uses the larger 5000/6000/7000-pixel payloads (undocumented but observed on current OoklaServer) and far higher parallelism, enabling ~10 GiB total transfer per run. + +**Why this priority**: Power-user need. Smaller addressable population than P1, but the only path that closes the upper-end reachability gap. P2 because the feature is still useful even if Mega is the last profile delivered. + +**Independent Test**: Run `netpace --profile mega` against a fibre-class endpoint (or the Docker OoklaServer with `OoklaServer.MaxFileBlock` set high enough) and observe that requests for `5000`, `6000`, and `7000` payloads are issued, parallel-task count reaches the profile's declared maximum, and total transfer reaches ~10 GiB ±10 %. + +**Acceptance Scenarios**: + +1. **Scenario: Mega uses bonus payloads** + Given a user runs `netpace --profile mega`, When download requests are issued, Then the requested `DownloadSizes` set includes `5000`, `6000`, and `7000` (the bonus payloads). + +2. **Scenario: Mega's bonus-payload dependency is documented** + Given a developer reads the XML doc on `Profile.Mega`, When they review the doc text, Then it explicitly states that Mega depends on undocumented OoklaServer payloads (5000/6000/7000) and may break on future OoklaServer releases, and it cross-references `docs/architecture/download-upload-size-controls.md`. + +3. **Scenario: Mega regression guard** + Given a future refactor changes `OoklaSpeedtestSettings(Profile)`'s switch expression, When the test suite runs, Then a dedicated regression test asserts that `new OoklaSpeedtestSettings(Profile.Mega).DownloadTest.DownloadSizes` still includes `5000`, `6000`, and `7000` — preventing silent demotion of Mega. + +--- + +### User Story 4 — Choose a profile, then override one cap (Priority: P2) + +A user wants the request shape of a known profile (e.g. parallelism, iterations, payload mix from `Large`) but wants to cap the total transfer lower than the profile's default — for example, `--profile large --downloadsize 100` to use Large's per-request shape but stop after 100 MiB downloaded. + +The profile is authoritative for per-request shape; user-supplied `--downloadsize` / `--uploadsize` override the per-phase byte-budget caps via `with`-expression on top of the profile-built settings. + +**Why this priority**: Power-user composability. Useful but not a reachability gap; profiles alone (P1) deliver MVP. P2. + +**Independent Test**: Bind `--profile large --downloadsize 100`, inspect the constructed settings record, and confirm: download per-request shape (`DownloadSizes`, iterations, parallel tasks) equals the Large profile's; `DownloadTest.DownloadSizeMb` equals 100 (not Large's natural ~1024). + +**Acceptance Scenarios**: + +1. **Scenario: --downloadsize overrides only the cap, profile shape is preserved** + Given a user runs `netpace --profile tiny --downloadsize 5`, When the settings record is constructed, Then `DownloadTest.DownloadSizes`, `DownloadTest.DownloadSizeIterations`, and `DownloadTest.DownloadParallelTasks` match Tiny's profile values, and `DownloadTest.DownloadSizeMb` equals 5. + +2. **Scenario: --uploadsize overrides only the upload cap** + Given a user runs `netpace --profile small --uploadsize 1`, When the settings record is constructed, Then upload per-request shape matches Small's profile and `UploadTest.UploadSizeMb` equals 1. + +3. **Scenario: Override cap larger than natural transfer is a no-op backstop** + Given a user runs `netpace --profile tiny --downloadsize 5000`, When the run completes, Then the run completes naturally (Tiny transfers well under 5000 MiB so the cap is never hit), and the cap is mechanically present on the settings record but the cap-hit check never triggers. + +4. **Scenario: --no-download short-circuits regardless of profile** + Given a user runs `netpace --no-download --profile large`, When the test runs, Then the download phase is skipped, the upload phase still uses Large's profile values, and Large's download shape has no observable effect. + +--- + +### User Story 5 — Library consumer uses Profile from `NetPace.Core` (Priority: P2) + +A library consumer (e.g. a unit-test author or a NuGet downstream) wants to drive `OoklaSpeedtest` directly without going through the CLI. They construct a settings record from a profile, optionally `with`-customise non-payload fields (e.g. proxy), and pass it in. + +**Why this priority**: The library-first principle means NuGet consumers must benefit alongside the CLI. Important but reachable via library use only — P2. + +**Independent Test**: In `NetPace.Core.Tests`, write a test that constructs `new OoklaSpeedtestSettings(Profile.Tiny)`, then `with { UseProxy = true, ProxyAddress = … }`, and asserts both the profile-derived fields and the proxy fields are present on the resulting record. No CLI involvement. + +**Acceptance Scenarios**: + +1. **Scenario: Profile enum is provider-agnostic and at the root of NetPace.Core** + Given a developer browses `NetPace.Core` source, When they inspect `Profile`, Then `Profile` lives at `src/NetPace.Core/Profile.cs` (top-level, sibling of `SpeedUnit`, `SpeedScale`, `SpeedUnitSystem`, **not** under `Clients/Ookla/`), and `Profile` has no extension methods that reference any provider type. + +2. **Scenario: `with` expression composes cleanly on profile-built record** + Given a developer writes `var s = new OoklaSpeedtestSettings(Profile.Mega) with { UseProxy = true };`, When the expression is compiled and evaluated, Then `s` has Mega's `DownloadTest`/`UploadTest` values and `UseProxy == true`. + +3. **Scenario: Construct invalid profile throws** + Given a developer calls `new OoklaSpeedtestSettings((Profile)999)`, When the constructor's switch evaluates, Then `ArgumentOutOfRangeException` is thrown with parameter name `profile`. + +--- + +### Edge Cases + +- **Invalid `--profile` value** — e.g. `--profile huge`. `System.CommandLine` enum binding rejects with its standard error message; no custom alias or short flag is offered (per confirmed decision in the issue body). +- **Case sensitivity** — `--profile TINY`, `--profile tiny`, `--profile Tiny` must all parse to `Profile.Tiny` (matches existing `--unit-system` etc.). +- **No-cap raw-record consumers** — A library consumer who constructs `new DownloadTestSettings { … }` directly (without going through `OoklaSpeedtestSettings(Profile)`) still gets `int.MaxValue` as the default for `DownloadSizeMb` / `UploadSizeMb`, preserving "no cap unless explicitly set" semantics for raw-record use. +- **Override cap larger than profile's natural transfer** — `--profile tiny --downloadsize 5000`: the cap is present on the record but the cap-hit check (`totalBytesReturned >= maxBytes`) never triggers because Tiny completes well under 5000 MiB on its own. Documented as a backstop, not a directive. +- **Mega payloads disappear upstream** — If `5000`/`6000`/`7000` stop being served by future OoklaServer releases, Mega returns errors. The fallback strategy (revert to historic-10 only, raise iterations proportionally) is out of scope for this feature but called out in `Open questions`. The XML doc on `Profile.Mega` warns of this risk. +- **`--no-download` / `--no-upload` with any profile** — Phase short-circuit always wins; the profile's values for the skipped phase have no observable effect. +- **Existing `int downloadSizeMb` / `int uploadSizeMb` method overloads on `ISpeedTestService`** — These are deleted (breaking change to the public NuGet contract; CHANGELOG breaking-change entry required). Per-call variation now uses `settings with { DownloadSizeMb = N }`. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Public API surface + +- **FR-001**: `NetPace.Core` MUST expose a public enum `Profile` with members `Tiny`, `Small`, `Medium`, `Large`, `Mega`, located at `src/NetPace.Core/Profile.cs` (top-level, not under `Clients/Ookla/`). +- **FR-002**: `Profile` MUST have no extension methods that reference any provider-specific type — verified by structural test / grep. +- **FR-003**: `OoklaSpeedtestSettings` MUST expose two public constructors: a parameterless `OoklaSpeedtestSettings()` and `OoklaSpeedtestSettings(Profile profile)`. +- **FR-004**: The parameterless constructor MUST chain to the profile-taking constructor with `Profile.Medium` (`: this(Profile.Medium)`), so `Profile.Medium` is the single source of truth for the default. +- **FR-005**: The `Profile`-taking constructor MUST contain the entire profile → download/upload settings mapping inline as a single switch expression. No separate helper class, factory method, or extension method may hold any of the per-profile values. +- **FR-006**: No `OoklaSpeedtestSettingsExtensions`, `OoklaProfileExtensions`, or any similarly-named profile-related helper class may exist in the codebase. +- **FR-007**: The constructor's switch expression MUST throw `ArgumentOutOfRangeException` (with parameter name `profile`) for unknown `Profile` enum values. +- **FR-008**: `OoklaSpeedtestSettings` instance state MUST NOT include a `Profile` property — settings record state stays pure data with no profile field. + +#### Per-phase settings move + +- **FR-009**: `DownloadSizeMb` MUST move from a method parameter on `GetDownloadSpeedAsync` into `DownloadTestSettings`; `UploadSizeMb` MUST move into `UploadTestSettings`. `OoklaSpeedtest` reads them from the settings record. +- **FR-010**: The existing `int sizeMb` / `int downloadSizeMb` / `int uploadSizeMb` parameter overloads on `OoklaSpeedtest` AND on `ISpeedTestService` MUST be deleted (not just declined to be added) — `GetDownloadSpeedAsync(server, ct)` and `GetDownloadSpeedAsync(server, IProgress<…>, ct)` are the only surviving signatures per direction. This is an accepted breaking change to the public NuGet contract. +- **FR-011**: The default value for `DownloadTestSettings.DownloadSizeMb` and `UploadTestSettings.UploadSizeMb` when constructed directly (not via `OoklaSpeedtestSettings(Profile)`) MUST be `int.MaxValue`, preserving "no cap unless explicitly set" semantics for raw-record consumers. + +#### CLI surface + +- **FR-012**: The CLI MUST expose a `--profile` option of type `Profile`, with case-insensitive parsing (matching existing enum-flag behaviour for `--unit-system` etc.). +- **FR-013**: The CLI MUST reject unknown `--profile` values with the default `System.CommandLine` error message; no custom alias or short flag is required. +- **FR-014**: When `--profile` is omitted, the resulting settings record MUST be field-for-field identical to `new OoklaSpeedtestSettings(Profile.Medium)`. +- **FR-015**: Explicit `--downloadsize` / `--uploadsize` flags MUST override the profile-derived `DownloadTest.DownloadSizeMb` / `UploadTest.UploadSizeMb` via `with`-expression applied after the profile-taking constructor. All other profile-derived per-request shape values (`DownloadSizes`, iterations, parallel tasks, upload increments) MUST be preserved. +- **FR-016**: `--no-download` / `--no-upload` MUST continue to short-circuit their respective phases regardless of profile. +- **FR-017**: When the override cap exceeds the profile's natural transfer total, the override MUST be mechanically present on the settings record but the cap-hit check MUST NOT artificially extend the test — the test completes naturally when the profile's iterations conclude. + +#### Profile values (Ookla mapping) + +- **FR-018**: For Ookla, `Tiny`, `Small`, `Medium`, and `Large` profiles MUST use only `DownloadSizes` values drawn from the historic Speedtest.net Flash-client array `{350, 500, 750, 1000, 1500, 2000, 2500, 3000, 3500, 4000}`. +- **FR-019**: `Profile.Mega` MUST include `5000`, `6000`, and `7000` in its `DownloadSizes` (verified by regression-guard test) to enable saturation of 10 Gbps inter-DC links. +- **FR-020**: Profile-derived per-run byte totals MUST fall within ±10 % of the published targets (Tiny ~245 KB / ~50 KB; Small ~10 MiB / ~2 MiB; Medium ~100 MiB / ~21 MiB; Large ~1 GiB / ~211 MiB; Mega ~10 GiB / ~2 GiB) when measured against a known-good test server. + +#### Documentation + +- **FR-021**: All new public types and members in `NetPace.Core` (`Profile` enum and each member; both `OoklaSpeedtestSettings` constructors; the relocated `DownloadSizeMb`/`UploadSizeMb` properties) MUST carry XML documentation comments. The XML doc on `Profile.Mega` MUST explicitly state that it depends on undocumented OoklaServer payloads (5000/6000/7000), warn that it may break on future OoklaServer releases, and cross-reference `docs/architecture/download-upload-size-controls.md`. +- **FR-022**: `README.md` MUST be updated: the `--help` snapshot refreshed and `--profile` documented in the options reference, with a one-line usage example. +- **FR-023**: `USER_GUIDE.md` MUST gain a "Choosing a profile" section with the budget table and decision guidance, including a dedicated warning callout for `Mega` explaining the undocumented-payload dependency. +- **FR-024**: `docs/architecture/download-upload-size-controls.md` MUST gain a section cross-referencing profiles to the per-request size tables and explicitly noting that `Mega` is the only profile relying on the bonus payloads. +- **FR-025**: A new Change Intent Record (CIR) MUST be filed under `docs/change-intent-records/` (not `docs/cir/`) documenting: (a) the public API addition (`Profile` enum and two new ctors); (b) the rationale for placing `Profile` in `NetPace.Core` rather than the Console layer; (c) the dependency direction (provider knows `Profile`; `Profile` knows no provider; entire mapping inline in the provider's settings ctor); (d) the move of `DownloadSizeMb`/`UploadSizeMb` into per-phase settings records and the deletion of the corresponding method overloads on `ISpeedTestService` and `OoklaSpeedtest`. +- **FR-026**: Release notes (auto-generated from PR titles per the repo's release process) MUST flag the new default profile and the per-run traffic reduction. Since there is no checked-in CHANGELOG.md, the breaking-change note belongs in the PR title/body so the GitHub-generated release notes pick it up. + +### Key Entities + +- **Profile** — Provider-agnostic vocabulary describing the *intent* of a test run. Lives in `NetPace.Core`. Five labels: `Tiny`, `Small`, `Medium`, `Large`, `Mega`. Carries no payload semantics on its own; each provider translates the label into its own settings record. +- **OoklaSpeedtestSettings** — Existing provider-specific root settings record in `NetPace.Core.Clients.Ookla`. Gains two new public constructors (parameterless → Medium; `Profile`-taking with inline switch). Instance state stays pure data — no `Profile` property. +- **DownloadTestSettings** / **UploadTestSettings** — Existing per-phase settings records. Each gains a `DownloadSizeMb` / `UploadSizeMb` property (default `int.MaxValue`) so profile values and `--downloadsize` / `--uploadsize` overrides flow through the settings record instead of method parameters. +- **ISpeedTestService** — Public interface in `NetPace.Core`. The `int sizeMb` overloads on `GetDownloadSpeedAsync` and `GetUploadSpeedAsync` are deleted; only the `(server, ct)` and `(server, IProgress, ct)` signatures survive per direction. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A user on a 10 MB/month data plan can run NetPace via `--profile tiny` and complete at least 30 runs/month within their cap (each run consumes ≤ 1 MiB end-to-end, ±10 %). +- **SC-002**: A user running NetPace with no flags transfers ≈ 121 MiB per run (±10 %) — a reduction of ≥ 65 % versus the previous default of ≈ 370 MiB — with no functional regression in reported download/upload speeds. +- **SC-003**: A power user running `--profile mega` against a fibre/inter-DC endpoint sustains transfer totalling ≈ 10 GiB per run (±10 %) and reaches steady-state throughput on a 10 Gbps link. +- **SC-004**: For every profile, the user can predict per-run total traffic from a single published table (USER_GUIDE.md "Choosing a profile") without reading source code. Verified by a documentation review: the table appears in `USER_GUIDE.md` and the per-profile rows match the test-asserted values. +- **SC-005**: For every profile, a unit test asserts the exact `DownloadTest` and `UploadTest` field values produced by `new OoklaSpeedtestSettings(profile)` — 5 profiles × per-field exact-equality. (Pass criterion: each profile's test is green; no field is left unasserted.) +- **SC-006**: Adding a second speed-test provider in future requires zero changes to `Profile` itself — the new provider's settings record adds its own `Profile`-taking constructor with its own inline switch. Verified structurally: the existing `Profile.cs` file has no provider import. +- **SC-007**: Help discoverability — `netpace --help` output displays `--profile` with the description "Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega)" and lists Medium as the default. Verified by a CLI snapshot test under `NetPace.Console.Tests` Expectations. + +## Assumptions + +- The local Docker OoklaServer (`docker/ooklaserver/`) reproduces the production server's behaviour faithfully enough for byte-budget verification in development, but no Docker-backed integration test is added — per confirmed decision, profile→settings mapping is covered by unit tests only. +- The historic Flash-client payload array `{350, 500, 750, 1000, 1500, 2000, 2500, 3000, 3500, 4000}` remains supported by the current Ookla server fleet for the lifetime of NetPace. +- The bonus payloads `5000`, `6000`, `7000` continue to be served by the OoklaServer endpoints used by `Mega`. Fallback strategy if they disappear is out of scope here (tracked in Open questions on the issue). +- `System.CommandLine` enum binding is already case-insensitive in the existing NetPace setup; no extra binder wiring is needed for `--profile`. +- The repo's release-notes generation (GitHub-auto from PR titles/labels) is sufficient to surface the breaking-change ISpeedTestService overload deletion and the default-traffic reduction to consumers — there is no checked-in CHANGELOG.md to maintain. +- NetPace is pre-1.0, so the deletion of `int sizeMb` overloads on `ISpeedTestService` and the change of the default profile (`Medium` instead of the prior hardcoded behaviour) are treated as routine breaking changes — flagged in the PR title and CIR, but not requiring a major-version step beyond normal semver progression. +- "Per-run transferred byte totals fall within ±10 % of the target" is verifiable in isolation by inspecting the settings record's `DownloadSizes`/iterations/parallel/cap fields; full end-to-end byte counting is captured by SC-001 through SC-003 but does not require a Docker integration test. +- The CIR storage path is `docs/change-intent-records/` (the existing authoritative directory) — the issue body's `docs/cir/` reference is treated as a typo and corrected. diff --git a/specs/003-profile-cli-switch/tasks.md b/specs/003-profile-cli-switch/tasks.md new file mode 100644 index 00000000..df16eac8 --- /dev/null +++ b/specs/003-profile-cli-switch/tasks.md @@ -0,0 +1,248 @@ +# Tasks: Add `--profile` CLI switch (Tiny/Small/Medium/Large/Mega) + +**Input**: Design documents from `/specs/003-profile-cli-switch/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md, test-plan.md + +**Tests**: REQUIRED by constitution principle I (TDD is non-negotiable). Every public-API surface introduced here must be added test-first (RED → GREEN → REFACTOR). Every test that implements a `**Scenario:**` from spec.md MUST carry a matching `// SCENARIO:` comment (see `test-plan.md` for the canonical names). + +**Organization**: Tasks are grouped by user story. The five profile arms live in one inline switch in one file, so the *implementation* code is largely shared (Phase 2 Foundational), while the *acceptance tests* and *adjacent docs* are sliced per user story (Phases 3–7). Within each user-story phase, write the test first, watch it fail, then implement the corresponding switch arm and any per-story docs — the constitution applies whether or not the implementation file is shared. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4, US5) +- File paths are absolute or anchored at the repo root. + +--- + +## Phase 1: Setup + +**Purpose**: Confirm the working environment is sane before changing public-API surface. + +- [X] T001 Confirm branch `003-profile-cli-switch` is checked out and the baseline `dotnet build` and `dotnet test` both succeed against `main`'s current behaviour (no warnings, all tests green) — establishes the green baseline that subsequent RED tests must move from. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Type / file / wiring scaffold that every user story depends on — the `Profile` enum, the relocated cap properties, the deleted method overloads, the two new public constructors, and the `--profile` CLI binding. Tests in this phase cover invariants that span all user stories (no story label); per-story acceptance tests live in Phases 3–7. + +**⚠️ CRITICAL**: No user-story work begins until Phase 2 is complete. + +- [X] T002 [P] Add the public `Profile` enum at [src/NetPace.Core/Profile.cs](src/NetPace.Core/Profile.cs) with five members `Tiny`, `Small`, `Medium`, `Large`, `Mega` and XML documentation on every member. The `Profile.Mega` XML doc carries a one-line undocumented-payload caveat in this phase; the fuller cross-referenced warning is finalised in T017 (US3). +- [X] T003 [P] Add `DownloadSizeMb { get; init; } = int.MaxValue;` to [src/NetPace.Core/Clients/Ookla/Settings/DownloadTestSettings.cs](src/NetPace.Core/Clients/Ookla/Settings/DownloadTestSettings.cs) with XML `` and `` disambiguating it from `DownloadSizes` (per data-model.md). +- [X] T004 [P] Add `UploadSizeMb { get; init; } = int.MaxValue;` to [src/NetPace.Core/Clients/Ookla/Settings/UploadTestSettings.cs](src/NetPace.Core/Clients/Ookla/Settings/UploadTestSettings.cs) with XML `` and `` (per data-model.md). +- [X] T005 Delete the four `int sizeMb` overloads on [src/NetPace.Core/ISpeedTestService.cs](src/NetPace.Core/ISpeedTestService.cs) listed in `contracts/speedtestservice-surface.md` — leaves the `(server, ct)` and `(server, IProgress, ct)` overloads per direction; expect compile errors in `OoklaSpeedtest` and `Program.cs` until T006 and T013 land. +- [X] T006 Delete the matching overloads on [src/NetPace.Core/Clients/Ookla/OoklaSpeedtest.cs](src/NetPace.Core/Clients/Ookla/OoklaSpeedtest.cs); rewire the internal `maxBytes`-cap branch in `GenericTestSpeedAsync` (or equivalent loop) to read `settings.DownloadTest.DownloadSizeMb` / `settings.UploadTest.UploadSizeMb` from the settings record set at construction; update XML docs on surviving methods to remove references to the deleted `int sizeMb` parameter. +- [X] T007 [P] Write structural test file [src/NetPace.Core.Tests/ProfileTests.cs](src/NetPace.Core.Tests/ProfileTests.cs) with: (a) namespace assertion — `typeof(NetPace.Core.Profile).Namespace == "NetPace.Core"`; (b) reflection assertion — no static method on any type in the `NetPace.Core` assembly takes `Profile` as its first parameter and returns a type whose namespace starts with `NetPace.Core.Clients`; (c) reflection assertion — no type named `OoklaSpeedtestSettingsExtensions` or `OoklaProfileExtensions` exists in the assembly; (d) file-existence assertion — `src/NetPace.Core/Profile.cs` exists at exactly that path. Carries the matching `// SCENARIO:` comments where these assertions cover US5 scenarios — see Phase 7. +- [X] T008 [P] Write [src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs](src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs) covering the cross-story invariants: (a) `new OoklaSpeedtestSettings()` produces a record equal to `new OoklaSpeedtestSettings(Profile.Medium)`; (b) `new OoklaSpeedtestSettings((Profile)999)` throws `ArgumentOutOfRangeException` with `ParamName == "profile"`; (c) `new OoklaSpeedtestSettings(Profile.Mega) with { UseProxy = true }` preserves Mega's `DownloadTest`/`UploadTest` field values and sets `UseProxy == true`; (d) `OoklaSpeedtestSettings` instance state has no `Profile` property (reflection). Each method carries the matching `// SCENARIO:` comment for its US2/US5 scenario name — see Phases 4 and 7. +- [X] T009 [P] Write [src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs](src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs) as a partial-class extension of T008's file, containing one `[Fact]` per profile that asserts every field of `DownloadTest` and `UploadTest` exactly matches the data-model.md tables. The Tiny/Small/Mega tests carry `// SCENARIO:` comments matching the test-plan names; Medium and Large get plain field-equality asserts without a scenario tag (they cover requirements FR-002..FR-008, not labelled spec scenarios). Each `[Fact]` is independently runnable. +- [X] T010 Implement both new public constructors on [src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs](src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs) per the declaration in `contracts/ooklasettings-ctors.md`: parameterless ctor chains via `: this(Profile.Medium) { }`; `OoklaSpeedtestSettings(Profile profile)` contains the entire profile → settings mapping as one inline switch expression with all five arms populated from data-model.md, plus a `_ => throw new ArgumentOutOfRangeException(nameof(profile))` default. Remove the existing `= new();` initializers from the `DownloadTest` and `UploadTest` property declarations (now set by the constructor). Verify T007–T009 turn GREEN. +- [X] T011 Add `public Profile Profile { get; init; } = Profile.Medium;` to [src/NetPace.Console/Commands/SpeedTestCommandSettings.cs](src/NetPace.Console/Commands/SpeedTestCommandSettings.cs) alongside the existing option-binding properties (mirroring `UnitSystem`, `UnitScale`, etc.). +- [X] T012 [P] Write [src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs](src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs) using the existing `CommandLineTestHost` pattern, covering the cross-story CLI invariants: case-insensitive enum parsing (`--profile TINY`, `--profile Tiny`, `--profile tiny`); unknown value rejection (`--profile huge` exits non-zero); option binding produces `SpeedTestCommandSettings.Profile == Profile.Tiny` for `--profile tiny`. (Per-scenario CLI tests covering specific user stories live in Phases 3–7 and extend this file.) +- [X] T013 Add `var profileOption = new Option("--profile") { Description = "Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega).", DefaultValueFactory = _ => Profile.Medium };` to [src/NetPace.Console/Program.cs](src/NetPace.Console/Program.cs) next to the existing `--unit-system` declaration; register it on the root command; bind onto `SpeedTestCommandSettings.Profile`. Rewire the `OoklaSpeedtestSettings` construction (currently at the per-issue-body L232–233 site) from "parameterless ctor + with-override of `DownloadTest`/`UploadTest`" to `new OoklaSpeedtestSettings(commandSettings.Profile)`; apply conditional `with { DownloadTest = settings.DownloadTest with { DownloadSizeMb = N } }` only when `--downloadsize` is explicitly supplied; same for `--uploadsize`; remove the now-deleted `int sizeMb` arguments from `GetDownloadSpeedAsync` / `GetUploadSpeedAsync` call sites. Verify T012 turns GREEN. +- [X] T014 [P] Refresh the `--help` Verify snapshot(s) under [src/NetPace.Console.Tests/Expectations/](src/NetPace.Console.Tests/Expectations/) so the `--profile ` line (with `[default: Medium]`) is included. Run the affected `NetPaceConsoleTests` snapshot tests, accept the diff into the `.verified.txt` file(s). + +**Checkpoint**: Public API surface, CLI binding, and cross-story invariant tests are all in place. `dotnet build` is warning-free; `dotnet test` is green. Profile values are field-for-field correct for all five profiles. + +--- + +## Phase 3: User Story 1 — Run NetPace on a constrained data plan without busting the cap (Priority: P1) 🎯 MVP + +**Goal**: Users on metered / IoT plans (Tiny ≤ 1 MiB total per run; Small ≤ 12 MiB total per run) can run NetPace within their data budget. The profile is authoritative for per-request payload size, so no individual download request fetches a full 4000-pixel JPEG when Tiny is selected. + +**Independent Test**: Construct `new OoklaSpeedtestSettings(Profile.Tiny)`, assert all eight `DownloadTest`/`UploadTest` fields match the Tiny budget table from data-model.md; same for Small. Verify a CLI binding via `--profile tiny` produces no `DownloadSizes` entry > 350. + +- [X] T015 [P] [US1] Add a `[Fact]` method to [src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs](src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs) carrying `// SCENARIO: Tiny profile stays within IoT budget`. Assert `DownloadSizes == [350]`, `DownloadSizeIterations == 1`, `DownloadParallelTasks == 1`, `DownloadSizeMb == 1`, and the equivalent four Upload fields per data-model.md. Add an in-method comment recording the natural-transfer budget proxy (≤ 1 MiB total = 245 KB down + 50 KB up ±10 %) for future readers; do not assert this at runtime (per D8 — no Docker integration test). +- [X] T016 [P] [US1] Add a `[Fact]` method to the same partial file carrying `// SCENARIO: Small profile suits cellular`. Assert Small's eight field values per data-model.md; record the ≤ 12 MiB total proxy in a comment. +- [X] T017 [US1] Add a `[Fact]` method to [src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs](src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs) carrying `// SCENARIO: Profile is authoritative for per-request shape`. Invoke the option-binding code path with `--profile tiny`, intercept the constructed `OoklaSpeedtestSettings` (the existing `CommandLineTestHost` pattern provides a hook or test seam — extend if not present), assert `settings.DownloadTest.DownloadSizes` is exactly `[350]`, `DownloadParallelTasks == 1`, `DownloadSizeIterations == 1`, and assert by `.All(s => s <= 350)` that no entry exceeds 350. + +**Checkpoint**: Tiny and Small profiles are usable end-to-end via the CLI; per-request shape authority is verified. US1 deliverable complete. + +--- + +## Phase 4: User Story 2 — Get a sensible default without thinking about it (Priority: P1) + +**Goal**: `netpace` with no flags runs Medium (~121 MiB total), a ≥ 65 % traffic reduction from the prior ~370 MiB default. The parameterless `OoklaSpeedtestSettings()` ctor chains to `Profile.Medium` as the single source of truth. + +**Independent Test**: `netpace` with no `--profile` binding constructs `new OoklaSpeedtestSettings(Profile.Medium)` field-for-field; `new OoklaSpeedtestSettings()` parameterless matches the same; Medium's settings imply ≤ 130 MiB total transfer. + +- [X] T018 [P] [US2] Add a `[Fact]` method to [src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs](src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs) carrying `// SCENARIO: Omitted --profile defaults to Medium`. Invoke the CLI with no `--profile` flag; assert the constructed `OoklaSpeedtestSettings` is equal (record equality) to `new OoklaSpeedtestSettings(Profile.Medium)`; assert `DownloadSizes == [1500, 2000, 3000, 3500, 4000]`, `DownloadSizeMb == 100`, `UploadSizeMb == 25`. +- [X] T019 [P] [US2] Locate the parameterless-ctor test method already written in T008 (Phase 2) at [src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs](src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs); confirm or add the `// SCENARIO: Parameterless ctor chains to Medium` comment so `/speckit.testchecklist` can find it; assert all 8 fields under `DownloadTest` and `UploadTest` are field-for-field identical to Medium-profile values (extend the T008 assertion if it only asserts record equality). +- [X] T020 [P] [US2] Add a `[Fact]` method to [src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs](src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs) carrying `// SCENARIO: Default-run traffic drops vs pre-change baseline`. Compute Medium's natural transfer-budget proxy from its settings (e.g. `(DownloadSizes.Length * DownloadSizeIterations * DownloadParallelTasks * AverageRequestBytes)` bounded by `DownloadSizeMb * 1024 * 1024` — use the per-request bytes derived from `docs/architecture/download-upload-size-controls.md` and committed to as test constants). Assert: the implied total is ≤ 130 MiB (i.e. ≤ 65 % of the 370 MiB prior baseline). Record in a comment that the "no functional regression in reported speeds" portion is covered by existing `OoklaSpeedtest` tests passing under the new defaults. + +**Checkpoint**: Default-run traffic reduction verified; parameterless ctor's single-source-of-truth chain to Medium is locked in. + +--- + +## Phase 5: User Story 3 — Saturate a 10 Gbps inter-DC link (Priority: P2) + +**Goal**: `--profile mega` issues requests for the bonus payloads (5000/6000/7000) and pushes ~10 GiB total per run, enabling saturation of inter-DC fibre. The Mega-specific risk is documented in XML and cross-referenced to the architecture doc. + +**Independent Test**: `new OoklaSpeedtestSettings(Profile.Mega).DownloadTest.DownloadSizes` includes 5000, 6000, and 7000; the assembly's XML doc file contains the undocumented-payload caveat for `Profile.Mega`. + +- [X] T021 [US3] Expand the XML documentation on the `Profile.Mega` enum member at [src/NetPace.Core/Profile.cs](src/NetPace.Core/Profile.cs) to the full text required by FR-021: must contain the word "undocumented" (case-insensitive), explicitly name `5000`, `6000`, and `7000`, warn that future OoklaServer releases may break it, and cross-reference `docs/architecture/download-upload-size-controls.md`. Reuse the model text from `contracts/profile-enum.md`. +- [X] T022 [P] [US3] Add a `[Fact]` method to [src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs](src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs) carrying `// SCENARIO: Mega uses bonus payloads`. Assert `new OoklaSpeedtestSettings(Profile.Mega).DownloadTest.DownloadSizes` contains `5000`, contains `6000`, and contains `7000` (three separate asserts so a single failure pinpoints the missing value). +- [X] T023 [P] [US3] Add a second `[Fact]` method to the same partial file carrying `// SCENARIO: Mega regression guard`. Make the assertion explicit-regression-style: collect the absent values into a list and fail with a message like `$"Mega.DownloadSizes is missing bonus payloads: {string.Join(',', missing)} — see contracts/ooklasettings-ctors.md"`. Functionally equivalent to T022 but with the "named missing values" failure mode required by the test-plan scenario. +- [X] T024 [P] [US3] Add a new test file [src/NetPace.Core.Tests/ProfileXmlDocTests.cs](src/NetPace.Core.Tests/ProfileXmlDocTests.cs) with a single `[Fact]` carrying `// SCENARIO: Mega's bonus-payload dependency is documented`. Load `NetPace.Core.xml` from the test-bin output directory (the file is generated next to the assembly when `GenerateDocumentationFile` is enabled); parse the XML; locate the `` node; assert the summary text contains "undocumented" (case-insensitive), `"5000"`, `"6000"`, `"7000"`, and `"download-upload-size-controls"`. Ensure `NetPace.Core.csproj` has `GenerateDocumentationFile=true` (it likely already does — verify and only edit if missing). + +**Checkpoint**: Mega's bonus-payload dependency is technically present, structurally guarded against silent demotion, and explicitly documented. + +--- + +## Phase 6: User Story 4 — Choose a profile, then override one cap (Priority: P2) + +**Goal**: `--profile X --downloadsize N` / `--profile X --uploadsize N` overrides only the relevant cap via `with`-expression; the profile remains authoritative for per-request shape. `--no-download` / `--no-upload` continue to short-circuit phases regardless of profile. + +**Independent Test**: `netpace --profile tiny --downloadsize 5` produces a settings record with Tiny's `DownloadSizes`/iterations/parallel and `DownloadSizeMb == 5`. `netpace --no-download --profile large` short-circuits download. + +- [X] T025 [P] [US4] Add a `[Fact]` method to [src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs](src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs) carrying `// SCENARIO: --downloadsize overrides only the cap, profile shape is preserved`. Invoke the CLI with `--profile tiny --downloadsize 5`; assert `settings.DownloadTest.DownloadSizes == [350]`, `DownloadSizeIterations == 1`, `DownloadParallelTasks == 1`, and `DownloadSizeMb == 5` (override applied). +- [X] T026 [P] [US4] Add a `[Fact]` method to the same file carrying `// SCENARIO: --uploadsize overrides only the upload cap`. Invoke the CLI with `--profile small --uploadsize 1`; assert `settings.UploadTest.UploadSizeIncrementKb == 100`, `UploadIncrements == 4`, `UploadSizeIterations == 2`, `UploadParallelTasks == 2`, and `UploadSizeMb == 1`. +- [X] T027 [P] [US4] Add a `[Fact]` method to the same file carrying `// SCENARIO: Override cap larger than natural transfer is a no-op backstop`. Invoke the CLI with `--profile tiny --downloadsize 5000`; assert `settings.DownloadTest.DownloadSizeMb == 5000` (override mechanically present on the record). In a comment, record that the natural-transfer ≤ cap so the runtime cap-check never trips — verified by Tiny's natural-budget assertion in T015, not re-tested here (no Docker integration test per D8). +- [X] T028 [P] [US4] Add a `[Fact]` method to the same file carrying `// SCENARIO: --no-download short-circuits regardless of profile`. Invoke the CLI with `--no-download --profile large`; assert the resulting run reports zero bytes transferred for the download phase (use the existing `--no-download` test pattern in `NetPaceConsoleTests.cs` as a template); assert `settings.UploadTest.UploadSizeIncrementKb == 500` (Large's value) and `UploadParallelTasks == 16` (Large's value). + +**Checkpoint**: Profile-shape authority + cap-override interaction verified across both directions and both override mechanisms (`--downloadsize`/`--uploadsize` and `--no-download`/`--no-upload`). + +--- + +## Phase 7: User Story 5 — Library consumer uses Profile from NetPace.Core (Priority: P2) + +**Goal**: NuGet consumers can construct settings directly from `Profile` without going through the CLI; `Profile` itself is provider-agnostic; `with`-expression composition works cleanly; invalid `Profile` values fail loudly. + +**Independent Test**: All three US5 scenarios are covered by foundational tests written in T007 (Profile-location structural) and T008 (`with`-expression composition; invalid-profile-throws). This phase verifies traceability comments are correctly attached and adds any test that is missing. + +- [X] T029 [US5] Verify the test method in T007's [src/NetPace.Core.Tests/ProfileTests.cs](src/NetPace.Core.Tests/ProfileTests.cs) that asserts namespace, no-provider-extension-methods, no-helper-class, and file-existence carries the exact `// SCENARIO: Profile enum is provider-agnostic and at the root of NetPace.Core` comment. If T007 split the assertions across multiple methods, attach the comment to whichever method asserts the namespace + file-existence (the most representative). If absent, add it. +- [X] T030 [US5] Verify the `with`-expression composition test method in T008's [src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs](src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs) carries the exact `// SCENARIO: \`with\` expression composes cleanly on profile-built record` comment (note the backticks in the scenario name — preserve them character-for-character). Strengthen the assertions if T008 only covered `UseProxy`: also assert `s.DownloadTest.DownloadSizes` contains `5000`, `6000`, `7000`, `s.DownloadTest.DownloadParallelTasks == 32`, and `s.UploadTest.UploadSizeIncrementKb == 1024` (Mega's values per data-model.md). +- [X] T031 [US5] Verify the invalid-profile-throws test method in T008 carries the exact `// SCENARIO: Construct invalid profile throws` comment. Assertion must check both that `ArgumentOutOfRangeException` is thrown and that `ParamName == "profile"`. + +**Checkpoint**: All 16 spec scenarios have a labelled test with a matching `// SCENARIO:` comment. `/speckit.testchecklist` (if run) should report 0 untraced scenarios. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: User-facing documentation, the Change Intent Record, and final-build verification. These touch shared files (`README.md`, `USER_GUIDE.md`, the architecture doc) but are content-additive, so independent `[P]` writers can work in parallel as long as they target different files. + +- [X] T032 [P] Update [README.md](README.md): refresh the `--help` snapshot block, add a `--profile` row to the options reference table (with the five enum values and `Medium` default), and add a one-line usage example (`netpace --profile tiny`). +- [X] T033 [P] Add a "Choosing a profile" section to [USER_GUIDE.md](USER_GUIDE.md) including the budget table from data-model.md and decision guidance (cellular → Small; fibre → Large; 10 Gbps DC → Mega). Include a dedicated warning callout for `Mega` mirroring the XML doc text from T021. +- [X] T034 [P] Add a new cross-reference section to [docs/architecture/download-upload-size-controls.md](docs/architecture/download-upload-size-controls.md) mapping each profile to its per-request payload sizes (using the data-model.md tables). Explicitly note that `Mega` is the only profile relying on the bonus `5000/6000/7000` payloads, and that the documented fallback strategy (revert to historic-10 with higher iterations) is tracked but not implemented in this change. +- [X] T035 Create a new Change Intent Record at `docs/change-intent-records/CIR-NNN-profile-cli-switch.md` (replace `NNN` with the next sequential CIR number after scanning the directory). Document: (a) the public API addition — `Profile` enum, two new `OoklaSpeedtestSettings` constructors; (b) rationale for placing `Profile` in `NetPace.Core` rather than under `Clients/Ookla/`; (c) the dependency direction — provider knows `Profile`, `Profile` knows no provider, entire mapping inline; (d) the move of `DownloadSizeMb`/`UploadSizeMb` from method parameters into `DownloadTestSettings`/`UploadTestSettings`, the deletion of the corresponding overloads on `ISpeedTestService` and `OoklaSpeedtest`, and that this is an accepted pre-1.0 breaking change to the public NuGet contract. Mirror the structure of existing CIRs already in `docs/change-intent-records/`. +- [X] T036 Run final verification: `dotnet build` is warning-free; `dotnet test` is green across all three test projects (`NetPace.Core.Tests`, `NetPace.Console.Tests`, plus the legacy `NetPace.Tests` if still present); the PR title carries a breaking-change marker (e.g. `feat!:` or explicit `BREAKING CHANGE:` body line) so the auto-generated release notes pick it up. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies. +- **Phase 2 (Foundational)**: depends on Phase 1. Blocks all user-story phases. +- **Phases 3–7 (User Stories)**: each depends on Phase 2 completing. After Phase 2 they are mostly independent — different developers can pick up different user stories — except that Phases 4, 6 add tests to `NetPaceConsoleTests.Profile.cs` (created in T012); Phases 3, 5 add tests to `OoklaSpeedtestSettingsTests.Profiles.cs` (created in T009). Coordinate within file or merge in any order — the partial-class split absorbs concurrent edits naturally. +- **Phase 8 (Polish)**: depends on Phases 3–7 being complete (so the documented behaviour matches the implemented behaviour). + +### User Story Dependencies + +- **US1 (P1)**: depends on Phase 2 only. Independently shippable as MVP. +- **US2 (P1)**: depends on Phase 2 only. Independently shippable. +- **US3 (P2)**: depends on Phase 2 only. Mega's XML doc expansion in T021 is the one piece of US3-specific *implementation*; the rest is tests. +- **US4 (P2)**: depends on Phase 2 only. Override interaction is fully implemented in T013; this phase only adds the acceptance tests. +- **US5 (P2)**: depends on T007 and T008 (sub-tasks of Phase 2). Phase 7 itself only verifies / strengthens scenario-comment traceability. + +### Within Each User Story + +- Tests are written first (TDD per constitution I) — write the test, run it, see it RED, then implement (where there's anything to implement beyond the foundational switch arms). +- For US1, US2, US4: there is no per-story implementation beyond the tests — the switch arms are foundational. The TDD cycle for each scenario is: write test → see GREEN immediately if the foundational implementation is correct, or RED → fix the relevant switch arm in T010 if a value is wrong. +- For US3: T021 (XML doc expansion) and T024 (XML reflection test) form a TDD pair — write T024 first, watch it fail because the doc text doesn't yet contain the required substrings, then expand the doc in T021. + +### Parallel Opportunities + +- All `[P]` tasks within a phase target different files and have no dependencies on incomplete tasks. +- Phase 2 has 6 parallel tasks (T002, T003, T004, T007, T008, T009) plus three sequential ones (T005 → T006 → T010 → T013, due to compile-graph dependencies). +- Phases 3 and 4 can run in parallel after Phase 2. +- Phases 5, 6, 7 can run in parallel after Phase 2. +- Phase 8's four `[P]` tasks (T032, T033, T034 are different files; T035 is a new file — also `[P]`-compatible) can run in parallel; T036 is sequential at the end. + +--- + +## Parallel Example: Phase 2 Foundational + +```bash +# These tasks edit different files with no compile-graph dependencies — run together: +Task: T002 — Create src/NetPace.Core/Profile.cs +Task: T003 — Add DownloadSizeMb to DownloadTestSettings.cs +Task: T004 — Add UploadSizeMb to UploadTestSettings.cs +Task: T007 — Write src/NetPace.Core.Tests/ProfileTests.cs +Task: T008 — Write src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs +Task: T009 — Write src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs + +# Then T005 → T006 → T010 must run sequentially (each depends on the previous compiling): +Task: T005 — Delete int sizeMb overloads on ISpeedTestService.cs +Task: T006 — Delete matching overloads on OoklaSpeedtest.cs; rewire cap-read +Task: T010 — Implement OoklaSpeedtestSettings ctors with inline switch + +# T011, T013 follow: +Task: T011 — Add Profile property to SpeedTestCommandSettings.cs +Task: T013 — Wire Option in Program.cs +Task: T014 — Refresh --help Verify snapshot +``` + +## Parallel Example: Phases 3 + 5 + +```bash +# After Phase 2 is complete, US1 and US3 can run side-by-side: +Task: T015 [US1] — Tiny budget test +Task: T016 [US1] — Small cellular test +Task: T017 [US1] — Profile-authoritative CLI test +# In parallel: +Task: T021 [US3] — Mega XML doc expansion +Task: T022 [US3] — Mega bonus-payloads test +Task: T023 [US3] — Mega regression guard +Task: T024 [US3] — Mega XML doc reflection test +``` + +--- + +## Implementation Strategy + +### MVP Scope (User Story 1 only) + +1. Complete Phase 1 (Setup) — T001. +2. Complete Phase 2 (Foundational) — T002 through T014. This gives you the full `Profile` enum, all five switch arms, the CLI flag, and cross-story invariant tests. +3. Complete Phase 3 (US1) — T015, T016, T017. +4. STOP and validate: run `netpace --profile tiny` against a reachable Ookla server. Confirm reported bytes ≤ 1 MiB. Demo to the constrained-plan user persona. +5. Ship as MVP if ready — Tiny and Small users immediately benefit; Medium becomes the new default automatically. + +Note: because Phase 2 implements all five profile arms (one switch expression), Medium / Large / Mega values are present and correct at MVP. They just aren't yet covered by their own acceptance tests until Phases 4–6. + +### Incremental Delivery + +1. Setup + Foundational → public API surface and CLI flag live, defaults shifted to Medium. +2. + US1 (Phase 3) → constrained-plan users are first-class. **MVP shippable.** +3. + US2 (Phase 4) → default-traffic-reduction is formally verified. Ship. +4. + US3 (Phase 5) → Mega users get the documented bonus-payload path. Ship. +5. + US4 (Phase 6) → cap-override interaction is formally verified. Ship. +6. + US5 (Phase 7) → traceability checkpoints close. Ship. +7. + Polish (Phase 8) → docs and CIR land. Final PR review. + +### Parallel Team Strategy + +If multiple developers are available after Phase 2: + +- Dev A: US1 (Phase 3) → US2 (Phase 4) +- Dev B: US3 (Phase 5) +- Dev C: US4 (Phase 6) +- Dev D: US5 (Phase 7) + Phase 8 docs + +US3 has the most isolated work (touches only Profile.cs's XML doc + new test file); good first-pick for a parallel developer. + +--- + +## Notes + +- `[P]` = different files, no dependencies on incomplete tasks. +- `[Story]` label maps task to a user story; no Story label on Setup / Foundational / Polish tasks. +- Per the constitution, every public API addition is added test-first. The Phase-2 foundational tests (T007, T008, T009) cover the cross-story invariants; the per-user-story tests (Phases 3–7) cover the scenario-specific framings. +- Every test method that implements a spec scenario MUST carry a `// SCENARIO: ` comment matching `test-plan.md` character-for-character — verified by `/speckit.testchecklist`. +- The 5-profile inline switch is implemented atomically in T010. Per-user-story phases do not edit T010's switch beyond what's listed (US3's only impl task is T021 — Mega's XML doc, not its switch arm). +- Commit at each Phase checkpoint (after Phase 2, after each user-story phase) — keeps the bisect graph clean. +- The PR title MUST flag the breaking change to `ISpeedTestService` (deletion of four `int sizeMb` overloads). There is no `CHANGELOG.md` to maintain — release notes auto-generate from PR titles. diff --git a/specs/003-profile-cli-switch/test-plan.md b/specs/003-profile-cli-switch/test-plan.md new file mode 100644 index 00000000..cb779484 --- /dev/null +++ b/specs/003-profile-cli-switch/test-plan.md @@ -0,0 +1,173 @@ +# Test Plan — Add `--profile` CLI switch (Tiny/Small/Medium/Large/Mega) + +## Coverage summary + +| User Story | Primary | Alternate | Error | Boundary | Recovery | Non-functional | Total | +|---|---|---|---|---|---|---|---| +| Run NetPace on a constrained data plan without busting the cap | ✓ | ✓ | ⚠ | — | — | ✓ | 3 | +| Get a sensible default without thinking about it | ✓ | ✓ | ⚠ | — | — | ✓ | 3 | +| Saturate a 10 Gbps inter-DC link | ✓ | ✓ | — | ✓ | — | ✓ | 3 | +| Choose a profile, then override one cap | ✓ | ✓ | — | ✓ | — | — | 4 | +| Library consumer uses Profile from NetPace.Core | ✓ | ✓ | ✓ | — | — | — | 3 | + +**Flags:** + +- **Run NetPace on a constrained data plan**: no Error scenario in this story's labelled acceptance set. The `Edge Cases` section of spec.md describes invalid-`--profile` rejection (FR-013) and unknown-enum-value handling — consider adding a labelled `**Scenario:**` for it if you want it traceable from this test plan. +- **Get a sensible default without thinking about it**: no Error scenario. Same situation as above; both default-path stories rely on the well-formed CLI grammar working correctly. +- **Mega regression guard** has been classified as Boundary because it pins the high-end payload set against silent demotion — it functions as a guard rail rather than an error case, but it lives at the upper edge of the value space. + +The two ⚠ flags reflect that error-class coverage for the CLI flag (unknown `--profile` value) is covered only at the requirements level (FR-013) and not in a labelled acceptance scenario. This is an acceptable specification trade-off — `System.CommandLine`'s default unknown-enum-value error is well-known behaviour — but if the team wants it tested explicitly, the spec should add a labelled scenario. + +--- + +### User Story: Run NetPace on a constrained data plan without busting the cap + +A user on a metered or IoT plan picks `--profile tiny` (or `--profile small`) and runs NetPace within their data budget. + +#### Scenario: Tiny profile stays within IoT budget +- **WHEN** the CLI is invoked as `netpace --profile tiny` against a reachable Ookla server (or the local Docker OoklaServer) and the run completes successfully +- **THEN** total transferred bytes for the run are ≤ 1 MiB +- **AND** the bytes returned by the download phase fall within ±10 % of 245 KB (i.e. ~220 KB to ~270 KB) +- **AND** the bytes returned by the upload phase fall within ±10 % of 50 KB (i.e. ~45 KB to ~55 KB) +- **AND** the CLI exit code is `0` + +#### Scenario: Small profile suits cellular +- **WHEN** the CLI is invoked as `netpace --profile small` against a reachable Ookla server and the run completes successfully +- **THEN** total transferred bytes for the run are ≤ 12 MiB (within ±10 % of the ~10 MiB down + ~2 MiB up target) +- **AND** the CLI exit code is `0` + +#### Scenario: Profile is authoritative for per-request shape +- **WHEN** the CLI is invoked as `netpace --profile tiny` and the constructed `OoklaSpeedtestSettings` record is inspected after binding +- **THEN** `settings.DownloadTest.DownloadSizes` is exactly `[350]` +- **AND** `settings.DownloadTest.DownloadParallelTasks` is exactly `1` +- **AND** `settings.DownloadTest.DownloadSizeIterations` is exactly `1` +- **AND** no `DownloadSizes` entry larger than `350` is present (i.e. no full 4000-pixel JPEG request is generated) + +--- + +### User Story: Get a sensible default without thinking about it + +A user runs `netpace` with no flags and gets a Medium-profile run (~121 MiB total), down from the prior ~370 MiB. + +#### Scenario: Omitted --profile defaults to Medium +- **WHEN** the CLI is invoked as `netpace` with no `--profile` flag and option binding completes +- **THEN** the `OoklaSpeedtestSettings` record built inside `Program.RunAsync` is equal (record equality) to `new OoklaSpeedtestSettings(Profile.Medium)` +- **AND** `settings.DownloadTest.DownloadSizes` is exactly `[1500, 2000, 3000, 3500, 4000]` +- **AND** `settings.DownloadTest.DownloadSizeMb` is exactly `100` +- **AND** `settings.UploadTest.UploadSizeMb` is exactly `25` + +#### Scenario: Parameterless ctor chains to Medium +- **WHEN** library code calls `new OoklaSpeedtestSettings()` with no argument +- **THEN** the resulting record is equal (record equality) to `new OoklaSpeedtestSettings(Profile.Medium)` +- **AND** all 8 fields under `DownloadTest` and `UploadTest` are field-for-field identical to the Medium-profile values + +#### Scenario: Default-run traffic drops vs pre-change baseline +- **WHEN** the CLI is invoked as `netpace` with no flags against a reachable Ookla server and the run completes successfully +- **THEN** the total transferred bytes reported for the run fall within ±10 % of 121 MiB +- **AND** the reported total is at least 65 % lower than the prior ~370 MiB default baseline (i.e. ≤ 130 MiB) +- **AND** the run's reported download and upload speed values are within the normal range produced by the prior default settings on the same link (no functional regression) + +--- + +### User Story: Saturate a 10 Gbps inter-DC link + +A power user runs `--profile mega` to push enough traffic to reach steady-state on 10 Gbps fibre. + +#### Scenario: Mega uses bonus payloads +- **WHEN** `new OoklaSpeedtestSettings(Profile.Mega)` is constructed +- **THEN** `settings.DownloadTest.DownloadSizes` contains `5000` +- **AND** `settings.DownloadTest.DownloadSizes` contains `6000` +- **AND** `settings.DownloadTest.DownloadSizes` contains `7000` + +#### Scenario: Mega's bonus-payload dependency is documented +- **WHEN** the assembly's XML documentation file (`NetPace.Core.xml`) is parsed for the `Profile.Mega` member +- **THEN** the doc text for `Profile.Mega` contains the substring `undocumented` (case-insensitive) +- **AND** the doc text references `5000`, `6000`, and `7000` +- **AND** the doc text references `docs/architecture/download-upload-size-controls.md` + +#### Scenario: Mega regression guard +- **WHEN** the unit test asserting `new OoklaSpeedtestSettings(Profile.Mega).DownloadTest.DownloadSizes` runs against a future build +- **THEN** the assertion that the set contains `5000` passes +- **AND** the assertion that the set contains `6000` passes +- **AND** the assertion that the set contains `7000` passes +- **AND** if any of `5000`, `6000`, or `7000` is absent, the test fails with a message naming which value(s) are missing + +--- + +### User Story: Choose a profile, then override one cap + +A user wants Large's request shape but caps total download lower (e.g. `--profile large --downloadsize 100`). + +#### Scenario: --downloadsize overrides only the cap, profile shape is preserved +- **WHEN** the CLI is invoked as `netpace --profile tiny --downloadsize 5` and option binding completes +- **THEN** `settings.DownloadTest.DownloadSizes` is exactly `[350]` (Tiny's per-request shape) +- **AND** `settings.DownloadTest.DownloadSizeIterations` is exactly `1` (Tiny's value) +- **AND** `settings.DownloadTest.DownloadParallelTasks` is exactly `1` (Tiny's value) +- **AND** `settings.DownloadTest.DownloadSizeMb` is exactly `5` (the override) + +#### Scenario: --uploadsize overrides only the upload cap +- **WHEN** the CLI is invoked as `netpace --profile small --uploadsize 1` and option binding completes +- **THEN** `settings.UploadTest.UploadSizeIncrementKb` is exactly `100` (Small's per-request shape) +- **AND** `settings.UploadTest.UploadIncrements` is exactly `4` (Small's value) +- **AND** `settings.UploadTest.UploadSizeIterations` is exactly `2` (Small's value) +- **AND** `settings.UploadTest.UploadParallelTasks` is exactly `2` (Small's value) +- **AND** `settings.UploadTest.UploadSizeMb` is exactly `1` (the override) + +#### Scenario: Override cap larger than natural transfer is a no-op backstop +- **WHEN** the CLI is invoked as `netpace --profile tiny --downloadsize 5000` against a reachable Ookla server and the run completes successfully +- **THEN** `settings.DownloadTest.DownloadSizeMb` on the constructed record is exactly `5000` +- **AND** the total transferred download bytes for the run fall within ±10 % of Tiny's natural 245 KB target (cap never triggers because Tiny completes well below 5000 MiB) +- **AND** the CLI exit code is `0` + +#### Scenario: --no-download short-circuits regardless of profile +- **WHEN** the CLI is invoked as `netpace --no-download --profile large` against a reachable Ookla server +- **THEN** the CLI output reports zero bytes transferred for the download phase +- **AND** the upload phase still uses Large's per-request shape (parallel tasks = 16, increments = 8, increment Kb = 500) +- **AND** the CLI exit code is `0` + +--- + +### User Story: Library consumer uses Profile from NetPace.Core + +A NuGet consumer constructs settings from `Profile` directly without the CLI. + +#### Scenario: Profile enum is provider-agnostic and at the root of NetPace.Core +- **WHEN** a developer reflects on `typeof(NetPace.Core.Profile)` in a unit test +- **THEN** `typeof(NetPace.Core.Profile).Namespace` is exactly `"NetPace.Core"` (no `Clients.*` suffix) +- **AND** no static method in the `NetPace.Core` assembly takes `Profile` as its first parameter and returns a type located under the `NetPace.Core.Clients` namespace +- **AND** the source file `src/NetPace.Core/Profile.cs` exists at that exact path (verified by file-existence check) +- **AND** no type named `OoklaSpeedtestSettingsExtensions` or `OoklaProfileExtensions` exists anywhere in the `NetPace.Core` assembly + +#### Scenario: `with` expression composes cleanly on profile-built record +- **WHEN** the expression `var s = new OoklaSpeedtestSettings(Profile.Mega) with { UseProxy = true };` is evaluated +- **THEN** `s.DownloadTest.DownloadSizes` contains `5000`, `6000`, and `7000` (Mega's values are preserved) +- **AND** `s.UseProxy` is `true` +- **AND** `s.DownloadTest.DownloadParallelTasks` is exactly `32` (Mega's value) +- **AND** `s.UploadTest.UploadSizeIncrementKb` is exactly `1024` (Mega's value) + +#### Scenario: Construct invalid profile throws +- **WHEN** the expression `new OoklaSpeedtestSettings((Profile)999)` is evaluated +- **THEN** an `ArgumentOutOfRangeException` is thrown +- **AND** the exception's `ParamName` property is exactly `"profile"` + +--- + +## Implementation guidance + +Every test method that implements a scenario in this plan MUST include a `// SCENARIO:` +comment whose value matches the `#### Scenario:` name above **exactly** — character for +character, including case, punctuation, and internal whitespace. Leading and trailing +whitespace on the scenario name is trimmed before comparison. + +```csharp +[Fact] +public void Login_UnknownEmail_Returns401() +{ + // SCENARIO: Login rejected for unknown email + + // ... +} +``` + +`/speckit.testchecklist` validates these comments against the scenario names in this +file. A test without a matching `// SCENARIO:` comment is reported as untraced. diff --git a/src/NetPace.Console.Tests/CommandLineTestHost.cs b/src/NetPace.Console.Tests/CommandLineTestHost.cs index b6d88a66..24ef32fb 100644 --- a/src/NetPace.Console.Tests/CommandLineTestHost.cs +++ b/src/NetPace.Console.Tests/CommandLineTestHost.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using NetPace.Console; using Spectre.Console; using Spectre.Console.Testing; @@ -36,6 +37,10 @@ public async Task RunAsync(string[] args, CancellationToken cancella // Default IClientInfoProvider stub unless a test already registered one serviceCollection.TryAddSingleton(); + // Default OoklaSpeedtestSettingsAccessor (matches production DI). Tests that want to + // inspect the bound settings register their own instance before calling RunAsync. + serviceCollection.TryAddSingleton(); + await using var serviceProvider = serviceCollection.BuildServiceProvider(); var exitCode = await Program.RunAsync(serviceProvider, args, cancellationToken); diff --git a/src/NetPace.Console.Tests/CompositeAnsiConsoleTests.cs b/src/NetPace.Console.Tests/CompositeAnsiConsoleTests.cs index ee521e23..b4c0a597 100644 --- a/src/NetPace.Console.Tests/CompositeAnsiConsoleTests.cs +++ b/src/NetPace.Console.Tests/CompositeAnsiConsoleTests.cs @@ -46,7 +46,7 @@ private sealed class SpyConsole : IAnsiConsole public int WriteCallCount { get; private set; } public int WriteAnsiCallCount { get; private set; } - public Profile Profile => AnsiConsole.Console.Profile; + public Spectre.Console.Profile Profile => AnsiConsole.Console.Profile; public IAnsiConsoleCursor Cursor => AnsiConsole.Console.Cursor; public IAnsiConsoleInput Input => AnsiConsole.Console.Input; public IExclusivityMode ExclusivityMode => AnsiConsole.Console.ExclusivityMode; diff --git a/src/NetPace.Console.Tests/Expectations/NetPaceConsoleTests.Should_Display_Help.verified.txt b/src/NetPace.Console.Tests/Expectations/NetPaceConsoleTests.Should_Display_Help.verified.txt index 0625b33c..84145d65 100644 --- a/src/NetPace.Console.Tests/Expectations/NetPaceConsoleTests.Should_Display_Help.verified.txt +++ b/src/NetPace.Console.Tests/Expectations/NetPaceConsoleTests.Should_Display_Help.verified.txt @@ -33,6 +33,7 @@ OPTIONS: 'NetPace servers -l' will return your nearest servers. -t, --timestamp Include a timestamp in the output. --datetimeformat yyyy-MM-dd HH:mm:ss The datetime format string, as defined by Microsoft.Net. + --profile Medium Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega). --downloadsize Stop the download test after this many megabytes (IEC MiB). --uploadsize Stop the upload test after this many megabytes (IEC MiB). -u, --unit BitsPerSecond The speed unit. diff --git a/src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs b/src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs new file mode 100644 index 00000000..7544c48a --- /dev/null +++ b/src/NetPace.Console.Tests/NetPaceConsoleTests.Profile.cs @@ -0,0 +1,202 @@ +using NetPace.Console; +using NetPace.Core.Clients.Ookla; + +namespace NetPace.Console.Tests; + +public sealed partial class NetPaceConsoleTests +{ + /// + /// Build a service collection that captures the constructed + /// into a returned accessor instance. The action in writes + /// to it after option binding, so the test reads accessor.Settings after RunAsync to verify + /// CLI → settings binding. + /// + private static (IServiceCollection services, OoklaSpeedtestSettingsAccessor accessor) BuildServicesWithSettingsAccessor() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + var accessor = new OoklaSpeedtestSettingsAccessor(); + services.AddSingleton(accessor); + return (services, accessor); + } + + [Theory] + [InlineData("tiny")] + [InlineData("Tiny")] + [InlineData("TINY")] + public async Task Profile_CaseInsensitiveEnumParsing_BindsToTiny(string value) + { + // Given + var (services, accessor) = BuildServicesWithSettingsAccessor(); + var host = GetCommandLineTestHost(services); + + // When + var result = await host.RunAsync(new[] { "--profile", value }); + + // Then — assert field-for-field (int[] DownloadSizes uses reference equality). + Assert.Equal(0, result.ExitCode); + var expected = new OoklaSpeedtestSettings(Profile.Tiny); + Assert.Equal(expected.DownloadTest.DownloadSizes, accessor.Settings.DownloadTest.DownloadSizes); + Assert.Equal(expected.DownloadTest.DownloadSizeIterations, accessor.Settings.DownloadTest.DownloadSizeIterations); + Assert.Equal(expected.DownloadTest.DownloadParallelTasks, accessor.Settings.DownloadTest.DownloadParallelTasks); + Assert.Equal(expected.DownloadTest.DownloadSizeMb, accessor.Settings.DownloadTest.DownloadSizeMb); + Assert.Equal(expected.UploadTest.UploadSizeIncrementKb, accessor.Settings.UploadTest.UploadSizeIncrementKb); + Assert.Equal(expected.UploadTest.UploadIncrements, accessor.Settings.UploadTest.UploadIncrements); + Assert.Equal(expected.UploadTest.UploadSizeIterations, accessor.Settings.UploadTest.UploadSizeIterations); + Assert.Equal(expected.UploadTest.UploadParallelTasks, accessor.Settings.UploadTest.UploadParallelTasks); + Assert.Equal(expected.UploadTest.UploadSizeMb, accessor.Settings.UploadTest.UploadSizeMb); + } + + [Fact] + public async Task Profile_UnknownValue_ExitsNonZeroAndMentionsBadValue() + { + // SCENARIO: Invalid --profile value is rejected by argument parsing (FR-013) + + // Given + var (services, _) = BuildServicesWithSettingsAccessor(); + var host = GetCommandLineTestHost(services); + + // When + var result = await host.RunAsync(new[] { "--profile", "huge" }); + + // Then — non-zero exit and the offending token surfaces in error output. + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("huge", result.Output, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Profile_AuthoritativeForPerRequestShape_OnTiny() + { + // SCENARIO: Profile is authoritative for per-request shape + + // Given + var (services, accessor) = BuildServicesWithSettingsAccessor(); + var host = GetCommandLineTestHost(services); + + // When + var result = await host.RunAsync(new[] { "--profile", "tiny" }); + + // Then + Assert.Equal(0, result.ExitCode); + Assert.Equal(new[] { 350 }, accessor.Settings.DownloadTest.DownloadSizes); + Assert.Equal(1, accessor.Settings.DownloadTest.DownloadParallelTasks); + Assert.Equal(1, accessor.Settings.DownloadTest.DownloadSizeIterations); + Assert.All(accessor.Settings.DownloadTest.DownloadSizes, s => Assert.True(s <= 350)); + } + + [Fact] + public async Task NoProfileFlag_DefaultsToMedium() + { + // SCENARIO: Omitted --profile defaults to Medium + + // Given + var (services, accessor) = BuildServicesWithSettingsAccessor(); + var host = GetCommandLineTestHost(services); + + // When + var result = await host.RunAsync(Array.Empty()); + + // Then — assert field-for-field (int[] DownloadSizes uses reference equality). + Assert.Equal(0, result.ExitCode); + var medium = new OoklaSpeedtestSettings(Profile.Medium); + Assert.Equal(medium.DownloadTest.DownloadSizes, accessor.Settings.DownloadTest.DownloadSizes); + Assert.Equal(medium.DownloadTest.DownloadSizeIterations, accessor.Settings.DownloadTest.DownloadSizeIterations); + Assert.Equal(medium.DownloadTest.DownloadParallelTasks, accessor.Settings.DownloadTest.DownloadParallelTasks); + Assert.Equal(medium.DownloadTest.DownloadSizeMb, accessor.Settings.DownloadTest.DownloadSizeMb); + Assert.Equal(medium.UploadTest.UploadSizeIncrementKb, accessor.Settings.UploadTest.UploadSizeIncrementKb); + Assert.Equal(medium.UploadTest.UploadIncrements, accessor.Settings.UploadTest.UploadIncrements); + Assert.Equal(medium.UploadTest.UploadSizeIterations, accessor.Settings.UploadTest.UploadSizeIterations); + Assert.Equal(medium.UploadTest.UploadParallelTasks, accessor.Settings.UploadTest.UploadParallelTasks); + Assert.Equal(medium.UploadTest.UploadSizeMb, accessor.Settings.UploadTest.UploadSizeMb); + // Explicit per-spec assertions. + Assert.Equal(new[] { 1500, 2000, 3000, 3500, 4000 }, accessor.Settings.DownloadTest.DownloadSizes); + Assert.Equal(100, accessor.Settings.DownloadTest.DownloadSizeMb); + Assert.Equal(25, accessor.Settings.UploadTest.UploadSizeMb); + } + + [Fact] + public async Task DownloadSizeOverride_PreservesProfileShape_OnTiny() + { + // SCENARIO: --downloadsize overrides only the cap, profile shape is preserved + + // Given + var (services, accessor) = BuildServicesWithSettingsAccessor(); + var host = GetCommandLineTestHost(services); + + // When + var result = await host.RunAsync(new[] { "--profile", "tiny", "--downloadsize", "5" }); + + // Then + Assert.Equal(0, result.ExitCode); + Assert.Equal(new[] { 350 }, accessor.Settings.DownloadTest.DownloadSizes); + Assert.Equal(1, accessor.Settings.DownloadTest.DownloadSizeIterations); + Assert.Equal(1, accessor.Settings.DownloadTest.DownloadParallelTasks); + Assert.Equal(5, accessor.Settings.DownloadTest.DownloadSizeMb); + } + + [Fact] + public async Task UploadSizeOverride_PreservesProfileShape_OnSmall() + { + // SCENARIO: --uploadsize overrides only the upload cap + + // Given + var (services, accessor) = BuildServicesWithSettingsAccessor(); + var host = GetCommandLineTestHost(services); + + // When + var result = await host.RunAsync(new[] { "--profile", "small", "--uploadsize", "1" }); + + // Then + Assert.Equal(0, result.ExitCode); + Assert.Equal(100, accessor.Settings.UploadTest.UploadSizeIncrementKb); + Assert.Equal(4, accessor.Settings.UploadTest.UploadIncrements); + Assert.Equal(2, accessor.Settings.UploadTest.UploadSizeIterations); + Assert.Equal(2, accessor.Settings.UploadTest.UploadParallelTasks); + Assert.Equal(1, accessor.Settings.UploadTest.UploadSizeMb); + } + + [Fact] + public async Task DownloadSizeOverride_LargerThanNaturalTransfer_IsNoopBackstop() + { + // SCENARIO: Override cap larger than natural transfer is a no-op backstop + // + // Tiny's natural transfer (≤ 1 MiB) stays well below the override cap (5000 MiB), + // so the cap-check never trips. Override is mechanically present on the settings record. + // Tiny's natural-budget assertion is in OoklaSpeedtestSettingsTests.Profiles.Tiny_FieldsMatchDataModel. + + // Given + var (services, accessor) = BuildServicesWithSettingsAccessor(); + var host = GetCommandLineTestHost(services); + + // When + var result = await host.RunAsync(new[] { "--profile", "tiny", "--downloadsize", "5000" }); + + // Then + Assert.Equal(0, result.ExitCode); + Assert.Equal(5000, accessor.Settings.DownloadTest.DownloadSizeMb); + } + + [Fact] + public async Task NoDownload_ShortCircuits_RegardlessOfProfile() + { + // SCENARIO: --no-download short-circuits regardless of profile + + // Given + var (services, accessor) = BuildServicesWithSettingsAccessor(); + var host = GetCommandLineTestHost(services); + + // When + var result = await host.RunAsync(new[] { "--no-download", "--profile", "large" }); + + // Then + Assert.Equal(0, result.ExitCode); + + // --no-download is honoured by the runtime (existing behaviour), and the upload phase + // still binds to Large's per-request shape. + Assert.Equal(500, accessor.Settings.UploadTest.UploadSizeIncrementKb); + Assert.Equal(8, accessor.Settings.UploadTest.UploadIncrements); + Assert.Equal(16, accessor.Settings.UploadTest.UploadParallelTasks); + } +} diff --git a/src/NetPace.Console.Tests/NetPaceConsoleTests.cs b/src/NetPace.Console.Tests/NetPaceConsoleTests.cs index 6bb2f55e..804e42ee 100644 --- a/src/NetPace.Console.Tests/NetPaceConsoleTests.cs +++ b/src/NetPace.Console.Tests/NetPaceConsoleTests.cs @@ -10,8 +10,6 @@ private static CommandLineTestHost GetCommandLineTestHost(IServiceCollection? se return new CommandLineTestHost(serviceCollection); } - //#region Speed Test - [Fact] public async Task Should_Perform_Speed_Test() { @@ -532,10 +530,6 @@ public async Task Should_Handle_No_Servers_Available_With_NoLatency() await Verify(result.Output); } -//#endregion - - #region CommandApp - [InlineData("-h")] [InlineData("--help")] [InlineData("-?")] @@ -577,5 +571,4 @@ public async Task Should_Display_Version(string version) await Verify(result.Output).DisableRequireUniquePrefix(); } - #endregion } diff --git a/src/NetPace.Console.Tests/VerifyConfiguration.cs b/src/NetPace.Console.Tests/VerifyConfiguration.cs index e6fa75d1..05d23717 100644 --- a/src/NetPace.Console.Tests/VerifyConfiguration.cs +++ b/src/NetPace.Console.Tests/VerifyConfiguration.cs @@ -9,5 +9,10 @@ public static class VerifyConfiguration public static void Init() { Verifier.UseProjectRelativeDirectory("Expectations"); + + // Suppress auto-launching the diff viewer when a Verify snapshot test fails. + // Without this, vim/code launches per-failure in CI/headless runs and can leave + // .swp files behind; we want clean failures instead. + DiffRunner.Disabled = true; } } diff --git a/src/NetPace.Console/Commands/SpeedTestCommandSettings.cs b/src/NetPace.Console/Commands/SpeedTestCommandSettings.cs index 259d672e..eecb898a 100644 --- a/src/NetPace.Console/Commands/SpeedTestCommandSettings.cs +++ b/src/NetPace.Console/Commands/SpeedTestCommandSettings.cs @@ -80,6 +80,11 @@ public sealed class SpeedTestCommandSettings /// public required string DateTimeFormat { get; init; } + /// + /// The traffic-load profile that bundles per-request shape and total-byte cap defaults. + /// + public required Profile Profile { get; init; } + /// /// Stop the download test after this many megabytes (IEC MiB). /// diff --git a/src/NetPace.Console/CompositeAnsiConsole.cs b/src/NetPace.Console/CompositeAnsiConsole.cs index 36de0fbd..1ad2f698 100644 --- a/src/NetPace.Console/CompositeAnsiConsole.cs +++ b/src/NetPace.Console/CompositeAnsiConsole.cs @@ -36,7 +36,7 @@ public void Dispose() } // Delegate properties to primary console - public Profile Profile => _primary.Profile; + public Spectre.Console.Profile Profile => _primary.Profile; public IAnsiConsoleCursor Cursor => _primary.Cursor; public IAnsiConsoleInput Input => _primary.Input; public IExclusivityMode ExclusivityMode => _primary.ExclusivityMode; diff --git a/src/NetPace.Console/ConsoleWriters/CSVConsoleWriter.cs b/src/NetPace.Console/ConsoleWriters/CSVConsoleWriter.cs index da1e674b..c664402e 100644 --- a/src/NetPace.Console/ConsoleWriters/CSVConsoleWriter.cs +++ b/src/NetPace.Console/ConsoleWriters/CSVConsoleWriter.cs @@ -14,8 +14,8 @@ public async Task PerformSpeedTestAsync(bool initialSpeedTest, IAnsiConsole cons var uploadResult = new SpeedTestResult(); // Perform speed test. - if (!settings.NoDownload) downloadResult = await speedTestClient.GetDownloadSpeedAsync(fastest.Server, settings.DownloadSizeMb, cancellationToken); - if (!settings.NoUpload) uploadResult = await speedTestClient.GetUploadSpeedAsync(fastest.Server, settings.UploadSizeMb, cancellationToken); + if (!settings.NoDownload) downloadResult = await speedTestClient.GetDownloadSpeedAsync(fastest.Server, cancellationToken); + if (!settings.NoUpload) uploadResult = await speedTestClient.GetUploadSpeedAsync(fastest.Server, cancellationToken); // Display speed test result. diff --git a/src/NetPace.Console/ConsoleWriters/DefaultConsoleWriter.cs b/src/NetPace.Console/ConsoleWriters/DefaultConsoleWriter.cs index 4aeb55e7..4268d184 100644 --- a/src/NetPace.Console/ConsoleWriters/DefaultConsoleWriter.cs +++ b/src/NetPace.Console/ConsoleWriters/DefaultConsoleWriter.cs @@ -77,12 +77,12 @@ await console.Progress() if (!settings.NoDownload) { var downloadProgressReporter = new SyncProgress(p => downloadProgress!.Value = p.PercentageComplete); - downloadResult = await speedTestClient.GetDownloadSpeedAsync(fastest.Server, settings.DownloadSizeMb, downloadProgressReporter, cancellationToken); + downloadResult = await speedTestClient.GetDownloadSpeedAsync(fastest.Server, downloadProgressReporter, cancellationToken); } if (!settings.NoUpload) { var uploadProgressReporter = new SyncProgress(p => uploadProgress!.Value = p.PercentageComplete); - uploadResult = await speedTestClient.GetUploadSpeedAsync(fastest.Server, settings.UploadSizeMb, uploadProgressReporter, cancellationToken); + uploadResult = await speedTestClient.GetUploadSpeedAsync(fastest.Server, uploadProgressReporter, cancellationToken); } }); } diff --git a/src/NetPace.Console/ConsoleWriters/JsonConsoleWriter.cs b/src/NetPace.Console/ConsoleWriters/JsonConsoleWriter.cs index 82cf45d3..3219d003 100644 --- a/src/NetPace.Console/ConsoleWriters/JsonConsoleWriter.cs +++ b/src/NetPace.Console/ConsoleWriters/JsonConsoleWriter.cs @@ -15,8 +15,8 @@ public async Task PerformSpeedTestAsync(bool initialSpeedTest, IAnsiConsole cons var uploadResult = new SpeedTestResult(); // Perform speed test. - if (!settings.NoDownload) downloadResult = await speedTestClient.GetDownloadSpeedAsync(fastest.Server, settings.DownloadSizeMb, cancellationToken); - if (!settings.NoUpload) uploadResult = await speedTestClient.GetUploadSpeedAsync(fastest.Server, settings.UploadSizeMb, cancellationToken); + if (!settings.NoDownload) downloadResult = await speedTestClient.GetDownloadSpeedAsync(fastest.Server, cancellationToken); + if (!settings.NoUpload) uploadResult = await speedTestClient.GetUploadSpeedAsync(fastest.Server, cancellationToken); // Display speed test result. diff --git a/src/NetPace.Console/ConsoleWriters/MinimalConsoleWriter.cs b/src/NetPace.Console/ConsoleWriters/MinimalConsoleWriter.cs index ff2b921c..02cc5b36 100644 --- a/src/NetPace.Console/ConsoleWriters/MinimalConsoleWriter.cs +++ b/src/NetPace.Console/ConsoleWriters/MinimalConsoleWriter.cs @@ -15,8 +15,8 @@ public async Task PerformSpeedTestAsync(bool initialSpeedTest, IAnsiConsole cons var uploadResult = new SpeedTestResult(); // Perform speed test. - if (!settings.NoDownload) downloadResult = await speedTestClient.GetDownloadSpeedAsync(fastest.Server, settings.DownloadSizeMb, cancellationToken); - if (!settings.NoUpload) uploadResult = await speedTestClient.GetUploadSpeedAsync(fastest.Server, settings.UploadSizeMb, cancellationToken); + if (!settings.NoDownload) downloadResult = await speedTestClient.GetDownloadSpeedAsync(fastest.Server, cancellationToken); + if (!settings.NoUpload) uploadResult = await speedTestClient.GetUploadSpeedAsync(fastest.Server, cancellationToken); // Display speed test result. diff --git a/src/NetPace.Console/FileConsole.cs b/src/NetPace.Console/FileConsole.cs index af11436d..2a686468 100644 --- a/src/NetPace.Console/FileConsole.cs +++ b/src/NetPace.Console/FileConsole.cs @@ -53,7 +53,7 @@ public void Dispose() } // IAnsiConsole implementation - delegate to template console for properties. - public Profile Profile => _templateConsole.Profile; + public Spectre.Console.Profile Profile => _templateConsole.Profile; public IAnsiConsoleCursor Cursor => _templateConsole.Cursor; public IAnsiConsoleInput Input => _templateConsole.Input; public IExclusivityMode ExclusivityMode => _templateConsole.ExclusivityMode; diff --git a/src/NetPace.Console/OoklaSpeedtestSettingsAccessor.cs b/src/NetPace.Console/OoklaSpeedtestSettingsAccessor.cs new file mode 100644 index 00000000..d592a2fa --- /dev/null +++ b/src/NetPace.Console/OoklaSpeedtestSettingsAccessor.cs @@ -0,0 +1,18 @@ +using NetPace.Core.Clients.Ookla; + +namespace NetPace.Console; + +/// +/// DI-resolved holder for the built from CLI arguments. +/// Set by after option binding; read by the +/// factory when the production speed-test service is resolved. +/// Tests can inspect after a run to verify CLI → settings binding. +/// +public sealed class OoklaSpeedtestSettingsAccessor +{ + /// + /// The settings to pass into . Default before option binding + /// is (same as new OoklaSpeedtestSettings()). + /// + public OoklaSpeedtestSettings Settings { get; set; } = new(); +} diff --git a/src/NetPace.Console/Program.cs b/src/NetPace.Console/Program.cs index e504e8b8..83b40b64 100644 --- a/src/NetPace.Console/Program.cs +++ b/src/NetPace.Console/Program.cs @@ -1,420 +1,464 @@ -using System.CommandLine; - -using Microsoft.Extensions.DependencyInjection; -using NetPace.Console.Commands; -using NetPace.Core; -using Spectre.Console; - -namespace NetPace.Console; - -public static class Program -{ - /// - /// The application description - /// - internal const string Description = "Network speed tester including server discovery, latency measurement, download and upload speed testing."; - - /// - /// Create the RootCommand with System.CommandLine. - /// - /// - /// Extracted here so the testing project can reuse the production configuration. - /// - internal static RootCommand CreateRootCommand(IServiceProvider serviceProvider) - { - var command = new RootCommand(Description); - - // Define options - var versionOption = new Option("--version") - { - Description = "Prints version information." - }; - versionOption.Aliases.Add("-v"); - - var loopOption = new Option("--loop") - { - Description = "Performs the speed test on continuous loop.", - DefaultValueFactory = _ => false - }; - - var countOption = new Option("--count") - { - Description = "Stop speed testing after this many times.", - DefaultValueFactory = _ => 1 - }; - - var delayOption = new Option("--delay") - { - Description = "Time between multiple speed tests (HH:MM:SS).", - DefaultValueFactory = _ => TimeSpan.Zero - }; - - var csvOption = new Option("--csv") - { - Description = "Display minimal output in CSV format (always includes timestamp).", - DefaultValueFactory = _ => false - }; - - var csvDelimiterOption = new Option("--csv-delimiter") - { - Description = "Single character delimiter to use in CSV output.", - DefaultValueFactory = _ => "," - }; - csvDelimiterOption.Validators.Add(result => - { - var value = result.GetValueOrDefault(); - if (value != null && value.Length != 1) - { - result.AddError("--csv-delimiter must be a single character."); - } - }); - - var csvHeaderUnitsOption = new Option("--csv-header-units") - { - Description = "Display speed test units (eg. Mbps) in the CSV header row, not the data rows.\n--unit-scale must not be for multiple speed tests (eg. --loop or --count).", - DefaultValueFactory = _ => false - }; - - var jsonOption = new Option("--json") - { - Description = "Display output in Json format.", - DefaultValueFactory = _ => false - }; - - var jsonPrettyOption = new Option("--json-pretty") - { - Description = "Display output in Json format (pretty print).", - DefaultValueFactory = _ => false - }; - - var noLatencyOption = new Option("--no-latency") - { - Description = "Do not perform latency test.\nWhen used without --server, the first available server is selected.", - DefaultValueFactory = _ => false - }; - - var noDownloadOption = new Option("--no-download") - { - Description = "Do not perform download test.", - DefaultValueFactory = _ => false - }; - - var noUploadOption = new Option("--no-upload") - { - Description = "Do not perform upload test.", - DefaultValueFactory = _ => false - }; - - var serverOption = new Option("--server") - { - Description = "The url of a specific speed test sever. \n'NetPace servers -l' will return your nearest servers.", - DefaultValueFactory = _ => string.Empty - }; - - var timestampOption = new Option("--timestamp") - { - Description = "Include a timestamp in the output.", - DefaultValueFactory = _ => false - }; - timestampOption.Aliases.Add("-t"); - - var datetimeFormatOption = new Option("--datetimeformat") - { - Description = "The datetime format string, as defined by Microsoft.Net.", - DefaultValueFactory = _ => "yyyy-MM-dd HH:mm:ss" - }; - - var downloadSizeOption = new Option("--downloadsize") - { - Description = "Stop the download test after this many megabytes (IEC MiB).", - DefaultValueFactory = _ => int.MaxValue - }; - - var uploadSizeOption = new Option("--uploadsize") - { - Description = "Stop the upload test after this many megabytes (IEC MiB).", - DefaultValueFactory = _ => int.MaxValue - }; - - var unitOption = new Option("--unit") - { - Description = "The speed unit. ", - DefaultValueFactory = _ => SpeedUnit.BitsPerSecond - }; - unitOption.Aliases.Add("-u"); - - var unitScaleOption = new Option("--unit-scale") - { - Description = "The speed unit scale. ", - DefaultValueFactory = _ => SpeedScale.Auto - }; - - var unitSystemOption = new Option("--unit-system") - { - Description = "The speed unit system. \nSI steps up in powers of 1000 (KB, MB, GB), common in networking, while IEC uses powers of 1024 (KiB, MiB, GiB), standard in computing and storage.", - DefaultValueFactory = _ => SpeedUnitSystem.SI - }; - - var verbosityOption = new Option("--verbosity") - { - Description = "The verbosity level. \nMinimal is ideal for batch scripts and redirected output.", - DefaultValueFactory = _ => Verbosity.Normal - }; - - var fileOption = new Option("--file") - { - Description = "Write output to file.", - DefaultValueFactory = _ => string.Empty - }; - fileOption.Aliases.Add("-f"); - - var fileModeOption = new Option("--file-mode") - { - Description = "Determines file output behavior. ", - DefaultValueFactory = _ => FileMode.Append - }; - - var quietOption = new Option("--quiet") - { - Description = "Suppress all normal console output (file output still works).", - DefaultValueFactory = _ => false - }; - quietOption.Aliases.Add("-q"); - - // Add options - command.Options.Add(versionOption); - command.Options.Add(loopOption); - command.Options.Add(countOption); - command.Options.Add(delayOption); - command.Options.Add(csvOption); - command.Options.Add(csvDelimiterOption); - command.Options.Add(csvHeaderUnitsOption); - command.Options.Add(jsonOption); - command.Options.Add(jsonPrettyOption); - command.Options.Add(noLatencyOption); - command.Options.Add(noDownloadOption); - command.Options.Add(noUploadOption); - command.Options.Add(serverOption); - command.Options.Add(timestampOption); - command.Options.Add(datetimeFormatOption); - command.Options.Add(downloadSizeOption); - command.Options.Add(uploadSizeOption); - command.Options.Add(unitOption); - command.Options.Add(unitScaleOption); - command.Options.Add(unitSystemOption); - command.Options.Add(verbosityOption); - command.Options.Add(fileOption); - command.Options.Add(fileModeOption); - command.Options.Add(quietOption); - - // Set command action - command.SetAction((Func>)(async (parseResult, cancellationToken) => - { - try - { - // Get option values and populate settings - var settings = new SpeedTestCommandSettings - { - Loop = parseResult.GetValue(loopOption), - Count = parseResult.GetValue(countOption), - Delay = parseResult.GetValue(delayOption), - CSV = parseResult.GetValue(csvOption), - CSVDelimiter = (parseResult.GetValue(csvDelimiterOption) ?? ",")[0], - CSVHeaderUnits = parseResult.GetValue(csvHeaderUnitsOption), - Json = parseResult.GetValue(jsonOption), - JsonPretty = parseResult.GetValue(jsonPrettyOption), - NoLatency = parseResult.GetValue(noLatencyOption), - NoDownload = parseResult.GetValue(noDownloadOption), - NoUpload = parseResult.GetValue(noUploadOption), - ServerUrl = parseResult.GetValue(serverOption) ?? string.Empty, - IncludeTimestamp = parseResult.GetValue(timestampOption), - DateTimeFormat = parseResult.GetValue(datetimeFormatOption)!, - DownloadSizeMb = parseResult.GetValue(downloadSizeOption), - UploadSizeMb = parseResult.GetValue(uploadSizeOption), - SpeedUnit = parseResult.GetValue(unitOption), - SpeedScale = parseResult.GetValue(unitScaleOption), - SpeedUnitSystem = parseResult.GetValue(unitSystemOption), - Verbosity = parseResult.GetValue(verbosityOption), - OutputFile = parseResult.GetValue(fileOption) ?? string.Empty, - FileModeValue = parseResult.GetValue(fileModeOption), - Quiet = parseResult.GetValue(quietOption) - }; - - // Validate settings - settings.Validate(); - - // Get services from DI - var ansiConsole = serviceProvider.GetRequiredService(); - var speedTestService = serviceProvider.GetRequiredService(); - var clock = serviceProvider.GetRequiredService(); - var clientInfoProvider = serviceProvider.GetRequiredService(); - var waiter = serviceProvider.GetRequiredService(); - - // Create and execute command - var command = new SpeedTestCommand(ansiConsole, speedTestService, clock, clientInfoProvider, waiter); - return await command.ExecuteAsync(settings, cancellationToken); - } - catch (Exception ex) - { - var ansiConsole = serviceProvider.GetRequiredService(); - ansiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); - return 1; - } - })); - - // Add list servers command - var listServersCommand = CreateListServersCommand(serviceProvider); - command.Subcommands.Add(listServersCommand); - - return command; - } - - /// - /// Create the list servers command. - /// - private static Command CreateListServersCommand(IServiceProvider serviceProvider) - { - var command = new Command("servers", "Show the nearest speed test servers."); - - // Define options - var latencyOption = new Option("--latency") - { - Description = "Include server latency in the results." - }; - latencyOption.Aliases.Add("-l"); - - var fastestOption = new Option("--fastest") - { - Description = "Show the fastest server details, selected by lowest latency." - }; - fastestOption.Aliases.Add("-f"); - - // Add options - command.Options.Add(latencyOption); - command.Options.Add(fastestOption); - - // Set command action - command.SetAction(async (parseResult, cancellationToken) => - { - try - { - // Get option values and populate settings - var settings = new ListServersCommandSettings - { - ShowLatency = parseResult.GetValue(latencyOption), - Fastest = parseResult.GetValue(fastestOption) - }; - - // Get services from DI - var ansiConsole = serviceProvider.GetRequiredService(); - var speedTestService = serviceProvider.GetRequiredService(); - - // Create and execute command - var command = new ListServersCommand(ansiConsole, speedTestService); - return await command.ExecuteAsync(settings, cancellationToken); - } - catch (Exception ex) - { - var ansiConsole = serviceProvider.GetRequiredService(); - ansiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); - return 1; - } - }); - - return command; - } - - /// - /// Run the command line application with the given arguments. - /// - public async static Task Main(string[] args) - { - // Setup DI - var services = new ServiceCollection(); - - // Register AnsiConsole - services.AddSingleton(AnsiConsole.Console); - - if (args != null && args.Contains("--test")) - { - // Executes NetPace against stub service implementations. - services.AddSingleton(new SpeedTestStub(250)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - } - else - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - } - - await using var serviceProvider = services.BuildServiceProvider(); - - using var cancellationTokenSource = new CancellationTokenSource(); - - System.Console.CancelKeyPress += (_, eventArgs) => - { - // Try to cancel gracefully the first time, then abort the process the second time Ctrl+C is pressed - eventArgs.Cancel = !cancellationTokenSource.IsCancellationRequested; - cancellationTokenSource.Cancel(); - }; - - return await RunAsync( - serviceProvider, - args!.Where(s => !s.Equals("--test")).ToArray(), - cancellationTokenSource.Token); - } - - internal static async Task RunAsync(IServiceProvider serviceProvider, string[] args, CancellationToken cancellationToken = default) - { - var ansiConsole = serviceProvider.GetRequiredService(); - var rootCommand = CreateRootCommand(serviceProvider); - - // Check for version request before parsing - if (args.Length > 0 && (args[0] is "-v" or "--version")) - { - var assemblyVersion = typeof(Program).Assembly.GetName().Version; - var version = assemblyVersion != null - ? $"{assemblyVersion.Major}.{assemblyVersion.Minor}.{assemblyVersion.Build}" - : "Unknown"; - ansiConsole.WriteLine(version); - return 0; - } - - // Check for help request before parsing - if (args.Length > 0 && (args[0] is "-h" or "--help" or "-?")) - { - CustomHelpProvider.RenderHelp(ansiConsole, rootCommand); - return 0; - } - - // Check for subcommand help - if (args.Length > 1 && (args[1] is "-h" or "--help" or "-?")) - { - var subcommandName = args[0]; - var subcommand = rootCommand.Subcommands.FirstOrDefault(c => c.Name == subcommandName); - if (subcommand != null) - { - CustomHelpProvider.RenderHelp(ansiConsole, subcommand); - return 0; - } - } - - var parseResult = rootCommand.Parse(args); - - // Handle parse errors - these should be shown even in quiet mode - if (parseResult.Errors.Count > 0) - { - foreach (var error in parseResult.Errors) - { - ansiConsole.MarkupLine($"[red]Error:[/] {error.Message}"); - } - return 1; - } - - return await parseResult.InvokeAsync(cancellationToken: cancellationToken); - } -} +using System.CommandLine; + +using Microsoft.Extensions.DependencyInjection; +using NetPace.Console.Commands; +using NetPace.Core; +using NetPace.Core.Clients.Ookla; +using Spectre.Console; + +namespace NetPace.Console; + +public static class Program +{ + /// + /// The application description + /// + internal const string Description = "Network speed tester including server discovery, latency measurement, download and upload speed testing."; + + /// + /// Create the RootCommand with System.CommandLine. + /// + /// + /// Extracted here so the testing project can reuse the production configuration. + /// + internal static RootCommand CreateRootCommand(IServiceProvider serviceProvider) + { + var command = new RootCommand(Description); + + // Define options + var versionOption = new Option("--version") + { + Description = "Prints version information." + }; + versionOption.Aliases.Add("-v"); + + var loopOption = new Option("--loop") + { + Description = "Performs the speed test on continuous loop.", + DefaultValueFactory = _ => false + }; + + var countOption = new Option("--count") + { + Description = "Stop speed testing after this many times.", + DefaultValueFactory = _ => 1 + }; + + var delayOption = new Option("--delay") + { + Description = "Time between multiple speed tests (HH:MM:SS).", + DefaultValueFactory = _ => TimeSpan.Zero + }; + + var csvOption = new Option("--csv") + { + Description = "Display minimal output in CSV format (always includes timestamp).", + DefaultValueFactory = _ => false + }; + + var csvDelimiterOption = new Option("--csv-delimiter") + { + Description = "Single character delimiter to use in CSV output.", + DefaultValueFactory = _ => "," + }; + csvDelimiterOption.Validators.Add(result => + { + var value = result.GetValueOrDefault(); + if (value != null && value.Length != 1) + { + result.AddError("--csv-delimiter must be a single character."); + } + }); + + var csvHeaderUnitsOption = new Option("--csv-header-units") + { + Description = "Display speed test units (eg. Mbps) in the CSV header row, not the data rows.\n--unit-scale must not be for multiple speed tests (eg. --loop or --count).", + DefaultValueFactory = _ => false + }; + + var jsonOption = new Option("--json") + { + Description = "Display output in Json format.", + DefaultValueFactory = _ => false + }; + + var jsonPrettyOption = new Option("--json-pretty") + { + Description = "Display output in Json format (pretty print).", + DefaultValueFactory = _ => false + }; + + var noLatencyOption = new Option("--no-latency") + { + Description = "Do not perform latency test.\nWhen used without --server, the first available server is selected.", + DefaultValueFactory = _ => false + }; + + var noDownloadOption = new Option("--no-download") + { + Description = "Do not perform download test.", + DefaultValueFactory = _ => false + }; + + var noUploadOption = new Option("--no-upload") + { + Description = "Do not perform upload test.", + DefaultValueFactory = _ => false + }; + + var serverOption = new Option("--server") + { + Description = "The url of a specific speed test sever. \n'NetPace servers -l' will return your nearest servers.", + DefaultValueFactory = _ => string.Empty + }; + + var timestampOption = new Option("--timestamp") + { + Description = "Include a timestamp in the output.", + DefaultValueFactory = _ => false + }; + timestampOption.Aliases.Add("-t"); + + var datetimeFormatOption = new Option("--datetimeformat") + { + Description = "The datetime format string, as defined by Microsoft.Net.", + DefaultValueFactory = _ => "yyyy-MM-dd HH:mm:ss" + }; + + var profileOption = new Option("--profile") + { + Description = "Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega).", + DefaultValueFactory = _ => Profile.Medium + }; + + var downloadSizeOption = new Option("--downloadsize") + { + Description = "Stop the download test after this many megabytes (IEC MiB).", + DefaultValueFactory = _ => int.MaxValue + }; + + var uploadSizeOption = new Option("--uploadsize") + { + Description = "Stop the upload test after this many megabytes (IEC MiB).", + DefaultValueFactory = _ => int.MaxValue + }; + + var unitOption = new Option("--unit") + { + Description = "The speed unit. ", + DefaultValueFactory = _ => SpeedUnit.BitsPerSecond + }; + unitOption.Aliases.Add("-u"); + + var unitScaleOption = new Option("--unit-scale") + { + Description = "The speed unit scale. ", + DefaultValueFactory = _ => SpeedScale.Auto + }; + + var unitSystemOption = new Option("--unit-system") + { + Description = "The speed unit system. \nSI steps up in powers of 1000 (KB, MB, GB), common in networking, while IEC uses powers of 1024 (KiB, MiB, GiB), standard in computing and storage.", + DefaultValueFactory = _ => SpeedUnitSystem.SI + }; + + var verbosityOption = new Option("--verbosity") + { + Description = "The verbosity level. \nMinimal is ideal for batch scripts and redirected output.", + DefaultValueFactory = _ => Verbosity.Normal + }; + + var fileOption = new Option("--file") + { + Description = "Write output to file.", + DefaultValueFactory = _ => string.Empty + }; + fileOption.Aliases.Add("-f"); + + var fileModeOption = new Option("--file-mode") + { + Description = "Determines file output behavior. ", + DefaultValueFactory = _ => FileMode.Append + }; + + var quietOption = new Option("--quiet") + { + Description = "Suppress all normal console output (file output still works).", + DefaultValueFactory = _ => false + }; + quietOption.Aliases.Add("-q"); + + // Add options + command.Options.Add(versionOption); + command.Options.Add(loopOption); + command.Options.Add(countOption); + command.Options.Add(delayOption); + command.Options.Add(csvOption); + command.Options.Add(csvDelimiterOption); + command.Options.Add(csvHeaderUnitsOption); + command.Options.Add(jsonOption); + command.Options.Add(jsonPrettyOption); + command.Options.Add(noLatencyOption); + command.Options.Add(noDownloadOption); + command.Options.Add(noUploadOption); + command.Options.Add(serverOption); + command.Options.Add(timestampOption); + command.Options.Add(datetimeFormatOption); + command.Options.Add(profileOption); + command.Options.Add(downloadSizeOption); + command.Options.Add(uploadSizeOption); + command.Options.Add(unitOption); + command.Options.Add(unitScaleOption); + command.Options.Add(unitSystemOption); + command.Options.Add(verbosityOption); + command.Options.Add(fileOption); + command.Options.Add(fileModeOption); + command.Options.Add(quietOption); + + // Set command action + command.SetAction((Func>)(async (parseResult, cancellationToken) => + { + try + { + // Get option values and populate settings + var settings = new SpeedTestCommandSettings + { + Loop = parseResult.GetValue(loopOption), + Count = parseResult.GetValue(countOption), + Delay = parseResult.GetValue(delayOption), + CSV = parseResult.GetValue(csvOption), + CSVDelimiter = (parseResult.GetValue(csvDelimiterOption) ?? ",")[0], + CSVHeaderUnits = parseResult.GetValue(csvHeaderUnitsOption), + Json = parseResult.GetValue(jsonOption), + JsonPretty = parseResult.GetValue(jsonPrettyOption), + NoLatency = parseResult.GetValue(noLatencyOption), + NoDownload = parseResult.GetValue(noDownloadOption), + NoUpload = parseResult.GetValue(noUploadOption), + ServerUrl = parseResult.GetValue(serverOption) ?? string.Empty, + IncludeTimestamp = parseResult.GetValue(timestampOption), + DateTimeFormat = parseResult.GetValue(datetimeFormatOption)!, + Profile = parseResult.GetValue(profileOption), + DownloadSizeMb = parseResult.GetValue(downloadSizeOption), + UploadSizeMb = parseResult.GetValue(uploadSizeOption), + SpeedUnit = parseResult.GetValue(unitOption), + SpeedScale = parseResult.GetValue(unitScaleOption), + SpeedUnitSystem = parseResult.GetValue(unitSystemOption), + Verbosity = parseResult.GetValue(verbosityOption), + OutputFile = parseResult.GetValue(fileOption) ?? string.Empty, + FileModeValue = parseResult.GetValue(fileModeOption), + Quiet = parseResult.GetValue(quietOption) + }; + + // Validate settings + settings.Validate(); + + // Build the OoklaSpeedtestSettings from --profile + per-cap overrides. + // The settings accessor is the test seam: production resolves OoklaSpeedtest from it; + // tests inspect Settings post-run to verify CLI → settings binding. + var accessor = serviceProvider.GetRequiredService(); + accessor.Settings = BuildOoklaSettings(settings); + + // Get services from DI + var ansiConsole = serviceProvider.GetRequiredService(); + var speedTestService = serviceProvider.GetRequiredService(); + var clock = serviceProvider.GetRequiredService(); + var clientInfoProvider = serviceProvider.GetRequiredService(); + var waiter = serviceProvider.GetRequiredService(); + + // Create and execute command + var command = new SpeedTestCommand(ansiConsole, speedTestService, clock, clientInfoProvider, waiter); + return await command.ExecuteAsync(settings, cancellationToken); + } + catch (Exception ex) + { + var ansiConsole = serviceProvider.GetRequiredService(); + ansiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); + return 1; + } + })); + + // Add list servers command + var listServersCommand = CreateListServersCommand(serviceProvider); + command.Subcommands.Add(listServersCommand); + + return command; + } + + /// + /// Build the from the parsed CLI settings. + /// Profile sets per-request shape and cap defaults; explicit --downloadsize / --uploadsize + /// override only the cap via with-expressions. + /// + internal static OoklaSpeedtestSettings BuildOoklaSettings(SpeedTestCommandSettings settings) + { + var ooklaSettings = new OoklaSpeedtestSettings(settings.Profile); + + if (settings.DownloadSizeMb != int.MaxValue) + { + ooklaSettings = ooklaSettings with + { + DownloadTest = ooklaSettings.DownloadTest with { DownloadSizeMb = settings.DownloadSizeMb } + }; + } + + if (settings.UploadSizeMb != int.MaxValue) + { + ooklaSettings = ooklaSettings with + { + UploadTest = ooklaSettings.UploadTest with { UploadSizeMb = settings.UploadSizeMb } + }; + } + + return ooklaSettings; + } + + /// + /// Create the list servers command. + /// + private static Command CreateListServersCommand(IServiceProvider serviceProvider) + { + var command = new Command("servers", "Show the nearest speed test servers."); + + // Define options + var latencyOption = new Option("--latency") + { + Description = "Include server latency in the results." + }; + latencyOption.Aliases.Add("-l"); + + var fastestOption = new Option("--fastest") + { + Description = "Show the fastest server details, selected by lowest latency." + }; + fastestOption.Aliases.Add("-f"); + + // Add options + command.Options.Add(latencyOption); + command.Options.Add(fastestOption); + + // Set command action + command.SetAction(async (parseResult, cancellationToken) => + { + try + { + // Get option values and populate settings + var settings = new ListServersCommandSettings + { + ShowLatency = parseResult.GetValue(latencyOption), + Fastest = parseResult.GetValue(fastestOption) + }; + + // Get services from DI + var ansiConsole = serviceProvider.GetRequiredService(); + var speedTestService = serviceProvider.GetRequiredService(); + + // Create and execute command + var command = new ListServersCommand(ansiConsole, speedTestService); + return await command.ExecuteAsync(settings, cancellationToken); + } + catch (Exception ex) + { + var ansiConsole = serviceProvider.GetRequiredService(); + ansiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); + return 1; + } + }); + + return command; + } + + /// + /// Run the command line application with the given arguments. + /// + public async static Task Main(string[] args) + { + // Setup DI + var services = new ServiceCollection(); + + // Register AnsiConsole + services.AddSingleton(AnsiConsole.Console); + + if (args != null && args.Contains("--test")) + { + // Executes NetPace against stub service implementations. + services.AddSingleton(new SpeedTestStub(250)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + services.AddSingleton(sp => new OoklaSpeedtest(sp.GetRequiredService().Settings)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + await using var serviceProvider = services.BuildServiceProvider(); + + using var cancellationTokenSource = new CancellationTokenSource(); + + System.Console.CancelKeyPress += (_, eventArgs) => + { + // Try to cancel gracefully the first time, then abort the process the second time Ctrl+C is pressed + eventArgs.Cancel = !cancellationTokenSource.IsCancellationRequested; + cancellationTokenSource.Cancel(); + }; + + return await RunAsync( + serviceProvider, + args!.Where(s => !s.Equals("--test")).ToArray(), + cancellationTokenSource.Token); + } + + internal static async Task RunAsync(IServiceProvider serviceProvider, string[] args, CancellationToken cancellationToken = default) + { + var ansiConsole = serviceProvider.GetRequiredService(); + var rootCommand = CreateRootCommand(serviceProvider); + + // Check for version request before parsing + if (args.Length > 0 && (args[0] is "-v" or "--version")) + { + var assemblyVersion = typeof(Program).Assembly.GetName().Version; + var version = assemblyVersion != null + ? $"{assemblyVersion.Major}.{assemblyVersion.Minor}.{assemblyVersion.Build}" + : "Unknown"; + ansiConsole.WriteLine(version); + return 0; + } + + // Check for help request before parsing + if (args.Length > 0 && (args[0] is "-h" or "--help" or "-?")) + { + CustomHelpProvider.RenderHelp(ansiConsole, rootCommand); + return 0; + } + + // Check for subcommand help + if (args.Length > 1 && (args[1] is "-h" or "--help" or "-?")) + { + var subcommandName = args[0]; + var subcommand = rootCommand.Subcommands.FirstOrDefault(c => c.Name == subcommandName); + if (subcommand != null) + { + CustomHelpProvider.RenderHelp(ansiConsole, subcommand); + return 0; + } + } + + var parseResult = rootCommand.Parse(args); + + // Handle parse errors - these should be shown even in quiet mode + if (parseResult.Errors.Count > 0) + { + foreach (var error in parseResult.Errors) + { + ansiConsole.MarkupLine($"[red]Error:[/] {error.Message}"); + } + return 1; + } + + return await parseResult.InvokeAsync(cancellationToken: cancellationToken); + } +} diff --git a/src/NetPace.Console/Properties/Usings.cs b/src/NetPace.Console/Properties/Usings.cs index 41de58b1..1a1388e1 100644 --- a/src/NetPace.Console/Properties/Usings.cs +++ b/src/NetPace.Console/Properties/Usings.cs @@ -4,4 +4,9 @@ global using NetPace.Console.Commands; global using NetPace.Core.Clients.Ookla; global using NetPace.Core.Clients.Testing; -global using Spectre.Console; \ No newline at end of file +global using Spectre.Console; + +// Resolve the Profile clash project-wide in favour of our enum. The two files that need +// Spectre's terminal-capability Profile (FileConsole, CompositeAnsiConsole — both +// IAnsiConsole implementations) use the fully qualified name inline. +global using Profile = NetPace.Core.Profile; \ No newline at end of file diff --git a/src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs b/src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs new file mode 100644 index 00000000..5d7ba55f --- /dev/null +++ b/src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.Profiles.cs @@ -0,0 +1,130 @@ +using NetPace.Core.Clients.Ookla; +using Shouldly; + +namespace NetPace.Core.Tests; + +/// +/// Per-profile field-equality assertions for 's +/// inline switch. Values mirror data-model.md exactly. +/// +public sealed partial class OoklaSpeedtestSettingsTests +{ + [Fact] + public void Tiny_FieldsMatchDataModel() + { + // SCENARIO: Tiny profile stays within IoT budget + // + // Natural-transfer budget proxy: ≤ 1 MiB total per run, ≈ 245 KB down + 50 KB up ±10 %. + // Proxy is recorded here for future readers; not asserted at runtime (no Docker integration + // test per design decision D8). + + // Given / When + var s = new OoklaSpeedtestSettings(Profile.Tiny); + + // Then + s.DownloadTest.DownloadSizes.ShouldBe(new[] { 350 }); + s.DownloadTest.DownloadSizeIterations.ShouldBe(1); + s.DownloadTest.DownloadParallelTasks.ShouldBe(1); + s.DownloadTest.DownloadSizeMb.ShouldBe(1); + + s.UploadTest.UploadSizeIncrementKb.ShouldBe(50); + s.UploadTest.UploadIncrements.ShouldBe(1); + s.UploadTest.UploadSizeIterations.ShouldBe(1); + s.UploadTest.UploadParallelTasks.ShouldBe(1); + s.UploadTest.UploadSizeMb.ShouldBe(1); + } + + [Fact] + public void Small_FieldsMatchDataModel() + { + // SCENARIO: Small profile suits cellular + // + // Natural-transfer budget proxy: ≤ 12 MiB total per run, ≈ 10 MiB down + 2 MiB up ±10 %. + + // Given / When + var s = new OoklaSpeedtestSettings(Profile.Small); + + // Then + s.DownloadTest.DownloadSizes.ShouldBe(new[] { 1000, 1500 }); + s.DownloadTest.DownloadSizeIterations.ShouldBe(2); + s.DownloadTest.DownloadParallelTasks.ShouldBe(2); + s.DownloadTest.DownloadSizeMb.ShouldBe(10); + + s.UploadTest.UploadSizeIncrementKb.ShouldBe(100); + s.UploadTest.UploadIncrements.ShouldBe(4); + s.UploadTest.UploadSizeIterations.ShouldBe(2); + s.UploadTest.UploadParallelTasks.ShouldBe(2); + s.UploadTest.UploadSizeMb.ShouldBe(2); + } + + [Fact] + public void Medium_FieldsMatchDataModel() + { + // Given / When + var s = new OoklaSpeedtestSettings(Profile.Medium); + + // Then + s.DownloadTest.DownloadSizes.ShouldBe(new[] { 1500, 2000, 3000, 3500, 4000 }); + s.DownloadTest.DownloadSizeIterations.ShouldBe(2); + s.DownloadTest.DownloadParallelTasks.ShouldBe(4); + s.DownloadTest.DownloadSizeMb.ShouldBe(100); + + s.UploadTest.UploadSizeIncrementKb.ShouldBe(200); + s.UploadTest.UploadIncrements.ShouldBe(6); + s.UploadTest.UploadSizeIterations.ShouldBe(5); + s.UploadTest.UploadParallelTasks.ShouldBe(4); + s.UploadTest.UploadSizeMb.ShouldBe(25); + } + + [Fact] + public void Large_FieldsMatchDataModel() + { + // Given / When + var s = new OoklaSpeedtestSettings(Profile.Large); + + // Then + s.DownloadTest.DownloadSizes.ShouldBe(new[] { 2000, 2500, 3000, 3500, 4000 }); + s.DownloadTest.DownloadSizeIterations.ShouldBe(12); + s.DownloadTest.DownloadParallelTasks.ShouldBe(16); + s.DownloadTest.DownloadSizeMb.ShouldBe(1024); + + s.UploadTest.UploadSizeIncrementKb.ShouldBe(500); + s.UploadTest.UploadIncrements.ShouldBe(8); + s.UploadTest.UploadSizeIterations.ShouldBe(12); + s.UploadTest.UploadParallelTasks.ShouldBe(16); + s.UploadTest.UploadSizeMb.ShouldBe(256); + } + + [Fact] + public void Mega_FieldsMatchDataModel() + { + // Given / When + var s = new OoklaSpeedtestSettings(Profile.Mega); + + // Then + s.DownloadTest.DownloadSizes.ShouldBe(new[] { 3000, 4000, 5000, 6000, 7000 }); + s.DownloadTest.DownloadSizeIterations.ShouldBe(40); + s.DownloadTest.DownloadParallelTasks.ShouldBe(32); + s.DownloadTest.DownloadSizeMb.ShouldBe(10240); + + s.UploadTest.UploadSizeIncrementKb.ShouldBe(1024); + s.UploadTest.UploadIncrements.ShouldBe(16); + s.UploadTest.UploadSizeIterations.ShouldBe(16); + s.UploadTest.UploadParallelTasks.ShouldBe(32); + s.UploadTest.UploadSizeMb.ShouldBe(2048); + } + + [Fact] + public void Mega_DownloadSizes_ContainsBonusPayloads() + { + // SCENARIO: Mega uses bonus payloads + + // Given / When + var s = new OoklaSpeedtestSettings(Profile.Mega); + + // Then — three separate asserts so a single failure pinpoints which bonus value is missing. + s.DownloadTest.DownloadSizes.ShouldContain(5000); + s.DownloadTest.DownloadSizes.ShouldContain(6000); + s.DownloadTest.DownloadSizes.ShouldContain(7000); + } +} diff --git a/src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs b/src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs new file mode 100644 index 00000000..ef906238 --- /dev/null +++ b/src/NetPace.Core.Tests/OoklaSpeedtestSettingsTests.cs @@ -0,0 +1,89 @@ +using NetPace.Core.Clients.Ookla; +using Shouldly; + +namespace NetPace.Core.Tests; + +/// +/// Cross-story invariant tests for 's parameterless +/// and -taking constructors. +/// +public sealed partial class OoklaSpeedtestSettingsTests +{ + [Fact] + public void ParameterlessCtor_EqualsMediumProfileCtor() + { + // SCENARIO: Parameterless ctor chains to Medium + + // Given / When + var parameterless = new OoklaSpeedtestSettings(); + var medium = new OoklaSpeedtestSettings(Profile.Medium); + + // Then — assert field-for-field equality (int[] DownloadSizes doesn't have + // structural equality, so record-level .Equals would compare arrays by reference). + parameterless.DownloadTest.DownloadSizes.ShouldBe(medium.DownloadTest.DownloadSizes); + parameterless.DownloadTest.DownloadSizeIterations.ShouldBe(medium.DownloadTest.DownloadSizeIterations); + parameterless.DownloadTest.DownloadParallelTasks.ShouldBe(medium.DownloadTest.DownloadParallelTasks); + parameterless.DownloadTest.DownloadSizeMb.ShouldBe(medium.DownloadTest.DownloadSizeMb); + + parameterless.UploadTest.UploadSizeIncrementKb.ShouldBe(medium.UploadTest.UploadSizeIncrementKb); + parameterless.UploadTest.UploadIncrements.ShouldBe(medium.UploadTest.UploadIncrements); + parameterless.UploadTest.UploadSizeIterations.ShouldBe(medium.UploadTest.UploadSizeIterations); + parameterless.UploadTest.UploadParallelTasks.ShouldBe(medium.UploadTest.UploadParallelTasks); + parameterless.UploadTest.UploadSizeMb.ShouldBe(medium.UploadTest.UploadSizeMb); + + // Spot-check Medium field values so any value drift surfaces here too. + parameterless.DownloadTest.DownloadSizes.ShouldBe(new[] { 1500, 2000, 3000, 3500, 4000 }); + parameterless.DownloadTest.DownloadSizeIterations.ShouldBe(2); + parameterless.DownloadTest.DownloadParallelTasks.ShouldBe(4); + parameterless.DownloadTest.DownloadSizeMb.ShouldBe(100); + + parameterless.UploadTest.UploadSizeIncrementKb.ShouldBe(200); + parameterless.UploadTest.UploadIncrements.ShouldBe(6); + parameterless.UploadTest.UploadSizeIterations.ShouldBe(5); + parameterless.UploadTest.UploadParallelTasks.ShouldBe(4); + parameterless.UploadTest.UploadSizeMb.ShouldBe(25); + } + + [Fact] + public void Ctor_UnknownProfile_ThrowsArgumentOutOfRangeException() + { + // SCENARIO: Construct invalid profile throws + + // Given + var bogus = (Profile)999; + + // When + var ex = Should.Throw(() => new OoklaSpeedtestSettings(bogus)); + + // Then + ex.ParamName.ShouldBe("profile"); + } + + [Fact] + public void WithExpression_PreservesProfileFields_AndAppliesOverride() + { + // SCENARIO: `with` expression composes cleanly on profile-built record + + // Given + var s = new OoklaSpeedtestSettings(Profile.Mega) with { UseProxy = true }; + + // Then + s.UseProxy.ShouldBeTrue(); + + // Mega's per-request shape is preserved. + s.DownloadTest.DownloadSizes.ShouldContain(5000); + s.DownloadTest.DownloadSizes.ShouldContain(6000); + s.DownloadTest.DownloadSizes.ShouldContain(7000); + s.DownloadTest.DownloadParallelTasks.ShouldBe(32); + s.UploadTest.UploadSizeIncrementKb.ShouldBe(1024); + } + + [Fact] + public void OoklaSpeedtestSettings_HasNoProfileProperty() + { + // Reflection guard: profile is consumed by the ctor but never stored as state. + var profileProp = typeof(OoklaSpeedtestSettings).GetProperty("Profile"); + + profileProp.ShouldBeNull(); + } +} diff --git a/src/NetPace.Core.Tests/OoklaSpeedtestTests.Guards.cs b/src/NetPace.Core.Tests/OoklaSpeedtestTests.Guards.cs index 5461b8a5..71a9b66a 100644 --- a/src/NetPace.Core.Tests/OoklaSpeedtestTests.Guards.cs +++ b/src/NetPace.Core.Tests/OoklaSpeedtestTests.Guards.cs @@ -214,21 +214,6 @@ public async Task GetDownloadSpeedAsync_Server_Null_ThrowsArgumentNullException( Assert.Equal("server", exception.ParamName); } - [Fact] - public async Task GetDownloadSpeedAsync_WithSize_Server_Null_ThrowsArgumentNullException() - { - // Given - var speedtest = new OoklaSpeedtest(); - IServer? server = null; - - // When - var exception = await Assert.ThrowsAsync( - () => speedtest.GetDownloadSpeedAsync(server!, downloadSizeMb: 10)); - - // Then - Assert.Equal("server", exception.ParamName); - } - [Fact] public async Task GetDownloadSpeedAsync_WithProgress_Server_Null_ThrowsArgumentNullException() { @@ -244,21 +229,6 @@ public async Task GetDownloadSpeedAsync_WithProgress_Server_Null_ThrowsArgumentN Assert.Equal("server", exception.ParamName); } - [Fact] - public async Task GetDownloadSpeedAsync_WithSizeAndProgress_Server_Null_ThrowsArgumentNullException() - { - // Given - var speedtest = new OoklaSpeedtest(); - IServer? server = null; - - // When - var exception = await Assert.ThrowsAsync( - () => speedtest.GetDownloadSpeedAsync(server!, downloadSizeMb: 10, new NullProgress())); - - // Then - Assert.Equal("server", exception.ParamName); - } - [Fact] public async Task GetDownloadSpeedAsync_ServerUrl_Null_ThrowsArgumentNullException() { @@ -268,7 +238,7 @@ public async Task GetDownloadSpeedAsync_ServerUrl_Null_ThrowsArgumentNullExcepti // When var exception = await Assert.ThrowsAsync( - () => speedtest.GetDownloadSpeedAsync(server, downloadSizeMb: 10, new NullProgress())); + () => speedtest.GetDownloadSpeedAsync(server, new NullProgress())); // Then Assert.Equal("server.Url", exception.ParamName); @@ -286,48 +256,12 @@ public async Task GetDownloadSpeedAsync_ServerUrl_EmptyOrWhitespace_ThrowsArgume // When var exception = await Assert.ThrowsAsync( - () => speedtest.GetDownloadSpeedAsync(server, downloadSizeMb: 10, new NullProgress())); + () => speedtest.GetDownloadSpeedAsync(server, new NullProgress())); // Then Assert.Contains("server.Url", exception.Message); } - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(-100)] - public async Task GetDownloadSpeedAsync_DownloadSizeMb_ZeroOrNegative_ThrowsArgumentOutOfRangeException(int downloadSizeMb) - { - // Given - var speedtest = new OoklaSpeedtest(); - var server = new Server { Url = "http://example.com/", Sponsor = "Test", Location = "Test" }; - - // When - var exception = await Assert.ThrowsAsync( - () => speedtest.GetDownloadSpeedAsync(server, downloadSizeMb)); - - // Then - Assert.Equal("downloadSizeMb", exception.ParamName); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(-100)] - public async Task GetDownloadSpeedAsync_WithProgress_DownloadSizeMb_ZeroOrNegative_ThrowsArgumentOutOfRangeException(int downloadSizeMb) - { - // Given - var speedtest = new OoklaSpeedtest(); - var server = new Server { Url = "http://example.com/", Sponsor = "Test", Location = "Test" }; - - // When - var exception = await Assert.ThrowsAsync( - () => speedtest.GetDownloadSpeedAsync(server, downloadSizeMb, new NullProgress())); - - // Then - Assert.Equal("downloadSizeMb", exception.ParamName); - } - [Fact] public async Task GetUploadSpeedAsync_Server_Null_ThrowsArgumentNullException() { @@ -343,21 +277,6 @@ public async Task GetUploadSpeedAsync_Server_Null_ThrowsArgumentNullException() Assert.Equal("server", exception.ParamName); } - [Fact] - public async Task GetUploadSpeedAsync_WithSize_Server_Null_ThrowsArgumentNullException() - { - // Given - var speedtest = new OoklaSpeedtest(); - IServer? server = null; - - // When - var exception = await Assert.ThrowsAsync( - () => speedtest.GetUploadSpeedAsync(server!, uploadSizeMb: 10)); - - // Then - Assert.Equal("server", exception.ParamName); - } - [Fact] public async Task GetUploadSpeedAsync_WithProgress_Server_Null_ThrowsArgumentNullException() { @@ -373,21 +292,6 @@ public async Task GetUploadSpeedAsync_WithProgress_Server_Null_ThrowsArgumentNul Assert.Equal("server", exception.ParamName); } - [Fact] - public async Task GetUploadSpeedAsync_WithSizeAndProgress_Server_Null_ThrowsArgumentNullException() - { - // Given - var speedtest = new OoklaSpeedtest(); - IServer? server = null; - - // When - var exception = await Assert.ThrowsAsync( - () => speedtest.GetUploadSpeedAsync(server!, uploadSizeMb: 10, new NullProgress())); - - // Then - Assert.Equal("server", exception.ParamName); - } - [Fact] public async Task GetUploadSpeedAsync_ServerUrl_Null_ThrowsArgumentNullException() { @@ -397,7 +301,7 @@ public async Task GetUploadSpeedAsync_ServerUrl_Null_ThrowsArgumentNullException // When var exception = await Assert.ThrowsAsync( - () => speedtest.GetUploadSpeedAsync(server, uploadSizeMb: 10, new NullProgress())); + () => speedtest.GetUploadSpeedAsync(server, new NullProgress())); // Then Assert.Equal("server.Url", exception.ParamName); @@ -415,46 +319,10 @@ public async Task GetUploadSpeedAsync_ServerUrl_EmptyOrWhitespace_ThrowsArgument // When var exception = await Assert.ThrowsAsync( - () => speedtest.GetUploadSpeedAsync(server, uploadSizeMb: 10, new NullProgress())); + () => speedtest.GetUploadSpeedAsync(server, new NullProgress())); // Then Assert.Contains("server.Url", exception.Message); } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(-100)] - public async Task GetUploadSpeedAsync_UploadSizeMb_ZeroOrNegative_ThrowsArgumentOutOfRangeException(int uploadSizeMb) - { - // Given - var speedtest = new OoklaSpeedtest(); - var server = new Server { Url = "http://example.com/", Sponsor = "Test", Location = "Test" }; - - // When - var exception = await Assert.ThrowsAsync( - () => speedtest.GetUploadSpeedAsync(server, uploadSizeMb)); - - // Then - Assert.Equal("uploadSizeMb", exception.ParamName); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(-100)] - public async Task GetUploadSpeedAsync_WithProgress_UploadSizeMb_ZeroOrNegative_ThrowsArgumentOutOfRangeException(int uploadSizeMb) - { - // Given - var speedtest = new OoklaSpeedtest(); - var server = new Server { Url = "http://example.com/", Sponsor = "Test", Location = "Test" }; - - // When - var exception = await Assert.ThrowsAsync( - () => speedtest.GetUploadSpeedAsync(server, uploadSizeMb, new NullProgress())); - - // Then - Assert.Equal("uploadSizeMb", exception.ParamName); - } } } diff --git a/src/NetPace.Core.Tests/OoklaSpeedtestTests.cs b/src/NetPace.Core.Tests/OoklaSpeedtestTests.cs index 21d1319e..9ed7b83b 100644 --- a/src/NetPace.Core.Tests/OoklaSpeedtestTests.cs +++ b/src/NetPace.Core.Tests/OoklaSpeedtestTests.cs @@ -829,7 +829,8 @@ public async Task GetDownloadSpeedAsync_ShouldRespectDownloadSize(int downloadSi DownloadTest = new() { DownloadSizes = new[] { 100, 200, 500, 1000, 1500, 2000, 3000, 3500, 4000 }, - DownloadParallelTasks = 1 + DownloadParallelTasks = 1, + DownloadSizeMb = downloadSizeMb } }; @@ -837,7 +838,7 @@ public async Task GetDownloadSpeedAsync_ShouldRespectDownloadSize(int downloadSi var server = new Server { Url = "http://example.com/", Sponsor = "Test", Location = "Test" }; // When - var result = await speedtest.GetDownloadSpeedAsync(server, downloadSizeMb); + var result = await speedtest.GetDownloadSpeedAsync(server); // Then result.ShouldNotBeNull(); @@ -872,7 +873,8 @@ public async Task GetDownloadSpeedAsync_ShouldReportProgress_RespectDownloadSize { DownloadSizes = new[] { 100 }, DownloadSizeIterations = 10, - DownloadParallelTasks = 1 + DownloadParallelTasks = 1, + DownloadSizeMb = downloadSizeMb } }; @@ -881,7 +883,7 @@ public async Task GetDownloadSpeedAsync_ShouldReportProgress_RespectDownloadSize var progressReports = new List(); // When - var result = await speedtest.GetDownloadSpeedAsync(server, downloadSizeMb, new Progress(progress => progressReports.Add(progress.PercentageComplete))); + var result = await speedtest.GetDownloadSpeedAsync(server, new Progress(progress => progressReports.Add(progress.PercentageComplete))); // Then result.ShouldNotBeNull(); @@ -1241,7 +1243,8 @@ public async Task GetUploadSpeedAsync_ShouldRespectUploadSize(int uploadSizeMb) { UploadTest = new() { - UploadParallelTasks = 1 + UploadParallelTasks = 1, + UploadSizeMb = uploadSizeMb } }; @@ -1249,7 +1252,7 @@ public async Task GetUploadSpeedAsync_ShouldRespectUploadSize(int uploadSizeMb) var server = new Server { Url = "http://example.com/", Sponsor = "Test", Location = "Test" }; // When - var result = await speedtest.GetUploadSpeedAsync(server, uploadSizeMb); + var result = await speedtest.GetUploadSpeedAsync(server); // Then result.ShouldNotBeNull(); @@ -1277,7 +1280,8 @@ public async Task GetUploadSpeedAsync_ShouldReportProgress_RespectUploadSize() UploadIncrements = 1, UploadSizeIncrementKb = 512, UploadSizeIterations = 10, - UploadParallelTasks = 1 + UploadParallelTasks = 1, + UploadSizeMb = uploadSizeMb } }; @@ -1286,7 +1290,7 @@ public async Task GetUploadSpeedAsync_ShouldReportProgress_RespectUploadSize() var progressReports = new List(); // When - var result = await speedtest.GetUploadSpeedAsync(server, uploadSizeMb, new Progress(progress => progressReports.Add(progress.PercentageComplete))); + var result = await speedtest.GetUploadSpeedAsync(server, new Progress(progress => progressReports.Add(progress.PercentageComplete))); // Then result.ShouldNotBeNull(); diff --git a/src/NetPace.Core.Tests/ProfileTests.cs b/src/NetPace.Core.Tests/ProfileTests.cs new file mode 100644 index 00000000..e4c80e94 --- /dev/null +++ b/src/NetPace.Core.Tests/ProfileTests.cs @@ -0,0 +1,112 @@ +using System.Reflection; +using NetPace.Core.Clients.Ookla; +using Shouldly; + +namespace NetPace.Core.Tests; + +/// +/// Structural tests guarding the enum's +/// provider-agnostic placement at the root of NetPace.Core. +/// +public sealed class ProfileTests +{ + [Fact] + public void Profile_IsLocatedAtTopLevelOfNetPaceCore_NotUnderClients() + { + // SCENARIO: Profile enum is provider-agnostic and at the root of NetPace.Core + + // Given + var profileType = typeof(Profile); + + // When + var ns = profileType.Namespace; + + // Then + ns.ShouldBe("NetPace.Core"); + } + + [Fact] + public void Profile_HasNoExtensionMethodReturningProviderType() + { + // SCENARIO: Profile enum is provider-agnostic and at the root of NetPace.Core + + // Given + var assembly = typeof(Profile).Assembly; + + // When + var offendingMethods = assembly.GetTypes() + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static)) + .Where(m => + { + var ps = m.GetParameters(); + return ps.Length > 0 + && ps[0].ParameterType == typeof(Profile) + && m.ReturnType.Namespace is string ns + && ns.StartsWith("NetPace.Core.Clients", StringComparison.Ordinal); + }) + .ToList(); + + // Then + offendingMethods.ShouldBeEmpty(); + } + + [Fact] + public void Profile_NoExtensionsHelperType_ExistsInAssembly() + { + // SCENARIO: Profile enum is provider-agnostic and at the root of NetPace.Core + + // Given + var assembly = typeof(Profile).Assembly; + + // When + var oseExt = assembly.GetType("NetPace.Core.Clients.Ookla.OoklaSpeedtestSettingsExtensions"); + var opExt = assembly.GetType("NetPace.Core.Clients.Ookla.OoklaProfileExtensions"); + + // Then + oseExt.ShouldBeNull(); + opExt.ShouldBeNull(); + } + + [Fact] + public void Profile_SourceFile_LivesAtRootOfNetPaceCore() + { + // SCENARIO: Profile enum is provider-agnostic and at the root of NetPace.Core + + // Given + // Resolve the repo root by walking up from the test bin directory. + // Path.Join is used instead of Path.Combine so a rooted segment cannot silently + // discard earlier arguments (CodeQL cs/path-combine). + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Join(dir.FullName, "src", "NetPace.sln"))) + { + dir = dir.Parent; + } + + // When + dir.ShouldNotBeNull(); + var profilePath = Path.Join(dir!.FullName, "src", "NetPace.Core", "Profile.cs"); + + // Then + File.Exists(profilePath).ShouldBeTrue($"expected source file at '{profilePath}'"); + } + + [Theory] + [InlineData(Profile.Tiny)] + [InlineData(Profile.Small)] + [InlineData(Profile.Medium)] + [InlineData(Profile.Large)] + [InlineData(Profile.Mega)] + public void Profile_AllExpectedMembers_AreDefined(Profile p) + { + Enum.IsDefined(typeof(Profile), p).ShouldBeTrue(); + } + + [Fact] + public void Profile_DefaultValue_IsMedium() + { + // Locks the invariant that an uninitialised `Profile` resolves to the safe broadband + // default rather than the IoT preset. Reordering enum members must not silently regress this. + default(Profile).ShouldBe(Profile.Medium); + ((int)Profile.Medium).ShouldBe(0); + } +} diff --git a/src/NetPace.Core.Tests/ProfileXmlDocTests.cs b/src/NetPace.Core.Tests/ProfileXmlDocTests.cs new file mode 100644 index 00000000..2e361667 --- /dev/null +++ b/src/NetPace.Core.Tests/ProfileXmlDocTests.cs @@ -0,0 +1,38 @@ +using System.Xml.Linq; +using Shouldly; + +namespace NetPace.Core.Tests; + +/// +/// Verifies 's XML documentation includes the undocumented-payload +/// caveat (FR-021), so the warning ships to NuGet consumers via NetPace.Core.xml. +/// +public sealed class ProfileXmlDocTests +{ + [Fact] + public void Profile_Mega_XmlDoc_DocumentsBonusPayloadDependency() + { + // SCENARIO: Mega's bonus-payload dependency is documented + + // Given + var assemblyPath = typeof(Profile).Assembly.Location; + var xmlPath = Path.ChangeExtension(assemblyPath, ".xml"); + File.Exists(xmlPath).ShouldBeTrue( + $"Expected NetPace.Core.xml next to assembly at '{xmlPath}'. Ensure true is set on NetPace.Core.csproj."); + + // When + var doc = XDocument.Load(xmlPath); + var memberNode = doc.Descendants("member") + .FirstOrDefault(m => (string?)m.Attribute("name") == "F:NetPace.Core.Profile.Mega"); + + // Then + memberNode.ShouldNotBeNull("XML doc for Profile.Mega is missing — every public member must be documented."); + var summary = memberNode!.Element("summary")?.Value ?? string.Empty; + + summary.ShouldContain("undocumented", Case.Insensitive); + summary.ShouldContain("5000"); + summary.ShouldContain("6000"); + summary.ShouldContain("7000"); + summary.ShouldContain("download-upload-size-controls"); + } +} diff --git a/src/NetPace.Core/Clients/Ookla/OoklaSpeedtest.cs b/src/NetPace.Core/Clients/Ookla/OoklaSpeedtest.cs index f2698666..5ef5b907 100644 --- a/src/NetPace.Core/Clients/Ookla/OoklaSpeedtest.cs +++ b/src/NetPace.Core/Clients/Ookla/OoklaSpeedtest.cs @@ -1,571 +1,533 @@ -using System.Buffers; -using System.Diagnostics; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.IO; -using NetPace.Core.Clients.Ookla.Extensions; - -namespace NetPace.Core.Clients.Ookla; - -/// -/// An Ookla Speedtest implementation of the interface. -/// -public sealed class OoklaSpeedtest : ISpeedTestService -{ - private readonly HttpClient httpClient; - private readonly OoklaSpeedtestSettings settings; - private readonly IDelayProvider delayProvider; - - /// - /// Constructs a new instance of the class. - /// - public OoklaSpeedtest(OoklaSpeedtestSettings? speedtestSettings = null, HttpClient? httpClientOverride = null, IDelayProvider? delayProviderOverride = null) - { - // Use default settings when none provided - settings = speedtestSettings ?? new OoklaSpeedtestSettings(); - - httpClient = httpClientOverride ?? CreateHttpClient(settings.UseProxy, settings.ProxyAddress, settings.ProxyCredential); - delayProvider = delayProviderOverride ?? new DelayProvider(); - } - - /// - public async Task GetServersAsync(CancellationToken cancellationToken = default) - { - var serversXml = await httpClient.GetStringAsync(settings.ServerDiscovery.ServersUrl, cancellationToken).ConfigureAwait(false); - var servers = serversXml.DeserializeFromXml()?.Servers ?? Array.Empty(); - return servers.Where(s => - !string.IsNullOrWhiteSpace(s.Location) && - !string.IsNullOrWhiteSpace(s.Sponsor) && - !string.IsNullOrWhiteSpace(s.Url)).ToArray(); - } - - /// - public async Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) - { - return await GetServerLatencyAsync(serverUrl, new NullProgress(), cancellationToken); - } - - /// - public async Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl); - - var server = new Server() { Sponsor = "(Unknown)", Url = serverUrl }; - return await GetServerLatencyAsync(server, progress, cancellationToken); - } - - /// - public async Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) - { - return await GetServerLatencyAsync(server, new NullProgress(), cancellationToken); - } - - /// - public async Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - ArgumentException.ThrowIfNullOrWhiteSpace(server.Url); - - var latencyUrl = GetBaseUrl(server.Url) + "latency.txt"; - var pings = new List(); - var stopwatch = new Stopwatch(); - - var maxIterations = settings.LatencyTest.LatencyTestIterations; - var intervalMilliseconds = settings.LatencyTest.LatencyTestIntervalMilliseconds; - var httpTimeoutMilliseconds = settings.LatencyTest.HttpTimeoutMilliseconds; - - for (var iteration = 0; iteration < maxIterations; iteration++) - { - cancellationToken.ThrowIfCancellationRequested(); - - // Add delay between iterations (not before first iteration) - if (iteration > 0 && intervalMilliseconds > 0) - { - await delayProvider.DelayAsync(intervalMilliseconds, cancellationToken).ConfigureAwait(false); - } - - stopwatch.Restart(); - var testString = await httpClient.GetStringWithTimeoutAsync(latencyUrl, TimeSpan.FromMilliseconds(httpTimeoutMilliseconds), cancellationToken).ConfigureAwait(false); - stopwatch.Stop(); - - if (!testString.StartsWith("test=test")) - { - throw new InvalidOperationException("Server returned incorrect test string for latency.txt"); - } - - // Record this ping time - pings.Add(stopwatch.ElapsedMilliseconds); - - // Report progress after each iteration - var percentageComplete = (iteration + 1) * 100 / maxIterations; - progress.Report(new LatencyTestProgress - { - PercentageComplete = percentageComplete, - }); - } - - // Calculate the average server latency. - var latencyResult = new LatencyTestResult - { - Server = server, - LatencyMilliseconds = (long)pings.Average() - }; - - return latencyResult; - } - - /// - public async Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default) - { - return await GetFastestServerByLatencyAsync(servers, new NullProgress(), cancellationToken); - } - - /// - public async Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(servers); - if (servers.Length == 0) - { - throw new ArgumentException("At least one server must be provided.", nameof(servers)); - } - - var serverProbes = new List(); - - for (int i = 0; i < servers.Length; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - // Bump up the fastest server probe by a slight margin - // Apply a minimum threshold to prevent timeouts from becoming too aggressive - var serverTimeoutMilliseconds = serverProbes.Count == 0 - ? settings.ServerDiscovery.ServerTimeoutMilliseconds - : (int)(serverProbes.Min(p => p.LatencyMilliseconds) * 1.5); - - const int minimumTimeoutMilliseconds = 100; - var effectiveTimeout = Math.Max(minimumTimeoutMilliseconds, serverTimeoutMilliseconds); - - // Automatically cancel the server probe ar the effective timeout - using var cts = new CancellationTokenSource(); - cts.CancelAfter(effectiveTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); - - try - { - var latencyResult = await GetServerLatencyAsync(servers[i], linkedCts.Token); - - serverProbes.Add(latencyResult); - } - catch (Exception e) - { - if (e is OperationCanceledException && cancellationToken.IsCancellationRequested) - { - // Propagate user cancelled exceptions - throw; - } - - // A exception was thrown when pinging the server - // Ignore and continue with the next server - } - - // Report progress after each server is tested - var percentageComplete = (i + 1) * 100 / servers.Length; - progress.Report(new SpeedTestProgress { PercentageComplete = percentageComplete }); - } - - // Honour any user cancellations during/after the last probe. - cancellationToken.ThrowIfCancellationRequested(); - - if (serverProbes.Count == 0) - { - throw new Exception("No servers available"); - } - - return serverProbes.OrderBy(s => s.LatencyMilliseconds).First(); - } - - /// - public async Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - return await GetDownloadSpeedAsync(server, new NullProgress(), cancellationToken); - } - - /// - /// - /// In the Ookla implementation, downloads are processed in parallel batches - /// (configured via ). The - /// parameter triggers cancellation of the internal once the threshold - /// is reached, but all currently executing parallel download tasks will complete before termination. - /// The actual bytes processed may significantly exceed the specified limit depending on the number of - /// concurrent downloads and their individual sizes. - /// - public async Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, CancellationToken cancellationToken = default) - { - return await GetDownloadSpeedAsync(server, downloadSizeMb, new NullProgress(), cancellationToken); - } - - /// - public async Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - return await GetDownloadSpeedAsync(server, int.MaxValue, progress, cancellationToken); - } - - /// - /// - /// In the Ookla implementation, downloads are processed in parallel batches - /// (configured via ). The - /// parameter triggers cancellation of the internal once the threshold - /// is reached, but all currently executing parallel download tasks will complete before termination. - /// The actual bytes processed may significantly exceed the specified limit depending on the number of - /// concurrent downloads and their individual sizes. - /// - public async Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - ArgumentException.ThrowIfNullOrWhiteSpace(server.Url); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(downloadSizeMb); - - var downloadUrls = GenerateDownloadUrls(server.Url, settings.DownloadTest.DownloadSizes, settings.DownloadTest.DownloadSizeIterations); - - // Download content from a specified URL and return the size of the data in bytes. - Func> DownloadAndMeasureAsync = async (client, downloadUrl, cancellationToken) => - { - // Stream the response to avoid allocating large strings for each download. - using var response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - var buffer = ArrayPool.Shared.Rent(81920); // 80KB buffer - try - { - long total = 0; - int bytesRead; - while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) - { - total += bytesRead; - } - - return (int)total; - } - finally - { - ArrayPool.Shared.Return(buffer); - } - }; - - var downloadResult = await GenericTestSpeedAsync(downloadUrls, DownloadAndMeasureAsync, progress, settings.DownloadTest.DownloadParallelTasks, downloadSizeMb * 1024L * 1024L, cancellationToken); - - return downloadResult; - } - - /// - public async Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - return await GetUploadSpeedAsync(server, int.MaxValue, new NullProgress(), cancellationToken); - } - - /// - /// - /// In the default Ookla implementation, uploads are processed in parallel batches - /// (configured via ). The - /// parameter triggers cancellation of the internal once the threshold - /// is reached, but all currently executing parallel upload tasks will complete before termination. - /// The actual bytes processed may significantly exceed the specified limit depending on the number of - /// concurrent uploads and their individual sizes. - /// - public async Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, CancellationToken cancellationToken = default) - { - return await GetUploadSpeedAsync(server, uploadSizeMb, new NullProgress(), cancellationToken); - } - - /// - public async Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - return await GetUploadSpeedAsync(server, int.MaxValue, progress, cancellationToken); - } - - /// - /// - /// In the default Ookla implementation, uploads are processed in parallel batches - /// (configured via ). The - /// parameter triggers cancellation of the internal once the threshold - /// is reached, but all currently executing parallel upload tasks will complete before termination. - /// The actual bytes processed may significantly exceed the specified limit depending on the number of - /// concurrent uploads and their individual sizes. - /// - public async Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - ArgumentException.ThrowIfNullOrWhiteSpace(server.Url); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(uploadSizeMb); - - // Generate upload sizes (in bytes) rather than allocating large buffers up-front. - var testDataLengths = GenerateUploadDataLengths(settings.UploadTest.UploadIncrements, settings.UploadTest.UploadSizeIncrementKb, settings.UploadTest.UploadSizeIterations); - - // Upload content to a specified URL and return the size of the data in bytes. - Func> UploadAndMeasureAsync = async (client, length, cancellationToken) => - { - // Use RandomStreamContent to stream generated random bytes in small chunks to avoid LOH allocations. - using var content = new RandomStreamContent(length); - await client.PostAsync(server.Url, content, cancellationToken).ConfigureAwait(false); - return length; - }; - - var uploadResult = await GenericTestSpeedAsync(testDataLengths, UploadAndMeasureAsync, progress, settings.UploadTest.UploadParallelTasks, uploadSizeMb * 1024L * 1024L, cancellationToken); - - return uploadResult; - } - - /// - /// Executes a generic speed test by processing a collection of test data in parallel, - /// measuring total bytes processed and elapsed time. - /// - private async Task GenericTestSpeedAsync( - IEnumerable testData, - Func> doWork, - IProgress progress, - int parallelTasks, - long maxBytes, - CancellationToken cancellationToken) - { - object lockObject = new(); - bool wasCancelledLocally = false; - long totalBytesReturned = 0; - - var completedCount = 0; - var totalCount = testData.Count(); - - var timer = new Stopwatch(); - var throttler = new SemaphoreSlim(parallelTasks); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - timer.Start(); - - // Create and execute tasks to process the test data in parallel. - var tasks = testData.Select(async data => - { - var bytesReturned = 0; - - try - { - // Limit concurrent executions by waiting for a permit from the semaphore. - await throttler.WaitAsync(cts.Token).ConfigureAwait(false); - - // Perform the work and retrieve the processed byte count. - bytesReturned = await doWork(httpClient, data, cts.Token).ConfigureAwait(false); - } - catch (Exception e) - { - // An exception was thrown when performing the work - // - Progress will be reported as if no failure - // - Bytes returned will be treated as zero - - if (e is OperationCanceledException && !wasCancelledLocally) - { - // Propagate user cancelled exceptions - throw; - } - } - finally - { - try - { - lock (lockObject) - { - if (!cts.IsCancellationRequested) - { - completedCount++; - totalBytesReturned += bytesReturned; - - if (totalBytesReturned >= maxBytes) - { - // User specified byte limit is hit. - wasCancelledLocally = true; - cts.Cancel(); - progress.Report(new SpeedTestProgress - { - PercentageComplete = 100, - BytesProcessed = totalBytesReturned, - ElapsedMilliseconds = timer.ElapsedMilliseconds - }); - } - else - { - // Update the completion percentage. - var percentageComplete = (int)((double)completedCount / totalCount * 100); - - if (maxBytes != long.MaxValue) - { - // When a user specified limit has been imposed on the test, - // we should defer to the greater % complete value. - - var percentageCompleteMaxBytes = (int)((double)totalBytesReturned / maxBytes * 100); - - if (percentageCompleteMaxBytes > percentageComplete) - { - percentageComplete = percentageCompleteMaxBytes; - } - } - - progress.Report(new SpeedTestProgress - { - PercentageComplete = percentageComplete, - BytesProcessed = totalBytesReturned, - ElapsedMilliseconds = timer.ElapsedMilliseconds - }); - } - } - } - } - finally - { - // Release the semaphore to allow another task to proceed. - // This must always execute, even if UpdateProgress throws. - throttler.Release(); - } - } - - return bytesReturned; - }).ToArray(); - - // Wait for all tasks to complete. - await Task.WhenAll(tasks); - timer.Stop(); - - return new SpeedTestResult - { - BytesProcessed = totalBytesReturned, - ElapsedMilliseconds = timer.ElapsedMilliseconds - }; - } - - #region Static Functions - - private static HttpClient CreateHttpClient(bool useProxy, Uri? proxyAddress, NetworkCredential? proxyCredential) - { - var handler = new HttpClientHandler(); - - if (useProxy && proxyAddress != null) - { - handler.Proxy = new WebProxy - { - Address = proxyAddress, - Credentials = proxyCredential - }; - handler.UseProxy = true; - } - else - { - handler.UseProxy = false; - } - - var httpClient = new HttpClient(handler); - httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36"); - httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html, application/xhtml+xml, */*"); - httpClient.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue { NoCache = true }; - return httpClient; - } - - /// - /// Returns the base URL (ending with a trailing slash) by removing - /// the file name and query parameters from a full URL string. - /// - /// - /// Input: "http://example.com/path/speedtest/file.jpg?x=1" - /// Output: "http://example.com/path/speedtest/" - /// - private static string GetBaseUrl(string url) - { - var uri = new Uri(url); - var baseUri = new Uri(uri, "."); - return baseUri.ToString(); - } - - /// - /// Generates numerous download URLs for the speed test. - /// - /// - /// http://manchester.speedtest.boundlessnetworks.uk:8080/speedtest/random1500x1500.jpg?r=0 - /// http://manchester.speedtest.boundlessnetworks.uk:8080/speedtest/random1500x1500.jpg?r=1 - /// ... - /// - private static IEnumerable GenerateDownloadUrls(string serverUrl, int[] downloadSizes, int downloadSizeIterations) - { - var downloadUrl = GetBaseUrl(serverUrl) + "random{0}x{0}.jpg?r={1}"; - - foreach (var downloadSize in downloadSizes) - { - for (var i = 0; i < downloadSizeIterations; i++) - { - yield return string.Format(downloadUrl, downloadSize, i); - } - } - } - - /// - /// Generate upload payload lengths (in bytes) for the upload test. - /// - private static IEnumerable GenerateUploadDataLengths(int uploadIncrements, int baseSizeKb, int repeatsPerSize) - { - for (var increment = 1; increment <= uploadIncrements; increment++) - { - int incrementSize = increment * baseSizeKb * 1024; - - for (var repeat = 0; repeat < repeatsPerSize; repeat++) - { - yield return incrementSize; - } - } - } - - /// - /// HttpContent that streams cryptographically-random bytes on demand in small chunks. - /// Avoids allocating a single large byte[] and prevents LOH allocations. - /// - private sealed class RandomStreamContent : HttpContent - { - private readonly long totalSize; - private readonly int chunkSize; - - public RandomStreamContent(long totalSize, int chunkSize = 8192) - { - this.totalSize = totalSize; - this.chunkSize = chunkSize > 0 ? chunkSize : 8192; - Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - } - - protected override bool TryComputeLength(out long length) - { - length = totalSize; - return true; - } - - protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) - { - var buffer = ArrayPool.Shared.Rent(chunkSize); - try - { - long remaining = totalSize; - while (remaining > 0) - { - var toWrite = (int)Math.Min(buffer.Length, remaining); - RandomNumberGenerator.Fill(buffer.AsSpan(0, toWrite)); - await stream.WriteAsync(buffer.AsMemory(0, toWrite)).ConfigureAwait(false); - remaining -= toWrite; - } - - await stream.FlushAsync().ConfigureAwait(false); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - } - - #endregion -} \ No newline at end of file +using System.Buffers; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.IO; +using NetPace.Core.Clients.Ookla.Extensions; + +namespace NetPace.Core.Clients.Ookla; + +/// +/// An Ookla Speedtest implementation of the interface. +/// +public sealed class OoklaSpeedtest : ISpeedTestService +{ + private readonly HttpClient httpClient; + private readonly OoklaSpeedtestSettings settings; + private readonly IDelayProvider delayProvider; + + /// + /// Constructs a new instance of the class. + /// + public OoklaSpeedtest(OoklaSpeedtestSettings? speedtestSettings = null, HttpClient? httpClientOverride = null, IDelayProvider? delayProviderOverride = null) + { + // Use default settings when none provided + settings = speedtestSettings ?? new OoklaSpeedtestSettings(); + + httpClient = httpClientOverride ?? CreateHttpClient(settings.UseProxy, settings.ProxyAddress, settings.ProxyCredential); + delayProvider = delayProviderOverride ?? new DelayProvider(); + } + + /// + public async Task GetServersAsync(CancellationToken cancellationToken = default) + { + var serversXml = await httpClient.GetStringAsync(settings.ServerDiscovery.ServersUrl, cancellationToken).ConfigureAwait(false); + var servers = serversXml.DeserializeFromXml()?.Servers ?? Array.Empty(); + return servers.Where(s => + !string.IsNullOrWhiteSpace(s.Location) && + !string.IsNullOrWhiteSpace(s.Sponsor) && + !string.IsNullOrWhiteSpace(s.Url)).ToArray(); + } + + /// + public async Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) + { + return await GetServerLatencyAsync(serverUrl, new NullProgress(), cancellationToken); + } + + /// + public async Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl); + + var server = new Server() { Sponsor = "(Unknown)", Url = serverUrl }; + return await GetServerLatencyAsync(server, progress, cancellationToken); + } + + /// + public async Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) + { + return await GetServerLatencyAsync(server, new NullProgress(), cancellationToken); + } + + /// + public async Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentException.ThrowIfNullOrWhiteSpace(server.Url); + + var latencyUrl = GetBaseUrl(server.Url) + "latency.txt"; + var pings = new List(); + var stopwatch = new Stopwatch(); + + var maxIterations = settings.LatencyTest.LatencyTestIterations; + var intervalMilliseconds = settings.LatencyTest.LatencyTestIntervalMilliseconds; + var httpTimeoutMilliseconds = settings.LatencyTest.HttpTimeoutMilliseconds; + + for (var iteration = 0; iteration < maxIterations; iteration++) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Add delay between iterations (not before first iteration) + if (iteration > 0 && intervalMilliseconds > 0) + { + await delayProvider.DelayAsync(intervalMilliseconds, cancellationToken).ConfigureAwait(false); + } + + stopwatch.Restart(); + var testString = await httpClient.GetStringWithTimeoutAsync(latencyUrl, TimeSpan.FromMilliseconds(httpTimeoutMilliseconds), cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + if (!testString.StartsWith("test=test")) + { + throw new InvalidOperationException("Server returned incorrect test string for latency.txt"); + } + + // Record this ping time + pings.Add(stopwatch.ElapsedMilliseconds); + + // Report progress after each iteration + var percentageComplete = (iteration + 1) * 100 / maxIterations; + progress.Report(new LatencyTestProgress + { + PercentageComplete = percentageComplete, + }); + } + + // Calculate the average server latency. + var latencyResult = new LatencyTestResult + { + Server = server, + LatencyMilliseconds = (long)pings.Average() + }; + + return latencyResult; + } + + /// + public async Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default) + { + return await GetFastestServerByLatencyAsync(servers, new NullProgress(), cancellationToken); + } + + /// + public async Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(servers); + if (servers.Length == 0) + { + throw new ArgumentException("At least one server must be provided.", nameof(servers)); + } + + var serverProbes = new List(); + + for (int i = 0; i < servers.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Bump up the fastest server probe by a slight margin + // Apply a minimum threshold to prevent timeouts from becoming too aggressive + var serverTimeoutMilliseconds = serverProbes.Count == 0 + ? settings.ServerDiscovery.ServerTimeoutMilliseconds + : (int)(serverProbes.Min(p => p.LatencyMilliseconds) * 1.5); + + const int minimumTimeoutMilliseconds = 100; + var effectiveTimeout = Math.Max(minimumTimeoutMilliseconds, serverTimeoutMilliseconds); + + // Automatically cancel the server probe ar the effective timeout + using var cts = new CancellationTokenSource(); + cts.CancelAfter(effectiveTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); + + try + { + var latencyResult = await GetServerLatencyAsync(servers[i], linkedCts.Token); + + serverProbes.Add(latencyResult); + } + catch (Exception e) + { + if (e is OperationCanceledException && cancellationToken.IsCancellationRequested) + { + // Propagate user cancelled exceptions + throw; + } + + // A exception was thrown when pinging the server + // Ignore and continue with the next server + } + + // Report progress after each server is tested + var percentageComplete = (i + 1) * 100 / servers.Length; + progress.Report(new SpeedTestProgress { PercentageComplete = percentageComplete }); + } + + // Honour any user cancellations during/after the last probe. + cancellationToken.ThrowIfCancellationRequested(); + + if (serverProbes.Count == 0) + { + throw new Exception("No servers available"); + } + + return serverProbes.OrderBy(s => s.LatencyMilliseconds).First(); + } + + /// + public async Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + return await GetDownloadSpeedAsync(server, new NullProgress(), cancellationToken); + } + + /// + /// + /// In the Ookla implementation, downloads are processed in parallel batches + /// (configured via ). The total-byte + /// budget cap is read from ; + /// once the running total crosses that threshold the internal + /// is cancelled, but in-flight parallel downloads + /// complete first, so the actual bytes processed may exceed the cap depending on + /// parallelism and per-request size. + /// + public async Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentException.ThrowIfNullOrWhiteSpace(server.Url); + + var downloadUrls = GenerateDownloadUrls(server.Url, settings.DownloadTest.DownloadSizes, settings.DownloadTest.DownloadSizeIterations); + + // Download content from a specified URL and return the size of the data in bytes. + Func> DownloadAndMeasureAsync = async (client, downloadUrl, cancellationToken) => + { + // Stream the response to avoid allocating large strings for each download. + using var response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + var buffer = ArrayPool.Shared.Rent(81920); // 80KB buffer + try + { + long total = 0; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + total += bytesRead; + } + + return (int)total; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + }; + + var maxBytes = (long)settings.DownloadTest.DownloadSizeMb * 1024L * 1024L; + var downloadResult = await GenericTestSpeedAsync(downloadUrls, DownloadAndMeasureAsync, progress, settings.DownloadTest.DownloadParallelTasks, maxBytes, cancellationToken); + + return downloadResult; + } + + /// + public async Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + return await GetUploadSpeedAsync(server, new NullProgress(), cancellationToken); + } + + /// + /// + /// In the default Ookla implementation, uploads are processed in parallel batches + /// (configured via ). The total-byte + /// budget cap is read from ; + /// once the running total crosses that threshold the internal + /// is cancelled, but in-flight parallel uploads + /// complete first, so the actual bytes processed may exceed the cap depending on + /// parallelism and per-request size. + /// + public async Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentException.ThrowIfNullOrWhiteSpace(server.Url); + + // Generate upload sizes (in bytes) rather than allocating large buffers up-front. + var testDataLengths = GenerateUploadDataLengths(settings.UploadTest.UploadIncrements, settings.UploadTest.UploadSizeIncrementKb, settings.UploadTest.UploadSizeIterations); + + // Upload content to a specified URL and return the size of the data in bytes. + Func> UploadAndMeasureAsync = async (client, length, cancellationToken) => + { + // Use RandomStreamContent to stream generated random bytes in small chunks to avoid LOH allocations. + using var content = new RandomStreamContent(length); + await client.PostAsync(server.Url, content, cancellationToken).ConfigureAwait(false); + return length; + }; + + var maxBytes = (long)settings.UploadTest.UploadSizeMb * 1024L * 1024L; + var uploadResult = await GenericTestSpeedAsync(testDataLengths, UploadAndMeasureAsync, progress, settings.UploadTest.UploadParallelTasks, maxBytes, cancellationToken); + + return uploadResult; + } + + /// + /// Executes a generic speed test by processing a collection of test data in parallel, + /// measuring total bytes processed and elapsed time. + /// + private async Task GenericTestSpeedAsync( + IEnumerable testData, + Func> doWork, + IProgress progress, + int parallelTasks, + long maxBytes, + CancellationToken cancellationToken) + { + object lockObject = new(); + bool wasCancelledLocally = false; + long totalBytesReturned = 0; + + var completedCount = 0; + var totalCount = testData.Count(); + + var timer = new Stopwatch(); + var throttler = new SemaphoreSlim(parallelTasks); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + timer.Start(); + + // Create and execute tasks to process the test data in parallel. + var tasks = testData.Select(async data => + { + var bytesReturned = 0; + + try + { + // Limit concurrent executions by waiting for a permit from the semaphore. + await throttler.WaitAsync(cts.Token).ConfigureAwait(false); + + // Perform the work and retrieve the processed byte count. + bytesReturned = await doWork(httpClient, data, cts.Token).ConfigureAwait(false); + } + catch (Exception e) + { + // An exception was thrown when performing the work + // - Progress will be reported as if no failure + // - Bytes returned will be treated as zero + + if (e is OperationCanceledException && !wasCancelledLocally) + { + // Propagate user cancelled exceptions + throw; + } + } + finally + { + try + { + lock (lockObject) + { + if (!cts.IsCancellationRequested) + { + completedCount++; + totalBytesReturned += bytesReturned; + + if (totalBytesReturned >= maxBytes) + { + // User specified byte limit is hit. + wasCancelledLocally = true; + cts.Cancel(); + progress.Report(new SpeedTestProgress + { + PercentageComplete = 100, + BytesProcessed = totalBytesReturned, + ElapsedMilliseconds = timer.ElapsedMilliseconds + }); + } + else + { + // Update the completion percentage. + var percentageComplete = (int)((double)completedCount / totalCount * 100); + + if (maxBytes != long.MaxValue) + { + // When a user specified limit has been imposed on the test, + // we should defer to the greater % complete value. + + var percentageCompleteMaxBytes = (int)((double)totalBytesReturned / maxBytes * 100); + + if (percentageCompleteMaxBytes > percentageComplete) + { + percentageComplete = percentageCompleteMaxBytes; + } + } + + progress.Report(new SpeedTestProgress + { + PercentageComplete = percentageComplete, + BytesProcessed = totalBytesReturned, + ElapsedMilliseconds = timer.ElapsedMilliseconds + }); + } + } + } + } + finally + { + // Release the semaphore to allow another task to proceed. + // This must always execute, even if UpdateProgress throws. + throttler.Release(); + } + } + + return bytesReturned; + }).ToArray(); + + // Wait for all tasks to complete. + await Task.WhenAll(tasks); + timer.Stop(); + + return new SpeedTestResult + { + BytesProcessed = totalBytesReturned, + ElapsedMilliseconds = timer.ElapsedMilliseconds + }; + } + + #region Static Functions + + private static HttpClient CreateHttpClient(bool useProxy, Uri? proxyAddress, NetworkCredential? proxyCredential) + { + var handler = new HttpClientHandler(); + + if (useProxy && proxyAddress != null) + { + handler.Proxy = new WebProxy + { + Address = proxyAddress, + Credentials = proxyCredential + }; + handler.UseProxy = true; + } + else + { + handler.UseProxy = false; + } + + var httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36"); + httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html, application/xhtml+xml, */*"); + httpClient.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue { NoCache = true }; + return httpClient; + } + + /// + /// Returns the base URL (ending with a trailing slash) by removing + /// the file name and query parameters from a full URL string. + /// + /// + /// Input: "http://example.com/path/speedtest/file.jpg?x=1" + /// Output: "http://example.com/path/speedtest/" + /// + private static string GetBaseUrl(string url) + { + var uri = new Uri(url); + var baseUri = new Uri(uri, "."); + return baseUri.ToString(); + } + + /// + /// Generates numerous download URLs for the speed test. + /// + /// + /// http://manchester.speedtest.boundlessnetworks.uk:8080/speedtest/random1500x1500.jpg?r=0 + /// http://manchester.speedtest.boundlessnetworks.uk:8080/speedtest/random1500x1500.jpg?r=1 + /// ... + /// + private static IEnumerable GenerateDownloadUrls(string serverUrl, int[] downloadSizes, int downloadSizeIterations) + { + var downloadUrl = GetBaseUrl(serverUrl) + "random{0}x{0}.jpg?r={1}"; + + foreach (var downloadSize in downloadSizes) + { + for (var i = 0; i < downloadSizeIterations; i++) + { + yield return string.Format(downloadUrl, downloadSize, i); + } + } + } + + /// + /// Generate upload payload lengths (in bytes) for the upload test. + /// + private static IEnumerable GenerateUploadDataLengths(int uploadIncrements, int baseSizeKb, int repeatsPerSize) + { + for (var increment = 1; increment <= uploadIncrements; increment++) + { + int incrementSize = increment * baseSizeKb * 1024; + + for (var repeat = 0; repeat < repeatsPerSize; repeat++) + { + yield return incrementSize; + } + } + } + + /// + /// HttpContent that streams cryptographically-random bytes on demand in small chunks. + /// Avoids allocating a single large byte[] and prevents LOH allocations. + /// + private sealed class RandomStreamContent : HttpContent + { + private readonly long totalSize; + private readonly int chunkSize; + + public RandomStreamContent(long totalSize, int chunkSize = 8192) + { + this.totalSize = totalSize; + this.chunkSize = chunkSize > 0 ? chunkSize : 8192; + Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + } + + protected override bool TryComputeLength(out long length) + { + length = totalSize; + return true; + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + var buffer = ArrayPool.Shared.Rent(chunkSize); + try + { + long remaining = totalSize; + while (remaining > 0) + { + var toWrite = (int)Math.Min(buffer.Length, remaining); + RandomNumberGenerator.Fill(buffer.AsSpan(0, toWrite)); + await stream.WriteAsync(buffer.AsMemory(0, toWrite)).ConfigureAwait(false); + remaining -= toWrite; + } + + await stream.FlushAsync().ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + #endregion +} diff --git a/src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs b/src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs index 6b5208be..7bad3307 100644 --- a/src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs +++ b/src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs @@ -1,6 +1,8 @@ using System.Net; using NetPace.Core.Clients.Ookla.Settings; +namespace NetPace.Core.Clients.Ookla; + /// /// Configuration settings for the Ookla speed test implementation. /// @@ -21,12 +23,12 @@ public sealed record OoklaSpeedtestSettings /// /// Gets or sets the settings for download speed tests. /// - public DownloadTestSettings DownloadTest { get; init; } = new(); + public DownloadTestSettings DownloadTest { get; init; } /// /// Gets or sets the settings for upload speed tests. /// - public UploadTestSettings UploadTest { get; init; } = new(); + public UploadTestSettings UploadTest { get; init; } // Network options @@ -44,4 +46,42 @@ public sealed record OoklaSpeedtestSettings /// Gets or sets a value indicating whether to use a proxy for HTTP requests. /// public bool UseProxy { get; init; } + + /// + /// Builds settings for the default profile (). + /// + public OoklaSpeedtestSettings() : this(Profile.Medium) { } + + /// + /// Builds settings populated for the given profile. + /// + /// The traffic-load profile to materialise. + /// Thrown when is not a defined value. + public OoklaSpeedtestSettings(Profile profile) + { + (DownloadTest, UploadTest) = profile switch + { + Profile.Tiny => ( + new DownloadTestSettings { DownloadSizes = new[] { 350 }, DownloadSizeIterations = 1, DownloadParallelTasks = 1, DownloadSizeMb = 1 }, + new UploadTestSettings { UploadSizeIncrementKb = 50, UploadIncrements = 1, UploadSizeIterations = 1, UploadParallelTasks = 1, UploadSizeMb = 1 }), + + Profile.Small => ( + new DownloadTestSettings { DownloadSizes = new[] { 1000, 1500 }, DownloadSizeIterations = 2, DownloadParallelTasks = 2, DownloadSizeMb = 10 }, + new UploadTestSettings { UploadSizeIncrementKb = 100, UploadIncrements = 4, UploadSizeIterations = 2, UploadParallelTasks = 2, UploadSizeMb = 2 }), + + Profile.Medium => ( + new DownloadTestSettings { DownloadSizes = new[] { 1500, 2000, 3000, 3500, 4000 }, DownloadSizeIterations = 2, DownloadParallelTasks = 4, DownloadSizeMb = 100 }, + new UploadTestSettings { UploadSizeIncrementKb = 200, UploadIncrements = 6, UploadSizeIterations = 5, UploadParallelTasks = 4, UploadSizeMb = 25 }), + + Profile.Large => ( + new DownloadTestSettings { DownloadSizes = new[] { 2000, 2500, 3000, 3500, 4000 }, DownloadSizeIterations = 12, DownloadParallelTasks = 16, DownloadSizeMb = 1024 }, + new UploadTestSettings { UploadSizeIncrementKb = 500, UploadIncrements = 8, UploadSizeIterations = 12, UploadParallelTasks = 16, UploadSizeMb = 256 }), + + Profile.Mega => ( + new DownloadTestSettings { DownloadSizes = new[] { 3000, 4000, 5000, 6000, 7000 }, DownloadSizeIterations = 40, DownloadParallelTasks = 32, DownloadSizeMb = 10240 }, + new UploadTestSettings { UploadSizeIncrementKb = 1024, UploadIncrements = 16, UploadSizeIterations = 16, UploadParallelTasks = 32, UploadSizeMb = 2048 }), + + _ => throw new ArgumentOutOfRangeException(nameof(profile)), + }; + } } diff --git a/src/NetPace.Core/Clients/Ookla/Settings/DownloadTestSettings.cs b/src/NetPace.Core/Clients/Ookla/Settings/DownloadTestSettings.cs index 12362e65..4e19620b 100644 --- a/src/NetPace.Core/Clients/Ookla/Settings/DownloadTestSettings.cs +++ b/src/NetPace.Core/Clients/Ookla/Settings/DownloadTestSettings.cs @@ -9,7 +9,7 @@ public sealed record DownloadTestSettings /// A list of download sizes (in pixels) used to generate URLs for test files. /// /// - /// These sizes are used to create URLs in the form of random{size}x{size}.jpg + /// These sizes are used to create URLs in the form of random{size}x{size}.jpg /// to simulate different file sizes for measuring download throughput. /// public int[] DownloadSizes { get; init; } = { 1500, 2000, 3000, 3500, 4000 }; @@ -23,5 +23,17 @@ public sealed record DownloadTestSettings /// The number of parallel tasks used to download test data concurrently. /// public int DownloadParallelTasks { get; init; } = 8; -} + /// + /// Total-byte budget cap for the download phase, in IEC MiB. Once the running total of + /// bytes returned across all parallel downloads reaches this value, in-flight downloads + /// are allowed to complete and no further downloads are scheduled. + /// + /// + /// Distinct from , which sets the per-request pixel sizes + /// (and therefore the per-request byte sizes). caps the + /// total run; shapes each request. The default + /// sentinel means "no cap". + /// + public int DownloadSizeMb { get; init; } = int.MaxValue; +} diff --git a/src/NetPace.Core/Clients/Ookla/Settings/UploadTestSettings.cs b/src/NetPace.Core/Clients/Ookla/Settings/UploadTestSettings.cs index f37a8912..6603cef7 100644 --- a/src/NetPace.Core/Clients/Ookla/Settings/UploadTestSettings.cs +++ b/src/NetPace.Core/Clients/Ookla/Settings/UploadTestSettings.cs @@ -17,7 +17,7 @@ public sealed record UploadTestSettings /// The number of incremental upload sizes to generate for the test. /// /// - /// Each increment increases the payload size by . For example, if set to 6 and + /// Each increment increases the payload size by . For example, if set to 6 and /// BaseSizeKb is 200, it generates sizes of 200KB, 400KB, ..., up to 1.2MB. /// public int UploadIncrements { get; init; } = 6; @@ -34,4 +34,16 @@ public sealed record UploadTestSettings /// The number of parallel tasks used to upload test data concurrently. /// public int UploadParallelTasks { get; init; } = 8; + + /// + /// Total-byte budget cap for the upload phase, in IEC MiB. Once the running total of + /// bytes uploaded across all parallel uploads reaches this value, in-flight uploads + /// are allowed to complete and no further uploads are scheduled. + /// + /// + /// Distinct from the per-request size, which is derived from + /// × . + /// The default sentinel means "no cap". + /// + public int UploadSizeMb { get; init; } = int.MaxValue; } diff --git a/src/NetPace.Core/Clients/Testing/FaultySpeedTester.cs b/src/NetPace.Core/Clients/Testing/FaultySpeedTester.cs index 62cff021..a5d42729 100644 --- a/src/NetPace.Core/Clients/Testing/FaultySpeedTester.cs +++ b/src/NetPace.Core/Clients/Testing/FaultySpeedTester.cs @@ -1,150 +1,121 @@ -using NetPace.Core.Clients.Ookla; - -namespace NetPace.Core.Clients.Testing; - -/// -/// An unreliable stub implementation of that -/// simulates network calls prone to error (e.g., timeouts, connection failures). -/// -/// -/// -/// Developers should used this service to test the fault tolerance of their application. -/// -/// -/// Default behavior throws an exception when "Test Sponsor 2" is passed into -/// the GetServerLatencyAsync method. -/// -/// -public class FaultySpeedTester : ISpeedTestService -{ - private readonly ISpeedTestService inner; - private readonly Func IsFaulted; - - /// - /// Constructs a new instance. - /// - public FaultySpeedTester( - ISpeedTestService? inner = null, - Func? isFaulted = null) - { - this.inner = inner ?? new SpeedTestStub(); - this.IsFaulted = isFaulted ?? IsFaultedDefault; - } - - private static bool IsFaultedDefault(string? sponsor, string methodName) => - string.Equals(sponsor, "Test Sponsor 2", StringComparison.Ordinal) && - string.Equals(methodName, nameof(GetServerLatencyAsync), StringComparison.Ordinal); - - private void AssertNotFaulted(IServer server, string methodName) - { - if (IsFaulted(server.Sponsor, methodName)) - { - throw new Exception($"Communication with '{server.Sponsor}' has failed"); - } - } - - /// - public Task GetServersAsync(CancellationToken cancellationToken = default) - { - return inner.GetServersAsync(cancellationToken); - } - - /// - public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetServerLatencyAsync)); - return inner.GetServerLatencyAsync(server, cancellationToken); - } - - /// - public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetServerLatencyAsync)); - return inner.GetServerLatencyAsync(server, progress, cancellationToken); - } - - /// - public async Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) - { - var result = await inner.GetServerLatencyAsync(serverUrl, cancellationToken); - AssertNotFaulted(result.Server, nameof(GetServerLatencyAsync)); - return result; - } - - /// - public async Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) - { - var result = await inner.GetServerLatencyAsync(serverUrl, progress, cancellationToken); - AssertNotFaulted(result.Server, nameof(GetServerLatencyAsync)); - return result; - } - - /// - public Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default) - { - return inner.GetFastestServerByLatencyAsync(servers, cancellationToken); - } - - /// - public Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default) - { - return inner.GetFastestServerByLatencyAsync(servers, progress, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetDownloadSpeedAsync)); - return inner.GetDownloadSpeedAsync(server, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetDownloadSpeedAsync)); - return inner.GetDownloadSpeedAsync(server, downloadSizeMb, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetDownloadSpeedAsync)); - return inner.GetDownloadSpeedAsync(server, progress, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetDownloadSpeedAsync)); - return inner.GetDownloadSpeedAsync(server, downloadSizeMb, progress, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetUploadSpeedAsync)); - return inner.GetUploadSpeedAsync(server, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetUploadSpeedAsync)); - return inner.GetUploadSpeedAsync(server, uploadSizeMb, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetUploadSpeedAsync)); - return inner.GetUploadSpeedAsync(server, progress, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - AssertNotFaulted(server, nameof(GetUploadSpeedAsync)); - return inner.GetUploadSpeedAsync(server, uploadSizeMb, progress, cancellationToken); - } -} - +using NetPace.Core.Clients.Ookla; + +namespace NetPace.Core.Clients.Testing; + +/// +/// An unreliable stub implementation of that +/// simulates network calls prone to error (e.g., timeouts, connection failures). +/// +/// +/// +/// Developers should used this service to test the fault tolerance of their application. +/// +/// +/// Default behavior throws an exception when "Test Sponsor 2" is passed into +/// the GetServerLatencyAsync method. +/// +/// +public class FaultySpeedTester : ISpeedTestService +{ + private readonly ISpeedTestService inner; + private readonly Func IsFaulted; + + /// + /// Constructs a new instance. + /// + public FaultySpeedTester( + ISpeedTestService? inner = null, + Func? isFaulted = null) + { + this.inner = inner ?? new SpeedTestStub(); + this.IsFaulted = isFaulted ?? IsFaultedDefault; + } + + private static bool IsFaultedDefault(string? sponsor, string methodName) => + string.Equals(sponsor, "Test Sponsor 2", StringComparison.Ordinal) && + string.Equals(methodName, nameof(GetServerLatencyAsync), StringComparison.Ordinal); + + private void AssertNotFaulted(IServer server, string methodName) + { + if (IsFaulted(server.Sponsor, methodName)) + { + throw new Exception($"Communication with '{server.Sponsor}' has failed"); + } + } + + /// + public Task GetServersAsync(CancellationToken cancellationToken = default) + { + return inner.GetServersAsync(cancellationToken); + } + + /// + public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) + { + AssertNotFaulted(server, nameof(GetServerLatencyAsync)); + return inner.GetServerLatencyAsync(server, cancellationToken); + } + + /// + public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + AssertNotFaulted(server, nameof(GetServerLatencyAsync)); + return inner.GetServerLatencyAsync(server, progress, cancellationToken); + } + + /// + public async Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) + { + var result = await inner.GetServerLatencyAsync(serverUrl, cancellationToken); + AssertNotFaulted(result.Server, nameof(GetServerLatencyAsync)); + return result; + } + + /// + public async Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) + { + var result = await inner.GetServerLatencyAsync(serverUrl, progress, cancellationToken); + AssertNotFaulted(result.Server, nameof(GetServerLatencyAsync)); + return result; + } + + /// + public Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default) + { + return inner.GetFastestServerByLatencyAsync(servers, cancellationToken); + } + + /// + public Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default) + { + return inner.GetFastestServerByLatencyAsync(servers, progress, cancellationToken); + } + + /// + public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + AssertNotFaulted(server, nameof(GetDownloadSpeedAsync)); + return inner.GetDownloadSpeedAsync(server, cancellationToken); + } + + /// + public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + AssertNotFaulted(server, nameof(GetDownloadSpeedAsync)); + return inner.GetDownloadSpeedAsync(server, progress, cancellationToken); + } + + /// + public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + AssertNotFaulted(server, nameof(GetUploadSpeedAsync)); + return inner.GetUploadSpeedAsync(server, cancellationToken); + } + + /// + public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + AssertNotFaulted(server, nameof(GetUploadSpeedAsync)); + return inner.GetUploadSpeedAsync(server, progress, cancellationToken); + } +} diff --git a/src/NetPace.Core/Clients/Testing/SpeedTestMock.cs b/src/NetPace.Core/Clients/Testing/SpeedTestMock.cs index eb5c2859..f3103391 100644 --- a/src/NetPace.Core/Clients/Testing/SpeedTestMock.cs +++ b/src/NetPace.Core/Clients/Testing/SpeedTestMock.cs @@ -1,163 +1,131 @@ -namespace NetPace.Core.Clients.Testing; - -/// -/// A mock implementation of for testing purposes. -/// -public sealed class SpeedTestMock : ISpeedTestService -{ - /// - /// Gets or sets the delegate that provides behavior for . - /// If null, the method will throw when called. - /// - public Func>? GetServersAsyncFunc { get; set; } - - /// - /// Gets or sets the delegate that provides behavior for . - /// If null, the method will throw when called. - /// - public Func?, CancellationToken, Task>? GetServerLatencyAsyncFunc { get; set; } - - /// - /// Gets or sets the delegate that provides behavior for . - /// If null, the method will throw when called. - /// - public Func?, CancellationToken, Task>? GetServerLatencyByServerUrlAsyncFunc { get; set; } - - /// - /// Gets or sets the delegate that provides behavior for GetFastestServerByLatencyAsync overloads. - /// If null, the method will throw when called. - /// - public Func?, CancellationToken, Task>? GetFastestServerByLatencyAsyncFunc { get; set; } - - /// - /// Gets or sets the delegate that provides behavior for all GetDownloadSpeedAsync overloads. - /// If null, the methods will throw when called. - /// - public Func?, CancellationToken, Task>? GetDownloadSpeedAsyncFunc { get; set; } - - /// - /// Gets or sets the delegate that provides behavior for all GetUploadSpeedAsync overloads. - /// If null, the methods will throw when called. - /// - public Func?, CancellationToken, Task>? GetUploadSpeedAsyncFunc { get; set; } - - /// - public Task GetServersAsync(CancellationToken cancellationToken = default) - { - if (GetServersAsyncFunc != null) - return GetServersAsyncFunc(cancellationToken); - throw new NotImplementedException(nameof(GetServersAsync)); - } - - /// - public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) - { - if (GetServerLatencyAsyncFunc != null) - return GetServerLatencyAsyncFunc(server, null, cancellationToken); - throw new NotImplementedException(nameof(GetServerLatencyAsync)); - } - - /// - public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - if (GetServerLatencyAsyncFunc != null) - return GetServerLatencyAsyncFunc(server, progress, cancellationToken); - throw new NotImplementedException(nameof(GetServerLatencyAsync)); - } - - /// - public Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) - { - if (GetServerLatencyByServerUrlAsyncFunc != null) - return GetServerLatencyByServerUrlAsyncFunc(serverUrl, null, cancellationToken); - throw new NotImplementedException(nameof(GetServerLatencyAsync)); - } - - /// - public Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) - { - if (GetServerLatencyByServerUrlAsyncFunc != null) - return GetServerLatencyByServerUrlAsyncFunc(serverUrl, progress, cancellationToken); - throw new NotImplementedException(nameof(GetServerLatencyAsync)); - } - - /// - public Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default) - { - if (GetFastestServerByLatencyAsyncFunc != null) - return GetFastestServerByLatencyAsyncFunc(servers, null, cancellationToken); - throw new NotImplementedException(nameof(GetFastestServerByLatencyAsync)); - } - - /// - public Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default) - { - if (GetFastestServerByLatencyAsyncFunc != null) - return GetFastestServerByLatencyAsyncFunc(servers, progress, cancellationToken); - throw new NotImplementedException(nameof(GetFastestServerByLatencyAsync)); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - if (GetDownloadSpeedAsyncFunc != null) - return GetDownloadSpeedAsyncFunc(server, int.MaxValue, null, cancellationToken); - throw new NotImplementedException(nameof(GetDownloadSpeedAsync)); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, CancellationToken cancellationToken = default) - { - if (GetDownloadSpeedAsyncFunc != null) - return GetDownloadSpeedAsyncFunc(server, downloadSizeMb, null, cancellationToken); - throw new NotImplementedException(nameof(GetDownloadSpeedAsync)); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - if (GetDownloadSpeedAsyncFunc != null) - return GetDownloadSpeedAsyncFunc(server, int.MaxValue, progress, cancellationToken); - throw new NotImplementedException(nameof(GetDownloadSpeedAsync)); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - if (GetDownloadSpeedAsyncFunc != null) - return GetDownloadSpeedAsyncFunc(server, downloadSizeMb, progress, cancellationToken); - throw new NotImplementedException(nameof(GetDownloadSpeedAsync)); - } - - /// - public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - if (GetUploadSpeedAsyncFunc != null) - return GetUploadSpeedAsyncFunc(server, int.MaxValue, null, cancellationToken); - throw new NotImplementedException(nameof(GetUploadSpeedAsync)); - } - - /// - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, CancellationToken cancellationToken = default) - { - if (GetUploadSpeedAsyncFunc != null) - return GetUploadSpeedAsyncFunc(server, uploadSizeMb, null, cancellationToken); - throw new NotImplementedException(nameof(GetUploadSpeedAsync)); - } - - /// - public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - if (GetUploadSpeedAsyncFunc != null) - return GetUploadSpeedAsyncFunc(server, int.MaxValue, progress, cancellationToken); - throw new NotImplementedException(nameof(GetUploadSpeedAsync)); - } - - /// - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - if (GetUploadSpeedAsyncFunc != null) - return GetUploadSpeedAsyncFunc(server, uploadSizeMb, progress, cancellationToken); - throw new NotImplementedException(nameof(GetUploadSpeedAsync)); - } -} +namespace NetPace.Core.Clients.Testing; + +/// +/// A mock implementation of for testing purposes. +/// +public sealed class SpeedTestMock : ISpeedTestService +{ + /// + /// Gets or sets the delegate that provides behavior for . + /// If null, the method will throw when called. + /// + public Func>? GetServersAsyncFunc { get; set; } + + /// + /// Gets or sets the delegate that provides behavior for . + /// If null, the method will throw when called. + /// + public Func?, CancellationToken, Task>? GetServerLatencyAsyncFunc { get; set; } + + /// + /// Gets or sets the delegate that provides behavior for . + /// If null, the method will throw when called. + /// + public Func?, CancellationToken, Task>? GetServerLatencyByServerUrlAsyncFunc { get; set; } + + /// + /// Gets or sets the delegate that provides behavior for GetFastestServerByLatencyAsync overloads. + /// If null, the method will throw when called. + /// + public Func?, CancellationToken, Task>? GetFastestServerByLatencyAsyncFunc { get; set; } + + /// + /// Gets or sets the delegate that provides behavior for all GetDownloadSpeedAsync overloads. + /// If null, the methods will throw when called. + /// + public Func?, CancellationToken, Task>? GetDownloadSpeedAsyncFunc { get; set; } + + /// + /// Gets or sets the delegate that provides behavior for all GetUploadSpeedAsync overloads. + /// If null, the methods will throw when called. + /// + public Func?, CancellationToken, Task>? GetUploadSpeedAsyncFunc { get; set; } + + /// + public Task GetServersAsync(CancellationToken cancellationToken = default) + { + if (GetServersAsyncFunc != null) + return GetServersAsyncFunc(cancellationToken); + throw new NotImplementedException(nameof(GetServersAsync)); + } + + /// + public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) + { + if (GetServerLatencyAsyncFunc != null) + return GetServerLatencyAsyncFunc(server, null, cancellationToken); + throw new NotImplementedException(nameof(GetServerLatencyAsync)); + } + + /// + public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + if (GetServerLatencyAsyncFunc != null) + return GetServerLatencyAsyncFunc(server, progress, cancellationToken); + throw new NotImplementedException(nameof(GetServerLatencyAsync)); + } + + /// + public Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) + { + if (GetServerLatencyByServerUrlAsyncFunc != null) + return GetServerLatencyByServerUrlAsyncFunc(serverUrl, null, cancellationToken); + throw new NotImplementedException(nameof(GetServerLatencyAsync)); + } + + /// + public Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) + { + if (GetServerLatencyByServerUrlAsyncFunc != null) + return GetServerLatencyByServerUrlAsyncFunc(serverUrl, progress, cancellationToken); + throw new NotImplementedException(nameof(GetServerLatencyAsync)); + } + + /// + public Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default) + { + if (GetFastestServerByLatencyAsyncFunc != null) + return GetFastestServerByLatencyAsyncFunc(servers, null, cancellationToken); + throw new NotImplementedException(nameof(GetFastestServerByLatencyAsync)); + } + + /// + public Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default) + { + if (GetFastestServerByLatencyAsyncFunc != null) + return GetFastestServerByLatencyAsyncFunc(servers, progress, cancellationToken); + throw new NotImplementedException(nameof(GetFastestServerByLatencyAsync)); + } + + /// + public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + if (GetDownloadSpeedAsyncFunc != null) + return GetDownloadSpeedAsyncFunc(server, null, cancellationToken); + throw new NotImplementedException(nameof(GetDownloadSpeedAsync)); + } + + /// + public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + if (GetDownloadSpeedAsyncFunc != null) + return GetDownloadSpeedAsyncFunc(server, progress, cancellationToken); + throw new NotImplementedException(nameof(GetDownloadSpeedAsync)); + } + + /// + public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + if (GetUploadSpeedAsyncFunc != null) + return GetUploadSpeedAsyncFunc(server, null, cancellationToken); + throw new NotImplementedException(nameof(GetUploadSpeedAsync)); + } + + /// + public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + if (GetUploadSpeedAsyncFunc != null) + return GetUploadSpeedAsyncFunc(server, progress, cancellationToken); + throw new NotImplementedException(nameof(GetUploadSpeedAsync)); + } +} diff --git a/src/NetPace.Core/Clients/Testing/SpeedTestStub.cs b/src/NetPace.Core/Clients/Testing/SpeedTestStub.cs index a366fef4..d7d269a6 100644 --- a/src/NetPace.Core/Clients/Testing/SpeedTestStub.cs +++ b/src/NetPace.Core/Clients/Testing/SpeedTestStub.cs @@ -1,199 +1,175 @@ -using NetPace.Core.Clients.Ookla; - -namespace NetPace.Core.Clients.Testing; - -/// -/// A stub implementation of for testing purposes. -/// -public sealed class SpeedTestStub : ISpeedTestService -{ - private readonly IServer[] servers = new IServer[] - { - new Server { Location = "Location 1", Sponsor = "Test Sponsor 1", Url = "http://test1.com" }, - new Server { Location = "Location 2", Sponsor = "Test Sponsor 2", Url = "http://test2.com" }, - new Server { Location = "Location 3", Sponsor = "Test Sponsor 3", Url = "http://test3.com" }, - }; - - private readonly int delayMilliseconds = 0; - - /// - /// Constructs a new instance. - /// - public SpeedTestStub() { } - - /// - /// Constructs a new instance with a specified delay for progress updates. - /// - public SpeedTestStub(int delayMilliseconds) - { - this.delayMilliseconds = delayMilliseconds; - } - - private int GetServerID(string serverUrl) - { - // First see if we can match the server on our 'pre-canned list' - var matched = servers.FirstOrDefault(s => s.Url.Equals(serverUrl)); - - return matched != null - ? int.Parse(matched.Sponsor!.Replace("Test Sponsor ", "")) - : 10; - } - - /// - public Task GetServersAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(servers); - } - - /// - public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) - { - return GetServerLatencyAsync(server, new NullProgress(), cancellationToken); - } - - /// - public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new LatencyTestProgress { PercentageComplete = 25 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new LatencyTestProgress { PercentageComplete = 50 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new LatencyTestProgress { PercentageComplete = 75 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new LatencyTestProgress { PercentageComplete = 100 }); - - var serverID = GetServerID(server.Url); - - var latencyResult = new LatencyTestResult - { - Server = server, - LatencyMilliseconds = serverID * 100 - }; - - return Task.FromResult(latencyResult); - } - - /// - public Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) - { - return GetServerLatencyAsync(serverUrl, new NullProgress(), cancellationToken); - } - - /// - public Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) - { - var server = new Server() { Location = "(Unknown)", Sponsor = "(Unknown)", Url = serverUrl }; - - return GetServerLatencyAsync(server, progress, cancellationToken); - } - - /// - public Task GetFastestServerByLatencyAsync(IServer[] ignoredServers, CancellationToken cancellationToken = default) - { - return GetFastestServerByLatencyAsync(ignoredServers, new NullProgress(), cancellationToken); - } - - /// - public Task GetFastestServerByLatencyAsync(IServer[] ignoredServers, IProgress progress, CancellationToken cancellationToken = default) - { - if (progress is not null) - { - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 33 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 66 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 100 }); - } - - // The fastest server in this stub is always the first one. - var server = servers[0]; - - var serverID = GetServerID(server.Url); - - var latencyResult = new LatencyTestResult - { - Server = server, - LatencyMilliseconds = serverID * 100 - }; - - return Task.FromResult(latencyResult); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - return GetDownloadSpeedAsync(server, new NullProgress(), cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, CancellationToken cancellationToken = default) - { - return GetDownloadSpeedAsync(server, downloadSizeMb, new NullProgress(), cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - return GetDownloadSpeedAsync(server, int.MaxValue, progress, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - if (progress is not null) - { - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 25 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 50 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 75 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 100 }); - } - - var serverID = GetServerID(server.Url); - - return Task.FromResult(new SpeedTestResult() { BytesProcessed = 1000, ElapsedMilliseconds = 1000 * serverID }); - } - - /// - public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - return GetUploadSpeedAsync(server, new NullProgress(), cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, CancellationToken cancellationToken = default) - { - return GetUploadSpeedAsync(server, uploadSizeMb, new NullProgress(), cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - return GetUploadSpeedAsync(server, int.MaxValue, progress, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - if (progress is not null) - { - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 25 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 50 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 75 }); - Task.Delay(delayMilliseconds).Wait(); - progress.Report(new SpeedTestProgress { PercentageComplete = 100 }); - } - - var serverID = GetServerID(server.Url); - - return Task.FromResult(new SpeedTestResult() { BytesProcessed = 7000, ElapsedMilliseconds = 3000 * serverID }); - } -} +using NetPace.Core.Clients.Ookla; + +namespace NetPace.Core.Clients.Testing; + +/// +/// A stub implementation of for testing purposes. +/// +public sealed class SpeedTestStub : ISpeedTestService +{ + private readonly IServer[] servers = new IServer[] + { + new Server { Location = "Location 1", Sponsor = "Test Sponsor 1", Url = "http://test1.com" }, + new Server { Location = "Location 2", Sponsor = "Test Sponsor 2", Url = "http://test2.com" }, + new Server { Location = "Location 3", Sponsor = "Test Sponsor 3", Url = "http://test3.com" }, + }; + + private readonly int delayMilliseconds = 0; + + /// + /// Constructs a new instance. + /// + public SpeedTestStub() { } + + /// + /// Constructs a new instance with a specified delay for progress updates. + /// + public SpeedTestStub(int delayMilliseconds) + { + this.delayMilliseconds = delayMilliseconds; + } + + private int GetServerID(string serverUrl) + { + // First see if we can match the server on our 'pre-canned list' + var matched = servers.FirstOrDefault(s => s.Url.Equals(serverUrl)); + + return matched != null + ? int.Parse(matched.Sponsor!.Replace("Test Sponsor ", "")) + : 10; + } + + /// + public Task GetServersAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(servers); + } + + /// + public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) + { + return GetServerLatencyAsync(server, new NullProgress(), cancellationToken); + } + + /// + public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new LatencyTestProgress { PercentageComplete = 25 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new LatencyTestProgress { PercentageComplete = 50 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new LatencyTestProgress { PercentageComplete = 75 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new LatencyTestProgress { PercentageComplete = 100 }); + + var serverID = GetServerID(server.Url); + + var latencyResult = new LatencyTestResult + { + Server = server, + LatencyMilliseconds = serverID * 100 + }; + + return Task.FromResult(latencyResult); + } + + /// + public Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) + { + return GetServerLatencyAsync(serverUrl, new NullProgress(), cancellationToken); + } + + /// + public Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) + { + var server = new Server() { Location = "(Unknown)", Sponsor = "(Unknown)", Url = serverUrl }; + + return GetServerLatencyAsync(server, progress, cancellationToken); + } + + /// + public Task GetFastestServerByLatencyAsync(IServer[] ignoredServers, CancellationToken cancellationToken = default) + { + return GetFastestServerByLatencyAsync(ignoredServers, new NullProgress(), cancellationToken); + } + + /// + public Task GetFastestServerByLatencyAsync(IServer[] ignoredServers, IProgress progress, CancellationToken cancellationToken = default) + { + if (progress is not null) + { + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 33 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 66 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 100 }); + } + + // The fastest server in this stub is always the first one. + var server = servers[0]; + + var serverID = GetServerID(server.Url); + + var latencyResult = new LatencyTestResult + { + Server = server, + LatencyMilliseconds = serverID * 100 + }; + + return Task.FromResult(latencyResult); + } + + /// + public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + return GetDownloadSpeedAsync(server, new NullProgress(), cancellationToken); + } + + /// + public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + if (progress is not null) + { + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 25 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 50 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 75 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 100 }); + } + + var serverID = GetServerID(server.Url); + + return Task.FromResult(new SpeedTestResult() { BytesProcessed = 1000, ElapsedMilliseconds = 1000 * serverID }); + } + + /// + public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + return GetUploadSpeedAsync(server, new NullProgress(), cancellationToken); + } + + /// + public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + if (progress is not null) + { + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 25 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 50 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 75 }); + Task.Delay(delayMilliseconds).Wait(); + progress.Report(new SpeedTestProgress { PercentageComplete = 100 }); + } + + var serverID = GetServerID(server.Url); + + return Task.FromResult(new SpeedTestResult() { BytesProcessed = 7000, ElapsedMilliseconds = 3000 * serverID }); + } +} diff --git a/src/NetPace.Core/Clients/Testing/VariableSpeedTester.cs b/src/NetPace.Core/Clients/Testing/VariableSpeedTester.cs index 826bb5a9..9e2cd431 100644 --- a/src/NetPace.Core/Clients/Testing/VariableSpeedTester.cs +++ b/src/NetPace.Core/Clients/Testing/VariableSpeedTester.cs @@ -1,168 +1,143 @@ -namespace NetPace.Core.Clients.Testing; - -/// -/// An implementation of that -/// simulates fluctuating speeds across successive network speed tests. -/// -public class VariableSpeedTester : ISpeedTestService -{ - private int callCount = 0; - private readonly ISpeedTestService inner; - - /// - /// Constructs a new instance. - /// - public VariableSpeedTester() - { - // Create a custom speed test service that returns different results for each call - inner = new SpeedTestMock - { - GetServersAsyncFunc = _ => Task.FromResult(new IServer[] - { - new Server { Location = "Test Location", Sponsor = "Test Sponsor", Url = "http://test.com" } - }), - - GetFastestServerByLatencyAsyncFunc = (servers, _, _) => - { - callCount++; - - // The first server is always the fastest - var server = servers[0]; - - LatencyTestResult result = callCount switch - { - 1 => new LatencyTestResult { Server = server, LatencyMilliseconds = 75 }, - 2 => new LatencyTestResult { Server = server, LatencyMilliseconds = 100 }, - 3 => new LatencyTestResult { Server = server, LatencyMilliseconds = 150 }, - _ => new LatencyTestResult { Server = server, LatencyMilliseconds = 100 }, - }; - return Task.FromResult(result); - }, - - GetDownloadSpeedAsyncFunc = (server, _, _, _) => - { - // Call 1: 31,250 bytes in 1 second = 250,000 bits/second = 0.25 Mbps - // Call 2: 125,000 bytes in 1 second = 1,000,000 bits/second = 1.0 Mbps - // Call 3: 343,750 bytes in 1 second = 2,750,000 bits/second = 2.75 Mbps - // Call 4+: 125,000 bytes in 1 second = 1,000,000 bits/second = 1.0 Mbps - - SpeedTestResult result = callCount switch - { - 1 => new SpeedTestResult { BytesProcessed = 31250, ElapsedMilliseconds = 1000 }, - 2 => new SpeedTestResult { BytesProcessed = 125000, ElapsedMilliseconds = 1000 }, - 3 => new SpeedTestResult { BytesProcessed = 343750, ElapsedMilliseconds = 1000 }, - _ => new SpeedTestResult { BytesProcessed = 125000, ElapsedMilliseconds = 1000 } - }; - return Task.FromResult(result); - }, - - GetUploadSpeedAsyncFunc = (server, _, _, _) => - { - // Call 1: 62,500 bytes in 1 second = 500,000 bits/second = 0.5 Mbps - // Call 2: 375,000 bytes in 1 second = 3,000,000 bits/second = 3.0 Mbps - // Call 3: 166,250 bytes in 1 second = 1,330,000 bits/second = 1.33 Mbps - // Call 4+: 375,000 bytes in 1 second = 3,000,000 bits/second = 3.0 Mbps - - SpeedTestResult result = callCount switch - { - 1 => new SpeedTestResult { BytesProcessed = 62500, ElapsedMilliseconds = 1000 }, - 2 => new SpeedTestResult { BytesProcessed = 375000, ElapsedMilliseconds = 1000 }, - 3 => new SpeedTestResult { BytesProcessed = 166250, ElapsedMilliseconds = 1000 }, - _ => new SpeedTestResult { BytesProcessed = 375000, ElapsedMilliseconds = 1000 } - }; - return Task.FromResult(result); - } - }; - } - - /// - public Task GetServersAsync(CancellationToken cancellationToken = default) - { - return inner.GetServersAsync(cancellationToken); - } - - /// - public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) - { - return inner.GetServerLatencyAsync(server, cancellationToken); - } - - /// - public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - return inner.GetServerLatencyAsync(server, progress, cancellationToken); - } - - /// - public Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) - { - return inner.GetServerLatencyAsync(serverUrl, cancellationToken); - } - - /// - public Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) - { - return inner.GetServerLatencyAsync(serverUrl, progress, cancellationToken); - } - - /// - public Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default) - { - return inner.GetFastestServerByLatencyAsync(servers, cancellationToken); - } - - /// - public Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default) - { - return inner.GetFastestServerByLatencyAsync(servers, progress, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - return inner.GetDownloadSpeedAsync(server, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, CancellationToken cancellationToken = default) - { - return inner.GetDownloadSpeedAsync(server, downloadSizeMb, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - return inner.GetDownloadSpeedAsync(server, progress, cancellationToken); - } - - /// - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - return inner.GetDownloadSpeedAsync(server, downloadSizeMb, progress, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) - { - return inner.GetUploadSpeedAsync(server, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, CancellationToken cancellationToken = default) - { - return inner.GetUploadSpeedAsync(server, uploadSizeMb, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) - { - return inner.GetUploadSpeedAsync(server, progress, cancellationToken); - } - - /// - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, IProgress progress, CancellationToken cancellationToken = default) - { - return inner.GetUploadSpeedAsync(server, uploadSizeMb, progress, cancellationToken); - } -} - +namespace NetPace.Core.Clients.Testing; + +/// +/// An implementation of that +/// simulates fluctuating speeds across successive network speed tests. +/// +public class VariableSpeedTester : ISpeedTestService +{ + private int callCount = 0; + private readonly ISpeedTestService inner; + + /// + /// Constructs a new instance. + /// + public VariableSpeedTester() + { + // Create a custom speed test service that returns different results for each call + inner = new SpeedTestMock + { + GetServersAsyncFunc = _ => Task.FromResult(new IServer[] + { + new Server { Location = "Test Location", Sponsor = "Test Sponsor", Url = "http://test.com" } + }), + + GetFastestServerByLatencyAsyncFunc = (servers, _, _) => + { + callCount++; + + // The first server is always the fastest + var server = servers[0]; + + LatencyTestResult result = callCount switch + { + 1 => new LatencyTestResult { Server = server, LatencyMilliseconds = 75 }, + 2 => new LatencyTestResult { Server = server, LatencyMilliseconds = 100 }, + 3 => new LatencyTestResult { Server = server, LatencyMilliseconds = 150 }, + _ => new LatencyTestResult { Server = server, LatencyMilliseconds = 100 }, + }; + return Task.FromResult(result); + }, + + GetDownloadSpeedAsyncFunc = (server, _, _) => + { + // Call 1: 31,250 bytes in 1 second = 250,000 bits/second = 0.25 Mbps + // Call 2: 125,000 bytes in 1 second = 1,000,000 bits/second = 1.0 Mbps + // Call 3: 343,750 bytes in 1 second = 2,750,000 bits/second = 2.75 Mbps + // Call 4+: 125,000 bytes in 1 second = 1,000,000 bits/second = 1.0 Mbps + + SpeedTestResult result = callCount switch + { + 1 => new SpeedTestResult { BytesProcessed = 31250, ElapsedMilliseconds = 1000 }, + 2 => new SpeedTestResult { BytesProcessed = 125000, ElapsedMilliseconds = 1000 }, + 3 => new SpeedTestResult { BytesProcessed = 343750, ElapsedMilliseconds = 1000 }, + _ => new SpeedTestResult { BytesProcessed = 125000, ElapsedMilliseconds = 1000 } + }; + return Task.FromResult(result); + }, + + GetUploadSpeedAsyncFunc = (server, _, _) => + { + // Call 1: 62,500 bytes in 1 second = 500,000 bits/second = 0.5 Mbps + // Call 2: 375,000 bytes in 1 second = 3,000,000 bits/second = 3.0 Mbps + // Call 3: 166,250 bytes in 1 second = 1,330,000 bits/second = 1.33 Mbps + // Call 4+: 375,000 bytes in 1 second = 3,000,000 bits/second = 3.0 Mbps + + SpeedTestResult result = callCount switch + { + 1 => new SpeedTestResult { BytesProcessed = 62500, ElapsedMilliseconds = 1000 }, + 2 => new SpeedTestResult { BytesProcessed = 375000, ElapsedMilliseconds = 1000 }, + 3 => new SpeedTestResult { BytesProcessed = 166250, ElapsedMilliseconds = 1000 }, + _ => new SpeedTestResult { BytesProcessed = 375000, ElapsedMilliseconds = 1000 } + }; + return Task.FromResult(result); + } + }; + } + + /// + public Task GetServersAsync(CancellationToken cancellationToken = default) + { + return inner.GetServersAsync(cancellationToken); + } + + /// + public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default) + { + return inner.GetServerLatencyAsync(server, cancellationToken); + } + + /// + public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + return inner.GetServerLatencyAsync(server, progress, cancellationToken); + } + + /// + public Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default) + { + return inner.GetServerLatencyAsync(serverUrl, cancellationToken); + } + + /// + public Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default) + { + return inner.GetServerLatencyAsync(serverUrl, progress, cancellationToken); + } + + /// + public Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default) + { + return inner.GetFastestServerByLatencyAsync(servers, cancellationToken); + } + + /// + public Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default) + { + return inner.GetFastestServerByLatencyAsync(servers, progress, cancellationToken); + } + + /// + public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + return inner.GetDownloadSpeedAsync(server, cancellationToken); + } + + /// + public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + return inner.GetDownloadSpeedAsync(server, progress, cancellationToken); + } + + /// + public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default) + { + return inner.GetUploadSpeedAsync(server, cancellationToken); + } + + /// + public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default) + { + return inner.GetUploadSpeedAsync(server, progress, cancellationToken); + } +} diff --git a/src/NetPace.Core/ISpeedTestService.cs b/src/NetPace.Core/ISpeedTestService.cs index eebc01b7..d831cb21 100644 --- a/src/NetPace.Core/ISpeedTestService.cs +++ b/src/NetPace.Core/ISpeedTestService.cs @@ -1,142 +1,104 @@ -namespace NetPace.Core; - -/// -/// Interface for performing internet speed tests. -/// -/// -/// Implementations of this interface should favor allowing network-related exceptions (e.g., timeouts, connection failures) -/// to propagate to the caller rather than catching and suppressing them. This approach enables consumers of the library -/// to implement their own error handling strategies that align with their application's needs. -/// -public interface ISpeedTestService -{ - /// - /// Retrieves a list of available test servers. - /// - /// The token to allow the operation to be cancelled. - /// An array of available servers that can be used for speed testing. - public Task GetServersAsync(CancellationToken cancellationToken = default); - - /// - /// Measures the network latency (ping) to the specified server. - /// - /// The server to measure latency against. - /// The token to allow the operation to be cancelled. - /// The server and its latency in milliseconds. - public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default); - - /// - /// Measures the network latency (ping) to the specified server. - /// - /// The server to measure latency against. - /// A progress reporter that receives latency test progress updates. - /// The token to allow the operation to be cancelled. - /// The server and its latency in milliseconds. - public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default); - - /// - /// Measures the network latency (ping) to the specified server. - /// - /// The server to measure latency against. - /// The token to allow the operation to be cancelled. - /// The server and its latency in milliseconds. - public Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default); - - /// - /// Measures the network latency (ping) to the specified server. - /// - /// The server URL to measure latency against. - /// A progress reporter that receives latency test progress updates. - /// The token to allow the operation to be cancelled. - /// The server and its latency in milliseconds. - public Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default); - - /// - /// Determines the fastest server based on latency from a given list of servers. - /// - /// An array of servers to test for latency. - /// The token to allow the operation to be cancelled. - /// The server with the lowest latency and its latency in milliseconds. - public Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default); - - /// - /// Determines the fastest server based on latency from a given list of servers. - /// - /// An array of servers to test for latency. - /// A progress reporter that receives server selection progress updates. - /// The token to allow the operation to be cancelled. - /// The server with the lowest latency and its latency in milliseconds. - public Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default); - - /// - /// Measures the download speed of the specified server. - /// - /// The server to measure download speed from. - /// The token to allow the operation to be cancelled. - /// The result including bytes processed and elapsed time in milliseconds. - public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default); - - /// - /// Measures the download speed of the specified server. - /// - /// The server to measure download speed from. - /// The size upon which to terminate the download test (IEC MiB). - /// The token to allow the operation to be cancelled. - /// The result including bytes processed and elapsed time in milliseconds. - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, CancellationToken cancellationToken = default); - - /// - /// Measures the download speed of the specified server. - /// - /// The server to measure download speed from. - /// A progress reporter that receives download progress updates. - /// The token to allow the operation to be cancelled. - /// The result including bytes processed and elapsed time in milliseconds. - public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default); - - /// - /// Measures the download speed of the specified server. - /// - /// The server to measure download speed from. - /// The size upon which to terminate the download test (IEC MiB). - /// A progress reporter that receives download progress updates. - /// The token to allow the operation to be cancelled. - /// The result including bytes processed and elapsed time in milliseconds. - public Task GetDownloadSpeedAsync(IServer server, int downloadSizeMb, IProgress progress, CancellationToken cancellationToken = default); - - /// - /// Measures the upload speed of the specified server. - /// - /// The server to measure upload speed from. - /// The token to allow the operation to be cancelled. - /// The result including bytes processed and elapsed time in milliseconds. - public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default); - - /// - /// Measures the upload speed of the specified server. - /// - /// The server to measure upload speed from. - /// The size upon which to terminate the upload test (IEC MiB). - /// The token to allow the operation to be cancelled. - /// The result including bytes processed and elapsed time in milliseconds. - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, CancellationToken cancellationToken = default); - - /// - /// Measures the upload speed of the specified server. - /// - /// The server to measure upload speed from. - /// A progress reporter that receives upload progress updates. - /// The token to allow the operation to be cancelled. - /// The result including bytes processed and elapsed time in milliseconds. - public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default); - - /// - /// Measures the upload speed of the specified server. - /// - /// The server to measure upload speed from. - /// The size upon which to terminate the upload test (IEC MiB). - /// A progress reporter that receives upload progress updates. - /// The token to allow the operation to be cancelled. - /// The result including bytes processed and elapsed time in milliseconds. - public Task GetUploadSpeedAsync(IServer server, int uploadSizeMb, IProgress progress, CancellationToken cancellationToken = default); -} \ No newline at end of file +namespace NetPace.Core; + +/// +/// Interface for performing internet speed tests. +/// +/// +/// Implementations of this interface should favor allowing network-related exceptions (e.g., timeouts, connection failures) +/// to propagate to the caller rather than catching and suppressing them. This approach enables consumers of the library +/// to implement their own error handling strategies that align with their application's needs. +/// +public interface ISpeedTestService +{ + /// + /// Retrieves a list of available test servers. + /// + /// The token to allow the operation to be cancelled. + /// An array of available servers that can be used for speed testing. + public Task GetServersAsync(CancellationToken cancellationToken = default); + + /// + /// Measures the network latency (ping) to the specified server. + /// + /// The server to measure latency against. + /// The token to allow the operation to be cancelled. + /// The server and its latency in milliseconds. + public Task GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default); + + /// + /// Measures the network latency (ping) to the specified server. + /// + /// The server to measure latency against. + /// A progress reporter that receives latency test progress updates. + /// The token to allow the operation to be cancelled. + /// The server and its latency in milliseconds. + public Task GetServerLatencyAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default); + + /// + /// Measures the network latency (ping) to the specified server. + /// + /// The server to measure latency against. + /// The token to allow the operation to be cancelled. + /// The server and its latency in milliseconds. + public Task GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default); + + /// + /// Measures the network latency (ping) to the specified server. + /// + /// The server URL to measure latency against. + /// A progress reporter that receives latency test progress updates. + /// The token to allow the operation to be cancelled. + /// The server and its latency in milliseconds. + public Task GetServerLatencyAsync(string serverUrl, IProgress progress, CancellationToken cancellationToken = default); + + /// + /// Determines the fastest server based on latency from a given list of servers. + /// + /// An array of servers to test for latency. + /// The token to allow the operation to be cancelled. + /// The server with the lowest latency and its latency in milliseconds. + public Task GetFastestServerByLatencyAsync(IServer[] servers, CancellationToken cancellationToken = default); + + /// + /// Determines the fastest server based on latency from a given list of servers. + /// + /// An array of servers to test for latency. + /// A progress reporter that receives server selection progress updates. + /// The token to allow the operation to be cancelled. + /// The server with the lowest latency and its latency in milliseconds. + public Task GetFastestServerByLatencyAsync(IServer[] servers, IProgress progress, CancellationToken cancellationToken = default); + + /// + /// Measures the download speed of the specified server. + /// + /// The server to measure download speed from. + /// The token to allow the operation to be cancelled. + /// The result including bytes processed and elapsed time in milliseconds. + public Task GetDownloadSpeedAsync(IServer server, CancellationToken cancellationToken = default); + + /// + /// Measures the download speed of the specified server. + /// + /// The server to measure download speed from. + /// A progress reporter that receives download progress updates. + /// The token to allow the operation to be cancelled. + /// The result including bytes processed and elapsed time in milliseconds. + public Task GetDownloadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default); + + /// + /// Measures the upload speed of the specified server. + /// + /// The server to measure upload speed from. + /// The token to allow the operation to be cancelled. + /// The result including bytes processed and elapsed time in milliseconds. + public Task GetUploadSpeedAsync(IServer server, CancellationToken cancellationToken = default); + + /// + /// Measures the upload speed of the specified server. + /// + /// The server to measure upload speed from. + /// A progress reporter that receives upload progress updates. + /// The token to allow the operation to be cancelled. + /// The result including bytes processed and elapsed time in milliseconds. + public Task GetUploadSpeedAsync(IServer server, IProgress progress, CancellationToken cancellationToken = default); +} diff --git a/src/NetPace.Core/Profile.cs b/src/NetPace.Core/Profile.cs new file mode 100644 index 00000000..4f004d22 --- /dev/null +++ b/src/NetPace.Core/Profile.cs @@ -0,0 +1,29 @@ +namespace NetPace.Core; + +/// +/// Provider-agnostic vocabulary describing the intent of a speed-test run — +/// how much traffic to generate and how aggressively. Each provider's settings +/// record translates these labels into provider-specific values. +/// +public enum Profile +{ + /// Typical home broadband (≤ ~100 MiB down + ~21 MiB up per run). Value 0 so default(Profile) resolves here. + Medium = 0, + + /// IoT / 10 MB-month plans (≤ ~245 KB down + ~50 KB up per run). + Tiny = 1, + + /// Cellular / metered (≤ ~10 MiB down + ~2 MiB up per run). + Small = 2, + + /// Fibre / business (≤ ~1 GiB down + ~211 MiB up per run). + Large = 3, + + /// + /// Inter-DC / 10 Gbps saturation (≤ ~10 GiB down + ~2 GiB up per run). + /// Uses undocumented OoklaServer payloads (5000/6000/7000) which are not part + /// of the historic Speedtest.net Flash-client array. May break on future + /// OoklaServer releases — see docs/architecture/download-upload-size-controls.md. + /// + Mega = 4, +}