Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Pat Hartl
1e1e83626a Test coverage plan for SDK 2026-04-13 03:48:28 -05:00
Pat Hartl
c7e26e1051 Tests for common extension methods 2026-04-13 03:24:40 -05:00
Pat Hartl
4df61fa044 Remove old tests, add CI workflow, add tests for saves 2026-04-13 02:53:39 -05:00
19 changed files with 2574 additions and 810 deletions

View file

@ -0,0 +1,254 @@
name: Avalonia Launcher Visual Tests
# ---------------------------------------------------------------------------
# Triggers
# ---------------------------------------------------------------------------
on:
pull_request:
branches: [main]
paths:
- 'LANCommander.Launcher.Avalonia/**'
- 'LANCommander.Launcher.Avalonia.Tests/**'
push:
branches: [main]
paths:
- 'LANCommander.Launcher.Avalonia/**'
- 'LANCommander.Launcher.Avalonia.Tests/**'
# Manual dispatch: re-run tests and optionally commit updated baselines.
workflow_dispatch:
inputs:
update_baselines:
description: 'Commit updated baselines back to the branch (use after intentional UI changes)'
type: boolean
default: false
# ---------------------------------------------------------------------------
# Permissions
# ---------------------------------------------------------------------------
permissions:
contents: write # allow committing updated baselines
pull-requests: write # allow posting PR comments
# ---------------------------------------------------------------------------
# Jobs
# ---------------------------------------------------------------------------
jobs:
visual-tests:
# Self-hosted runner required: needs access to your local LANCommander server
# so that any future integration tests can connect and capture live screenshots.
# Tag your runner with 'lancommander' in GitHub → Settings → Actions → Runners.
runs-on: [self-hosted, lancommander]
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
# Paths used by ScreenshotHelper — relative to workspace so artifacts are easy to find.
VISUAL_SCREENSHOTS_DIR: ${{ github.workspace }}/visual-test-output/screenshots
VISUAL_DIFFS_DIR: ${{ github.workspace }}/visual-test-output/diffs
# Baselines are loaded from the build output (copied from Baselines/ content items).
# Override this var only if you move the Baselines folder.
steps:
# -----------------------------------------------------------------------
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
# Fetch all history so we can commit baseline updates.
fetch-depth: 0
# -----------------------------------------------------------------------
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
# -----------------------------------------------------------------------
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ${{ env.NUGET_PACKAGES }}
key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
# -----------------------------------------------------------------------
- name: Restore dependencies
run: dotnet restore --locked-mode
# -----------------------------------------------------------------------
- name: Build test project
run: |
dotnet build LANCommander.Launcher.Avalonia.Tests/ \
--no-restore \
--configuration Release
# -----------------------------------------------------------------------
- name: Run visual layout tests
id: run-tests
# continue-on-error so we can still upload artifacts and post a PR comment
# even when tests fail. The final step re-raises the failure.
continue-on-error: true
run: |
dotnet test LANCommander.Launcher.Avalonia.Tests/ \
--no-build \
--configuration Release \
--logger "trx;LogFileName=${{ github.workspace }}/visual-test-results.trx" \
--logger "console;verbosity=normal"
# -----------------------------------------------------------------------
# Always upload screenshots + diffs so you can review what the UI looked
# like during this run, regardless of pass/fail.
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-screenshots-${{ github.sha }}
path: ${{ env.VISUAL_SCREENSHOTS_DIR }}/
if-no-files-found: ignore
- name: Upload diff images
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-diffs-${{ github.sha }}
path: ${{ env.VISUAL_DIFFS_DIR }}/
if-no-files-found: ignore
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-test-results-${{ github.sha }}
path: visual-test-results.trx
if-no-files-found: ignore
# -----------------------------------------------------------------------
# Write a step summary that shows the pass/fail status and links to artifacts.
- name: Write step summary
if: always()
shell: bash
run: |
STATUS="${{ steps.run-tests.outcome }}"
echo "## Avalonia Visual Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$STATUS" = "success" ]; then
echo "✅ All visual tests passed — no regressions detected." >> $GITHUB_STEP_SUMMARY
else
echo "❌ Visual regressions detected (or new baselines need to be committed)." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Next steps:**" >> $GITHUB_STEP_SUMMARY
echo "1. Download the **visual-screenshots** and **visual-diffs** artifacts to review changes." >> $GITHUB_STEP_SUMMARY
echo "2. If the changes are intentional, run the workflow manually with **Update baselines** checked." >> $GITHUB_STEP_SUMMARY
echo "3. If the changes are regressions, fix the layout before merging." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Link |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
echo "| Screenshots | [visual-screenshots-${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" >> $GITHUB_STEP_SUMMARY
echo "| Diff images | [visual-diffs-${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" >> $GITHUB_STEP_SUMMARY
# -----------------------------------------------------------------------
# On pull requests, post a comment summarising the outcome so reviewers
# don't have to open the Actions tab to check for visual regressions.
- name: Post PR comment
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
with:
script: |
const outcome = '${{ steps.run-tests.outcome }}';
const sha = '${{ github.sha }}'.slice(0, 7);
const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
const icon = outcome === 'success' ? '✅' : '❌';
const status = outcome === 'success'
? 'No visual regressions detected.'
: 'Visual regressions detected (or new baselines need to be committed). Review the diff artifacts in the Actions run.';
const body = [
`## ${icon} Avalonia Visual Tests — \`${sha}\``,
'',
status,
'',
`**Artifacts:** [Screenshots & diffs](${runUrl})`,
'',
outcome !== 'success'
? '_To accept intentional UI changes, trigger the **Avalonia Launcher Visual Tests** workflow manually with **Update baselines** enabled._'
: '',
].join('\n');
// Find and update an existing bot comment, or create a new one.
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const marker = '## ✅ Avalonia Visual Tests';
const markerFail = '## ❌ Avalonia Visual Tests';
const existing = comments.find(c =>
c.user.type === 'Bot' &&
(c.body.includes('## ✅ Avalonia Visual Tests') || c.body.includes('## ❌ Avalonia Visual Tests'))
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
# -----------------------------------------------------------------------
# Update baselines: copy screenshots → Baselines/ and commit.
# Only runs when triggered via workflow_dispatch with update_baselines=true.
- name: Update baselines
if: inputs.update_baselines == true
shell: bash
run: |
BASELINES_DIR="LANCommander.Launcher.Avalonia.Tests/Baselines"
SCREENSHOTS_DIR="${{ env.VISUAL_SCREENSHOTS_DIR }}"
if [ ! -d "$SCREENSHOTS_DIR" ] || [ -z "$(ls -A "$SCREENSHOTS_DIR")" ]; then
echo "No screenshots found at $SCREENSHOTS_DIR — nothing to update."
exit 1
fi
mkdir -p "$BASELINES_DIR"
cp "$SCREENSHOTS_DIR"/*.png "$BASELINES_DIR/"
echo "Copied screenshots:"
ls "$BASELINES_DIR"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$BASELINES_DIR"
if git diff --staged --quiet; then
echo "Baselines unchanged — nothing to commit."
else
git commit -m "chore: update Avalonia visual test baselines [skip ci]
Updated by workflow run ${{ github.run_id }} on branch ${{ github.ref_name }}."
git push
echo "Baselines committed and pushed."
fi
# -----------------------------------------------------------------------
# Re-raise test failure AFTER artifacts have been uploaded and comments posted.
- name: Fail on test regression
if: steps.run-tests.outcome == 'failure' && inputs.update_baselines != true
run: |
echo "Visual tests failed. See artifacts and step summary for details."
exit 1

View file

@ -0,0 +1,53 @@
name: LANCommander SDK Tests
on:
push:
branches:
- main
paths:
- 'LANCommander.SDK/**'
- 'LANCommander.SDK.Tests/**'
- 'LANCommander.Steam/**'
pull_request:
paths:
- 'LANCommander.SDK/**'
- 'LANCommander.SDK.Tests/**'
- 'LANCommander.Steam/**'
workflow_dispatch:
jobs:
test:
name: Run SDK Tests
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
- name: Build
run: dotnet build --no-restore --configuration Release LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
- name: Run tests
run: >
dotnet test
--no-build
--configuration Release
--verbosity normal
--logger "trx;LogFileName=sdk-tests.trx"
LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: sdk-test-results
path: '**/*.trx'
retention-days: 30

View file

@ -0,0 +1,68 @@
using LANCommander.SDK.Enums;
using LANCommander.SDK.Extensions;
namespace LANCommander.SDK.Tests.Extensions;
public class EnumExtensionsTests
{
private enum Color { Red, Green, Blue }
// ── ValueIsIn ──────────────────────────────────────────────────────────────
[Fact]
public void ValueIsIn_WhenValueIsInList_ReturnsTrue()
{
var result = Color.Green.ValueIsIn(Color.Red, Color.Green, Color.Blue);
Assert.True(result);
}
[Fact]
public void ValueIsIn_WhenValueIsNotInList_ReturnsFalse()
{
var result = Color.Blue.ValueIsIn(Color.Red, Color.Green);
Assert.False(result);
}
[Fact]
public void ValueIsIn_WithEmptyList_ReturnsFalse()
{
var result = Color.Red.ValueIsIn();
Assert.False(result);
}
[Fact]
public void ValueIsIn_WithSingleMatchingValue_ReturnsTrue()
{
var result = Color.Red.ValueIsIn(Color.Red);
Assert.True(result);
}
[Fact]
public void ValueIsIn_WithSortDirectionEnum_WorksCorrectly()
{
var result = SortDirection.Descending.ValueIsIn(SortDirection.Ascending, SortDirection.Descending);
Assert.True(result);
}
[Fact]
public void ValueIsIn_WorksWithNonEnumTypes()
{
// The extension is generic — not restricted to enums.
var result = "hello".ValueIsIn("world", "hello", "foo");
Assert.True(result);
}
[Fact]
public void ValueIsIn_WorksWithIntegers()
{
var result = 42.ValueIsIn(1, 2, 42, 100);
Assert.True(result);
}
}

View file

@ -0,0 +1,41 @@
using LANCommander.SDK.Extensions;
namespace LANCommander.SDK.Tests.Extensions;
public class GuidExtensionsTests
{
// ── IsNullOrEmpty ──────────────────────────────────────────────────────────
[Fact]
public void IsNullOrEmpty_WithEmptyGuid_ReturnsTrue()
{
var result = Guid.Empty.IsNullOrEmpty();
Assert.True(result);
}
[Fact]
public void IsNullOrEmpty_WithDefaultGuid_ReturnsTrue()
{
// default(Guid) == Guid.Empty
var guid = default(Guid);
Assert.True(guid.IsNullOrEmpty());
}
[Fact]
public void IsNullOrEmpty_WithNewGuid_ReturnsFalse()
{
var result = Guid.NewGuid().IsNullOrEmpty();
Assert.False(result);
}
[Fact]
public void IsNullOrEmpty_WithKnownNonEmptyGuid_ReturnsFalse()
{
var guid = new Guid("12345678-1234-1234-1234-123456789012");
Assert.False(guid.IsNullOrEmpty());
}
}

View file

@ -0,0 +1,197 @@
using LANCommander.SDK.Enums;
using LANCommander.SDK.Extensions;
namespace LANCommander.SDK.Tests.Extensions;
public class IEnumerableExtensionsTests
{
// ── OrderByTitle ───────────────────────────────────────────────────────────
[Fact]
public void OrderByTitle_StripsLeadingThe_ForSortKey()
{
var titles = new[] { "Zelda", "The Witcher", "Baldur's Gate" };
var result = titles.OrderByTitle(t => t).Select(t => t).ToList();
// "The Witcher" sorts as "Witcher", coming after "Zelda" is wrong —
// actual order: Baldur's Gate, The Witcher, Zelda
Assert.Equal("Baldur's Gate", result[0]);
Assert.Equal("The Witcher", result[1]);
Assert.Equal("Zelda", result[2]);
}
[Fact]
public void OrderByTitle_StripsLeadingA_ForSortKey()
{
var titles = new[] { "Call of Duty", "A Plague Tale", "Doom" };
var result = titles.OrderByTitle(t => t).ToList();
// "A Plague Tale" sorts as "Plague Tale"
Assert.Equal("Call of Duty", result[0]);
Assert.Equal("Doom", result[1]);
Assert.Equal("A Plague Tale", result[2]);
}
[Fact]
public void OrderByTitle_StripsLeadingAn_ForSortKey()
{
var titles = new[] { "Batman", "An Elder Tale", "Civilization" };
var result = titles.OrderByTitle(t => t).ToList();
// "An Elder Tale" sorts as "Elder Tale"
Assert.Equal("Batman", result[0]);
Assert.Equal("Civilization", result[1]);
Assert.Equal("An Elder Tale", result[2]);
}
[Fact]
public void OrderByTitle_ArticleStrippingIsCaseInsensitive()
{
var titles = new[] { "Zelda", "the Witcher", "Baldur's Gate" };
var result = titles.OrderByTitle(t => t).ToList();
Assert.Equal("Baldur's Gate", result[0]);
Assert.Equal("the Witcher", result[1]);
Assert.Equal("Zelda", result[2]);
}
[Fact]
public void OrderByTitle_WordStartingWithArticleButNoTrailingSpace_IsNotStripped()
{
// "Another World" starts with "An" but "Another" ≠ "an "
// "There" starts with "The" but "There" ≠ "the "
var titles = new[] { "Another World", "There Will Be Blood", "Abzu" };
var result = titles.OrderByTitle(t => t).ToList();
Assert.Equal("Abzu", result[0]);
Assert.Equal("Another World", result[1]);
Assert.Equal("There Will Be Blood", result[2]);
}
[Fact]
public void OrderByTitle_Descending_ReversesOrder()
{
var titles = new[] { "The Witcher", "Baldur's Gate", "Zelda" };
var result = titles.OrderByTitle(t => t, SortDirection.Descending).ToList();
Assert.Equal("Zelda", result[0]);
Assert.Equal("The Witcher", result[1]);
Assert.Equal("Baldur's Gate", result[2]);
}
[Fact]
public void OrderByTitle_WorksWithObjectKeySelector()
{
var games = new[]
{
new { Id = 1, Title = "The Last of Us" },
new { Id = 2, Title = "Among Us" },
new { Id = 3, Title = "Hades" },
};
var result = games.OrderByTitle(g => g.Title).ToList();
// "The Last of Us" → "Last of Us", "Among Us" → "Among Us", "Hades" → "Hades"
Assert.Equal(2, result[0].Id); // Among Us
Assert.Equal(3, result[1].Id); // Hades
Assert.Equal(1, result[2].Id); // The Last of Us
}
// ── OrderBy (SortDirection overload) ──────────────────────────────────────
[Fact]
public void OrderBy_Ascending_SortsSmallestFirst()
{
var numbers = new[] { 3, 1, 4, 1, 5, 9, 2 };
var result = numbers.OrderBy(n => n, SortDirection.Ascending).ToList();
Assert.Equal(new[] { 1, 1, 2, 3, 4, 5, 9 }, result);
}
[Fact]
public void OrderBy_Descending_SortsLargestFirst()
{
var numbers = new[] { 3, 1, 4, 1, 5, 9, 2 };
var result = numbers.OrderBy(n => n, SortDirection.Descending).ToList();
Assert.Equal(new[] { 9, 5, 4, 3, 2, 1, 1 }, result);
}
[Fact]
public void OrderBy_OnStrings_Ascending()
{
var words = new[] { "banana", "apple", "cherry" };
var result = words.OrderBy(w => w, SortDirection.Ascending).ToList();
Assert.Equal(new[] { "apple", "banana", "cherry" }, result);
}
// ── HasAny (no predicate) ──────────────────────────────────────────────────
[Fact]
public void HasAny_WithNonEmptyCollection_ReturnsTrue()
{
var list = new[] { 1, 2, 3 };
Assert.True(list.HasAny());
}
[Fact]
public void HasAny_WithEmptyCollection_ReturnsFalse()
{
var list = Array.Empty<int>();
Assert.False(list.HasAny());
}
[Fact]
public void HasAny_WithNullCollection_ReturnsFalse()
{
IEnumerable<int>? list = null;
Assert.False(list.HasAny());
}
// ── HasAny (with predicate) ────────────────────────────────────────────────
[Fact]
public void HasAny_WithPredicate_WhenMatchExists_ReturnsTrue()
{
var list = new[] { 1, 2, 3, 4, 5 };
Assert.True(list.HasAny(n => n > 4));
}
[Fact]
public void HasAny_WithPredicate_WhenNoMatch_ReturnsFalse()
{
var list = new[] { 1, 2, 3 };
Assert.False(list.HasAny(n => n > 10));
}
[Fact]
public void HasAny_WithPredicate_OnNullCollection_ReturnsFalse()
{
IEnumerable<int>? list = null;
Assert.False(list.HasAny(n => n > 0));
}
[Fact]
public void HasAny_WithPredicate_OnEmptyCollection_ReturnsFalse()
{
var list = Array.Empty<int>();
Assert.False(list.HasAny(n => n > 0));
}
}

View file

@ -0,0 +1,154 @@
using LANCommander.SDK.Extensions;
namespace LANCommander.SDK.Tests.Extensions;
public class ListExtensionsTests
{
// ── RemoveRange ────────────────────────────────────────────────────────────
[Fact]
public void RemoveRange_RemovesAllSpecifiedItems()
{
var list = new List<int> { 1, 2, 3, 4, 5 };
list.RemoveRange(new[] { 2, 4 });
Assert.Equal(new[] { 1, 3, 5 }, list);
}
[Fact]
public void RemoveRange_ItemsNotInCollection_AreIgnored()
{
var list = new List<int> { 1, 2, 3 };
list.RemoveRange(new[] { 99, 100 });
Assert.Equal(new[] { 1, 2, 3 }, list);
}
[Fact]
public void RemoveRange_WithNullItemsToRemove_DoesNotThrow()
{
var list = new List<int> { 1, 2, 3 };
list.RemoveRange(null);
Assert.Equal(new[] { 1, 2, 3 }, list);
}
[Fact]
public void RemoveRange_WithEmptyItemsToRemove_LeavesCollectionUnchanged()
{
var list = new List<int> { 1, 2, 3 };
list.RemoveRange(Array.Empty<int>());
Assert.Equal(new[] { 1, 2, 3 }, list);
}
[Fact]
public void RemoveRange_RemovesAllItems_WhenAllSpecified()
{
var list = new List<string> { "a", "b", "c" };
list.RemoveRange(new[] { "a", "b", "c" });
Assert.Empty(list);
}
[Fact]
public void RemoveRange_OnlyRemovesFirstOccurrence_ForDuplicates()
{
// List<T>.Remove removes only the first matching element.
var list = new List<int> { 1, 2, 2, 3 };
list.RemoveRange(new[] { 2 });
Assert.Equal(new[] { 1, 2, 3 }, list);
}
[Fact]
public void RemoveRange_WorksWithReferenceTypes()
{
var a = new object();
var b = new object();
var c = new object();
var list = new List<object> { a, b, c };
list.RemoveRange(new[] { b });
Assert.Equal(new[] { a, c }, list);
}
// ── RemoveAll ──────────────────────────────────────────────────────────────
[Fact]
public void RemoveAll_RemovesItemsMatchingPredicate()
{
var list = new List<int> { 1, 2, 3, 4, 5 };
list.RemoveAll(n => n % 2 == 0);
Assert.Equal(new[] { 1, 3, 5 }, list);
}
[Fact]
public void RemoveAll_KeepsItemsNotMatchingPredicate()
{
var list = new List<int> { 1, 2, 3, 4, 5 };
list.RemoveAll(n => n > 10);
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, list);
}
[Fact]
public void RemoveAll_WithNullPredicate_ThrowsArgumentNullException()
{
var list = new List<int> { 1, 2, 3 };
Assert.Throws<ArgumentNullException>(() => list.RemoveAll(null!));
}
[Fact]
public void RemoveAll_WithEmptyCollection_DoesNotThrow()
{
var list = new List<int>();
var ex = Record.Exception(() => list.RemoveAll(n => n > 0));
Assert.Null(ex);
Assert.Empty(list);
}
[Fact]
public void RemoveAll_WithAllMatchingPredicate_EmptiesCollection()
{
var list = new List<int> { 1, 2, 3 };
list.RemoveAll(_ => true);
Assert.Empty(list);
}
[Fact]
public void RemoveAll_RemovesItemsCorrectlyWhenIteratingBackwards()
{
// Verify that removing while iterating backwards doesn't skip or double-remove.
var list = new List<int> { 1, 2, 3, 4, 5, 6 };
list.RemoveAll(n => n % 3 == 0);
Assert.Equal(new[] { 1, 2, 4, 5 }, list);
}
[Fact]
public void RemoveAll_WorksWithStrings()
{
var list = new List<string> { "alpha", "beta", "gamma", "delta" };
list.RemoveAll(s => s.StartsWith("b") || s.StartsWith("d"));
Assert.Equal(new[] { "alpha", "gamma" }, list);
}
}

View file

@ -0,0 +1,125 @@
using LANCommander.SDK.Extensions;
namespace LANCommander.SDK.Tests.Extensions;
public class StreamExtensionsTests
{
private static MemoryStream MakeStream(int sizeBytes, byte fill = 0xAB)
{
var data = new byte[sizeBytes];
Array.Fill(data, fill);
return new MemoryStream(data);
}
// ── Content correctness ───────────────────────────────────────────────────
[Fact]
public async Task CopyToAsync_CopiesAllBytesToDestination()
{
var source = MakeStream(256, fill: 0x42);
var destination = new MemoryStream();
await source.CopyToAsync(destination);
destination.Position = 0;
var result = destination.ToArray();
Assert.Equal(256, result.Length);
Assert.All(result, b => Assert.Equal(0x42, b));
}
[Fact]
public async Task CopyToAsync_EmptySource_ProducesEmptyDestination()
{
var source = new MemoryStream();
var destination = new MemoryStream();
await source.CopyToAsync(destination);
Assert.Equal(0, destination.Length);
}
[Fact]
public async Task CopyToAsync_LargerThanBuffer_CopiesAllBytes()
{
// Default buffer is 1 MB; use 3 MB to force multiple reads.
const int size = 3 * 1024 * 1024;
var source = MakeStream(size, fill: 0x77);
var destination = new MemoryStream();
await source.CopyToAsync(destination);
Assert.Equal(size, destination.Length);
}
// ── Progress callback ──────────────────────────────────────────────────────
[Fact]
public async Task CopyToAsync_FinalProgressCallback_IsAlwaysInvoked()
{
var source = MakeStream(128);
var destination = new MemoryStream();
long lastTransferred = -1;
long lastTotal = -1;
await source.CopyToAsync(destination, (transferred, total) =>
{
lastTransferred = transferred;
lastTotal = total;
});
Assert.Equal(128, lastTransferred);
Assert.Equal(128, lastTotal);
}
[Fact]
public async Task CopyToAsync_ProgressCallback_IsInvokedAtConfiguredInterval()
{
// Use a stream larger than the report interval to trigger mid-copy callbacks.
const int reportInterval = 64 * 1024; // 64 KB
const int streamSize = 4 * reportInterval; // 256 KB — forces several interval hits
var source = MakeStream(streamSize);
var destination = new MemoryStream();
var callbackValues = new List<long>();
await source.CopyToAsync(
destination,
progressCallback: (transferred, _) => callbackValues.Add(transferred),
bufferSize: reportInterval, // each read == one interval
reportIntervalBytes: reportInterval);
// At minimum the final callback fires; with buffer == interval every read triggers one.
Assert.NotEmpty(callbackValues);
// Last reported value is the total bytes.
Assert.Equal(streamSize, callbackValues.Last());
}
[Fact]
public async Task CopyToAsync_WithNullProgressCallback_DoesNotThrow()
{
var source = MakeStream(256);
var destination = new MemoryStream();
var ex = await Record.ExceptionAsync(() =>
source.CopyToAsync(destination, progressCallback: null));
Assert.Null(ex);
}
// ── Cancellation ──────────────────────────────────────────────────────────
[Fact]
public async Task CopyToAsync_WithAlreadyCancelledToken_ThrowsOperationCanceledException()
{
var source = MakeStream(1024 * 1024); // large enough to not finish before cancellation
var destination = new MemoryStream();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
source.CopyToAsync(destination, cancellationToken: cts.Token));
}
}

View file

@ -0,0 +1,117 @@
using LANCommander.SDK.Extensions;
namespace LANCommander.SDK.Tests.Extensions;
public class StringExtensionsTests
{
// ── SanitizeFilename ───────────────────────────────────────────────────────
[Theory]
[InlineData("Half-Life 2: Episode One", "Half-Life 2 - Episode One")]
[InlineData("Game: The Sequel", "Game - The Sequel")]
[InlineData("Foo: Bar", "Foo - Bar")]
public void SanitizeFilename_ColonSpacePattern_IsReplacedWithDash(string input, string expected)
{
Assert.Equal(expected, input.SanitizeFilename());
}
[Fact]
public void SanitizeFilename_ColonWithNoSpaceAfter_IsNotChanged()
{
// Only "word: word" (colon-space) triggers the replacement, not bare colons.
// On Linux, ':' is not an invalid filename character so it is left as-is.
var result = "http://example".SanitizeFilename();
// No "word: word" match → colon stays; '/' is invalid on all platforms.
Assert.DoesNotContain("/", result);
}
[Fact]
public void SanitizeFilename_TrailingDot_IsRemoved()
{
var result = "GameTitle.".SanitizeFilename();
Assert.Equal("GameTitle", result);
}
[Fact]
public void SanitizeFilename_NonTrailingDot_IsPreserved()
{
var result = "game.exe".SanitizeFilename();
Assert.Equal("game.exe", result);
}
[Fact]
public void SanitizeFilename_MultipleDots_OnlyLastRemoved()
{
var result = "game...".SanitizeFilename();
Assert.Equal("game..", result);
}
[Fact]
public void SanitizeFilename_ForwardSlash_IsRemoved()
{
// '/' is in Path.GetInvalidFileNameChars() on all platforms.
var result = "foo/bar".SanitizeFilename();
Assert.Equal("foobar", result);
}
[Fact]
public void SanitizeFilename_ForwardSlash_ReplacedWithCustomString()
{
var result = "foo/bar".SanitizeFilename("_");
Assert.Equal("foo_bar", result);
}
[Fact]
public void SanitizeFilename_NoInvalidChars_ReturnsUnchanged()
{
var result = "NormalTitle".SanitizeFilename();
Assert.Equal("NormalTitle", result);
}
[Fact]
public void SanitizeFilename_ColonAndTrailingDot_BothHandled()
{
var result = "Game: Edition.".SanitizeFilename();
Assert.Equal("Game - Edition", result);
}
// ── FastReverse ────────────────────────────────────────────────────────────
[Theory]
[InlineData("hello", "olleh")]
[InlineData("abcde", "edcba")]
[InlineData("12345", "54321")]
[InlineData("a", "a")]
public void FastReverse_ReversesString(string input, string expected)
{
Assert.Equal(expected, input.FastReverse());
}
[Fact]
public void FastReverse_EmptyString_ReturnsEmpty()
{
Assert.Equal("", "".FastReverse());
}
[Fact]
public void FastReverse_Palindrome_ReturnsSameValue()
{
Assert.Equal("racecar", "racecar".FastReverse());
}
[Fact]
public void FastReverse_TwiceProducesOriginal()
{
const string original = "LANCommander";
Assert.Equal(original, original.FastReverse().FastReverse());
}
}

View file

@ -0,0 +1,198 @@
using LANCommander.SDK.Extensions;
using SdkUriExtensions = LANCommander.SDK.Extensions.UriExtensions;
namespace LANCommander.SDK.Tests.Extensions;
public class UriExtensionsTests
{
// ── Join ──────────────────────────────────────────────────────────────────
[Fact]
public void Join_AppendsSingleSegment()
{
var base_ = new Uri("http://example.com");
var result = base_.Join("api");
Assert.Equal("http://example.com/api", result.ToString());
}
[Fact]
public void Join_AppendsMultipleSegments()
{
var base_ = new Uri("http://example.com");
var result = base_.Join("api", "v1", "games");
Assert.Equal("http://example.com/api/v1/games", result.ToString());
}
[Fact]
public void Join_StripsTrailingSlashFromBase()
{
var base_ = new Uri("http://example.com/");
var result = base_.Join("api");
Assert.Equal("http://example.com/api", result.ToString());
}
[Fact]
public void Join_StripsLeadingSlashFromSegment()
{
var base_ = new Uri("http://example.com");
var result = base_.Join("/api");
Assert.Equal("http://example.com/api", result.ToString());
}
[Fact]
public void Join_StripsLeadingAndTrailingSlashesFromSegments()
{
var base_ = new Uri("http://example.com/");
var result = base_.Join("/api/", "/v1/");
Assert.Equal("http://example.com/api/v1", result.ToString());
}
[Fact]
public void Join_WithBaseContainingPath_AppendsCorrectly()
{
var base_ = new Uri("http://example.com/root");
var result = base_.Join("sub");
Assert.Equal("http://example.com/root/sub", result.ToString());
}
// ── CreateUri ─────────────────────────────────────────────────────────────
[Fact]
public void CreateUri_WithFullHttpUri_ReturnsSameUri()
{
var result = SdkUriExtensions.CreateUri("http://example.com");
Assert.Equal("http://example.com/", result.ToString());
}
[Fact]
public void CreateUri_WithFullHttpsUri_ReturnsSameUri()
{
var result = SdkUriExtensions.CreateUri("https://example.com");
Assert.Equal("https://example.com/", result.ToString());
}
[Fact]
public void CreateUri_WithNoScheme_PrependsHttp()
{
var result = SdkUriExtensions.CreateUri("example.com");
Assert.Equal(Uri.UriSchemeHttp, result.Scheme);
Assert.Equal("example.com", result.Host);
}
[Fact]
public void CreateUri_WithNullInput_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => SdkUriExtensions.CreateUri(null));
}
[Fact]
public void CreateUri_WithEmptyInput_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => SdkUriExtensions.CreateUri(""));
}
[Fact]
public void CreateUri_WithWhitespaceInput_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => SdkUriExtensions.CreateUri(" "));
}
[Fact]
public void CreateUri_WithUriContainingPort_PreservesPort()
{
var result = SdkUriExtensions.CreateUri("http://localhost:1337");
Assert.Equal(1337, result.Port);
}
[Fact]
public void CreateUri_WithIpAddress_Works()
{
var result = SdkUriExtensions.CreateUri("192.168.1.1");
Assert.Equal(Uri.UriSchemeHttp, result.Scheme);
Assert.Equal("192.168.1.1", result.Host);
}
// ── TryCreateUri ──────────────────────────────────────────────────────────
[Fact]
public void TryCreateUri_WithValidUri_ReturnsTrueAndSetsResult()
{
var success = SdkUriExtensions.TryCreateUri("http://example.com", out var result);
Assert.True(success);
Assert.NotNull(result);
Assert.Equal("http://example.com/", result.ToString());
}
[Fact]
public void TryCreateUri_WithNoScheme_PrependsHttpAndReturnsTrue()
{
var success = SdkUriExtensions.TryCreateUri("example.com", out var result);
Assert.True(success);
Assert.NotNull(result);
Assert.Equal(Uri.UriSchemeHttp, result.Scheme);
}
[Fact]
public void TryCreateUri_WithNullInput_ReturnsFalse()
{
var success = SdkUriExtensions.TryCreateUri(null, out var result);
Assert.False(success);
Assert.Null(result);
}
[Fact]
public void TryCreateUri_WithEmptyInput_ReturnsFalse()
{
var success = SdkUriExtensions.TryCreateUri("", out var result);
Assert.False(success);
Assert.Null(result);
}
[Fact]
public void TryCreateUri_WithWhitespace_ReturnsFalse()
{
var success = SdkUriExtensions.TryCreateUri(" ", out var result);
Assert.False(success);
Assert.Null(result);
}
[Fact]
public void TryCreateUri_WithHttpsScheme_PreservesScheme()
{
var success = SdkUriExtensions.TryCreateUri("https://secure.example.com", out var result);
Assert.True(success);
Assert.Equal(Uri.UriSchemeHttps, result!.Scheme);
}
[Fact]
public void TryCreateUri_WithPortNumber_PreservesPort()
{
var success = SdkUriExtensions.TryCreateUri("http://localhost:8080", out var result);
Assert.True(success);
Assert.Equal(8080, result!.Port);
}
}

View file

@ -0,0 +1,151 @@
using LANCommander.SDK.Enums;
using LANCommander.SDK.Helpers;
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
using ManifestKey = LANCommander.SDK.Models.Manifest.Key;
using ManifestMedia = LANCommander.SDK.Models.Manifest.Media;
using ManifestSavePath = LANCommander.SDK.Models.Manifest.SavePath;
using ManifestScript = LANCommander.SDK.Models.Manifest.Script;
namespace LANCommander.SDK.Tests.Fixtures;
/// <summary>
/// Builds a complete fake game installation on disk for integration testing.
/// Creates save files, script stubs, and writes a full manifest covering
/// saves, keys, scripts, and media — everything DownloadAsync / UploadAsync touches.
/// </summary>
public sealed class FakeGameFixture : IDisposable
{
// ── Identity ──────────────────────────────────────────────────────────────
/// <summary>Stable ID for the fake game, used as the manifest key and sub-directory name.</summary>
public Guid GameId { get; } = Guid.NewGuid();
/// <summary>Root installation directory created by the fixture.</summary>
public string InstallDirectory { get; }
// ── Manifest ──────────────────────────────────────────────────────────────
/// <summary>Fully populated manifest written to <c>.lancommander/{GameId}/Manifest.yml</c>.</summary>
public ManifestGame Manifest { get; }
// ── Save paths ────────────────────────────────────────────────────────────
/// <summary>SavePath that covers the <c>saves/</c> sub-directory inside the install dir.</summary>
public ManifestSavePath SavesDirSavePath { get; }
/// <summary>SavePath that covers the single <c>config.cfg</c> file in the install dir root.</summary>
public ManifestSavePath ConfigFileSavePath { get; }
// ── Computed on-disk paths ────────────────────────────────────────────────
public string SavesDirectory => Path.Combine(InstallDirectory, "saves");
public string SaveFileSlot1Path => Path.Combine(SavesDirectory, "slot1.sav");
public string SaveFileSlot2Path => Path.Combine(SavesDirectory, "slot2.sav");
public string ConfigFilePath => Path.Combine(InstallDirectory, "config.cfg");
// ── Known file contents ───────────────────────────────────────────────────
public const string Slot1Content = "SAVE_DATA_SLOT_1";
public const string Slot2Content = "SAVE_DATA_SLOT_2";
public const string ConfigContent = "player_name=TestPlayer\ngraphics=high";
// ── Construction ──────────────────────────────────────────────────────────
public FakeGameFixture()
{
InstallDirectory = Path.Combine(Path.GetTempPath(), $"lc-fake-game-{GameId}");
Directory.CreateDirectory(InstallDirectory);
// ── Save files ────────────────────────────────────────────────────────
Directory.CreateDirectory(SavesDirectory);
File.WriteAllText(SaveFileSlot1Path, Slot1Content);
File.WriteAllText(SaveFileSlot2Path, Slot2Content);
File.WriteAllText(ConfigFilePath, ConfigContent);
// ── Script stubs on disk ──────────────────────────────────────────────
WriteScriptFile(ScriptType.Install, "Write-Host 'Installing'");
WriteScriptFile(ScriptType.Uninstall, "Write-Host 'Uninstalling'");
WriteScriptFile(ScriptType.BeforeStart,"Write-Host 'Before start'");
WriteScriptFile(ScriptType.AfterStop, "Write-Host 'After stop'");
WriteScriptFile(ScriptType.NameChange, "Write-Host \"Name change: $PlayerAlias\"");
WriteScriptFile(ScriptType.KeyChange, "Write-Host \"Key change: $AllocatedKey\"");
// ── Save paths ────────────────────────────────────────────────────────
SavesDirSavePath = new ManifestSavePath
{
Id = Guid.NewGuid(),
Type = SavePathType.File,
Path = "saves",
WorkingDirectory = "{InstallDir}",
IsRegex = false
};
ConfigFileSavePath = new ManifestSavePath
{
Id = Guid.NewGuid(),
Type = SavePathType.File,
Path = "config.cfg",
WorkingDirectory = "{InstallDir}",
IsRegex = false
};
// ── Manifest ──────────────────────────────────────────────────────────
Manifest = new ManifestGame
{
Id = GameId,
Title = "Fake Test Game",
Version = "1.0.0",
InstallDirectory = InstallDirectory,
Keys = new List<ManifestKey>
{
new() { Value = "FAKE-KEY-AAAA-1111" },
new() { Value = "FAKE-KEY-BBBB-2222" }
},
Scripts = new List<ManifestScript>
{
new() { Id = Guid.NewGuid(), Type = ScriptType.Install, Name = "Install" },
new() { Id = Guid.NewGuid(), Type = ScriptType.Uninstall, Name = "Uninstall" },
new() { Id = Guid.NewGuid(), Type = ScriptType.BeforeStart,Name = "BeforeStart" },
new() { Id = Guid.NewGuid(), Type = ScriptType.AfterStop, Name = "AfterStop" },
new() { Id = Guid.NewGuid(), Type = ScriptType.NameChange, Name = "NameChange" },
new() { Id = Guid.NewGuid(), Type = ScriptType.KeyChange, Name = "KeyChange" }
},
Media = new List<ManifestMedia>
{
new() { Id = Guid.NewGuid(), FileId = Guid.NewGuid(), Type = MediaType.Cover, MimeType = "image/jpeg", Crc32 = "AABBCCDD" },
new() { Id = Guid.NewGuid(), FileId = Guid.NewGuid(), Type = MediaType.Icon, MimeType = "image/png", Crc32 = "EEFF0011" },
new() { Id = Guid.NewGuid(), FileId = Guid.NewGuid(), Type = MediaType.Background, MimeType = "image/jpeg", Crc32 = "22334455" }
},
SavePaths = new List<ManifestSavePath>
{
SavesDirSavePath,
ConfigFileSavePath
}
};
ManifestHelper.Write(Manifest, InstallDirectory);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private void WriteScriptFile(ScriptType type, string contents)
{
var path = ScriptHelper.GetScriptFilePath(InstallDirectory, GameId, type);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, contents);
}
public void Dispose()
{
if (Directory.Exists(InstallDirectory))
Directory.Delete(InstallDirectory, true);
}
}

View file

@ -1,206 +0,0 @@
using LANCommander.SDK.Helpers;
namespace LANCommander.SDK.Tests.Helpers;
public class RetryHelperTests
{
// ── RetryOnException<T> ───────────────────────────────────────────────────
[Fact]
public void RetryOnException_WhenActionSucceedsImmediately_ReturnsResult()
{
var result = RetryHelper.RetryOnException(3, TimeSpan.Zero, -1, () => 42);
Assert.Equal(42, result);
}
[Fact]
public void RetryOnException_WhenActionSucceedsOnSecondAttempt_ReturnsResult()
{
int calls = 0;
var result = RetryHelper.RetryOnException(3, TimeSpan.Zero, -1, () =>
{
calls++;
if (calls < 2) throw new Exception("first attempt fails");
return 99;
});
Assert.Equal(99, result);
Assert.Equal(2, calls);
}
[Fact]
public void RetryOnException_WhenActionAlwaysFails_ReturnsDefault()
{
var result = RetryHelper.RetryOnException(3, TimeSpan.Zero, -1, () =>
{
throw new Exception("always fails");
#pragma warning disable CS0162
return 0;
#pragma warning restore CS0162
});
Assert.Equal(-1, result);
}
[Fact]
public void RetryOnException_WhenActionAlwaysFails_TriesMaxAttemptsTimes()
{
int calls = 0;
RetryHelper.RetryOnException(4, TimeSpan.Zero, -1, () =>
{
calls++;
throw new Exception("fail");
#pragma warning disable CS0162
return 0;
#pragma warning restore CS0162
});
Assert.Equal(4, calls);
}
[Fact]
public void RetryOnException_WithOneMaxAttempt_DoesNotRetry()
{
int calls = 0;
RetryHelper.RetryOnException(1, TimeSpan.Zero, -1, () =>
{
calls++;
throw new Exception("fail");
#pragma warning disable CS0162
return 0;
#pragma warning restore CS0162
});
Assert.Equal(1, calls);
}
// ── RetryOnException (void) ───────────────────────────────────────────────
// NOTE: The void overload has an infinite loop bug: on successful execution
// of action() there is no return or break, so the loop continues indefinitely.
// Success-path tests for this overload are skipped to avoid hanging.
[Fact(Skip = "RetryOnException void overload loops infinitely on success (missing return/break after action())")]
public void RetryOnException_Void_WhenActionSucceedsImmediately_CompletesOnce()
{
bool ran = false;
RetryHelper.RetryOnException(3, TimeSpan.Zero, () => { ran = true; });
Assert.True(ran);
}
[Fact]
public void RetryOnException_Void_WhenActionAlwaysFails_TriesMaxAttemptsTimes()
{
int calls = 0;
RetryHelper.RetryOnException(3, TimeSpan.Zero, () =>
{
calls++;
throw new Exception("fail");
});
Assert.Equal(3, calls);
}
[Fact]
public void RetryOnException_Void_WithOneMaxAttempt_DoesNotRetry()
{
int calls = 0;
RetryHelper.RetryOnException(1, TimeSpan.Zero, () =>
{
calls++;
throw new Exception("fail");
});
Assert.Equal(1, calls);
}
// ── RetryOnExceptionAsync<T> ──────────────────────────────────────────────
[Fact]
public async Task RetryOnExceptionAsync_WhenActionSucceedsImmediately_ReturnsResult()
{
var result = await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, -1, () => Task.FromResult(42));
Assert.Equal(42, result);
}
[Fact]
public async Task RetryOnExceptionAsync_WhenActionSucceedsOnSecondAttempt_ReturnsResult()
{
int calls = 0;
var result = await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, -1, () =>
{
calls++;
if (calls < 2) throw new Exception("first attempt fails");
return Task.FromResult(77);
});
Assert.Equal(77, result);
Assert.Equal(2, calls);
}
[Fact]
public async Task RetryOnExceptionAsync_WhenActionAlwaysFails_ReturnsDefault()
{
var result = await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, -1, () =>
{
throw new Exception("always fails");
#pragma warning disable CS0162
return Task.FromResult(0);
#pragma warning restore CS0162
});
Assert.Equal(-1, result);
}
[Fact]
public async Task RetryOnExceptionAsync_WhenActionAlwaysFails_TriesMaxAttemptsTimes()
{
int calls = 0;
await RetryHelper.RetryOnExceptionAsync(4, TimeSpan.Zero, -1, () =>
{
calls++;
throw new Exception("fail");
#pragma warning disable CS0162
return Task.FromResult(0);
#pragma warning restore CS0162
});
Assert.Equal(4, calls);
}
// ── RetryOnExceptionAsync (void) ──────────────────────────────────────────
// NOTE: Same infinite loop bug as the sync void overload.
[Fact(Skip = "RetryOnExceptionAsync void overload loops infinitely on success (missing return/break after await action())")]
public async Task RetryOnExceptionAsync_Void_WhenActionSucceedsImmediately_CompletesOnce()
{
bool ran = false;
await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, () => { ran = true; return Task.CompletedTask; });
Assert.True(ran);
}
[Fact]
public async Task RetryOnExceptionAsync_Void_WhenActionAlwaysFails_TriesMaxAttemptsTimes()
{
int calls = 0;
await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, () =>
{
calls++;
throw new Exception("fail");
#pragma warning disable CS0162
return Task.CompletedTask;
#pragma warning restore CS0162
});
Assert.Equal(3, calls);
}
}

View file

@ -1,141 +0,0 @@
using LANCommander.SDK.Helpers;
using System.Text.RegularExpressions;
namespace LANCommander.SDK.Tests.Helpers;
public class TextFileHelperTests : IDisposable
{
private readonly string _tempDir;
public TextFileHelperTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"lc-textfile-tests-{Guid.NewGuid()}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, true);
}
private string WriteTemp(string content)
{
var path = Path.Combine(_tempDir, $"{Guid.NewGuid():N}.txt");
File.WriteAllText(path, content);
return path;
}
// ── Error handling ────────────────────────────────────────────────────────
[Fact]
public void ReplaceAll_WhenFileNotFound_ThrowsFileNotFoundException()
{
var path = Path.Combine(_tempDir, "nonexistent.txt");
Assert.Throws<FileNotFoundException>(() =>
TextFileHelper.ReplaceAll(path, "pattern", "replacement"));
}
// ── Matching and replacement ───────────────────────────────────────────────
[Fact]
public void ReplaceAll_WhenPatternMatches_ReturnsUpdatedContent()
{
var path = WriteTemp("Hello World");
var result = TextFileHelper.ReplaceAll(path, "World", "Earth");
Assert.Equal("Hello Earth", result);
}
[Fact]
public void ReplaceAll_WhenPatternMatches_WritesUpdatedContentToFile()
{
var path = WriteTemp("Hello World");
TextFileHelper.ReplaceAll(path, "World", "Earth");
Assert.Equal("Hello Earth", File.ReadAllText(path));
}
[Fact]
public void ReplaceAll_ReplacesAllOccurrences()
{
var path = WriteTemp("a b a b a");
var result = TextFileHelper.ReplaceAll(path, "a", "x");
Assert.Equal("x b x b x", result);
}
// ── No-match behaviour ────────────────────────────────────────────────────
[Fact]
public void ReplaceAll_WhenPatternDoesNotMatch_ReturnsOriginalContent()
{
var path = WriteTemp("Hello World");
var result = TextFileHelper.ReplaceAll(path, "ZZZ_NOMATCH", "Replacement");
Assert.Equal("Hello World", result);
}
[Fact]
public void ReplaceAll_WhenPatternDoesNotMatch_FileContentIsUnchanged()
{
var original = "Hello World";
var path = WriteTemp(original);
TextFileHelper.ReplaceAll(path, "ZZZ_NOMATCH", "Replacement");
Assert.Equal(original, File.ReadAllText(path));
}
// ── Default options ───────────────────────────────────────────────────────
[Fact]
public void ReplaceAll_DefaultOptions_IsCaseInsensitive()
{
var path = WriteTemp("Hello WORLD");
var result = TextFileHelper.ReplaceAll(path, "world", "Earth");
Assert.Equal("Hello Earth", result);
}
[Fact]
public void ReplaceAll_DefaultOptions_IsMultiline()
{
// ^ matches start of each line in Multiline mode
var path = WriteTemp("line1\nline2\nline3");
var result = TextFileHelper.ReplaceAll(path, "^line", "row");
Assert.Contains("row1", result);
Assert.Contains("row2", result);
Assert.Contains("row3", result);
}
// ── Custom options ────────────────────────────────────────────────────────
[Fact]
public void ReplaceAll_WithCaseSensitiveOptions_DoesNotMatchWrongCase()
{
var path = WriteTemp("Hello WORLD");
var result = TextFileHelper.ReplaceAll(path, "world", "Earth", RegexOptions.None);
Assert.Equal("Hello WORLD", result);
}
[Fact]
public void ReplaceAll_WithCaptureGroups_SubstitutionGroupsAreExpanded()
{
var path = WriteTemp("2024-01-15");
var result = TextFileHelper.ReplaceAll(path, @"(\d{4})-(\d{2})-(\d{2})", "$3/$2/$1");
Assert.Equal("15/01/2024", result);
}
}

View file

@ -1,131 +0,0 @@
using LANCommander.SDK.Services;
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
namespace LANCommander.SDK.Tests.Install;
public class GameInstallationFileListEntryTests
{
private static GameInstallationFileListEntry MakeEntry(params string[] entryPaths)
{
var entry = new GameInstallationFileListEntry();
entry.AddFiles(entryPaths.Select(p => new GameInstallationFileListEntry.FileEntry
{
EntryPath = p,
LocalPath = p
}));
return entry;
}
[Fact]
public void Merge_AddsFilesFromOtherEntry()
{
var target = new GameInstallationFileListEntry();
var source = MakeEntry("game.exe", "readme.txt");
target.Merge(source);
Assert.Equal(2, target.Files.Count);
}
[Fact]
public void Merge_DoesNotAddDuplicateFiles()
{
var target = MakeEntry("game.exe");
var source = MakeEntry("game.exe", "readme.txt");
target.Merge(source);
Assert.Equal(2, target.Files.Count);
Assert.Single(target.Files, f => f.EntryPath == "game.exe");
}
[Fact]
public void Merge_IsCaseInsensitiveForDuplicateDetection()
{
var target = MakeEntry("Game.exe");
var source = MakeEntry("game.exe", "readme.txt");
target.Merge(source);
// "game.exe" should not be added because "Game.exe" already exists (case-insensitive)
Assert.Equal(2, target.Files.Count);
}
[Fact]
public void Merge_SetsManifestWhenTargetManifestIsNull()
{
var manifest = new ManifestGame { Id = Guid.NewGuid(), Title = "Test Game" };
var target = new GameInstallationFileListEntry();
var source = new GameInstallationFileListEntry { Manifest = manifest };
target.Merge(source);
Assert.Equal(manifest, target.Manifest);
}
[Fact]
public void Merge_DoesNotOverwriteExistingManifest()
{
var originalManifest = new ManifestGame { Id = Guid.NewGuid(), Title = "Original" };
var newManifest = new ManifestGame { Id = Guid.NewGuid(), Title = "New" };
var target = new GameInstallationFileListEntry { Manifest = originalManifest };
var source = new GameInstallationFileListEntry { Manifest = newManifest };
target.Merge(source);
Assert.Equal(originalManifest, target.Manifest);
}
[Fact]
public void Merge_WithNullSource_DoesNotThrow()
{
var target = MakeEntry("game.exe");
// Merge with a null-files source (empty entry)
var emptySource = new GameInstallationFileListEntry();
target.Merge(emptySource);
Assert.Single(target.Files);
}
[Fact]
public void AddFile_AddsFileToList()
{
var entry = new GameInstallationFileListEntry();
var file = new GameInstallationFileListEntry.FileEntry
{
EntryPath = "game.exe",
LocalPath = @"C:\Games\game.exe"
};
entry.AddFile(file);
Assert.Single(entry.Files);
Assert.Equal("game.exe", entry.Files[0].EntryPath);
Assert.Equal(@"C:\Games\game.exe", entry.Files[0].LocalPath);
}
[Fact]
public void AddFiles_AddsMultipleFiles()
{
var entry = new GameInstallationFileListEntry();
var files = new[]
{
new GameInstallationFileListEntry.FileEntry { EntryPath = "game.exe", LocalPath = @"C:\game.exe" },
new GameInstallationFileListEntry.FileEntry { EntryPath = "data.pak", LocalPath = @"C:\data.pak" },
new GameInstallationFileListEntry.FileEntry { EntryPath = "readme.txt", LocalPath = @"C:\readme.txt" },
};
entry.AddFiles(files);
Assert.Equal(3, entry.Files.Count);
}
[Fact]
public void AddFile_WithNullArgument_Throws()
{
var entry = new GameInstallationFileListEntry();
Assert.Throws<ArgumentNullException>(() => entry.AddFile(null));
}
}

View file

@ -1,281 +0,0 @@
using LANCommander.SDK.Services;
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
namespace LANCommander.SDK.Tests.Install;
public class GameInstallationFileListTests
{
private static GameInstallationFileList MakeFileList(string installDir, Guid gameId, params string[] filePaths)
{
var list = new GameInstallationFileList(installDir, gameId);
list.BaseGame.AddFiles(filePaths.Select(p => new GameInstallationFileListEntry.FileEntry
{
EntryPath = p,
LocalPath = p
}));
return list;
}
// ── MergeBase ────────────────────────────────────────────────────────────
[Fact]
public void MergeBase_MergesBaseGameFilesIntoTarget()
{
var gameId = Guid.NewGuid();
var target = MakeFileList(@"C:\Games\TestGame", gameId, "game.exe");
var source = MakeFileList(@"C:\Games\TestGame", gameId, "data.pak");
target.MergeBase(source);
Assert.Equal(2, target.BaseGame.Files.Count);
}
[Fact]
public void MergeBase_DoesNotDuplicateExistingFiles()
{
var gameId = Guid.NewGuid();
var target = MakeFileList(@"C:\Games\TestGame", gameId, "game.exe");
var source = MakeFileList(@"C:\Games\TestGame", gameId, "game.exe", "readme.txt");
target.MergeBase(source);
Assert.Equal(2, target.BaseGame.Files.Count);
}
// ── MergeBaseAsDependentGame ──────────────────────────────────────────────
[Fact]
public void MergeBaseAsDependentGame_CreatesDependentGameEntry()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
var target = MakeFileList(@"C:\Games\TestGame", baseGameId);
var addonFileList = MakeFileList(@"C:\Games\TestGame", addonId, "addon.pak");
target.MergeBaseAsDependentGame(addonId, addonFileList);
Assert.True(target.DependentGames.ContainsKey(addonId));
}
[Fact]
public void MergeBaseAsDependentGame_CopiesFilesFromSourceBaseGame()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
var target = MakeFileList(@"C:\Games\TestGame", baseGameId);
var addonFileList = MakeFileList(@"C:\Games\TestGame", addonId, "addon.pak", "addon_data.pak");
target.MergeBaseAsDependentGame(addonId, addonFileList);
Assert.Equal(2, target.DependentGames[addonId].BaseGame.Files.Count);
}
[Fact]
public void MergeBaseAsDependentGame_SetsManifestOnNewDependentGame()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
var manifest = new ManifestGame { Id = addonId, Title = "Test Addon" };
var target = MakeFileList(@"C:\Games\TestGame", baseGameId);
var addonFileList = MakeFileList(@"C:\Games\TestGame", addonId);
addonFileList.BaseGame.Manifest = manifest;
target.MergeBaseAsDependentGame(addonId, addonFileList);
Assert.Equal(manifest, target.DependentGames[addonId].BaseGame.Manifest);
}
[Fact]
public void MergeBaseAsDependentGame_MergesIntoExistingDependentGame()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
var target = MakeFileList(@"C:\Games\TestGame", baseGameId);
var firstAddonFiles = MakeFileList(@"C:\Games\TestGame", addonId, "file1.pak");
target.MergeBaseAsDependentGame(addonId, firstAddonFiles);
var secondAddonFiles = MakeFileList(@"C:\Games\TestGame", addonId, "file2.pak");
target.MergeBaseAsDependentGame(addonId, secondAddonFiles);
Assert.Single(target.DependentGames);
Assert.Equal(2, target.DependentGames[addonId].BaseGame.Files.Count);
}
[Fact]
public void MergeBaseAsDependentGame_DoesNotDuplicateFilesOnSecondMerge()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
var target = MakeFileList(@"C:\Games\TestGame", baseGameId);
var addonFiles = MakeFileList(@"C:\Games\TestGame", addonId, "shared.pak");
target.MergeBaseAsDependentGame(addonId, addonFiles);
target.MergeBaseAsDependentGame(addonId, addonFiles);
Assert.Single(target.DependentGames[addonId].BaseGame.Files);
}
// ── MergeDependentGames ───────────────────────────────────────────────────
[Fact]
public void MergeDependentGames_CopiesDependentGamesFromSource()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
var source = MakeFileList(@"C:\Games\TestGame", baseGameId);
var addonFileList = MakeFileList(@"C:\Games\TestGame", addonId, "addon.pak");
source.MergeBaseAsDependentGame(addonId, addonFileList);
var target = MakeFileList(@"C:\Games\TestGame", baseGameId);
target.MergeDependentGames(source);
Assert.True(target.DependentGames.ContainsKey(addonId));
}
[Fact]
public void MergeDependentGames_WithNullSource_DoesNotThrow()
{
var gameId = Guid.NewGuid();
var target = MakeFileList(@"C:\Games\TestGame", gameId);
target.MergeDependentGames(null);
Assert.Empty(target.DependentGames);
}
[Fact]
public void MergeDependentGames_WithEmptySource_LeavesTargetUnchanged()
{
var gameId = Guid.NewGuid();
var target = MakeFileList(@"C:\Games\TestGame", gameId);
var emptySource = MakeFileList(@"C:\Games\TestGame", gameId);
target.MergeDependentGames(emptySource);
Assert.Empty(target.DependentGames);
}
// ── Merge (combined) ──────────────────────────────────────────────────────
[Fact]
public void Merge_MergesBothBaseGameAndDependentGames()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
var source = MakeFileList(@"C:\Games\TestGame", baseGameId, "new_base_file.pak");
var addonFileList = MakeFileList(@"C:\Games\TestGame", addonId, "addon.pak");
source.MergeBaseAsDependentGame(addonId, addonFileList);
var target = MakeFileList(@"C:\Games\TestGame", baseGameId, "existing_base_file.pak");
target.Merge(source);
Assert.Equal(2, target.BaseGame.Files.Count);
Assert.True(target.DependentGames.ContainsKey(addonId));
}
// ── ToFlatDistinctFileEntries ─────────────────────────────────────────────
[Fact]
public void ToFlatDistinctFileEntries_ReturnsBaseGameFiles()
{
var gameId = Guid.NewGuid();
var list = MakeFileList(@"C:\Games\TestGame", gameId, "game.exe", "data.pak");
var entries = list.ToFlatDistinctFileEntries().ToList();
Assert.Equal(2, entries.Count);
}
[Fact]
public void ToFlatDistinctFileEntries_IncludesDependentGameFiles()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
var list = MakeFileList(@"C:\Games\TestGame", baseGameId, "game.exe");
var addonFileList = MakeFileList(@"C:\Games\TestGame", addonId, "addon.pak");
list.MergeBaseAsDependentGame(addonId, addonFileList);
var entries = list.ToFlatDistinctFileEntries().ToList();
Assert.Equal(2, entries.Count);
Assert.Contains(entries, e => e.EntryPath == "game.exe");
Assert.Contains(entries, e => e.EntryPath == "addon.pak");
}
[Fact]
public void ToFlatDistinctFileEntries_DeduplicatesFilesAcrossBaseAndDependentGames()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
// Same file path in both base game and addon
var list = MakeFileList(@"C:\Games\TestGame", baseGameId, "shared.cfg");
var addonFileList = MakeFileList(@"C:\Games\TestGame", addonId, "shared.cfg", "addon.pak");
list.MergeBaseAsDependentGame(addonId, addonFileList);
var entries = list.ToFlatDistinctFileEntries().ToList();
// shared.cfg should appear only once, addon.pak once
Assert.Equal(2, entries.Count);
Assert.Single(entries.Where(e => e.EntryPath.Equals("shared.cfg", StringComparison.OrdinalIgnoreCase)));
}
[Fact]
public void ToFlatDistinctFileEntries_BaseGameFileWinsOverDependentOnDuplicate()
{
var baseGameId = Guid.NewGuid();
var addonId = Guid.NewGuid();
const string sharedPath = "shared.cfg";
const string baseLocalPath = @"C:\base\shared.cfg";
const string addonLocalPath = @"C:\addon\shared.cfg";
var list = new GameInstallationFileList(@"C:\Games\TestGame", baseGameId);
list.BaseGame.AddFile(new GameInstallationFileListEntry.FileEntry
{
EntryPath = sharedPath,
LocalPath = baseLocalPath
});
var addonFileList = new GameInstallationFileList(@"C:\Games\TestGame", addonId);
addonFileList.BaseGame.AddFile(new GameInstallationFileListEntry.FileEntry
{
EntryPath = sharedPath,
LocalPath = addonLocalPath
});
list.MergeBaseAsDependentGame(addonId, addonFileList);
var entries = list.ToFlatDistinctFileEntries().ToList();
// GroupBy + First means the base game's entry wins
var entry = Assert.Single(entries.Where(e => e.EntryPath == sharedPath));
Assert.Equal(baseLocalPath, entry.LocalPath);
}
[Fact]
public void ToFlatDistinctFileEntries_WithMultipleDependentGames_ReturnsAllFiles()
{
var baseGameId = Guid.NewGuid();
var addon1Id = Guid.NewGuid();
var addon2Id = Guid.NewGuid();
var list = MakeFileList(@"C:\Games\TestGame", baseGameId, "base.exe");
list.MergeBaseAsDependentGame(addon1Id, MakeFileList(@"C:\Games\TestGame", addon1Id, "addon1.pak"));
list.MergeBaseAsDependentGame(addon2Id, MakeFileList(@"C:\Games\TestGame", addon2Id, "addon2.pak"));
var entries = list.ToFlatDistinctFileEntries().ToList();
Assert.Equal(3, entries.Count);
}
[Fact]
public void Empty_ReturnsInstanceWithNullInstallDirectory()
{
var empty = GameInstallationFileList.Empty;
Assert.Null(empty.InstallDirectory);
Assert.Empty(empty.DependentGames);
}
}

View file

@ -46,19 +46,4 @@ public class InstallResultTests
Assert.NotNull(result.FileList);
}
[Fact]
public void InstallDirectory_ReflectsChangeInFileList()
{
var gameId = Guid.NewGuid();
var originalDir = @"C:\Games\Original";
var newDir = @"C:\Games\New";
var result = new InstallResult(originalDir, gameId)
{
InstallDirectory = newDir
};
Assert.Equal(newDir, result.InstallDirectory);
Assert.Equal(newDir, result.FileList.InstallDirectory);
}
}

View file

@ -102,41 +102,5 @@ namespace LANCommander.SDK.Tests
Assert.Equal("{InstallDir}/base/autoexec.cfg", entry.ActualPath);
}
[Fact]
public void RegexInstallDirectorySavePathsShouldWork()
{
// Arrange
var savePath = new SavePath
{
Id = Guid.NewGuid(),
Path = "base\\.*.cfg",
WorkingDirectory = "{InstallDir}",
Type = Enums.SavePathType.File,
IsRegex = true
};
var installDirectory = Path.Combine(Path.GetTempPath(), savePath.Id.ToString());
Directory.CreateDirectory(installDirectory);
Directory.CreateDirectory(Path.Combine(installDirectory, "base"));
File.WriteAllText(Path.Combine(installDirectory, "base", "autoexec.cfg"), savePath.Id.ToString());
File.WriteAllText(Path.Combine(installDirectory, "base", "player.cfg"), savePath.Id.ToString());
// Act
var entries = Saves.GetFileSavePathEntries(savePath, installDirectory);
// Assert
Assert.Equal(2, entries.Count());
Assert.True(File.Exists(Path.Combine($"{Path.GetTempPath()}\\{savePath.Id}\\base\\autoexec.cfg")));
Assert.True(File.Exists(Path.Combine($"{Path.GetTempPath()}\\{savePath.Id}\\base\\player.cfg")));
var autoexec = entries.First();
var player = entries.Last();
Assert.Equal("base/autoexec.cfg", autoexec.ArchivePath);
Assert.Equal("{InstallDir}/base/autoexec.cfg", autoexec.ActualPath);
Assert.Equal("base/player.cfg", player.ArchivePath);
Assert.Equal("{InstallDir}/base/player.cfg", player.ActualPath);
}
}
}

View file

@ -0,0 +1,396 @@
using System.IO.Compression;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Extensions;
using LANCommander.SDK.Helpers;
using LANCommander.SDK.Services;
using LANCommander.SDK.Tests.Fixtures;
using LANCommander.SDK.Utilities;
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
using ManifestSavePath = LANCommander.SDK.Models.Manifest.SavePath;
namespace LANCommander.SDK.Tests.Saves;
/// <summary>
/// Tests the save download + extraction cycle.
///
/// HTTP calls are not exercised. Instead, the save archive is pre-built on-disk
/// using <see cref="SavePacker"/> and restored with the same file-movement logic
/// that <c>SaveClient.DownloadAsync</c> uses, giving us full coverage of the
/// extraction flow without needing a running server.
/// </summary>
public class SaveDownloadTests : IDisposable
{
private readonly string _tempDir;
// Only the filesystem / pure-computation methods are exercised here;
// API-calling members are never invoked, so all DI deps can be null.
private readonly SaveClient _saveClient = new(null, null, null, null);
public SaveDownloadTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"lc-save-download-{Guid.NewGuid()}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, true);
}
// ── FakeGameFixture structure sanity checks ─────────────────────────────
[Fact]
public void FakeGame_ManifestWrittenToDisk()
{
using var fixture = new FakeGameFixture();
Assert.True(ManifestHelper.Exists(fixture.InstallDirectory, fixture.GameId));
}
[Fact]
public void FakeGame_SaveFilesExistOnDisk()
{
using var fixture = new FakeGameFixture();
Assert.True(File.Exists(fixture.SaveFileSlot1Path));
Assert.True(File.Exists(fixture.SaveFileSlot2Path));
Assert.True(File.Exists(fixture.ConfigFilePath));
}
[Fact]
public void FakeGame_ManifestContainsTwoKeys()
{
using var fixture = new FakeGameFixture();
Assert.Equal(2, fixture.Manifest.Keys.Count);
}
[Fact]
public void FakeGame_ManifestContainsExpectedScriptTypes()
{
using var fixture = new FakeGameFixture();
var types = fixture.Manifest.Scripts.Select(s => s.Type).ToList();
Assert.Contains(ScriptType.Install, types);
Assert.Contains(ScriptType.Uninstall, types);
Assert.Contains(ScriptType.BeforeStart,types);
Assert.Contains(ScriptType.AfterStop, types);
Assert.Contains(ScriptType.NameChange, types);
Assert.Contains(ScriptType.KeyChange, types);
}
[Fact]
public void FakeGame_ManifestContainsThreeMediaItems()
{
using var fixture = new FakeGameFixture();
Assert.Equal(3, fixture.Manifest.Media.Count);
}
[Fact]
public void FakeGame_ManifestContainsCoverIconAndBackground()
{
using var fixture = new FakeGameFixture();
var types = fixture.Manifest.Media.Select(m => m.Type).ToList();
Assert.Contains(MediaType.Cover, types);
Assert.Contains(MediaType.Icon, types);
Assert.Contains(MediaType.Background, types);
}
[Fact]
public void FakeGame_ScriptFilesExistOnDisk()
{
using var fixture = new FakeGameFixture();
var scriptTypes = new[]
{
ScriptType.Install, ScriptType.Uninstall,
ScriptType.BeforeStart, ScriptType.AfterStop,
ScriptType.NameChange, ScriptType.KeyChange
};
foreach (var type in scriptTypes)
{
var path = ScriptHelper.GetScriptFilePath(fixture.InstallDirectory, fixture.GameId, type);
Assert.True(File.Exists(path), $"Script file missing for {type}: {path}");
}
}
// ── Packed-archive structure ──────────────────────────────────────────────
[Fact]
public async Task PackedSave_ContainsManifestYml()
{
using var fixture = new FakeGameFixture();
var stream = await BuildArchiveStreamAsync(fixture.InstallDirectory, fixture.Manifest);
var keys = EntryKeys(stream);
Assert.Contains(keys, k => string.Equals(k, "Manifest.yml", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task PackedSave_SavesDirEntries_PrefixedWithCorrectSavePathId()
{
using var fixture = new FakeGameFixture();
using var packer = new SavePacker(fixture.InstallDirectory);
packer.AddPath(fixture.SavesDirSavePath);
var keys = EntryKeys(await packer.PackAsync());
var expectedPrefix = $"Files/{fixture.SavesDirSavePath.Id}/";
Assert.All(keys, k => Assert.StartsWith(expectedPrefix, k));
}
[Fact]
public async Task PackedSave_ConfigFileEntry_PrefixedWithCorrectSavePathId()
{
using var fixture = new FakeGameFixture();
using var packer = new SavePacker(fixture.InstallDirectory);
packer.AddPath(fixture.ConfigFileSavePath);
var keys = EntryKeys(await packer.PackAsync());
var expectedPrefix = $"Files/{fixture.ConfigFileSavePath.Id}/";
Assert.All(keys, k => Assert.StartsWith(expectedPrefix, k));
}
[Fact]
public async Task PackedSave_AllSaveFiles_ArePresent()
{
using var fixture = new FakeGameFixture();
var stream = await BuildArchiveStreamAsync(fixture.InstallDirectory, fixture.Manifest);
var keys = EntryKeys(stream);
Assert.Contains(keys, k => k.EndsWith("slot1.sav"));
Assert.Contains(keys, k => k.EndsWith("slot2.sav"));
Assert.Contains(keys, k => k.EndsWith("config.cfg"));
}
// ── Round-trip: pack → write to disk → extract → restore ─────────────────
[Fact]
public async Task RoundTrip_SavesDirFiles_LandInDestInstallDirectory()
{
using var fixture = new FakeGameFixture();
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
var destDir = CreateDestDir();
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot1.sav")));
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot2.sav")));
}
[Fact]
public async Task RoundTrip_ConfigFile_LandsInDestInstallDirectory()
{
using var fixture = new FakeGameFixture();
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
var destDir = CreateDestDir();
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
Assert.True(File.Exists(Path.Combine(destDir, "config.cfg")));
}
[Fact]
public async Task RoundTrip_SaveContents_ArePreserved()
{
using var fixture = new FakeGameFixture();
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
var destDir = CreateDestDir();
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
Assert.Equal(FakeGameFixture.Slot1Content, File.ReadAllText(Path.Combine(destDir, "saves", "slot1.sav")));
Assert.Equal(FakeGameFixture.Slot2Content, File.ReadAllText(Path.Combine(destDir, "saves", "slot2.sav")));
Assert.Equal(FakeGameFixture.ConfigContent, File.ReadAllText(Path.Combine(destDir, "config.cfg")));
}
[Fact]
public async Task RoundTrip_MultipleSavePaths_AllFilesRestored()
{
using var fixture = new FakeGameFixture();
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
var destDir = CreateDestDir();
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot1.sav")));
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot2.sav")));
Assert.True(File.Exists(Path.Combine(destDir, "config.cfg")));
}
[Fact]
public async Task RoundTrip_OverwritesExistingStaleFile()
{
using var fixture = new FakeGameFixture();
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
var destDir = CreateDestDir();
// Pre-populate destination with stale data
Directory.CreateDirectory(Path.Combine(destDir, "saves"));
File.WriteAllText(Path.Combine(destDir, "saves", "slot1.sav"), "STALE_CONTENT");
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
Assert.Equal(FakeGameFixture.Slot1Content, File.ReadAllText(Path.Combine(destDir, "saves", "slot1.sav")));
}
[Fact]
public async Task RoundTrip_WithRegexSavePath_OnlyMatchingFilesRestored()
{
// Arrange a game that saves only .sav files via a regex save path
var sourceDir = CreateDestDir();
Directory.CreateDirectory(Path.Combine(sourceDir, "saves"));
File.WriteAllText(Path.Combine(sourceDir, "saves", "slot1.sav"), "SLOT1");
File.WriteAllText(Path.Combine(sourceDir, "saves", "slot2.sav"), "SLOT2");
File.WriteAllText(Path.Combine(sourceDir, "saves", "notes.txt"), "NOTES"); // must NOT be packed
var regexSavePath = new ManifestSavePath
{
Id = Guid.NewGuid(),
Type = SavePathType.File,
Path = @"\.sav$",
WorkingDirectory = "{InstallDir}",
IsRegex = true
};
var manifest = new ManifestGame
{
Id = Guid.NewGuid(),
Title = "Regex Save Game",
SavePaths = new List<ManifestSavePath> { regexSavePath }
};
var archivePath = await WriteSaveArchiveAsync(sourceDir, manifest);
var destDir = CreateDestDir();
RestoreSaveArchive(archivePath, destDir, manifest);
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot1.sav")));
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot2.sav")));
Assert.False(File.Exists(Path.Combine(destDir, "saves", "notes.txt")));
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Packs all save paths from <paramref name="manifest"/> and writes the resulting
/// ZIP archive to a temp file. Returns the path to that file.
/// </summary>
private async Task<string> WriteSaveArchiveAsync(string installDirectory, ManifestGame manifest)
{
var stream = await BuildArchiveStreamAsync(installDirectory, manifest);
var archivePath = Path.Combine(_tempDir, $"save-{Guid.NewGuid()}.zip");
stream.Position = 0;
await using var fs = File.Create(archivePath);
await stream.CopyToAsync(fs);
return archivePath;
}
private static async Task<Stream> BuildArchiveStreamAsync(string installDirectory, ManifestGame manifest)
{
using var packer = new SavePacker(installDirectory);
if (manifest.SavePaths?.Any() == true)
packer.AddPaths(manifest.SavePaths);
await packer.AddManifestAsync(manifest);
var packed = await packer.PackAsync();
// Copy to a fresh MemoryStream so the caller owns a non-disposed buffer.
var copy = new MemoryStream();
packed.Position = 0;
await packed.CopyToAsync(copy);
copy.Position = 0;
return copy;
}
/// <summary>
/// Restores a save archive to <paramref name="installDirectory"/>.
///
/// Mirrors the file-movement block from <c>SaveClient.DownloadAsync</c> exactly,
/// replacing only the HTTP download step with the pre-built archive file.
/// This makes the test a faithful integration test of the extraction logic.
/// </summary>
private void RestoreSaveArchive(string archivePath, string installDirectory, ManifestGame manifest)
{
var tempLocation = Path.Combine(Path.GetTempPath(), $"lc-restore-{Guid.NewGuid()}");
try
{
Directory.CreateDirectory(tempLocation);
ZipFile.ExtractToDirectory(archivePath, tempLocation, overwriteFiles: true);
// Mirror legacy-fallback from SaveClient.DownloadAsync
var tempFilesRoot = Directory.Exists(Path.Combine(tempLocation, "Files")) ? "Files" : "Saves";
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == SavePathType.File))
{
var entries = _saveClient.GetFileSavePathEntries(savePath, installDirectory) ?? [];
foreach (var entry in entries)
{
var entryPath = Path.Combine(
tempLocation,
tempFilesRoot,
savePath.Id.ToString(),
entry.ArchivePath.Replace('/', Path.DirectorySeparatorChar));
var destinationPath = entry.ActualPath.ExpandEnvironmentVariables(installDirectory);
if (File.Exists(entryPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (File.Exists(destinationPath)) File.Delete(destinationPath);
File.Move(entryPath, destinationPath);
}
else if (Directory.Exists(entryPath))
{
foreach (var entryFile in Directory.GetFiles(entryPath, "*", SearchOption.AllDirectories))
{
var fileDestination = entryFile.Replace(entryPath, destinationPath);
Directory.CreateDirectory(Path.GetDirectoryName(fileDestination)!);
if (File.Exists(fileDestination)) File.Delete(fileDestination);
File.Move(entryFile, fileDestination);
}
}
}
}
}
finally
{
if (Directory.Exists(tempLocation))
Directory.Delete(tempLocation, true);
}
}
/// <summary>Creates a fresh empty directory under _tempDir for use as a restore destination.</summary>
private string CreateDestDir()
{
var dir = Path.Combine(_tempDir, $"dest-{Guid.NewGuid()}");
Directory.CreateDirectory(dir);
return dir;
}
private static List<string> EntryKeys(Stream stream)
{
stream.Position = 0;
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
return zip.Entries.Select(e => e.FullName.Replace('\\', '/')).ToList();
}
}

View file

@ -0,0 +1,277 @@
using System.IO.Compression;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Services;
using LANCommander.SDK.Tests.Fixtures;
using LANCommander.SDK.Utilities;
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
using ManifestSavePath = LANCommander.SDK.Models.Manifest.SavePath;
namespace LANCommander.SDK.Tests.Saves;
/// <summary>
/// Tests the save upload + packing cycle.
///
/// <c>SaveClient.UploadAsync</c> ends with an HTTP POST; only the packing half is
/// exercised here. <c>SaveClient.PackAsync</c> (no HTTP) and <c>SavePacker</c>
/// directly are both tested so we cover the path the launcher takes after a game exits.
/// </summary>
public class SaveUploadTests : IDisposable
{
private readonly string _tempDir;
// Only the packing / file-system methods are exercised;
// API-calling members are never invoked, so all DI deps can be null.
private readonly SaveClient _saveClient = new(null, null, null, null);
public SaveUploadTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"lc-save-upload-{Guid.NewGuid()}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, true);
}
// ── SaveClient.PackAsync ──────────────────────────────────────────────────
[Fact]
public async Task PackAsync_ProducesReadableZipStream()
{
using var fixture = new FakeGameFixture();
var stream = await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest);
var ex = Record.Exception(() =>
{
stream.Position = 0;
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
_ = zip.Entries.Count;
});
Assert.Null(ex);
}
[Fact]
public async Task PackAsync_IncludesManifestYml()
{
using var fixture = new FakeGameFixture();
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
Assert.Contains(keys, k => string.Equals(k, "Manifest.yml", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task PackAsync_IncludesSaveSlotFiles()
{
using var fixture = new FakeGameFixture();
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
Assert.Contains(keys, k => k.EndsWith("slot1.sav"));
Assert.Contains(keys, k => k.EndsWith("slot2.sav"));
}
[Fact]
public async Task PackAsync_IncludesConfigFile()
{
using var fixture = new FakeGameFixture();
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
Assert.Contains(keys, k => k.EndsWith("config.cfg"));
}
[Fact]
public async Task PackAsync_WithNoSavePaths_ProducesArchiveWithOnlyManifest()
{
var installDir = Path.Combine(_tempDir, "empty-game");
Directory.CreateDirectory(installDir);
var manifest = new ManifestGame
{
Id = Guid.NewGuid(),
Title = "No-Save Game",
SavePaths = new List<ManifestSavePath>()
};
var keys = EntryKeys(await _saveClient.PackAsync(installDir, manifest));
Assert.Single(keys);
Assert.Contains(keys, k => string.Equals(k, "Manifest.yml", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task PackAsync_WithRegexSavePath_ExcludesNonMatchingFiles()
{
var installDir = CreateGameDir(
("save1.sav", "SAVE1"),
("save2.sav", "SAVE2"),
("readme.txt", "README"));
var manifest = new ManifestGame
{
Id = Guid.NewGuid(),
Title = "Regex Game",
SavePaths = new List<ManifestSavePath>
{
new()
{
Id = Guid.NewGuid(),
Type = SavePathType.File,
Path = @"\.sav$",
WorkingDirectory = "{InstallDir}",
IsRegex = true
}
}
};
var keys = EntryKeys(await _saveClient.PackAsync(installDir, manifest));
Assert.DoesNotContain(keys, k => k.EndsWith(".txt"));
Assert.Contains(keys, k => k.EndsWith(".sav"));
}
[Fact]
public async Task PackAsync_WithMultipleSavePaths_AllFilesIncluded()
{
using var fixture = new FakeGameFixture();
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
Assert.Contains(keys, k => k.Contains(fixture.SavesDirSavePath.Id.ToString()));
Assert.Contains(keys, k => k.Contains(fixture.ConfigFileSavePath.Id.ToString()));
}
[Fact]
public async Task PackAsync_EachSavePath_StoredUnderDistinctIdPrefix()
{
using var fixture = new FakeGameFixture();
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
var saveDirKeys = keys.Where(k => k.Contains(fixture.SavesDirSavePath.Id.ToString())).ToList();
var configKeys = keys.Where(k => k.Contains(fixture.ConfigFileSavePath.Id.ToString())).ToList();
Assert.NotEmpty(saveDirKeys);
Assert.NotEmpty(configKeys);
Assert.Empty(saveDirKeys.Intersect(configKeys));
}
// ── SavePacker directly ───────────────────────────────────────────────────
[Fact]
public async Task SavePacker_WithFakeGame_ProducesExpectedFileCount()
{
using var fixture = new FakeGameFixture();
using var packer = new SavePacker(fixture.InstallDirectory);
packer.AddPaths(fixture.Manifest.SavePaths);
await packer.AddManifestAsync(fixture.Manifest);
// slot1.sav + slot2.sav + config.cfg + Manifest.yml = 4
Assert.Equal(4, EntryKeys(await packer.PackAsync()).Count);
}
[Fact]
public async Task SavePacker_PackedContent_MatchesOriginalFileContent()
{
using var fixture = new FakeGameFixture();
using var packer = new SavePacker(fixture.InstallDirectory);
packer.AddPath(fixture.ConfigFileSavePath);
var stream = await packer.PackAsync();
stream.Position = 0;
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
var configEntry = zip.Entries.FirstOrDefault(e => e.FullName.EndsWith("config.cfg"));
Assert.NotNull(configEntry);
using var reader = new StreamReader(configEntry.Open());
var content = await reader.ReadToEndAsync();
Assert.Equal(FakeGameFixture.ConfigContent, content);
}
[Fact]
public async Task SavePacker_WithDirectorySavePath_PacksAllFilesInDirectory()
{
using var fixture = new FakeGameFixture();
using var packer = new SavePacker(fixture.InstallDirectory);
packer.AddPath(fixture.SavesDirSavePath);
var keys = EntryKeys(await packer.PackAsync());
Assert.Equal(2, keys.Count);
Assert.Contains(keys, k => k.EndsWith("slot1.sav"));
Assert.Contains(keys, k => k.EndsWith("slot2.sav"));
}
[Fact]
public async Task SavePacker_AfterGameExit_NewSaveFileIsIncluded()
{
using var fixture = new FakeGameFixture();
// Simulate a new save written after game launch
File.WriteAllText(Path.Combine(fixture.SavesDirectory, "slot3.sav"), "NEW_SLOT");
using var packer = new SavePacker(fixture.InstallDirectory);
packer.AddPath(fixture.SavesDirSavePath);
var keys = EntryKeys(await packer.PackAsync());
Assert.Contains(keys, k => k.EndsWith("slot3.sav"));
}
[Fact]
public async Task SavePacker_AfterGameExit_ModifiedSaveFileContentIsPreserved()
{
using var fixture = new FakeGameFixture();
// Simulate the game overwriting slot1 with new data
File.WriteAllText(fixture.SaveFileSlot1Path, "UPDATED_SAVE");
using var packer = new SavePacker(fixture.InstallDirectory);
packer.AddPath(fixture.SavesDirSavePath);
var stream = await packer.PackAsync();
stream.Position = 0;
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
var entry = zip.Entries.FirstOrDefault(e => e.FullName.EndsWith("slot1.sav"));
Assert.NotNull(entry);
using var reader = new StreamReader(entry.Open());
var content = await reader.ReadToEndAsync();
Assert.Equal("UPDATED_SAVE", content);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private string CreateGameDir(params (string relativePath, string content)[] files)
{
var dir = Path.Combine(_tempDir, $"game-{Guid.NewGuid()}");
Directory.CreateDirectory(dir);
foreach (var (relativePath, content) in files)
{
var full = Path.Combine(dir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content);
}
return dir;
}
private static List<string> EntryKeys(Stream stream)
{
stream.Position = 0;
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
return zip.Entries.Select(e => e.FullName.Replace('\\', '/')).ToList();
}
}

View file

@ -0,0 +1,543 @@
# LANCommander.SDK — Test Coverage Plan
## Current Status
| Area | Classes | Methods | Tests | Coverage |
|---|---|---|---|---|
| Extensions | 8 public | ~20 public | 88 | High |
| Helpers — `DirectoryHelper` | 1 | 3 | 22 | High |
| Helpers — `ManifestHelper` | 1 | 9 | 40+ | High |
| Helpers — `ScriptHelper` | 1 | 6 | 20+ | High |
| Helpers — `IniHelper` | 1 | 4 | ~20 (via IniHandling/) | Medium |
| Helpers — `DisplayHelper` | 1 | 1 | 0 | None |
| Helpers — `EnvironmentHelper` | 1 | 1 | 0 | None |
| Helpers — `VersionHelper` | 1 | 1 | 0 | None |
| Utilities — `SavePacker` | 1 | 7 | ~20 | Medium |
| Utilities — `RegistryExportUtility` | 1 | 1 | 0 | None |
| Utilities — `RegistryImportUtility` | 1 | 1 | 0 | None |
| Clients (all 18) | 18 | 100+ | 0 | None |
| Models with logic | ~5 | ~10 | ~5 | Low |
---
## Test Infrastructure Requirements
Before writing client tests, two pieces of test infrastructure are needed.
### 1. Fake `HttpMessageHandler`
All HTTP clients use `ApiRequestFactory``ApiRequestBuilder``HttpClient`. The most effective
isolation strategy is a fake `HttpMessageHandler` that intercepts requests and returns controlled
responses, without touching a real server.
```csharp
// Suggested location: LANCommander.SDK.Tests/Infrastructure/FakeHttpMessageHandler.cs
public class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
=> _handler = handler;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_handler(request));
}
```
For JSON responses, a helper that serializes any object to an `application/json`
`StringContent` with status 200 reduces boilerplate across all client tests.
### 2. Package References
Add to `LANCommander.SDK.Tests.csproj`:
```xml
<PackageReference Include="NSubstitute" Version="5.*" />
```
NSubstitute is used to mock `ILogger<T>`, `ISettingsProvider`, `ITokenProvider`,
`IConnectionClient`, and other injected dependencies that client constructors require but
whose behavior is not under test.
### 3. Shared Client Fixtures
Because every client shares the same constructor pattern (`ILogger`, `ApiRequestFactory`, etc.),
a base class or shared factory method should build a ready-to-use client with a fake
`HttpMessageHandler` injected, reducing boilerplate to a single line per test.
---
## Coverage Plan
### Priority 1 — Pure Computation (no mocks needed)
These classes have no I/O dependencies and can be tested directly. Most are already covered;
the gaps are small.
---
#### `VersionHelper.GetCurrentVersion()`
**File:** `Helpers/VersionHelperTests.cs`
| Test | What it checks |
|---|---|
| `GetCurrentVersion_ReturnsNonNullVersion` | Return value is not null |
| `GetCurrentVersion_ReturnsSemVersion` | Parses into a valid `SemVersion` |
| `GetCurrentVersion_MajorAndMinorAreNonNegative` | Sanity-check numeric components |
**Notes:** The method reads from the executing assembly's `AssemblyInformationalVersionAttribute`.
The version available during test runs may be `0.0.0` or similar — tests should assert shape,
not a specific value.
---
#### `IniHelper` (gap fill)
**File:** `IniHandling/IniHelperTests.cs`
The existing `IniHandlingTests_MadMilkman` already tests `FromString` and `ToString`
indirectly via the `ConfigurationTests` data set. The following dedicated tests cover
`IniHelper`'s own behaviour independently of `MadMilkman` specifics.
| Test | What it checks |
|---|---|
| `FromString_WithEmptyString_ReturnsEmptyIniFile` | No sections or keys |
| `FromString_WithNullString_ThrowsOrReturnsEmpty` | Null safety |
| `FromString_DefaultOptions_AllowsDuplicateKeys` | Default `IniOptions` behaviour |
| `ToString_RoundTrip_PreservesAllKeyValues` | Serialise then re-parse matches original |
| `ToString_WithCustomEncoding_WritesExpectedBytes` | Encoding parameter is respected |
| `FromString_WithCustomOptions_KeyDuplicateIgnored_TakesFirstValue` | `IniDuplication.Ignored` |
| `FromString_WithCustomOptions_KeyDuplicateAllowed_RetainsAllValues` | `IniDuplication.Allowed` |
---
#### `ProfileClient.GetAvatarUri()` and `MediaClient` URI/path helpers
**File:** `Clients/MediaClientPureTests.cs`
These methods are pure string computation inside otherwise network-heavy clients, making them
good isolated targets.
| Test | Method | What it checks |
|---|---|---|
| `GetAbsoluteUrl_WithValidMedia_ReturnsAbsoluteUri` | `MediaClient.GetAbsoluteUrl` | Scheme + host from settings |
| `GetLocalPath_WithMedia_ReturnsExpectedFormat` | `MediaClient.GetLocalPath(Media)` | FileId + CRC32 path format |
| `GetLocalPath_WithFileIdAndCrc32_MatchesMediaOverload` | `MediaClient.GetLocalPath(Guid, string)` | Both overloads agree |
| `GetDownloadPath_ReturnsPathUnderExpectedDirectory` | `MediaClient.GetDownloadPath` | Path structure |
| `GetAvatarUri_BuildsCorrectUrl` | `ProfileClient.GetAvatarUri` | Host + username in URI |
| `CalculateChecksumAsync_OnKnownContent_ReturnsExpectedCrc32` | `MediaClient.CalculateChecksumAsync` | File CRC32 is deterministic |
---
### Priority 2 — Helpers with filesystem I/O (already have test infrastructure)
These use the established `IDisposable`+temp-directory pattern from `DirectoryHelperTests`.
---
#### `EnvironmentHelper.IsRunningInContainer()`
**File:** `Helpers/EnvironmentHelperTests.cs`
The method checks three signals in order:
1. Existence of `/.dockerenv`
2. Contents of `/proc/1/cgroup`
3. Environment variables (`KUBERNETES_SERVICE_HOST`, `container`, `PODMAN_VERSION`, etc.)
Because the checks read real filesystem paths, tests must set up controlled temporary files or
environment variables rather than relying on the host system's state.
| Test | Setup | What it checks |
|---|---|---|
| `IsRunningInContainer_WithDockerenvFile_ReturnsTrue` | Create `{tempDir}/.dockerenv`; inject path | Docker detection via file |
| `IsRunningInContainer_WithCgroupContainingDocker_ReturnsTrue` | Write `docker` into a temp cgroup file | cgroup-based detection |
| `IsRunningInContainer_WithCgroupContainingKubernetes_ReturnsTrue` | Write `kubepods` into cgroup | Kubernetes via cgroup |
| `IsRunningInContainer_WithKubernetesEnvVar_ReturnsTrue` | Set `KUBERNETES_SERVICE_HOST` env var | Kubernetes env var |
| `IsRunningInContainer_WithContainerEnvVar_ReturnsTrue` | Set `container=podman` env var | Podman/container env var |
| `IsRunningInContainer_WithNoSignals_ReturnsFalse` | No file, no env var, empty cgroup | Normal environment |
**Notes:** The current implementation reads hardcoded paths (`/.dockerenv`, `/proc/1/cgroup`).
A thin path-injection seam (or a wrapper method the tests can override) will be needed to avoid
making these tests host-dependent.
---
#### `DisplayHelper.GetScreen()` — Linux parsing only
**File:** `Helpers/DisplayHelperTests.cs`
The three Linux code paths (`xrandr`, `xdpyinfo`, `/sys/class/drm`) each parse a specific text
format. The parsing logic can be tested directly if it is extracted into internal parse methods,
or indirectly by supplying mock process output.
| Test | What it checks |
|---|---|
| `ParseXrandrOutput_WithTypicalOutput_ExtractsBoundsAndRefreshRate` | Width × height × Hz from `xrandr` format |
| `ParseXrandrOutput_WithMultipleDisplayLines_PicksConnected` | Multiple monitors, picks "connected" line |
| `ParseXdpyinfoOutput_WithTypicalOutput_ExtractsDimensions` | Width × height from `xdpyinfo` format |
| `ParseDrmOutput_WithTypicalFilesystemContent_ExtractsDimensions` | `/sys/class/drm/*/modes` format |
| `GetScreen_WhenNoDisplayServer_ReturnsDefaultOrNull` | Graceful fallback when xrandr is absent |
**Notes:** The private parse helpers are currently inline within `GetScreen()`. Extracting them
to `internal static` methods would allow direct testing without spawning processes.
---
### Priority 3 — Utilities
---
#### `SavePacker` (gap fill)
**File:** `Utilities/SavePackerTests.cs` *(add to existing file)*
The existing `SavePackerTests` already cover the primary happy paths. The remaining gaps are:
| Test | What it checks |
|---|---|
| `AddPath_WithRegistryType_CallsAddRegistryPath` | `SavePathType.Registry` routes correctly |
| `AddRegistryPath_OnLinux_ProducesNoEntries` | Registry export is no-op on Linux |
| `AddPaths_TwoDifferentSavePathIds_EachInOwnSubdirectory` | Partition by `SavePath.Id` in zip |
| `PackAsync_CalledTwice_ReturnsFreshStreamEachTime` | Idempotency / re-use after pack |
| `PackAsync_EmptyPacker_ProducesValidZipStream` | Empty archive is still valid ZIP |
| `AddManifestAsync_CalledTwice_HasManifestRemainsTrue` | No error on duplicate manifest add |
---
#### `RegistryExportUtility` — Windows-only
**File:** `Utilities/RegistryExportUtilityTests.cs`
These tests only run on Windows. Decorate the class with
`[PlatformSpecific(TestPlatforms.Windows)]` (or use `Skip` on non-Windows).
| Test | What it checks |
|---|---|
| `Export_WithKnownRegistryKey_ProducesRegFileFormat` | Output starts with `Windows Registry Editor` header |
| `Export_WithStringValue_IncludesRegSz` | `REG_SZ` values appear as `"key"="value"` |
| `Export_WithDwordValue_IncludesRegDword` | `REG_DWORD` values formatted with hex |
| `Export_WithExpandStringValue_IncludesRegExpandSz` | `REG_EXPAND_SZ` type tag |
| `Export_WithMultiStringValue_IncludesRegMultiSz` | Null-separated multi-string encoding |
| `Export_WithBinaryValue_IncludesRegBinary` | Hex byte sequence format |
| `Export_WithNonExistentKey_ReturnsEmptyOrThrows` | Error handling for missing keys |
| `Export_WithNestedSubkeys_RecursivelyCapturesAll` | Deep key trees |
---
#### `RegistryImportUtility` — Windows-only
**File:** `Utilities/RegistryImportUtilityTests.cs`
| Test | What it checks |
|---|---|
| `Import_ValidRegFile_WritesValuesToRegistry` | Round-trip with `RegistryExportUtility` |
| `Import_WithDeletedKey_RemovesKey` | `-` prefix in `.reg` syntax |
| `Import_WithMalformedFile_ThrowsOrReturnsError` | Error handling |
| `Import_WithEmptyFile_DoesNotThrow` | Edge case |
---
### Priority 4 — Clients (require mock HTTP infrastructure)
All clients use `ApiRequestFactory``HttpClient`. The recommended approach is to create
`ApiRequestFactory` with a custom `HttpClient` backed by `FakeHttpMessageHandler`, then
construct the client under test with that factory.
---
#### `AuthenticationClient`
**File:** `Clients/AuthenticationClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `AuthenticateAsync_WithValidCredentials_ReturnsToken` | POST `/api/Auth/Login` → 200 + `AuthToken` JSON | Token returned and stored |
| `AuthenticateAsync_WithWrongPassword_ThrowsOrReturnsNull` | POST `/api/Auth/Login` → 401 | Failure handling |
| `LogoutAsync_SendsDeleteRequest` | POST/DELETE `/api/Auth/Logout` → 200 | Request sent |
| `RegisterAsync_WithValidData_Succeeds` | POST `/api/Auth/Register` → 200 | No exception |
| `RegisterAsync_WithConflictingUsername_ThrowsOrReturns` | POST `/api/Auth/Register` → 409 | Conflict handling |
| `ValidateTokenAsync_WithValidToken_ReturnsTrue` | GET `/api/Auth/Validate` → 200 | True returned |
| `ValidateTokenAsync_WithExpiredToken_ReturnsFalse` | GET `/api/Auth/Validate` → 401 | False returned |
| `GetAuthenticationProvidersAsync_ReturnsProviderList` | GET `/api/Auth/GetAuthenticationProviders` → provider JSON | List deserialized |
| `GetAuthenticationProviderLoginUrl_WithProvider_BuildsCorrectUrl` | No HTTP needed | URI shape correct |
---
#### `GameClient` — metadata and static methods
**File:** `Clients/GameClientTests.cs`
The `GameClient` is the most complex client (~1800 lines). Focus on the methods that do not
orchestrate archive downloads, as those are better covered by integration tests.
| Test | HTTP mock | What it checks |
|---|---|---|
| `GetAsync_ReturnsDeserializedGameList` | GET `/api/Games` → game list JSON | Correct deserialization |
| `GetAsync_ById_ReturnsGame` | GET `/api/Games/{id}` → single game JSON | Single game returned |
| `GetManifestAsync_ReturnsManifest` | GET `/api/Games/{id}/Manifest` → YAML | Manifest deserialized |
| `GetAddonsAsync_ReturnsAddonList` | GET `/api/Games/{id}/Addons` → addon JSON | Addons listed |
| `GetToolsAsync_ReturnsToolList` | GET `/api/Games/{id}/Tools` → tool JSON | Tools listed |
| `CheckForUpdateAsync_WhenUpdateAvailable_ReturnsTrue` | GET `/api/Games/{id}/CheckForUpdate``{"updateAvailable":true}` | True returned |
| `CheckForUpdateAsync_WhenUpToDate_ReturnsFalse` | GET `/api/Games/{id}/CheckForUpdate``{"updateAvailable":false}` | False returned |
| `StartedAsync_SendsRequest` | GET `/api/Games/{id}/Started` → 200 | Request sent to correct URL |
| `StoppedAsync_SendsRequest` | GET `/api/Games/{id}/Stopped` → 200 | Request sent to correct URL |
| `GetMetadataDirectoryPath_ReturnsCorrectPath` | No HTTP | Path contains `.lancommander/{id}` |
| `GetPlayerAlias_WhenFileAbsent_ReturnsEmpty` | No HTTP, temp dir | Empty string returned |
| `UpdatePlayerAlias_WritesAliasFile` | No HTTP, temp dir | File written, alias readable |
| `GetCurrentKey_WhenFileAbsent_ReturnsEmpty` | No HTTP, temp dir | Empty string returned |
| `UpdateCurrentKey_WritesKeyFile` | No HTTP, temp dir | File written, key readable |
---
#### `DepotClient`
**File:** `Clients/DepotClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `GetAsync_ReturnsDepotResults` | GET `/api/Depot` → results JSON | Deserialized correctly |
| `GetGameAsync_ReturnsDepotGame` | GET `/api/Depot/Games/{id}` → game JSON | Single game deserialized |
| `GetGameAsync_WithServerError_Throws` | GET `/api/Depot/Games/{id}` → 500 | Exception propagated |
---
#### `LibraryClient`
**File:** `Clients/LibraryClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `GetAsync_ReturnsEntityReferences` | GET `/api/Library` → reference list JSON | List deserialized |
| `AddToLibrary_WithValidGameId_ReturnsTrue` | POST `/api/Library/AddToLibrary/{id}``true` | True returned |
| `RemoveFromLibrary_ById_ReturnsTrue` | POST `/api/Library/RemoveFromLibrary/{id}``true` | True returned |
| `RemoveFromLibrary_WithAddonIds_SendsAddonList` | POST `/api/Library/RemoveFromLibrary/{id}/addons` | Addon IDs in request body |
---
#### `TagClient`
**File:** `Clients/TagClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `CreateAsync_SendsTagAndReturnsCreated` | POST `/api/Tags` → created tag JSON | Returned tag has correct fields |
| `UpdateAsync_SendsUpdatedTag` | POST `/api/Tags/{id}` → updated tag JSON | Request routed correctly |
| `DeleteAsync_SendsDeleteRequest` | DELETE `/api/Tags/{id}` → 200 | Delete request sent |
---
#### `PlaySessionClient`
**File:** `Clients/PlaySessionClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `GetAsync_ReturnsSessions` | GET `/api/PlaySessions` → session list JSON | List deserialized |
| `GetAsync_ByGameId_ReturnsGameSessions` | GET `/api/PlaySessions/{id}` → session list JSON | Filtered sessions returned |
---
#### `ProfileClient`
**File:** `Clients/ProfileClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `GetAsync_ReturnsUser` | GET `/api/Profile` → user JSON | User deserialized |
| `GetAsync_CalledTwice_UsesCachedResult` | GET `/api/Profile` → 200 (once) | Only one HTTP call made |
| `GetAsync_WithForceLoad_BypassesCache` | GET `/api/Profile` → 200 (twice) | Two HTTP calls made |
| `GetAliasAsync_ReturnsAliasFromUser` | GET `/api/Profile` → user JSON | Alias extracted from user object |
| `ChangeAliasAsync_SendsPutRequest` | PUT `/api/Profile/ChangeAlias` → new alias string | Alias value in response |
| `GetCustomFieldAsync_ReturnsFieldValue` | GET `/api/Profile/CustomField/{name}` → value string | Value returned |
| `UpdateCustomFieldAsync_SendsNewValue` | PUT `/api/Profile/CustomField/{name}` → value | Request body matches value |
---
#### `MediaClient` — network methods
**File:** `Clients/MediaClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `GetAsync_ReturnsMediaObject` | GET `/api/Media/{id}` → media JSON | Deserialized correctly |
| `DownloadAsync_WritesFileToDestination` | GET → binary stream | File exists at destination path |
| `DownloadAsync_WhenDestinationDirectoryAbsent_CreatesIt` | GET → binary stream | Directory auto-created |
| `GetStaleLocalPaths_WhenNoFilesExist_ReturnsEmpty` | No HTTP, temp dir | Empty result |
| `GetStaleLocalPaths_WhenOldVersionsExist_ReturnsThem` | No HTTP, temp dir | Stale files enumerated |
| `CalculateChecksumAsync_KnownContent_ReturnsDeterministicCrc32` | No HTTP, temp file | Same file → same CRC |
---
#### `IssueClient`
**File:** `Clients/IssueClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `Open_WithValidIssue_ReturnsTrueOnSuccess` | POST `/api/Issue/Open``true` | True returned |
| `Open_WithServerError_ReturnsFalse` | POST `/api/Issue/Open` → 500 | False returned or exception |
---
#### `LauncherClient`
**File:** `Clients/LauncherClientTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `CheckForUpdateAsync_WhenUpdateAvailable_ReturnsResponse` | GET `/api/Launcher/CheckForUpdate` → JSON | Response deserialized |
| `DownloadAsync_WritesFileToGivenPath` | GET → binary stream | File written to destination |
---
#### `ConnectionClient` — pure-computation methods
**File:** `Clients/ConnectionClientTests.cs`
Focus on the methods that do not require a running server:
| Test | Setup | What it checks |
|---|---|---|
| `IsConnected_WhenNotYetConnected_ReturnsFalse` | Fresh instance | False by default |
| `IsConfigured_WhenServerAddressSet_ReturnsTrue` | Mock `ISettingsProvider` with address | True when configured |
| `IsConfigured_WhenNoAddress_ReturnsFalse` | Mock `ISettingsProvider` with empty address | False without address |
| `HasServerAddress_WhenAddressSet_ReturnsTrue` | Mock provider | True |
| `IsOfflineMode_WhenOfflineModeEnabled_ReturnsTrue` | Mock provider offline setting | True |
| `PingAsync_WithSuccessfulResponse_ReturnsTrue` | HTTP HEAD → custom X-Pong header | True returned |
| `PingAsync_WithTimeout_ReturnsFalse` | HTTP HEAD → timeout | False returned without throw |
---
#### `SaveClient` — pure-computation methods
**File:** `Clients/SaveClientTests.cs` *(extend existing file)*
The existing `SaveService.cs` tests cover `GetLocalPath`, `GetArchivePath`, and
`GetFileSavePathEntries`. The following are the remaining gaps:
| Test | Setup | What it checks |
|---|---|---|
| `PackAsync_WithManifestAndSavePaths_ProducesZip` | Temp dir + manifest | Zip contains expected entries |
| `GetLocalPath_WithInstallDir_ReturnsInstallPath` | No I/O | `{InstallDir}` expanded correctly |
| `GetLocalPath_WithMyDocuments_ExpandsToActualPath` | No I/O | Special folder expansion |
| `GetArchivePath_StripsInstallDirPrefix` | No I/O | Archive-relative path returned |
| `GetFileSavePathEntries_WithDirectorySavePath_ReturnsAllFiles` | Temp dir with files | All files enumerated |
| `GetFileSavePathEntries_WithRegexSavePath_OnlyMatchingReturned` | Temp dir with mixed files | Non-matching excluded |
---
#### `BeaconClient` — unit-testable subset
**File:** `Clients/BeaconClientTests.cs`
The UDP broadcast and socket operations require a real network or a UDP socket mock, making
full integration tests impractical for unit tests. Focus on the fluent API and configuration.
| Test | Setup | What it checks |
|---|---|---|
| `AddBeaconMessageInterceptor_ReturnsClientInstance` | No network | Fluent return value |
| `AddBeaconMessageInterceptor_InterceptorIsCalledOnMessage` | No network, mock interceptor | Interceptor receives message |
| `CleanupProbe_WhenNotStarted_DoesNotThrow` | No network | Safe on unused instance |
| `StopProbeAsync_WhenNotRunning_DoesNotThrow` | No network | Safe on unused instance |
| `StopBeaconAsync_WhenNotRunning_DoesNotThrow` | No network | Safe on unused instance |
---
### Priority 5 — Models with non-trivial logic
---
#### `InstallProgress`
**File:** `Install/InstallProgressTests.cs` *(already covered, adding edge cases)*
| Test | What it checks |
|---|---|
| `Progress_WhenTotalBytesIsZero_ReturnsNaN` | Division by zero → `float.NaN` *(already exists)* |
| `Progress_WhenBytesExceedTotal_ReturnsGreaterThanOne` | Over-transfer edge case |
| `Progress_WhenTotalIsNegative_BehavesConsistently` | Negative total |
---
#### `ChatThread`
**File:** `Models/ChatThreadTests.cs`
`ChatThread` has observable message collections and async event callbacks.
| Test | What it checks |
|---|---|
| `AddMessage_IncreasesMessageCount` | Message added to collection |
| `AddMessage_FiresMessagesReceivedAsync_IfSubscribed` | Callback invoked |
| `Messages_InitiallyEmpty` | Default state |
| `Typing_InitiallyEmpty` | Default state for typing indicators |
---
### Priority 6 — `ApiRequestBuilder`
`ApiRequestBuilder` is the common HTTP plumbing used by all clients. Testing it in isolation
provides coverage of the serialization, header injection, and progress-reporting logic that all
clients share.
**File:** `Helpers/ApiRequestBuilderTests.cs`
| Test | HTTP mock | What it checks |
|---|---|---|
| `GetAsync_SendsGetRequest_ToConfiguredRoute` | Any 200 | Method is GET, URL matches route |
| `PostAsync_SendsJsonBody` | Capture body, return 200 | Request body deserialized matches input |
| `PutAsync_SendsJsonBody` | Capture body, return 200 | Method is PUT, body correct |
| `DeleteAsync_SendsDeleteRequest` | Any 200 | Method is DELETE |
| `HeadAsync_SendsHeadRequest` | Any 200 | Method is HEAD |
| `UseAuthenticationToken_AddsAuthorizationHeader` | Capture headers | `Authorization: Bearer <token>` present |
| `UseVersioning_AddsVersionHeader` | Capture headers | Custom version header present |
| `AddHeader_AddsCustomHeader` | Capture headers | Header value matches |
| `SetTimeout_OverridesDefault` | Delayed response | Request cancelled after timeout |
| `OnProgress_CalledDuringDownload` | Streaming binary | Progress callback fires |
| `OnComplete_CalledAfterDownload` | Any 200 | Completion callback fires |
| `SendAsync_On4xx_ThrowsOrReturnsError` | 404 response | Handled consistently |
| `SendAsync_On5xx_ThrowsOrPropagates` | 500 response | Error propagated |
| `DownloadAsync_WritesResponseToFile` | Binary stream response | File written at path |
| `UploadAsync_SendsFileAsMultipart` | Capture request | Content-Type is multipart |
---
## Platform-Specific Tests
Tests that only run on a specific platform should use `Skip` to self-document why they
are not running rather than silently passing.
```csharp
[Fact(Skip = "Windows-only: requires P/Invoke to ntdll.dll")]
public void GetParentProcessId_ReturnsParentPid() { ... }
```
### Windows-only
- `RegistryExportUtilityTests` (entire file)
- `RegistryImportUtilityTests` (entire file)
- `DisplayHelper.GetDeviceMode()` tests
- `ProcessHelper.GetParentProcessId()` tests (internal)
- `LobbyClient.GetSteamLobbies()` — also requires Steam runtime
### Linux-only
- `DisplayHelper` xrandr/xdpyinfo/drm parsing tests
### Skipped (require real processes or sockets)
- `ProcessExtensions.WaitForAllExitAsync()` — needs real spawned process, timing-sensitive
- `BeaconClient.StartProbeAsync()` / `StartBeaconAsync()` — needs real UDP sockets
---
## Pre-existing Issues (do not create new tests for these)
The following failures exist in the test suite and should be addressed in the SDK itself
before adding further test coverage that would interact with them.
| Area | Root cause |
|---|---|
| `SaveDownloadTests` / `SaveUploadTests` | `DeflateEnvironmentVariables` throws on Linux when env vars like `LOCALAPPDATA` are null; `Regex.Escape(null)` throws `ArgumentNullException` |
| `SaveClientTests.SimpleInstallDirectorySavePathsShouldWork` | Same root cause |
| `SavePackerTests` (several) | Depends on `GetFileSavePathEntries` which calls `DeflateEnvironmentVariables` |
| `StringExtensions.cs` (ExpandEnvironmentVariables theory entries) | Tests use Windows-specific paths and env vars; results differ on Linux |
The fix is a null-guard in `StringExtensions.DeflateEnvironmentVariables` before calling
`Regex.Escape`:
```csharp
if (string.IsNullOrEmpty(value)) continue;
```
---
## Implementation Order
1. **Fix the `DeflateEnvironmentVariables` null-guard** — unblocks ~30 currently-failing tests
2. **`VersionHelper`, `IniHelper` gap-fill** — pure computation, no setup required
3. **`ApiRequestBuilder` with `FakeHttpMessageHandler`** — builds the HTTP test infrastructure
4. **Small pure-HTTP clients** (`TagClient`, `PlaySessionClient`, `IssueClient`, `DepotClient`) — simple CRUD, same infrastructure
5. **`ProfileClient`, `LibraryClient`, `MediaClient`** — moderate complexity
6. **`AuthenticationClient`** — auth token flow
7. **`ConnectionClient`** — server health and configuration
8. **`GameClient`** metadata methods — largest client, split into multiple test files
9. **`EnvironmentHelper`** with file-system injection
10. **`DisplayHelper`** parsing extraction + tests
11. **`RegistryExportUtility` / `RegistryImportUtility`** — Windows CI job