Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/knowledgebase/gui-architecture-deferred.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Plans: `docs/plans/2026-06-03-012`, `021`–`029`, `033`–`057`. Surface refere
| Directory picker init/sync → `SettingsService` | Done | plan `075`, PR #123 |
| Mod context menu + global flyout → `MenuBuilderService` | Done | plan `072`, PR #130 |

**Headless tests:** `dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter SettingsService` (plan `077`); `--filter MenuBuilderService` (plan `072`); `--filter DownloadOrchestrationService` (plan `088`).
**Headless tests:** `dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter SettingsService` (plan `077`); `--filter MenuBuilderService` (plan `072`); `--filter DialogServiceTests` / `FileSystemServiceTests` (plan `113`).
**Headless tests:** `dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter SettingsService` (plan `077`); `--filter MenuBuilderService` (plan `072`); `--filter FilterItemModel` (plan `112`).
**Headless tests:** `dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter SettingsService` (plan `077`); `--filter MenuBuilderService` (plan `072`); `--filter FileTreeNode` (plan `111`).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
title: "test: headless DownloadOrchestrationService coverage"
status: shipped
pr: pending
---

# test: headless DownloadOrchestrationService coverage

## Problem

`DownloadOrchestrationService` coordinates GUI download sessions but had no dedicated headless tests. `DownloadQueueHeadlessTests` covers `DownloadProgressWindow` queue behavior only.

## Solution

Add `DownloadOrchestrationServiceTests` for constructor guards, idle initial state, cancel state-change signaling, no-URL single-component guard, and null-safe single-component entry points.

## Verification

`dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter DownloadOrchestrationService`
168 changes: 168 additions & 0 deletions src/ModSync.Tests/DownloadOrchestrationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright 2021-2025 ModSync
// Licensed under the Business Source License 1.1 (BSL 1.1).
// See LICENSE.txt file in the project root for full license information.

using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using ModSync.Core;
using ModSync.Core.Services;
using ModSync.Services;
using Xunit;

namespace ModSync.Tests
{
[Collection(HeadlessTestApp.CollectionName)]
public sealed class DownloadOrchestrationServiceTests
{
[AvaloniaFact(DisplayName = "Constructor rejects null DownloadCacheService")]
public void Constructor_NullCacheService_Throws()
{
Window window = new Window();
var config = new MainConfig();

Assert.Throws<ArgumentNullException>(() =>
new DownloadOrchestrationService(null, config, window));
}

[AvaloniaFact(DisplayName = "Constructor rejects null MainConfig")]
public void Constructor_NullMainConfig_Throws()
{
Window window = new Window();
var cacheService = new DownloadCacheService();

Assert.Throws<ArgumentNullException>(() =>
new DownloadOrchestrationService(cacheService, null, window));
}

[AvaloniaFact(DisplayName = "Constructor rejects null parent window")]
public void Constructor_NullParentWindow_Throws()
{
var cacheService = new DownloadCacheService();
var config = new MainConfig();

Assert.Throws<ArgumentNullException>(() =>
new DownloadOrchestrationService(cacheService, config, null));
}

[AvaloniaFact(DisplayName = "Initial download state is idle with zero counters")]
public void InitialState_IsIdleWithZeroCounters()
{
DownloadOrchestrationService service = CreateService(out Window window);

try
{
Assert.False(service.IsDownloadInProgress);
Assert.Equal(0, service.TotalComponentsToDownload);
Assert.Equal(0, service.CompletedComponents);
}
finally
{
window.Close();
}
}

[AvaloniaFact(DisplayName = "CancelAllDownloadsAsync raises DownloadStateChanged and stays idle")]
public async Task CancelAllDownloadsAsync_RaisesStateChangedAndClearsInProgress()
{
DownloadOrchestrationService service = CreateService(out Window window);
int stateChangeCount = 0;
service.DownloadStateChanged += (_, _) => stateChangeCount++;

try
{
await service.CancelAllDownloadsAsync();
await PumpEventsAsync();

Assert.False(service.IsDownloadInProgress);
Assert.Equal(1, stateChangeCount);
}
finally
{
window.Close();
}
}

[AvaloniaFact(DisplayName = "DownloadSingleComponentAsync without URLs does not mark download in progress")]
public async Task DownloadSingleComponentAsync_NoUrls_DoesNotStartDownload()
{
DownloadOrchestrationService service = CreateService(out Window window);
DownloadProgressWindow progressWindow = await CreateProgressWindowAsync();
var component = new ModComponent { Name = "No Urls Mod" };

try
{
await service.DownloadSingleComponentAsync(component, progressWindow);
await PumpEventsAsync();

Assert.False(service.IsDownloadInProgress);
}
finally
{
progressWindow.Close();
window.Close();
}
}

[AvaloniaFact(DisplayName = "DownloadSingleComponentAsync ignores null component")]
public async Task DownloadSingleComponentAsync_NullComponent_ReturnsWithoutThrow()
{
DownloadOrchestrationService service = CreateService(out Window window);
DownloadProgressWindow progressWindow = await CreateProgressWindowAsync();

try
{
await service.DownloadSingleComponentAsync(null, progressWindow);
await PumpEventsAsync();
}
finally
{
progressWindow.Close();
window.Close();
}
}

[AvaloniaFact(DisplayName = "DownloadSingleComponentAsync ignores null progress window")]
public async Task DownloadSingleComponentAsync_NullProgressWindow_ReturnsWithoutThrow()
{
DownloadOrchestrationService service = CreateService(out Window window);
var component = new ModComponent { Name = "Test Mod" };

try
{
await service.DownloadSingleComponentAsync(component, null);
await PumpEventsAsync();
}
finally
{
window.Close();
}
}

private static DownloadOrchestrationService CreateService(out Window window)
{
window = new Window { Width = 400, Height = 300 };
var config = new MainConfig();
var cacheService = new DownloadCacheService();
cacheService.SetDownloadManager();
return new DownloadOrchestrationService(cacheService, config, window);
}

private static async Task<DownloadProgressWindow> CreateProgressWindowAsync()
{
DownloadProgressWindow window = await Dispatcher.UIThread.InvokeAsync(
() => new DownloadProgressWindow(),
DispatcherPriority.Background);
await PumpEventsAsync();
return window;
}

private static async Task PumpEventsAsync()
{
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle);
}
}
}
Loading