diff --git a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs
index 9435231e..4643d416 100644
--- a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs
+++ b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs
@@ -285,6 +285,18 @@ private void ApplyRuntimeOptions()
_configInfo.Encoding = GetOption(UpdateOptions.Encoding);
_configInfo.Format = GetOption(UpdateOptions.Format);
_configInfo.DownloadTimeOut = GetOption(UpdateOptions.DownloadTimeout) ?? 60;
+
+ // Download behaviour
+ _configInfo.MaxConcurrency = GetOption(UpdateOptions.MaxConcurrency);
+ _configInfo.EnableResume = GetOption(UpdateOptions.EnableResume);
+ _configInfo.RetryCount = GetOption(UpdateOptions.RetryCount);
+ _configInfo.RetryInterval = GetOption(UpdateOptions.RetryInterval);
+ _configInfo.VerifyChecksum = GetOption(UpdateOptions.VerifyChecksum);
+
+ // Update behaviour
+ _configInfo.BackupEnabled = GetOption(UpdateOptions.BackupEnabled);
+ _configInfo.PatchEnabled = GetOption(UpdateOptions.PatchEnabled);
+ _configInfo.DiffMode = GetOption(UpdateOptions.DiffMode);
}
///
diff --git a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs
index 33e25a7a..56cb91e1 100644
--- a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs
+++ b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Text;
@@ -112,9 +113,54 @@ public class GlobalConfigInfo : BaseConfigInfo
///
public bool? PatchEnabled { get; set; }
+ ///
+ /// Whether to back up the current version before applying an update.
+ /// Computed from UpdateOption.BackupEnabled, defaults to true.
+ ///
+ public bool? BackupEnabled { get; set; }
+
///
/// Directory path where the current version files are backed up before update.
/// Computed by combining InstallPath with a versioned directory name.
///
public string BackupDirectory { get; set; }
-}
\ No newline at end of file
+
+ // ═══ Download/update behaviour options (wired from UpdateOptions) ═══
+
+ ///
+ /// Maximum number of concurrent download operations.
+ /// Computed from UpdateOption.MaxConcurrency, defaults to 3.
+ /// Valid range: 1 to * 2.
+ ///
+ public int MaxConcurrency { get; set; } = 3;
+
+ ///
+ /// Whether to resume interrupted downloads via HTTP Range requests.
+ /// Computed from UpdateOption.EnableResume, defaults to true.
+ ///
+ public bool EnableResume { get; set; } = true;
+
+ ///
+ /// Maximum number of retry attempts for failed download operations.
+ /// Computed from UpdateOption.RetryCount, defaults to 3.
+ ///
+ public int RetryCount { get; set; } = 3;
+
+ ///
+ /// Initial retry interval for exponential back-off.
+ /// Computed from UpdateOption.RetryInterval, defaults to 1 second.
+ ///
+ public TimeSpan RetryInterval { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Whether to perform SHA256 checksum verification after download.
+ /// Computed from UpdateOption.VerifyChecksum, defaults to true.
+ ///
+ public bool VerifyChecksum { get; set; } = true;
+
+ ///
+ /// Diff/patch generation mode — Serial or Parallel.
+ /// Computed from UpdateOption.DiffMode, defaults to .
+ ///
+ public DiffMode DiffMode { get; set; } = DiffMode.Serial;
+}
diff --git a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs
index 6774fd7c..70aa25ed 100644
--- a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs
+++ b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs
@@ -10,18 +10,20 @@
namespace GeneralUpdate.Core.Download.Executors;
///
-/// HTTP-based download executor with Range/resume support.
+/// HTTP-based download executor with optional Range/resume support.
/// Uses the shared HttpClient from VersionService for consistent SSL/auth handling.
///
public class HttpDownloadExecutor : IDownloadExecutor
{
private readonly HttpClient _client;
private readonly TimeSpan _timeout;
+ private readonly bool _enableResume;
- public HttpDownloadExecutor(HttpClient client, TimeSpan? timeout = null)
+ public HttpDownloadExecutor(HttpClient client, TimeSpan? timeout = null, bool enableResume = true)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_timeout = timeout ?? TimeSpan.FromSeconds(30);
+ _enableResume = enableResume;
}
public async Task ExecuteAsync(
@@ -34,8 +36,8 @@ public async Task ExecuteAsync(
long totalBytes = -1;
long existingBytes = 0;
- // Check for existing partial file (resume support)
- if (File.Exists(destPath))
+ // Check for existing partial file (resume support; skip when disabled)
+ if (_enableResume && File.Exists(destPath))
{
existingBytes = new FileInfo(destPath).Length;
}
@@ -44,8 +46,8 @@ public async Task ExecuteAsync(
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
- // Request resume from existing position
- if (existingBytes > 0)
+ // Request resume from existing position (skip when resume is disabled)
+ if (_enableResume && existingBytes > 0)
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(existingBytes, null);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
@@ -56,7 +58,7 @@ public async Task ExecuteAsync(
.ConfigureAwait(false);
// If server doesn't support Range, discard partial file
- if (existingBytes > 0 && response.StatusCode != System.Net.HttpStatusCode.PartialContent)
+ if (_enableResume && existingBytes > 0 && response.StatusCode != System.Net.HttpStatusCode.PartialContent)
{
existingBytes = 0;
File.Delete(destPath);
diff --git a/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs b/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs
new file mode 100644
index 00000000..7870df70
--- /dev/null
+++ b/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs
@@ -0,0 +1,82 @@
+using System;
+
+namespace GeneralUpdate.Core.Download.Models;
+
+///
+/// Bundles all configurable download behaviour options into a single value object.
+/// Used by and
+/// to avoid constructor parameter explosion.
+///
+public class DownloadOrchestratorOptions
+{
+ ///
+ /// Maximum number of concurrent download operations.
+ /// Valid range: 1 to * 2.
+ /// Default: 3.
+ ///
+ public int MaxConcurrency { get; set; } = 3;
+
+ ///
+ /// Whether to resume interrupted downloads via HTTP Range requests.
+ /// Default: true.
+ ///
+ public bool EnableResume { get; set; } = true;
+
+ ///
+ /// Maximum number of retry attempts for failed download operations.
+ /// Default: 3.
+ ///
+ public int RetryCount { get; set; } = 3;
+
+ ///
+ /// Initial retry interval for exponential back-off.
+ /// Actual delay before N-th retry = RetryInterval * 2^(N-1).
+ /// Default: 1 second.
+ ///
+ public TimeSpan RetryInterval { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Whether to perform SHA256 checksum verification after download.
+ /// Default: true.
+ ///
+ public bool VerifyChecksum { get; set; } = true;
+
+ ///
+ /// Diff/patch generation mode — Serial or Parallel.
+ /// When , is forced to 1.
+ /// Default: .
+ ///
+ public Configuration.DiffMode DiffMode { get; set; } = Configuration.DiffMode.Serial;
+
+ ///
+ /// HTTP download timeout duration.
+ /// Default: 30 seconds.
+ ///
+ public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Creates a from .
+ ///
+ public static DownloadOrchestratorOptions From(Configuration.GlobalConfigInfo config)
+ {
+ return new DownloadOrchestratorOptions
+ {
+ MaxConcurrency = SanitizeMaxConcurrency(config.MaxConcurrency),
+ EnableResume = config.EnableResume,
+ RetryCount = Math.Max(0, config.RetryCount),
+ RetryInterval = config.RetryInterval,
+ VerifyChecksum = config.VerifyChecksum,
+ DiffMode = config.DiffMode,
+ DownloadTimeout = TimeSpan.FromSeconds(config.DownloadTimeOut > 0 ? config.DownloadTimeOut : 30),
+ };
+ }
+
+ /// Clamps to [1, ProcessorCount * 2].
+ public static int SanitizeMaxConcurrency(int value)
+ {
+ var max = Math.Max(1, Environment.ProcessorCount * 2);
+ if (value < 1) return 1;
+ if (value > max) return max;
+ return value;
+ }
+}
diff --git a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs
index 1e1fcb6f..492f666f 100644
--- a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs
+++ b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs
@@ -6,6 +6,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
+using GeneralUpdate.Core.Configuration;
using GeneralUpdate.Core.Download.Abstractions;
using GeneralUpdate.Core.Download.Executors;
using GeneralUpdate.Core.Download.Policy;
@@ -16,17 +17,22 @@ namespace GeneralUpdate.Core.Download.Orchestrators;
///
/// Default download orchestrator with parallel execution, concurrency limit,
-/// SHA256 verification, and progress reporting.
+/// SHA256 verification, resume support, and progress reporting.
+///
+/// All configurable behaviour is driven by ,
+/// which maps to the defined in the bootstrap layer.
///
public class DefaultDownloadOrchestrator : IDownloadOrchestrator
{
private readonly HttpClient _httpClient;
private readonly IDownloadPolicy _policy;
+ private readonly DownloadOrchestratorOptions _options;
- public DefaultDownloadOrchestrator(HttpClient httpClient, IDownloadPolicy? policy = null)
+ public DefaultDownloadOrchestrator(HttpClient httpClient, DownloadOrchestratorOptions? options = null, IDownloadPolicy? policy = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
- _policy = policy ?? new DefaultRetryPolicy();
+ _options = options ?? new DownloadOrchestratorOptions();
+ _policy = policy ?? new DefaultRetryPolicy(_options.RetryCount, _options.RetryInterval);
}
/// Execute downloads for all assets in the plan.
@@ -42,9 +48,20 @@ public async Task ExecuteAsync(
Directory.CreateDirectory(destDir);
+ // Resolve effective concurrency: Serial mode forces 1.
+ // Uses _options.MaxConcurrency as primary value; the method parameter
+ // maxConcurrency acts as an override (default 3).
+ var baseConcurrency = maxConcurrency > 0 ? maxConcurrency : _options.MaxConcurrency;
+ var effectiveConcurrency = _options.DiffMode == DiffMode.Serial
+ ? 1
+ : DownloadOrchestratorOptions.SanitizeMaxConcurrency(Math.Max(1, baseConcurrency));
+
+ GeneralTracer.Info($"DefaultDownloadOrchestrator.ExecuteAsync: concurrency={effectiveConcurrency}, " +
+ $"resume={_options.EnableResume}, verifyChecksum={_options.VerifyChecksum}, diffMode={_options.DiffMode}");
+
var sw = Stopwatch.StartNew();
var results = new List();
- using var sem = new SemaphoreSlim(maxConcurrency);
+ using var sem = new SemaphoreSlim(effectiveConcurrency);
long totalBytes = 0;
var tasks = plan.Assets.Select(async asset =>
@@ -55,7 +72,7 @@ public async Task ExecuteAsync(
var fileName = GetFileName(asset);
var destPath = Path.Combine(destDir, fileName);
- var executor = new HttpDownloadExecutor(_httpClient);
+ var executor = new HttpDownloadExecutor(_httpClient, _options.DownloadTimeout, _options.EnableResume);
var pipeline = new DefaultDownloadPipeline(asset.SHA256);
var result = await _policy.ExecuteAsync(async ct =>
@@ -69,7 +86,13 @@ public async Task ExecuteAsync(
if (!downloadResult.Success)
return downloadResult;
- // Verify (SHA256)
+ // Verify (SHA256) — conditionally skipped when VerifyChecksum is false
+ if (!_options.VerifyChecksum)
+ {
+ GeneralTracer.Info($"DefaultDownloadOrchestrator: checksum verification skipped for {asset.Name} (VerifyChecksum=false).");
+ return downloadResult;
+ }
+
try
{
await pipeline.ProcessAsync(destPath, ct).ConfigureAwait(false);
diff --git a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs
index e34add4a..0810c2b4 100644
--- a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs
+++ b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs
@@ -183,12 +183,20 @@ private async Task ExecuteStandardWorkflowAsync(Encoding encoding, int timeout)
BlackListManager.Instance.SkipDirectorys.ToList()),
ProcessInfoJsonContext.Default.ProcessInfo);
- // Backup
- Backup();
+ // Backup — conditionally skipped when BackupEnabled is false
+ if (_configInfo.BackupEnabled != false)
+ {
+ Backup();
+ }
+ else
+ {
+ GeneralTracer.Info("ClientUpdateStrategy: backup skipped (BackupEnabled=false).");
+ }
_osStrategy!.Create(_configInfo);
- // Download via orchestrator
+ // Download via orchestrator — wired with options from GlobalConfigInfo
+ var orchOptions = Download.Models.DownloadOrchestratorOptions.From(_configInfo);
GeneralTracer.Info($"ClientUpdateStrategy: downloading {downloadPlan.Assets.Count} asset(s).");
if (_orchestrator != null)
{
@@ -199,7 +207,7 @@ private async Task ExecuteStandardWorkflowAsync(Encoding encoding, int timeout)
var httpClient = new System.Net.Http.HttpClient();
try
{
- var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient);
+ var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient, orchOptions);
await orchestrator.ExecuteAsync(downloadPlan, _configInfo.TempPath).ConfigureAwait(false);
}
finally { httpClient.Dispose(); }
diff --git a/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs b/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs
new file mode 100644
index 00000000..4303913a
--- /dev/null
+++ b/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs
@@ -0,0 +1,241 @@
+using System;
+using System.IO;
+using GeneralUpdate.Core;
+using GeneralUpdate.Core.Configuration;
+using Xunit;
+
+namespace CoreTest.Configuration;
+
+///
+/// Verifies that properties are correctly
+/// populated from via
+/// .
+///
+public class GlobalConfigInfoWiringTests
+{
+ #region GlobalConfigInfo Default Values
+
+ [Fact]
+ public void GlobalConfigInfo_MaxConcurrency_DefaultsTo3()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.Equal(3, config.MaxConcurrency);
+ }
+
+ [Fact]
+ public void GlobalConfigInfo_EnableResume_DefaultsToTrue()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.True(config.EnableResume);
+ }
+
+ [Fact]
+ public void GlobalConfigInfo_RetryCount_DefaultsTo3()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.Equal(3, config.RetryCount);
+ }
+
+ [Fact]
+ public void GlobalConfigInfo_RetryInterval_DefaultsToOneSecond()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.Equal(TimeSpan.FromSeconds(1), config.RetryInterval);
+ }
+
+ [Fact]
+ public void GlobalConfigInfo_VerifyChecksum_DefaultsToTrue()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.True(config.VerifyChecksum);
+ }
+
+ [Fact]
+ public void GlobalConfigInfo_BackupEnabled_DefaultsToNull()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.Null(config.BackupEnabled);
+ }
+
+ [Fact]
+ public void GlobalConfigInfo_PatchEnabled_DefaultsToNull()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.Null(config.PatchEnabled);
+ }
+
+ [Fact]
+ public void GlobalConfigInfo_DiffMode_DefaultsToSerial()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.Equal(DiffMode.Serial, config.DiffMode);
+ }
+
+ [Fact]
+ public void GlobalConfigInfo_AllDownloadProperties_HaveReasonableDefaults()
+ {
+ var config = new GlobalConfigInfo();
+ Assert.True(config.MaxConcurrency >= 1);
+ Assert.True(config.EnableResume);
+ Assert.True(config.RetryCount >= 0);
+ Assert.True(config.RetryInterval > TimeSpan.Zero);
+ Assert.True(config.VerifyChecksum);
+ }
+
+ #endregion
+
+ #region Bootstrap Option → GetOption Roundtrip
+
+ /// Test subclass that exposes the protected GetOption method.
+ private sealed class TestableBootstrap : GeneralUpdateBootstrap
+ {
+ public T PublicGetOption(UpdateOption? option) => GetOption(option);
+ }
+
+ [Fact]
+ public void Bootstrap_GetOption_MaxConcurrency_ReturnsSetValue()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.MaxConcurrency, 8);
+ Assert.Equal(8, b.PublicGetOption(UpdateOptions.MaxConcurrency));
+ }
+
+ [Fact]
+ public void Bootstrap_GetOption_EnableResume_ReturnsSetValue()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.EnableResume, false);
+ Assert.False(b.PublicGetOption(UpdateOptions.EnableResume));
+ }
+
+ [Fact]
+ public void Bootstrap_GetOption_RetryCount_ReturnsSetValue()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.RetryCount, 10);
+ Assert.Equal(10, b.PublicGetOption(UpdateOptions.RetryCount));
+ }
+
+ [Fact]
+ public void Bootstrap_GetOption_RetryInterval_ReturnsSetValue()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(5));
+ Assert.Equal(TimeSpan.FromSeconds(5), b.PublicGetOption(UpdateOptions.RetryInterval));
+ }
+
+ [Fact]
+ public void Bootstrap_GetOption_VerifyChecksum_ReturnsSetValue()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.VerifyChecksum, false);
+ Assert.False(b.PublicGetOption(UpdateOptions.VerifyChecksum));
+ }
+
+ [Fact]
+ public void Bootstrap_GetOption_BackupEnabled_ReturnsSetValue()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.BackupEnabled, false);
+ Assert.False(b.PublicGetOption(UpdateOptions.BackupEnabled));
+ }
+
+ [Fact]
+ public void Bootstrap_GetOption_PatchEnabled_ReturnsSetValue()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.PatchEnabled, true);
+ Assert.True(b.PublicGetOption(UpdateOptions.PatchEnabled));
+ }
+
+ [Fact]
+ public void Bootstrap_GetOption_DiffMode_ReturnsSetValue()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.DiffMode, DiffMode.Parallel);
+ Assert.Equal(DiffMode.Parallel, b.PublicGetOption(UpdateOptions.DiffMode));
+ }
+
+ [Fact]
+ public void Bootstrap_AllEightOptions_SetWithoutError()
+ {
+ var b = new TestableBootstrap();
+ b.Option(UpdateOptions.MaxConcurrency, 6)
+ .Option(UpdateOptions.EnableResume, false)
+ .Option(UpdateOptions.RetryCount, 5)
+ .Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(3))
+ .Option(UpdateOptions.VerifyChecksum, false)
+ .Option(UpdateOptions.BackupEnabled, false)
+ .Option(UpdateOptions.PatchEnabled, true)
+ .Option(UpdateOptions.DiffMode, DiffMode.Parallel);
+
+ // Verify each option was stored correctly
+ Assert.Equal(6, b.PublicGetOption(UpdateOptions.MaxConcurrency));
+ Assert.False(b.PublicGetOption(UpdateOptions.EnableResume));
+ Assert.Equal(5, b.PublicGetOption(UpdateOptions.RetryCount));
+ Assert.Equal(TimeSpan.FromSeconds(3), b.PublicGetOption(UpdateOptions.RetryInterval));
+ Assert.False(b.PublicGetOption(UpdateOptions.VerifyChecksum));
+ Assert.False(b.PublicGetOption(UpdateOptions.BackupEnabled));
+ Assert.True(b.PublicGetOption(UpdateOptions.PatchEnabled));
+ Assert.Equal(DiffMode.Parallel, b.PublicGetOption(UpdateOptions.DiffMode));
+ }
+
+ #endregion
+
+ #region Option Defaults Match UpdateOptions
+
+ [Fact]
+ public void UpdateOptions_MaxConcurrency_DefaultIs3()
+ => Assert.Equal(3, UpdateOptions.MaxConcurrency.DefaultValue);
+
+ [Fact]
+ public void UpdateOptions_EnableResume_DefaultIsTrue()
+ => Assert.True(UpdateOptions.EnableResume.DefaultValue);
+
+ [Fact]
+ public void UpdateOptions_RetryCount_DefaultIs3()
+ => Assert.Equal(3, UpdateOptions.RetryCount.DefaultValue);
+
+ [Fact]
+ public void UpdateOptions_RetryInterval_DefaultIsOneSecond()
+ => Assert.Equal(TimeSpan.FromSeconds(1), UpdateOptions.RetryInterval.DefaultValue);
+
+ [Fact]
+ public void UpdateOptions_VerifyChecksum_DefaultIsTrue()
+ => Assert.True(UpdateOptions.VerifyChecksum.DefaultValue);
+
+ [Fact]
+ public void UpdateOptions_BackupEnabled_DefaultIsTrue()
+ => Assert.True(UpdateOptions.BackupEnabled.DefaultValue);
+
+ [Fact]
+ public void UpdateOptions_PatchEnabled_DefaultIsTrue()
+ => Assert.True(UpdateOptions.PatchEnabled.DefaultValue);
+
+ [Fact]
+ public void UpdateOptions_DiffMode_DefaultIsSerial()
+ => Assert.Equal(DiffMode.Serial, UpdateOptions.DiffMode.DefaultValue);
+
+ #endregion
+
+ #region Helpers
+
+ private static GeneralUpdateBootstrap MakeBootstrap()
+ {
+ var testDir = Path.Combine(Path.GetTempPath(), $"GU_Wiring_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(testDir);
+
+ return new GeneralUpdateBootstrap().SetConfig(new Configinfo
+ {
+ UpdateUrl = "https://api.example.com",
+ MainAppName = "MyApp.exe",
+ ClientVersion = "1.0.0",
+ InstallPath = testDir,
+ AppSecretKey = "secret",
+ Scheme = "https",
+ Token = "token",
+ });
+ }
+
+ #endregion
+}
diff --git a/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs b/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs
new file mode 100644
index 00000000..59a1f962
--- /dev/null
+++ b/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs
@@ -0,0 +1,173 @@
+using System;
+using GeneralUpdate.Core.Configuration;
+using GeneralUpdate.Core.Download.Models;
+using Xunit;
+
+namespace CoreTest.Download;
+
+///
+/// Unit tests for covering:
+/// - default values
+/// - SanitizeMaxConcurrency clamping
+/// - From(GlobalConfigInfo) mapping
+///
+public class DownloadOrchestratorOptionsTests
+{
+ #region Defaults
+
+ [Fact]
+ public void Defaults_AreAsSpecified()
+ {
+ var opts = new DownloadOrchestratorOptions();
+
+ Assert.Equal(3, opts.MaxConcurrency);
+ Assert.True(opts.EnableResume);
+ Assert.Equal(3, opts.RetryCount);
+ Assert.Equal(TimeSpan.FromSeconds(1), opts.RetryInterval);
+ Assert.True(opts.VerifyChecksum);
+ Assert.Equal(DiffMode.Serial, opts.DiffMode);
+ Assert.Equal(TimeSpan.FromSeconds(30), opts.DownloadTimeout);
+ }
+
+ #endregion
+
+ #region SanitizeMaxConcurrency
+
+ [Theory]
+ [InlineData(0, 1)]
+ [InlineData(-1, 1)]
+ [InlineData(-100, 1)]
+ [InlineData(1, 1)]
+ [InlineData(3, 3)]
+ [InlineData(5, 5)]
+ [InlineData(100, 1)]
+ public void SanitizeMaxConcurrency_ClampsCorrectly(int input, int expectedMin)
+ {
+ var result = DownloadOrchestratorOptions.SanitizeMaxConcurrency(input);
+
+ Assert.True(result >= 1, $"Expected result >= 1, got {result}");
+ var max = Math.Max(1, Environment.ProcessorCount * 2);
+ Assert.True(result <= max, $"Expected result <= {max}, got {result}");
+
+ if (input <= 1)
+ Assert.Equal(expectedMin, result);
+ }
+
+ [Fact]
+ public void SanitizeMaxConcurrency_DoesNotExceedProcessorCountTimes2()
+ {
+ var max = Math.Max(1, Environment.ProcessorCount * 2);
+ var result = DownloadOrchestratorOptions.SanitizeMaxConcurrency(int.MaxValue);
+ Assert.Equal(max, result);
+ }
+
+ [Fact]
+ public void SanitizeMaxConcurrency_NormalValue_Unchanged()
+ {
+ var normalValue = Math.Min(3, Math.Max(1, Environment.ProcessorCount * 2));
+ var result = DownloadOrchestratorOptions.SanitizeMaxConcurrency(normalValue);
+ Assert.Equal(normalValue, result);
+ }
+
+ #endregion
+
+ #region From(GlobalConfigInfo)
+
+ [Fact]
+ public void From_CopiesAllFields()
+ {
+ var config = new GlobalConfigInfo
+ {
+ MaxConcurrency = 5,
+ EnableResume = false,
+ RetryCount = 7,
+ RetryInterval = TimeSpan.FromSeconds(2),
+ VerifyChecksum = false,
+ DiffMode = DiffMode.Parallel,
+ DownloadTimeOut = 60,
+ };
+
+ var opts = DownloadOrchestratorOptions.From(config);
+
+ Assert.Equal(5, opts.MaxConcurrency);
+ Assert.False(opts.EnableResume);
+ Assert.Equal(7, opts.RetryCount);
+ Assert.Equal(TimeSpan.FromSeconds(2), opts.RetryInterval);
+ Assert.False(opts.VerifyChecksum);
+ Assert.Equal(DiffMode.Parallel, opts.DiffMode);
+ Assert.Equal(TimeSpan.FromSeconds(60), opts.DownloadTimeout);
+ }
+
+ [Fact]
+ public void From_DefaultsWhenConfigIsMinimal()
+ {
+ var config = new GlobalConfigInfo();
+
+ var opts = DownloadOrchestratorOptions.From(config);
+
+ Assert.Equal(3, opts.MaxConcurrency);
+ Assert.True(opts.EnableResume);
+ Assert.Equal(3, opts.RetryCount);
+ }
+
+ [Fact]
+ public void From_SanitizesMaxConcurrency()
+ {
+ var config = new GlobalConfigInfo { MaxConcurrency = -5 };
+ var opts = DownloadOrchestratorOptions.From(config);
+ Assert.Equal(1, opts.MaxConcurrency);
+ }
+
+ [Fact]
+ public void From_ClampsNegativeRetryCount()
+ {
+ var config = new GlobalConfigInfo { RetryCount = -1 };
+ var opts = DownloadOrchestratorOptions.From(config);
+ Assert.Equal(0, opts.RetryCount);
+ }
+
+ [Fact]
+ public void From_FallsBackDownloadTimeoutWhenZero()
+ {
+ var config = new GlobalConfigInfo { DownloadTimeOut = 0 };
+ var opts = DownloadOrchestratorOptions.From(config);
+ Assert.Equal(TimeSpan.FromSeconds(30), opts.DownloadTimeout);
+ }
+
+ [Fact]
+ public void From_SerialMode()
+ {
+ var config = new GlobalConfigInfo { DiffMode = DiffMode.Serial };
+ var opts = DownloadOrchestratorOptions.From(config);
+ Assert.Equal(DiffMode.Serial, opts.DiffMode);
+ }
+
+ #endregion
+
+ #region All Properties Settable
+
+ [Fact]
+ public void AllProperties_AreSettable()
+ {
+ var opts = new DownloadOrchestratorOptions
+ {
+ MaxConcurrency = 8,
+ EnableResume = false,
+ RetryCount = 5,
+ RetryInterval = TimeSpan.FromSeconds(3),
+ VerifyChecksum = false,
+ DiffMode = DiffMode.Parallel,
+ DownloadTimeout = TimeSpan.FromSeconds(120),
+ };
+
+ Assert.Equal(8, opts.MaxConcurrency);
+ Assert.False(opts.EnableResume);
+ Assert.Equal(5, opts.RetryCount);
+ Assert.Equal(TimeSpan.FromSeconds(3), opts.RetryInterval);
+ Assert.False(opts.VerifyChecksum);
+ Assert.Equal(DiffMode.Parallel, opts.DiffMode);
+ Assert.Equal(TimeSpan.FromSeconds(120), opts.DownloadTimeout);
+ }
+
+ #endregion
+}
diff --git a/tests/CoreTest/Download/OrchestratorOptionsBehaviourTests.cs b/tests/CoreTest/Download/OrchestratorOptionsBehaviourTests.cs
new file mode 100644
index 00000000..dd6add3e
--- /dev/null
+++ b/tests/CoreTest/Download/OrchestratorOptionsBehaviourTests.cs
@@ -0,0 +1,374 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using GeneralUpdate.Core.Configuration;
+using GeneralUpdate.Core.Download.Abstractions;
+using GeneralUpdate.Core.Download.Executors;
+using GeneralUpdate.Core.Download.Models;
+using GeneralUpdate.Core.Download.Orchestrators;
+using GeneralUpdate.Core.Download.Policy;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace CoreTest.Download;
+
+///
+/// Tests that correctly reads and
+/// applies behaviour from .
+///
+public class OrchestratorOptionsBehaviourTests
+{
+ #region MaxConcurrency
+
+ [Fact]
+ public void Constructor_ReadsMaxConcurrencyFromOptions()
+ {
+ var httpClient = new HttpClient();
+ var opts = new DownloadOrchestratorOptions { MaxConcurrency = 5 };
+ var orch = new DefaultDownloadOrchestrator(httpClient, opts);
+ Assert.NotNull(orch);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SerialMode_ForcesConcurrencyToOne()
+ {
+ var fakeHandler = new FakeSuccessHandler();
+ var tracker = new ConcurrencyTracker(fakeHandler.PublicSendAsync);
+ using var httpClient = new HttpClient(tracker);
+ var opts = new DownloadOrchestratorOptions
+ {
+ DiffMode = DiffMode.Serial,
+ MaxConcurrency = 10,
+ VerifyChecksum = false,
+ RetryCount = 1,
+ RetryInterval = TimeSpan.Zero,
+ };
+
+ var assets = new List
+ {
+ new("file1.zip", "http://example.com/1.zip", 10, null, "1.0"),
+ new("file2.zip", "http://example.com/2.zip", 10, null, "1.0"),
+ };
+ var plan = new DownloadPlan(assets, false);
+ var destDir = Path.Combine(Path.GetTempPath(), "GU_Serial_" + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ var orch = new DefaultDownloadOrchestrator(httpClient, opts);
+ var report = await orch.ExecuteAsync(plan, destDir, token: CancellationToken.None);
+ Assert.Equal(2, report.SuccessCount);
+ // Serial mode must never exceed 1 concurrent request
+ Assert.True(tracker.PeakConcurrency <= 1,
+ $"Expected peak concurrency <= 1 for Serial mode, got {tracker.PeakConcurrency}");
+ }
+ finally
+ {
+ if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+ }
+ }
+
+ #endregion
+
+ #region VerifyChecksum
+
+ private sealed class FakeSuccessHandler : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new ByteArrayContent(new byte[100]),
+ });
+ }
+
+ /// Public wrapper for testing.
+ public Task PublicSendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => SendAsync(request, cancellationToken);
+ }
+
+ /// Wraps an inner handler and tracks peak concurrent requests.
+ private sealed class ConcurrencyTracker : HttpMessageHandler
+ {
+ private readonly Func> _sendAsync;
+ private int _current;
+ public int PeakConcurrency;
+
+ public ConcurrencyTracker(Func> sendAsync)
+ => _sendAsync = sendAsync;
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var current = Interlocked.Increment(ref _current);
+ InterlockedAddMax(ref PeakConcurrency, current);
+ try
+ {
+ return await _sendAsync(request, cancellationToken);
+ }
+ finally
+ {
+ Interlocked.Decrement(ref _current);
+ }
+ }
+
+ private static void InterlockedAddMax(ref int target, int value)
+ {
+ int snapshot;
+ do { snapshot = Volatile.Read(ref target); }
+ while (value > snapshot && Interlocked.CompareExchange(ref target, value, snapshot) != snapshot);
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_VerifyChecksumFalse_SkipsVerification()
+ {
+ using var httpClient = new HttpClient(new FakeSuccessHandler());
+ var opts = new DownloadOrchestratorOptions
+ {
+ VerifyChecksum = false,
+ RetryCount = 1,
+ RetryInterval = TimeSpan.Zero,
+ };
+
+ var assets = new List
+ {
+ new("test.zip", "http://example.com/test.zip", 100, "sha256:invalid_hash_would_fail", "1.0"),
+ };
+ var plan = new DownloadPlan(assets, false);
+ var destDir = Path.Combine(Path.GetTempPath(), "GU_NoVerify_" + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ var orch = new DefaultDownloadOrchestrator(httpClient, opts);
+ var report = await orch.ExecuteAsync(plan, destDir, token: CancellationToken.None);
+ Assert.Equal(1, report.SuccessCount);
+ }
+ finally
+ {
+ if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_VerifyChecksumTrueWithInvalidHash_Fails()
+ {
+ using var httpClient = new HttpClient(new FakeSuccessHandler());
+ var opts = new DownloadOrchestratorOptions
+ {
+ VerifyChecksum = true,
+ RetryCount = 1,
+ RetryInterval = TimeSpan.Zero,
+ };
+
+ var assets = new List
+ {
+ new("test.zip", "http://example.com/test.zip", 100,
+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", "1.0"),
+ };
+ var plan = new DownloadPlan(assets, false);
+ var destDir = Path.Combine(Path.GetTempPath(), "GU_Verify_" + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ var orch = new DefaultDownloadOrchestrator(httpClient, opts);
+ var report = await orch.ExecuteAsync(plan, destDir, token: CancellationToken.None);
+ Assert.Equal(0, report.SuccessCount);
+ }
+ finally
+ {
+ if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+ }
+ }
+
+ #endregion
+
+ #region RetryCount & RetryInterval
+
+ [Fact]
+ public async Task ExecuteAsync_RetryCountFromOptions_IsUsedByPolicy()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 5, initialDelay: TimeSpan.FromMilliseconds(10));
+ int attempts = 0;
+
+ await Assert.ThrowsAsync(() =>
+ policy.ExecuteAsync(_ =>
+ {
+ attempts++;
+ throw new HttpRequestException("timeout");
+ }, CancellationToken.None));
+
+ Assert.Equal(5, attempts);
+ }
+
+ [Fact]
+ public void Orchestrator_PassesOptionsToPolicy()
+ {
+ using var httpClient = new HttpClient();
+ var opts = new DownloadOrchestratorOptions
+ {
+ RetryCount = 7,
+ RetryInterval = TimeSpan.FromSeconds(3),
+ };
+
+ var orch = new DefaultDownloadOrchestrator(httpClient, opts);
+ Assert.NotNull(orch);
+ }
+
+ [Fact]
+ public void Orchestrator_AcceptsCustomPolicyOverride()
+ {
+ using var httpClient = new HttpClient();
+ var customPolicy = new DefaultRetryPolicy(maxRetries: 10, initialDelay: TimeSpan.FromMilliseconds(100));
+ var opts = new DownloadOrchestratorOptions { RetryCount = 1 };
+
+ var orch = new DefaultDownloadOrchestrator(httpClient, opts, customPolicy);
+ Assert.NotNull(orch);
+ }
+
+ #endregion
+
+ #region EnableResume
+
+ [Fact]
+ public void HttpDownloadExecutor_EnableResumeFalse_Constructs()
+ {
+ using var client = new HttpClient();
+ var executor = new HttpDownloadExecutor(client, timeout: TimeSpan.FromSeconds(10), enableResume: false);
+ Assert.NotNull(executor);
+ }
+
+ [Fact]
+ public void HttpDownloadExecutor_EnableResumeTrue_Default()
+ {
+ using var client = new HttpClient();
+ var executor = new HttpDownloadExecutor(client);
+ Assert.NotNull(executor);
+ }
+
+ [Fact]
+ public async Task HttpDownloadExecutor_EnableResumeFalse_DeletesExistingFile()
+ {
+ var tmpDir = Path.Combine(Path.GetTempPath(), "GU_Resume_" + Guid.NewGuid().ToString("N"));
+ var destPath = Path.Combine(tmpDir, "download.bin");
+ Directory.CreateDirectory(tmpDir);
+ File.WriteAllText(destPath, "partial-data");
+
+ using var client = new HttpClient(new FakeSuccessHandler());
+ var executor = new HttpDownloadExecutor(client, enableResume: false);
+
+ try
+ {
+ var result = await executor.ExecuteAsync(
+ "http://example.com/file.bin", destPath, token: CancellationToken.None);
+ Assert.True(result.Success, $"Download should succeed, error: {result.ErrorMessage}");
+ Assert.True(File.Exists(destPath));
+ }
+ finally
+ {
+ if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true);
+ }
+ }
+
+ [Fact]
+ public async Task HttpDownloadExecutor_WithRangeResponse_AppendsCorrectly()
+ {
+ var tmpDir = Path.Combine(Path.GetTempPath(), "GU_ResumeAppend_" + Guid.NewGuid().ToString("N"));
+ var destPath = Path.Combine(tmpDir, "download.bin");
+ Directory.CreateDirectory(tmpDir);
+ var partialData = new byte[20];
+ File.WriteAllBytes(destPath, partialData);
+
+ var handler = new Mock();
+ handler.Protected()
+ .Setup>("SendAsync",
+ ItExpr.Is(r => r.Headers.Range != null),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.PartialContent,
+ Content = new ByteArrayContent(new byte[30]) { Headers = { ContentLength = 30 } }
+ });
+ handler.Protected()
+ .Setup>("SendAsync",
+ ItExpr.Is(r => r.Headers.Range == null),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new ByteArrayContent(new byte[50])
+ });
+
+ using var client = new HttpClient(handler.Object);
+ var executor = new HttpDownloadExecutor(client, enableResume: true);
+
+ try
+ {
+ var result = await executor.ExecuteAsync(
+ "http://example.com/file.bin", destPath, token: CancellationToken.None);
+ Assert.True(result.Success);
+ // Partial content (30 bytes) appended to existing (20 bytes) = 50 total
+ var fileInfo = new FileInfo(destPath);
+ Assert.Equal(50, fileInfo.Length);
+ }
+ finally
+ {
+ if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true);
+ }
+ }
+
+ #endregion
+
+ #region DiffMode Behaviour
+
+ [Fact]
+ public async Task ExecuteAsync_ParallelMode_UsesConfiguredConcurrency()
+ {
+ using var httpClient = new HttpClient(new FakeSuccessHandler());
+ var opts = new DownloadOrchestratorOptions
+ {
+ DiffMode = DiffMode.Parallel,
+ MaxConcurrency = 4,
+ VerifyChecksum = false,
+ RetryCount = 1,
+ RetryInterval = TimeSpan.Zero,
+ };
+
+ var assets = new List();
+ for (int i = 0; i < 3; i++)
+ assets.Add(new($"file{i}.zip", $"http://example.com/{i}.zip", 10, null, "1.0"));
+ var plan = new DownloadPlan(assets, false);
+ var destDir = Path.Combine(Path.GetTempPath(), "GU_Parallel_" + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ var orch = new DefaultDownloadOrchestrator(httpClient, opts);
+ var report = await orch.ExecuteAsync(plan, destDir, token: CancellationToken.None);
+ Assert.Equal(3, report.SuccessCount);
+ }
+ finally
+ {
+ if (Directory.Exists(destDir)) Directory.Delete(destDir, true);
+ }
+ }
+
+ #endregion
+
+ #region Empty Plan
+
+ [Fact]
+ public async Task ExecuteAsync_EmptyPlan_ReturnsEmptyReport()
+ {
+ using var httpClient = new HttpClient();
+ var orch = new DefaultDownloadOrchestrator(httpClient);
+ var report = await orch.ExecuteAsync(DownloadPlan.Empty, Path.GetTempPath(), token: CancellationToken.None);
+ Assert.Equal(0, report.SuccessCount);
+ Assert.Equal(0, report.FailedCount);
+ }
+
+ #endregion
+}