diff --git a/.github/workflows/LANCommander.Launcher.Avalonia.Visual.yml b/.github/workflows/LANCommander.Launcher.Avalonia.Visual.yml new file mode 100644 index 00000000..a1549437 --- /dev/null +++ b/.github/workflows/LANCommander.Launcher.Avalonia.Visual.yml @@ -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 diff --git a/.github/workflows/LANCommander.SDK.Tests.yml b/.github/workflows/LANCommander.SDK.Tests.yml new file mode 100644 index 00000000..0d047a40 --- /dev/null +++ b/.github/workflows/LANCommander.SDK.Tests.yml @@ -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 diff --git a/LANCommander.SDK.Tests/Extensions/EnumExtensionsTests.cs b/LANCommander.SDK.Tests/Extensions/EnumExtensionsTests.cs new file mode 100644 index 00000000..abcf6844 --- /dev/null +++ b/LANCommander.SDK.Tests/Extensions/EnumExtensionsTests.cs @@ -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); + } +} diff --git a/LANCommander.SDK.Tests/Extensions/GuidExtensionsTests.cs b/LANCommander.SDK.Tests/Extensions/GuidExtensionsTests.cs new file mode 100644 index 00000000..7e3e9389 --- /dev/null +++ b/LANCommander.SDK.Tests/Extensions/GuidExtensionsTests.cs @@ -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()); + } +} diff --git a/LANCommander.SDK.Tests/Extensions/IEnumerableExtensionsTests.cs b/LANCommander.SDK.Tests/Extensions/IEnumerableExtensionsTests.cs new file mode 100644 index 00000000..2bf3e78a --- /dev/null +++ b/LANCommander.SDK.Tests/Extensions/IEnumerableExtensionsTests.cs @@ -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(); + + Assert.False(list.HasAny()); + } + + [Fact] + public void HasAny_WithNullCollection_ReturnsFalse() + { + IEnumerable? 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? list = null; + + Assert.False(list.HasAny(n => n > 0)); + } + + [Fact] + public void HasAny_WithPredicate_OnEmptyCollection_ReturnsFalse() + { + var list = Array.Empty(); + + Assert.False(list.HasAny(n => n > 0)); + } +} diff --git a/LANCommander.SDK.Tests/Extensions/ListExtensionsTests.cs b/LANCommander.SDK.Tests/Extensions/ListExtensionsTests.cs new file mode 100644 index 00000000..ef6923a9 --- /dev/null +++ b/LANCommander.SDK.Tests/Extensions/ListExtensionsTests.cs @@ -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 { 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 { 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 { 1, 2, 3 }; + + list.RemoveRange(null); + + Assert.Equal(new[] { 1, 2, 3 }, list); + } + + [Fact] + public void RemoveRange_WithEmptyItemsToRemove_LeavesCollectionUnchanged() + { + var list = new List { 1, 2, 3 }; + + list.RemoveRange(Array.Empty()); + + Assert.Equal(new[] { 1, 2, 3 }, list); + } + + [Fact] + public void RemoveRange_RemovesAllItems_WhenAllSpecified() + { + var list = new List { "a", "b", "c" }; + + list.RemoveRange(new[] { "a", "b", "c" }); + + Assert.Empty(list); + } + + [Fact] + public void RemoveRange_OnlyRemovesFirstOccurrence_ForDuplicates() + { + // List.Remove removes only the first matching element. + var list = new List { 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 { a, b, c }; + + list.RemoveRange(new[] { b }); + + Assert.Equal(new[] { a, c }, list); + } + + // ── RemoveAll ────────────────────────────────────────────────────────────── + + [Fact] + public void RemoveAll_RemovesItemsMatchingPredicate() + { + var list = new List { 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 { 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 { 1, 2, 3 }; + + Assert.Throws(() => list.RemoveAll(null!)); + } + + [Fact] + public void RemoveAll_WithEmptyCollection_DoesNotThrow() + { + var list = new List(); + + 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 { 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 { 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 { "alpha", "beta", "gamma", "delta" }; + + list.RemoveAll(s => s.StartsWith("b") || s.StartsWith("d")); + + Assert.Equal(new[] { "alpha", "gamma" }, list); + } +} diff --git a/LANCommander.SDK.Tests/Extensions/StreamExtensionsTests.cs b/LANCommander.SDK.Tests/Extensions/StreamExtensionsTests.cs new file mode 100644 index 00000000..61e27d4b --- /dev/null +++ b/LANCommander.SDK.Tests/Extensions/StreamExtensionsTests.cs @@ -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(); + + 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(() => + source.CopyToAsync(destination, cancellationToken: cts.Token)); + } +} diff --git a/LANCommander.SDK.Tests/Extensions/StringExtensionsTests.cs b/LANCommander.SDK.Tests/Extensions/StringExtensionsTests.cs new file mode 100644 index 00000000..499924db --- /dev/null +++ b/LANCommander.SDK.Tests/Extensions/StringExtensionsTests.cs @@ -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()); + } +} diff --git a/LANCommander.SDK.Tests/Extensions/UriExtensionsTests.cs b/LANCommander.SDK.Tests/Extensions/UriExtensionsTests.cs new file mode 100644 index 00000000..a5241f42 --- /dev/null +++ b/LANCommander.SDK.Tests/Extensions/UriExtensionsTests.cs @@ -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(() => SdkUriExtensions.CreateUri(null)); + } + + [Fact] + public void CreateUri_WithEmptyInput_ThrowsArgumentException() + { + Assert.Throws(() => SdkUriExtensions.CreateUri("")); + } + + [Fact] + public void CreateUri_WithWhitespaceInput_ThrowsArgumentException() + { + Assert.Throws(() => 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); + } +} diff --git a/LANCommander.SDK.Tests/Fixtures/FakeGameFixture.cs b/LANCommander.SDK.Tests/Fixtures/FakeGameFixture.cs new file mode 100644 index 00000000..895cfc02 --- /dev/null +++ b/LANCommander.SDK.Tests/Fixtures/FakeGameFixture.cs @@ -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; + +/// +/// 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. +/// +public sealed class FakeGameFixture : IDisposable +{ + // ── Identity ────────────────────────────────────────────────────────────── + + /// Stable ID for the fake game, used as the manifest key and sub-directory name. + public Guid GameId { get; } = Guid.NewGuid(); + + /// Root installation directory created by the fixture. + public string InstallDirectory { get; } + + // ── Manifest ────────────────────────────────────────────────────────────── + + /// Fully populated manifest written to .lancommander/{GameId}/Manifest.yml. + public ManifestGame Manifest { get; } + + // ── Save paths ──────────────────────────────────────────────────────────── + + /// SavePath that covers the saves/ sub-directory inside the install dir. + public ManifestSavePath SavesDirSavePath { get; } + + /// SavePath that covers the single config.cfg file in the install dir root. + 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 + { + new() { Value = "FAKE-KEY-AAAA-1111" }, + new() { Value = "FAKE-KEY-BBBB-2222" } + }, + + Scripts = new List + { + 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 + { + 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 + { + 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); + } +} diff --git a/LANCommander.SDK.Tests/Helpers/RetryHelperTests.cs b/LANCommander.SDK.Tests/Helpers/RetryHelperTests.cs deleted file mode 100644 index 830ad9f7..00000000 --- a/LANCommander.SDK.Tests/Helpers/RetryHelperTests.cs +++ /dev/null @@ -1,206 +0,0 @@ -using LANCommander.SDK.Helpers; - -namespace LANCommander.SDK.Tests.Helpers; - -public class RetryHelperTests -{ - // ── RetryOnException ─────────────────────────────────────────────────── - - [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 ────────────────────────────────────────────── - - [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); - } -} diff --git a/LANCommander.SDK.Tests/Helpers/TextFileHelperTests.cs b/LANCommander.SDK.Tests/Helpers/TextFileHelperTests.cs deleted file mode 100644 index c39ef5e7..00000000 --- a/LANCommander.SDK.Tests/Helpers/TextFileHelperTests.cs +++ /dev/null @@ -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(() => - 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); - } -} diff --git a/LANCommander.SDK.Tests/Install/GameInstallationFileListEntryTests.cs b/LANCommander.SDK.Tests/Install/GameInstallationFileListEntryTests.cs deleted file mode 100644 index e52ba3c1..00000000 --- a/LANCommander.SDK.Tests/Install/GameInstallationFileListEntryTests.cs +++ /dev/null @@ -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(() => entry.AddFile(null)); - } -} diff --git a/LANCommander.SDK.Tests/Install/GameInstallationFileListTests.cs b/LANCommander.SDK.Tests/Install/GameInstallationFileListTests.cs deleted file mode 100644 index 0d4e884d..00000000 --- a/LANCommander.SDK.Tests/Install/GameInstallationFileListTests.cs +++ /dev/null @@ -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); - } -} diff --git a/LANCommander.SDK.Tests/Install/InstallResultTests.cs b/LANCommander.SDK.Tests/Install/InstallResultTests.cs index e093c294..45705d10 100644 --- a/LANCommander.SDK.Tests/Install/InstallResultTests.cs +++ b/LANCommander.SDK.Tests/Install/InstallResultTests.cs @@ -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); - } } diff --git a/LANCommander.SDK.Tests/SaveService.cs b/LANCommander.SDK.Tests/SaveService.cs index d279d65b..cc1f0d80 100644 --- a/LANCommander.SDK.Tests/SaveService.cs +++ b/LANCommander.SDK.Tests/SaveService.cs @@ -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); - } } } diff --git a/LANCommander.SDK.Tests/Saves/SaveDownloadTests.cs b/LANCommander.SDK.Tests/Saves/SaveDownloadTests.cs new file mode 100644 index 00000000..b1e0ace7 --- /dev/null +++ b/LANCommander.SDK.Tests/Saves/SaveDownloadTests.cs @@ -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; + +/// +/// Tests the save download + extraction cycle. +/// +/// HTTP calls are not exercised. Instead, the save archive is pre-built on-disk +/// using and restored with the same file-movement logic +/// that SaveClient.DownloadAsync uses, giving us full coverage of the +/// extraction flow without needing a running server. +/// +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 { 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 ─────────────────────────────────────────────────────────────── + + /// + /// Packs all save paths from and writes the resulting + /// ZIP archive to a temp file. Returns the path to that file. + /// + private async Task 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 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; + } + + /// + /// Restores a save archive to . + /// + /// Mirrors the file-movement block from SaveClient.DownloadAsync 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. + /// + 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); + } + } + + /// Creates a fresh empty directory under _tempDir for use as a restore destination. + private string CreateDestDir() + { + var dir = Path.Combine(_tempDir, $"dest-{Guid.NewGuid()}"); + Directory.CreateDirectory(dir); + return dir; + } + + private static List 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(); + } +} diff --git a/LANCommander.SDK.Tests/Saves/SaveUploadTests.cs b/LANCommander.SDK.Tests/Saves/SaveUploadTests.cs new file mode 100644 index 00000000..bde30870 --- /dev/null +++ b/LANCommander.SDK.Tests/Saves/SaveUploadTests.cs @@ -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; + +/// +/// Tests the save upload + packing cycle. +/// +/// SaveClient.UploadAsync ends with an HTTP POST; only the packing half is +/// exercised here. SaveClient.PackAsync (no HTTP) and SavePacker +/// directly are both tested so we cover the path the launcher takes after a game exits. +/// +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() + }; + + 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 + { + 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 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(); + } +} diff --git a/LANCommander.SDK.Tests/TEST_COVERAGE_PLAN.md b/LANCommander.SDK.Tests/TEST_COVERAGE_PLAN.md new file mode 100644 index 00000000..5f7e6d4f --- /dev/null +++ b/LANCommander.SDK.Tests/TEST_COVERAGE_PLAN.md @@ -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 _handler; + + public FakeHttpMessageHandler(Func handler) + => _handler = handler; + + protected override Task 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 + +``` + +NSubstitute is used to mock `ILogger`, `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 ` 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