Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e1e83626a | ||
|
|
c7e26e1051 | ||
|
|
4df61fa044 |
19 changed files with 2574 additions and 810 deletions
254
.github/workflows/LANCommander.Launcher.Avalonia.Visual.yml
vendored
Normal file
254
.github/workflows/LANCommander.Launcher.Avalonia.Visual.yml
vendored
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
name: Avalonia Launcher Visual Tests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Triggers
|
||||
# ---------------------------------------------------------------------------
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'LANCommander.Launcher.Avalonia/**'
|
||||
- 'LANCommander.Launcher.Avalonia.Tests/**'
|
||||
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'LANCommander.Launcher.Avalonia/**'
|
||||
- 'LANCommander.Launcher.Avalonia.Tests/**'
|
||||
|
||||
# Manual dispatch: re-run tests and optionally commit updated baselines.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
update_baselines:
|
||||
description: 'Commit updated baselines back to the branch (use after intentional UI changes)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
permissions:
|
||||
contents: write # allow committing updated baselines
|
||||
pull-requests: write # allow posting PR comments
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jobs
|
||||
# ---------------------------------------------------------------------------
|
||||
jobs:
|
||||
visual-tests:
|
||||
# Self-hosted runner required: needs access to your local LANCommander server
|
||||
# so that any future integration tests can connect and capture live screenshots.
|
||||
# Tag your runner with 'lancommander' in GitHub → Settings → Actions → Runners.
|
||||
runs-on: [self-hosted, lancommander]
|
||||
|
||||
env:
|
||||
DOTNET_NOLOGO: true
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
|
||||
|
||||
# Paths used by ScreenshotHelper — relative to workspace so artifacts are easy to find.
|
||||
VISUAL_SCREENSHOTS_DIR: ${{ github.workspace }}/visual-test-output/screenshots
|
||||
VISUAL_DIFFS_DIR: ${{ github.workspace }}/visual-test-output/diffs
|
||||
# Baselines are loaded from the build output (copied from Baselines/ content items).
|
||||
# Override this var only if you move the Baselines folder.
|
||||
|
||||
steps:
|
||||
# -----------------------------------------------------------------------
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
# Fetch all history so we can commit baseline updates.
|
||||
fetch-depth: 0
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.NUGET_PACKAGES }}
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore --locked-mode
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
- name: Build test project
|
||||
run: |
|
||||
dotnet build LANCommander.Launcher.Avalonia.Tests/ \
|
||||
--no-restore \
|
||||
--configuration Release
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
- name: Run visual layout tests
|
||||
id: run-tests
|
||||
# continue-on-error so we can still upload artifacts and post a PR comment
|
||||
# even when tests fail. The final step re-raises the failure.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
dotnet test LANCommander.Launcher.Avalonia.Tests/ \
|
||||
--no-build \
|
||||
--configuration Release \
|
||||
--logger "trx;LogFileName=${{ github.workspace }}/visual-test-results.trx" \
|
||||
--logger "console;verbosity=normal"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Always upload screenshots + diffs so you can review what the UI looked
|
||||
# like during this run, regardless of pass/fail.
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: visual-screenshots-${{ github.sha }}
|
||||
path: ${{ env.VISUAL_SCREENSHOTS_DIR }}/
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload diff images
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: visual-diffs-${{ github.sha }}
|
||||
path: ${{ env.VISUAL_DIFFS_DIR }}/
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: visual-test-results-${{ github.sha }}
|
||||
path: visual-test-results.trx
|
||||
if-no-files-found: ignore
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Write a step summary that shows the pass/fail status and links to artifacts.
|
||||
- name: Write step summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
STATUS="${{ steps.run-tests.outcome }}"
|
||||
echo "## Avalonia Visual Test Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
echo "✅ All visual tests passed — no regressions detected." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ Visual regressions detected (or new baselines need to be committed)." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Next steps:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "1. Download the **visual-screenshots** and **visual-diffs** artifacts to review changes." >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. If the changes are intentional, run the workflow manually with **Update baselines** checked." >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. If the changes are regressions, fix the layout before merging." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Artifact | Link |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Screenshots | [visual-screenshots-${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Diff images | [visual-diffs-${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# On pull requests, post a comment summarising the outcome so reviewers
|
||||
# don't have to open the Actions tab to check for visual regressions.
|
||||
- name: Post PR comment
|
||||
if: github.event_name == 'pull_request' && always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const outcome = '${{ steps.run-tests.outcome }}';
|
||||
const sha = '${{ github.sha }}'.slice(0, 7);
|
||||
const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
|
||||
|
||||
const icon = outcome === 'success' ? '✅' : '❌';
|
||||
const status = outcome === 'success'
|
||||
? 'No visual regressions detected.'
|
||||
: 'Visual regressions detected (or new baselines need to be committed). Review the diff artifacts in the Actions run.';
|
||||
|
||||
const body = [
|
||||
`## ${icon} Avalonia Visual Tests — \`${sha}\``,
|
||||
'',
|
||||
status,
|
||||
'',
|
||||
`**Artifacts:** [Screenshots & diffs](${runUrl})`,
|
||||
'',
|
||||
outcome !== 'success'
|
||||
? '_To accept intentional UI changes, trigger the **Avalonia Launcher Visual Tests** workflow manually with **Update baselines** enabled._'
|
||||
: '',
|
||||
].join('\n');
|
||||
|
||||
// Find and update an existing bot comment, or create a new one.
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const marker = '## ✅ Avalonia Visual Tests';
|
||||
const markerFail = '## ❌ Avalonia Visual Tests';
|
||||
const existing = comments.find(c =>
|
||||
c.user.type === 'Bot' &&
|
||||
(c.body.includes('## ✅ Avalonia Visual Tests') || c.body.includes('## ❌ Avalonia Visual Tests'))
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Update baselines: copy screenshots → Baselines/ and commit.
|
||||
# Only runs when triggered via workflow_dispatch with update_baselines=true.
|
||||
- name: Update baselines
|
||||
if: inputs.update_baselines == true
|
||||
shell: bash
|
||||
run: |
|
||||
BASELINES_DIR="LANCommander.Launcher.Avalonia.Tests/Baselines"
|
||||
SCREENSHOTS_DIR="${{ env.VISUAL_SCREENSHOTS_DIR }}"
|
||||
|
||||
if [ ! -d "$SCREENSHOTS_DIR" ] || [ -z "$(ls -A "$SCREENSHOTS_DIR")" ]; then
|
||||
echo "No screenshots found at $SCREENSHOTS_DIR — nothing to update."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BASELINES_DIR"
|
||||
cp "$SCREENSHOTS_DIR"/*.png "$BASELINES_DIR/"
|
||||
echo "Copied screenshots:"
|
||||
ls "$BASELINES_DIR"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add "$BASELINES_DIR"
|
||||
|
||||
if git diff --staged --quiet; then
|
||||
echo "Baselines unchanged — nothing to commit."
|
||||
else
|
||||
git commit -m "chore: update Avalonia visual test baselines [skip ci]
|
||||
|
||||
Updated by workflow run ${{ github.run_id }} on branch ${{ github.ref_name }}."
|
||||
git push
|
||||
echo "Baselines committed and pushed."
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Re-raise test failure AFTER artifacts have been uploaded and comments posted.
|
||||
- name: Fail on test regression
|
||||
if: steps.run-tests.outcome == 'failure' && inputs.update_baselines != true
|
||||
run: |
|
||||
echo "Visual tests failed. See artifacts and step summary for details."
|
||||
exit 1
|
||||
53
.github/workflows/LANCommander.SDK.Tests.yml
vendored
Normal file
53
.github/workflows/LANCommander.SDK.Tests.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: LANCommander SDK Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'LANCommander.SDK/**'
|
||||
- 'LANCommander.SDK.Tests/**'
|
||||
- 'LANCommander.Steam/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'LANCommander.SDK/**'
|
||||
- 'LANCommander.SDK.Tests/**'
|
||||
- 'LANCommander.Steam/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run SDK Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore --configuration Release LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
|
||||
|
||||
- name: Run tests
|
||||
run: >
|
||||
dotnet test
|
||||
--no-build
|
||||
--configuration Release
|
||||
--verbosity normal
|
||||
--logger "trx;LogFileName=sdk-tests.trx"
|
||||
LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sdk-test-results
|
||||
path: '**/*.trx'
|
||||
retention-days: 30
|
||||
68
LANCommander.SDK.Tests/Extensions/EnumExtensionsTests.cs
Normal file
68
LANCommander.SDK.Tests/Extensions/EnumExtensionsTests.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Extensions;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Extensions;
|
||||
|
||||
public class EnumExtensionsTests
|
||||
{
|
||||
private enum Color { Red, Green, Blue }
|
||||
|
||||
// ── ValueIsIn ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ValueIsIn_WhenValueIsInList_ReturnsTrue()
|
||||
{
|
||||
var result = Color.Green.ValueIsIn(Color.Red, Color.Green, Color.Blue);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueIsIn_WhenValueIsNotInList_ReturnsFalse()
|
||||
{
|
||||
var result = Color.Blue.ValueIsIn(Color.Red, Color.Green);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueIsIn_WithEmptyList_ReturnsFalse()
|
||||
{
|
||||
var result = Color.Red.ValueIsIn();
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueIsIn_WithSingleMatchingValue_ReturnsTrue()
|
||||
{
|
||||
var result = Color.Red.ValueIsIn(Color.Red);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueIsIn_WithSortDirectionEnum_WorksCorrectly()
|
||||
{
|
||||
var result = SortDirection.Descending.ValueIsIn(SortDirection.Ascending, SortDirection.Descending);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueIsIn_WorksWithNonEnumTypes()
|
||||
{
|
||||
// The extension is generic — not restricted to enums.
|
||||
var result = "hello".ValueIsIn("world", "hello", "foo");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueIsIn_WorksWithIntegers()
|
||||
{
|
||||
var result = 42.ValueIsIn(1, 2, 42, 100);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
}
|
||||
41
LANCommander.SDK.Tests/Extensions/GuidExtensionsTests.cs
Normal file
41
LANCommander.SDK.Tests/Extensions/GuidExtensionsTests.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using LANCommander.SDK.Extensions;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Extensions;
|
||||
|
||||
public class GuidExtensionsTests
|
||||
{
|
||||
// ── IsNullOrEmpty ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsNullOrEmpty_WithEmptyGuid_ReturnsTrue()
|
||||
{
|
||||
var result = Guid.Empty.IsNullOrEmpty();
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNullOrEmpty_WithDefaultGuid_ReturnsTrue()
|
||||
{
|
||||
// default(Guid) == Guid.Empty
|
||||
var guid = default(Guid);
|
||||
|
||||
Assert.True(guid.IsNullOrEmpty());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNullOrEmpty_WithNewGuid_ReturnsFalse()
|
||||
{
|
||||
var result = Guid.NewGuid().IsNullOrEmpty();
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNullOrEmpty_WithKnownNonEmptyGuid_ReturnsFalse()
|
||||
{
|
||||
var guid = new Guid("12345678-1234-1234-1234-123456789012");
|
||||
|
||||
Assert.False(guid.IsNullOrEmpty());
|
||||
}
|
||||
}
|
||||
197
LANCommander.SDK.Tests/Extensions/IEnumerableExtensionsTests.cs
Normal file
197
LANCommander.SDK.Tests/Extensions/IEnumerableExtensionsTests.cs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Extensions;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Extensions;
|
||||
|
||||
public class IEnumerableExtensionsTests
|
||||
{
|
||||
// ── OrderByTitle ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OrderByTitle_StripsLeadingThe_ForSortKey()
|
||||
{
|
||||
var titles = new[] { "Zelda", "The Witcher", "Baldur's Gate" };
|
||||
|
||||
var result = titles.OrderByTitle(t => t).Select(t => t).ToList();
|
||||
|
||||
// "The Witcher" sorts as "Witcher", coming after "Zelda" is wrong —
|
||||
// actual order: Baldur's Gate, The Witcher, Zelda
|
||||
Assert.Equal("Baldur's Gate", result[0]);
|
||||
Assert.Equal("The Witcher", result[1]);
|
||||
Assert.Equal("Zelda", result[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderByTitle_StripsLeadingA_ForSortKey()
|
||||
{
|
||||
var titles = new[] { "Call of Duty", "A Plague Tale", "Doom" };
|
||||
|
||||
var result = titles.OrderByTitle(t => t).ToList();
|
||||
|
||||
// "A Plague Tale" sorts as "Plague Tale"
|
||||
Assert.Equal("Call of Duty", result[0]);
|
||||
Assert.Equal("Doom", result[1]);
|
||||
Assert.Equal("A Plague Tale", result[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderByTitle_StripsLeadingAn_ForSortKey()
|
||||
{
|
||||
var titles = new[] { "Batman", "An Elder Tale", "Civilization" };
|
||||
|
||||
var result = titles.OrderByTitle(t => t).ToList();
|
||||
|
||||
// "An Elder Tale" sorts as "Elder Tale"
|
||||
Assert.Equal("Batman", result[0]);
|
||||
Assert.Equal("Civilization", result[1]);
|
||||
Assert.Equal("An Elder Tale", result[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderByTitle_ArticleStrippingIsCaseInsensitive()
|
||||
{
|
||||
var titles = new[] { "Zelda", "the Witcher", "Baldur's Gate" };
|
||||
|
||||
var result = titles.OrderByTitle(t => t).ToList();
|
||||
|
||||
Assert.Equal("Baldur's Gate", result[0]);
|
||||
Assert.Equal("the Witcher", result[1]);
|
||||
Assert.Equal("Zelda", result[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderByTitle_WordStartingWithArticleButNoTrailingSpace_IsNotStripped()
|
||||
{
|
||||
// "Another World" starts with "An" but "Another" ≠ "an "
|
||||
// "There" starts with "The" but "There" ≠ "the "
|
||||
var titles = new[] { "Another World", "There Will Be Blood", "Abzu" };
|
||||
|
||||
var result = titles.OrderByTitle(t => t).ToList();
|
||||
|
||||
Assert.Equal("Abzu", result[0]);
|
||||
Assert.Equal("Another World", result[1]);
|
||||
Assert.Equal("There Will Be Blood", result[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderByTitle_Descending_ReversesOrder()
|
||||
{
|
||||
var titles = new[] { "The Witcher", "Baldur's Gate", "Zelda" };
|
||||
|
||||
var result = titles.OrderByTitle(t => t, SortDirection.Descending).ToList();
|
||||
|
||||
Assert.Equal("Zelda", result[0]);
|
||||
Assert.Equal("The Witcher", result[1]);
|
||||
Assert.Equal("Baldur's Gate", result[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderByTitle_WorksWithObjectKeySelector()
|
||||
{
|
||||
var games = new[]
|
||||
{
|
||||
new { Id = 1, Title = "The Last of Us" },
|
||||
new { Id = 2, Title = "Among Us" },
|
||||
new { Id = 3, Title = "Hades" },
|
||||
};
|
||||
|
||||
var result = games.OrderByTitle(g => g.Title).ToList();
|
||||
|
||||
// "The Last of Us" → "Last of Us", "Among Us" → "Among Us", "Hades" → "Hades"
|
||||
Assert.Equal(2, result[0].Id); // Among Us
|
||||
Assert.Equal(3, result[1].Id); // Hades
|
||||
Assert.Equal(1, result[2].Id); // The Last of Us
|
||||
}
|
||||
|
||||
// ── OrderBy (SortDirection overload) ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OrderBy_Ascending_SortsSmallestFirst()
|
||||
{
|
||||
var numbers = new[] { 3, 1, 4, 1, 5, 9, 2 };
|
||||
|
||||
var result = numbers.OrderBy(n => n, SortDirection.Ascending).ToList();
|
||||
|
||||
Assert.Equal(new[] { 1, 1, 2, 3, 4, 5, 9 }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderBy_Descending_SortsLargestFirst()
|
||||
{
|
||||
var numbers = new[] { 3, 1, 4, 1, 5, 9, 2 };
|
||||
|
||||
var result = numbers.OrderBy(n => n, SortDirection.Descending).ToList();
|
||||
|
||||
Assert.Equal(new[] { 9, 5, 4, 3, 2, 1, 1 }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderBy_OnStrings_Ascending()
|
||||
{
|
||||
var words = new[] { "banana", "apple", "cherry" };
|
||||
|
||||
var result = words.OrderBy(w => w, SortDirection.Ascending).ToList();
|
||||
|
||||
Assert.Equal(new[] { "apple", "banana", "cherry" }, result);
|
||||
}
|
||||
|
||||
// ── HasAny (no predicate) ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HasAny_WithNonEmptyCollection_ReturnsTrue()
|
||||
{
|
||||
var list = new[] { 1, 2, 3 };
|
||||
|
||||
Assert.True(list.HasAny());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAny_WithEmptyCollection_ReturnsFalse()
|
||||
{
|
||||
var list = Array.Empty<int>();
|
||||
|
||||
Assert.False(list.HasAny());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAny_WithNullCollection_ReturnsFalse()
|
||||
{
|
||||
IEnumerable<int>? list = null;
|
||||
|
||||
Assert.False(list.HasAny());
|
||||
}
|
||||
|
||||
// ── HasAny (with predicate) ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HasAny_WithPredicate_WhenMatchExists_ReturnsTrue()
|
||||
{
|
||||
var list = new[] { 1, 2, 3, 4, 5 };
|
||||
|
||||
Assert.True(list.HasAny(n => n > 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAny_WithPredicate_WhenNoMatch_ReturnsFalse()
|
||||
{
|
||||
var list = new[] { 1, 2, 3 };
|
||||
|
||||
Assert.False(list.HasAny(n => n > 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAny_WithPredicate_OnNullCollection_ReturnsFalse()
|
||||
{
|
||||
IEnumerable<int>? list = null;
|
||||
|
||||
Assert.False(list.HasAny(n => n > 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAny_WithPredicate_OnEmptyCollection_ReturnsFalse()
|
||||
{
|
||||
var list = Array.Empty<int>();
|
||||
|
||||
Assert.False(list.HasAny(n => n > 0));
|
||||
}
|
||||
}
|
||||
154
LANCommander.SDK.Tests/Extensions/ListExtensionsTests.cs
Normal file
154
LANCommander.SDK.Tests/Extensions/ListExtensionsTests.cs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
using LANCommander.SDK.Extensions;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Extensions;
|
||||
|
||||
public class ListExtensionsTests
|
||||
{
|
||||
// ── RemoveRange ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RemoveRange_RemovesAllSpecifiedItems()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3, 4, 5 };
|
||||
|
||||
list.RemoveRange(new[] { 2, 4 });
|
||||
|
||||
Assert.Equal(new[] { 1, 3, 5 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveRange_ItemsNotInCollection_AreIgnored()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3 };
|
||||
|
||||
list.RemoveRange(new[] { 99, 100 });
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveRange_WithNullItemsToRemove_DoesNotThrow()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3 };
|
||||
|
||||
list.RemoveRange(null);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveRange_WithEmptyItemsToRemove_LeavesCollectionUnchanged()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3 };
|
||||
|
||||
list.RemoveRange(Array.Empty<int>());
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveRange_RemovesAllItems_WhenAllSpecified()
|
||||
{
|
||||
var list = new List<string> { "a", "b", "c" };
|
||||
|
||||
list.RemoveRange(new[] { "a", "b", "c" });
|
||||
|
||||
Assert.Empty(list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveRange_OnlyRemovesFirstOccurrence_ForDuplicates()
|
||||
{
|
||||
// List<T>.Remove removes only the first matching element.
|
||||
var list = new List<int> { 1, 2, 2, 3 };
|
||||
|
||||
list.RemoveRange(new[] { 2 });
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveRange_WorksWithReferenceTypes()
|
||||
{
|
||||
var a = new object();
|
||||
var b = new object();
|
||||
var c = new object();
|
||||
var list = new List<object> { a, b, c };
|
||||
|
||||
list.RemoveRange(new[] { b });
|
||||
|
||||
Assert.Equal(new[] { a, c }, list);
|
||||
}
|
||||
|
||||
// ── RemoveAll ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RemoveAll_RemovesItemsMatchingPredicate()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3, 4, 5 };
|
||||
|
||||
list.RemoveAll(n => n % 2 == 0);
|
||||
|
||||
Assert.Equal(new[] { 1, 3, 5 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAll_KeepsItemsNotMatchingPredicate()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3, 4, 5 };
|
||||
|
||||
list.RemoveAll(n => n > 10);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAll_WithNullPredicate_ThrowsArgumentNullException()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3 };
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => list.RemoveAll(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAll_WithEmptyCollection_DoesNotThrow()
|
||||
{
|
||||
var list = new List<int>();
|
||||
|
||||
var ex = Record.Exception(() => list.RemoveAll(n => n > 0));
|
||||
|
||||
Assert.Null(ex);
|
||||
Assert.Empty(list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAll_WithAllMatchingPredicate_EmptiesCollection()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3 };
|
||||
|
||||
list.RemoveAll(_ => true);
|
||||
|
||||
Assert.Empty(list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAll_RemovesItemsCorrectlyWhenIteratingBackwards()
|
||||
{
|
||||
// Verify that removing while iterating backwards doesn't skip or double-remove.
|
||||
var list = new List<int> { 1, 2, 3, 4, 5, 6 };
|
||||
|
||||
list.RemoveAll(n => n % 3 == 0);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 4, 5 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAll_WorksWithStrings()
|
||||
{
|
||||
var list = new List<string> { "alpha", "beta", "gamma", "delta" };
|
||||
|
||||
list.RemoveAll(s => s.StartsWith("b") || s.StartsWith("d"));
|
||||
|
||||
Assert.Equal(new[] { "alpha", "gamma" }, list);
|
||||
}
|
||||
}
|
||||
125
LANCommander.SDK.Tests/Extensions/StreamExtensionsTests.cs
Normal file
125
LANCommander.SDK.Tests/Extensions/StreamExtensionsTests.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using LANCommander.SDK.Extensions;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Extensions;
|
||||
|
||||
public class StreamExtensionsTests
|
||||
{
|
||||
private static MemoryStream MakeStream(int sizeBytes, byte fill = 0xAB)
|
||||
{
|
||||
var data = new byte[sizeBytes];
|
||||
Array.Fill(data, fill);
|
||||
return new MemoryStream(data);
|
||||
}
|
||||
|
||||
// ── Content correctness ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CopyToAsync_CopiesAllBytesToDestination()
|
||||
{
|
||||
var source = MakeStream(256, fill: 0x42);
|
||||
var destination = new MemoryStream();
|
||||
|
||||
await source.CopyToAsync(destination);
|
||||
|
||||
destination.Position = 0;
|
||||
var result = destination.ToArray();
|
||||
Assert.Equal(256, result.Length);
|
||||
Assert.All(result, b => Assert.Equal(0x42, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyToAsync_EmptySource_ProducesEmptyDestination()
|
||||
{
|
||||
var source = new MemoryStream();
|
||||
var destination = new MemoryStream();
|
||||
|
||||
await source.CopyToAsync(destination);
|
||||
|
||||
Assert.Equal(0, destination.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyToAsync_LargerThanBuffer_CopiesAllBytes()
|
||||
{
|
||||
// Default buffer is 1 MB; use 3 MB to force multiple reads.
|
||||
const int size = 3 * 1024 * 1024;
|
||||
var source = MakeStream(size, fill: 0x77);
|
||||
var destination = new MemoryStream();
|
||||
|
||||
await source.CopyToAsync(destination);
|
||||
|
||||
Assert.Equal(size, destination.Length);
|
||||
}
|
||||
|
||||
// ── Progress callback ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CopyToAsync_FinalProgressCallback_IsAlwaysInvoked()
|
||||
{
|
||||
var source = MakeStream(128);
|
||||
var destination = new MemoryStream();
|
||||
|
||||
long lastTransferred = -1;
|
||||
long lastTotal = -1;
|
||||
|
||||
await source.CopyToAsync(destination, (transferred, total) =>
|
||||
{
|
||||
lastTransferred = transferred;
|
||||
lastTotal = total;
|
||||
});
|
||||
|
||||
Assert.Equal(128, lastTransferred);
|
||||
Assert.Equal(128, lastTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyToAsync_ProgressCallback_IsInvokedAtConfiguredInterval()
|
||||
{
|
||||
// Use a stream larger than the report interval to trigger mid-copy callbacks.
|
||||
const int reportInterval = 64 * 1024; // 64 KB
|
||||
const int streamSize = 4 * reportInterval; // 256 KB — forces several interval hits
|
||||
|
||||
var source = MakeStream(streamSize);
|
||||
var destination = new MemoryStream();
|
||||
|
||||
var callbackValues = new List<long>();
|
||||
|
||||
await source.CopyToAsync(
|
||||
destination,
|
||||
progressCallback: (transferred, _) => callbackValues.Add(transferred),
|
||||
bufferSize: reportInterval, // each read == one interval
|
||||
reportIntervalBytes: reportInterval);
|
||||
|
||||
// At minimum the final callback fires; with buffer == interval every read triggers one.
|
||||
Assert.NotEmpty(callbackValues);
|
||||
// Last reported value is the total bytes.
|
||||
Assert.Equal(streamSize, callbackValues.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyToAsync_WithNullProgressCallback_DoesNotThrow()
|
||||
{
|
||||
var source = MakeStream(256);
|
||||
var destination = new MemoryStream();
|
||||
|
||||
var ex = await Record.ExceptionAsync(() =>
|
||||
source.CopyToAsync(destination, progressCallback: null));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// ── Cancellation ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CopyToAsync_WithAlreadyCancelledToken_ThrowsOperationCanceledException()
|
||||
{
|
||||
var source = MakeStream(1024 * 1024); // large enough to not finish before cancellation
|
||||
var destination = new MemoryStream();
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
source.CopyToAsync(destination, cancellationToken: cts.Token));
|
||||
}
|
||||
}
|
||||
117
LANCommander.SDK.Tests/Extensions/StringExtensionsTests.cs
Normal file
117
LANCommander.SDK.Tests/Extensions/StringExtensionsTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using LANCommander.SDK.Extensions;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Extensions;
|
||||
|
||||
public class StringExtensionsTests
|
||||
{
|
||||
// ── SanitizeFilename ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Half-Life 2: Episode One", "Half-Life 2 - Episode One")]
|
||||
[InlineData("Game: The Sequel", "Game - The Sequel")]
|
||||
[InlineData("Foo: Bar", "Foo - Bar")]
|
||||
public void SanitizeFilename_ColonSpacePattern_IsReplacedWithDash(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, input.SanitizeFilename());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeFilename_ColonWithNoSpaceAfter_IsNotChanged()
|
||||
{
|
||||
// Only "word: word" (colon-space) triggers the replacement, not bare colons.
|
||||
// On Linux, ':' is not an invalid filename character so it is left as-is.
|
||||
var result = "http://example".SanitizeFilename();
|
||||
|
||||
// No "word: word" match → colon stays; '/' is invalid on all platforms.
|
||||
Assert.DoesNotContain("/", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeFilename_TrailingDot_IsRemoved()
|
||||
{
|
||||
var result = "GameTitle.".SanitizeFilename();
|
||||
|
||||
Assert.Equal("GameTitle", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeFilename_NonTrailingDot_IsPreserved()
|
||||
{
|
||||
var result = "game.exe".SanitizeFilename();
|
||||
|
||||
Assert.Equal("game.exe", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeFilename_MultipleDots_OnlyLastRemoved()
|
||||
{
|
||||
var result = "game...".SanitizeFilename();
|
||||
|
||||
Assert.Equal("game..", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeFilename_ForwardSlash_IsRemoved()
|
||||
{
|
||||
// '/' is in Path.GetInvalidFileNameChars() on all platforms.
|
||||
var result = "foo/bar".SanitizeFilename();
|
||||
|
||||
Assert.Equal("foobar", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeFilename_ForwardSlash_ReplacedWithCustomString()
|
||||
{
|
||||
var result = "foo/bar".SanitizeFilename("_");
|
||||
|
||||
Assert.Equal("foo_bar", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeFilename_NoInvalidChars_ReturnsUnchanged()
|
||||
{
|
||||
var result = "NormalTitle".SanitizeFilename();
|
||||
|
||||
Assert.Equal("NormalTitle", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeFilename_ColonAndTrailingDot_BothHandled()
|
||||
{
|
||||
var result = "Game: Edition.".SanitizeFilename();
|
||||
|
||||
Assert.Equal("Game - Edition", result);
|
||||
}
|
||||
|
||||
// ── FastReverse ────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("hello", "olleh")]
|
||||
[InlineData("abcde", "edcba")]
|
||||
[InlineData("12345", "54321")]
|
||||
[InlineData("a", "a")]
|
||||
public void FastReverse_ReversesString(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, input.FastReverse());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FastReverse_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal("", "".FastReverse());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FastReverse_Palindrome_ReturnsSameValue()
|
||||
{
|
||||
Assert.Equal("racecar", "racecar".FastReverse());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FastReverse_TwiceProducesOriginal()
|
||||
{
|
||||
const string original = "LANCommander";
|
||||
|
||||
Assert.Equal(original, original.FastReverse().FastReverse());
|
||||
}
|
||||
}
|
||||
198
LANCommander.SDK.Tests/Extensions/UriExtensionsTests.cs
Normal file
198
LANCommander.SDK.Tests/Extensions/UriExtensionsTests.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
using LANCommander.SDK.Extensions;
|
||||
using SdkUriExtensions = LANCommander.SDK.Extensions.UriExtensions;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Extensions;
|
||||
|
||||
public class UriExtensionsTests
|
||||
{
|
||||
// ── Join ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Join_AppendsSingleSegment()
|
||||
{
|
||||
var base_ = new Uri("http://example.com");
|
||||
|
||||
var result = base_.Join("api");
|
||||
|
||||
Assert.Equal("http://example.com/api", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Join_AppendsMultipleSegments()
|
||||
{
|
||||
var base_ = new Uri("http://example.com");
|
||||
|
||||
var result = base_.Join("api", "v1", "games");
|
||||
|
||||
Assert.Equal("http://example.com/api/v1/games", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Join_StripsTrailingSlashFromBase()
|
||||
{
|
||||
var base_ = new Uri("http://example.com/");
|
||||
|
||||
var result = base_.Join("api");
|
||||
|
||||
Assert.Equal("http://example.com/api", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Join_StripsLeadingSlashFromSegment()
|
||||
{
|
||||
var base_ = new Uri("http://example.com");
|
||||
|
||||
var result = base_.Join("/api");
|
||||
|
||||
Assert.Equal("http://example.com/api", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Join_StripsLeadingAndTrailingSlashesFromSegments()
|
||||
{
|
||||
var base_ = new Uri("http://example.com/");
|
||||
|
||||
var result = base_.Join("/api/", "/v1/");
|
||||
|
||||
Assert.Equal("http://example.com/api/v1", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Join_WithBaseContainingPath_AppendsCorrectly()
|
||||
{
|
||||
var base_ = new Uri("http://example.com/root");
|
||||
|
||||
var result = base_.Join("sub");
|
||||
|
||||
Assert.Equal("http://example.com/root/sub", result.ToString());
|
||||
}
|
||||
|
||||
// ── CreateUri ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CreateUri_WithFullHttpUri_ReturnsSameUri()
|
||||
{
|
||||
var result = SdkUriExtensions.CreateUri("http://example.com");
|
||||
|
||||
Assert.Equal("http://example.com/", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUri_WithFullHttpsUri_ReturnsSameUri()
|
||||
{
|
||||
var result = SdkUriExtensions.CreateUri("https://example.com");
|
||||
|
||||
Assert.Equal("https://example.com/", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUri_WithNoScheme_PrependsHttp()
|
||||
{
|
||||
var result = SdkUriExtensions.CreateUri("example.com");
|
||||
|
||||
Assert.Equal(Uri.UriSchemeHttp, result.Scheme);
|
||||
Assert.Equal("example.com", result.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUri_WithNullInput_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SdkUriExtensions.CreateUri(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUri_WithEmptyInput_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SdkUriExtensions.CreateUri(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUri_WithWhitespaceInput_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SdkUriExtensions.CreateUri(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUri_WithUriContainingPort_PreservesPort()
|
||||
{
|
||||
var result = SdkUriExtensions.CreateUri("http://localhost:1337");
|
||||
|
||||
Assert.Equal(1337, result.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUri_WithIpAddress_Works()
|
||||
{
|
||||
var result = SdkUriExtensions.CreateUri("192.168.1.1");
|
||||
|
||||
Assert.Equal(Uri.UriSchemeHttp, result.Scheme);
|
||||
Assert.Equal("192.168.1.1", result.Host);
|
||||
}
|
||||
|
||||
// ── TryCreateUri ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryCreateUri_WithValidUri_ReturnsTrueAndSetsResult()
|
||||
{
|
||||
var success = SdkUriExtensions.TryCreateUri("http://example.com", out var result);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("http://example.com/", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateUri_WithNoScheme_PrependsHttpAndReturnsTrue()
|
||||
{
|
||||
var success = SdkUriExtensions.TryCreateUri("example.com", out var result);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Uri.UriSchemeHttp, result.Scheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateUri_WithNullInput_ReturnsFalse()
|
||||
{
|
||||
var success = SdkUriExtensions.TryCreateUri(null, out var result);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateUri_WithEmptyInput_ReturnsFalse()
|
||||
{
|
||||
var success = SdkUriExtensions.TryCreateUri("", out var result);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateUri_WithWhitespace_ReturnsFalse()
|
||||
{
|
||||
var success = SdkUriExtensions.TryCreateUri(" ", out var result);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateUri_WithHttpsScheme_PreservesScheme()
|
||||
{
|
||||
var success = SdkUriExtensions.TryCreateUri("https://secure.example.com", out var result);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal(Uri.UriSchemeHttps, result!.Scheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateUri_WithPortNumber_PreservesPort()
|
||||
{
|
||||
var success = SdkUriExtensions.TryCreateUri("http://localhost:8080", out var result);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal(8080, result!.Port);
|
||||
}
|
||||
}
|
||||
151
LANCommander.SDK.Tests/Fixtures/FakeGameFixture.cs
Normal file
151
LANCommander.SDK.Tests/Fixtures/FakeGameFixture.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Helpers;
|
||||
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
|
||||
using ManifestKey = LANCommander.SDK.Models.Manifest.Key;
|
||||
using ManifestMedia = LANCommander.SDK.Models.Manifest.Media;
|
||||
using ManifestSavePath = LANCommander.SDK.Models.Manifest.SavePath;
|
||||
using ManifestScript = LANCommander.SDK.Models.Manifest.Script;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a complete fake game installation on disk for integration testing.
|
||||
/// Creates save files, script stubs, and writes a full manifest covering
|
||||
/// saves, keys, scripts, and media — everything DownloadAsync / UploadAsync touches.
|
||||
/// </summary>
|
||||
public sealed class FakeGameFixture : IDisposable
|
||||
{
|
||||
// ── Identity ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Stable ID for the fake game, used as the manifest key and sub-directory name.</summary>
|
||||
public Guid GameId { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Root installation directory created by the fixture.</summary>
|
||||
public string InstallDirectory { get; }
|
||||
|
||||
// ── Manifest ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Fully populated manifest written to <c>.lancommander/{GameId}/Manifest.yml</c>.</summary>
|
||||
public ManifestGame Manifest { get; }
|
||||
|
||||
// ── Save paths ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>SavePath that covers the <c>saves/</c> sub-directory inside the install dir.</summary>
|
||||
public ManifestSavePath SavesDirSavePath { get; }
|
||||
|
||||
/// <summary>SavePath that covers the single <c>config.cfg</c> file in the install dir root.</summary>
|
||||
public ManifestSavePath ConfigFileSavePath { get; }
|
||||
|
||||
// ── Computed on-disk paths ────────────────────────────────────────────────
|
||||
|
||||
public string SavesDirectory => Path.Combine(InstallDirectory, "saves");
|
||||
public string SaveFileSlot1Path => Path.Combine(SavesDirectory, "slot1.sav");
|
||||
public string SaveFileSlot2Path => Path.Combine(SavesDirectory, "slot2.sav");
|
||||
public string ConfigFilePath => Path.Combine(InstallDirectory, "config.cfg");
|
||||
|
||||
// ── Known file contents ───────────────────────────────────────────────────
|
||||
|
||||
public const string Slot1Content = "SAVE_DATA_SLOT_1";
|
||||
public const string Slot2Content = "SAVE_DATA_SLOT_2";
|
||||
public const string ConfigContent = "player_name=TestPlayer\ngraphics=high";
|
||||
|
||||
// ── Construction ──────────────────────────────────────────────────────────
|
||||
|
||||
public FakeGameFixture()
|
||||
{
|
||||
InstallDirectory = Path.Combine(Path.GetTempPath(), $"lc-fake-game-{GameId}");
|
||||
Directory.CreateDirectory(InstallDirectory);
|
||||
|
||||
// ── Save files ────────────────────────────────────────────────────────
|
||||
|
||||
Directory.CreateDirectory(SavesDirectory);
|
||||
File.WriteAllText(SaveFileSlot1Path, Slot1Content);
|
||||
File.WriteAllText(SaveFileSlot2Path, Slot2Content);
|
||||
File.WriteAllText(ConfigFilePath, ConfigContent);
|
||||
|
||||
// ── Script stubs on disk ──────────────────────────────────────────────
|
||||
|
||||
WriteScriptFile(ScriptType.Install, "Write-Host 'Installing'");
|
||||
WriteScriptFile(ScriptType.Uninstall, "Write-Host 'Uninstalling'");
|
||||
WriteScriptFile(ScriptType.BeforeStart,"Write-Host 'Before start'");
|
||||
WriteScriptFile(ScriptType.AfterStop, "Write-Host 'After stop'");
|
||||
WriteScriptFile(ScriptType.NameChange, "Write-Host \"Name change: $PlayerAlias\"");
|
||||
WriteScriptFile(ScriptType.KeyChange, "Write-Host \"Key change: $AllocatedKey\"");
|
||||
|
||||
// ── Save paths ────────────────────────────────────────────────────────
|
||||
|
||||
SavesDirSavePath = new ManifestSavePath
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = SavePathType.File,
|
||||
Path = "saves",
|
||||
WorkingDirectory = "{InstallDir}",
|
||||
IsRegex = false
|
||||
};
|
||||
|
||||
ConfigFileSavePath = new ManifestSavePath
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = SavePathType.File,
|
||||
Path = "config.cfg",
|
||||
WorkingDirectory = "{InstallDir}",
|
||||
IsRegex = false
|
||||
};
|
||||
|
||||
// ── Manifest ──────────────────────────────────────────────────────────
|
||||
|
||||
Manifest = new ManifestGame
|
||||
{
|
||||
Id = GameId,
|
||||
Title = "Fake Test Game",
|
||||
Version = "1.0.0",
|
||||
InstallDirectory = InstallDirectory,
|
||||
|
||||
Keys = new List<ManifestKey>
|
||||
{
|
||||
new() { Value = "FAKE-KEY-AAAA-1111" },
|
||||
new() { Value = "FAKE-KEY-BBBB-2222" }
|
||||
},
|
||||
|
||||
Scripts = new List<ManifestScript>
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), Type = ScriptType.Install, Name = "Install" },
|
||||
new() { Id = Guid.NewGuid(), Type = ScriptType.Uninstall, Name = "Uninstall" },
|
||||
new() { Id = Guid.NewGuid(), Type = ScriptType.BeforeStart,Name = "BeforeStart" },
|
||||
new() { Id = Guid.NewGuid(), Type = ScriptType.AfterStop, Name = "AfterStop" },
|
||||
new() { Id = Guid.NewGuid(), Type = ScriptType.NameChange, Name = "NameChange" },
|
||||
new() { Id = Guid.NewGuid(), Type = ScriptType.KeyChange, Name = "KeyChange" }
|
||||
},
|
||||
|
||||
Media = new List<ManifestMedia>
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), FileId = Guid.NewGuid(), Type = MediaType.Cover, MimeType = "image/jpeg", Crc32 = "AABBCCDD" },
|
||||
new() { Id = Guid.NewGuid(), FileId = Guid.NewGuid(), Type = MediaType.Icon, MimeType = "image/png", Crc32 = "EEFF0011" },
|
||||
new() { Id = Guid.NewGuid(), FileId = Guid.NewGuid(), Type = MediaType.Background, MimeType = "image/jpeg", Crc32 = "22334455" }
|
||||
},
|
||||
|
||||
SavePaths = new List<ManifestSavePath>
|
||||
{
|
||||
SavesDirSavePath,
|
||||
ConfigFileSavePath
|
||||
}
|
||||
};
|
||||
|
||||
ManifestHelper.Write(Manifest, InstallDirectory);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void WriteScriptFile(ScriptType type, string contents)
|
||||
{
|
||||
var path = ScriptHelper.GetScriptFilePath(InstallDirectory, GameId, type);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, contents);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(InstallDirectory))
|
||||
Directory.Delete(InstallDirectory, true);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
using LANCommander.SDK.Helpers;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Helpers;
|
||||
|
||||
public class RetryHelperTests
|
||||
{
|
||||
// ── RetryOnException<T> ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RetryOnException_WhenActionSucceedsImmediately_ReturnsResult()
|
||||
{
|
||||
var result = RetryHelper.RetryOnException(3, TimeSpan.Zero, -1, () => 42);
|
||||
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryOnException_WhenActionSucceedsOnSecondAttempt_ReturnsResult()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
var result = RetryHelper.RetryOnException(3, TimeSpan.Zero, -1, () =>
|
||||
{
|
||||
calls++;
|
||||
if (calls < 2) throw new Exception("first attempt fails");
|
||||
return 99;
|
||||
});
|
||||
|
||||
Assert.Equal(99, result);
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryOnException_WhenActionAlwaysFails_ReturnsDefault()
|
||||
{
|
||||
var result = RetryHelper.RetryOnException(3, TimeSpan.Zero, -1, () =>
|
||||
{
|
||||
throw new Exception("always fails");
|
||||
#pragma warning disable CS0162
|
||||
return 0;
|
||||
#pragma warning restore CS0162
|
||||
});
|
||||
|
||||
Assert.Equal(-1, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryOnException_WhenActionAlwaysFails_TriesMaxAttemptsTimes()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
RetryHelper.RetryOnException(4, TimeSpan.Zero, -1, () =>
|
||||
{
|
||||
calls++;
|
||||
throw new Exception("fail");
|
||||
#pragma warning disable CS0162
|
||||
return 0;
|
||||
#pragma warning restore CS0162
|
||||
});
|
||||
|
||||
Assert.Equal(4, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryOnException_WithOneMaxAttempt_DoesNotRetry()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
RetryHelper.RetryOnException(1, TimeSpan.Zero, -1, () =>
|
||||
{
|
||||
calls++;
|
||||
throw new Exception("fail");
|
||||
#pragma warning disable CS0162
|
||||
return 0;
|
||||
#pragma warning restore CS0162
|
||||
});
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
// ── RetryOnException (void) ───────────────────────────────────────────────
|
||||
// NOTE: The void overload has an infinite loop bug: on successful execution
|
||||
// of action() there is no return or break, so the loop continues indefinitely.
|
||||
// Success-path tests for this overload are skipped to avoid hanging.
|
||||
|
||||
[Fact(Skip = "RetryOnException void overload loops infinitely on success (missing return/break after action())")]
|
||||
public void RetryOnException_Void_WhenActionSucceedsImmediately_CompletesOnce()
|
||||
{
|
||||
bool ran = false;
|
||||
RetryHelper.RetryOnException(3, TimeSpan.Zero, () => { ran = true; });
|
||||
Assert.True(ran);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryOnException_Void_WhenActionAlwaysFails_TriesMaxAttemptsTimes()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
RetryHelper.RetryOnException(3, TimeSpan.Zero, () =>
|
||||
{
|
||||
calls++;
|
||||
throw new Exception("fail");
|
||||
});
|
||||
|
||||
Assert.Equal(3, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryOnException_Void_WithOneMaxAttempt_DoesNotRetry()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
RetryHelper.RetryOnException(1, TimeSpan.Zero, () =>
|
||||
{
|
||||
calls++;
|
||||
throw new Exception("fail");
|
||||
});
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
// ── RetryOnExceptionAsync<T> ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOnExceptionAsync_WhenActionSucceedsImmediately_ReturnsResult()
|
||||
{
|
||||
var result = await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, -1, () => Task.FromResult(42));
|
||||
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOnExceptionAsync_WhenActionSucceedsOnSecondAttempt_ReturnsResult()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
var result = await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, -1, () =>
|
||||
{
|
||||
calls++;
|
||||
if (calls < 2) throw new Exception("first attempt fails");
|
||||
return Task.FromResult(77);
|
||||
});
|
||||
|
||||
Assert.Equal(77, result);
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOnExceptionAsync_WhenActionAlwaysFails_ReturnsDefault()
|
||||
{
|
||||
var result = await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, -1, () =>
|
||||
{
|
||||
throw new Exception("always fails");
|
||||
#pragma warning disable CS0162
|
||||
return Task.FromResult(0);
|
||||
#pragma warning restore CS0162
|
||||
});
|
||||
|
||||
Assert.Equal(-1, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOnExceptionAsync_WhenActionAlwaysFails_TriesMaxAttemptsTimes()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
await RetryHelper.RetryOnExceptionAsync(4, TimeSpan.Zero, -1, () =>
|
||||
{
|
||||
calls++;
|
||||
throw new Exception("fail");
|
||||
#pragma warning disable CS0162
|
||||
return Task.FromResult(0);
|
||||
#pragma warning restore CS0162
|
||||
});
|
||||
|
||||
Assert.Equal(4, calls);
|
||||
}
|
||||
|
||||
// ── RetryOnExceptionAsync (void) ──────────────────────────────────────────
|
||||
// NOTE: Same infinite loop bug as the sync void overload.
|
||||
|
||||
[Fact(Skip = "RetryOnExceptionAsync void overload loops infinitely on success (missing return/break after await action())")]
|
||||
public async Task RetryOnExceptionAsync_Void_WhenActionSucceedsImmediately_CompletesOnce()
|
||||
{
|
||||
bool ran = false;
|
||||
await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, () => { ran = true; return Task.CompletedTask; });
|
||||
Assert.True(ran);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOnExceptionAsync_Void_WhenActionAlwaysFails_TriesMaxAttemptsTimes()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
await RetryHelper.RetryOnExceptionAsync(3, TimeSpan.Zero, () =>
|
||||
{
|
||||
calls++;
|
||||
throw new Exception("fail");
|
||||
#pragma warning disable CS0162
|
||||
return Task.CompletedTask;
|
||||
#pragma warning restore CS0162
|
||||
});
|
||||
|
||||
Assert.Equal(3, calls);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
using LANCommander.SDK.Helpers;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Helpers;
|
||||
|
||||
public class TextFileHelperTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public TextFileHelperTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"lc-textfile-tests-{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
|
||||
private string WriteTemp(string content)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, $"{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_WhenFileNotFound_ThrowsFileNotFoundException()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "nonexistent.txt");
|
||||
|
||||
Assert.Throws<FileNotFoundException>(() =>
|
||||
TextFileHelper.ReplaceAll(path, "pattern", "replacement"));
|
||||
}
|
||||
|
||||
// ── Matching and replacement ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_WhenPatternMatches_ReturnsUpdatedContent()
|
||||
{
|
||||
var path = WriteTemp("Hello World");
|
||||
|
||||
var result = TextFileHelper.ReplaceAll(path, "World", "Earth");
|
||||
|
||||
Assert.Equal("Hello Earth", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_WhenPatternMatches_WritesUpdatedContentToFile()
|
||||
{
|
||||
var path = WriteTemp("Hello World");
|
||||
|
||||
TextFileHelper.ReplaceAll(path, "World", "Earth");
|
||||
|
||||
Assert.Equal("Hello Earth", File.ReadAllText(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_ReplacesAllOccurrences()
|
||||
{
|
||||
var path = WriteTemp("a b a b a");
|
||||
|
||||
var result = TextFileHelper.ReplaceAll(path, "a", "x");
|
||||
|
||||
Assert.Equal("x b x b x", result);
|
||||
}
|
||||
|
||||
// ── No-match behaviour ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_WhenPatternDoesNotMatch_ReturnsOriginalContent()
|
||||
{
|
||||
var path = WriteTemp("Hello World");
|
||||
|
||||
var result = TextFileHelper.ReplaceAll(path, "ZZZ_NOMATCH", "Replacement");
|
||||
|
||||
Assert.Equal("Hello World", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_WhenPatternDoesNotMatch_FileContentIsUnchanged()
|
||||
{
|
||||
var original = "Hello World";
|
||||
var path = WriteTemp(original);
|
||||
|
||||
TextFileHelper.ReplaceAll(path, "ZZZ_NOMATCH", "Replacement");
|
||||
|
||||
Assert.Equal(original, File.ReadAllText(path));
|
||||
}
|
||||
|
||||
// ── Default options ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_DefaultOptions_IsCaseInsensitive()
|
||||
{
|
||||
var path = WriteTemp("Hello WORLD");
|
||||
|
||||
var result = TextFileHelper.ReplaceAll(path, "world", "Earth");
|
||||
|
||||
Assert.Equal("Hello Earth", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_DefaultOptions_IsMultiline()
|
||||
{
|
||||
// ^ matches start of each line in Multiline mode
|
||||
var path = WriteTemp("line1\nline2\nline3");
|
||||
|
||||
var result = TextFileHelper.ReplaceAll(path, "^line", "row");
|
||||
|
||||
Assert.Contains("row1", result);
|
||||
Assert.Contains("row2", result);
|
||||
Assert.Contains("row3", result);
|
||||
}
|
||||
|
||||
// ── Custom options ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_WithCaseSensitiveOptions_DoesNotMatchWrongCase()
|
||||
{
|
||||
var path = WriteTemp("Hello WORLD");
|
||||
|
||||
var result = TextFileHelper.ReplaceAll(path, "world", "Earth", RegexOptions.None);
|
||||
|
||||
Assert.Equal("Hello WORLD", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAll_WithCaptureGroups_SubstitutionGroupsAreExpanded()
|
||||
{
|
||||
var path = WriteTemp("2024-01-15");
|
||||
|
||||
var result = TextFileHelper.ReplaceAll(path, @"(\d{4})-(\d{2})-(\d{2})", "$3/$2/$1");
|
||||
|
||||
Assert.Equal("15/01/2024", result);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
using LANCommander.SDK.Services;
|
||||
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Install;
|
||||
|
||||
public class GameInstallationFileListEntryTests
|
||||
{
|
||||
private static GameInstallationFileListEntry MakeEntry(params string[] entryPaths)
|
||||
{
|
||||
var entry = new GameInstallationFileListEntry();
|
||||
entry.AddFiles(entryPaths.Select(p => new GameInstallationFileListEntry.FileEntry
|
||||
{
|
||||
EntryPath = p,
|
||||
LocalPath = p
|
||||
}));
|
||||
return entry;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_AddsFilesFromOtherEntry()
|
||||
{
|
||||
var target = new GameInstallationFileListEntry();
|
||||
var source = MakeEntry("game.exe", "readme.txt");
|
||||
|
||||
target.Merge(source);
|
||||
|
||||
Assert.Equal(2, target.Files.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_DoesNotAddDuplicateFiles()
|
||||
{
|
||||
var target = MakeEntry("game.exe");
|
||||
var source = MakeEntry("game.exe", "readme.txt");
|
||||
|
||||
target.Merge(source);
|
||||
|
||||
Assert.Equal(2, target.Files.Count);
|
||||
Assert.Single(target.Files, f => f.EntryPath == "game.exe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_IsCaseInsensitiveForDuplicateDetection()
|
||||
{
|
||||
var target = MakeEntry("Game.exe");
|
||||
var source = MakeEntry("game.exe", "readme.txt");
|
||||
|
||||
target.Merge(source);
|
||||
|
||||
// "game.exe" should not be added because "Game.exe" already exists (case-insensitive)
|
||||
Assert.Equal(2, target.Files.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_SetsManifestWhenTargetManifestIsNull()
|
||||
{
|
||||
var manifest = new ManifestGame { Id = Guid.NewGuid(), Title = "Test Game" };
|
||||
var target = new GameInstallationFileListEntry();
|
||||
var source = new GameInstallationFileListEntry { Manifest = manifest };
|
||||
|
||||
target.Merge(source);
|
||||
|
||||
Assert.Equal(manifest, target.Manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_DoesNotOverwriteExistingManifest()
|
||||
{
|
||||
var originalManifest = new ManifestGame { Id = Guid.NewGuid(), Title = "Original" };
|
||||
var newManifest = new ManifestGame { Id = Guid.NewGuid(), Title = "New" };
|
||||
var target = new GameInstallationFileListEntry { Manifest = originalManifest };
|
||||
var source = new GameInstallationFileListEntry { Manifest = newManifest };
|
||||
|
||||
target.Merge(source);
|
||||
|
||||
Assert.Equal(originalManifest, target.Manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_WithNullSource_DoesNotThrow()
|
||||
{
|
||||
var target = MakeEntry("game.exe");
|
||||
|
||||
// Merge with a null-files source (empty entry)
|
||||
var emptySource = new GameInstallationFileListEntry();
|
||||
target.Merge(emptySource);
|
||||
|
||||
Assert.Single(target.Files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFile_AddsFileToList()
|
||||
{
|
||||
var entry = new GameInstallationFileListEntry();
|
||||
var file = new GameInstallationFileListEntry.FileEntry
|
||||
{
|
||||
EntryPath = "game.exe",
|
||||
LocalPath = @"C:\Games\game.exe"
|
||||
};
|
||||
|
||||
entry.AddFile(file);
|
||||
|
||||
Assert.Single(entry.Files);
|
||||
Assert.Equal("game.exe", entry.Files[0].EntryPath);
|
||||
Assert.Equal(@"C:\Games\game.exe", entry.Files[0].LocalPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFiles_AddsMultipleFiles()
|
||||
{
|
||||
var entry = new GameInstallationFileListEntry();
|
||||
var files = new[]
|
||||
{
|
||||
new GameInstallationFileListEntry.FileEntry { EntryPath = "game.exe", LocalPath = @"C:\game.exe" },
|
||||
new GameInstallationFileListEntry.FileEntry { EntryPath = "data.pak", LocalPath = @"C:\data.pak" },
|
||||
new GameInstallationFileListEntry.FileEntry { EntryPath = "readme.txt", LocalPath = @"C:\readme.txt" },
|
||||
};
|
||||
|
||||
entry.AddFiles(files);
|
||||
|
||||
Assert.Equal(3, entry.Files.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFile_WithNullArgument_Throws()
|
||||
{
|
||||
var entry = new GameInstallationFileListEntry();
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => entry.AddFile(null));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
396
LANCommander.SDK.Tests/Saves/SaveDownloadTests.cs
Normal file
396
LANCommander.SDK.Tests/Saves/SaveDownloadTests.cs
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
using System.IO.Compression;
|
||||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Extensions;
|
||||
using LANCommander.SDK.Helpers;
|
||||
using LANCommander.SDK.Services;
|
||||
using LANCommander.SDK.Tests.Fixtures;
|
||||
using LANCommander.SDK.Utilities;
|
||||
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
|
||||
using ManifestSavePath = LANCommander.SDK.Models.Manifest.SavePath;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Saves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the save download + extraction cycle.
|
||||
///
|
||||
/// HTTP calls are not exercised. Instead, the save archive is pre-built on-disk
|
||||
/// using <see cref="SavePacker"/> and restored with the same file-movement logic
|
||||
/// that <c>SaveClient.DownloadAsync</c> uses, giving us full coverage of the
|
||||
/// extraction flow without needing a running server.
|
||||
/// </summary>
|
||||
public class SaveDownloadTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
// Only the filesystem / pure-computation methods are exercised here;
|
||||
// API-calling members are never invoked, so all DI deps can be null.
|
||||
private readonly SaveClient _saveClient = new(null, null, null, null);
|
||||
|
||||
public SaveDownloadTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"lc-save-download-{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
|
||||
// ── FakeGameFixture – structure sanity checks ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FakeGame_ManifestWrittenToDisk()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
Assert.True(ManifestHelper.Exists(fixture.InstallDirectory, fixture.GameId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FakeGame_SaveFilesExistOnDisk()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
Assert.True(File.Exists(fixture.SaveFileSlot1Path));
|
||||
Assert.True(File.Exists(fixture.SaveFileSlot2Path));
|
||||
Assert.True(File.Exists(fixture.ConfigFilePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FakeGame_ManifestContainsTwoKeys()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
Assert.Equal(2, fixture.Manifest.Keys.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FakeGame_ManifestContainsExpectedScriptTypes()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
var types = fixture.Manifest.Scripts.Select(s => s.Type).ToList();
|
||||
|
||||
Assert.Contains(ScriptType.Install, types);
|
||||
Assert.Contains(ScriptType.Uninstall, types);
|
||||
Assert.Contains(ScriptType.BeforeStart,types);
|
||||
Assert.Contains(ScriptType.AfterStop, types);
|
||||
Assert.Contains(ScriptType.NameChange, types);
|
||||
Assert.Contains(ScriptType.KeyChange, types);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FakeGame_ManifestContainsThreeMediaItems()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
Assert.Equal(3, fixture.Manifest.Media.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FakeGame_ManifestContainsCoverIconAndBackground()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
var types = fixture.Manifest.Media.Select(m => m.Type).ToList();
|
||||
|
||||
Assert.Contains(MediaType.Cover, types);
|
||||
Assert.Contains(MediaType.Icon, types);
|
||||
Assert.Contains(MediaType.Background, types);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FakeGame_ScriptFilesExistOnDisk()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var scriptTypes = new[]
|
||||
{
|
||||
ScriptType.Install, ScriptType.Uninstall,
|
||||
ScriptType.BeforeStart, ScriptType.AfterStop,
|
||||
ScriptType.NameChange, ScriptType.KeyChange
|
||||
};
|
||||
|
||||
foreach (var type in scriptTypes)
|
||||
{
|
||||
var path = ScriptHelper.GetScriptFilePath(fixture.InstallDirectory, fixture.GameId, type);
|
||||
Assert.True(File.Exists(path), $"Script file missing for {type}: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Packed-archive structure ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PackedSave_ContainsManifestYml()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var stream = await BuildArchiveStreamAsync(fixture.InstallDirectory, fixture.Manifest);
|
||||
var keys = EntryKeys(stream);
|
||||
|
||||
Assert.Contains(keys, k => string.Equals(k, "Manifest.yml", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackedSave_SavesDirEntries_PrefixedWithCorrectSavePathId()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
using var packer = new SavePacker(fixture.InstallDirectory);
|
||||
packer.AddPath(fixture.SavesDirSavePath);
|
||||
|
||||
var keys = EntryKeys(await packer.PackAsync());
|
||||
var expectedPrefix = $"Files/{fixture.SavesDirSavePath.Id}/";
|
||||
|
||||
Assert.All(keys, k => Assert.StartsWith(expectedPrefix, k));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackedSave_ConfigFileEntry_PrefixedWithCorrectSavePathId()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
using var packer = new SavePacker(fixture.InstallDirectory);
|
||||
packer.AddPath(fixture.ConfigFileSavePath);
|
||||
|
||||
var keys = EntryKeys(await packer.PackAsync());
|
||||
var expectedPrefix = $"Files/{fixture.ConfigFileSavePath.Id}/";
|
||||
|
||||
Assert.All(keys, k => Assert.StartsWith(expectedPrefix, k));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackedSave_AllSaveFiles_ArePresent()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var stream = await BuildArchiveStreamAsync(fixture.InstallDirectory, fixture.Manifest);
|
||||
var keys = EntryKeys(stream);
|
||||
|
||||
Assert.Contains(keys, k => k.EndsWith("slot1.sav"));
|
||||
Assert.Contains(keys, k => k.EndsWith("slot2.sav"));
|
||||
Assert.Contains(keys, k => k.EndsWith("config.cfg"));
|
||||
}
|
||||
|
||||
// ── Round-trip: pack → write to disk → extract → restore ─────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_SavesDirFiles_LandInDestInstallDirectory()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
|
||||
var destDir = CreateDestDir();
|
||||
|
||||
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot1.sav")));
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot2.sav")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_ConfigFile_LandsInDestInstallDirectory()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
|
||||
var destDir = CreateDestDir();
|
||||
|
||||
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "config.cfg")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_SaveContents_ArePreserved()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
|
||||
var destDir = CreateDestDir();
|
||||
|
||||
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
|
||||
|
||||
Assert.Equal(FakeGameFixture.Slot1Content, File.ReadAllText(Path.Combine(destDir, "saves", "slot1.sav")));
|
||||
Assert.Equal(FakeGameFixture.Slot2Content, File.ReadAllText(Path.Combine(destDir, "saves", "slot2.sav")));
|
||||
Assert.Equal(FakeGameFixture.ConfigContent, File.ReadAllText(Path.Combine(destDir, "config.cfg")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_MultipleSavePaths_AllFilesRestored()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
|
||||
var destDir = CreateDestDir();
|
||||
|
||||
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot1.sav")));
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot2.sav")));
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "config.cfg")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_OverwritesExistingStaleFile()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var archivePath = await WriteSaveArchiveAsync(fixture.InstallDirectory, fixture.Manifest);
|
||||
var destDir = CreateDestDir();
|
||||
|
||||
// Pre-populate destination with stale data
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "saves"));
|
||||
File.WriteAllText(Path.Combine(destDir, "saves", "slot1.sav"), "STALE_CONTENT");
|
||||
|
||||
RestoreSaveArchive(archivePath, destDir, fixture.Manifest);
|
||||
|
||||
Assert.Equal(FakeGameFixture.Slot1Content, File.ReadAllText(Path.Combine(destDir, "saves", "slot1.sav")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_WithRegexSavePath_OnlyMatchingFilesRestored()
|
||||
{
|
||||
// Arrange – a game that saves only .sav files via a regex save path
|
||||
var sourceDir = CreateDestDir();
|
||||
Directory.CreateDirectory(Path.Combine(sourceDir, "saves"));
|
||||
File.WriteAllText(Path.Combine(sourceDir, "saves", "slot1.sav"), "SLOT1");
|
||||
File.WriteAllText(Path.Combine(sourceDir, "saves", "slot2.sav"), "SLOT2");
|
||||
File.WriteAllText(Path.Combine(sourceDir, "saves", "notes.txt"), "NOTES"); // must NOT be packed
|
||||
|
||||
var regexSavePath = new ManifestSavePath
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = SavePathType.File,
|
||||
Path = @"\.sav$",
|
||||
WorkingDirectory = "{InstallDir}",
|
||||
IsRegex = true
|
||||
};
|
||||
|
||||
var manifest = new ManifestGame
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Regex Save Game",
|
||||
SavePaths = new List<ManifestSavePath> { regexSavePath }
|
||||
};
|
||||
|
||||
var archivePath = await WriteSaveArchiveAsync(sourceDir, manifest);
|
||||
var destDir = CreateDestDir();
|
||||
|
||||
RestoreSaveArchive(archivePath, destDir, manifest);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot1.sav")));
|
||||
Assert.True(File.Exists(Path.Combine(destDir, "saves", "slot2.sav")));
|
||||
Assert.False(File.Exists(Path.Combine(destDir, "saves", "notes.txt")));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Packs all save paths from <paramref name="manifest"/> and writes the resulting
|
||||
/// ZIP archive to a temp file. Returns the path to that file.
|
||||
/// </summary>
|
||||
private async Task<string> WriteSaveArchiveAsync(string installDirectory, ManifestGame manifest)
|
||||
{
|
||||
var stream = await BuildArchiveStreamAsync(installDirectory, manifest);
|
||||
|
||||
var archivePath = Path.Combine(_tempDir, $"save-{Guid.NewGuid()}.zip");
|
||||
stream.Position = 0;
|
||||
|
||||
await using var fs = File.Create(archivePath);
|
||||
await stream.CopyToAsync(fs);
|
||||
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
private static async Task<Stream> BuildArchiveStreamAsync(string installDirectory, ManifestGame manifest)
|
||||
{
|
||||
using var packer = new SavePacker(installDirectory);
|
||||
|
||||
if (manifest.SavePaths?.Any() == true)
|
||||
packer.AddPaths(manifest.SavePaths);
|
||||
|
||||
await packer.AddManifestAsync(manifest);
|
||||
|
||||
var packed = await packer.PackAsync();
|
||||
|
||||
// Copy to a fresh MemoryStream so the caller owns a non-disposed buffer.
|
||||
var copy = new MemoryStream();
|
||||
packed.Position = 0;
|
||||
await packed.CopyToAsync(copy);
|
||||
copy.Position = 0;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a save archive to <paramref name="installDirectory"/>.
|
||||
///
|
||||
/// Mirrors the file-movement block from <c>SaveClient.DownloadAsync</c> exactly,
|
||||
/// replacing only the HTTP download step with the pre-built archive file.
|
||||
/// This makes the test a faithful integration test of the extraction logic.
|
||||
/// </summary>
|
||||
private void RestoreSaveArchive(string archivePath, string installDirectory, ManifestGame manifest)
|
||||
{
|
||||
var tempLocation = Path.Combine(Path.GetTempPath(), $"lc-restore-{Guid.NewGuid()}");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(tempLocation);
|
||||
ZipFile.ExtractToDirectory(archivePath, tempLocation, overwriteFiles: true);
|
||||
|
||||
// Mirror legacy-fallback from SaveClient.DownloadAsync
|
||||
var tempFilesRoot = Directory.Exists(Path.Combine(tempLocation, "Files")) ? "Files" : "Saves";
|
||||
|
||||
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == SavePathType.File))
|
||||
{
|
||||
var entries = _saveClient.GetFileSavePathEntries(savePath, installDirectory) ?? [];
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var entryPath = Path.Combine(
|
||||
tempLocation,
|
||||
tempFilesRoot,
|
||||
savePath.Id.ToString(),
|
||||
entry.ArchivePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
var destinationPath = entry.ActualPath.ExpandEnvironmentVariables(installDirectory);
|
||||
|
||||
if (File.Exists(entryPath))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
if (File.Exists(destinationPath)) File.Delete(destinationPath);
|
||||
File.Move(entryPath, destinationPath);
|
||||
}
|
||||
else if (Directory.Exists(entryPath))
|
||||
{
|
||||
foreach (var entryFile in Directory.GetFiles(entryPath, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var fileDestination = entryFile.Replace(entryPath, destinationPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fileDestination)!);
|
||||
if (File.Exists(fileDestination)) File.Delete(fileDestination);
|
||||
File.Move(entryFile, fileDestination);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempLocation))
|
||||
Directory.Delete(tempLocation, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a fresh empty directory under _tempDir for use as a restore destination.</summary>
|
||||
private string CreateDestDir()
|
||||
{
|
||||
var dir = Path.Combine(_tempDir, $"dest-{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static List<string> EntryKeys(Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
return zip.Entries.Select(e => e.FullName.Replace('\\', '/')).ToList();
|
||||
}
|
||||
}
|
||||
277
LANCommander.SDK.Tests/Saves/SaveUploadTests.cs
Normal file
277
LANCommander.SDK.Tests/Saves/SaveUploadTests.cs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
using System.IO.Compression;
|
||||
using LANCommander.SDK.Enums;
|
||||
using LANCommander.SDK.Services;
|
||||
using LANCommander.SDK.Tests.Fixtures;
|
||||
using LANCommander.SDK.Utilities;
|
||||
using ManifestGame = LANCommander.SDK.Models.Manifest.Game;
|
||||
using ManifestSavePath = LANCommander.SDK.Models.Manifest.SavePath;
|
||||
|
||||
namespace LANCommander.SDK.Tests.Saves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the save upload + packing cycle.
|
||||
///
|
||||
/// <c>SaveClient.UploadAsync</c> ends with an HTTP POST; only the packing half is
|
||||
/// exercised here. <c>SaveClient.PackAsync</c> (no HTTP) and <c>SavePacker</c>
|
||||
/// directly are both tested so we cover the path the launcher takes after a game exits.
|
||||
/// </summary>
|
||||
public class SaveUploadTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
// Only the packing / file-system methods are exercised;
|
||||
// API-calling members are never invoked, so all DI deps can be null.
|
||||
private readonly SaveClient _saveClient = new(null, null, null, null);
|
||||
|
||||
public SaveUploadTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"lc-save-upload-{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
|
||||
// ── SaveClient.PackAsync ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PackAsync_ProducesReadableZipStream()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var stream = await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest);
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
_ = zip.Entries.Count;
|
||||
});
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackAsync_IncludesManifestYml()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
|
||||
|
||||
Assert.Contains(keys, k => string.Equals(k, "Manifest.yml", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackAsync_IncludesSaveSlotFiles()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
|
||||
|
||||
Assert.Contains(keys, k => k.EndsWith("slot1.sav"));
|
||||
Assert.Contains(keys, k => k.EndsWith("slot2.sav"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackAsync_IncludesConfigFile()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
|
||||
|
||||
Assert.Contains(keys, k => k.EndsWith("config.cfg"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackAsync_WithNoSavePaths_ProducesArchiveWithOnlyManifest()
|
||||
{
|
||||
var installDir = Path.Combine(_tempDir, "empty-game");
|
||||
Directory.CreateDirectory(installDir);
|
||||
|
||||
var manifest = new ManifestGame
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "No-Save Game",
|
||||
SavePaths = new List<ManifestSavePath>()
|
||||
};
|
||||
|
||||
var keys = EntryKeys(await _saveClient.PackAsync(installDir, manifest));
|
||||
|
||||
Assert.Single(keys);
|
||||
Assert.Contains(keys, k => string.Equals(k, "Manifest.yml", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackAsync_WithRegexSavePath_ExcludesNonMatchingFiles()
|
||||
{
|
||||
var installDir = CreateGameDir(
|
||||
("save1.sav", "SAVE1"),
|
||||
("save2.sav", "SAVE2"),
|
||||
("readme.txt", "README"));
|
||||
|
||||
var manifest = new ManifestGame
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Regex Game",
|
||||
SavePaths = new List<ManifestSavePath>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = SavePathType.File,
|
||||
Path = @"\.sav$",
|
||||
WorkingDirectory = "{InstallDir}",
|
||||
IsRegex = true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var keys = EntryKeys(await _saveClient.PackAsync(installDir, manifest));
|
||||
|
||||
Assert.DoesNotContain(keys, k => k.EndsWith(".txt"));
|
||||
Assert.Contains(keys, k => k.EndsWith(".sav"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackAsync_WithMultipleSavePaths_AllFilesIncluded()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
|
||||
|
||||
Assert.Contains(keys, k => k.Contains(fixture.SavesDirSavePath.Id.ToString()));
|
||||
Assert.Contains(keys, k => k.Contains(fixture.ConfigFileSavePath.Id.ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackAsync_EachSavePath_StoredUnderDistinctIdPrefix()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
var keys = EntryKeys(await _saveClient.PackAsync(fixture.InstallDirectory, fixture.Manifest));
|
||||
|
||||
var saveDirKeys = keys.Where(k => k.Contains(fixture.SavesDirSavePath.Id.ToString())).ToList();
|
||||
var configKeys = keys.Where(k => k.Contains(fixture.ConfigFileSavePath.Id.ToString())).ToList();
|
||||
|
||||
Assert.NotEmpty(saveDirKeys);
|
||||
Assert.NotEmpty(configKeys);
|
||||
Assert.Empty(saveDirKeys.Intersect(configKeys));
|
||||
}
|
||||
|
||||
// ── SavePacker directly ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SavePacker_WithFakeGame_ProducesExpectedFileCount()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
using var packer = new SavePacker(fixture.InstallDirectory);
|
||||
|
||||
packer.AddPaths(fixture.Manifest.SavePaths);
|
||||
await packer.AddManifestAsync(fixture.Manifest);
|
||||
|
||||
// slot1.sav + slot2.sav + config.cfg + Manifest.yml = 4
|
||||
Assert.Equal(4, EntryKeys(await packer.PackAsync()).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavePacker_PackedContent_MatchesOriginalFileContent()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
using var packer = new SavePacker(fixture.InstallDirectory);
|
||||
packer.AddPath(fixture.ConfigFileSavePath);
|
||||
|
||||
var stream = await packer.PackAsync();
|
||||
stream.Position = 0;
|
||||
|
||||
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var configEntry = zip.Entries.FirstOrDefault(e => e.FullName.EndsWith("config.cfg"));
|
||||
|
||||
Assert.NotNull(configEntry);
|
||||
|
||||
using var reader = new StreamReader(configEntry.Open());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
|
||||
Assert.Equal(FakeGameFixture.ConfigContent, content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavePacker_WithDirectorySavePath_PacksAllFilesInDirectory()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
using var packer = new SavePacker(fixture.InstallDirectory);
|
||||
packer.AddPath(fixture.SavesDirSavePath);
|
||||
|
||||
var keys = EntryKeys(await packer.PackAsync());
|
||||
|
||||
Assert.Equal(2, keys.Count);
|
||||
Assert.Contains(keys, k => k.EndsWith("slot1.sav"));
|
||||
Assert.Contains(keys, k => k.EndsWith("slot2.sav"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavePacker_AfterGameExit_NewSaveFileIsIncluded()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
// Simulate a new save written after game launch
|
||||
File.WriteAllText(Path.Combine(fixture.SavesDirectory, "slot3.sav"), "NEW_SLOT");
|
||||
|
||||
using var packer = new SavePacker(fixture.InstallDirectory);
|
||||
packer.AddPath(fixture.SavesDirSavePath);
|
||||
|
||||
var keys = EntryKeys(await packer.PackAsync());
|
||||
|
||||
Assert.Contains(keys, k => k.EndsWith("slot3.sav"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavePacker_AfterGameExit_ModifiedSaveFileContentIsPreserved()
|
||||
{
|
||||
using var fixture = new FakeGameFixture();
|
||||
|
||||
// Simulate the game overwriting slot1 with new data
|
||||
File.WriteAllText(fixture.SaveFileSlot1Path, "UPDATED_SAVE");
|
||||
|
||||
using var packer = new SavePacker(fixture.InstallDirectory);
|
||||
packer.AddPath(fixture.SavesDirSavePath);
|
||||
|
||||
var stream = await packer.PackAsync();
|
||||
stream.Position = 0;
|
||||
|
||||
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entry = zip.Entries.FirstOrDefault(e => e.FullName.EndsWith("slot1.sav"));
|
||||
|
||||
Assert.NotNull(entry);
|
||||
|
||||
using var reader = new StreamReader(entry.Open());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
|
||||
Assert.Equal("UPDATED_SAVE", content);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private string CreateGameDir(params (string relativePath, string content)[] files)
|
||||
{
|
||||
var dir = Path.Combine(_tempDir, $"game-{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
foreach (var (relativePath, content) in files)
|
||||
{
|
||||
var full = Path.Combine(dir, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
File.WriteAllText(full, content);
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static List<string> EntryKeys(Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
return zip.Entries.Select(e => e.FullName.Replace('\\', '/')).ToList();
|
||||
}
|
||||
}
|
||||
543
LANCommander.SDK.Tests/TEST_COVERAGE_PLAN.md
Normal file
543
LANCommander.SDK.Tests/TEST_COVERAGE_PLAN.md
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
# LANCommander.SDK — Test Coverage Plan
|
||||
|
||||
## Current Status
|
||||
|
||||
| Area | Classes | Methods | Tests | Coverage |
|
||||
|---|---|---|---|---|
|
||||
| Extensions | 8 public | ~20 public | 88 | High |
|
||||
| Helpers — `DirectoryHelper` | 1 | 3 | 22 | High |
|
||||
| Helpers — `ManifestHelper` | 1 | 9 | 40+ | High |
|
||||
| Helpers — `ScriptHelper` | 1 | 6 | 20+ | High |
|
||||
| Helpers — `IniHelper` | 1 | 4 | ~20 (via IniHandling/) | Medium |
|
||||
| Helpers — `DisplayHelper` | 1 | 1 | 0 | None |
|
||||
| Helpers — `EnvironmentHelper` | 1 | 1 | 0 | None |
|
||||
| Helpers — `VersionHelper` | 1 | 1 | 0 | None |
|
||||
| Utilities — `SavePacker` | 1 | 7 | ~20 | Medium |
|
||||
| Utilities — `RegistryExportUtility` | 1 | 1 | 0 | None |
|
||||
| Utilities — `RegistryImportUtility` | 1 | 1 | 0 | None |
|
||||
| Clients (all 18) | 18 | 100+ | 0 | None |
|
||||
| Models with logic | ~5 | ~10 | ~5 | Low |
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure Requirements
|
||||
|
||||
Before writing client tests, two pieces of test infrastructure are needed.
|
||||
|
||||
### 1. Fake `HttpMessageHandler`
|
||||
|
||||
All HTTP clients use `ApiRequestFactory` → `ApiRequestBuilder` → `HttpClient`. The most effective
|
||||
isolation strategy is a fake `HttpMessageHandler` that intercepts requests and returns controlled
|
||||
responses, without touching a real server.
|
||||
|
||||
```csharp
|
||||
// Suggested location: LANCommander.SDK.Tests/Infrastructure/FakeHttpMessageHandler.cs
|
||||
public class FakeHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||
|
||||
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||
=> _handler = handler;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_handler(request));
|
||||
}
|
||||
```
|
||||
|
||||
For JSON responses, a helper that serializes any object to an `application/json`
|
||||
`StringContent` with status 200 reduces boilerplate across all client tests.
|
||||
|
||||
### 2. Package References
|
||||
|
||||
Add to `LANCommander.SDK.Tests.csproj`:
|
||||
|
||||
```xml
|
||||
<PackageReference Include="NSubstitute" Version="5.*" />
|
||||
```
|
||||
|
||||
NSubstitute is used to mock `ILogger<T>`, `ISettingsProvider`, `ITokenProvider`,
|
||||
`IConnectionClient`, and other injected dependencies that client constructors require but
|
||||
whose behavior is not under test.
|
||||
|
||||
### 3. Shared Client Fixtures
|
||||
|
||||
Because every client shares the same constructor pattern (`ILogger`, `ApiRequestFactory`, etc.),
|
||||
a base class or shared factory method should build a ready-to-use client with a fake
|
||||
`HttpMessageHandler` injected, reducing boilerplate to a single line per test.
|
||||
|
||||
---
|
||||
|
||||
## Coverage Plan
|
||||
|
||||
### Priority 1 — Pure Computation (no mocks needed)
|
||||
|
||||
These classes have no I/O dependencies and can be tested directly. Most are already covered;
|
||||
the gaps are small.
|
||||
|
||||
---
|
||||
|
||||
#### `VersionHelper.GetCurrentVersion()`
|
||||
**File:** `Helpers/VersionHelperTests.cs`
|
||||
|
||||
| Test | What it checks |
|
||||
|---|---|
|
||||
| `GetCurrentVersion_ReturnsNonNullVersion` | Return value is not null |
|
||||
| `GetCurrentVersion_ReturnsSemVersion` | Parses into a valid `SemVersion` |
|
||||
| `GetCurrentVersion_MajorAndMinorAreNonNegative` | Sanity-check numeric components |
|
||||
|
||||
**Notes:** The method reads from the executing assembly's `AssemblyInformationalVersionAttribute`.
|
||||
The version available during test runs may be `0.0.0` or similar — tests should assert shape,
|
||||
not a specific value.
|
||||
|
||||
---
|
||||
|
||||
#### `IniHelper` (gap fill)
|
||||
**File:** `IniHandling/IniHelperTests.cs`
|
||||
|
||||
The existing `IniHandlingTests_MadMilkman` already tests `FromString` and `ToString`
|
||||
indirectly via the `ConfigurationTests` data set. The following dedicated tests cover
|
||||
`IniHelper`'s own behaviour independently of `MadMilkman` specifics.
|
||||
|
||||
| Test | What it checks |
|
||||
|---|---|
|
||||
| `FromString_WithEmptyString_ReturnsEmptyIniFile` | No sections or keys |
|
||||
| `FromString_WithNullString_ThrowsOrReturnsEmpty` | Null safety |
|
||||
| `FromString_DefaultOptions_AllowsDuplicateKeys` | Default `IniOptions` behaviour |
|
||||
| `ToString_RoundTrip_PreservesAllKeyValues` | Serialise then re-parse matches original |
|
||||
| `ToString_WithCustomEncoding_WritesExpectedBytes` | Encoding parameter is respected |
|
||||
| `FromString_WithCustomOptions_KeyDuplicateIgnored_TakesFirstValue` | `IniDuplication.Ignored` |
|
||||
| `FromString_WithCustomOptions_KeyDuplicateAllowed_RetainsAllValues` | `IniDuplication.Allowed` |
|
||||
|
||||
---
|
||||
|
||||
#### `ProfileClient.GetAvatarUri()` and `MediaClient` URI/path helpers
|
||||
**File:** `Clients/MediaClientPureTests.cs`
|
||||
|
||||
These methods are pure string computation inside otherwise network-heavy clients, making them
|
||||
good isolated targets.
|
||||
|
||||
| Test | Method | What it checks |
|
||||
|---|---|---|
|
||||
| `GetAbsoluteUrl_WithValidMedia_ReturnsAbsoluteUri` | `MediaClient.GetAbsoluteUrl` | Scheme + host from settings |
|
||||
| `GetLocalPath_WithMedia_ReturnsExpectedFormat` | `MediaClient.GetLocalPath(Media)` | FileId + CRC32 path format |
|
||||
| `GetLocalPath_WithFileIdAndCrc32_MatchesMediaOverload` | `MediaClient.GetLocalPath(Guid, string)` | Both overloads agree |
|
||||
| `GetDownloadPath_ReturnsPathUnderExpectedDirectory` | `MediaClient.GetDownloadPath` | Path structure |
|
||||
| `GetAvatarUri_BuildsCorrectUrl` | `ProfileClient.GetAvatarUri` | Host + username in URI |
|
||||
| `CalculateChecksumAsync_OnKnownContent_ReturnsExpectedCrc32` | `MediaClient.CalculateChecksumAsync` | File CRC32 is deterministic |
|
||||
|
||||
---
|
||||
|
||||
### Priority 2 — Helpers with filesystem I/O (already have test infrastructure)
|
||||
|
||||
These use the established `IDisposable`+temp-directory pattern from `DirectoryHelperTests`.
|
||||
|
||||
---
|
||||
|
||||
#### `EnvironmentHelper.IsRunningInContainer()`
|
||||
**File:** `Helpers/EnvironmentHelperTests.cs`
|
||||
|
||||
The method checks three signals in order:
|
||||
1. Existence of `/.dockerenv`
|
||||
2. Contents of `/proc/1/cgroup`
|
||||
3. Environment variables (`KUBERNETES_SERVICE_HOST`, `container`, `PODMAN_VERSION`, etc.)
|
||||
|
||||
Because the checks read real filesystem paths, tests must set up controlled temporary files or
|
||||
environment variables rather than relying on the host system's state.
|
||||
|
||||
| Test | Setup | What it checks |
|
||||
|---|---|---|
|
||||
| `IsRunningInContainer_WithDockerenvFile_ReturnsTrue` | Create `{tempDir}/.dockerenv`; inject path | Docker detection via file |
|
||||
| `IsRunningInContainer_WithCgroupContainingDocker_ReturnsTrue` | Write `docker` into a temp cgroup file | cgroup-based detection |
|
||||
| `IsRunningInContainer_WithCgroupContainingKubernetes_ReturnsTrue` | Write `kubepods` into cgroup | Kubernetes via cgroup |
|
||||
| `IsRunningInContainer_WithKubernetesEnvVar_ReturnsTrue` | Set `KUBERNETES_SERVICE_HOST` env var | Kubernetes env var |
|
||||
| `IsRunningInContainer_WithContainerEnvVar_ReturnsTrue` | Set `container=podman` env var | Podman/container env var |
|
||||
| `IsRunningInContainer_WithNoSignals_ReturnsFalse` | No file, no env var, empty cgroup | Normal environment |
|
||||
|
||||
**Notes:** The current implementation reads hardcoded paths (`/.dockerenv`, `/proc/1/cgroup`).
|
||||
A thin path-injection seam (or a wrapper method the tests can override) will be needed to avoid
|
||||
making these tests host-dependent.
|
||||
|
||||
---
|
||||
|
||||
#### `DisplayHelper.GetScreen()` — Linux parsing only
|
||||
**File:** `Helpers/DisplayHelperTests.cs`
|
||||
|
||||
The three Linux code paths (`xrandr`, `xdpyinfo`, `/sys/class/drm`) each parse a specific text
|
||||
format. The parsing logic can be tested directly if it is extracted into internal parse methods,
|
||||
or indirectly by supplying mock process output.
|
||||
|
||||
| Test | What it checks |
|
||||
|---|---|
|
||||
| `ParseXrandrOutput_WithTypicalOutput_ExtractsBoundsAndRefreshRate` | Width × height × Hz from `xrandr` format |
|
||||
| `ParseXrandrOutput_WithMultipleDisplayLines_PicksConnected` | Multiple monitors, picks "connected" line |
|
||||
| `ParseXdpyinfoOutput_WithTypicalOutput_ExtractsDimensions` | Width × height from `xdpyinfo` format |
|
||||
| `ParseDrmOutput_WithTypicalFilesystemContent_ExtractsDimensions` | `/sys/class/drm/*/modes` format |
|
||||
| `GetScreen_WhenNoDisplayServer_ReturnsDefaultOrNull` | Graceful fallback when xrandr is absent |
|
||||
|
||||
**Notes:** The private parse helpers are currently inline within `GetScreen()`. Extracting them
|
||||
to `internal static` methods would allow direct testing without spawning processes.
|
||||
|
||||
---
|
||||
|
||||
### Priority 3 — Utilities
|
||||
|
||||
---
|
||||
|
||||
#### `SavePacker` (gap fill)
|
||||
**File:** `Utilities/SavePackerTests.cs` *(add to existing file)*
|
||||
|
||||
The existing `SavePackerTests` already cover the primary happy paths. The remaining gaps are:
|
||||
|
||||
| Test | What it checks |
|
||||
|---|---|
|
||||
| `AddPath_WithRegistryType_CallsAddRegistryPath` | `SavePathType.Registry` routes correctly |
|
||||
| `AddRegistryPath_OnLinux_ProducesNoEntries` | Registry export is no-op on Linux |
|
||||
| `AddPaths_TwoDifferentSavePathIds_EachInOwnSubdirectory` | Partition by `SavePath.Id` in zip |
|
||||
| `PackAsync_CalledTwice_ReturnsFreshStreamEachTime` | Idempotency / re-use after pack |
|
||||
| `PackAsync_EmptyPacker_ProducesValidZipStream` | Empty archive is still valid ZIP |
|
||||
| `AddManifestAsync_CalledTwice_HasManifestRemainsTrue` | No error on duplicate manifest add |
|
||||
|
||||
---
|
||||
|
||||
#### `RegistryExportUtility` — Windows-only
|
||||
**File:** `Utilities/RegistryExportUtilityTests.cs`
|
||||
|
||||
These tests only run on Windows. Decorate the class with
|
||||
`[PlatformSpecific(TestPlatforms.Windows)]` (or use `Skip` on non-Windows).
|
||||
|
||||
| Test | What it checks |
|
||||
|---|---|
|
||||
| `Export_WithKnownRegistryKey_ProducesRegFileFormat` | Output starts with `Windows Registry Editor` header |
|
||||
| `Export_WithStringValue_IncludesRegSz` | `REG_SZ` values appear as `"key"="value"` |
|
||||
| `Export_WithDwordValue_IncludesRegDword` | `REG_DWORD` values formatted with hex |
|
||||
| `Export_WithExpandStringValue_IncludesRegExpandSz` | `REG_EXPAND_SZ` type tag |
|
||||
| `Export_WithMultiStringValue_IncludesRegMultiSz` | Null-separated multi-string encoding |
|
||||
| `Export_WithBinaryValue_IncludesRegBinary` | Hex byte sequence format |
|
||||
| `Export_WithNonExistentKey_ReturnsEmptyOrThrows` | Error handling for missing keys |
|
||||
| `Export_WithNestedSubkeys_RecursivelyCapturesAll` | Deep key trees |
|
||||
|
||||
---
|
||||
|
||||
#### `RegistryImportUtility` — Windows-only
|
||||
**File:** `Utilities/RegistryImportUtilityTests.cs`
|
||||
|
||||
| Test | What it checks |
|
||||
|---|---|
|
||||
| `Import_ValidRegFile_WritesValuesToRegistry` | Round-trip with `RegistryExportUtility` |
|
||||
| `Import_WithDeletedKey_RemovesKey` | `-` prefix in `.reg` syntax |
|
||||
| `Import_WithMalformedFile_ThrowsOrReturnsError` | Error handling |
|
||||
| `Import_WithEmptyFile_DoesNotThrow` | Edge case |
|
||||
|
||||
---
|
||||
|
||||
### Priority 4 — Clients (require mock HTTP infrastructure)
|
||||
|
||||
All clients use `ApiRequestFactory` → `HttpClient`. The recommended approach is to create
|
||||
`ApiRequestFactory` with a custom `HttpClient` backed by `FakeHttpMessageHandler`, then
|
||||
construct the client under test with that factory.
|
||||
|
||||
---
|
||||
|
||||
#### `AuthenticationClient`
|
||||
**File:** `Clients/AuthenticationClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `AuthenticateAsync_WithValidCredentials_ReturnsToken` | POST `/api/Auth/Login` → 200 + `AuthToken` JSON | Token returned and stored |
|
||||
| `AuthenticateAsync_WithWrongPassword_ThrowsOrReturnsNull` | POST `/api/Auth/Login` → 401 | Failure handling |
|
||||
| `LogoutAsync_SendsDeleteRequest` | POST/DELETE `/api/Auth/Logout` → 200 | Request sent |
|
||||
| `RegisterAsync_WithValidData_Succeeds` | POST `/api/Auth/Register` → 200 | No exception |
|
||||
| `RegisterAsync_WithConflictingUsername_ThrowsOrReturns` | POST `/api/Auth/Register` → 409 | Conflict handling |
|
||||
| `ValidateTokenAsync_WithValidToken_ReturnsTrue` | GET `/api/Auth/Validate` → 200 | True returned |
|
||||
| `ValidateTokenAsync_WithExpiredToken_ReturnsFalse` | GET `/api/Auth/Validate` → 401 | False returned |
|
||||
| `GetAuthenticationProvidersAsync_ReturnsProviderList` | GET `/api/Auth/GetAuthenticationProviders` → provider JSON | List deserialized |
|
||||
| `GetAuthenticationProviderLoginUrl_WithProvider_BuildsCorrectUrl` | No HTTP needed | URI shape correct |
|
||||
|
||||
---
|
||||
|
||||
#### `GameClient` — metadata and static methods
|
||||
**File:** `Clients/GameClientTests.cs`
|
||||
|
||||
The `GameClient` is the most complex client (~1800 lines). Focus on the methods that do not
|
||||
orchestrate archive downloads, as those are better covered by integration tests.
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `GetAsync_ReturnsDeserializedGameList` | GET `/api/Games` → game list JSON | Correct deserialization |
|
||||
| `GetAsync_ById_ReturnsGame` | GET `/api/Games/{id}` → single game JSON | Single game returned |
|
||||
| `GetManifestAsync_ReturnsManifest` | GET `/api/Games/{id}/Manifest` → YAML | Manifest deserialized |
|
||||
| `GetAddonsAsync_ReturnsAddonList` | GET `/api/Games/{id}/Addons` → addon JSON | Addons listed |
|
||||
| `GetToolsAsync_ReturnsToolList` | GET `/api/Games/{id}/Tools` → tool JSON | Tools listed |
|
||||
| `CheckForUpdateAsync_WhenUpdateAvailable_ReturnsTrue` | GET `/api/Games/{id}/CheckForUpdate` → `{"updateAvailable":true}` | True returned |
|
||||
| `CheckForUpdateAsync_WhenUpToDate_ReturnsFalse` | GET `/api/Games/{id}/CheckForUpdate` → `{"updateAvailable":false}` | False returned |
|
||||
| `StartedAsync_SendsRequest` | GET `/api/Games/{id}/Started` → 200 | Request sent to correct URL |
|
||||
| `StoppedAsync_SendsRequest` | GET `/api/Games/{id}/Stopped` → 200 | Request sent to correct URL |
|
||||
| `GetMetadataDirectoryPath_ReturnsCorrectPath` | No HTTP | Path contains `.lancommander/{id}` |
|
||||
| `GetPlayerAlias_WhenFileAbsent_ReturnsEmpty` | No HTTP, temp dir | Empty string returned |
|
||||
| `UpdatePlayerAlias_WritesAliasFile` | No HTTP, temp dir | File written, alias readable |
|
||||
| `GetCurrentKey_WhenFileAbsent_ReturnsEmpty` | No HTTP, temp dir | Empty string returned |
|
||||
| `UpdateCurrentKey_WritesKeyFile` | No HTTP, temp dir | File written, key readable |
|
||||
|
||||
---
|
||||
|
||||
#### `DepotClient`
|
||||
**File:** `Clients/DepotClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `GetAsync_ReturnsDepotResults` | GET `/api/Depot` → results JSON | Deserialized correctly |
|
||||
| `GetGameAsync_ReturnsDepotGame` | GET `/api/Depot/Games/{id}` → game JSON | Single game deserialized |
|
||||
| `GetGameAsync_WithServerError_Throws` | GET `/api/Depot/Games/{id}` → 500 | Exception propagated |
|
||||
|
||||
---
|
||||
|
||||
#### `LibraryClient`
|
||||
**File:** `Clients/LibraryClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `GetAsync_ReturnsEntityReferences` | GET `/api/Library` → reference list JSON | List deserialized |
|
||||
| `AddToLibrary_WithValidGameId_ReturnsTrue` | POST `/api/Library/AddToLibrary/{id}` → `true` | True returned |
|
||||
| `RemoveFromLibrary_ById_ReturnsTrue` | POST `/api/Library/RemoveFromLibrary/{id}` → `true` | True returned |
|
||||
| `RemoveFromLibrary_WithAddonIds_SendsAddonList` | POST `/api/Library/RemoveFromLibrary/{id}/addons` | Addon IDs in request body |
|
||||
|
||||
---
|
||||
|
||||
#### `TagClient`
|
||||
**File:** `Clients/TagClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `CreateAsync_SendsTagAndReturnsCreated` | POST `/api/Tags` → created tag JSON | Returned tag has correct fields |
|
||||
| `UpdateAsync_SendsUpdatedTag` | POST `/api/Tags/{id}` → updated tag JSON | Request routed correctly |
|
||||
| `DeleteAsync_SendsDeleteRequest` | DELETE `/api/Tags/{id}` → 200 | Delete request sent |
|
||||
|
||||
---
|
||||
|
||||
#### `PlaySessionClient`
|
||||
**File:** `Clients/PlaySessionClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `GetAsync_ReturnsSessions` | GET `/api/PlaySessions` → session list JSON | List deserialized |
|
||||
| `GetAsync_ByGameId_ReturnsGameSessions` | GET `/api/PlaySessions/{id}` → session list JSON | Filtered sessions returned |
|
||||
|
||||
---
|
||||
|
||||
#### `ProfileClient`
|
||||
**File:** `Clients/ProfileClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `GetAsync_ReturnsUser` | GET `/api/Profile` → user JSON | User deserialized |
|
||||
| `GetAsync_CalledTwice_UsesCachedResult` | GET `/api/Profile` → 200 (once) | Only one HTTP call made |
|
||||
| `GetAsync_WithForceLoad_BypassesCache` | GET `/api/Profile` → 200 (twice) | Two HTTP calls made |
|
||||
| `GetAliasAsync_ReturnsAliasFromUser` | GET `/api/Profile` → user JSON | Alias extracted from user object |
|
||||
| `ChangeAliasAsync_SendsPutRequest` | PUT `/api/Profile/ChangeAlias` → new alias string | Alias value in response |
|
||||
| `GetCustomFieldAsync_ReturnsFieldValue` | GET `/api/Profile/CustomField/{name}` → value string | Value returned |
|
||||
| `UpdateCustomFieldAsync_SendsNewValue` | PUT `/api/Profile/CustomField/{name}` → value | Request body matches value |
|
||||
|
||||
---
|
||||
|
||||
#### `MediaClient` — network methods
|
||||
**File:** `Clients/MediaClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `GetAsync_ReturnsMediaObject` | GET `/api/Media/{id}` → media JSON | Deserialized correctly |
|
||||
| `DownloadAsync_WritesFileToDestination` | GET → binary stream | File exists at destination path |
|
||||
| `DownloadAsync_WhenDestinationDirectoryAbsent_CreatesIt` | GET → binary stream | Directory auto-created |
|
||||
| `GetStaleLocalPaths_WhenNoFilesExist_ReturnsEmpty` | No HTTP, temp dir | Empty result |
|
||||
| `GetStaleLocalPaths_WhenOldVersionsExist_ReturnsThem` | No HTTP, temp dir | Stale files enumerated |
|
||||
| `CalculateChecksumAsync_KnownContent_ReturnsDeterministicCrc32` | No HTTP, temp file | Same file → same CRC |
|
||||
|
||||
---
|
||||
|
||||
#### `IssueClient`
|
||||
**File:** `Clients/IssueClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `Open_WithValidIssue_ReturnsTrueOnSuccess` | POST `/api/Issue/Open` → `true` | True returned |
|
||||
| `Open_WithServerError_ReturnsFalse` | POST `/api/Issue/Open` → 500 | False returned or exception |
|
||||
|
||||
---
|
||||
|
||||
#### `LauncherClient`
|
||||
**File:** `Clients/LauncherClientTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `CheckForUpdateAsync_WhenUpdateAvailable_ReturnsResponse` | GET `/api/Launcher/CheckForUpdate` → JSON | Response deserialized |
|
||||
| `DownloadAsync_WritesFileToGivenPath` | GET → binary stream | File written to destination |
|
||||
|
||||
---
|
||||
|
||||
#### `ConnectionClient` — pure-computation methods
|
||||
**File:** `Clients/ConnectionClientTests.cs`
|
||||
|
||||
Focus on the methods that do not require a running server:
|
||||
|
||||
| Test | Setup | What it checks |
|
||||
|---|---|---|
|
||||
| `IsConnected_WhenNotYetConnected_ReturnsFalse` | Fresh instance | False by default |
|
||||
| `IsConfigured_WhenServerAddressSet_ReturnsTrue` | Mock `ISettingsProvider` with address | True when configured |
|
||||
| `IsConfigured_WhenNoAddress_ReturnsFalse` | Mock `ISettingsProvider` with empty address | False without address |
|
||||
| `HasServerAddress_WhenAddressSet_ReturnsTrue` | Mock provider | True |
|
||||
| `IsOfflineMode_WhenOfflineModeEnabled_ReturnsTrue` | Mock provider offline setting | True |
|
||||
| `PingAsync_WithSuccessfulResponse_ReturnsTrue` | HTTP HEAD → custom X-Pong header | True returned |
|
||||
| `PingAsync_WithTimeout_ReturnsFalse` | HTTP HEAD → timeout | False returned without throw |
|
||||
|
||||
---
|
||||
|
||||
#### `SaveClient` — pure-computation methods
|
||||
**File:** `Clients/SaveClientTests.cs` *(extend existing file)*
|
||||
|
||||
The existing `SaveService.cs` tests cover `GetLocalPath`, `GetArchivePath`, and
|
||||
`GetFileSavePathEntries`. The following are the remaining gaps:
|
||||
|
||||
| Test | Setup | What it checks |
|
||||
|---|---|---|
|
||||
| `PackAsync_WithManifestAndSavePaths_ProducesZip` | Temp dir + manifest | Zip contains expected entries |
|
||||
| `GetLocalPath_WithInstallDir_ReturnsInstallPath` | No I/O | `{InstallDir}` expanded correctly |
|
||||
| `GetLocalPath_WithMyDocuments_ExpandsToActualPath` | No I/O | Special folder expansion |
|
||||
| `GetArchivePath_StripsInstallDirPrefix` | No I/O | Archive-relative path returned |
|
||||
| `GetFileSavePathEntries_WithDirectorySavePath_ReturnsAllFiles` | Temp dir with files | All files enumerated |
|
||||
| `GetFileSavePathEntries_WithRegexSavePath_OnlyMatchingReturned` | Temp dir with mixed files | Non-matching excluded |
|
||||
|
||||
---
|
||||
|
||||
#### `BeaconClient` — unit-testable subset
|
||||
**File:** `Clients/BeaconClientTests.cs`
|
||||
|
||||
The UDP broadcast and socket operations require a real network or a UDP socket mock, making
|
||||
full integration tests impractical for unit tests. Focus on the fluent API and configuration.
|
||||
|
||||
| Test | Setup | What it checks |
|
||||
|---|---|---|
|
||||
| `AddBeaconMessageInterceptor_ReturnsClientInstance` | No network | Fluent return value |
|
||||
| `AddBeaconMessageInterceptor_InterceptorIsCalledOnMessage` | No network, mock interceptor | Interceptor receives message |
|
||||
| `CleanupProbe_WhenNotStarted_DoesNotThrow` | No network | Safe on unused instance |
|
||||
| `StopProbeAsync_WhenNotRunning_DoesNotThrow` | No network | Safe on unused instance |
|
||||
| `StopBeaconAsync_WhenNotRunning_DoesNotThrow` | No network | Safe on unused instance |
|
||||
|
||||
---
|
||||
|
||||
### Priority 5 — Models with non-trivial logic
|
||||
|
||||
---
|
||||
|
||||
#### `InstallProgress`
|
||||
**File:** `Install/InstallProgressTests.cs` *(already covered, adding edge cases)*
|
||||
|
||||
| Test | What it checks |
|
||||
|---|---|
|
||||
| `Progress_WhenTotalBytesIsZero_ReturnsNaN` | Division by zero → `float.NaN` *(already exists)* |
|
||||
| `Progress_WhenBytesExceedTotal_ReturnsGreaterThanOne` | Over-transfer edge case |
|
||||
| `Progress_WhenTotalIsNegative_BehavesConsistently` | Negative total |
|
||||
|
||||
---
|
||||
|
||||
#### `ChatThread`
|
||||
**File:** `Models/ChatThreadTests.cs`
|
||||
|
||||
`ChatThread` has observable message collections and async event callbacks.
|
||||
|
||||
| Test | What it checks |
|
||||
|---|---|
|
||||
| `AddMessage_IncreasesMessageCount` | Message added to collection |
|
||||
| `AddMessage_FiresMessagesReceivedAsync_IfSubscribed` | Callback invoked |
|
||||
| `Messages_InitiallyEmpty` | Default state |
|
||||
| `Typing_InitiallyEmpty` | Default state for typing indicators |
|
||||
|
||||
---
|
||||
|
||||
### Priority 6 — `ApiRequestBuilder`
|
||||
|
||||
`ApiRequestBuilder` is the common HTTP plumbing used by all clients. Testing it in isolation
|
||||
provides coverage of the serialization, header injection, and progress-reporting logic that all
|
||||
clients share.
|
||||
|
||||
**File:** `Helpers/ApiRequestBuilderTests.cs`
|
||||
|
||||
| Test | HTTP mock | What it checks |
|
||||
|---|---|---|
|
||||
| `GetAsync_SendsGetRequest_ToConfiguredRoute` | Any 200 | Method is GET, URL matches route |
|
||||
| `PostAsync_SendsJsonBody` | Capture body, return 200 | Request body deserialized matches input |
|
||||
| `PutAsync_SendsJsonBody` | Capture body, return 200 | Method is PUT, body correct |
|
||||
| `DeleteAsync_SendsDeleteRequest` | Any 200 | Method is DELETE |
|
||||
| `HeadAsync_SendsHeadRequest` | Any 200 | Method is HEAD |
|
||||
| `UseAuthenticationToken_AddsAuthorizationHeader` | Capture headers | `Authorization: Bearer <token>` present |
|
||||
| `UseVersioning_AddsVersionHeader` | Capture headers | Custom version header present |
|
||||
| `AddHeader_AddsCustomHeader` | Capture headers | Header value matches |
|
||||
| `SetTimeout_OverridesDefault` | Delayed response | Request cancelled after timeout |
|
||||
| `OnProgress_CalledDuringDownload` | Streaming binary | Progress callback fires |
|
||||
| `OnComplete_CalledAfterDownload` | Any 200 | Completion callback fires |
|
||||
| `SendAsync_On4xx_ThrowsOrReturnsError` | 404 response | Handled consistently |
|
||||
| `SendAsync_On5xx_ThrowsOrPropagates` | 500 response | Error propagated |
|
||||
| `DownloadAsync_WritesResponseToFile` | Binary stream response | File written at path |
|
||||
| `UploadAsync_SendsFileAsMultipart` | Capture request | Content-Type is multipart |
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Tests
|
||||
|
||||
Tests that only run on a specific platform should use `Skip` to self-document why they
|
||||
are not running rather than silently passing.
|
||||
|
||||
```csharp
|
||||
[Fact(Skip = "Windows-only: requires P/Invoke to ntdll.dll")]
|
||||
public void GetParentProcessId_ReturnsParentPid() { ... }
|
||||
```
|
||||
|
||||
### Windows-only
|
||||
- `RegistryExportUtilityTests` (entire file)
|
||||
- `RegistryImportUtilityTests` (entire file)
|
||||
- `DisplayHelper.GetDeviceMode()` tests
|
||||
- `ProcessHelper.GetParentProcessId()` tests (internal)
|
||||
- `LobbyClient.GetSteamLobbies()` — also requires Steam runtime
|
||||
|
||||
### Linux-only
|
||||
- `DisplayHelper` xrandr/xdpyinfo/drm parsing tests
|
||||
|
||||
### Skipped (require real processes or sockets)
|
||||
- `ProcessExtensions.WaitForAllExitAsync()` — needs real spawned process, timing-sensitive
|
||||
- `BeaconClient.StartProbeAsync()` / `StartBeaconAsync()` — needs real UDP sockets
|
||||
|
||||
---
|
||||
|
||||
## Pre-existing Issues (do not create new tests for these)
|
||||
|
||||
The following failures exist in the test suite and should be addressed in the SDK itself
|
||||
before adding further test coverage that would interact with them.
|
||||
|
||||
| Area | Root cause |
|
||||
|---|---|
|
||||
| `SaveDownloadTests` / `SaveUploadTests` | `DeflateEnvironmentVariables` throws on Linux when env vars like `LOCALAPPDATA` are null; `Regex.Escape(null)` throws `ArgumentNullException` |
|
||||
| `SaveClientTests.SimpleInstallDirectorySavePathsShouldWork` | Same root cause |
|
||||
| `SavePackerTests` (several) | Depends on `GetFileSavePathEntries` which calls `DeflateEnvironmentVariables` |
|
||||
| `StringExtensions.cs` (ExpandEnvironmentVariables theory entries) | Tests use Windows-specific paths and env vars; results differ on Linux |
|
||||
|
||||
The fix is a null-guard in `StringExtensions.DeflateEnvironmentVariables` before calling
|
||||
`Regex.Escape`:
|
||||
|
||||
```csharp
|
||||
if (string.IsNullOrEmpty(value)) continue;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Fix the `DeflateEnvironmentVariables` null-guard** — unblocks ~30 currently-failing tests
|
||||
2. **`VersionHelper`, `IniHelper` gap-fill** — pure computation, no setup required
|
||||
3. **`ApiRequestBuilder` with `FakeHttpMessageHandler`** — builds the HTTP test infrastructure
|
||||
4. **Small pure-HTTP clients** (`TagClient`, `PlaySessionClient`, `IssueClient`, `DepotClient`) — simple CRUD, same infrastructure
|
||||
5. **`ProfileClient`, `LibraryClient`, `MediaClient`** — moderate complexity
|
||||
6. **`AuthenticationClient`** — auth token flow
|
||||
7. **`ConnectionClient`** — server health and configuration
|
||||
8. **`GameClient`** metadata methods — largest client, split into multiple test files
|
||||
9. **`EnvironmentHelper`** with file-system injection
|
||||
10. **`DisplayHelper`** parsing extraction + tests
|
||||
11. **`RegistryExportUtility` / `RegistryImportUtility`** — Windows CI job
|
||||
Loading…
Add table
Add a link
Reference in a new issue