Skip to content

Add --profile CLI switch (Tiny/Small/Medium/Large/Mega) #174

@FrankRay78

Description

@FrankRay78

Summary

Add a --profile CLI switch with five profiles — Tiny, Small, Medium, Large, Mega — that select a coherent bundle of Ookla client settings (per-request sizes, iterations, parallelism, and total-byte budget caps) tailored to a target audience, ranging from IoT devices on 10 MB/month plans to inter-data-centre 10 Gbps fibre saturation. Medium becomes the new default. Profiles are exposed as a public NetPace.Core API so library/NuGet consumers benefit alongside the CLI.

Motivation

NetPace today exposes only two CLI knobs that influence payload size: --downloadsize and --uploadsize. Both are total-byte budget caps (default int.MaxValue), not per-request controls — see docs/architecture/download-upload-size-controls.md. The actual per-request sizes (DownloadSizes, DownloadSizeIterations, DownloadParallelTasks, UploadSizeIncrementKb, etc.) are hardcoded in OoklaSpeedtestSettings and reachable only from the library API.

Concrete consequences:

  • A user on a 10 MB/month IoT data plan cannot run NetPace at all without burning >30× their monthly allowance — defaults transfer ≈ 328 MiB down + ≈ 41 MiB up = ≈ 370 MiB per run.
  • A fibre/DC user wanting to saturate a 10 Gbps link cannot easily push parallelism above 8 or sustain transfer beyond ~370 MiB without dropping into the library API.
  • The two budget-cap flags do not protect against per-request fan-out — --downloadsize 1 still spawns 8 parallel HTTP GETs against full-sized JPEGs before the cap kicks in.

A single --profile knob that bundles all relevant settings collapses these concerns into one user-visible decision and makes NetPace usable across the full spectrum from IoT to inter-DC.

Proposal

Public Profile enum (NetPace.Core, provider-agnostic)

Profile is a domain-level vocabulary describing the intent of a test run (how much traffic, how aggressive). It carries no payload semantics on its own — it is just a set of labels. Each provider supplies its own translation of these labels into provider-specific settings. The enum lives at the top of NetPace.Core alongside the existing domain enums:

namespace NetPace.Core;

public enum Profile
{
    Tiny,
    Small,
    Medium,
    Large,
    Mega
}

File path: src/NetPace.Core/Profile.cs — sibling of SpeedUnit, SpeedScale, SpeedUnitSystem. Mirrors their enum-backed CLI option pattern.

Profile → settings via constructor on OoklaSpeedtestSettings

Profile stays a pure provider-agnostic enum — no methods returning provider types, no awareness of any concrete provider.

OoklaSpeedtestSettings exposes two public constructors: a parameterless one that defaults to Profile.Medium, and a Profile-taking one that contains the entire profile → download/upload mapping inline as a single switch expression. There is no separate helper class.

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; }
    // proxy settings unchanged

    /// <summary>Builds settings for the default profile (<see cref="Profile.Medium"/>).</summary>
    public OoklaSpeedtestSettings() : this(Profile.Medium) { }

    /// <summary>Builds settings populated for the given profile.</summary>
    public OoklaSpeedtestSettings(Profile profile)
    {
        (DownloadTest, UploadTest) = profile switch
        {
            Profile.Tiny => (
                new DownloadTestSettings { /* Tiny download */ },
                new UploadTestSettings   { /* Tiny upload   */ }),
            Profile.Small => (
                new DownloadTestSettings { /* Small download */ },
                new UploadTestSettings   { /* Small upload   */ }),
            Profile.Medium => (
                new DownloadTestSettings { /* Medium download */ },
                new UploadTestSettings   { /* Medium upload   */ }),
            Profile.Large => (
                new DownloadTestSettings { /* Large download */ },
                new UploadTestSettings   { /* Large upload   */ }),
            Profile.Mega => (
                new DownloadTestSettings { /* Mega download */ },
                new UploadTestSettings   { /* Mega upload   */ }),
            _ => throw new ArgumentOutOfRangeException(nameof(profile)),
        };
    }
}

File path: src/NetPace.Core/Clients/Ookla/OoklaSpeedtestSettings.cs (existing file — gains the two constructors and the inline switch; DownloadTest / UploadTest lose their property initializers since the constructor now sets them).

Usage patterns:

// Default — Medium
var s = new OoklaSpeedtestSettings();

// Explicit profile
var s = new OoklaSpeedtestSettings(Profile.Tiny);

// Profile + non-payload customisation (e.g. proxy)
var s = new OoklaSpeedtestSettings(Profile.Mega) with
{
    UseProxy = true,
    ProxyAddress = new Uri("http://proxy.example.com:8080"),
};

// Profile + tweaked download cap (Mega but capped at 100 MiB instead of 1 GiB)
var baseSettings = new OoklaSpeedtestSettings(Profile.Mega);
var s = baseSettings with
{
    DownloadTest = baseSettings.DownloadTest with { DownloadSizeMb = 100 }
};

Why this shape:

  • new OoklaSpeedtestSettings(profile) is the natural C# idiom — "construct one of these from this input". No factory-method indirection, no static-class call site, no helper file.
  • new OoklaSpeedtestSettings() defaults to Medium — matches CLI default behaviour (omit --profile → Medium) without any extra ceremony. Single source of truth: parameterless ctor chains via : this(Profile.Medium).
  • One file, one switch — every profile's concrete values are visible side-by-side in the constructor body. Easy to review, easy to test.
  • Profile enum stays pure — no extension methods on it return provider types; the dependency direction is correct (Ookla knows Profile; Profile knows nothing about Ookla).
  • Settings record instance state stays pure data — no Payload property, no profile field that could drift from the actual values.
  • Future provider — adds analogous constructors to its own settings record (e.g. MlabSpeedtestSettings(Profile)) with its own inline switch. Parallel structure; mapping is provider-specific.
  • with expressions work normally — the synthesised record copy constructor is unaffected by user-defined constructors.

The CLI consumes this by parsing --profile (default Profile.Medium), calling new OoklaSpeedtestSettings(parsedProfile), then applying any explicit-flag overrides via with expressions before passing to OoklaSpeedtest.

Per-test budget caps (DownloadSizeMb, UploadSizeMb) move into the settings records

The current --downloadsize / --uploadsize flags route to method parameters on GetDownloadSpeedAsync / GetUploadSpeedAsync (see docs/architecture/download-upload-size-controls.md §3). For profiles to drive these caps coherently, they should move into DownloadTestSettings.DownloadSizeMb and UploadTestSettings.UploadSizeMb. OoklaSpeedtest reads them from the settings record instead of taking them as separate arguments. The CLI flags still bind to the same final values; the data just flows via the settings record now.

Profile values (Ookla mapping)

Tiny, Small, Medium, and Large use only the historic Speedtest.net Flash-client array {350, 500, 750, 1000, 1500, 2000, 2500, 3000, 3500, 4000} to insulate ordinary users from any future OoklaServer change — see docs/architecture/download-upload-size-controls.md.

Mega is the deliberate exception: it includes the undocumented bonus payloads (5000, 6000, 7000) because they are the only way to reach steady-state on 10 Gbps fibre / inter-DC links. This is a power-user, opt-in tier — the brittleness is quarantined to one profile, called out in its XML doc, and flagged in the user guide.

Profile DownloadSizes DlIter DlPar DlCap (MiB) UlIncKb UlIncs UlIter UlPar UlCap (MiB) ≈ Down per run ≈ Up per run Target audience
Tiny [350] 1 1 1 50 1 1 1 1 ~245 KB ~50 KB IoT, 10 MB/month plans (≤30 tests/month)
Small [1000, 1500] 2 2 10 100 4 2 2 2 ~10 MiB ~2 MiB Mobile/cellular, metered
Medium [1500, 2000, 3000, 3500, 4000] 2 4 100 200 6 5 4 25 ~100 MiB ~21 MiB Typical home broadband (default)
Large [2000, 2500, 3000, 3500, 4000] 12 16 1024 500 8 12 16 256 ~1 GiB ~211 MiB Fibre, business
Mega [3000, 4000, 5000, 6000, 7000] 40 32 10240 1024 16 16 32 2048 ~10 GiB ~2 GiB Inter-DC, 10 Gbps fibre saturation

Per-request bytes derive from the cross-server-validated table in docs/architecture/download-upload-size-controls.md. The download budget cap is set to actively truncate (rather than be inert at int.MaxValue), so per-run transfer is bounded even if the size×iteration product over-shoots.

These are Ookla-specific values. A second provider added later would supply its own translation of the same Profile enum into its own settings record.

CLI surface

Add to src/NetPace.Console/Program.cs following the established Option<TEnum> pattern (see the existing --unit-system definition):

var profileOption = new Option<Profile>("--profile")
{
    Description = "Profile bundle of payload settings (Tiny | Small | Medium | Large | Mega).",
    DefaultValueFactory = _ => Profile.Medium
};

Wire into SpeedTestCommandSettings and use it as the seed for the OoklaSpeedtestSettings instance built in Program.RunAsync / SpeedTestCommand. Explicit --downloadsize / --uploadsize (and any future per-knob flags) override the profile's corresponding fields.

Default behaviour change

The default profile is Medium — this reduces per-run traffic from the current ≈ 370 MiB to ≈ 121 MiB. NetPace is pre-1.0, so this is treated as a routine change rather than a breaking-API event; the CHANGELOG entry will call it out.

Override interaction

The selected --profile is authoritative for the per-request shape (DownloadSizes, iterations, parallel tasks, upload increments). The only knobs an explicit CLI flag can override are the DownloadSizeMb / UploadSizeMb budget caps — those are always applied on top of the profile via with-expressions on the corresponding settings record.

Invocation Builds settings via
netpace new OoklaSpeedtestSettings() (chains to Profile.Medium)
netpace --profile tiny new OoklaSpeedtestSettings(Profile.Tiny)
netpace --profile tiny --downloadsize 5 new OoklaSpeedtestSettings(Profile.Tiny) then with { DownloadTest = ... with { DownloadSizeMb = 5 } }
netpace --downloadsize 50 new OoklaSpeedtestSettings() then with { DownloadTest = ... with { DownloadSizeMb = 50 } }

When the override cap exceeds the profile's natural transfer total (e.g. --profile tiny --downloadsize 5000 — Tiny transfers ~245 KB; cap set to 5000 MiB), the override is mechanically present on the settings record but the cap-hit check (totalBytesReturned >= maxBytes inside GenericTestSpeedAsync) never triggers — the test completes naturally. The cap is a backstop, not a directive.

--no-download / --no-upload continue to short-circuit their respective phases regardless of profile.

Provider scope

The Profile enum is provider-agnostic and lives at the top of NetPace.Core (sibling of SpeedUnit, SpeedScale, SpeedUnitSystem). It is never extended with provider-specific knowledge — no extension method on Profile ever returns a provider type. Settings record instance state is also provider-pure — no Payload property, no profile in the data.

The provider-specific translation is entirely the provider's responsibility, contained in its settings record's Profile-taking constructor (e.g. OoklaSpeedtestSettings(Profile)) as a single inline switch expression. No companion helper classes.

When a second provider lands, it adds the analogous constructor to its own settings record (e.g. MlabSpeedtestSettings(Profile)) with its own inline switch. The mapping may not be byte-for-byte identical, and that's fine. No speculative abstraction now — just the right dependency direction (provider knows Profile; Profile knows no provider) to make adding a second provider cheap later.

Documentation

  • README.md — refresh the --help snapshot; add --profile to the options reference; add a one-line example (netpace --profile tiny).
  • USER_GUIDE.md — new "Choosing a profile" section with the budget table and decision guidance ("if you're on cellular, pick Small; if you're on fibre, pick Large; if you're saturating a 10 Gbps DC link, pick Mega"). Includes a dedicated warning callout for Mega explaining the undocumented-payload dependency.
  • docs/architecture/download-upload-size-controls.md — add a new section cross-referencing profiles to the per-request-size tables, so future maintainers can see how profile values were derived. Explicitly note that Mega is the only profile relying on the bonus 5000/6000/7000 payloads and document the fallback strategy if those payloads disappear upstream.
  • XML docs — required on Profile enum (and each enum member) and on both OoklaSpeedtestSettings constructors (per CLAUDE.md: all public APIs must have XML docs). The Profile.Mega enum member's XML doc must explicitly state: "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."
  • CIR — new brief CIR documenting: (a) the public API addition (Profile enum, two new OoklaSpeedtestSettings constructors); (b) the rationale for putting Profile in NetPace.Core rather than the Console; (c) the deliberate dependency direction — provider's settings record gains a Profile-taking constructor with the entire profile→settings switch inline; Profile itself never references any provider; settings record instance state stays pure data with no profile field; no separate helper classes; (d) the move of DownloadSizeMb / UploadSizeMb from method parameters into DownloadTestSettings / UploadTestSettings to let profiles drive caps coherently.
  • CHANGELOG — entry noting the new flag, the new default, and the per-run traffic reduction.

Out of scope

  • Per-knob CLI flags exposing DownloadSizes, DownloadSizeIterations, DownloadParallelTasks, UploadSizeIncrementKb, UploadIncrements, UploadSizeIterations, UploadParallelTasks individually. Power users can keep using the library API; the goal of this issue is the curated-profile surface, not infinite knobs.
  • A user-defined / config-file-loaded custom profile. Out of scope; revisit if requested.
  • Generalising Profile across multiple providers. Single-provider today; abstract on second-provider arrival.
  • Auto-detection / adaptive profile selection ("pick a profile based on observed link speed"). Explicit user choice only.
  • Localisation of profile descriptions in --help output.

Acceptance criteria

Functional

  • Profile enum exists in NetPace.Core with values Tiny, Small, Medium, Large, Mega.
  • Profile enum lives at src/NetPace.Core/Profile.cs (top-level, not under Clients/Ookla/) — confirms its provider-agnostic nature.
  • Profile has no extension methods that reference any provider type — verified by grep / structural test.
  • OoklaSpeedtestSettings instance state has no Profile property — settings record state stays pure data.
  • OoklaSpeedtestSettings exposes two public constructors: OoklaSpeedtestSettings() and OoklaSpeedtestSettings(Profile profile).
  • The parameterless OoklaSpeedtestSettings() constructor chains to the Profile-taking one with Profile.Medium (single source of truth — : this(Profile.Medium)).
  • The Profile-taking constructor contains the entire profile→download/upload mapping inline as a single switch expression — there is no separate helper class, factory method, or extension method holding any of the per-profile values.
  • No OoklaSpeedtestSettingsExtensions, OoklaProfileExtensions, or any similar profile-related helper class exists in the codebase.
  • The constructor's switch expression throws ArgumentOutOfRangeException for unknown Profile values (defensive against future enum additions).
  • DownloadSizeMb and UploadSizeMb move from method parameters into DownloadTestSettings / UploadTestSettings respectively. OoklaSpeedtest reads them from the settings record.
  • --profile flag is parseable from the CLI, accepts the five values case-insensitively (matches existing enum-flag behaviour), and rejects anything else with a System.CommandLine error message.
  • Default profile when --profile is omitted is Medium.
  • Explicit --downloadsize / --uploadsize flags override the profile-derived DownloadSizeMb / UploadSizeMb (applied via with { DownloadTest = ... with { DownloadSizeMb = N } } after the factory); profile values for non-overridden settings are preserved.
  • --no-download / --no-upload continue to short-circuit their phases regardless of profile.
  • Per-run transferred byte totals fall within ±10 % of the targets in the profile table when run against a local Docker OoklaServer.

Tests (xUnit, mirroring the partial-class style of NetPaceConsoleTests.*)

  • Unit test per profile asserting the exact DownloadTest and UploadTest field values produced by new OoklaSpeedtestSettings(profile) (DownloadSizes, iterations, parallel tasks, increment Kb, caps).
  • Unit test that new OoklaSpeedtestSettings() produces field-for-field identical values to new OoklaSpeedtestSettings(Profile.Medium) (verifies the constructor-chaining wiring).
  • Unit test that Profile.Medium is returned when --profile is omitted.
  • Unit test that each profile name parses case-insensitively.
  • Unit test that --profile tiny --downloadsize 5 produces a settings record with the Tiny profile's DownloadSizes/iterations/parallel but DownloadTest.DownloadSizeMb == 5.
  • Unit test that --profile small --uploadsize 1 produces a settings record with the Small profile's upload increments but UploadTest.UploadSizeMb == 1.
  • Unit test that --no-download --profile large skips the download phase.
  • Unit test that new OoklaSpeedtestSettings(Profile.Mega).DownloadTest.DownloadSizes includes 5000, 6000, and 7000 (regression guard so a future refactor cannot silently demote Mega to historic-10 only).
  • Unit test that new OoklaSpeedtestSettings((Profile)999) throws ArgumentOutOfRangeException.
  • Optional integration test against the local Docker OoklaServer (docker/ooklaserver/) verifying Tiny actually transfers ≤ 1 MiB end-to-end.

Documentation

  • README.md --help snapshot updated; --profile documented in options table.
  • USER_GUIDE.md has a "Choosing a profile" section with the budget table and target-audience guidance.
  • docs/architecture/download-upload-size-controls.md has a new section cross-referencing profiles to per-request sizes.
  • All new public types/members in NetPace.Core carry XML docs.
  • New CIR added under docs/cir/ documenting the public API addition.
  • CHANGELOG entry added.

Open questions / future work

  • Mega fallback strategy if bonus payloads disappear upstream — if a future OoklaServer release stops serving 5000/6000/7000, fall back Mega.DownloadTest.DownloadSizes to historic-10 only (e.g. [2500, 3000, 3500, 4000]) with proportionally higher iteration counts to preserve the ~10 GiB target. A CI probe or release-time HEAD check (mirroring the cross-server validation already in docs/architecture/download-upload-size-controls.md) could detect this automatically.
  • Override-priority discoverability — should netpace --profile tiny --downloadsize 5 print a one-line "(profile X, with overrides: --downloadsize=5)" trace at Verbosity.Debug so users can confirm what actually applied? Likely yes, but folded into a follow-up.
  • Profile in output — should JSON/CSV output include the profile that was requested at the CLI (e.g. as a profile column/field) so logs can be filtered/grouped post-hoc? Since OoklaSpeedtestSettings doesn't carry the profile, the CLI would need to thread it through to the output writer separately. Useful but out of scope here.
  • Per-knob CLI flags — if real-world feedback shows the five profiles are too coarse, expose the underlying knobs in a follow-up. Don't pre-empt.

Confirmed decisions

  • CIR storage path: New CIR is filed under docs/change-intent-records/ (the existing repo directory and authoritative convention); the issue body's docs/cir/ reference is corrected before SDD.
  • Size-cap parameter overloads: Hard-remove the int downloadSizeMb / int uploadSizeMb parameter overloads from both OoklaSpeedtest and ISpeedTestService, leaving (server, ct) and (server, IProgress, ct) per direction; the cap is read from DownloadTestSettings.DownloadSizeMb / UploadTestSettings.UploadSizeMb on the settings record set at construction. Per-call variation uses settings with { DownloadSizeMb = N }. Accepted breaking change to the public NuGet contract; CHANGELOG breaking-change entry, XML doc updates on surviving methods, and call-site rewires at Program.cs:232-233 are in scope.
  • ISpeedTestService cap surface: Remove the existing int sizeMb overloads from ISpeedTestService.cs (GetDownloadSpeedAsync at L86 and L105; GetUploadSpeedAsync at L122 and L141) — "cap-free interface" means deletion, not just declining to add new ones. (Author redirected from "do not add an int downloadSizeMb overload to the interface" — corrected the framing: the overloads already exist on the interface and must be deleted.)
  • DownloadSizeMb / UploadSizeMb naming: Keep DownloadSizeMb / UploadSizeMb on the settings records (CLI flag spelling preserved); disambiguate from DownloadSizes (pixel array) via a one-line <remarks> on the XML doc.
  • Settings-record default value: int.MaxValue initializer on both DownloadSizeMb and UploadSizeMb, preserving "no cap unless explicitly set" semantics for raw-record consumers; profiles always overwrite, so the default never leaks into normal CLI/profile flow.
  • Docker integration test: No Docker-backed integration test for profile→bytes wiring; existing unit tests cover the profile→settings mapping. (Author redirected from "Tiny-only integration test against the local Docker OoklaServer" — Docker-backed integration tests considered an anti-pattern.)
  • Profile / override conflict: The selected --profile is authoritative for per-request shape (DownloadSizes, iterations, parallel tasks, upload increments). User-supplied --downloadsize / --uploadsize overrides always apply on top of --profile — they replace DownloadTest.DownloadSizeMb / UploadTest.UploadSizeMb on the settings record via with-application. When the override exceeds the profile's natural transfer total (e.g. --profile tiny --downloadsize 5000), the override is mechanically present on the record but the cap-hit check (totalBytesReturned >= maxBytes) never triggers because Tiny completes well below 5000 MiB on its own. The profile's per-request shape is strict; the cap acts as a backstop.
  • --profile flag aliases and error message: No short alias for --profile; rely on the default System.CommandLine error for unknown values.

Related

Metadata

Metadata

Assignees

Labels

readyIssue is fully defined and ready to be implemented

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions