diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index c4ba6e5143..6ec071be2f 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -9,7 +9,7 @@
]
},
"nvika": {
- "version": "3.0.0",
+ "version": "4.0.0",
"commands": [
"nvika"
]
diff --git a/.github/workflows/_diffcalc_processor.yml b/.github/workflows/_diffcalc_processor.yml
index 4e221d0550..2f1b2cf893 100644
--- a/.github/workflows/_diffcalc_processor.yml
+++ b/.github/workflows/_diffcalc_processor.yml
@@ -36,7 +36,7 @@ jobs:
generator:
name: Run
runs-on: self-hosted
- timeout-minutes: 720
+ timeout-minutes: 1440
outputs:
target: ${{ steps.run.outputs.target }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d8645d728e..610648cfe4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -82,8 +82,18 @@ jobs:
run: dotnet build -c Debug -warnaserror osu.Desktop.slnf
- name: Test
- run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" -- NUnit.ConsoleOut=0
- shell: pwsh
+ run: >
+ dotnet test
+ osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll
+ osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll
+ osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll
+ osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll
+ osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll
+ osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll
+ Templates/**/*.Tests/bin/Debug/**/*.Tests.dll
+ --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx"
+ --
+ NUnit.ConsoleOut=0
# Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
@@ -114,17 +124,14 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET workloads
- # since windows image 20241113.3.0, not specifying a version here
- # installs the .NET 7 version of android workload for very unknown reasons.
- # revisit once we upgrade to .NET 9, it's probably fixed there.
- run: dotnet workload install android --version (dotnet --version)
+ run: dotnet workload install android
- name: Compile
run: dotnet build -c Debug osu.Android.slnf
build-only-ios:
name: Build only (iOS)
- runs-on: macos-latest
+ runs-on: macos-15
timeout-minutes: 60
steps:
- name: Checkout
@@ -136,7 +143,14 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET Workloads
- run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json
+ run: dotnet workload install ios
+
+ # https://github.com/dotnet/macios/issues/19157
+ # https://github.com/actions/runner-images/issues/12758
+ - name: Use Xcode 16.4
+ run: |
+ sudo xcode-select -switch /Applications/Xcode_16.4.app
+ xcodebuild -downloadPlatform iOS
- name: Build
- run: dotnet build -c Debug osu.iOS
+ run: dotnet build -c Debug osu.iOS.slnf
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000000..1a921b21ae
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,87 @@
+name: Pack and nuget
+
+on:
+ push:
+ tags:
+ - '*'
+
+jobs:
+ notify_pending_production_deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Submit pending deployment notification
+ run: |
+ export TITLE="Pending osu Production Deployment: $GITHUB_REF_NAME"
+ export URL="https://github.com/ppy/osu/actions/runs/$GITHUB_RUN_ID"
+ export DESCRIPTION="Awaiting approval for building NuGet packages for tag $GITHUB_REF_NAME:
+ [View Workflow Run]($URL)"
+ export ACTOR_ICON="https://avatars.githubusercontent.com/u/$GITHUB_ACTOR_ID"
+
+ BODY="$(jq --null-input '{
+ "embeds": [
+ {
+ "title": env.TITLE,
+ "color": 15098112,
+ "description": env.DESCRIPTION,
+ "url": env.URL,
+ "author": {
+ "name": env.GITHUB_ACTOR,
+ "icon_url": env.ACTOR_ICON
+ }
+ }
+ ]
+ }')"
+
+ curl \
+ -H "Content-Type: application/json" \
+ -d "$BODY" \
+ "${{ secrets.DISCORD_INFRA_WEBHOOK_URL }}"
+
+ pack:
+ name: Pack
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set artifacts directory
+ id: artifactsPath
+ run: echo "::set-output name=nuget_artifacts::${{github.workspace}}/artifacts"
+
+ - name: Install .NET 8.0.x
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
+ - name: Pack
+ run: |
+ # Replace project references in templates with package reference, because they're included as source files.
+ dotnet remove Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game/osu.Game.csproj
+ dotnet remove Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj
+ dotnet remove Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game/osu.Game.csproj
+ dotnet remove Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj
+
+ dotnet add Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
+ dotnet add Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
+ dotnet add Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
+ dotnet add Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
+
+ # Pack
+ dotnet pack -c Release osu.Game /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
+ dotnet pack -c Release osu.Game.Rulesets.Osu /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
+ dotnet pack -c Release osu.Game.Rulesets.Taiko /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
+ dotnet pack -c Release osu.Game.Rulesets.Catch /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
+ dotnet pack -c Release osu.Game.Rulesets.Mania /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
+ dotnet pack -c Release Templates /p:Version=${{ github.ref_name }} -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: osu
+ path: |
+ ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg
+ ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.snupkg
+
+ - name: Publish packages to nuget.org
+ run: dotnet nuget push ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json
diff --git a/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml b/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000000..4432459b86
--- /dev/null
+++ b/.idea/.idea.osu.Android/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt
index 550f7c8e11..58f281a01d 100644
--- a/CodeAnalysis/BannedSymbols.txt
+++ b/CodeAnalysis/BannedSymbols.txt
@@ -18,3 +18,10 @@ M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize(
M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead.
M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead.
M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToKebabCase() instead.
+M:osuTK.MathHelper.Clamp(System.Int32,System.Int32,System.Int32)~System.Int32;Use Math.Clamp() instead.
+M:osuTK.MathHelper.Clamp(System.Single,System.Single,System.Single)~System.Single;This osuTK helper has unsafe semantics when one of the bounds provided is NaN. Use Math.Clamp() instead.
+M:osuTK.MathHelper.Clamp(System.Double,System.Double,System.Double)~System.Double;This osuTK helper has unsafe semantics when one of the bounds provided is NaN. Use Math.Clamp() instead.
+M:TagLib.File.Create(System.String);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead.
+M:TagLib.File.Create(TagLib.File.IFileAbstraction);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead.
+M:TagLib.File.Create(System.String,TagLib.ReadStyle);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead.
+M:TagLib.File.Create(TagLib.File.IFileAbstraction,TagLib.ReadStyle);TagLib's MIME type detection changes behaviour depending on CultureInfo.CurrentCulture. Use TagLibUtils.GetTagLibFile() instead.
diff --git a/CodeAnalysis/osu.globalconfig b/CodeAnalysis/osu.globalconfig
index 247a825033..8012c31eca 100644
--- a/CodeAnalysis/osu.globalconfig
+++ b/CodeAnalysis/osu.globalconfig
@@ -51,8 +51,11 @@ dotnet_diagnostic.IDE1006.severity = warning
# Too many noisy warnings for parsing/formatting numbers
dotnet_diagnostic.CA1305.severity = none
+# messagepack complains about "osu" not being title cased due to reserved words
+dotnet_diagnostic.CS8981.severity = none
+
# CA1507: Use nameof to express symbol names
-# Flaggs serialization name attributes
+# Flags serialization name attributes
dotnet_diagnostic.CA1507.severity = suggestion
# CA1806: Do not ignore method results
diff --git a/Directory.Build.props b/Directory.Build.props
index 3acb86ee0c..a856825d87 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,6 +3,10 @@
12.0
enable
+
+ false
+
+ $(NoWarn);CA1416
$(MSBuildThisFileDirectory)app.manifest
@@ -46,7 +50,7 @@
https://github.com/ppy/osu
Automated release.
ppy Pty Ltd
- Copyright (c) 2024 ppy Pty Ltd
+ Copyright (c) 2025 ppy Pty Ltd
osu game
diff --git a/LICENCE b/LICENCE
index 3bb8b62d5d..9ffcc70c13 100644
--- a/LICENCE
+++ b/LICENCE
@@ -1,4 +1,4 @@
-Copyright (c) 2024 ppy Pty Ltd .
+Copyright (c) 2025 ppy Pty Ltd .
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 6043497181..d87ca31f72 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ You can also generally download a version for your current device from the [osu!
If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below.
-**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores in early 2024.
+**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon so we don't have to live with this limitation.
## Developing a custom ruleset
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
index f77cda1533..86f73a37d4 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
@@ -9,9 +9,9 @@
false
-
+
-
+
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs
index 9cd18d2d9f..0699f5d039 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs
@@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.EmptyFreeform.Objects
public Vector2 Position { get; set; }
- public float X => Position.X;
- public float Y => Position.Y;
+ public float X
+ {
+ get => Position.X;
+ set => Position = new Vector2(value, Y);
+ }
+
+ public float Y
+ {
+ get => Position.Y;
+ set => Position = new Vector2(X, value);
+ }
}
}
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs
index c84101ca70..c6be5d6861 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
+using System.Linq;
using osu.Game.Rulesets.Replays;
using osuTK;
@@ -17,5 +18,8 @@ namespace osu.Game.Rulesets.EmptyFreeform.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
+
+ public override bool IsEquivalentTo(ReplayFrame other)
+ => other is EmptyFreeformReplayFrame freeformFrame && Time == freeformFrame.Time && Position == freeformFrame.Position && Actions.SequenceEqual(freeformFrame.Actions);
}
}
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
index 47cabaddb1..51c0233942 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
@@ -9,9 +9,9 @@
false
-
+
-
+
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs
index 0c22554e82..f938d26b26 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs
@@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.Pippidon.Objects
public Vector2 Position { get; set; }
- public float X => Position.X;
- public float Y => Position.Y;
+ public float X
+ {
+ get => Position.X;
+ set => Position = new Vector2(value, Y);
+ }
+
+ public float Y
+ {
+ get => Position.Y;
+ set => Position = new Vector2(X, value);
+ }
}
}
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs
index 949ca160be..c434b62257 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs
@@ -9,5 +9,8 @@ namespace osu.Game.Rulesets.Pippidon.Replays
public class PippidonReplayFrame : ReplayFrame
{
public Vector2 Position;
+
+ public override bool IsEquivalentTo(ReplayFrame other)
+ => other is PippidonReplayFrame pippidonFrame && Time == pippidonFrame.Time && Position == pippidonFrame.Position;
}
}
diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
index a7d62291d0..ed4e8631ea 100644
--- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
+++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
@@ -9,9 +9,9 @@
false
-
+
-
+
diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs
index 2f19cffd2a..722eff6f05 100644
--- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs
+++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
+using System.Linq;
using osu.Game.Rulesets.Replays;
namespace osu.Game.Rulesets.EmptyScrolling.Replays
@@ -15,5 +16,8 @@ namespace osu.Game.Rulesets.EmptyScrolling.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
+
+ public override bool IsEquivalentTo(ReplayFrame other)
+ => other is EmptyScrollingReplayFrame scrollingFrame && Time == scrollingFrame.Time && Actions.SequenceEqual(scrollingFrame.Actions);
}
}
diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
index 47cabaddb1..51c0233942 100644
--- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
+++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
@@ -9,9 +9,9 @@
false
-
+
-
+
diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs
index 0a4fa84ce1..dd8337abee 100644
--- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs
+++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -9,7 +10,6 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Pippidon.Objects;
using osu.Game.Rulesets.Pippidon.UI;
-using osuTK;
namespace osu.Game.Rulesets.Pippidon.Beatmaps
{
@@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Pippidon.Beatmaps
};
}
- private int getLane(HitObject hitObject) => (int)MathHelper.Clamp(
+ private int getLane(HitObject hitObject) => (int)Math.Clamp(
(getUsablePosition(hitObject) - minPosition) / (maxPosition - minPosition) * PippidonPlayfield.LANE_COUNT, 0, PippidonPlayfield.LANE_COUNT - 1);
private float getUsablePosition(HitObject h) => (h as IHasYPosition)?.Y ?? ((IHasXPosition)h).X;
diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs
index 468ac9c725..c8df06f6d7 100644
--- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs
+++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
+using System.Linq;
using osu.Game.Rulesets.Replays;
namespace osu.Game.Rulesets.Pippidon.Replays
@@ -15,5 +16,8 @@ namespace osu.Game.Rulesets.Pippidon.Replays
if (button.HasValue)
Actions.Add(button.Value);
}
+
+ public override bool IsEquivalentTo(ReplayFrame other)
+ => other is PippidonReplayFrame pippidonFrame && Time == pippidonFrame.Time && Actions.SequenceEqual(pippidonFrame.Actions);
}
}
diff --git a/Templates/osu.Game.Templates.csproj b/Templates/osu.Game.Templates.csproj
index 186a6093f5..ecac2e4794 100644
--- a/Templates/osu.Game.Templates.csproj
+++ b/Templates/osu.Game.Templates.csproj
@@ -8,7 +8,7 @@
https://github.com/ppy/osu/blob/master/Templates
https://github.com/ppy/osu
Automated release.
- Copyright (c) 2024 ppy Pty Ltd
+ Copyright (c) 2025 ppy Pty Ltd
Templates to use when creating a ruleset for consumption in osu!.
dotnet-new;templates;osu
netstandard2.1
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index ed48a997e8..0000000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-clone_depth: 1
-version: '{branch}-{build}'
-image: Visual Studio 2022
-cache:
- - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml'
-
-dotnet_csproj:
- patch: true
- file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects
- version: '0.0.{build}'
-
-before_build:
- - cmd: dotnet --info # Useful when version mismatch between CI and local
- - cmd: dotnet workload install maui-android # Change to `dotnet workload restore` once there's no old projects
- - cmd: dotnet workload install maui-ios # Change to `dotnet workload restore` once there's no old projects
- - cmd: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects
-
-build:
- project: osu.sln
- parallel: true
- verbosity: minimal
- publish_nuget: true
-
-after_build:
- - ps: .\InspectCode.ps1
-
-test:
- assemblies:
- except:
- - '**\*Android*'
- - '**\*iOS*'
- - 'build\**\*'
diff --git a/appveyor_deploy.yml b/appveyor_deploy.yml
deleted file mode 100644
index 175c8d0f1b..0000000000
--- a/appveyor_deploy.yml
+++ /dev/null
@@ -1,86 +0,0 @@
-clone_depth: 1
-version: '{build}'
-image: Visual Studio 2022
-test: off
-skip_non_tags: true
-configuration: Release
-
-environment:
- matrix:
- - job_name: osu-game
- - job_name: osu-ruleset
- job_depends_on: osu-game
- - job_name: taiko-ruleset
- job_depends_on: osu-game
- - job_name: catch-ruleset
- job_depends_on: osu-game
- - job_name: mania-ruleset
- job_depends_on: osu-game
- - job_name: templates
- job_depends_on: osu-game
-
-nuget:
- project_feed: true
-
-for:
- -
- matrix:
- only:
- - job_name: osu-game
- build_script:
- - cmd: dotnet pack osu.Game\osu.Game.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
- -
- matrix:
- only:
- - job_name: osu-ruleset
- build_script:
- - cmd: dotnet remove osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj reference osu.Game\osu.Game.csproj
- - cmd: dotnet add osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- - cmd: dotnet pack osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
- -
- matrix:
- only:
- - job_name: taiko-ruleset
- build_script:
- - cmd: dotnet remove osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj reference osu.Game\osu.Game.csproj
- - cmd: dotnet add osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- - cmd: dotnet pack osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
- -
- matrix:
- only:
- - job_name: catch-ruleset
- build_script:
- - cmd: dotnet remove osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj reference osu.Game\osu.Game.csproj
- - cmd: dotnet add osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- - cmd: dotnet pack osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
- -
- matrix:
- only:
- - job_name: mania-ruleset
- build_script:
- - cmd: dotnet remove osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj reference osu.Game\osu.Game.csproj
- - cmd: dotnet add osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- - cmd: dotnet pack osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
- -
- matrix:
- only:
- - job_name: templates
- build_script:
- - cmd: dotnet remove Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game\osu.Game.csproj
- - cmd: dotnet remove Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj
- - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game\osu.Game.csproj
- - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj
-
- - cmd: dotnet add Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- - cmd: dotnet add Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
-
- - cmd: dotnet pack Templates\osu.Game.Templates.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
-artifacts:
- - path: '**\*.nupkg'
-
-deploy:
- - provider: Environment
- name: nuget
diff --git a/osu.Android.props b/osu.Android.props
index 632325725a..010413a869 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -10,7 +10,7 @@
true
-
+
+
+ %(RecursiveDir)%(Filename)%(Extension)
+ iOS\%(RecursiveDir)%(Filename)%(Extension)
+
@@ -20,6 +29,7 @@
+
diff --git a/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs b/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs
new file mode 100644
index 0000000000..1dda2e314d
--- /dev/null
+++ b/osu.Game.Tests/Beatmaps/BeatmapExtensionsTest.cs
@@ -0,0 +1,58 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Timing;
+using osu.Game.Rulesets.Osu.Objects;
+
+namespace osu.Game.Tests.Beatmaps
+{
+ public class BeatmapExtensionsTest
+ {
+ [Test]
+ public void TestLengthCalculations()
+ {
+ var beatmap = new Beatmap
+ {
+ HitObjects =
+ {
+ new HitCircle { StartTime = 5_000 },
+ new HitCircle { StartTime = 300_000 },
+ new Spinner { StartTime = 280_000, Duration = 40_000 }
+ },
+ Breaks =
+ {
+ new BreakPeriod(50_000, 75_000),
+ new BreakPeriod(100_000, 150_000),
+ }
+ };
+
+ Assert.That(beatmap.CalculatePlayableBounds(), Is.EqualTo((5_000, 320_000)));
+ Assert.That(beatmap.CalculatePlayableLength(), Is.EqualTo(315_000)); // 320_000 - 5_000
+ Assert.That(beatmap.CalculateDrainLength(), Is.EqualTo(240_000)); // 315_000 - (25_000 + 50_000) = 315_000 - 75_000
+ }
+
+ [Test]
+ public void TestDrainLengthCannotGoNegative()
+ {
+ var beatmap = new Beatmap
+ {
+ HitObjects =
+ {
+ new HitCircle { StartTime = 5_000 },
+ new HitCircle { StartTime = 300_000 },
+ new Spinner { StartTime = 280_000, Duration = 40_000 }
+ },
+ Breaks =
+ {
+ new BreakPeriod(0, 350_000),
+ }
+ };
+
+ Assert.That(beatmap.CalculatePlayableBounds(), Is.EqualTo((5_000, 320_000)));
+ Assert.That(beatmap.CalculatePlayableLength(), Is.EqualTo(315_000)); // 320_000 - 5_000
+ Assert.That(beatmap.CalculateDrainLength(), Is.EqualTo(0)); // break period encompasses entire beatmap
+ }
+ }
+}
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
index adb1755c11..916e1e757a 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
@@ -42,9 +42,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
var decoder = Decoder.GetDecoder(stream);
var working = new TestWorkingBeatmap(decoder.Decode(stream));
- Assert.AreEqual(6, working.BeatmapInfo.BeatmapVersion);
- Assert.AreEqual(6, working.Beatmap.BeatmapInfo.BeatmapVersion);
- Assert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapInfo.BeatmapVersion);
+ Assert.AreEqual(6, working.Beatmap.BeatmapVersion);
+ Assert.That(working.Beatmap.BeatmapInfo.Ruleset.Name, Is.Not.EqualTo("null placeholder ruleset"));
+ Assert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion);
}
}
@@ -59,9 +59,8 @@ namespace osu.Game.Tests.Beatmaps.Formats
((LegacyBeatmapDecoder)decoder).ApplyOffsets = applyOffsets;
var working = new TestWorkingBeatmap(decoder.Decode(stream));
- Assert.AreEqual(4, working.BeatmapInfo.BeatmapVersion);
- Assert.AreEqual(4, working.Beatmap.BeatmapInfo.BeatmapVersion);
- Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapInfo.BeatmapVersion);
+ Assert.AreEqual(4, working.Beatmap.BeatmapVersion);
+ Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()).BeatmapVersion);
Assert.AreEqual(-1, working.BeatmapInfo.Metadata.PreviewTime);
}
@@ -404,6 +403,35 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
+ [Test]
+ public void TestComboColourCountIsLimitedToEight()
+ {
+ var decoder = new LegacySkinDecoder();
+
+ using (var resStream = TestResources.OpenResource("too-many-combo-colours.osu"))
+ using (var stream = new LineBufferedReader(resStream))
+ {
+ var comboColors = decoder.Decode(stream).ComboColours;
+
+ Debug.Assert(comboColors != null);
+
+ Color4[] expectedColors =
+ {
+ new Color4(142, 199, 255, 255),
+ new Color4(255, 128, 128, 255),
+ new Color4(128, 255, 255, 255),
+ new Color4(128, 255, 128, 255),
+ new Color4(255, 187, 255, 255),
+ new Color4(255, 177, 140, 255),
+ new Color4(100, 100, 100, 255),
+ new Color4(142, 199, 255, 255),
+ };
+ Assert.AreEqual(expectedColors.Length, comboColors.Count);
+ for (int i = 0; i < expectedColors.Length; i++)
+ Assert.AreEqual(expectedColors[i], comboColors[i]);
+ }
+ }
+
[Test]
public void TestGetLastObjectTime()
{
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
index c8a09786ec..e27146a86f 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
@@ -28,6 +28,7 @@ using osu.Game.Skinning;
using osu.Game.Storyboards;
using osu.Game.Tests.Resources;
using osuTK;
+using osuTK.Graphics;
namespace osu.Game.Tests.Beatmaps.Formats
{
@@ -184,6 +185,57 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.That(decodedSlider.Path.ControlPoints.Count, Is.EqualTo(5));
}
+ [Test]
+ public void TestOnlyEightComboColoursEncoded()
+ {
+ var beatmapSkin = new LegacyBeatmapSkin(new BeatmapInfo(), null)
+ {
+ Configuration =
+ {
+ CustomComboColours =
+ {
+ new Color4(1, 1, 1, 255),
+ new Color4(2, 2, 2, 255),
+ new Color4(3, 3, 3, 255),
+ new Color4(4, 4, 4, 255),
+ new Color4(5, 5, 5, 255),
+ new Color4(6, 6, 6, 255),
+ new Color4(7, 7, 7, 255),
+ new Color4(8, 8, 8, 255),
+ new Color4(9, 9, 9, 255),
+ }
+ }
+ };
+
+ var decodedAfterEncode = decodeFromLegacy(encodeToLegacy((new Beatmap(), beatmapSkin)), string.Empty);
+ Assert.That(decodedAfterEncode.skin.Configuration.CustomComboColours, Has.Count.EqualTo(8));
+ }
+
+ [Test]
+ public void TestEncodeStabilityOfSliderWithFractionalCoordinates()
+ {
+ Slider originalSlider = new Slider
+ {
+ Position = new Vector2(0.6f),
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(Vector2.Zero, PathType.PERFECT_CURVE),
+ new PathControlPoint(new Vector2(25.6f, 78.4f)),
+ new PathControlPoint(new Vector2(55.8f, 34.2f)),
+ })
+ };
+ var beatmap = new Beatmap
+ {
+ HitObjects = { originalSlider }
+ };
+
+ var encoded = encodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty)));
+ var decodedAfterEncode = decodeFromLegacy(encoded, string.Empty, version: LegacyBeatmapEncoder.FIRST_LAZER_VERSION);
+ var decodedSlider = (Slider)decodedAfterEncode.beatmap.HitObjects[0];
+ Assert.That(decodedSlider.Path.ControlPoints.Select(p => p.Position),
+ Is.EquivalentTo(originalSlider.Path.ControlPoints.Select(p => p.Position)));
+ }
+
private bool areComboColoursEqual(IHasComboColours a, IHasComboColours b)
{
// equal to null, no need to SequenceEqual
@@ -206,12 +258,14 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
- private (IBeatmap beatmap, TestLegacySkin skin) decodeFromLegacy(Stream stream, string name)
+ private (IBeatmap beatmap, TestLegacySkin skin) decodeFromLegacy(Stream stream, string name, int version = LegacyDecoder.LATEST_VERSION)
{
using (var reader = new LineBufferedReader(stream))
{
- var beatmap = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(reader);
+ var beatmap = new LegacyBeatmapDecoder(version) { ApplyOffsets = false }.Decode(reader);
var beatmapSkin = new TestLegacySkin(beatmaps_resource_store, name);
+ stream.Seek(0, SeekOrigin.Begin);
+ beatmapSkin.Configuration = new LegacySkinDecoder().Decode(reader);
return (convert(beatmap), beatmapSkin);
}
}
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
index 713f2f3fb1..2815c9cd8f 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs
@@ -13,6 +13,7 @@ using osu.Framework.Extensions;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.Legacy;
+using osu.Game.Extensions;
using osu.Game.IO.Legacy;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Replays;
@@ -155,10 +156,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
var scoreInfo = TestResources.CreateTestScoreInfo(ruleset);
var beatmap = new TestBeatmap(ruleset)
{
- BeatmapInfo =
- {
- BeatmapVersion = beatmapVersion
- }
+ BeatmapVersion = beatmapVersion
};
var score = new Score
@@ -324,6 +322,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
CountryCode = CountryCode.PL
};
scoreInfo.ClientVersion = "2023.1221.0";
+ scoreInfo.Pauses.AddRange([111111, 222222, 333333]);
var beatmap = new TestBeatmap(ruleset);
var score = new Score
@@ -348,6 +347,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.That(decodedAfterEncode.ScoreInfo.Mods, Is.EqualTo(scoreInfo.Mods));
Assert.That(decodedAfterEncode.ScoreInfo.ClientVersion, Is.EqualTo("2023.1221.0"));
Assert.That(decodedAfterEncode.ScoreInfo.RealmUser.OnlineID, Is.EqualTo(3035836));
+ Assert.That(decodedAfterEncode.ScoreInfo.Pauses, Is.EquivalentTo(new[] { 111111, 222222, 333333 }));
});
}
@@ -633,14 +633,14 @@ namespace osu.Game.Tests.Beatmaps.Formats
MD5Hash = md5Hash,
Ruleset = new OsuRuleset().RulesetInfo,
Difficulty = new BeatmapDifficulty(),
- BeatmapVersion = beatmapVersion,
},
- // needs to have at least one objects so that `StandardisedScoreMigrationTools` doesn't die
+ // needs to have at least one object so that `StandardisedScoreMigrationTools` doesn't die
// when trying to recompute total score.
HitObjects =
{
new HitCircle()
- }
+ },
+ BeatmapVersion = beatmapVersion,
});
}
}
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs
index 647c0aed75..b10cce6a52 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs
@@ -135,6 +135,24 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
+ [Test]
+ public void TestNoopFadeTransformIsIgnoredForLifetime()
+ {
+ var decoder = new LegacyStoryboardDecoder();
+
+ using (var resStream = TestResources.OpenResource("noop-fade-transform-is-ignored-for-lifetime.osb"))
+ using (var stream = new LineBufferedReader(resStream))
+ {
+ var storyboard = decoder.Decode(stream);
+
+ StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3);
+ Assert.AreEqual(2, background.Elements.Count);
+
+ Assert.AreEqual(1500, background.Elements[0].StartTime);
+ Assert.AreEqual(1500, background.Elements[1].StartTime);
+ }
+ }
+
[Test]
public void TestOutOfOrderStartTimes()
{
@@ -288,6 +306,29 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
+ [Test]
+ public void TestVideoWithCustomFadeIn()
+ {
+ var decoder = new LegacyStoryboardDecoder();
+
+ using var resStream = TestResources.OpenResource("video-custom-alpha-transform.osb");
+ using var stream = new LineBufferedReader(resStream);
+
+ var storyboard = decoder.Decode(stream);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(storyboard.GetLayer(@"Video").Elements, Has.Count.EqualTo(1));
+ Assert.That(storyboard.GetLayer(@"Video").Elements.Single(), Is.InstanceOf());
+ Assert.That(storyboard.GetLayer(@"Video").Elements.Single().StartTime, Is.EqualTo(-5678));
+ Assert.That(((StoryboardVideo)storyboard.GetLayer(@"Video").Elements.Single()).Commands.Alpha.Single().StartTime, Is.EqualTo(1500));
+ Assert.That(((StoryboardVideo)storyboard.GetLayer(@"Video").Elements.Single()).Commands.Alpha.Single().EndTime, Is.EqualTo(1600));
+
+ Assert.That(storyboard.EarliestEventTime, Is.Null);
+ Assert.That(storyboard.LatestEventTime, Is.Null);
+ });
+ }
+
[Test]
public void TestVideoAndBackgroundEventsDoNotAffectStoryboardBounds()
{
diff --git a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs
index 8a95d26782..cf498c7856 100644
--- a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs
+++ b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs
@@ -11,6 +11,7 @@ using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.IO.Archives;
+using osu.Game.Rulesets.Objects.Types;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual;
using MemoryStream = System.IO.MemoryStream;
@@ -50,6 +51,29 @@ namespace osu.Game.Tests.Beatmaps.IO
AddAssert("hit object is snapped", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28519).Within(0.001));
}
+ [Test]
+ public void TestFractionalObjectCoordinatesRounded()
+ {
+ IWorkingBeatmap beatmap = null!;
+ MemoryStream outStream = null!;
+
+ // Ensure importer encoding is correct
+ AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"fractional-coordinates.olz"));
+ AddAssert("hit object has fractional position", () => ((IHasYPosition)beatmap.Beatmap.HitObjects[1]).Y, () => Is.EqualTo(383.99997).Within(0.00001));
+
+ // Ensure exporter legacy conversion is correct
+ AddStep("export", () =>
+ {
+ outStream = new MemoryStream();
+
+ new LegacyBeatmapExporter(LocalStorage)
+ .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null);
+ });
+
+ AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream));
+ AddAssert("hit object is snapped", () => ((IHasYPosition)beatmap.Beatmap.HitObjects[1]).Y, () => Is.EqualTo(384).Within(0.00001));
+ }
+
[Test]
public void TestExportStability()
{
diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs
index c7cf3fe956..ee2733ad91 100644
--- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs
+++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs
@@ -112,5 +112,20 @@ namespace osu.Game.Tests.Beatmaps
}
});
}
+
+ [Test]
+ public void TestRepeatsGeneratedEvenForZeroLengthSlider()
+ {
+ var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, 0, 2).ToArray();
+
+ Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head));
+ Assert.That(events[0].Time, Is.EqualTo(start_time));
+
+ Assert.That(events[1].Type, Is.EqualTo(SliderEventType.Repeat));
+ Assert.That(events[1].Time, Is.EqualTo(span_duration));
+
+ Assert.That(events[3].Type, Is.EqualTo(SliderEventType.Tail));
+ Assert.That(events[3].Time, Is.EqualTo(span_duration * 2));
+ }
}
}
diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs
index ab40092b3f..7a05a3da5c 100644
--- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs
+++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs
@@ -32,7 +32,7 @@ namespace osu.Game.Tests.Beatmaps
private TestBeatmapDifficultyCache difficultyCache;
- private IBindable starDifficultyBindable;
+ private IBindable starDifficultyBindable;
[BackgroundDependencyLoader]
private void load(OsuGameBase osu)
@@ -52,7 +52,7 @@ namespace osu.Game.Tests.Beatmaps
starDifficultyBindable = difficultyCache.GetBindableDifficulty(importedSet.Beatmaps.First());
});
- AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value?.Stars == BASE_STARS);
+ AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value.Stars == BASE_STARS);
}
[Test]
@@ -67,13 +67,13 @@ namespace osu.Game.Tests.Beatmaps
});
AddStep("change selected mod to DT", () => SelectedMods.Value = new[] { dt = new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } });
- AddUntilStep($"star difficulty -> {BASE_STARS + 1.5}", () => starDifficultyBindable.Value?.Stars == BASE_STARS + 1.5);
+ AddUntilStep($"star difficulty -> {BASE_STARS + 1.5}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 1.5);
AddStep("change DT speed to 1.25", () => dt.SpeedChange.Value = 1.25);
- AddUntilStep($"star difficulty -> {BASE_STARS + 1.25}", () => starDifficultyBindable.Value?.Stars == BASE_STARS + 1.25);
+ AddUntilStep($"star difficulty -> {BASE_STARS + 1.25}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 1.25);
AddStep("change selected mod to NC", () => SelectedMods.Value = new[] { new OsuModNightcore { SpeedChange = { Value = 1.75 } } });
- AddUntilStep($"star difficulty -> {BASE_STARS + 1.75}", () => starDifficultyBindable.Value?.Stars == BASE_STARS + 1.75);
+ AddUntilStep($"star difficulty -> {BASE_STARS + 1.75}", () => starDifficultyBindable.Value.Stars == BASE_STARS + 1.75);
}
[Test]
@@ -88,15 +88,15 @@ namespace osu.Game.Tests.Beatmaps
});
AddStep("change selected mod to DA", () => SelectedMods.Value = new[] { difficultyAdjust = new OsuModDifficultyAdjust() });
- AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value?.Stars == BASE_STARS);
+ AddUntilStep($"star difficulty -> {BASE_STARS}", () => starDifficultyBindable.Value.Stars == BASE_STARS);
AddStep("change DA difficulty to 0.5", () => difficultyAdjust.OverallDifficulty.Value = 0.5f);
- AddUntilStep($"star difficulty -> {BASE_STARS * 0.5f}", () => starDifficultyBindable.Value?.Stars == BASE_STARS / 2);
+ AddUntilStep($"star difficulty -> {BASE_STARS * 0.5f}", () => starDifficultyBindable.Value.Stars == BASE_STARS / 2);
// hash code of 0 (the value) conflicts with the hash code of null (the initial/default value).
// it's important that the mod reference and its underlying bindable references stay the same to demonstrate this failure.
AddStep("change DA difficulty to 0", () => difficultyAdjust.OverallDifficulty.Value = 0);
- AddUntilStep("star difficulty -> 0", () => starDifficultyBindable.Value?.Stars == 0);
+ AddUntilStep("star difficulty -> 0", () => starDifficultyBindable.Value.Stars == 0);
}
[Test]
diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
index c40624a3a0..bae8e7c76a 100644
--- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
+++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
@@ -62,12 +62,11 @@ namespace osu.Game.Tests.Database
});
});
- AddStep("Run background processor", () =>
- {
- Add(new TestBackgroundDataStoreProcessor());
- });
+ TestBackgroundDataStoreProcessor processor = null!;
+ AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor()));
+ AddUntilStep("Wait for completion", () => processor.Completed);
- AddUntilStep("wait for difficulties repopulated", () =>
+ AddAssert("Difficulties repopulated", () =>
{
return Realm.Run(r =>
{
@@ -101,13 +100,10 @@ namespace osu.Game.Tests.Database
});
});
- AddStep("Run background processor", () =>
- {
- Add(new TestBackgroundDataStoreProcessor());
- });
+ TestBackgroundDataStoreProcessor processor = null!;
+ AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor()));
AddWaitStep("wait some", 500);
-
AddAssert("Difficulty still not populated", () =>
{
return Realm.Run(r =>
@@ -118,8 +114,9 @@ namespace osu.Game.Tests.Database
});
AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying);
+ AddUntilStep("Wait for completion", () => processor.Completed);
- AddUntilStep("wait for difficulties repopulated", () =>
+ AddAssert("Difficulties repopulated", () =>
{
return Realm.Run(r =>
{
@@ -151,9 +148,11 @@ namespace osu.Game.Tests.Database
});
});
- AddStep("Run background processor", () => Add(new TestBackgroundDataStoreProcessor()));
+ TestBackgroundDataStoreProcessor processor = null!;
+ AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor()));
+ AddUntilStep("Wait for completion", () => processor.Completed);
- AddUntilStep("Score version upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(LegacyScoreEncoder.LATEST_VERSION));
+ AddAssert("Score version upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(LegacyScoreEncoder.LATEST_VERSION));
AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False);
}
@@ -183,7 +182,7 @@ namespace osu.Game.Tests.Database
AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor()));
AddUntilStep("Wait for completion", () => processor.Completed);
- AddUntilStep("Score marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.True);
+ AddAssert("Score marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.True);
AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(scoreVersion));
}
diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs
index 61ee6a3663..c2a712b580 100644
--- a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs
+++ b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
+using ManagedBass;
using Moq;
using NUnit.Framework;
using osu.Framework.Audio.Track;
@@ -10,7 +11,9 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
+using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual;
+using osuTK.Audio;
namespace osu.Game.Tests.Editing.Checks
{
@@ -28,9 +31,13 @@ namespace osu.Game.Tests.Editing.Checks
{
BeatmapInfo = new BeatmapInfo
{
- Metadata = new BeatmapMetadata { AudioFile = "abc123.jpg" }
+ Metadata = new BeatmapMetadata()
}
};
+
+ // 0 = No output device. This still allows decoding.
+ if (!Bass.Init(0) && Bass.LastError != Errors.Already)
+ throw new AudioException("Could not initialize Bass.");
}
[Test]
@@ -54,6 +61,14 @@ namespace osu.Game.Tests.Editing.Checks
Assert.That(check.Run(context), Is.Empty);
}
+ [Test]
+ public void TestAcceptableOgg()
+ {
+ var context = getContext(208, useOgg: true);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
[Test]
public void TestNullBitrate()
{
@@ -87,6 +102,17 @@ namespace osu.Game.Tests.Editing.Checks
Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooHighBitrate);
}
+ [Test]
+ public void TestTooHighBitrateOgg()
+ {
+ var context = getContext(250, useOgg: true);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooHighBitrate);
+ }
+
[Test]
public void TestTooLowBitrate()
{
@@ -98,24 +124,41 @@ namespace osu.Game.Tests.Editing.Checks
Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooLowBitrate);
}
- private BeatmapVerifierContext getContext(int? audioBitrate)
+ private BeatmapVerifierContext getContext(int? audioBitrate, bool useOgg = false)
{
- return new BeatmapVerifierContext(beatmap, getMockWorkingBeatmap(audioBitrate).Object);
+ // Update the audio filename and beatmapset files based on the format being tested
+ string audioFileName = useOgg ? "abc123.ogg" : "abc123.mp3";
+ string fileExtension = useOgg ? "ogg" : "mp3";
+
+ beatmap.Metadata.AudioFile = audioFileName;
+ beatmap.BeatmapInfo.BeatmapSet = new BeatmapSetInfo
+ {
+ Files = { CheckTestHelpers.CreateMockFile(fileExtension) }
+ };
+
+ return new BeatmapVerifierContext(beatmap, getMockWorkingBeatmap(audioBitrate, useOgg).Object);
}
///
/// Returns the mock of the working beatmap with the given audio properties.
///
/// The bitrate of the audio file the beatmap uses.
- private Mock getMockWorkingBeatmap(int? audioBitrate)
+ /// Whether to use an OGG sample instead of MP3.
+ private Mock getMockWorkingBeatmap(int? audioBitrate, bool useOgg = false)
{
var mockTrack = new Mock(new FramedClock(), "virtual");
mockTrack.SetupGet(t => t.Bitrate).Returns(audioBitrate);
+ // Use real audio samples for format detection
+ string samplePath = useOgg ? "Samples/test-sample.ogg" : "Samples/test-sample-cut.mp3";
+
var mockWorkingBeatmap = new Mock();
mockWorkingBeatmap.SetupGet(w => w.Beatmap).Returns(beatmap);
mockWorkingBeatmap.SetupGet(w => w.Track).Returns(mockTrack.Object);
+ // Return a fresh stream each time GetStream is called to avoid disposed stream issues
+ mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(() => TestResources.OpenResource(samplePath));
+
return mockWorkingBeatmap;
}
}
diff --git a/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs
index b5c6568583..fd63e1b05d 100644
--- a/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs
+++ b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs
@@ -57,6 +57,16 @@ namespace osu.Game.Tests.Editing.Checks
});
}
+ [Test]
+ public void TestCirclesAlmostConcurrentWarning()
+ {
+ assertAlmostConcurrentSame(new List
+ {
+ new HitCircle { StartTime = 100 },
+ new HitCircle { StartTime = 108 }
+ });
+ }
+
[Test]
public void TestSlidersSeparate()
{
@@ -97,6 +107,16 @@ namespace osu.Game.Tests.Editing.Checks
});
}
+ [Test]
+ public void TestSliderAndCircleAlmostConcurrent()
+ {
+ assertAlmostConcurrentDifferent(new List
+ {
+ getSliderMock(startTime: 100, endTime: 400.75d).Object,
+ new HitCircle { StartTime = 408 }
+ });
+ }
+
[Test]
public void TestManyObjectsConcurrent()
{
@@ -110,38 +130,14 @@ namespace osu.Game.Tests.Editing.Checks
var issues = check.Run(getContext(hitobjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(3));
- Assert.That(issues.Where(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent).ToList(), Has.Count.EqualTo(2));
- Assert.That(issues.Any(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame));
- }
+ Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrent));
- [Test]
- public void TestHoldNotesSeparateOnSameColumn()
- {
- assertOk(new List
- {
- getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object,
- getHoldNoteMock(startTime: 500, endTime: 900.75d, column: 1).Object
- });
- }
+ // Should have 1 same-type concurrent (Slider & Slider) and 2 different-type concurrent (Slider & Circle)
+ var sameTypeIssues = issues.Where(issue => issue.ToString().Contains("s are concurrent here")).ToList();
+ var differentTypeIssues = issues.Where(issue => issue.ToString().Contains(" and ") && issue.ToString().Contains("are concurrent here")).ToList();
- [Test]
- public void TestHoldNotesConcurrentOnDifferentColumns()
- {
- assertOk(new List
- {
- getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object,
- getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 2).Object
- });
- }
-
- [Test]
- public void TestHoldNotesConcurrentOnSameColumn()
- {
- assertConcurrentSame(new List
- {
- getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object,
- getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 1).Object
- });
+ Assert.That(sameTypeIssues, Has.Count.EqualTo(1));
+ Assert.That(differentTypeIssues, Has.Count.EqualTo(2));
}
private Mock getSliderMock(double startTime, double endTime, int repeats = 0)
@@ -174,7 +170,8 @@ namespace osu.Game.Tests.Editing.Checks
var issues = check.Run(getContext(hitobjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
- Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame));
+ Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrent));
+ Assert.That(issues.All(issue => issue.ToString().Contains("s are concurrent here")));
}
private void assertConcurrentDifferent(List hitobjects, int count = 1)
@@ -182,7 +179,26 @@ namespace osu.Game.Tests.Editing.Checks
var issues = check.Run(getContext(hitobjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
- Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent));
+ Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrent));
+ Assert.That(issues.All(issue => issue.ToString().Contains(" and ") && issue.ToString().Contains("are concurrent here")));
+ }
+
+ private void assertAlmostConcurrentSame(List hitobjects)
+ {
+ var issues = check.Run(getContext(hitobjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateAlmostConcurrent));
+ Assert.That(issues.All(issue => issue.ToString().Contains("s are less than 10ms apart")));
+ }
+
+ private void assertAlmostConcurrentDifferent(List hitobjects)
+ {
+ var issues = check.Run(getContext(hitobjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateAlmostConcurrent));
+ Assert.That(issues.All(issue => issue.ToString().Contains(" and ") && issue.ToString().Contains("are less than 10ms apart")));
}
private BeatmapVerifierContext getContext(List hitobjects)
diff --git a/osu.Game.Tests/Editing/Checks/CheckInconsistentMetadataTest.cs b/osu.Game.Tests/Editing/Checks/CheckInconsistentMetadataTest.cs
new file mode 100644
index 0000000000..09d731152d
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckInconsistentMetadataTest.cs
@@ -0,0 +1,215 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Models;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckInconsistentMetadataTest
+ {
+ private CheckInconsistentMetadata check = null!;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckInconsistentMetadata();
+ }
+
+ [Test]
+ public void TestConsistentMetadata()
+ {
+ var metadata = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2");
+ var beatmaps = createBeatmapSetWithMetadata(metadata, metadata);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestInconsistentArtist()
+ {
+ var metadata1 = createMetadata("Artist One", "Test Title", "Test Source", "Test Creator", "tag1 tag2");
+ var metadata2 = createMetadata("Artist Two", "Test Title", "Test Source", "Test Creator", "tag1 tag2");
+ var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields);
+ Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent artist fields"));
+ Assert.That(issues[0].ToString(), Contains.Substring("Artist One"));
+ Assert.That(issues[0].ToString(), Contains.Substring("Artist Two"));
+ }
+
+ [Test]
+ public void TestInconsistentTitle()
+ {
+ var metadata1 = createMetadata("Test Artist", "Title One", "Test Source", "Test Creator", "tag1 tag2");
+ var metadata2 = createMetadata("Test Artist", "Title Two", "Test Source", "Test Creator", "tag1 tag2");
+ var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields);
+ Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent title fields"));
+ }
+
+ [Test]
+ public void TestInconsistentUnicodeArtist()
+ {
+ var metadata1 = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2", unicodeArtist: "Test Unicode Artist 1");
+ var metadata2 = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2", unicodeArtist: "Test Unicode Artist 2");
+ var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields);
+ Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent unicode artist fields"));
+ }
+
+ [Test]
+ public void TestInconsistentSource()
+ {
+ var metadata1 = createMetadata("Test Artist", "Test Title", "Source One", "Test Creator", "tag1 tag2");
+ var metadata2 = createMetadata("Test Artist", "Test Title", "Source Two", "Test Creator", "tag1 tag2");
+ var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields);
+ Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent source fields"));
+ }
+
+ [Test]
+ public void TestInconsistentCreator()
+ {
+ var metadata1 = createMetadata("Test Artist", "Test Title", "Test Source", "Creator One", "tag1 tag2");
+ var metadata2 = createMetadata("Test Artist", "Test Title", "Test Source", "Creator Two", "tag1 tag2");
+ var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields);
+ Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent creator fields"));
+ }
+
+ [Test]
+ public void TestInconsistentTags()
+ {
+ var metadata1 = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2 tag3");
+ var metadata2 = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag4 tag5");
+ var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentMetadata.IssueTemplateInconsistentTags);
+ Assert.That(issues[0].ToString(), Contains.Substring("Inconsistent tags"));
+ Assert.That(issues[0].ToString(), Contains.Substring("tag2 tag3 tag4 tag5"));
+ }
+
+ [Test]
+ public void TestMultipleInconsistencies()
+ {
+ var metadata1 = createMetadata("Artist One", "Title One", "Test Source", "Test Creator", "tag1 tag2");
+ var metadata2 = createMetadata("Artist Two", "Title Two", "Test Source", "Test Creator", "tag3 tag4");
+ var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(3)); // artist, title, tags
+ Assert.That(issues.Count(i => i.Template is CheckInconsistentMetadata.IssueTemplateInconsistentOtherFields), Is.EqualTo(2));
+ Assert.That(issues.Count(i => i.Template is CheckInconsistentMetadata.IssueTemplateInconsistentTags), Is.EqualTo(1));
+ }
+
+ [Test]
+ public void TestSingleDifficulty()
+ {
+ var metadata = createMetadata("Test Artist", "Test Title", "Test Source", "Test Creator", "tag1 tag2");
+ var beatmaps = createBeatmapSetWithMetadata(metadata);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestEmptyStringFieldsAreConsistent()
+ {
+ var metadata1 = createMetadata("Test Artist", "Test Title", "", "Test Creator", "");
+ var metadata2 = createMetadata("Test Artist", "Test Title", "", "Test Creator", "");
+ var beatmaps = createBeatmapSetWithMetadata(metadata1, metadata2);
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private BeatmapMetadata createMetadata(string artist, string title, string source, string creator, string tags, string unicodeArtist = "", string unicodeTitle = "")
+ {
+ return new BeatmapMetadata(new RealmUser { Username = creator })
+ {
+ Artist = artist,
+ Title = title,
+ Source = source,
+ Tags = tags,
+ ArtistUnicode = unicodeArtist,
+ TitleUnicode = unicodeTitle
+ };
+ }
+
+ private IBeatmap[] createBeatmapSetWithMetadata(params BeatmapMetadata[] metadata)
+ {
+ var beatmapSet = new BeatmapSetInfo();
+ var beatmaps = new IBeatmap[metadata.Length];
+
+ for (int i = 0; i < metadata.Length; i++)
+ {
+ beatmaps[i] = createBeatmapWithMetadata(metadata[i], $"Difficulty {i + 1}");
+ beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet;
+ }
+
+ // Configure the beatmapset to contain all the beatmap infos
+ foreach (var beatmap in beatmaps)
+ beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo);
+
+ return beatmaps;
+ }
+
+ private Beatmap createBeatmapWithMetadata(BeatmapMetadata metadata, string difficultyName)
+ {
+ return new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ DifficultyName = difficultyName,
+ Metadata = metadata
+ }
+ };
+ }
+
+ private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IBeatmap[] allDifficulties)
+ {
+ var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap);
+ var verifiedOtherBeatmaps = allDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList();
+
+ return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Editing/Checks/CheckInconsistentSettingsTest.cs b/osu.Game.Tests/Editing/Checks/CheckInconsistentSettingsTest.cs
new file mode 100644
index 0000000000..079c6855a9
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckInconsistentSettingsTest.cs
@@ -0,0 +1,272 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osuTK;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Tests.Beatmaps;
+using osu.Game.Storyboards;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckInconsistentSettingsTest
+ {
+ private CheckInconsistentSettings check = null!;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckInconsistentSettings();
+ }
+
+ [Test]
+ public void TestConsistentSettings()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(audioLeadIn: 1000, countdown: CountdownType.Normal, epilepsyWarning: false),
+ createSettings(audioLeadIn: 1000, countdown: CountdownType.Normal, epilepsyWarning: false)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestInconsistentAudioLeadIn()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(audioLeadIn: 1000),
+ createSettings(audioLeadIn: 2000)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting);
+ Assert.That(issues[0].ToString(), Contains.Substring("Audio lead-in"));
+ Assert.That(issues[0].ToString(), Contains.Substring("1000"));
+ Assert.That(issues[0].ToString(), Contains.Substring("2000"));
+ }
+
+ [Test]
+ public void TestInconsistentCountdown()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(countdown: CountdownType.Normal),
+ createSettings(countdown: CountdownType.None)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting);
+ Assert.That(issues[0].ToString(), Contains.Substring("Countdown"));
+ Assert.That(issues[0].ToString(), Contains.Substring("Normal"));
+ Assert.That(issues[0].ToString(), Contains.Substring("None"));
+ }
+
+ [Test]
+ public void TestInconsistentCountdownOffset()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(countdownOffset: 100),
+ createSettings(countdownOffset: 200)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting);
+ Assert.That(issues[0].ToString(), Contains.Substring("Countdown offset"));
+ }
+
+ [Test]
+ public void TestInconsistentEpilepsyWarning()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(epilepsyWarning: true),
+ createSettings(epilepsyWarning: false)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting);
+ Assert.That(issues[0].ToString(), Contains.Substring("Epilepsy warning"));
+ }
+
+ [Test]
+ public void TestInconsistentLetterboxInBreaks()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(letterboxInBreaks: true),
+ createSettings(letterboxInBreaks: false)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting);
+ Assert.That(issues[0].ToString(), Contains.Substring("Letterbox during breaks"));
+ }
+
+ [Test]
+ public void TestInconsistentSamplesMatchPlaybackRate()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(samplesMatchPlaybackRate: true),
+ createSettings(samplesMatchPlaybackRate: false)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting);
+ Assert.That(issues[0].ToString(), Contains.Substring("Samples match playback rate"));
+ }
+
+ [Test]
+ public void TestInconsistentWidescreenSupport()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(widescreenStoryboard: true),
+ createSettings(widescreenStoryboard: false)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestInconsistentWidescreenSupportWithStoryboard()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(widescreenStoryboard: true),
+ createSettings(widescreenStoryboard: false)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps, hasStoryboard: true);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting);
+ Assert.That(issues[0].ToString(), Contains.Substring("Widescreen support"));
+ }
+
+ [Test]
+ public void TestInconsistentSliderTickRate()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(sliderTickRate: 1.0),
+ createSettings(sliderTickRate: 2.0)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues[0].Template is CheckInconsistentSettings.IssueTemplateInconsistentSetting);
+ Assert.That(issues[0].ToString(), Contains.Substring("Tick Rate"));
+ }
+
+ [Test]
+ public void TestMultipleInconsistencies()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(audioLeadIn: 1000, countdown: CountdownType.Normal, epilepsyWarning: false),
+ createSettings(audioLeadIn: 2000, countdown: CountdownType.None, epilepsyWarning: true)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(3));
+ Assert.That(issues.Count(i => i.ToString().Contains("Audio lead-in")), Is.EqualTo(1));
+ Assert.That(issues.Count(i => i.ToString().Contains("Countdown")), Is.EqualTo(1));
+ Assert.That(issues.Count(i => i.ToString().Contains("Epilepsy warning")), Is.EqualTo(1));
+ }
+
+ [Test]
+ public void TestSingleDifficulty()
+ {
+ var beatmaps = createBeatmapSetWithSettings(
+ createSettings(audioLeadIn: 1000)
+ );
+ var context = createContextWithMultipleDifficulties(beatmaps.First(), beatmaps);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private Beatmap createSettings(
+ double audioLeadIn = 0,
+ CountdownType countdown = CountdownType.None,
+ int countdownOffset = 0,
+ bool epilepsyWarning = false,
+ bool letterboxInBreaks = false,
+ bool samplesMatchPlaybackRate = false,
+ bool widescreenStoryboard = false,
+ double sliderTickRate = 1.0)
+ {
+ return new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ DifficultyName = "Test Difficulty",
+ StarRating = 5.0
+ },
+ AudioLeadIn = audioLeadIn,
+ Countdown = countdown,
+ CountdownOffset = countdownOffset,
+ EpilepsyWarning = epilepsyWarning,
+ LetterboxInBreaks = letterboxInBreaks,
+ SamplesMatchPlaybackRate = samplesMatchPlaybackRate,
+ WidescreenStoryboard = widescreenStoryboard,
+ Difficulty = new BeatmapDifficulty
+ {
+ SliderTickRate = sliderTickRate
+ }
+ };
+ }
+
+ private IBeatmap[] createBeatmapSetWithSettings(params IBeatmap[] beatmaps)
+ {
+ var beatmapSet = new BeatmapSetInfo();
+
+ for (int i = 0; i < beatmaps.Length; i++)
+ {
+ beatmaps[i].BeatmapInfo.DifficultyName = $"Difficulty {i + 1}";
+ beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet;
+ beatmapSet.Beatmaps.Add(beatmaps[i].BeatmapInfo);
+ }
+
+ return beatmaps;
+ }
+
+ private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IBeatmap[] allDifficulties, bool hasStoryboard = false)
+ {
+ Storyboard? storyboard = null;
+
+ if (hasStoryboard)
+ {
+ storyboard = new Storyboard();
+ storyboard.GetLayer("Background").Add(new StoryboardSprite("test.png", Anchor.Centre, Vector2.Zero));
+ }
+
+ var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap, storyboard), currentBeatmap);
+ var verifiedOtherBeatmaps = allDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b, storyboard), b)).ToList();
+
+ return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs b/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs
new file mode 100644
index 0000000000..afcb38c858
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs
@@ -0,0 +1,254 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Beatmaps.Timing;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckInconsistentTimingControlPointsTest
+ {
+ private CheckInconsistentTimingControlPoints check = null!;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckInconsistentTimingControlPoints();
+ }
+
+ [Test]
+ public void TestConsistentTiming()
+ {
+ var beatmaps = createBeatmapSetWithTiming(
+ new[] { 1000.0, 2000.0 }, // Timing at 1000ms and 2000ms
+ new[] { 1000.0, 2000.0 } // Same timing
+ );
+
+ var context = createContext(beatmaps[0], beatmaps);
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestMissingTimingPoint()
+ {
+ var beatmaps = createBeatmapSetWithTiming(
+ new[] { 1000.0, 2000.0 }, // Reference has timing at 1000ms and 2000ms
+ new[] { 1000.0 } // Second difficulty missing timing at 2000ms
+ );
+
+ var context = createContext(beatmaps[0], beatmaps);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateMissingTimingPoint));
+ }
+
+ [Test]
+ public void TestInconsistentBPM()
+ {
+ var beatmaps = createBeatmapSetWithBPM(
+ new[] { (1000.0, 500.0) }, // Reference: 120 BPM (500ms beat length)
+ new[] { (1000.0, 600.0) } // Second: 100 BPM (600ms beat length)
+ );
+
+ var context = createContext(beatmaps[0], beatmaps);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateInconsistentBPM));
+ }
+
+ [Test]
+ public void TestInconsistentMeter()
+ {
+ var beatmaps = createBeatmapSetWithMeter(
+ new[] { (1000.0, TimeSignature.SimpleQuadruple) }, // Reference: 4/4
+ new[] { (1000.0, TimeSignature.SimpleTriple) } // Second: 3/4
+ );
+
+ var context = createContext(beatmaps[0], beatmaps);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateInconsistentMeter));
+ }
+
+ [Test]
+ public void TestDecimalOffset()
+ {
+ var beatmaps = createBeatmapSetWithTiming(
+ new[] { 1000.0 }, // Reference at exactly 1000ms
+ new[] { 1000.5 } // Second at 1000.5ms (decimal difference)
+ );
+
+ var context = createContext(beatmaps[0], beatmaps);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(2));
+ Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateMissingTimingPointMinor));
+ }
+
+ [Test]
+ public void TestSingleDifficulty()
+ {
+ var beatmaps = createBeatmapSetWithTiming(
+ new[] { 1000.0, 2000.0 } // Only one difficulty
+ );
+
+ var context = createContext(beatmaps[0], beatmaps);
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestExtraTimingPoint()
+ {
+ var beatmaps = createBeatmapSetWithTiming(
+ new[] { 1000.0 }, // Reference has timing at 1000ms
+ new[] { 1000.0, 2000.0 } // Second has additional timing at 2000ms
+ );
+
+ var context = createContext(beatmaps[0], beatmaps);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.All(issue => issue.Template is CheckInconsistentTimingControlPoints.IssueTemplateExtraTimingPoint));
+ }
+
+ private IBeatmap[] createBeatmapSetWithTiming(params double[][] timingPoints)
+ {
+ var beatmapSet = new BeatmapSetInfo();
+ var beatmaps = new IBeatmap[timingPoints.Length];
+
+ for (int i = 0; i < timingPoints.Length; i++)
+ {
+ beatmaps[i] = createBeatmapWithTiming(timingPoints[i], $"Difficulty {i + 1}");
+ beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet;
+ }
+
+ foreach (var beatmap in beatmaps)
+ beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo);
+
+ return beatmaps;
+ }
+
+ private IBeatmap[] createBeatmapSetWithBPM(params (double time, double beatLength)[][] timingData)
+ {
+ var beatmapSet = new BeatmapSetInfo();
+ var beatmaps = new IBeatmap[timingData.Length];
+
+ for (int i = 0; i < timingData.Length; i++)
+ {
+ beatmaps[i] = createBeatmapWithBPM(timingData[i], $"Difficulty {i + 1}");
+ beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet;
+ }
+
+ foreach (var beatmap in beatmaps)
+ beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo);
+
+ return beatmaps;
+ }
+
+ private IBeatmap[] createBeatmapSetWithMeter(params (double time, TimeSignature meter)[][] timingData)
+ {
+ var beatmapSet = new BeatmapSetInfo();
+ var beatmaps = new IBeatmap[timingData.Length];
+
+ for (int i = 0; i < timingData.Length; i++)
+ {
+ beatmaps[i] = createBeatmapWithMeter(timingData[i], $"Difficulty {i + 1}");
+ beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet;
+ }
+
+ foreach (var beatmap in beatmaps)
+ beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo);
+
+ return beatmaps;
+ }
+
+ private IBeatmap createBeatmapWithTiming(double[] timingPoints, string difficultyName)
+ {
+ var beatmap = new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ DifficultyName = difficultyName,
+ Metadata = new BeatmapMetadata()
+ },
+ ControlPointInfo = new ControlPointInfo()
+ };
+
+ foreach (double time in timingPoints)
+ {
+ beatmap.ControlPointInfo.Add(time, new TimingControlPoint
+ {
+ BeatLength = 500 // 120 BPM
+ });
+ }
+
+ return beatmap;
+ }
+
+ private IBeatmap createBeatmapWithBPM((double time, double beatLength)[] timingData, string difficultyName)
+ {
+ var beatmap = new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ DifficultyName = difficultyName,
+ Metadata = new BeatmapMetadata()
+ },
+ ControlPointInfo = new ControlPointInfo()
+ };
+
+ foreach ((double time, double beatLength) in timingData)
+ {
+ beatmap.ControlPointInfo.Add(time, new TimingControlPoint
+ {
+ BeatLength = beatLength
+ });
+ }
+
+ return beatmap;
+ }
+
+ private IBeatmap createBeatmapWithMeter((double time, TimeSignature meter)[] timingData, string difficultyName)
+ {
+ var beatmap = new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ DifficultyName = difficultyName,
+ Metadata = new BeatmapMetadata()
+ },
+ ControlPointInfo = new ControlPointInfo()
+ };
+
+ foreach ((double time, var meter) in timingData)
+ {
+ beatmap.ControlPointInfo.Add(time, new TimingControlPoint
+ {
+ BeatLength = 500, // 120 BPM
+ TimeSignature = meter
+ });
+ }
+
+ return beatmap;
+ }
+
+ private BeatmapVerifierContext createContext(IBeatmap currentBeatmap, IBeatmap[] allDifficulties)
+ {
+ var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap);
+ var verifiedOtherBeatmaps = allDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList();
+
+ return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs
new file mode 100644
index 0000000000..91333d2916
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs
@@ -0,0 +1,251 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Timing;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckLowestDiffDrainTimeTest
+ {
+ private TestCheckLowestDiffDrainTime check = null!;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new TestCheckLowestDiffDrainTime();
+ }
+
+ [Test]
+ public void TestSingleDifficultyMeetsRequirement()
+ {
+ var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 3.5, "Hard"); // 4 minutes
+ assertOk(beatmap);
+ }
+
+ [Test]
+ public void TestSingleDifficultyTooShort()
+ {
+ var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 3.5, "Hard"); // 2 minutes - too short for Hard
+ assertTooShort(beatmap);
+ }
+
+ [Test]
+ public void TestHardDifficultyAtThreshold()
+ {
+ var beatmap = createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"); // Exactly 3:30
+ assertOk(beatmap);
+ }
+
+ [Test]
+ public void TestHardDifficultyJustUnderThreshold()
+ {
+ var beatmap = createBeatmapWithDrainTime((3 * 60 + 29) * 1000, 3.5, "Hard"); // 3:29 - just under threshold
+ assertTooShort(beatmap);
+ }
+
+ [Test]
+ public void TestInsaneDifficultyAtThreshold()
+ {
+ var beatmap = createBeatmapWithDrainTime((4 * 60 + 15) * 1000, 4.5, "Insane"); // Exactly 4:15
+ assertOk(beatmap);
+ }
+
+ [Test]
+ public void TestInsaneDifficultyTooShort()
+ {
+ var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"); // 4:00 - too short for Insane
+ assertTooShort(beatmap);
+ }
+
+ [Test]
+ public void TestExpertDifficultyAtThreshold()
+ {
+ var beatmap = createBeatmapWithDrainTime(5 * 60 * 1000, 5.5, "Expert"); // Exactly 5:00
+ assertOk(beatmap);
+ }
+
+ [Test]
+ public void TestExpertDifficultyTooShort()
+ {
+ var beatmap = createBeatmapWithDrainTime((4 * 60 + 30) * 1000, 5.5, "Expert"); // 4:30 - too short for Expert
+ assertTooShort(beatmap);
+ }
+
+ [Test]
+ public void TestEasyDifficultyMeetsRequirement()
+ {
+ var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 1.5, "Easy"); // 2 minutes - should be ok for Easy
+ assertOk(beatmap);
+ }
+
+ [Test]
+ public void TestNormalDifficultyMeetsRequirement()
+ {
+ var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 2.5, "Normal"); // 2 minutes - should be ok for Normal
+ assertOk(beatmap);
+ }
+
+ [Test]
+ public void TestMultipleDifficultiesMeetsRequirement()
+ {
+ var difficulties = new List
+ {
+ createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"), // Hard - lowest difficulty, 3:30
+ createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 4.5, "Insane"),
+ createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 5.5, "Expert")
+ };
+
+ // All should be ok because lowest difficulty is Hard and drain time meets Hard requirement
+ assertOkWithMultipleDifficulties(difficulties[0], difficulties);
+ assertOkWithMultipleDifficulties(difficulties[1], difficulties);
+ assertOkWithMultipleDifficulties(difficulties[2], difficulties);
+ }
+
+ [Test]
+ public void TestMultipleDifficultiesTooShort()
+ {
+ var difficulties = new List
+ {
+ createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"), // Insane - lowest difficulty, 4:00
+ createBeatmapWithDrainTime(4 * 60 * 1000, 5.5, "Expert") // Same drain time
+ };
+
+ // Should be too short because lowest difficulty is Insane and requires 4:15
+ assertTooShortWithMultipleDifficulties(difficulties[0], difficulties);
+ assertTooShortWithMultipleDifficulties(difficulties[1], difficulties);
+ }
+
+ [Test]
+ public void TestPlayTimeVsDrainTimeNotHighestDifficulty()
+ {
+ var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time
+ expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break
+
+ var difficulties = new List
+ {
+ expertBeatmap, // Expert - 5:00 play, 4:20 drain
+ createBeatmapWithPlayTime(5 * 60 * 1000, 6.5, "ExpertPlus") // ExpertPlus - highest difficulty
+ };
+
+ // The Expert difficulty (not highest) should use play time (5:00) and pass the Expert requirement
+ assertOkWithMultipleDifficulties(difficulties[0], difficulties);
+ }
+
+ [Test]
+ public void TestPlayTimeVsDrainTimeHighestDifficulty()
+ {
+ var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time
+ expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break
+
+ // As the highest difficulty with breaks > 30s, it should use drain time and fail
+ assertTooShort(expertBeatmap);
+ }
+
+ private IBeatmap createBeatmapWithDrainTime(double drainTimeMs, double starRating = 3.5, string difficultyName = "Default")
+ {
+ var beatmap = new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ StarRating = starRating,
+ DifficultyName = difficultyName,
+ Ruleset = new OsuRuleset().RulesetInfo
+ },
+ HitObjects = new List
+ {
+ new HitObject { StartTime = 0 },
+ new HitObject { StartTime = drainTimeMs } // Last object at drain time
+ }
+ };
+
+ return beatmap;
+ }
+
+ private IBeatmap createBeatmapWithPlayTime(double playTimeMs, double starRating = 3.5, string difficultyName = "Default")
+ {
+ var beatmap = new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ StarRating = starRating,
+ DifficultyName = difficultyName,
+ Ruleset = new OsuRuleset().RulesetInfo
+ },
+ HitObjects = new List
+ {
+ new HitObject { StartTime = 0 },
+ new HitObject { StartTime = playTimeMs } // Last object at play time
+ }
+ };
+
+ return beatmap;
+ }
+
+ private void assertOk(IBeatmap beatmap)
+ {
+ var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating);
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private void assertTooShort(IBeatmap beatmap)
+ {
+ var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating);
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort);
+ }
+
+ private void assertOkWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties)
+ {
+ var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private void assertTooShortWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties)
+ {
+ var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort);
+ }
+
+ private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties)
+ {
+ var difficultiesArray = allDifficulties.ToArray();
+ var currentDifficultyRating = StarDifficulty.GetDifficultyRating(currentBeatmap.BeatmapInfo.StarRating);
+
+ var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap);
+ var verifiedOtherBeatmaps = difficultiesArray.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList();
+
+ return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, currentDifficultyRating);
+ }
+
+ private class TestCheckLowestDiffDrainTime : CheckLowestDiffDrainTime
+ {
+ protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
+ {
+ // Same thresholds as `CheckOsuLowestDiffDrainTime` for testing
+ yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard");
+ yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane");
+ yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert");
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Editing/Checks/CheckMissingGenreLanguageTest.cs b/osu.Game.Tests/Editing/Checks/CheckMissingGenreLanguageTest.cs
new file mode 100644
index 0000000000..df5b207ce7
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckMissingGenreLanguageTest.cs
@@ -0,0 +1,184 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckMissingGenreLanguageTest
+ {
+ private CheckMissingGenreLanguage check = null!;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckMissingGenreLanguage();
+ }
+
+ [Test]
+ public void TestHasGenreAndLanguage()
+ {
+ var beatmap = createBeatmapWithTags("rock english instrumental");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestHasGenreOnly()
+ {
+ var beatmap = createBeatmapWithTags("electronic pop");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage);
+ }
+
+ [Test]
+ public void TestHasLanguageOnly()
+ {
+ var beatmap = createBeatmapWithTags("japanese instrumental");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre);
+ }
+
+ [Test]
+ public void TestMissingBoth()
+ {
+ var beatmap = createBeatmapWithTags("tag1 tag2 tag3");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(2));
+ Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre));
+ Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage));
+ }
+
+ [Test]
+ public void TestMultiWordGenreHipHop()
+ {
+ var beatmap = createBeatmapWithTags("hip hop music");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage);
+ }
+
+ [Test]
+ public void TestScatteredMultiWordGenre()
+ {
+ var beatmap = createBeatmapWithTags("video hip game hop ost");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage);
+ }
+
+ [Test]
+ public void TestCaseInsensitive()
+ {
+ var beatmap = createBeatmapWithTags("ROCK JAPANESE");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestMixedCase()
+ {
+ var beatmap = createBeatmapWithTags("Rock Japanese");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestSingleWordGenre()
+ {
+ var beatmap = createBeatmapWithTags("electronic");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage);
+ }
+
+ [Test]
+ public void TestSingleWordLanguage()
+ {
+ var beatmap = createBeatmapWithTags("instrumental");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre);
+ }
+
+ [Test]
+ public void TestEmptyTags()
+ {
+ var beatmap = createBeatmapWithTags("");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(2));
+ Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre));
+ Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage));
+ }
+
+ [Test]
+ public void TestPartialMultiWordMatch()
+ {
+ // Should not match if only one word is found
+ var beatmap = createBeatmapWithTags("hip music");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(2));
+ Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingGenre));
+ Assert.That(issues.Any(issue => issue.Template is CheckMissingGenreLanguage.IssueTemplateMissingLanguage));
+ }
+
+ [Test]
+ public void TestGenreAndLanguageWithExtraTags()
+ {
+ var beatmap = createBeatmapWithTags("tag1 rock tag2 english tag3");
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private IBeatmap createBeatmapWithTags(string tags)
+ {
+ return new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ Metadata = new BeatmapMetadata { Tags = tags }
+ }
+ };
+ }
+ }
+}
diff --git a/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs b/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs
index 37b01da6ee..7fbe822e8d 100644
--- a/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs
+++ b/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs
@@ -1,7 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System.Collections.Generic;
+using System;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
@@ -16,8 +16,6 @@ namespace osu.Game.Tests.Editing.Checks
{
private CheckPreviewTime check = null!;
- private IBeatmap beatmap = null!;
-
[SetUp]
public void Setup()
{
@@ -27,62 +25,69 @@ namespace osu.Game.Tests.Editing.Checks
[Test]
public void TestPreviewTimeNotSet()
{
- setNoPreviewTimeBeatmap();
- var content = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+ // single difficulty with no preview time
+ var current = createBeatmapWithPreviewPoint(-1, "Current");
+ var context = createContext(current, Array.Empty());
- var issues = check.Run(content).ToList();
+ var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckPreviewTime.IssueTemplateHasNoPreviewTime);
}
[Test]
- public void TestPreviewTimeconflict()
+ public void TestPreviewTimeConflict()
{
- setPreviewTimeConflictBeatmap();
+ var beatmaps = createBeatmapSetWithPreviewPoint(
+ ("Current", 10),
+ ("Test1", 5),
+ ("Test2", 10)
+ );
- var content = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+ var context = createContext(beatmaps[0], new[] { beatmaps[1], beatmaps[2] });
- var issues = check.Run(content).ToList();
+ var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckPreviewTime.IssueTemplatePreviewTimeConflict);
Assert.That(issues.Single().Arguments.FirstOrDefault()?.ToString() == "Test1");
}
- private void setNoPreviewTimeBeatmap()
+ private IBeatmap[] createBeatmapSetWithPreviewPoint(params (string name, int preview)[] entries)
{
- beatmap = new Beatmap
+ var beatmapSet = new BeatmapSetInfo();
+ var beatmaps = new IBeatmap[entries.Length];
+
+ for (int i = 0; i < entries.Length; i++)
+ {
+ beatmaps[i] = createBeatmapWithPreviewPoint(entries[i].preview, entries[i].name);
+ beatmaps[i].BeatmapInfo.BeatmapSet = beatmapSet;
+ }
+
+ foreach (var b in beatmaps)
+ beatmapSet.Beatmaps.Add(b.BeatmapInfo);
+
+ return beatmaps;
+ }
+
+ private IBeatmap createBeatmapWithPreviewPoint(int previewTime, string difficultyName)
+ {
+ return new Beatmap
{
BeatmapInfo = new BeatmapInfo
{
- Metadata = new BeatmapMetadata { PreviewTime = -1 },
+ DifficultyName = difficultyName,
+ Metadata = new BeatmapMetadata { PreviewTime = previewTime }
}
};
}
- private void setPreviewTimeConflictBeatmap()
+ private BeatmapVerifierContext createContext(IBeatmap currentBeatmap, IBeatmap[] otherDifficulties)
{
- beatmap = new Beatmap
- {
- BeatmapInfo = new BeatmapInfo
- {
- Metadata = new BeatmapMetadata { PreviewTime = 10 },
- BeatmapSet = new BeatmapSetInfo(new List
- {
- new BeatmapInfo
- {
- DifficultyName = "Test1",
- Metadata = new BeatmapMetadata { PreviewTime = 5 },
- },
- new BeatmapInfo
- {
- DifficultyName = "Test2",
- Metadata = new BeatmapMetadata { PreviewTime = 10 },
- },
- })
- }
- };
+ var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap), currentBeatmap);
+ var verifiedOtherBeatmaps = otherDifficulties.Select(b => new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(b), b)).ToList();
+
+ return new BeatmapVerifierContext(verifiedCurrentBeatmap, verifiedOtherBeatmaps, DifficultyRating.ExpertPlus);
}
}
}
diff --git a/osu.Game.Tests/Editing/Checks/CheckVideoUsageTest.cs b/osu.Game.Tests/Editing/Checks/CheckVideoUsageTest.cs
new file mode 100644
index 0000000000..8e332fb405
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckVideoUsageTest.cs
@@ -0,0 +1,179 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Storyboards;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckVideoUsageTest
+ {
+ private CheckVideoUsage check = null!;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckVideoUsage();
+ }
+
+ [Test]
+ public void TestConsistentVideoUsage()
+ {
+ var beatmap1 = createBeatmapWithVideo("Diff 1", "video.mp4", 1000);
+ var beatmap2 = createBeatmapWithVideo("Diff 2", "video.mp4", 1000);
+
+ var context = createContext(beatmap1, [beatmap2]);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestDifferentVideoFile()
+ {
+ var beatmap1 = createBeatmapWithVideo("Diff 1", "videoA.mp4", 0);
+ var beatmap2 = createBeatmapWithVideo("Diff 2", "videoB.mp4", 500);
+
+ var context = createContext(beatmap1, [beatmap2]);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckVideoUsage.IssueTemplateDifferentVideo);
+ }
+
+ [Test]
+ public void TestDifferentStartTime()
+ {
+ var beatmap1 = createBeatmapWithVideo("Diff 1", "video.mp4", 0);
+ var beatmap2 = createBeatmapWithVideo("Diff 2", "video.mp4", 500);
+
+ var context = createContext(beatmap1, [beatmap2]);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckVideoUsage.IssueTemplateDifferentStartTime);
+ }
+
+ [Test]
+ public void TestOtherDifficultyMissingVideo()
+ {
+ var beatmap1 = createBeatmapWithVideo("Diff 1", "video.mp4", 0);
+ var beatmap2 = createBeatmapWithoutVideo("Diff 2");
+
+ var context = createContext(beatmap1, [beatmap2]);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckVideoUsage.IssueTemplateMissingVideo);
+ }
+
+ [Test]
+ public void TestCurrentDifficultyMissingVideo()
+ {
+ var beatmap1 = createBeatmapWithoutVideo("Diff 1");
+ var beatmap2 = createBeatmapWithVideo("Diff 2", "video.mp4", 0);
+
+ var context = createContext(beatmap1, [beatmap2]);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Single().Template is CheckVideoUsage.IssueTemplateMissingVideo);
+ }
+
+ [Test]
+ public void TestBothDifficultiesMissingVideo()
+ {
+ var beatmap1 = createBeatmapWithoutVideo("Diff 1");
+ var beatmap2 = createBeatmapWithoutVideo("Diff 2");
+
+ var context = createContext(beatmap1, [beatmap2]);
+
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ [Test]
+ public void TestPairwiseStartTimeMismatchAcrossNonCurrentDifficulties()
+ {
+ var beatmapCurrent = createBeatmapWithVideo("Diff A", "A.mp4", 0);
+ var beatmapB = createBeatmapWithVideo("Diff B", "X.mp4", 1000);
+ var beatmapC = createBeatmapWithVideo("Diff C", "X.mp4", 2000);
+
+ var context = createContext(beatmapCurrent, [beatmapB, beatmapC]);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(3));
+ Assert.That(issues.Count(i => i.Template is CheckVideoUsage.IssueTemplateDifferentVideo), Is.EqualTo(2));
+ Assert.That(issues.Count(i => i.Template is CheckVideoUsage.IssueTemplateDifferentStartTime), Is.EqualTo(1));
+ }
+
+ [Test]
+ public void TestPairwiseStartTimeMismatchWhenCurrentMissingVideo()
+ {
+ var beatmapCurrent = createBeatmapWithoutVideo("Diff A");
+ var beatmapB = createBeatmapWithVideo("Diff B", "X.mp4", 1000);
+ var beatmapC = createBeatmapWithVideo("Diff C", "X.mp4", 2000);
+
+ var context = createContext(beatmapCurrent, [beatmapB, beatmapC]);
+
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(2));
+ Assert.That(issues.Count(i => i.Template is CheckVideoUsage.IssueTemplateMissingVideo), Is.EqualTo(1));
+ Assert.That(issues.Count(i => i.Template is CheckVideoUsage.IssueTemplateDifferentStartTime), Is.EqualTo(1));
+ }
+
+ private BeatmapVerifierContext.VerifiedBeatmap createBeatmapWithVideo(string difficultyName, string path, double startTime)
+ {
+ var beatmap = new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ DifficultyName = difficultyName
+ }
+ };
+
+ var storyboard = new Storyboard();
+ storyboard.GetLayer("Video").Add(new StoryboardVideo(path, startTime));
+
+ var working = new TestWorkingBeatmap(beatmap, storyboard);
+ return new BeatmapVerifierContext.VerifiedBeatmap(working, beatmap);
+ }
+
+ private BeatmapVerifierContext.VerifiedBeatmap createBeatmapWithoutVideo(string difficultyName)
+ {
+ var beatmap = new Beatmap
+ {
+ BeatmapInfo = new BeatmapInfo
+ {
+ DifficultyName = difficultyName
+ }
+ };
+
+ var storyboard = new Storyboard();
+ // no video added
+ var working = new TestWorkingBeatmap(beatmap, storyboard);
+ return new BeatmapVerifierContext.VerifiedBeatmap(working, beatmap);
+ }
+
+ private BeatmapVerifierContext createContext(BeatmapVerifierContext.VerifiedBeatmap current, BeatmapVerifierContext.VerifiedBeatmap[] others)
+ {
+ return new BeatmapVerifierContext(
+ current,
+ others.ToList(),
+ DifficultyRating.ExpertPlus
+ );
+ }
+ }
+}
+
+
diff --git a/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs b/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs
index bbcf6aac2c..c625346645 100644
--- a/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs
+++ b/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs
@@ -539,5 +539,85 @@ namespace osu.Game.Tests.Editing
Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(5000 - OsuHitObject.PREEMPT_MAX));
});
}
+
+ [Test]
+ public void TestPuttingObjectBetweenBreakEndAndAnotherObjectForcesNewCombo()
+ {
+ var controlPoints = new ControlPointInfo();
+ controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 });
+ var beatmap = new EditorBeatmap(new Beatmap
+ {
+ ControlPointInfo = controlPoints,
+ BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo },
+ Difficulty =
+ {
+ ApproachRate = 10,
+ },
+ HitObjects =
+ {
+ new HitCircle { StartTime = 1000, NewCombo = true },
+ new HitCircle { StartTime = 4500 },
+ new HitCircle { StartTime = 5000, NewCombo = true },
+ },
+ Breaks =
+ {
+ new BreakPeriod(2000, 4000),
+ }
+ });
+
+ foreach (var ho in beatmap.HitObjects)
+ ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty);
+
+ var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset());
+ beatmapProcessor.PreProcess();
+ beatmapProcessor.PostProcess();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(((HitCircle)beatmap.HitObjects[1]).NewCombo, Is.True);
+ Assert.That(((HitCircle)beatmap.HitObjects[2]).NewCombo, Is.True);
+
+ Assert.That(((HitCircle)beatmap.HitObjects[0]).ComboIndex, Is.EqualTo(1));
+ Assert.That(((HitCircle)beatmap.HitObjects[1]).ComboIndex, Is.EqualTo(2));
+ Assert.That(((HitCircle)beatmap.HitObjects[2]).ComboIndex, Is.EqualTo(3));
+ });
+ }
+
+ [Test]
+ public void TestAutomaticallyInsertedBreakForcesNewCombo()
+ {
+ var controlPoints = new ControlPointInfo();
+ controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 });
+ var beatmap = new EditorBeatmap(new Beatmap
+ {
+ ControlPointInfo = controlPoints,
+ BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo },
+ Difficulty =
+ {
+ ApproachRate = 10,
+ },
+ HitObjects =
+ {
+ new HitCircle { StartTime = 1000, NewCombo = true },
+ new HitCircle { StartTime = 5000 },
+ },
+ });
+
+ foreach (var ho in beatmap.HitObjects)
+ ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty);
+
+ var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset());
+ beatmapProcessor.PreProcess();
+ beatmapProcessor.PostProcess();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.Breaks, Has.Count.EqualTo(1));
+ Assert.That(((HitCircle)beatmap.HitObjects[1]).NewCombo, Is.True);
+
+ Assert.That(((HitCircle)beatmap.HitObjects[0]).ComboIndex, Is.EqualTo(1));
+ Assert.That(((HitCircle)beatmap.HitObjects[1]).ComboIndex, Is.EqualTo(2));
+ });
+ }
}
}
diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
index 0f8583253b..c081671a48 100644
--- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
+++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
@@ -3,15 +3,14 @@
using System.Linq;
using NUnit.Framework;
-using osu.Framework.Allocation;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Edit;
@@ -26,33 +25,34 @@ namespace osu.Game.Tests.Editing
public partial class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene
{
private TestHitObjectComposer composer = null!;
-
- [Cached(typeof(EditorBeatmap))]
- [Cached(typeof(IBeatSnapProvider))]
- private readonly EditorBeatmap editorBeatmap;
-
- protected override Container Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both };
-
- public TestSceneHitObjectComposerDistanceSnapping()
- {
- base.Content.Add(new Container
- {
- RelativeSizeAxes = Axes.Both,
- Children = new Drawable[]
- {
- editorBeatmap = new EditorBeatmap(new OsuBeatmap
- {
- BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo },
- }),
- Content
- },
- });
- }
+ private EditorBeatmap editorBeatmap = null!;
[SetUp]
public void Setup() => Schedule(() =>
{
- Child = composer = new TestHitObjectComposer();
+ editorBeatmap = new EditorBeatmap(new OsuBeatmap
+ {
+ BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo },
+ });
+
+ Child = new DependencyProvidingContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies =
+ [
+ (typeof(EditorBeatmap), editorBeatmap),
+ (typeof(IBeatSnapProvider), editorBeatmap)
+ ],
+ Children = new Drawable[]
+ {
+ editorBeatmap,
+ new PopoverContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Child = composer = new TestHitObjectComposer()
+ }
+ }
+ };
BeatDivisor.Value = 1;
@@ -67,17 +67,7 @@ namespace osu.Game.Tests.Editing
{
AddStep($"set slider multiplier = {multiplier}", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = multiplier);
- assertSnapDistance(100 * multiplier, null, true);
- }
-
- [TestCase(1)]
- [TestCase(2)]
- public void TestSpeedMultiplierDoesNotChangeDistanceSnap(float multiplier)
- {
- assertSnapDistance(100, new Slider
- {
- SliderVelocityMultiplier = multiplier
- }, false);
+ assertSnapDistance(100 * multiplier);
}
[TestCase(1)]
@@ -87,7 +77,7 @@ namespace osu.Game.Tests.Editing
assertSnapDistance(100 * multiplier, new Slider
{
SliderVelocityMultiplier = multiplier
- }, true);
+ });
}
[TestCase(1)]
@@ -96,7 +86,7 @@ namespace osu.Game.Tests.Editing
{
AddStep($"set divisor = {divisor}", () => BeatDivisor.Value = divisor);
- assertSnapDistance(100f / divisor, null, true);
+ assertSnapDistance(100f / divisor);
}
///
@@ -114,9 +104,8 @@ namespace osu.Game.Tests.Editing
};
AddStep("add to beatmap", () => composer.EditorBeatmap.Add(referenceObject));
- assertSnapDistance(base_distance * slider_velocity, referenceObject, true);
+ assertSnapDistance(base_distance * slider_velocity, referenceObject);
assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject);
- assertSnappedDuration(base_distance * slider_velocity + 10, 1000, referenceObject);
assertDistanceToDuration(base_distance * slider_velocity, 1000, referenceObject);
assertDurationToDistance(1000, base_distance * slider_velocity, referenceObject);
@@ -164,39 +153,6 @@ namespace osu.Game.Tests.Editing
assertDistanceToDuration(400, 1000);
}
- [Test]
- public void TestGetSnappedDurationFromDistance()
- {
- assertSnappedDuration(0, 0);
- assertSnappedDuration(50, 1000);
- assertSnappedDuration(100, 1000);
- assertSnappedDuration(150, 2000);
- assertSnappedDuration(200, 2000);
- assertSnappedDuration(250, 3000);
-
- AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2);
-
- assertSnappedDuration(0, 0);
- assertSnappedDuration(50, 0);
- assertSnappedDuration(100, 1000);
- assertSnappedDuration(150, 1000);
- assertSnappedDuration(200, 1000);
- assertSnappedDuration(250, 1000);
-
- AddStep("set beat length = 500", () =>
- {
- composer.EditorBeatmap.ControlPointInfo.Clear();
- composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 });
- });
-
- assertSnappedDuration(50, 0);
- assertSnappedDuration(100, 500);
- assertSnappedDuration(150, 500);
- assertSnappedDuration(200, 500);
- assertSnappedDuration(250, 500);
- assertSnappedDuration(400, 1000);
- }
-
[Test]
public void GetSnappedDistanceFromDistance()
{
@@ -289,20 +245,24 @@ namespace osu.Game.Tests.Editing
AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False);
}
- private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity)
- => AddAssert($"distance is {expectedDistance}", () => composer.DistanceSnapProvider.GetBeatSnapDistanceAt(referenceObject ?? new HitObject(), includeSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
+ private void assertSnapDistance(float expectedDistance, IHasSliderVelocity? hasSliderVelocity = null)
+ => AddAssert($"distance is {expectedDistance}", () => composer.DistanceSnapProvider.GetBeatSnapDistance(hasSliderVelocity),
+ () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
private void assertDurationToDistance(double duration, float expectedDistance, HitObject? referenceObject = null)
- => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DistanceSnapProvider.DurationToDistance(referenceObject ?? new HitObject(), duration), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
+ => AddAssert($"duration = {duration} -> distance = {expectedDistance}",
+ () => composer.DistanceSnapProvider.DurationToDistance(duration, referenceObject?.StartTime ?? 0, referenceObject as IHasSliderVelocity),
+ () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
private void assertDistanceToDuration(float distance, double expectedDuration, HitObject? referenceObject = null)
- => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceSnapProvider.DistanceToDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON));
-
- private void assertSnappedDuration(float distance, double expectedDuration, HitObject? referenceObject = null)
- => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON));
+ => AddAssert($"distance = {distance} -> duration = {expectedDuration}",
+ () => composer.DistanceSnapProvider.DistanceToDuration(distance, referenceObject?.StartTime ?? 0, referenceObject as IHasSliderVelocity),
+ () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON));
private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null)
- => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance, DistanceSnapTarget.End), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
+ => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)",
+ () => composer.DistanceSnapProvider.FindSnappedDistance(distance, referenceObject?.GetEndTime() ?? 0, referenceObject as IHasSliderVelocity),
+ () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
private partial class TestHitObjectComposer : OsuHitObjectComposer
{
diff --git a/osu.Game.Tests/Extensions/NumberFormattingExtensionsTest.cs b/osu.Game.Tests/Extensions/NumberFormattingExtensionsTest.cs
new file mode 100644
index 0000000000..b02bf01019
--- /dev/null
+++ b/osu.Game.Tests/Extensions/NumberFormattingExtensionsTest.cs
@@ -0,0 +1,54 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Game.Extensions;
+
+namespace osu.Game.Tests.Extensions
+{
+ [TestFixture]
+ public class NumberFormattingExtensionsTest
+ {
+ [TestCase(-1, false, 0, ExpectedResult = "-1")]
+ [TestCase(0, false, 0, ExpectedResult = "0")]
+ [TestCase(1, false, 0, ExpectedResult = "1")]
+ [TestCase(500, false, 10, ExpectedResult = "500")]
+ [TestCase(-1, true, 0, ExpectedResult = "-1%")]
+ [TestCase(0, true, 0, ExpectedResult = "0%")]
+ [TestCase(1, true, 0, ExpectedResult = "1%")]
+ [TestCase(50, true, 0, ExpectedResult = "50%")]
+ public string TestInteger(int input, bool percent, int decimalDigits)
+ {
+ return input.ToStandardFormattedString(decimalDigits, percent);
+ }
+
+ [TestCase(-1, false, 0, ExpectedResult = "-1")]
+ [TestCase(-1e-6, false, 0, ExpectedResult = "0")]
+ [TestCase(-1e-6, false, 6, ExpectedResult = "-0.000001")]
+ [TestCase(0, false, 10, ExpectedResult = "0")]
+ [TestCase(0, false, 0, ExpectedResult = "0")]
+ [TestCase(double.NegativeZero, false, 0, ExpectedResult = "0")]
+ [TestCase(1e-6, false, 0, ExpectedResult = "0")]
+ [TestCase(1e-6, false, 6, ExpectedResult = "0.000001")]
+ [TestCase(1, false, 0, ExpectedResult = "1")]
+ [TestCase(1.528, false, 2, ExpectedResult = "1.53")]
+ [TestCase(500, false, 10, ExpectedResult = "500")]
+ [TestCase(-0.1, true, 0, ExpectedResult = "-10%")]
+ [TestCase(0, true, 0, ExpectedResult = "0%")]
+ [TestCase(0.4, true, 0, ExpectedResult = "40%")]
+ [TestCase(0.48333, true, 2, ExpectedResult = "48%")]
+ [TestCase(0.48333, true, 4, ExpectedResult = "48.33%")]
+ [TestCase(1, true, 0, ExpectedResult = "100%")]
+ public string TestDouble(double input, bool percent, int decimalDigits)
+ {
+ return input.ToStandardFormattedString(decimalDigits, percent);
+ }
+
+ [Test]
+ [SetCulture("fr-FR")]
+ public void TestCultureInsensitivity()
+ {
+ Assert.That(0.4.ToStandardFormattedString(maxDecimalDigits: 2, asPercentage: true), Is.EqualTo("40%"));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs
index 584a9e09c0..18030d7222 100644
--- a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs
+++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs
@@ -359,7 +359,7 @@ namespace osu.Game.Tests.Gameplay
}
public override Judgement CreateJudgement() => new TestJudgement(maxResult);
- protected override HitWindows CreateHitWindows() => new HitWindows();
+ protected override HitWindows CreateHitWindows() => new DefaultHitWindows();
private class TestJudgement : Judgement
{
diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs
index d198ef5074..c9f5f50232 100644
--- a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs
+++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs
@@ -64,11 +64,9 @@ namespace osu.Game.Tests.Gameplay
///
/// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the beatmap skin:
/// normal-hitnormal2
- /// normal-hitnormal
/// hitnormal
///
[TestCase("normal-hitnormal2")]
- [TestCase("normal-hitnormal")]
[TestCase("hitnormal")]
public void TestDefaultCustomSampleFromBeatmap(string expectedSample)
{
@@ -162,7 +160,6 @@ namespace osu.Game.Tests.Gameplay
/// Tests that a control point that provides a custom sample of 2 causes .
///
[TestCase("normal-hitnormal2")]
- [TestCase("normal-hitnormal")]
[TestCase("hitnormal")]
public void TestControlPointCustomSampleFromBeatmap(string sampleName)
{
diff --git a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs
index e31a3dbdf0..2230763984 100644
--- a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs
+++ b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs
@@ -166,11 +166,7 @@ namespace osu.Game.Tests.Mods
///
private BeatmapDifficulty applyDifficulty(BeatmapDifficulty difficulty)
{
- // ensure that ReadFromDifficulty doesn't pollute the values.
var newDifficulty = difficulty.Clone();
-
- testMod.ReadFromDifficulty(difficulty);
-
testMod.ApplyToDifficulty(newDifficulty);
return newDifficulty;
}
diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs
index decb0a31ac..6ec4e799e6 100644
--- a/osu.Game.Tests/Mods/ModUtilsTest.cs
+++ b/osu.Game.Tests/Mods/ModUtilsTest.cs
@@ -2,13 +2,20 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
using System.Linq;
using Moq;
using NUnit.Framework;
+using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Localisation;
+using osu.Game.Online.Rooms;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Catch;
+using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Rulesets.Taiko;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Utils;
@@ -181,98 +188,6 @@ namespace osu.Game.Tests.Mods
},
};
- private static readonly object[] invalid_multiplayer_mod_test_scenarios =
- {
- // incompatible pair.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() },
- new[] { typeof(OsuModHidden), typeof(OsuModApproachDifferent) }
- },
- // incompatible pair with derived class.
- new object[]
- {
- new Mod[] { new OsuModDeflate(), new OsuModApproachDifferent() },
- new[] { typeof(OsuModDeflate), typeof(OsuModApproachDifferent) }
- },
- // system mod.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new OsuModTouchDevice() },
- new[] { typeof(OsuModTouchDevice) }
- },
- // multi mod.
- new object[]
- {
- new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) },
- new[] { typeof(MultiMod) }
- },
- // invalid multiplayer mod.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() },
- new[] { typeof(InvalidMultiplayerMod) }
- },
- // invalid free mod is valid for multiplayer global.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() },
- Array.Empty()
- },
- // valid pair.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new OsuModHardRock() },
- Array.Empty()
- },
- };
-
- private static readonly object[] invalid_free_mod_test_scenarios =
- {
- // system mod.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new OsuModTouchDevice() },
- new[] { typeof(OsuModTouchDevice) }
- },
- // multi mod.
- new object[]
- {
- new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) },
- new[] { typeof(MultiMod) }
- },
- // invalid multiplayer mod.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() },
- new[] { typeof(InvalidMultiplayerMod) }
- },
- // invalid free mod.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() },
- new[] { typeof(InvalidMultiplayerFreeMod) }
- },
- // incompatible pair is valid for free mods.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() },
- Array.Empty(),
- },
- // incompatible pair with derived class is valid for free mods.
- new object[]
- {
- new Mod[] { new OsuModDeflate(), new OsuModSpinIn() },
- Array.Empty(),
- },
- // valid pair.
- new object[]
- {
- new Mod[] { new OsuModHidden(), new OsuModHardRock() },
- Array.Empty()
- },
- };
-
[TestCaseSource(nameof(invalid_mod_test_scenarios))]
public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid)
{
@@ -286,32 +201,6 @@ namespace osu.Game.Tests.Mods
Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
}
- [TestCaseSource(nameof(invalid_multiplayer_mod_test_scenarios))]
- public void TestInvalidMultiplayerModScenarios(Mod[] inputMods, Type[] expectedInvalid)
- {
- bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, out var invalid);
-
- Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0));
-
- if (isValid)
- Assert.IsNull(invalid);
- else
- Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
- }
-
- [TestCaseSource(nameof(invalid_free_mod_test_scenarios))]
- public void TestInvalidFreeModScenarios(Mod[] inputMods, Type[] expectedInvalid)
- {
- bool isValid = ModUtils.CheckValidFreeModsForMultiplayer(inputMods, out var invalid);
-
- Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0));
-
- if (isValid)
- Assert.IsNull(invalid);
- else
- Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
- }
-
[Test]
public void TestModBelongsToRuleset()
{
@@ -342,6 +231,163 @@ namespace osu.Game.Tests.Mods
Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x");
}
+ private static readonly object[] multiplayer_mod_test_scenarios =
+ {
+ // valid - as allowed mod.
+ new MultiplayerTestScenario(false, false, [new OsuModBarrelRoll()], []),
+ new MultiplayerTestScenario(false, true, [new OsuModBarrelRoll()], []),
+ // valid - as allowed mod (incompatible pair).
+ new MultiplayerTestScenario(false, false, [new OsuModHardRock(), new OsuModEasy()], []),
+ new MultiplayerTestScenario(false, true, [new OsuModHardRock(), new OsuModEasy()], []),
+ // valid - as allowed mod (incompatible pair with derived classes).
+ new MultiplayerTestScenario(false, false, [new OsuModDeflate(), new OsuModApproachDifferent()], []),
+ new MultiplayerTestScenario(false, true, [new OsuModDeflate(), new OsuModApproachDifferent()], []),
+ // valid - as allowed mod (not implemented in all rulesets).
+ new MultiplayerTestScenario(false, false, [new OsuModBarrelRoll()], []),
+ new MultiplayerTestScenario(false, true, [new OsuModBarrelRoll()], []),
+ // valid - as required mod.
+ new MultiplayerTestScenario(true, false, [new OsuModStrictTracking()], []),
+ // valid - as required mod when not freestyle.
+ new MultiplayerTestScenario(true, false, [new InvalidFreestyleRequiredMod()], []),
+ // valid - as required mod when freestyle (implemented in all rulesets).
+ new MultiplayerTestScenario(true, true, [new OsuModEasy()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModNoFail()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModHalfTime()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModDaycore()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModHardRock()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModSuddenDeath()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModPerfect()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModDoubleTime()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModNightcore()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModDifficultyAdjust()], []),
+ new MultiplayerTestScenario(true, true, [new ModWindUp()], []),
+ new MultiplayerTestScenario(true, true, [new ModWindDown()], []),
+ new MultiplayerTestScenario(true, true, [new OsuModMuted()], []),
+
+ // invalid - always (system mod)
+ new MultiplayerTestScenario(false, false, [new OsuModTouchDevice()], [typeof(OsuModTouchDevice)]),
+ new MultiplayerTestScenario(true, false, [new OsuModTouchDevice()], [typeof(OsuModTouchDevice)]),
+ // invalid - always (multi mod).
+ new MultiplayerTestScenario(false, false, [new MultiMod()], [typeof(MultiMod)]),
+ new MultiplayerTestScenario(true, false, [new MultiMod()], [typeof(MultiMod)]),
+ // invalid - always (disallowed by mod)
+ new MultiplayerTestScenario(false, false, [new InvalidMultiplayerMod()], [typeof(InvalidMultiplayerMod)]),
+ new MultiplayerTestScenario(true, false, [new InvalidMultiplayerMod()], [typeof(InvalidMultiplayerMod)]),
+ new MultiplayerTestScenario(false, false, [new OsuModAutoplay()], [typeof(OsuModAutoplay)]),
+ new MultiplayerTestScenario(true, false, [new OsuModAutoplay()], [typeof(OsuModAutoplay)]),
+ // invalid - always (changes play length - for now not allowed in multiplayer).
+ new MultiplayerTestScenario(false, false, [new ModAdaptiveSpeed()], [typeof(ModAdaptiveSpeed)]),
+ new MultiplayerTestScenario(true, false, [new ModAdaptiveSpeed()], [typeof(ModAdaptiveSpeed)]),
+ // invalid - as allowed mod (disallowed by mod).
+ new MultiplayerTestScenario(false, false, [new InvalidMultiplayerFreeMod()], [typeof(InvalidMultiplayerFreeMod)]),
+ new MultiplayerTestScenario(false, true, [new InvalidMultiplayerFreeMod()], [typeof(InvalidMultiplayerFreeMod)]),
+ // invalid - as allowed mod (changes play length - for now not allowed in multiplayer).
+ new MultiplayerTestScenario(false, false, [new OsuModHalfTime()], [typeof(OsuModHalfTime)]),
+ new MultiplayerTestScenario(false, false, [new OsuModDaycore()], [typeof(OsuModDaycore)]),
+ new MultiplayerTestScenario(false, false, [new OsuModDoubleTime()], [typeof(OsuModDoubleTime)]),
+ new MultiplayerTestScenario(false, false, [new OsuModNightcore()], [typeof(OsuModNightcore)]),
+ // invalid - as required mod (incompatible pair)
+ new MultiplayerTestScenario(true, false, [new OsuModHidden(), new OsuModApproachDifferent()], [typeof(OsuModHidden), typeof(OsuModApproachDifferent)]),
+ new MultiplayerTestScenario(true, true, [new OsuModHidden(), new OsuModApproachDifferent()], [typeof(OsuModHidden), typeof(OsuModApproachDifferent)]),
+ new MultiplayerTestScenario(true, false, [new OsuModDeflate(), new OsuModApproachDifferent()], [typeof(OsuModDeflate), typeof(OsuModApproachDifferent)]),
+ new MultiplayerTestScenario(true, true, [new OsuModDeflate(), new OsuModApproachDifferent()], [typeof(OsuModDeflate), typeof(OsuModApproachDifferent)]),
+ // invalid - as required mod when freestyle (disallowed by mod).
+ new MultiplayerTestScenario(true, true, [new InvalidFreestyleRequiredMod()], [typeof(InvalidFreestyleRequiredMod)]),
+ // invalid - as required mod when freestyle (not implemented in all rulesets).
+ new MultiplayerTestScenario(true, true, [new OsuModStrictTracking()], [typeof(OsuModStrictTracking)]),
+ new MultiplayerTestScenario(true, true, [new OsuModBarrelRoll()], [typeof(OsuModBarrelRoll)]),
+ };
+
+ [TestCaseSource(nameof(multiplayer_mod_test_scenarios))]
+ public void TestMultiplayerModScenarios(MultiplayerTestScenario scenario)
+ {
+ List? invalidMods;
+ bool isValid = scenario.IsRequired
+ ? ModUtils.CheckValidRequiredModsForMultiplayer(scenario.Mods, scenario.IsFreestyle, out invalidMods)
+ : ModUtils.CheckValidAllowedModsForMultiplayer(scenario.Mods, scenario.IsFreestyle, out invalidMods);
+
+ Assert.That(isValid, Is.EqualTo(scenario.InvalidTypes.Length == 0));
+
+ if (isValid)
+ Assert.IsNull(invalidMods);
+ else
+ Assert.That(invalidMods?.Select(t => t.GetType()), Is.EquivalentTo(scenario.InvalidTypes));
+ }
+
+ [Test]
+ public void TestPlaylistsModScenarios()
+ {
+ // The rest are tested by TestMultiplayerModScenarios.
+ Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), false, MatchType.Playlists, false));
+ Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), true, MatchType.Playlists, false));
+ Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), false, MatchType.Playlists, false));
+ Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), true, MatchType.Playlists, false));
+ Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), false, MatchType.Playlists, false));
+ Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), true, MatchType.Playlists, false));
+ }
+
+ [Test]
+ public void TestFreestyleRulesetCompatibility()
+ {
+ HashSet commonAcronyms = new HashSet();
+
+ commonAcronyms.UnionWith(new OsuRuleset().CreateAllMods().Select(m => m.Acronym));
+ commonAcronyms.IntersectWith(new TaikoRuleset().CreateAllMods().Select(m => m.Acronym));
+ commonAcronyms.IntersectWith(new CatchRuleset().CreateAllMods().Select(m => m.Acronym));
+ commonAcronyms.IntersectWith(new ManiaRuleset().CreateAllMods().Select(m => m.Acronym));
+
+ Assert.Multiple(() =>
+ {
+ foreach (var ruleset in new Ruleset[] { new OsuRuleset(), new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset() })
+ {
+ foreach (var mod in ruleset.CreateAllMods())
+ {
+ if (mod.ValidForFreestyleAsRequiredMod && !mod.UserPlayable)
+ Assert.Fail($"Mod {mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyleAsRequiredMod)} but is not playable!");
+
+ if (mod.ValidForFreestyleAsRequiredMod && !mod.HasImplementation)
+ Assert.Fail($"Mod {mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyleAsRequiredMod)} but is not implemented!");
+
+ if (mod.ValidForFreestyleAsRequiredMod && mod.UserPlayable && mod.HasImplementation && !commonAcronyms.Contains(mod.Acronym))
+ Assert.Fail($"{mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyleAsRequiredMod)} but does not exist in all four basic rulesets!");
+ }
+ }
+ });
+ }
+
+ [Test]
+ public void TestModsValidForRequiredFreestyleAreConsistentlyCompatibleAcrossRulesets()
+ {
+ Dictionary<(string firstMod, string secondMod), bool> compatibilityMap = new Dictionary<(string, string), bool>();
+
+ Assert.Multiple(() =>
+ {
+ for (int rulesetId = 0; rulesetId < 4; ++rulesetId)
+ {
+ var rulesetStore = new AssemblyRulesetStore();
+ var ruleset = rulesetStore.GetRuleset(rulesetId)!.CreateInstance();
+
+ var modsValidForFreestyleAsRequired = ruleset.CreateAllMods().Where(m => m.ValidForFreestyleAsRequiredMod).OrderBy(m => m.Acronym).ToList();
+
+ for (int i = 0; i < modsValidForFreestyleAsRequired.Count; i++)
+ {
+ for (int j = i; j < modsValidForFreestyleAsRequired.Count; ++j)
+ {
+ var first = modsValidForFreestyleAsRequired[i];
+ var second = modsValidForFreestyleAsRequired[j];
+
+ bool compatible = ModUtils.CheckCompatibleSet([first, second]);
+
+ if (!compatibilityMap.TryGetValue((first.Acronym, second.Acronym), out bool previousCompatible))
+ compatibilityMap[(first.Acronym, second.Acronym)] = compatible;
+ else if (previousCompatible != compatible)
+ Assert.Fail($"{first.Acronym} and {second.Acronym} declare {nameof(Mod.ValidForFreestyleAsRequiredMod)} while not being consistently compatible in all four rulesets!");
+ }
+ }
+ }
+ });
+ }
+
public abstract class CustomMod1 : Mod, IModCompatibilitySpecification
{
}
@@ -350,7 +396,7 @@ namespace osu.Game.Tests.Mods
{
}
- public class InvalidMultiplayerMod : Mod
+ private class InvalidMultiplayerMod : Mod
{
public override string Name => string.Empty;
public override LocalisableString Description => string.Empty;
@@ -371,18 +417,22 @@ namespace osu.Game.Tests.Mods
public override bool ValidForMultiplayerAsFreeMod => false;
}
- public class EditableMod : Mod
+ public class InvalidFreestyleRequiredMod : Mod
{
public override string Name => string.Empty;
public override LocalisableString Description => string.Empty;
+ public override double ScoreMultiplier => 1;
public override string Acronym => string.Empty;
- public override double ScoreMultiplier => Multiplier;
-
- public double Multiplier = 1;
+ public override bool HasImplementation => true;
+ public override bool ValidForFreestyleAsRequiredMod => false;
}
- public interface IModCompatibilitySpecification
+ public interface IModCompatibilitySpecification;
+
+ public readonly record struct MultiplayerTestScenario(bool IsRequired, bool IsFreestyle, Mod[] Mods, Type[] InvalidTypes)
{
+ public override string ToString()
+ => $"{IsRequired}, {IsFreestyle}, [{string.Join(',', Mods.Select(m => m.GetType().ReadableName()))}], [{string.Join(',', InvalidTypes.Select(t => t.ReadableName()))}]";
}
}
}
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
index 1efcc8542d..db76782350 100644
--- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Collections.Generic;
+using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
@@ -40,6 +42,11 @@ namespace osu.Game.Tests.NonVisual.Filtering
Author = { Username = "The Author" },
Source = "unit tests",
Tags = "look for tags too",
+ UserTags =
+ {
+ "song representation/simple",
+ "style/clean",
+ }
},
DifficultyName = "version as well",
Length = 2500,
@@ -292,6 +299,69 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(filtered, carouselItem.Filtered.Value);
}
+ [TestCase("simple", false)]
+ [TestCase("\"style/clean\"", false)]
+ [TestCase("\"style/clean\"!", false)]
+ [TestCase("iNiS-style", true)]
+ [TestCase("\"reading/visually dense\"!", true)]
+ public void TestCriteriaMatchingUserTags(string query, bool filtered)
+ {
+ var beatmap = getExampleBeatmap();
+ var criteria = new FilterCriteria { UserTags = [new FilterCriteria.OptionalTextFilter { SearchTerm = query }] };
+ var carouselItem = new CarouselBeatmap(beatmap);
+ carouselItem.Filter(criteria);
+
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ public void TestCriteriaMatchingMultipleTagsAtOnce()
+ {
+ var beatmap = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ UserTags =
+ [
+ new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!" },
+ new FilterCriteria.OptionalTextFilter { SearchTerm = "\"style/clean\"!" }
+ ]
+ };
+ var carouselItem = new CarouselBeatmap(beatmap);
+ carouselItem.Filter(criteria);
+
+ Assert.AreEqual(false, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ public void TestCriteriaAllTagFiltersMustMatch()
+ {
+ var beatmap = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ UserTags =
+ [
+ new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!" },
+ new FilterCriteria.OptionalTextFilter { SearchTerm = "\"style/dirty\"!" }
+ ]
+ };
+ var carouselItem = new CarouselBeatmap(beatmap);
+ carouselItem.Filter(criteria);
+
+ Assert.AreEqual(true, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive()
+ {
+ var beatmap = getExampleBeatmap();
+ var criteria = new FilterCriteria { UserTags = [new FilterCriteria.OptionalTextFilter { SearchTerm = "simple" }] };
+ var carouselItem = new CarouselBeatmap(beatmap);
+ carouselItem.BeatmapInfo.Metadata.UserTags.Clear();
+ carouselItem.Filter(criteria);
+
+ Assert.True(carouselItem.Filtered.Value);
+ }
+
[Test]
public void TestCustomRulesetCriteria([Values(null, true, false)] bool? matchCustomCriteria)
{
@@ -305,6 +375,167 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(matchCustomCriteria == false, carouselItem.Filtered.Value);
}
+ [TestCase("title!=Title", new[] { 2, 4, 6 })]
+ [TestCase("title!=\"Title1\"", new[] { 2, 3, 4, 5, 6 })]
+ [TestCase("title!=\"Title1\"!", new[] { 2, 3, 4, 5, 6 })]
+ public void TestNotEqualSearchForTextFilters(string query, int[] expectedBeatmapIndexes)
+ {
+ string[] titles =
+ [
+ "Title1",
+ "Title1",
+ "My[Favourite]Song",
+ "Title",
+ "Another One",
+ "Diff in title",
+ "a",
+ ];
+
+ var carouselBeatmaps = titles.Select(title => new CarouselBeatmap(new BeatmapInfo
+ {
+ Metadata = new BeatmapMetadata
+ {
+ Title = title,
+ },
+ })).ToList();
+
+ var criteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(criteria, query);
+ carouselBeatmaps.ForEach(b => b.Filter(criteria));
+
+ int[] visibleBeatmaps = carouselBeatmaps
+ .Where(b => !b.Filtered.Value)
+ .Select(b => carouselBeatmaps.IndexOf(b)).ToArray();
+ Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes));
+ }
+
+ [Test]
+ public void TestNotEqualSearchForNumberFilters()
+ {
+ double[] starRatings =
+ [
+ 2.78,
+ 1.78,
+ 1.55,
+ 3.78,
+ 1.78,
+ 1.55,
+ 2.78
+ ];
+
+ var carouselBeatmaps = starRatings.Select(starRating => new CarouselBeatmap(new BeatmapInfo
+ {
+ StarRating = starRating,
+ })).ToList();
+
+ var criteria = new FilterCriteria();
+
+ FilterQueryParser.ApplyQueries(criteria, "star!=1.78");
+ carouselBeatmaps.ForEach(b => b.Filter(criteria));
+
+ int[] visibleBeatmaps = carouselBeatmaps
+ .Where(b => !b.Filtered.Value)
+ .Select(b => carouselBeatmaps.IndexOf(b)).ToArray();
+
+ Assert.That(visibleBeatmaps, Is.EqualTo(new[] { 0, 2, 3, 5, 6 }));
+ }
+
+ [TestCase("status!=ranked", new[] { 1, 2, 4, 5 })]
+ [TestCase("status!=r", new[] { 1, 2, 4, 5 })]
+ [TestCase("status!=loved", new[] { 0, 1, 2, 3, 4, 6 })]
+ [TestCase("status!=l", new[] { 0, 1, 2, 3, 4, 6 })]
+ [TestCase("status!=r,l", new[] { 1, 2, 4 })]
+ public void TestNotEqualSearchForEnumFilter(string query, int[] expectedBeatmapIndexes)
+ {
+ var carouselBeatmaps = new[]
+ {
+ BeatmapOnlineStatus.Ranked,
+ BeatmapOnlineStatus.Qualified,
+ BeatmapOnlineStatus.Approved,
+ BeatmapOnlineStatus.Ranked,
+ BeatmapOnlineStatus.Approved,
+ BeatmapOnlineStatus.Loved,
+ BeatmapOnlineStatus.Ranked
+ }.Select(info => new CarouselBeatmap(new BeatmapInfo
+ {
+ Status = info
+ })).ToList();
+
+ var criteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(criteria, query);
+ carouselBeatmaps.ForEach(b => b.Filter(criteria));
+
+ int[] visibleBeatmaps = carouselBeatmaps
+ .Where(b => !b.Filtered.Value)
+ .Select(b => carouselBeatmaps.IndexOf(b)).ToArray();
+
+ Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes));
+ }
+
+ [TestCase("played!=1", new[] { 1, 4, 5 })]
+ [TestCase("played!=0", new[] { 0, 2, 3, 6, 7 })]
+ public void TestNotEqualSearchForBooleanFilter(string query, int[] expectedBeatmapIndexes)
+ {
+ var carouselBeatmaps = (new DateTimeOffset?[]
+ {
+ new DateTimeOffset(2012, 10, 21, 12, 13, 24, TimeSpan.Zero),
+ null,
+ new DateTimeOffset(2012, 11, 12, 23, 10, 13, TimeSpan.Zero),
+ new DateTimeOffset(2013, 2, 13, 11, 43, 23, TimeSpan.Zero),
+ null,
+ null,
+ new DateTimeOffset(2014, 1, 15, 20, 13, 24, TimeSpan.Zero),
+ new DateTimeOffset(2014, 11, 16, 0, 13, 23, TimeSpan.Zero),
+ }).Select(lastPlayed => new CarouselBeatmap(new BeatmapInfo
+ {
+ LastPlayed = lastPlayed
+ })).ToList();
+
+ var criteria = new FilterCriteria();
+
+ FilterQueryParser.ApplyQueries(criteria, query);
+ carouselBeatmaps.ForEach(b => b.Filter(criteria));
+
+ int[] visibleBeatmaps = carouselBeatmaps
+ .Where(b => !b.Filtered.Value)
+ .Select(b => carouselBeatmaps.IndexOf(b)).ToArray();
+
+ Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes));
+ }
+
+ [TestCase("ranked!=2012", new[] { 3, 4, 5, 6, 7 })]
+ [TestCase("ranked!=2012.11", new[] { 0, 1, 3, 4, 5, 6, 7 })]
+ [TestCase("ranked!=2012.10.21", new[] { 1, 2, 3, 4, 5, 6, 7 })]
+ public void TestNotEqualSearchForDateFilter(string query, int[] expectedBeatmapIndexes)
+ {
+ var carouselBeatmaps = new[]
+ {
+ new DateTimeOffset(2012, 10, 21, 13, 42, 13, TimeSpan.Zero),
+ new DateTimeOffset(2012, 10, 11, 2, 33, 43, TimeSpan.Zero),
+ new DateTimeOffset(2012, 11, 12, 10, 22, 32, TimeSpan.Zero),
+ new DateTimeOffset(2013, 2, 13, 5, 19, 0, TimeSpan.Zero),
+ new DateTimeOffset(2013, 2, 13, 11, 23, 35, TimeSpan.Zero),
+ new DateTimeOffset(2013, 3, 14, 9, 9, 1, TimeSpan.Zero),
+ new DateTimeOffset(2014, 1, 15, 10, 5, 0, TimeSpan.Zero),
+ new DateTimeOffset(2014, 11, 16, 23, 27, 0, TimeSpan.Zero),
+ }.Select(dateRanked => new CarouselBeatmap(new BeatmapInfo
+ {
+ BeatmapSet = new BeatmapSetInfo
+ {
+ DateRanked = dateRanked,
+ }
+ })).ToList();
+ var criteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(criteria, query);
+ carouselBeatmaps.ForEach(b => b.Filter(criteria));
+
+ int[] visibleBeatmaps = carouselBeatmaps
+ .Where(b => !b.Filtered.Value)
+ .Select(b => carouselBeatmaps.IndexOf(b)).ToArray();
+
+ Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes));
+ }
+
private class CustomCriteria : IRulesetFilterCriteria
{
private readonly bool match;
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
index f4e324d7ba..9968647cb2 100644
--- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
@@ -85,16 +85,6 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
}
- /*
- * The following tests have been written a bit strangely (they don't check exact
- * bound equality with what the filter says).
- * This is to account for floating-point arithmetic issues.
- * For example, specifying a bpm<140 filter would previously match beatmaps with BPM
- * of 139.99999, which would be displayed in the UI as 140.
- * Due to this the tests check the last tick inside the range and the first tick
- * outside of the range.
- */
-
[TestCase("star")]
[TestCase("stars")]
public void TestApplyStarQueries(string variant)
@@ -105,11 +95,31 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("easy", filterCriteria.SearchText.Trim());
Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
Assert.IsNotNull(filterCriteria.StarDifficulty.Max);
- Assert.Greater(filterCriteria.StarDifficulty.Max, 3.99d);
- Assert.Less(filterCriteria.StarDifficulty.Max, 4.00d);
+ Assert.AreEqual(filterCriteria.StarDifficulty.Max, 4.00d);
Assert.IsNull(filterCriteria.StarDifficulty.Min);
}
+ [Test]
+ public void TestStarQueriesInclusive()
+ {
+ const string query = "stars>=6";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual(filterCriteria.StarDifficulty.Min, 6.00d);
+ Assert.True(filterCriteria.StarDifficulty.IsLowerInclusive);
+ Assert.IsNull(filterCriteria.StarDifficulty.Max);
+ }
+
+ /*
+ * The following tests have been written a bit strangely (they don't check exact
+ * bound equality with what the filter says).
+ * This is to account for floating-point arithmetic issues.
+ * For example, specifying a bpm<140 filter would previously match beatmaps with BPM
+ * of 139.99999, which would be displayed in the UI as 140.
+ * Due to this the tests check the last tick inside the range and the first tick
+ * outside of the range.
+ */
+
[Test]
public void TestApplyApproachRateQueries()
{
@@ -284,6 +294,16 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.That(filterCriteria.OnlineStatus.Values, Is.Empty);
}
+ [Test]
+ public void TestPartialStatusNotMatch()
+ {
+ const string query = "status!=r";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.IsNotEmpty(filterCriteria.OnlineStatus.Values);
+ Assert.That(filterCriteria.OnlineStatus.Values, Does.Not.Contain(BeatmapOnlineStatus.Ranked));
+ }
+
[Test]
public void TestApplyEqualStatusQueryWithMultipleValues()
{
@@ -746,5 +766,17 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter);
Assert.AreEqual(matched, filterCriteria.LastPlayed.IsInRange(reference));
}
+
+ [Test]
+ public void TestMultipleTextFilters()
+ {
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, "tag=\"simple\" tag=\"clean\"!");
+ Assert.That(filterCriteria.UserTags, Has.Count.EqualTo(2));
+ Assert.That(filterCriteria.UserTags[0].SearchTerm, Is.EqualTo("simple"));
+ Assert.That(filterCriteria.UserTags[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
+ Assert.That(filterCriteria.UserTags[1].SearchTerm, Is.EqualTo("clean"));
+ Assert.That(filterCriteria.UserTags[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
+ }
}
}
diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs
index 07d6d68e82..69c98351ad 100644
--- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs
+++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs
@@ -33,7 +33,7 @@ namespace osu.Game.Tests.NonVisual
public void TestResultIfOnlyParentHitWindowIsEmpty()
{
var testObject = new TestHitObject(HitWindows.Empty);
- HitObject nested = new TestHitObject(new HitWindows());
+ HitObject nested = new TestHitObject(new DefaultHitWindows());
testObject.AddNested(nested);
testDrawableRuleset.HitObjects = new List { testObject };
@@ -43,8 +43,8 @@ namespace osu.Game.Tests.NonVisual
[Test]
public void TestResultIfParentHitWindowsIsNotEmpty()
{
- var testObject = new TestHitObject(new HitWindows());
- HitObject nested = new TestHitObject(new HitWindows());
+ var testObject = new TestHitObject(new DefaultHitWindows());
+ HitObject nested = new TestHitObject(new DefaultHitWindows());
testObject.AddNested(nested);
testDrawableRuleset.HitObjects = new List { testObject };
@@ -58,7 +58,7 @@ namespace osu.Game.Tests.NonVisual
HitObject nested = new TestHitObject(HitWindows.Empty);
firstObject.AddNested(nested);
- var secondObject = new TestHitObject(new HitWindows());
+ var secondObject = new TestHitObject(new DefaultHitWindows());
testDrawableRuleset.HitObjects = new List { firstObject, secondObject };
Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, secondObject.HitWindows);
@@ -101,7 +101,6 @@ namespace osu.Game.Tests.NonVisual
public override Container FrameStableComponents { get; }
public override IFrameStableClock FrameStableClock { get; }
internal override bool FrameStablePlayback { get; set; }
- public override bool AllowBackwardsSeeks { get; set; }
public override IReadOnlyList Mods { get; }
public override double GameplayStartTime { get; }
diff --git a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs
index a12658bd8b..0fcf754cf6 100644
--- a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs
+++ b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs
@@ -21,5 +21,18 @@ namespace osu.Game.Tests.NonVisual
{
Assert.AreEqual(expectedOutput, input.FormatAccuracy().ToString());
}
+
+ [TestCase(3, "3.00")]
+ [TestCase(3.3, "3.30")]
+ [TestCase(3.55, "3.55")]
+ [TestCase(3.553, "3.55")]
+ [TestCase(3.557, "3.55")]
+ [TestCase(3.9999, "3.99")]
+ [TestCase(3.999999, "3.99")]
+ [TestCase(4, "4.00")]
+ public void TestStarRatingFormatting(double input, string expectedOutput)
+ {
+ Assert.AreEqual(expectedOutput, input.FormatStarRating().ToString());
+ }
}
}
diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs
index 541ad1e8bb..ffb21f124c 100644
--- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs
+++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs
@@ -383,6 +383,9 @@ namespace osu.Game.Tests.NonVisual
IsImportant = isImportant;
FrameIndex = frameIndex;
}
+
+ public override bool IsEquivalentTo(ReplayFrame other)
+ => other is TestReplayFrame testFrame && Time == testFrame.Time && IsImportant == testFrame.IsImportant && FrameIndex == testFrame.FrameIndex;
}
private class TestInputHandler : FramedReplayInputHandler
diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
index 559db16751..8364e58bdc 100644
--- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
+++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
@@ -6,9 +6,9 @@ using Humanizer;
using NUnit.Framework;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
+using osu.Game.Extensions;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
-using osu.Game.Online.Rooms;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.NonVisual.Multiplayer
@@ -16,6 +16,13 @@ namespace osu.Game.Tests.NonVisual.Multiplayer
[HeadlessTest]
public partial class StatefulMultiplayerClientTest : MultiplayerTestScene
{
+ public override void SetUpSteps()
+ {
+ base.SetUpSteps();
+ AddStep("join room", () => JoinRoom(CreateDefaultRoom()));
+ WaitForJoined();
+ }
+
[Test]
public void TestUserAddedOnJoin()
{
@@ -72,10 +79,6 @@ namespace osu.Game.Tests.NonVisual.Multiplayer
AddStep("create room initially in gameplay", () =>
{
- var newRoom = new Room();
- newRoom.CopyFrom(SelectedRoom.Value!);
-
- newRoom.RoomID = null;
MultiplayerClient.RoomSetupAction = room =>
{
room.State = MultiplayerRoomState.Playing;
@@ -86,13 +89,32 @@ namespace osu.Game.Tests.NonVisual.Multiplayer
});
};
- RoomManager.CreateRoom(newRoom);
+ MultiplayerClient.JoinRoom(MultiplayerClient.ServerSideRooms.Single()).ConfigureAwait(false);
});
AddUntilStep("wait for room join", () => RoomJoined);
checkPlayingUserCount(1);
}
+ [Test]
+ public void TestJoinRoomWithManyUsers()
+ {
+ AddStep("leave room", () => MultiplayerClient.LeaveRoom());
+ AddUntilStep("wait for room part", () => !RoomJoined);
+
+ AddStep("create room with many users", () =>
+ {
+ MultiplayerClient.RoomSetupAction = room =>
+ {
+ room.Users.AddRange(Enumerable.Range(PLAYER_1_ID, 100).Select(id => new MultiplayerRoomUser(id)));
+ };
+
+ MultiplayerClient.JoinRoom(MultiplayerClient.ServerSideRooms.Single()).ConfigureAwait(false);
+ });
+
+ AddUntilStep("wait for room join", () => RoomJoined);
+ }
+
private void checkPlayingUserCount(int expectedCount)
=> AddAssert($"{"user".ToQuantity(expectedCount)} playing", () => MultiplayerClient.CurrentMatchPlayingUserIds.Count == expectedCount);
diff --git a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs
index 03dc91b5d4..18ac5b4964 100644
--- a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs
+++ b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs
@@ -36,6 +36,10 @@ namespace osu.Game.Tests.NonVisual.Ranking
.Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null))
.ToList();
+ // Add some red herrings
+ events.Insert(4, new HitEvent(200, 1.0, HitResult.Meh, new HitObject { HitWindows = HitWindows.Empty }, null, null));
+ events.Insert(8, new HitEvent(-100, 1.0, HitResult.Miss, new HitObject(), null, null));
+
HitEventExtensions.UnstableRateCalculationResult result = null;
for (int i = 0; i < events.Count; i++)
@@ -57,6 +61,10 @@ namespace osu.Game.Tests.NonVisual.Ranking
.Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null))
.ToList();
+ // Add some red herrings
+ events.Insert(4, new HitEvent(200, 1.0, HitResult.Meh, new HitObject { HitWindows = HitWindows.Empty }, null, null));
+ events.Insert(8, new HitEvent(-100, 1.0, HitResult.Miss, new HitObject(), null, null));
+
HitEventExtensions.UnstableRateCalculationResult result = null;
for (int i = 0; i < events.Count; i++)
diff --git a/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs
new file mode 100644
index 0000000000..e20fb50722
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/TestSceneUpdateManager.cs
@@ -0,0 +1,219 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Testing;
+using osu.Game.Configuration;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Notifications;
+using osu.Game.Tests.Visual;
+using osu.Game.Updater;
+
+namespace osu.Game.Tests.NonVisual
+{
+ [HeadlessTest]
+ public partial class TestSceneUpdateManager : OsuTestScene
+ {
+ [Cached(typeof(INotificationOverlay))]
+ private readonly INotificationOverlay notifications = new TestNotificationOverlay();
+
+ private TestUpdateManager manager = null!;
+ private OsuConfigManager config = null!;
+
+ [SetUpSteps]
+ public void SetupSteps()
+ {
+ AddStep("add manager", () =>
+ {
+ config = new OsuConfigManager(LocalStorage);
+ config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer);
+
+ Child = new DependencyProvidingContainer
+ {
+ CachedDependencies = [(typeof(OsuConfigManager), config)],
+ Child = manager = new TestUpdateManager()
+ };
+ });
+
+ // Updates should be checked when the object is loaded for the first time.
+ AddUntilStep("check pending", () => manager.IsPending);
+ AddStep("complete check", () => manager.Complete());
+ AddUntilStep("1 check completed", () => manager.Completions, () => Is.EqualTo(1));
+ AddUntilStep("no check pending", () => !manager.IsPending);
+ }
+
+ [TearDownSteps]
+ public void TeardownSteps()
+ {
+ // Importantly, this immediately saves the config, which cancels any pending background save.
+ AddStep("dispose config manager", () => config.Dispose());
+ }
+
+ ///
+ /// Updates should be checked when the release stream is changed.
+ ///
+ [Test]
+ public void TestReleaseStreamChanged()
+ {
+ AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon));
+
+ AddUntilStep("check pending", () => manager.IsPending);
+ AddStep("complete check", () => manager.Complete());
+ AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2));
+ AddUntilStep("no check pending", () => !manager.IsPending);
+
+ AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer));
+
+ AddUntilStep("check pending", () => manager.IsPending);
+ AddStep("complete check", () => manager.Complete());
+ AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3));
+ AddUntilStep("no check pending", () => !manager.IsPending);
+ }
+
+ ///
+ /// Changing the release stream should start a new invocation and cancel the existing one.
+ ///
+ [Test]
+ public void TestNewInvocationOnReleaseStreamChanged()
+ {
+ AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon));
+ AddUntilStep("check pending", () => manager.IsPending);
+ AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer));
+ AddUntilStep("3 invocations", () => manager.Invocations, () => Is.EqualTo(3));
+
+ AddStep("complete check", () => manager.Complete());
+ AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2));
+ AddUntilStep("no check pending", () => !manager.IsPending);
+ }
+
+ ///
+ /// Updates should be checked when the user requests them to.
+ ///
+ [Test]
+ public void TestUserRequest()
+ {
+ AddStep("request check", () => manager.CheckForUpdate());
+
+ AddUntilStep("check pending", () => manager.IsPending);
+ AddStep("complete check", () => manager.Complete());
+ AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2));
+ AddUntilStep("no check pending", () => !manager.IsPending);
+
+ AddStep("request check", () => manager.CheckForUpdate());
+
+ AddUntilStep("check pending", () => manager.IsPending);
+ AddStep("complete check", () => manager.Complete());
+ AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3));
+ AddUntilStep("no check pending", () => !manager.IsPending);
+ }
+
+ ///
+ /// User requests should start a new invocation and cancel the existing one.
+ ///
+ [Test]
+ public void TestUserRequestOverridesExistingCheck()
+ {
+ // This part covering double user input is not really possible because the settings button is disabled during the check,
+ // but it's kept here for sanity in-case the update manager is used as a standalone object elsewhere.
+
+ AddStep("request check", () => manager.CheckForUpdate());
+ AddUntilStep("check pending", () => manager.IsPending);
+ AddStep("request check", () => manager.CheckForUpdate());
+ AddUntilStep("3 invocations", () => manager.Invocations, () => Is.EqualTo(3));
+
+ AddStep("complete check", () => manager.Complete());
+ AddUntilStep("2 checks completed", () => manager.Completions, () => Is.EqualTo(2));
+ AddUntilStep("no check pending", () => !manager.IsPending);
+
+ // This next part tests for the user requesting an update during a background check, and is possible to occur in practice.
+
+ AddStep("change release stream", () => config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Tachyon));
+ AddUntilStep("check pending", () => manager.IsPending);
+ AddStep("request check", () => manager.CheckForUpdate());
+ AddUntilStep("5 invocations", () => manager.Invocations, () => Is.EqualTo(5));
+
+ AddStep("complete check", () => manager.Complete());
+ AddUntilStep("3 checks completed", () => manager.Completions, () => Is.EqualTo(3));
+ AddUntilStep("no check pending", () => !manager.IsPending);
+ }
+
+ [Test]
+ public void TestFixedReleaseStreamWrittenToConfig()
+ {
+ AddStep("add manager", () =>
+ {
+ config = new OsuConfigManager(LocalStorage);
+ config.SetValue(OsuSetting.ReleaseStream, ReleaseStream.Lazer);
+
+ Child = new DependencyProvidingContainer
+ {
+ CachedDependencies = [(typeof(OsuConfigManager), config)],
+ Child = manager = new TestUpdateManager(ReleaseStream.Tachyon)
+ };
+ });
+
+ AddAssert("release stream set to tachyon", () => config.Get(OsuSetting.ReleaseStream), () => Is.EqualTo(ReleaseStream.Tachyon));
+ }
+
+ private partial class TestUpdateManager : UpdateManager
+ {
+ public override ReleaseStream? FixedReleaseStream { get; }
+
+ public bool IsPending { get; private set; }
+ public int Invocations { get; private set; }
+ public int Completions { get; private set; }
+
+ private TaskCompletionSource? pendingCheck;
+
+ public TestUpdateManager(ReleaseStream? fixedReleaseStream = null)
+ {
+ FixedReleaseStream = fixedReleaseStream;
+ }
+
+ protected override async Task PerformUpdateCheck(CancellationToken cancellationToken)
+ {
+ Invocations++;
+
+ var check = pendingCheck = new TaskCompletionSource();
+ IsPending = true;
+
+ try
+ {
+ bool result = await check.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
+ Completions++;
+ return result;
+ }
+ finally
+ {
+ IsPending = false;
+ }
+ }
+
+ public void Complete()
+ {
+ pendingCheck?.SetResult(true);
+ }
+ }
+
+ private partial class TestNotificationOverlay : INotificationOverlay
+ {
+ public void Post(Notification notification)
+ {
+ }
+
+ public void Hide()
+ {
+ }
+
+ public IBindable UnreadCount { get; } = new Bindable();
+
+ public IEnumerable AllNotifications { get; } = Enumerable.Empty();
+ }
+ }
+}
diff --git a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs
index e4118a23b4..a391ec4066 100644
--- a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs
+++ b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs
@@ -12,79 +12,79 @@ namespace osu.Game.Tests.Online.Chat
[Test]
public void TestContainsUsernameMidlinePositive()
{
- Assert.IsTrue(MessageNotifier.CheckContainsUsername("This is a test message", "Test"));
+ Assert.IsTrue(MessageNotifier.MatchUsername("This is a test message", "Test").Success);
}
[Test]
public void TestContainsUsernameStartOfLinePositive()
{
- Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test message", "Test"));
+ Assert.IsTrue(MessageNotifier.MatchUsername("Test message", "Test").Success);
}
[Test]
public void TestContainsUsernameEndOfLinePositive()
{
- Assert.IsTrue(MessageNotifier.CheckContainsUsername("This is a test", "Test"));
+ Assert.IsTrue(MessageNotifier.MatchUsername("This is a test", "Test").Success);
}
[Test]
public void TestContainsUsernameMidlineNegative()
{
- Assert.IsFalse(MessageNotifier.CheckContainsUsername("This is a testmessage for notifications", "Test"));
+ Assert.IsFalse(MessageNotifier.MatchUsername("This is a testmessage for notifications", "Test").Success);
}
[Test]
public void TestContainsUsernameStartOfLineNegative()
{
- Assert.IsFalse(MessageNotifier.CheckContainsUsername("Testmessage", "Test"));
+ Assert.IsFalse(MessageNotifier.MatchUsername("Testmessage", "Test").Success);
}
[Test]
public void TestContainsUsernameEndOfLineNegative()
{
- Assert.IsFalse(MessageNotifier.CheckContainsUsername("This is a notificationtest", "Test"));
+ Assert.IsFalse(MessageNotifier.MatchUsername("This is a notificationtest", "Test").Success);
}
[Test]
public void TestContainsUsernameBetweenPunctuation()
{
- Assert.IsTrue(MessageNotifier.CheckContainsUsername("Hello 'test'-message", "Test"));
+ Assert.IsTrue(MessageNotifier.MatchUsername("Hello 'test'-message", "Test").Success);
}
[Test]
public void TestContainsUsernameUnicode()
{
- Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test \u0460\u0460 message", "\u0460\u0460"));
+ Assert.IsTrue(MessageNotifier.MatchUsername("Test \u0460\u0460 message", "\u0460\u0460").Success);
}
[Test]
public void TestContainsUsernameUnicodeNegative()
{
- Assert.IsFalse(MessageNotifier.CheckContainsUsername("Test ha\u0460\u0460o message", "\u0460\u0460"));
+ Assert.IsFalse(MessageNotifier.MatchUsername("Test ha\u0460\u0460o message", "\u0460\u0460").Success);
}
[Test]
public void TestContainsUsernameSpecialCharactersPositive()
{
- Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test [#^-^#] message", "[#^-^#]"));
+ Assert.IsTrue(MessageNotifier.MatchUsername("Test [#^-^#] message", "[#^-^#]").Success);
}
[Test]
public void TestContainsUsernameSpecialCharactersNegative()
{
- Assert.IsFalse(MessageNotifier.CheckContainsUsername("Test pad[#^-^#]oru message", "[#^-^#]"));
+ Assert.IsFalse(MessageNotifier.MatchUsername("Test pad[#^-^#]oru message", "[#^-^#]").Success);
}
[Test]
public void TestContainsUsernameAtSign()
{
- Assert.IsTrue(MessageNotifier.CheckContainsUsername("@username hi", "username"));
+ Assert.IsTrue(MessageNotifier.MatchUsername("@username hi", "username").Success);
}
[Test]
public void TestContainsUsernameColon()
{
- Assert.IsTrue(MessageNotifier.CheckContainsUsername("username: hi", "username"));
+ Assert.IsTrue(MessageNotifier.MatchUsername("username: hi", "username").Success);
}
}
}
diff --git a/osu.Game.Tests/Online/TestSceneMetadataClient.cs b/osu.Game.Tests/Online/TestSceneMetadataClient.cs
new file mode 100644
index 0000000000..04e1d91edf
--- /dev/null
+++ b/osu.Game.Tests/Online/TestSceneMetadataClient.cs
@@ -0,0 +1,52 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Tests.Visual;
+using osu.Game.Tests.Visual.Metadata;
+
+namespace osu.Game.Tests.Online
+{
+ [TestFixture]
+ [HeadlessTest]
+ public partial class TestSceneMetadataClient : OsuTestScene
+ {
+ private TestMetadataClient client = null!;
+
+ [SetUp]
+ public void Setup() => Schedule(() =>
+ {
+ Child = client = new TestMetadataClient();
+ });
+
+ [Test]
+ public void TestWatchingMultipleTimesInvokesServerMethodsOnce()
+ {
+ int countBegin = 0;
+ int countEnd = 0;
+
+ IDisposable token1 = null!;
+ IDisposable token2 = null!;
+
+ AddStep("setup", () =>
+ {
+ client.OnBeginWatchingUserPresence += () => countBegin++;
+ client.OnEndWatchingUserPresence += () => countEnd++;
+ });
+
+ AddStep("begin watching presence (1)", () => token1 = client.BeginWatchingUserPresence());
+ AddAssert("server method invoked once", () => countBegin, () => Is.EqualTo(1));
+
+ AddStep("begin watching presence (2)", () => token2 = client.BeginWatchingUserPresence());
+ AddAssert("server method not invoked a second time", () => countBegin, () => Is.EqualTo(1));
+
+ AddStep("end watching presence (1)", () => token1.Dispose());
+ AddAssert("server method not invoked", () => countEnd, () => Is.EqualTo(0));
+
+ AddStep("end watching presence (2)", () => token2.Dispose());
+ AddAssert("server method invoked once", () => countEnd, () => Is.EqualTo(1));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs
new file mode 100644
index 0000000000..41ffd9c9a9
--- /dev/null
+++ b/osu.Game.Tests/Online/TestSceneMultiplayerBeatmapAvailabilityTracker.cs
@@ -0,0 +1,185 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Extensions;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Online;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Online.Rooms;
+using osu.Game.Screens.OnlinePlay.Multiplayer;
+using osu.Game.Tests.Resources;
+using osu.Game.Tests.Visual.Multiplayer;
+using osu.Game.Utils;
+
+namespace osu.Game.Tests.Online
+{
+ [HeadlessTest]
+ public partial class TestSceneMultiplayerBeatmapAvailabilityTracker : MultiplayerTestScene
+ {
+ private BeatmapManager beatmapManager = null!;
+ private BeatmapInfo availableBeatmap = null!;
+ private BeatmapInfo unavailableBeatmap = null!;
+
+ private MultiplayerBeatmapAvailabilityTracker tracker = null!;
+
+ [BackgroundDependencyLoader]
+ private void load(GameHost host)
+ {
+ Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default));
+ Dependencies.Cache(Realm);
+
+ beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
+
+ var importedSet = beatmapManager.GetAllUsableBeatmapSets().First();
+ availableBeatmap = importedSet.Beatmaps[0];
+ unavailableBeatmap = importedSet.Beatmaps[1];
+
+ Realm.Write(r => r.Remove(r.Find(unavailableBeatmap.ID)!));
+ }
+
+ public override void SetUpSteps()
+ {
+ base.SetUpSteps();
+
+ AddStep("setup tracker", () =>
+ {
+ DummyAPIAccess api = (DummyAPIAccess)API;
+ Func? defaultRequestHandler = api.HandleRequest;
+
+ api.HandleRequest = req =>
+ {
+ switch (req)
+ {
+ case GetBeatmapsRequest beatmapsReq:
+ var availableApiBeatmap = CreateAPIBeatmap();
+ availableApiBeatmap.OnlineID = availableBeatmap.OnlineID;
+ availableApiBeatmap.OnlineBeatmapSetID = availableBeatmap.BeatmapSet!.OnlineID;
+ availableApiBeatmap.Checksum = availableBeatmap.MD5Hash;
+ availableApiBeatmap.BeatmapSet!.OnlineID = availableBeatmap.BeatmapSet!.OnlineID;
+
+ var unavailableApiBeatmap = CreateAPIBeatmap();
+ unavailableApiBeatmap.OnlineID = unavailableBeatmap.OnlineID;
+ unavailableApiBeatmap.OnlineBeatmapSetID = unavailableBeatmap.BeatmapSet!.OnlineID;
+ unavailableApiBeatmap.Checksum = unavailableBeatmap.MD5Hash;
+ unavailableApiBeatmap.BeatmapSet!.OnlineID = unavailableBeatmap.BeatmapSet!.OnlineID;
+
+ beatmapsReq.TriggerSuccess(new GetBeatmapsResponse
+ {
+ Beatmaps = new List
+ {
+ availableApiBeatmap,
+ unavailableApiBeatmap
+ }
+ });
+ return true;
+
+ default:
+ return defaultRequestHandler?.Invoke(req) ?? false;
+ }
+ };
+
+ Child = tracker = new MultiplayerBeatmapAvailabilityTracker();
+ });
+ }
+
+ [Test]
+ public void TestEnterRoomWithNotDownloadedBeatmap()
+ {
+ AddStep("join room", () =>
+ {
+ var room = CreateDefaultRoom();
+ room.Playlist = [new PlaylistItem(unavailableBeatmap)];
+ JoinRoom(room);
+ });
+
+ WaitForJoined();
+
+ AddUntilStep("beatmap is not available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.NotDownloaded));
+ }
+
+ [Test]
+ public void TestEnterRoomWithLocallyAvailableBeatmap()
+ {
+ AddStep("join room", () =>
+ {
+ var room = CreateDefaultRoom();
+ room.Playlist = [new PlaylistItem(availableBeatmap)];
+ JoinRoom(room);
+ });
+
+ WaitForJoined();
+
+ AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable));
+ }
+
+ [Test]
+ public void TestAvailabilityUpdatesOnItemEdit()
+ {
+ AddStep("join room", () =>
+ {
+ var room = CreateDefaultRoom();
+ room.Playlist = [new PlaylistItem(availableBeatmap)];
+ JoinRoom(room);
+ });
+
+ WaitForJoined();
+
+ AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable));
+
+ AddStep("change item to not downloaded beatmap", () =>
+ {
+ PlaylistItem newItem = new PlaylistItem(MultiplayerClient.ClientRoom!.CurrentPlaylistItem).With(beatmap: new Optional(unavailableBeatmap));
+ MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(newItem)).WaitSafely();
+ });
+
+ AddUntilStep("beatmap is not available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.NotDownloaded));
+
+ AddStep("change item to downloaded beatmap", () =>
+ {
+ PlaylistItem newItem = new PlaylistItem(MultiplayerClient.ClientRoom!.CurrentPlaylistItem).With(beatmap: new Optional(availableBeatmap));
+ MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(newItem)).WaitSafely();
+ });
+
+ AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable));
+ }
+
+ [Test]
+ public void TestAvailabilityUpdatesOnSettingsChange()
+ {
+ AddStep("join room", () =>
+ {
+ var room = CreateDefaultRoom();
+ room.Playlist = [new PlaylistItem(availableBeatmap), new PlaylistItem(unavailableBeatmap)];
+ JoinRoom(room);
+ });
+
+ WaitForJoined();
+
+ AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable));
+
+ AddStep("change settings to not downloaded beatmap", () => MultiplayerClient.ChangeServerRoomSettings(new MultiplayerRoomSettings(MultiplayerClient.ClientAPIRoom!)
+ {
+ PlaylistItemId = MultiplayerClient.ServerRoom!.Playlist[1].ID
+ }).WaitSafely());
+
+ AddUntilStep("beatmap is not available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.NotDownloaded));
+
+ AddStep("change settings to downloaded beatmap", () => MultiplayerClient.ChangeServerRoomSettings(new MultiplayerRoomSettings(MultiplayerClient.ClientAPIRoom!)
+ {
+ PlaylistItemId = MultiplayerClient.ServerRoom!.Playlist[0].ID
+ }).WaitSafely());
+
+ AddUntilStep("beatmap is available", () => tracker.Availability.Value.State, () => Is.EqualTo(DownloadState.LocallyAvailable));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs
similarity index 83%
rename from osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
rename to osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs
index ae3451c3e0..220c23b5bc 100644
--- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
+++ b/osu.Game.Tests/Online/TestScenePlaylistsBeatmapAvailabilityTracker.cs
@@ -1,18 +1,14 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
-using JetBrains.Annotations;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
-using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.IO.Stores;
@@ -27,31 +23,29 @@ using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
-using osu.Game.Rulesets;
+using osu.Game.Screens.OnlinePlay;
+using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Online
{
[HeadlessTest]
- public partial class TestSceneOnlinePlayBeatmapAvailabilityTracker : OsuTestScene
+ public partial class TestScenePlaylistsBeatmapAvailabilityTracker : OsuTestScene
{
- private RulesetStore rulesets;
- private TestBeatmapManager beatmaps;
- private TestBeatmapModelDownloader beatmapDownloader;
+ private TestBeatmapManager beatmaps = null!;
+ private TestBeatmapModelDownloader beatmapDownloader = null!;
- private string testBeatmapFile;
- private BeatmapInfo testBeatmapInfo;
- private BeatmapSetInfo testBeatmapSet;
+ private string testBeatmapFile = null!;
+ private BeatmapInfo testBeatmapInfo = null!;
+ private BeatmapSetInfo testBeatmapSet = null!;
- private readonly Bindable selectedItem = new Bindable();
- private OnlinePlayBeatmapAvailabilityTracker availabilityTracker;
+ private OnlinePlayBeatmapAvailabilityTracker availabilityTracker = null!;
[BackgroundDependencyLoader]
private void load(AudioManager audio, GameHost host)
{
- Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
- Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, Realm, rulesets, API, audio, Resources, host, Beatmap.Default));
+ Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default));
Dependencies.CacheAs(beatmapDownloader = new TestBeatmapModelDownloader(beatmaps, API));
}
@@ -82,16 +76,11 @@ namespace osu.Game.Tests.Online
testBeatmapFile = TestResources.GetQuickTestBeatmapForImport();
testBeatmapInfo = getTestBeatmapInfo(testBeatmapFile);
- testBeatmapSet = testBeatmapInfo.BeatmapSet;
+ testBeatmapSet = testBeatmapInfo.BeatmapSet!;
Realm.Write(r => r.RemoveAll());
Realm.Write(r => r.RemoveAll());
- selectedItem.Value = new PlaylistItem(testBeatmapInfo)
- {
- RulesetID = testBeatmapInfo.Ruleset.OnlineID,
- };
-
recreateChildren();
});
@@ -108,9 +97,15 @@ namespace osu.Game.Tests.Online
Children = new Drawable[]
{
beatmapLookupCache,
- availabilityTracker = new OnlinePlayBeatmapAvailabilityTracker
+ availabilityTracker = new PlaylistsBeatmapAvailabilityTracker
{
- SelectedItem = { BindTarget = selectedItem, }
+ PlaylistItem =
+ {
+ Value = new PlaylistItem(testBeatmapInfo)
+ {
+ RulesetID = testBeatmapInfo.Ruleset.OnlineID,
+ },
+ }
}
}
};
@@ -125,10 +120,10 @@ namespace osu.Game.Tests.Online
AddStep("start downloading", () => beatmapDownloader.Download(testBeatmapSet));
addAvailabilityCheckStep("state downloading 0%", () => BeatmapAvailability.Downloading(0.0f));
- AddStep("set progress 40%", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.SetProgress(0.4f));
+ AddStep("set progress 40%", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet)!).SetProgress(0.4f));
addAvailabilityCheckStep("state downloading 40%", () => BeatmapAvailability.Downloading(0.4f));
- AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.TriggerSuccess(testBeatmapFile));
+ AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet)!).TriggerSuccess(testBeatmapFile));
addAvailabilityCheckStep("state importing", BeatmapAvailability.Importing);
AddStep("allow importing", () => beatmaps.AllowImport.Set());
@@ -203,10 +198,10 @@ namespace osu.Game.Tests.Online
{
public readonly ManualResetEventSlim AllowImport = new ManualResetEventSlim();
- public Live CurrentImport { get; private set; }
+ public Live? CurrentImport { get; private set; }
- public TestBeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources,
- GameHost host = null, WorkingBeatmap defaultBeatmap = null)
+ public TestBeatmapManager(Storage storage, RealmAccess realm, IAPIProvider api, AudioManager audioManager, IResourceStore resources,
+ GameHost? host = null, WorkingBeatmap? defaultBeatmap = null)
: base(storage, realm, api, audioManager, resources, host, defaultBeatmap)
{
}
@@ -226,12 +221,13 @@ namespace osu.Game.Tests.Online
this.testBeatmapManager = testBeatmapManager;
}
- public override Live ImportModel(BeatmapSetInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default)
+ public override Live? ImportModel(BeatmapSetInfo item, ArchiveReader? archive = null, ImportParameters parameters = default,
+ CancellationToken cancellationToken = default)
{
if (!testBeatmapManager.AllowImport.Wait(TimeSpan.FromSeconds(10), cancellationToken))
throw new TimeoutException("Timeout waiting for import to be allowed.");
- return (testBeatmapManager.CurrentImport = base.ImportModel(item, archive, parameters, cancellationToken));
+ return testBeatmapManager.CurrentImport = base.ImportModel(item, archive, parameters, cancellationToken);
}
}
}
diff --git a/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs
new file mode 100644
index 0000000000..4a80c71c3d
--- /dev/null
+++ b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs
@@ -0,0 +1,73 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using Bogus;
+using MessagePack;
+using NUnit.Framework;
+using osu.Game.Online.API;
+using osu.Game.Online.Rooms;
+
+namespace osu.Game.Tests.OnlinePlay
+{
+ [TestFixture]
+ public class MultiplayerPlaylistItemTest
+ {
+ [SetUp]
+ public void Setup()
+ {
+ Randomizer.Seed = new Random(1337);
+ }
+
+ [Test]
+ public void TestCloneMultiplayerPlaylistItem()
+ {
+ var faker = new Faker()
+ .StrictMode(true)
+ .RuleFor(o => o.ID, f => f.Random.Long())
+ .RuleFor(o => o.OwnerID, f => f.Random.Int())
+ .RuleFor(o => o.BeatmapID, f => f.Random.Int())
+ .RuleFor(o => o.BeatmapChecksum, f => f.Random.Hash())
+ .RuleFor(o => o.RulesetID, f => f.Random.Int())
+ .RuleFor(o => o.RequiredMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) }))
+ .RuleFor(o => o.AllowedMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) }))
+ .RuleFor(o => o.Expired, f => f.Random.Bool())
+ .RuleFor(o => o.PlaylistOrder, f => f.Random.UShort())
+ .RuleFor(o => o.PlayedAt, f => f.Date.RecentOffset())
+ .RuleFor(o => o.StarRating, f => f.Random.Double())
+ .RuleFor(o => o.Freestyle, f => f.Random.Bool());
+
+ for (int i = 0; i < 100; i++)
+ {
+ MultiplayerPlaylistItem item = faker.Generate();
+ Assert.That(MessagePackSerializer.SerializeToJson(item.Clone()), Is.EqualTo(MessagePackSerializer.SerializeToJson(item)));
+ }
+ }
+
+ [Test]
+ public void TestConstructFromAPIModel()
+ {
+ var faker = new Faker()
+ .StrictMode(true)
+ .RuleFor(o => o.ID, f => f.Random.Long())
+ .RuleFor(o => o.OwnerID, f => f.Random.Int())
+ .RuleFor(o => o.BeatmapID, f => f.Random.Int())
+ .RuleFor(o => o.BeatmapChecksum, f => f.Random.Hash())
+ .RuleFor(o => o.RulesetID, f => f.Random.Int())
+ .RuleFor(o => o.RequiredMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) }))
+ .RuleFor(o => o.AllowedMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) }))
+ .RuleFor(o => o.Expired, f => f.Random.Bool())
+ .RuleFor(o => o.PlaylistOrder, f => f.Random.UShort())
+ .RuleFor(o => o.PlayedAt, f => f.Date.RecentOffset())
+ .RuleFor(o => o.StarRating, f => f.Random.Double())
+ .RuleFor(o => o.Freestyle, f => f.Random.Bool());
+
+ for (int i = 0; i < 100; i++)
+ {
+ MultiplayerPlaylistItem initialItem = faker.Generate();
+ MultiplayerPlaylistItem copiedItem = new MultiplayerPlaylistItem(new PlaylistItem(initialItem));
+ Assert.That(MessagePackSerializer.SerializeToJson(copiedItem), Is.EqualTo(MessagePackSerializer.SerializeToJson(initialItem)));
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs b/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs
new file mode 100644
index 0000000000..d463610034
--- /dev/null
+++ b/osu.Game.Tests/OnlinePlay/TestSceneOnlinePlaySubScreenStack.cs
@@ -0,0 +1,85 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Screens;
+using osu.Framework.Testing;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Screens;
+using osu.Game.Screens.OnlinePlay;
+using osu.Game.Screens.OnlinePlay.Playlists;
+using osu.Game.Tests.Visual.OnlinePlay;
+
+namespace osu.Game.Tests.OnlinePlay
+{
+ [HeadlessTest]
+ public partial class TestSceneOnlinePlaySubScreenStack : OnlinePlayTestScene
+ {
+ private ScreenStack stack = null!;
+
+ [SetUp]
+ public void Setup() => Schedule(() =>
+ {
+ Child = stack = new OnlinePlaySubScreenStack
+ {
+ RelativeSizeAxes = Axes.Both
+ };
+ });
+
+ [Test]
+ public void TestBindablesDisabledWhenRequested()
+ {
+ AddAssert("bindables not disabled", () => Beatmap.Disabled || Ruleset.Disabled || SelectedMods.Disabled, () => Is.False);
+
+ AddStep("push screen that disables bindables", () => stack.Push(new ScreenWithExternalBindableDisablement(true)));
+ AddAssert("bindables disabled", () => Beatmap.Disabled && Ruleset.Disabled && SelectedMods.Disabled, () => Is.True);
+
+ AddStep("push screen that does not disable bindables", () => stack.Push(new ScreenWithExternalBindableDisablement(false)));
+ AddAssert("bindables not disabled", () => Beatmap.Disabled || Ruleset.Disabled || SelectedMods.Disabled, () => Is.False);
+
+ AddStep("exit one screen", () => stack.Exit());
+ AddAssert("bindables disabled", () => Beatmap.Disabled && Ruleset.Disabled && SelectedMods.Disabled, () => Is.True);
+ }
+
+ [Test]
+ public void TestModsResetWhenExitToLounge()
+ {
+ AddStep("push lounge", () => stack.Push(new PlaylistsLoungeSubScreen()));
+
+ AddStep("push screen with mod", () => stack.Push(new ScreenWithMod(new OsuModDoubleTime())));
+ AddUntilStep("wait for screen to load", () => ((OsuScreen)stack.CurrentScreen).IsLoaded);
+ AddAssert("mod set", () => SelectedMods.Value.Count, () => Is.GreaterThan(0));
+
+ AddStep("exit to lounge", () => stack.Exit());
+ AddAssert("mods reset", () => SelectedMods.Value.Count, () => Is.Zero);
+ }
+
+ private partial class ScreenWithExternalBindableDisablement : OsuScreen
+ {
+ public override bool DisallowExternalBeatmapRulesetChanges { get; }
+
+ public ScreenWithExternalBindableDisablement(bool disableBindables)
+ {
+ DisallowExternalBeatmapRulesetChanges = disableBindables;
+ }
+ }
+
+ private partial class ScreenWithMod : OsuScreen
+ {
+ private readonly Mod mod;
+
+ public ScreenWithMod(Mod mod)
+ {
+ this.mod = mod;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ Mods.Value = [mod];
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz b/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz
new file mode 100644
index 0000000000..5c5af368c8
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/fractional-coordinates.olz differ
diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk
new file mode 100644
index 0000000000..23322e7373
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk differ
diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk
new file mode 100644
index 0000000000..74abef25ca
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250214.osk differ
diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250424.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250424.osk
new file mode 100644
index 0000000000..24aa90cdd0
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250424.osk differ
diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250809.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250809.osk
new file mode 100644
index 0000000000..bea78dcbef
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250809.osk differ
diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk b/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk
new file mode 100644
index 0000000000..8ed25fa8f4
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20241207.osk differ
diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs
index e0572e604c..469bc8ee73 100644
--- a/osu.Game.Tests/Resources/TestResources.cs
+++ b/osu.Game.Tests/Resources/TestResources.cs
@@ -29,6 +29,11 @@ namespace osu.Game.Tests.Resources
{
public const double QUICK_BEATMAP_LENGTH = 10000;
+ public const string COVER_IMAGE_1 = "https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg";
+ public const string COVER_IMAGE_2 = "https://assets.ppy.sh/user-cover-presets/7/4a0ccb7b7fdd5c4238b11f0e7c686760fe2c99c6472b19400e82d1a8ff503e31.jpeg";
+ public const string COVER_IMAGE_3 = "https://assets.ppy.sh/user-cover-presets/12/6e8d3402c8080c2d9549a98321e1bff111dd9c94603ccdb237597479cab6e8a7.jpeg";
+ public const string COVER_IMAGE_4 = "https://assets.ppy.sh/user-cover-presets/17/80f82e4c2b27d8d6eed3ce89708ec27343e5ac63389cba6b5fb4550776562d08.jpeg";
+
private static readonly TemporaryNativeStorage temp_storage = new TemporaryNativeStorage("TestResources");
public static DllResourceStore GetStore() => new DllResourceStore(typeof(TestResources).Assembly);
@@ -99,7 +104,7 @@ namespace osu.Game.Tests.Resources
{
// Create random metadata, then we can check if sorting works based on these
Artist = "Some Artist " + RNG.Next(0, 9),
- Title = $"Some Song (set id {setId:000}) {Guid.NewGuid()}",
+ Title = $"Some Song (set id {setId:000000}) {Guid.NewGuid()}",
Author = { Username = "Some Guy " + RNG.Next(0, 9) },
};
@@ -178,7 +183,7 @@ namespace osu.Game.Tests.Resources
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = COVER_IMAGE_3,
},
BeatmapInfo = beatmap,
BeatmapHash = beatmap.Hash,
diff --git a/osu.Game.Tests/Resources/noop-fade-transform-is-ignored-for-lifetime.osb b/osu.Game.Tests/Resources/noop-fade-transform-is-ignored-for-lifetime.osb
new file mode 100644
index 0000000000..aca9bf926a
--- /dev/null
+++ b/osu.Game.Tests/Resources/noop-fade-transform-is-ignored-for-lifetime.osb
@@ -0,0 +1,8 @@
+[Events]
+//Storyboard Layer 0 (Background)
+Sprite,Background,TopCentre,"img.jpg",320,240
+ F,0,1000,1000,0,0 // should be ignored
+ F,0,1500,1600,0,1
+Sprite,Background,TopCentre,"img.jpg",320,240
+ F,0,1000,1000,0,0 // should be ignored
+ F,0,1500,1600,1,1
diff --git a/osu.Game.Tests/Resources/too-many-combo-colours.osu b/osu.Game.Tests/Resources/too-many-combo-colours.osu
new file mode 100644
index 0000000000..477e362a6d
--- /dev/null
+++ b/osu.Game.Tests/Resources/too-many-combo-colours.osu
@@ -0,0 +1,73 @@
+osu file format v14
+
+[General]
+AudioFilename: 03. Renatus - Soleily 192kbps.mp3
+AudioLeadIn: 0
+PreviewTime: 164471
+Countdown: 0
+SampleSet: Soft
+StackLeniency: 0.7
+Mode: 0
+LetterboxInBreaks: 0
+WidescreenStoryboard: 0
+
+[Editor]
+Bookmarks: 11505,22054,32604,43153,53703,64252,74802,85351,95901,106450,116999,119637,130186,140735,151285,161834,164471,175020,185570,196119,206669,209306
+DistanceSpacing: 1.8
+BeatDivisor: 4
+GridSize: 4
+TimelineZoom: 2
+
+[Metadata]
+Title:Renatus
+TitleUnicode:Renatus
+Artist:Soleily
+ArtistUnicode:Soleily
+Creator:Gamu
+Version:Insane
+Source:
+Tags:MBC7 Unisphere 地球ヤバイEP Chikyu Yabai
+BeatmapID:557821
+BeatmapSetID:241526
+
+[Difficulty]
+HPDrainRate:6.5
+CircleSize:4
+OverallDifficulty:8
+ApproachRate:9
+SliderMultiplier:1.8
+SliderTickRate:2
+
+[Events]
+//Background and Video events
+0,0,"machinetop_background.jpg",0,0
+//Break Periods
+2,122474,140135
+//Storyboard Layer 0 (Background)
+//Storyboard Layer 1 (Fail)
+//Storyboard Layer 2 (Pass)
+//Storyboard Layer 3 (Foreground)
+//Storyboard Sound Samples
+
+[TimingPoints]
+956,329.67032967033,4,2,0,60,1,0
+
+
+[Colours]
+Combo1:142,199,255
+Combo2:255,128,128
+Combo3:128,255,255
+Combo4:128,255,128
+Combo5:255,187,255
+Combo6:255,177,140
+Combo7:100,100,100
+Combo8:142,199,255
+Combo9:255,128,128
+Combo10:128,255,255
+Combo11:128,255,128
+Combo12:255,187,255
+Combo13:255,177,140
+Combo14:100,100,100
+
+[HitObjects]
+192,168,956,6,0,P|184:128|200:80,1,90,4|0,1:2|0:0,0:0:0:0:
diff --git a/osu.Game.Tests/Resources/video-custom-alpha-transform.osb b/osu.Game.Tests/Resources/video-custom-alpha-transform.osb
new file mode 100644
index 0000000000..39fcf87c06
--- /dev/null
+++ b/osu.Game.Tests/Resources/video-custom-alpha-transform.osb
@@ -0,0 +1,5 @@
+osu file format v14
+
+[Events]
+Video,-5678,"Video.avi",0,0
+ F,0,1500,1600,0,1
diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs
index 1647fbee42..f45422e0c4 100644
--- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs
+++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs
@@ -421,6 +421,65 @@ namespace osu.Game.Tests.Rulesets.Scoring
Assert.That(scoreProcessor.Rank.Value, Is.EqualTo(ScoreRank.SH));
}
+ [Test]
+ public void TestComboAccounting([Values] bool shuffleResults)
+ {
+ var testBeatmap = new Beatmap
+ {
+ HitObjects = Enumerable.Range(1, 40).Select(i => new TestHitObject(HitResult.Perfect, HitResult.Miss)).ToList(),
+ };
+ scoreProcessor.ApplyBeatmap(testBeatmap);
+
+ var results = new List();
+ JudgementResult judgementResult;
+
+ for (int i = 0; i < 25; ++i)
+ {
+ judgementResult = new JudgementResult(testBeatmap.HitObjects[i], new TestJudgement(HitResult.Perfect, HitResult.Miss))
+ {
+ Type = HitResult.Perfect
+ };
+ results.Add(judgementResult);
+ scoreProcessor.ApplyResult(judgementResult);
+ Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(i + 1));
+ }
+
+ judgementResult = new JudgementResult(testBeatmap.HitObjects[25], new TestJudgement(HitResult.Perfect, HitResult.Miss))
+ {
+ Type = HitResult.Miss
+ };
+ results.Add(judgementResult);
+ scoreProcessor.ApplyResult(judgementResult);
+ Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0));
+
+ for (int i = 26; i < 40; ++i)
+ {
+ judgementResult = new JudgementResult(testBeatmap.HitObjects[i], new TestJudgement(HitResult.Perfect, HitResult.Miss))
+ {
+ Type = HitResult.Perfect
+ };
+ results.Add(judgementResult);
+ scoreProcessor.ApplyResult(judgementResult);
+ Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(i - 25));
+ }
+
+ Assert.That(scoreProcessor.MaximumStatistics[HitResult.Perfect], Is.EqualTo(40));
+ Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(14));
+ Assert.That(scoreProcessor.HighestCombo.Value, Is.EqualTo(25));
+
+ // random shuffle is VERY extreme and overkill.
+ // it might not work correctly for any other `ScoreProcessor` property, and the intermediate results likely make no sense.
+ // the goal is only to demonstrate idempotency to zero when reverting all results.
+ var random = new Random(20250519);
+ var toRevert = shuffleResults ? results.OrderBy(_ => random.Next()).ToList() : Enumerable.Reverse(results);
+
+ foreach (var result in toRevert)
+ scoreProcessor.RevertResult(result);
+
+ Assert.That(scoreProcessor.Combo.Value, Is.Zero);
+ Assert.That(scoreProcessor.HighestCombo.Value, Is.Zero);
+ }
+
private class TestJudgement : Judgement
{
public override HitResult MaxResult { get; }
diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs
index 9c72804a6b..6558834a63 100644
--- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs
+++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs
@@ -215,6 +215,35 @@ namespace osu.Game.Tests.Scores.IO
}
}
+ [Test]
+ public void TestScoreWithInvalidModCombinationsWillNotImport()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ {
+ try
+ {
+ var osu = LoadOsuIntoHost(host, true);
+
+ var beatmap = BeatmapImportHelper.LoadOszIntoOsu(osu, TestResources.GetQuickTestBeatmapForImport()).GetResultSafely();
+
+ var toImport = new ScoreInfo
+ {
+ User = new APIUser { Username = "Test user" },
+ BeatmapInfo = beatmap.Beatmaps.First(),
+ Ruleset = new OsuRuleset().RulesetInfo,
+ ClientVersion = "12345",
+ Mods = new Mod[] { new OsuModHalfTime(), new OsuModDoubleTime() },
+ };
+
+ Assert.Throws(() => LoadScoreIntoOsu(osu, toImport));
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
[Test]
public void TestImportStatistics()
{
diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
index 7372557161..0eafe33343 100644
--- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
+++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs
@@ -13,10 +13,10 @@ using osu.Game.Audio;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Screens.Menu;
-using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osu.Game.Skinning;
using osu.Game.Skinning.Components;
+using osu.Game.Skinning.Triangles;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Skins
@@ -68,7 +68,17 @@ namespace osu.Game.Tests.Skins
// Covers legacy rank display
"Archives/modified-classic-20230809.osk",
// Covers legacy key counter
- "Archives/modified-classic-20240724.osk"
+ "Archives/modified-classic-20240724.osk",
+ // Covers skinnable mod display
+ "Archives/modified-default-20241207.osk",
+ // Covers skinnable spectator list
+ "Archives/modified-argon-20250116.osk",
+ // Covers player team flag
+ "Archives/modified-argon-20250214.osk",
+ // Covers skinnable leaderboard
+ "Archives/modified-argon-20250424.osk",
+ // Covers "Argon" unstable rate counter
+ "Archives/modified-argon-20250809.osk",
};
///
@@ -162,7 +172,7 @@ namespace osu.Game.Tests.Skins
{
var skin = new TestSkin(new SkinInfo(), null, storage);
Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(8));
- Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter)));
+ Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(TrianglesUnstableRateCounter)));
Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter)));
Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(LegacySongProgress)));
}
diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs
index 54a722cee0..7b22ff1d6a 100644
--- a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs
+++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs
@@ -131,21 +131,6 @@ namespace osu.Game.Tests.Visual.Background
assertNoBackgrounds();
}
- [Test]
- public void TestDelayedConnectivity()
- {
- registerBackgroundsResponse(DateTimeOffset.Now.AddDays(30));
- setSeasonalBackgroundMode(SeasonalBackgroundMode.Always);
- AddStep("go offline", () => dummyAPI.SetState(APIState.Offline));
-
- createLoader();
- assertNoBackgrounds();
-
- AddStep("go online", () => dummyAPI.SetState(APIState.Online));
-
- assertAnyBackground();
- }
-
private void registerBackgroundsResponse(DateTimeOffset endDate)
=> AddStep("setup request handler", () =>
{
@@ -185,7 +170,8 @@ namespace osu.Game.Tests.Visual.Background
{
previousBackground = (SeasonalBackground)backgroundContainer.SingleOrDefault();
background = backgroundLoader.LoadNextBackground();
- LoadComponentAsync(background, bg => backgroundContainer.Child = bg);
+ if (background != null)
+ LoadComponentAsync(background, bg => backgroundContainer.Child = bg);
});
AddUntilStep("background loaded", () => background.IsLoaded);
diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs
index 693e1e48d4..58fb02c90c 100644
--- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs
+++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs
@@ -3,6 +3,7 @@
#nullable disable
+using System.Linq;
using System.Threading;
using NUnit.Framework;
using osu.Framework.Allocation;
@@ -15,6 +16,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Input.States;
using osu.Framework.Platform;
using osu.Framework.Screens;
+using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
@@ -30,7 +32,8 @@ using osu.Game.Screens.Backgrounds;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.PlayerSettings;
using osu.Game.Screens.Ranking;
-using osu.Game.Screens.Select;
+using osu.Game.Screens.SelectV2;
+using osu.Game.Storyboards.Drawables;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Graphics;
@@ -45,6 +48,7 @@ namespace osu.Game.Tests.Visual.Background
private LoadBlockingTestPlayer player;
private BeatmapManager manager;
private RulesetStore rulesets;
+ private UpdateCounter storyboardUpdateCounter;
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
@@ -194,6 +198,28 @@ namespace osu.Game.Tests.Visual.Background
AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible);
}
+ [Test]
+ public void TestStoryboardUpdatesWhenDimmed()
+ {
+ performFullSetup();
+ createFakeStoryboard();
+
+ AddStep("Enable fully dimmed storyboard", () =>
+ {
+ player.StoryboardReplacesBackground.Value = true;
+ player.StoryboardEnabled.Value = true;
+ player.DimmableStoryboard.IgnoreUserSettings.Value = false;
+ songSelect.DimLevel.Value = 1f;
+ });
+
+ AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible);
+
+ AddWaitStep("wait some", 20);
+
+ AddUntilStep("Storyboard is always present", () => player.ChildrenOfType().Single().AlwaysPresent, () => Is.True);
+ AddUntilStep("Dimmable storyboard content is being updated", () => storyboardUpdateCounter.StoryboardContentLastUpdated, () => Is.EqualTo(Time.Current).Within(100));
+ }
+
[Test]
public void TestStoryboardIgnoreUserSettings()
{
@@ -269,15 +295,19 @@ namespace osu.Game.Tests.Visual.Background
{
player.StoryboardEnabled.Value = false;
player.StoryboardReplacesBackground.Value = false;
- player.DimmableStoryboard.Add(new OsuSpriteText
+ player.DimmableStoryboard.AddRange(new Drawable[]
{
- Size = new Vector2(500, 50),
- Alpha = 1,
- Colour = Color4.White,
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Text = "THIS IS A STORYBOARD",
- Font = new FontUsage(size: 50)
+ storyboardUpdateCounter = new UpdateCounter(),
+ new OsuSpriteText
+ {
+ Size = new Vector2(500, 50),
+ Alpha = 1,
+ Colour = Color4.White,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Text = "THIS IS A STORYBOARD",
+ Font = new FontUsage(size: 50)
+ }
});
});
@@ -295,7 +325,7 @@ namespace osu.Game.Tests.Visual.Background
private void setupUserSettings()
{
AddUntilStep("Song select is current", () => songSelect.IsCurrentScreen());
- AddUntilStep("Song select has selection", () => songSelect.Carousel?.SelectedBeatmapInfo != null);
+ AddUntilStep("Song select has selection", () => songSelect.Carousel?.CurrentSelection != null);
AddStep("Set default user settings", () =>
{
SelectedMods.Value = new[] { new OsuModNoFail() };
@@ -310,7 +340,7 @@ namespace osu.Game.Tests.Visual.Background
rulesets?.Dispose();
}
- private partial class DummySongSelect : PlaySongSelect
+ private partial class DummySongSelect : SoloSongSelect
{
private FadeAccessibleBackground background;
@@ -325,7 +355,7 @@ namespace osu.Game.Tests.Visual.Background
public readonly Bindable DimLevel = new BindableDouble();
public readonly Bindable BlurLevel = new BindableDouble();
- public new BeatmapCarousel Carousel => base.Carousel;
+ public BeatmapCarousel Carousel => this.ChildrenOfType().SingleOrDefault();
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
@@ -338,7 +368,7 @@ namespace osu.Game.Tests.Visual.Background
public bool IsBackgroundDimmed() => background.CurrentColour == OsuColour.Gray(1f - background.CurrentDim);
- public bool IsBackgroundUndimmed() => background.CurrentColour == Color4.White;
+ public bool IsBackgroundUndimmed() => background.CurrentColour == new Color4(0.9f, 0.9f, 0.9f, 1f);
public bool IsUserBlurApplied() => Precision.AlmostEquals(background.CurrentBlur, new Vector2((float)BlurLevel.Value * BackgroundScreenBeatmap.USER_BLUR_FACTOR), 0.1f);
@@ -346,14 +376,14 @@ namespace osu.Game.Tests.Visual.Background
public bool IsBackgroundVisible() => background.CurrentAlpha == 1;
- public bool IsBackgroundBlur() => Precision.AlmostEquals(background.CurrentBlur, new Vector2(BACKGROUND_BLUR), 0.1f);
+ public bool IsBackgroundBlur() => Precision.AlmostBigger(background.CurrentBlur.X, 0, 0.1f);
public bool CheckBackgroundBlur(Vector2 expected) => Precision.AlmostEquals(background.CurrentBlur, expected, 0.1f);
///
/// Make sure every time a screen gets pushed, the background doesn't get replaced
///
- /// Whether or not the original background (The one created in DummySongSelect) is still the current background
+ /// Whether the original background (The one created in DummySongSelect) is still the current background
public bool IsBackgroundCurrent() => background?.IsCurrentScreen() == true;
}
@@ -384,7 +414,7 @@ namespace osu.Game.Tests.Visual.Background
public new DimmableStoryboard DimmableStoryboard => base.DimmableStoryboard;
- // Whether or not the player should be allowed to load.
+ // Whether the player should be allowed to load.
public bool BlockLoad;
public Bindable StoryboardEnabled;
@@ -451,6 +481,17 @@ namespace osu.Game.Tests.Visual.Background
}
}
+ private partial class UpdateCounter : Drawable
+ {
+ public double StoryboardContentLastUpdated;
+
+ protected override void Update()
+ {
+ base.Update();
+ StoryboardContentLastUpdated = Time.Current;
+ }
+ }
+
private partial class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground
{
public Color4 CurrentColour => Content.Colour;
diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
index fed26d8acb..2f31911fac 100644
--- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
+++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
@@ -16,6 +16,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Beatmaps.Drawables.Cards.Buttons;
+using osu.Game.Beatmaps.Drawables.Cards.Statistics;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
@@ -63,7 +64,11 @@ namespace osu.Game.Tests.Visual.Beatmaps
withStatistics.NominationStatus = new BeatmapSetNominationStatus
{
Current = 1,
- Required = 2
+ RequiredMeta =
+ {
+ MainRuleset = 2,
+ NonMainRuleset = 1,
+ }
};
var undownloadable = getUndownloadableBeatmapSet();
@@ -78,7 +83,11 @@ namespace osu.Game.Tests.Visual.Beatmaps
someDifficulties.NominationStatus = new BeatmapSetNominationStatus
{
Current = 2,
- Required = 2
+ RequiredMeta =
+ {
+ MainRuleset = 2,
+ NonMainRuleset = 1,
+ }
};
var manyDifficulties = getManyDifficultiesBeatmapSet(100);
@@ -220,6 +229,9 @@ namespace osu.Game.Tests.Visual.Beatmaps
}
private Drawable createContent(OverlayColourScheme colourScheme, Func creationFunc)
+ => createContent(colourScheme, testCases.Select(creationFunc).ToArray());
+
+ private Drawable createContent(OverlayColourScheme colourScheme, Drawable[] cards)
{
var colourProvider = new OverlayColourProvider(colourScheme);
@@ -247,7 +259,7 @@ namespace osu.Game.Tests.Visual.Beatmaps
Direction = FillDirection.Full,
Padding = new MarginPadding(10),
Spacing = new Vector2(10),
- ChildrenEnumerable = testCases.Select(creationFunc)
+ ChildrenEnumerable = cards
}
}
}
@@ -320,5 +332,54 @@ namespace osu.Game.Tests.Visual.Beatmaps
BeatmapCardNormal firstCard() => this.ChildrenOfType().First();
}
+
+ [Test]
+ public void TestNominations()
+ {
+ AddStep("create cards", () =>
+ {
+ var singleRuleset = CreateAPIBeatmapSet(Ruleset.Value);
+ singleRuleset.HypeStatus = new BeatmapSetHypeStatus();
+ singleRuleset.NominationStatus = new BeatmapSetNominationStatus
+ {
+ Current = 4,
+ RequiredMeta =
+ {
+ MainRuleset = 5,
+ NonMainRuleset = 1,
+ }
+ };
+
+ var multipleRulesets = getManyDifficultiesBeatmapSet(3);
+ multipleRulesets.HypeStatus = new BeatmapSetHypeStatus();
+ multipleRulesets.NominationStatus = new BeatmapSetNominationStatus
+ {
+ Current = 4,
+ RequiredMeta =
+ {
+ MainRuleset = 5,
+ NonMainRuleset = 1,
+ }
+ };
+
+ Child = createContent(OverlayColourScheme.Blue, new Drawable[]
+ {
+ new BeatmapCardNormal(singleRuleset),
+ new BeatmapCardNormal(multipleRulesets),
+ });
+ });
+
+ // first card: only has main ruleset, required nominations = main_ruleset = 5
+ AddAssert("first card has single ruleset", () => firstCard().BeatmapSet.Beatmaps.GroupBy(b => b.Ruleset).Count(), () => Is.EqualTo(1));
+ AddAssert("first card nominations = 4/5", () => firstCard().ChildrenOfType().Single().TooltipText.ToString(), () => Is.EqualTo("Nominations: 4/5"));
+
+ // second card: has non-main rulesets, required nominations = main_ruleset + non_main_ruleset * (count of non-main rulesets) = 5 + 1 * 2 = 7
+ AddAssert("second card has three rulesets", () => secondCard().BeatmapSet.Beatmaps.GroupBy(b => b.Ruleset).Count(), () => Is.EqualTo(3));
+ AddAssert("second card nominations = 4/7", () => secondCard().ChildrenOfType().Single().TooltipText.ToString(), () => Is.EqualTo("Nominations: 4/7"));
+
+ // order is reversed due to the cards being inside a reverse child-id fill flow.
+ BeatmapCardNormal firstCard() => this.ChildrenOfType().ElementAt(1);
+ BeatmapCardNormal secondCard() => this.ChildrenOfType().ElementAt(0);
+ }
}
}
diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs
index c33033624a..81abe105f1 100644
--- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs
+++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs
@@ -91,6 +91,6 @@ namespace osu.Game.Tests.Visual.Beatmaps
}
private void assertCorrectIcon(bool favourited) => AddAssert("icon correct",
- () => this.ChildrenOfType().Single().Icon.Equals(favourited ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart));
+ () => this.ChildrenOfType().First().Icon.Equals(favourited ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart));
}
}
diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs
index dcc4654437..1651adc08f 100644
--- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs
+++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs
@@ -19,6 +19,9 @@ namespace osu.Game.Tests.Visual.Beatmaps
{
public partial class TestSceneBeatmapSetOnlineStatusPill : ThemeComparisonTestScene
{
+ private bool showUnknownStatus;
+ private bool animated = true;
+
protected override Drawable CreateContent() => new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
@@ -26,12 +29,21 @@ namespace osu.Game.Tests.Visual.Beatmaps
Origin = Anchor.Centre,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 10),
- ChildrenEnumerable = Enum.GetValues(typeof(BeatmapOnlineStatus)).Cast().Select(status => new BeatmapSetOnlineStatusPill
+ ChildrenEnumerable = Enum.GetValues(typeof(BeatmapOnlineStatus)).Cast().Select(status => new Container
{
- AutoSizeAxes = Axes.Both,
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Status = status
+ RelativeSizeAxes = Axes.X,
+ Height = 20,
+ Children = new Drawable[]
+ {
+ new BeatmapSetOnlineStatusPill
+ {
+ ShowUnknownStatus = showUnknownStatus,
+ Animated = animated,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Status = status
+ },
+ }
})
};
@@ -48,6 +60,18 @@ namespace osu.Game.Tests.Visual.Beatmaps
pill.Width = 90;
}));
+ AddStep("toggle show unknown", () =>
+ {
+ showUnknownStatus = !showUnknownStatus;
+ CreateThemedContent(OverlayColourScheme.Red);
+ });
+
+ AddStep("toggle animate", () =>
+ {
+ animated = !animated;
+ CreateThemedContent(OverlayColourScheme.Red);
+ });
+
AddStep("unset fixed width", () => statusPills.ForEach(pill => pill.AutoSizeAxes = Axes.Both));
}
@@ -65,11 +89,6 @@ namespace osu.Game.Tests.Visual.Beatmaps
pill.Status = BeatmapOnlineStatus.LocallyModified;
break;
- // skip none
- case BeatmapOnlineStatus.LocallyModified:
- pill.Status = BeatmapOnlineStatus.Graveyard;
- break;
-
default:
pill.Status = (pill.Status + 1);
break;
diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs
index 11fa6ed92d..39de2b7bc9 100644
--- a/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs
+++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs
@@ -1,12 +1,10 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
-using osu.Game.Beatmaps;
+using osu.Framework.Testing;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
@@ -15,16 +13,18 @@ namespace osu.Game.Tests.Visual.Beatmaps
{
public partial class TestSceneDifficultySpectrumDisplay : OsuTestScene
{
- private DifficultySpectrumDisplay display;
+ private DifficultySpectrumDisplay display = null!;
- private static APIBeatmapSet createBeatmapSetWith(params (int rulesetId, double stars)[] difficulties) => new APIBeatmapSet
+ [SetUpSteps]
+ public void SetUpSteps()
{
- Beatmaps = difficulties.Select(difficulty => new APIBeatmap
+ AddStep("create spectrum display", () => Child = display = new DifficultySpectrumDisplay
{
- RulesetID = difficulty.rulesetId,
- StarRating = difficulty.stars
- }).ToArray()
- };
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Scale = new Vector2(3)
+ });
+ }
[Test]
public void TestSingleRuleset()
@@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual.Beatmaps
(rulesetId: 0, stars: 3.2),
(rulesetId: 0, stars: 5.6));
- createDisplay(beatmapSet);
+ AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet);
}
[Test]
@@ -47,7 +47,7 @@ namespace osu.Game.Tests.Visual.Beatmaps
(rulesetId: 1, stars: 4.3),
(rulesetId: 0, stars: 5.6));
- createDisplay(beatmapSet);
+ AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet);
}
[Test]
@@ -61,52 +61,30 @@ namespace osu.Game.Tests.Visual.Beatmaps
(rulesetId: 0, stars: 5.6),
(rulesetId: 15, stars: 7.8));
- createDisplay(beatmapSet);
+ AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet);
}
[Test]
public void TestMaximumUncollapsed()
{
var beatmapSet = createBeatmapSetWith(Enumerable.Range(0, 12).Select(i => (rulesetId: i % 4, stars: 2.5 + i * 0.25)).ToArray());
- createDisplay(beatmapSet);
+ AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet);
}
[Test]
public void TestMinimumCollapsed()
{
var beatmapSet = createBeatmapSetWith(Enumerable.Range(0, 13).Select(i => (rulesetId: i % 4, stars: 2.5 + i * 0.25)).ToArray());
- createDisplay(beatmapSet);
+ AddStep("set beatmap to display", () => display.BeatmapSet = beatmapSet);
}
- [Test]
- public void TestAdjustableDotSize()
+ private static APIBeatmapSet createBeatmapSetWith(params (int rulesetId, double stars)[] difficulties) => new APIBeatmapSet
{
- var beatmapSet = createBeatmapSetWith(
- (rulesetId: 0, stars: 2.0),
- (rulesetId: 3, stars: 2.3),
- (rulesetId: 0, stars: 3.2),
- (rulesetId: 1, stars: 4.3),
- (rulesetId: 0, stars: 5.6));
-
- createDisplay(beatmapSet);
-
- AddStep("change dot dimensions", () =>
+ Beatmaps = difficulties.Select(difficulty => new APIBeatmap
{
- display.DotSize = new Vector2(8, 12);
- display.DotSpacing = 2;
- });
- AddStep("change dot dimensions back", () =>
- {
- display.DotSize = new Vector2(4, 8);
- display.DotSpacing = 1;
- });
- }
-
- private void createDisplay(IBeatmapSetInfo beatmapSetInfo) => AddStep("create spectrum display", () => Child = display = new DifficultySpectrumDisplay(beatmapSetInfo)
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Scale = new Vector2(3)
- });
+ RulesetID = difficulty.rulesetId,
+ StarRating = difficulty.stars
+ }).ToArray()
+ };
}
}
diff --git a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs
new file mode 100644
index 0000000000..2fe2326508
--- /dev/null
+++ b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs
@@ -0,0 +1,129 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Testing;
+using osu.Game.Online;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Chat;
+using osu.Game.Online.Metadata;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Notifications;
+using osu.Game.Tests.Visual.Metadata;
+using osu.Game.Users;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Components
+{
+ public partial class TestSceneFriendPresenceNotifier : OsuManualInputManagerTestScene
+ {
+ private ChannelManager channelManager = null!;
+ private NotificationOverlay notificationOverlay = null!;
+ private ChatOverlay chatOverlay = null!;
+ private TestMetadataClient metadataClient = null!;
+
+ [SetUp]
+ public void Setup() => Schedule(() =>
+ {
+ Child = new DependencyProvidingContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies =
+ [
+ (typeof(ChannelManager), channelManager = new ChannelManager(API)),
+ (typeof(INotificationOverlay), notificationOverlay = new NotificationOverlay()),
+ (typeof(ChatOverlay), chatOverlay = new ChatOverlay()),
+ (typeof(MetadataClient), metadataClient = new TestMetadataClient()),
+ ],
+ Children = new Drawable[]
+ {
+ channelManager,
+ notificationOverlay,
+ chatOverlay,
+ metadataClient,
+ new FriendPresenceNotifier()
+ }
+ };
+
+ for (int i = 1; i <= 100; i++)
+ ((DummyAPIAccess)API).Friends.Add(new APIRelation { TargetID = i, TargetUser = new APIUser { Username = $"Friend {i}" } });
+ });
+
+ [Test]
+ public void TestNotifications()
+ {
+ AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }));
+ AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
+ AddStep("bring friend 1 offline", () => metadataClient.FriendPresenceUpdated(1, null));
+ AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2));
+ }
+
+ [Test]
+ public void TestSingleUserNotificationOpensChat()
+ {
+ AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }));
+ AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
+
+ AddStep("click notification", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType().First());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddUntilStep("chat overlay opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Visible));
+ AddUntilStep("user channel selected", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo(((DummyAPIAccess)API).Friends[0].TargetUser!.Username));
+ }
+
+ [Test]
+ public void TestMultipleUserNotificationDoesNotOpenChat()
+ {
+ AddStep("bring friends 1 & 2 online", () =>
+ {
+ metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online });
+ metadataClient.FriendPresenceUpdated(2, new UserPresence { Status = UserStatus.Online });
+ });
+
+ AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
+
+ AddStep("click notification", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType().First());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("chat overlay not opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Hidden));
+ }
+
+ [Test]
+ public void TestNonFriendsDoNotNotify()
+ {
+ AddStep("bring non-friend 1000 online", () => metadataClient.UserPresenceUpdated(1000, new UserPresence { Status = UserStatus.Online }));
+ AddWaitStep("wait for possible notification", 10);
+ AddAssert("no notification", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero);
+ }
+
+ [Test]
+ public void TestPostManyDebounced()
+ {
+ AddStep("bring friends 1-10 online", () =>
+ {
+ for (int i = 1; i <= 10; i++)
+ metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.Online });
+ });
+
+ AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
+
+ AddStep("bring friends 1-10 offline", () =>
+ {
+ for (int i = 1; i <= 10; i++)
+ metadataClient.FriendPresenceUpdated(i, null);
+ });
+
+ AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs
index 0742ed5eb9..f1422b4654 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs
@@ -6,6 +6,8 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Screens;
+using osu.Framework.Testing;
+using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.Metadata;
@@ -13,9 +15,11 @@ using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.Metadata;
using osu.Game.Tests.Visual.OnlinePlay;
+using osuTK.Input;
namespace osu.Game.Tests.Visual.DailyChallenge
{
@@ -39,7 +43,6 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
var room = new Room
{
- RoomID = 1234,
Name = "Daily Challenge: June 4, 2024",
Playlist =
[
@@ -57,12 +60,43 @@ namespace osu.Game.Tests.Visual.DailyChallenge
AddStep("push screen", () => LoadScreen(new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
}
+ [Test]
+ public void TestUseTheseModsUnavailableIfNoFreeMods()
+ {
+ var room = new Room
+ {
+ Name = "Daily Challenge: June 4, 2024",
+ Playlist =
+ [
+ new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First())
+ {
+ RequiredMods = [new APIMod(new OsuModTraceable())],
+ AllowedMods = []
+ }
+ ],
+ EndDate = DateTimeOffset.Now.AddHours(12),
+ Category = RoomCategory.DailyChallenge
+ };
+
+ AddStep("add room", () => API.Perform(new CreateRoomRequest(room)));
+ Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!;
+ AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
+ AddUntilStep("wait for pushed", () => screen.IsCurrentScreen());
+ AddStep("force transforms to finish", () => FinishTransforms(true));
+ AddStep("right click second score", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1));
+ InputManager.Click(MouseButton.Right);
+ });
+ AddAssert("use these mods not present",
+ () => this.ChildrenOfType().All(m => m.Items.All(item => item.Text.Value != "Use these mods")));
+ }
+
[Test]
public void TestNotifications()
{
var room = new Room
{
- RoomID = 1234,
Name = "Daily Challenge: June 4, 2024",
Playlist =
[
@@ -77,7 +111,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
};
AddStep("add room", () => API.Perform(new CreateRoomRequest(room)));
- AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 });
+ AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = room.RoomID!.Value });
Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!;
AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
@@ -91,7 +125,6 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
var room = new Room
{
- RoomID = 1234,
Name = "Daily Challenge: June 4, 2024",
Playlist =
[
@@ -106,7 +139,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
};
AddStep("add room", () => API.Perform(new CreateRoomRequest(room)));
- AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 });
+ AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = room.RoomID!.Value });
Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!;
AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs
index b9470f3be4..becce7b22a 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs
@@ -16,6 +16,7 @@ using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.DailyChallenge;
using osu.Game.Screens.OnlinePlay.DailyChallenge.Events;
+using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.DailyChallenge
{
@@ -129,7 +130,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), null);
feed.AddNewScore(ev);
@@ -141,7 +142,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), RNG.Next(1, 1000));
feed.AddNewScore(ev);
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs
index 4b784f661d..eda596effb 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs
@@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), null);
feed.AddNewScore(ev);
@@ -75,7 +75,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), RNG.Next(11, 1000));
var testScore = TestResources.CreateTestScoreInfo();
@@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), RNG.Next(1, 10));
feed.AddNewScore(ev);
@@ -108,7 +108,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), null);
feed.AddNewScore(ev);
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs
index d6665e24a4..97b957df43 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs
@@ -44,17 +44,17 @@ namespace osu.Game.Tests.Visual.DailyChallenge
[Test]
public void TestDailyChallenge()
{
- startChallenge(1234);
+ startChallenge();
AddStep("push screen", () => LoadScreen(new DailyChallengeIntro(room)));
}
[Test]
public void TestPlayIntroOnceFlag()
{
- startChallenge(1234);
+ startChallenge();
AddStep("set intro played flag", () => Dependencies.Get().SetValue(Static.DailyChallengeIntroPlayed, true));
- startChallenge(1235);
+ startChallenge();
AddAssert("intro played flag reset", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed), () => Is.False);
@@ -62,13 +62,12 @@ namespace osu.Game.Tests.Visual.DailyChallenge
AddUntilStep("intro played flag set", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed), () => Is.True);
}
- private void startChallenge(int roomId)
+ private void startChallenge()
{
AddStep("add room", () =>
{
API.Perform(new CreateRoomRequest(room = new Room
{
- RoomID = roomId,
Name = "Daily Challenge: June 4, 2024",
Playlist =
[
@@ -83,7 +82,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
Category = RoomCategory.DailyChallenge
}));
});
- AddStep("signal client", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = roomId }));
+ AddStep("signal client", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = room.RoomID!.Value }));
}
}
}
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs
index b04696aded..b4e1ffffdb 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs
@@ -13,6 +13,7 @@ using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.DailyChallenge;
using osu.Game.Screens.OnlinePlay.DailyChallenge.Events;
+using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.DailyChallenge
{
@@ -65,7 +66,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), null);
breakdown.AddNewScore(ev);
@@ -85,7 +86,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), null);
breakdown.AddNewScore(ev);
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs
index ae212f5212..4619fad938 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs
@@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), null);
totals.AddNewScore(ev);
@@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
Id = 2,
Username = "peppy",
- CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = TestResources.COVER_IMAGE_3,
}, RNG.Next(1_000_000), RNG.Next(11, 1000));
var testScore = TestResources.CreateTestScoreInfo();
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs
new file mode 100644
index 0000000000..f83d424d56
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatmapSubmissionOverlay.cs
@@ -0,0 +1,45 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Testing;
+using osu.Game.Screens.Edit.Submission;
+using osu.Game.Screens.Footer;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+ public partial class TestSceneBeatmapSubmissionOverlay : OsuTestScene
+ {
+ private ScreenFooter footer = null!;
+
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddStep("add overlay", () =>
+ {
+ var receptor = new ScreenFooter.BackReceptor();
+ footer = new ScreenFooter(receptor);
+
+ Child = new DependencyProvidingContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies = new[]
+ {
+ (typeof(ScreenFooter), (object)footer),
+ (typeof(BeatmapSubmissionSettings), new BeatmapSubmissionSettings()),
+ },
+ Children = new Drawable[]
+ {
+ receptor,
+ new BeatmapSubmissionOverlay
+ {
+ State = { Value = Visibility.Visible, },
+ },
+ footer,
+ }
+ };
+ });
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
index fd3431c08b..6a9ca1292c 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
@@ -615,6 +615,25 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft));
}
+ [Test]
+ public void TestUndoAfterQuickDeletingObjectWhileDragged()
+ {
+ AddStep("add hitobject", () => EditorBeatmap.Add(
+ new HitCircle { StartTime = 0, Position = new Vector2(200, 200) }
+ ));
+
+ moveMouseToObject(() => EditorBeatmap.HitObjects[0]);
+
+ AddStep("hold left click", () => InputManager.PressButton(MouseButton.Left));
+ AddStep("drag hitobject to different position", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.BottomRight));
+ AddStep("click middle mouse button", () => InputManager.Click(MouseButton.Middle));
+ AddStep("release left click", () => InputManager.ReleaseButton(MouseButton.Left));
+ AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count, () => Is.Zero);
+
+ AddStep("undo", () => Editor.Undo());
+ AddAssert("one hitobject in beatmap", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1));
+ }
+
[Test]
public void TestShiftModifierMaintainsAspectRatio()
{
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs
index c1a788cd22..fb57422e66 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs
@@ -10,7 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Edit;
-using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Screens.Edit;
@@ -115,7 +115,7 @@ namespace osu.Game.Tests.Visual.Editing
public new int MaxIntervals => base.MaxIntervals;
public TestDistanceSnapGrid(double? endTime = null)
- : base(new HitObject(), grid_position, 0, endTime)
+ : base(grid_position, 0, endTime)
{
}
@@ -181,7 +181,7 @@ namespace osu.Game.Tests.Visual.Editing
}
}
- public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition)
+ public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition, double? fixedTime = null)
=> (Vector2.Zero, 0);
}
@@ -191,15 +191,13 @@ namespace osu.Game.Tests.Visual.Editing
Bindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier;
- public float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) => beat_snap_distance;
+ public float GetBeatSnapDistance(IHasSliderVelocity withVelocity = null) => beat_snap_distance;
- public float DurationToDistance(HitObject referenceObject, double duration) => (float)duration;
+ public float DurationToDistance(double duration, double timingReference, IHasSliderVelocity withVelocity = null) => (float)duration;
- public double DistanceToDuration(HitObject referenceObject, float distance) => distance;
+ public double DistanceToDuration(float distance, double timingReference, IHasSliderVelocity withVelocity = null) => distance;
- public double FindSnappedDuration(HitObject referenceObject, float distance) => 0;
-
- public float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) => 0;
+ public float FindSnappedDistance(float distance, double snapReferenceTime, IHasSliderVelocity withVelocity = null) => 0;
}
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
index b7990b64c1..8d7eb41369 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs
@@ -94,6 +94,8 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.AsNonNull().ID)?.Value.DeletePending == true);
+
+ AddUntilStep("wait for default beatmap", () => Editor.Beatmap.Value is DummyWorkingBeatmap);
}
[Test]
@@ -171,6 +173,8 @@ namespace osu.Game.Tests.Visual.Editing
return difficultyName != null && difficultyName != firstDifficultyName;
});
+ ensureEditorLoaded();
+
AddAssert("created difficulty has timing point", () =>
{
var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single();
@@ -215,6 +219,8 @@ namespace osu.Game.Tests.Visual.Editing
return difficultyName != null && difficultyName != previousDifficultyName;
});
+ ensureEditorLoaded();
+
AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = previousDifficultyName = Guid.NewGuid().ToString());
AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }));
AddStep("add effect points", () =>
@@ -226,8 +232,8 @@ namespace osu.Game.Tests.Visual.Editing
EditorBeatmap.ControlPointInfo.Add(1500, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.3 });
});
+ ensureEditorLoaded();
AddStep("save beatmap", () => Editor.Save());
-
AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo));
AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog);
@@ -239,6 +245,8 @@ namespace osu.Game.Tests.Visual.Editing
return difficultyName != null && difficultyName != previousDifficultyName;
});
+ ensureEditorLoaded();
+
AddAssert("created difficulty has timing point", () =>
{
var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single();
@@ -266,6 +274,14 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("save beatmap", () => Editor.Save());
AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo));
+ AddUntilStep("wait for created", () =>
+ {
+ string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName;
+ return difficultyName != null && difficultyName != firstDifficultyName;
+ });
+
+ ensureEditorLoaded();
+
AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName);
AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }));
AddStep("add effect points", () =>
@@ -277,8 +293,8 @@ namespace osu.Game.Tests.Visual.Editing
EditorBeatmap.ControlPointInfo.Add(1500, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.3 });
});
+ ensureEditorLoaded();
AddStep("save beatmap", () => Editor.Save());
-
AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new TaikoRuleset().RulesetInfo));
AddUntilStep("wait for created", () =>
@@ -287,6 +303,8 @@ namespace osu.Game.Tests.Visual.Editing
return difficultyName != null && difficultyName != firstDifficultyName;
});
+ ensureEditorLoaded();
+
AddAssert("created difficulty has timing point", () =>
{
var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single();
@@ -367,6 +385,8 @@ namespace osu.Game.Tests.Visual.Editing
return difficultyName != null && difficultyName != originalDifficultyName;
});
+ ensureEditorLoaded();
+
AddAssert("created difficulty has copy suffix in name", () => EditorBeatmap.BeatmapInfo.DifficultyName == copyDifficultyName);
AddAssert("created difficulty has timing point", () =>
{
@@ -377,7 +397,9 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("approach rate correctly copied", () => EditorBeatmap.Difficulty.ApproachRate == 4);
AddAssert("combo colours correctly copied", () => EditorBeatmap.BeatmapSkin.AsNonNull().ComboColours.Count == 2);
+ ensureEditorLoaded();
AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified);
+
AddAssert("online ID not copied", () => EditorBeatmap.BeatmapInfo.OnlineID == -1);
AddStep("save beatmap", () => Editor.Save());
@@ -440,6 +462,8 @@ namespace osu.Game.Tests.Visual.Editing
return difficultyName != null && difficultyName != originalDifficultyName;
});
+ ensureEditorLoaded();
+
AddStep("save without changes", () => Editor.Save());
AddAssert("collection still points to old beatmap", () => !collection.BeatmapMD5Hashes.Contains(EditorBeatmap.BeatmapInfo.MD5Hash)
@@ -477,6 +501,9 @@ namespace osu.Game.Tests.Visual.Editing
string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName;
return difficultyName != null && difficultyName != "New Difficulty";
});
+
+ ensureEditorLoaded();
+
AddAssert("new difficulty has correct name", () => EditorBeatmap.BeatmapInfo.DifficultyName == "New Difficulty (1)");
AddAssert("new difficulty persisted", () =>
{
@@ -514,6 +541,8 @@ namespace osu.Game.Tests.Visual.Editing
return difficultyName != null && difficultyName != duplicate_difficulty_name;
});
+ ensureEditorLoaded();
+
AddStep("set difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = duplicate_difficulty_name);
AddStep("try to save beatmap", () => Editor.Save());
AddAssert("beatmap set not corrupted", () =>
@@ -540,6 +569,8 @@ namespace osu.Game.Tests.Visual.Editing
return set != null && set.PerformRead(s => s.Beatmaps.Count == 1 && s.Files.Count == 1);
});
+ ensureEditorLoaded();
+
AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new CatchRuleset().RulesetInfo));
AddUntilStep("wait for created", () =>
@@ -547,7 +578,8 @@ namespace osu.Game.Tests.Visual.Editing
string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName;
return difficultyName != null && difficultyName != duplicate_difficulty_name;
});
- AddUntilStep("wait for editor load", () => Editor.IsLoaded && DialogOverlay.IsLoaded);
+
+ ensureEditorLoaded();
AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[]
{
@@ -584,6 +616,9 @@ namespace osu.Game.Tests.Visual.Editing
string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName;
return difficultyName != null && difficultyName == "New Difficulty";
});
+
+ ensureEditorLoaded();
+
AddAssert("new difficulty persisted", () =>
{
var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId);
@@ -602,6 +637,8 @@ namespace osu.Game.Tests.Visual.Editing
StartTime = 1000
}
}));
+
+ ensureEditorLoaded();
AddStep("save beatmap", () => Editor.Save());
AddStep("try to create new catch difficulty", () => Editor.CreateNewDifficulty(new CatchRuleset().RulesetInfo));
@@ -610,6 +647,9 @@ namespace osu.Game.Tests.Visual.Editing
string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName;
return difficultyName != null && difficultyName == "New Difficulty (1)";
});
+
+ ensureEditorLoaded();
+
AddAssert("new difficulty persisted", () =>
{
var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId);
@@ -735,6 +775,8 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("other audio not removed", () => Beatmap.Value.BeatmapSetInfo.Files.Any(f => f.Filename == "audio (1).mp3"));
}
+ private void ensureEditorLoaded() => AddUntilStep("wait for editor load", () => Editor.ReadyForUse && DialogOverlay.IsLoaded);
+
private void createNewDifficulty()
{
string? currentDifficulty = null;
@@ -748,13 +790,14 @@ namespace osu.Game.Tests.Visual.Editing
AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog);
AddStep("confirm creation with no objects", () => DialogOverlay.CurrentDialog!.PerformOkAction());
+
AddUntilStep("wait for created", () =>
{
string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName;
return difficultyName != null && difficultyName != currentDifficulty;
});
+ ensureEditorLoaded();
- AddUntilStep("wait for editor load", () => Editor.IsLoaded);
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddUntilStep("wait for load", () => Editor.ChildrenOfType().Any());
}
@@ -765,7 +808,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep($"switch to difficulty #{index + 1}", () =>
Editor.SwitchToDifficulty(Beatmap.Value.BeatmapSetInfo.Beatmaps.ElementAt(index)));
- AddUntilStep("wait for editor load", () => Editor.IsLoaded);
+ ensureEditorLoaded();
AddStep("enter setup mode", () => Editor.Mode.Value = EditorScreenMode.SongSetup);
AddUntilStep("wait for load", () => Editor.ChildrenOfType().Any());
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs
index a766b253aa..ce9dbd5fb1 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs
@@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1);
- AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == newTime);
+ AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(newTime, null));
}
[Test]
@@ -122,6 +122,8 @@ namespace osu.Game.Tests.Visual.Editing
[TestCase(true)]
public void TestCopyPaste(bool deselectAfterCopy)
{
+ const int paste_time = 2000;
+
var addedObject = new HitCircle { StartTime = 1000 };
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
@@ -130,7 +132,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("copy hitobject", () => Editor.Copy());
- AddStep("move forward in time", () => EditorClock.Seek(2000));
+ AddStep("move forward in time", () => EditorClock.Seek(paste_time));
if (deselectAfterCopy)
{
@@ -144,7 +146,7 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("are two objects", () => EditorBeatmap.HitObjects.Count == 2);
- AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000);
+ AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(paste_time, null));
AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0);
AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0);
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs
new file mode 100644
index 0000000000..edaba67591
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs
@@ -0,0 +1,82 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Screens.Edit;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+ public partial class TestSceneEditorClipboardSnapping : EditorTestScene
+ {
+ protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
+
+ private const double beat_length = 60_000 / 180.0; // 180 bpm
+ private const double timing_point_time = 1500;
+
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
+ {
+ var controlPointInfo = new ControlPointInfo();
+ controlPointInfo.Add(timing_point_time, new TimingControlPoint { BeatLength = beat_length });
+ return new TestBeatmap(ruleset, false)
+ {
+ ControlPointInfo = controlPointInfo
+ };
+ }
+
+ [TestCase(1)]
+ [TestCase(2)]
+ [TestCase(3)]
+ [TestCase(4)]
+ [TestCase(6)]
+ [TestCase(8)]
+ [TestCase(12)]
+ [TestCase(16)]
+ public void TestPasteSnapping(int divisor)
+ {
+ const double paste_time = timing_point_time + 1271; // arbitrary timestamp that doesn't snap to the timing point at any divisor
+
+ var addedObjects = new HitObject[]
+ {
+ new HitCircle { StartTime = 1000 },
+ new HitCircle { StartTime = 1200 },
+ };
+
+ AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects));
+ AddStep("select added objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects));
+ AddStep("copy hitobjects", () => Editor.Copy());
+
+ AddStep($"set beat divisor to 1/{divisor}", () =>
+ {
+ var beatDivisor = (BindableBeatDivisor)Editor.Dependencies.Get(typeof(BindableBeatDivisor));
+ beatDivisor.SetArbitraryDivisor(divisor);
+ });
+
+ AddStep("move forward in time", () => EditorClock.Seek(paste_time));
+ AddAssert("not at snapped time", () => EditorClock.CurrentTime != EditorBeatmap.SnapTime(EditorClock.CurrentTime, null));
+
+ AddStep("paste hitobjects", () => Editor.Paste());
+
+ AddAssert("first object is snapped", () => Precision.AlmostEquals(
+ EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!.StartTime,
+ EditorBeatmap.ControlPointInfo.GetClosestSnappedTime(paste_time, divisor)
+ ));
+
+ AddAssert("duration between pasted objects is same", () =>
+ {
+ var firstObject = EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!;
+ var secondObject = EditorBeatmap.SelectedHitObjects.MaxBy(h => h.StartTime)!;
+
+ return Precision.AlmostEquals(secondObject.StartTime - firstObject.StartTime, addedObjects[1].StartTime - addedObjects[0].StartTime);
+ });
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs
index 8b941d7597..092b2bc01c 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs
@@ -24,12 +24,7 @@ namespace osu.Game.Tests.Visual.Editing
PoolableSkinnableSample[] loopingSamples = null;
PoolableSkinnableSample[] onceOffSamples = null;
- AddStep("get first slider", () =>
- {
- slider = Editor.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First();
- onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray();
- loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray();
- });
+ AddStep("get first slider", () => slider = Editor.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First());
AddStep("start playback", () => EditorClock.Start());
@@ -38,6 +33,9 @@ namespace osu.Game.Tests.Visual.Editing
if (!slider.Tracking.Value)
return false;
+ onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray();
+ loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray();
+
if (!loopingSamples.Any(s => s.Playing))
return false;
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
index d1782da25f..7f40da5bab 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs
@@ -15,7 +15,7 @@ using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Overlays;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose.Components.Timeline;
-using osu.Game.Screens.Select;
+using osu.Game.Screens.SelectV2;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing
@@ -190,7 +190,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("Set tags again", () => EditorBeatmap.BeatmapInfo.Metadata.Tags = tags_to_discard);
AddStep("Exit editor", () => Editor.Exit());
- AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect);
+ AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect);
AddAssert("Tags reverted correctly", () => Game.Beatmap.Value.BeatmapInfo.Metadata.Tags == tags_to_save);
}
@@ -208,5 +208,11 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("Beatmap still has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(7));
AddAssert("Correct beat divisor actually active", () => Editor.BeatDivisor, () => Is.EqualTo(7));
}
+
+ [Test]
+ public void TestBeatmapVersionPopulatedCorrectly()
+ {
+ AddAssert("beatmap version is populated", () => EditorBeatmap.BeatmapVersion > 0);
+ }
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs
index 23efb40d3f..f65a3e67e8 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs
@@ -7,18 +7,18 @@ using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
-using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
-using osu.Game.Configuration;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.UI;
+using osu.Game.Screens;
using osu.Game.Screens.Backgrounds;
using osu.Game.Screens.Edit;
+using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Edit.Components.Timelines.Summary;
using osu.Game.Screens.Edit.GameplayTest;
using osu.Game.Screens.Play;
@@ -42,14 +42,6 @@ namespace osu.Game.Tests.Visual.Editing
private BeatmapSetInfo importedBeatmapSet;
- private Bindable editorDim;
-
- [BackgroundDependencyLoader]
- private void load(OsuConfigManager config)
- {
- editorDim = config.GetBindable(OsuSetting.EditorDim);
- }
-
public override void SetUpSteps()
{
AddStep("import test beatmap", () => importedBeatmapSet = BeatmapImportHelper.LoadOszIntoOsu(game).GetResultSafely());
@@ -80,15 +72,7 @@ namespace osu.Game.Tests.Visual.Editing
AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null);
AddStep("exit player", () => editorPlayer.Exit());
AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor);
- AddUntilStep("background has correct params", () =>
- {
- // the test gameplay player's beatmap may be the "same" beatmap as the one being edited, *but* the `BeatmapInfo` references may differ
- // due to the beatmap refetch logic ran on editor suspend.
- // this test cares about checking the background belonging to the editor specifically, so check that using reference equality
- // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID).
- var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo));
- return background.DimWhenUserSettingsIgnored.Value == editorDim.Value && background.BlurAmount.Value == 0;
- });
+ AddUntilStep("background is correct", () => this.ChildrenOfType().Single().CurrentScreen is EditorBackgroundScreen);
AddAssert("no mods selected", () => SelectedMods.Value.Count == 0);
}
@@ -113,20 +97,41 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("exit player", () => editorPlayer.Exit());
AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor);
- AddUntilStep("background has correct params", () =>
- {
- // the test gameplay player's beatmap may be the "same" beatmap as the one being edited, *but* the `BeatmapInfo` references may differ
- // due to the beatmap refetch logic ran on editor suspend.
- // this test cares about checking the background belonging to the editor specifically, so check that using reference equality
- // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID).
- var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo));
- return background.DimWhenUserSettingsIgnored.Value == editorDim.Value && background.BlurAmount.Value == 0;
- });
+ AddUntilStep("background is correct", () => this.ChildrenOfType().Single().CurrentScreen is EditorBackgroundScreen);
AddStep("start track", () => EditorClock.Start());
AddAssert("sample playback re-enabled", () => !Editor.SamplePlaybackDisabled.Value);
}
+ [Test]
+ public void TestGameplayTestResetsPlaybackSpeedAdjustment()
+ {
+ AddStep("start track", () => EditorClock.Start());
+ AddStep("change playback speed", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType().First());
+ InputManager.Click(MouseButton.Left);
+ });
+ AddAssert("track playback rate is 0.25x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(0.25));
+
+ AddStep("click test gameplay button", () =>
+ {
+ var button = Editor.ChildrenOfType().Single();
+
+ InputManager.MoveMouseTo(button);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ EditorPlayer editorPlayer = null;
+ AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null);
+ AddAssert("editor track stopped", () => !EditorClock.IsRunning);
+ AddAssert("track playback rate is 1x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(1));
+
+ AddStep("exit player", () => editorPlayer.Exit());
+ AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor);
+ AddAssert("track playback rate is 0.25x", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(0.25));
+ }
+
[TestCase(2000)] // chosen to be after last object in the map
[TestCase(22000)] // chosen to be in the middle of the last spinner
public void TestGameplayTestAtEndOfBeatmap(int offsetFromEnd)
@@ -177,6 +182,7 @@ namespace osu.Game.Tests.Visual.Editing
// bit of a hack to ensure this test can be ran multiple times without running into UNIQUE constraint failures
AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = Guid.NewGuid().ToString());
+ AddStep("start playing track", () => InputManager.Key(Key.Space));
AddStep("click test gameplay button", () =>
{
var button = Editor.ChildrenOfType().Single();
@@ -185,11 +191,13 @@ namespace osu.Game.Tests.Visual.Editing
InputManager.Click(MouseButton.Left);
});
AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveRequiredPopupDialog);
+ AddAssert("track stopped", () => !Beatmap.Value.Track.IsRunning);
AddStep("save changes", () => DialogOverlay.CurrentDialog!.PerformOkAction());
EditorPlayer editorPlayer = null;
AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null);
+ AddUntilStep("track playing", () => Beatmap.Value.Track.IsRunning);
AddAssert("beatmap has 1 object", () => editorPlayer.Beatmap.Value.Beatmap.HitObjects.Count == 1);
AddUntilStep("wait for return to editor", () => Stack.CurrentScreen is Editor);
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs
index 7f9a69833c..636b3f54d8 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs
@@ -4,6 +4,7 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
+using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Tests.Resources;
@@ -25,13 +26,16 @@ namespace osu.Game.Tests.Visual.Editing
[Test]
public void TestLocallyModifyingOnlineBeatmap()
{
+ string initialHash = string.Empty;
AddAssert("editor beatmap has online ID", () => EditorBeatmap.BeatmapInfo.OnlineID, () => Is.GreaterThan(0));
+ AddStep("store hash for later", () => initialHash = EditorBeatmap.BeatmapInfo.MD5Hash);
AddStep("delete first hitobject", () => EditorBeatmap.RemoveAt(0));
SaveEditor();
ReloadEditorToSameBeatmap();
- AddAssert("editor beatmap online ID reset", () => EditorBeatmap.BeatmapInfo.OnlineID, () => Is.EqualTo(-1));
+ AddAssert("beatmap marked as locally modified", () => EditorBeatmap.BeatmapInfo.Status, () => Is.EqualTo(BeatmapOnlineStatus.LocallyModified));
+ AddAssert("beatmap hash changed", () => EditorBeatmap.BeatmapInfo.MD5Hash, () => Is.Not.EqualTo(initialHash));
}
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs
index 743529d40c..995acd28dd 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs
@@ -65,10 +65,10 @@ namespace osu.Game.Tests.Visual.Editing
InputManager.Keys(PlatformAction.Paste);
});
- assertArtistMetadata("Example Artist");
+ assertArtistMetadata("Example ArtistExample Artist");
// It's important values are committed immediately on focus loss so the editor exit sequence detects them.
- AddAssert("value immediately changed on focus loss", () =>
+ AddAssert("value still changed after focus loss", () =>
{
((IFocusManager)InputManager).TriggerFocusContention(metadataSection);
return editorBeatmap.Metadata.Artist;
@@ -104,7 +104,7 @@ namespace osu.Game.Tests.Visual.Editing
InputManager.Keys(PlatformAction.Paste);
});
- assertArtistMetadata("Example Artist");
+ assertArtistMetadata("Example ArtistExample Artist");
AddStep("commit", () => InputManager.Key(Key.Enter));
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs
index 955ded97af..e3b79d4053 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs
@@ -14,7 +14,7 @@ using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Menu;
-using osu.Game.Screens.Select;
+using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.Editing
@@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.Editing
() => Is.EqualTo(1));
AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke());
- AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded);
+ AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect && songSelect.CarouselItemsPresented);
addStepClickLink("00:00:000 (1)", waitForSeek: false);
AddUntilStep("received 'must be in edit'",
@@ -151,12 +151,12 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("Present beatmap", () => Game.PresentBeatmap(beatmapSet));
AddUntilStep("Wait for song select", () =>
Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet)
- && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect
- && songSelect.BeatmapSetsLoaded
+ && Game.ScreenStack.CurrentScreen is SoloSongSelect songSelect
+ && songSelect.CarouselItemsPresented
);
AddStep("Switch ruleset", () => Game.Ruleset.Value = ruleset);
AddStep("Open editor for ruleset", () =>
- ((PlaySongSelect)Game.ScreenStack.CurrentScreen)
+ ((SoloSongSelect)Game.ScreenStack.CurrentScreen)
.Edit(beatmapSet.Beatmaps.Last(beatmap => beatmap.Ruleset.Name == ruleset.Name))
);
AddUntilStep("Wait for editor open", () => editor?.ReadyForUse == true);
diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs
index 966e6513bb..ae20f5e5cf 100644
--- a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs
+++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs
@@ -7,6 +7,7 @@ using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Audio;
using osu.Game.Beatmaps;
+using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
@@ -14,8 +15,10 @@ using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.UI;
+using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Beatmaps;
+using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing
@@ -58,23 +61,66 @@ namespace osu.Game.Tests.Visual.Editing
}
[Test]
- public void TestContextMenu()
+ public void TestRightClickDuringEmptyPlacementTogglesNewCombo()
+ {
+ AddStep("select circle placement tool", () => InputManager.Key(Key.Number2));
+ AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single()));
+ AddStep("place circle", () => InputManager.Click(MouseButton.Left));
+ AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items);
+
+ AddStep("move mouse away from placed circle", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft + Vector2.One));
+
+ AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False));
+ AddStep("click right mouse", () => InputManager.Click(MouseButton.Right));
+ AddAssert("new combo true", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.True));
+ AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent));
+
+ AddStep("click right mouse", () => InputManager.Click(MouseButton.Right));
+ AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False));
+ AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent));
+ }
+
+ [Test]
+ public void TestRightClickDuringPlacementDeletes()
+ {
+ AddStep("select circle placement tool", () => InputManager.Key(Key.Number2));
+ AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single()));
+ AddStep("place circle", () => InputManager.Click(MouseButton.Left));
+ AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items);
+
+ AddStep("click right mouse", () => InputManager.Click(MouseButton.Right));
+
+ AddAssert("circle removed", () => EditorBeatmap.HitObjects, () => Has.Exactly(0).Items);
+ AddAssert("circle not selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Exactly(0).Items);
+ AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent));
+ AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False));
+ }
+
+ [Test]
+ public void TestRightClickDuringSelectionShowsContextMenu()
{
AddStep("select circle placement tool", () => InputManager.Key(Key.Number2));
AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single()));
AddStep("place circle", () => InputManager.Click(MouseButton.Left));
- AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items);
- AddStep("delete with right mouse", () =>
- {
- InputManager.Click(MouseButton.Right);
- });
- AddAssert("circle not removed", () => EditorBeatmap.HitObjects, () => Has.One.Items);
+ // ensure the circle we're selecting is not a new combo so we can assert
+ // new combo doesn't happen to get toggled by right click.
+ AddStep("seek forward", () => EditorClock.Seek(1000));
+ AddStep("place second circle", () => InputManager.Click(MouseButton.Left));
+
+ AddAssert("two circles added", () => EditorBeatmap.HitObjects, () => Has.Exactly(2).Items);
+ AddAssert("context menu not visible", () => !Editor.ChildrenOfType().Any(c => c.IsPresent));
+
+ AddStep("select selection tool", () => InputManager.Key(Key.Number1));
+ AddStep("click right mouse", () => InputManager.Click(MouseButton.Right));
+
+ AddAssert("circle not removed", () => EditorBeatmap.HitObjects, () => Has.Exactly(2).Items);
AddAssert("circle selected", () => EditorBeatmap.SelectedHitObjects, () => Has.One.Items);
+ AddAssert("context menu visible", () => Editor.ChildrenOfType().Any(c => c.IsPresent));
+ AddAssert("new combo false", () => this.ChildrenOfType().Single().Current.Value, () => Is.EqualTo(TernaryState.False));
}
[Test]
- [Solo]
public void TestCommitPlacementViaRightClick()
{
Playfield playfield = null!;
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs
new file mode 100644
index 0000000000..ee22cbda71
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs
@@ -0,0 +1,167 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Threading;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps.Drawables.Cards;
+using osu.Game.Overlays;
+using osu.Game.Screens.Edit.Submission;
+using osuTK;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+ public partial class TestSceneSubmissionStageProgress : OsuTestScene
+ {
+ [Cached]
+ private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
+
+ [Resolved]
+ private AudioManager audio { get; set; } = null!;
+
+ private Sample? completeSample;
+
+ [Test]
+ public void TestAppearance()
+ {
+ float incrementingProgress = 0;
+
+ SubmissionStageProgress progress = null!;
+
+ AddStep("create content", () => Child = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Size = new Vector2(0.8f),
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Child = progress = new SubmissionStageProgress
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ StageDescription = "Frobnicating the foobarator...",
+ }
+ });
+ AddStep("not started", () => progress.SetNotStarted());
+ AddStep("indeterminate progress", () => progress.SetInProgress());
+ AddStep("increase progress to 100", () =>
+ {
+ incrementingProgress = 0;
+
+ ScheduledDelegate? task = null;
+
+ task = Scheduler.AddDelayed(() =>
+ {
+ if (incrementingProgress >= 1)
+ {
+ // ReSharper disable once AccessToModifiedClosure
+ task?.Cancel();
+ return;
+ }
+
+ if (RNG.NextDouble() < 0.01)
+ progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f));
+ }, 0, true);
+ });
+
+ AddUntilStep("wait for completed", () => incrementingProgress >= 1);
+ AddStep("completed", () => progress.SetCompleted());
+ AddStep("failed", () => progress.SetFailed("the foobarator has defrobnicated"));
+ AddStep("failed with long message", () => progress.SetFailed("this is a very very very very VERY VEEEEEEEEEEEEEEEEEEEEEEEEERY long error message like you would never believe"));
+ AddStep("canceled", () => progress.SetCanceled());
+ }
+
+ [Test]
+ public void TestAudioSequence()
+ {
+ SubmissionStageProgress[] stages = new SubmissionStageProgress[4];
+ Container? cardContainer = null;
+
+ AddStep("prepare", () =>
+ {
+ Child = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Size = new Vector2(1),
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Child = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Size = new Vector2(0.8f),
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(0, 5),
+ Children = new Drawable[]
+ {
+ stages[0] = new SubmissionStageProgress
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ StageDescription = "Export...",
+ StageIndex = 0
+ },
+ stages[1] = new SubmissionStageProgress
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ StageDescription = "CreateSet...",
+ StageIndex = 1
+ },
+ stages[2] = new SubmissionStageProgress
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ StageDescription = "Upload...",
+ StageIndex = 2
+ },
+ stages[3] = new SubmissionStageProgress
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ StageDescription = "Update...",
+ StageIndex = 3
+ },
+ cardContainer = new Container
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ }
+ }
+ }
+ };
+
+ completeSample = audio.Samples.Get(@"UI/bss-complete");
+ });
+
+ for (int i = 0; i < stages.Length; i++)
+ {
+ int step = i;
+ AddStep($"{step}: not started", () => stages[step].SetNotStarted());
+ AddStep($"{step}: indeterminate progress", () => stages[step].SetInProgress());
+ AddStep($"{step}: 25% progress", () => stages[step].SetInProgress(0.25f));
+ AddStep($"{step}: 70% progress", () => stages[step].SetInProgress(0.7f));
+ AddStep($"{step}: completed", () => stages[step].SetCompleted());
+ }
+
+ AddWaitStep("pause for timing", 2);
+
+ AddStep("Sequence Complete", () =>
+ {
+ var beatmapSet = CreateAPIBeatmapSet(Ruleset.Value);
+ beatmapSet.Beatmaps = Enumerable.Repeat(beatmapSet.Beatmaps.First(), 100).ToArray();
+ LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded =>
+ {
+ cardContainer?.Add(loaded);
+ completeSample?.Play();
+ });
+ });
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs
index cf07ce2431..eecfb7cb6e 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs
@@ -32,6 +32,7 @@ namespace osu.Game.Tests.Visual.Editing
private TimingScreen timingScreen;
private EditorBeatmap editorBeatmap;
+ private BeatmapEditorChangeHandler changeHandler;
protected override bool ScrollUsingMouseWheel => false;
@@ -46,6 +47,7 @@ namespace osu.Game.Tests.Visual.Editing
private void reloadEditorBeatmap()
{
editorBeatmap = new EditorBeatmap(Beatmap.Value.GetPlayableBeatmap(Ruleset.Value));
+ changeHandler = new BeatmapEditorChangeHandler(editorBeatmap);
Child = new DependencyProvidingContainer
{
@@ -53,6 +55,7 @@ namespace osu.Game.Tests.Visual.Editing
CachedDependencies = new (Type, object)[]
{
(typeof(EditorBeatmap), editorBeatmap),
+ (typeof(IEditorChangeHandler), changeHandler),
(typeof(IBeatSnapProvider), editorBeatmap)
},
Child = timingScreen = new TimingScreen
@@ -72,8 +75,10 @@ namespace osu.Game.Tests.Visual.Editing
AddUntilStep("Wait for rows to load", () => Child.ChildrenOfType().Any());
}
+ // TODO: this is best-effort for now, but the comment out test below should probably be how things should work.
+ // Was originally working as of https://github.com/ppy/osu/pull/26141; Regressed at some point.
[Test]
- public void TestSelectedRetainedOverUndo()
+ public void TestSelectionDismissedOnUndo()
{
AddStep("Select first timing point", () =>
{
@@ -95,25 +100,52 @@ namespace osu.Game.Tests.Visual.Editing
return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170;
});
- AddStep("simulate undo", () =>
- {
- var clone = editorBeatmap.ControlPointInfo.DeepClone();
+ AddStep("undo", () => changeHandler?.RestoreState(-1));
- editorBeatmap.ControlPointInfo.Clear();
-
- foreach (var group in clone.Groups)
- {
- foreach (var cp in group.ControlPoints)
- editorBeatmap.ControlPointInfo.Add(group.Time, cp);
- }
- });
-
- AddUntilStep("selection retained", () =>
- {
- return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170;
- });
+ AddUntilStep("selection dismissed", () => timingScreen.SelectedGroup.Value, () => Is.Null);
}
+ // [Test]
+ // public void TestSelectedRetainedOverUndo()
+ // {
+ // AddStep("Select first timing point", () =>
+ // {
+ // InputManager.MoveMouseTo(Child.ChildrenOfType().First());
+ // InputManager.Click(MouseButton.Left);
+ // });
+ //
+ // AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 2170);
+ // AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 2170);
+ //
+ // AddStep("Adjust offset", () =>
+ // {
+ // InputManager.MoveMouseTo(timingScreen.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre + new Vector2(20, 0));
+ // InputManager.Click(MouseButton.Left);
+ // });
+ //
+ // AddUntilStep("wait for offset changed", () =>
+ // {
+ // return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170;
+ // });
+ //
+ // AddStep("undo", () => changeHandler?.RestoreState(-1));
+ //
+ // AddUntilStep("selection retained", () =>
+ // {
+ // return timingScreen.SelectedGroup.Value.ControlPoints.Any(c => c is TimingControlPoint) && timingScreen.SelectedGroup.Value.Time > 2170;
+ // });
+ //
+ // AddAssert("check group count", () => editorBeatmap.ControlPointInfo.Groups.Count, () => Is.EqualTo(10));
+ //
+ // AddStep("Adjust offset", () =>
+ // {
+ // InputManager.MoveMouseTo(timingScreen.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre + new Vector2(20, 0));
+ // InputManager.Click(MouseButton.Left);
+ // });
+ //
+ // AddAssert("check group count", () => editorBeatmap.ControlPointInfo.Groups.Count, () => Is.EqualTo(10));
+ // }
+
[Test]
public void TestScrollControlGroupIntoView()
{
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs
index 1c8a18e131..2c84e76b2e 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs
@@ -128,12 +128,12 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("Press alt down", () => InputManager.PressKey(Key.AltLeft));
AddStep("Scroll by 3", () => InputManager.ScrollBy(new Vector2(0, 3)));
AddAssert("Box not at 0", () => !Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft));
- AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X));
+ AddAssert("Box 1/2 at 1/2", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.5f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.5f * scrollQuad.Size.X));
// Scroll out at 0.25
AddStep("Scroll by -3", () => InputManager.ScrollBy(new Vector2(0, -3)));
AddAssert("Box at 0", () => Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft));
- AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X));
+ AddAssert("Box 1/2 at 1/2", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.5f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.5f * scrollQuad.Size.X));
AddStep("Release alt", () => InputManager.ReleaseKey(Key.AltLeft));
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
index 0f47c3cd27..adbfebbfc6 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
@@ -4,12 +4,16 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Allocation;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
@@ -23,22 +27,20 @@ namespace osu.Game.Tests.Visual.Gameplay
public partial class TestSceneBeatmapOffsetControl : OsuTestScene
{
private BeatmapOffsetControl offsetControl = null!;
+ private OsuConfigManager localConfig = null!;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage));
+ }
[SetUpSteps]
public void SetUpSteps()
{
- AddStep("Create control", () =>
- {
- Child = new PlayerSettingsGroup("Some settings")
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Children = new Drawable[]
- {
- offsetControl = new BeatmapOffsetControl()
- }
- };
- });
+ AddStep("reset settings", () => localConfig.SetValue(OsuSetting.AutomaticallyAdjustBeatmapOffset, false));
+
+ recreateControl();
}
[Test]
@@ -56,26 +58,60 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any());
}
+ ///
+ /// If we already have an old score with enough hit events and the new score doesn't have enough, continue displaying the old one rather than showing the user "play too short" message.
+ ///
+ [Test]
+ public void TestTooShortToDisplay_HasPreviousValidScore()
+ {
+ const double average_error = -4.5;
+ const double initial_offset = -2;
+
+ AddStep("Set offset non-neutral", () => offsetControl.Current.Value = initial_offset);
+ AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any());
+
+ AddStep("Set reference score", () =>
+ {
+ offsetControl.ReferenceScore.Value = new ScoreInfo
+ {
+ HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error),
+ BeatmapInfo = Beatmap.Value.BeatmapInfo,
+ };
+ });
+
+ AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any());
+
+ AddStep("Set short reference score", () =>
+ {
+ offsetControl.ReferenceScore.Value = new ScoreInfo
+ {
+ HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(0, 2),
+ BeatmapInfo = Beatmap.Value.BeatmapInfo,
+ };
+ });
+
+ AddUntilStep("Still calibration button", () => offsetControl.ChildrenOfType().Any());
+
+ AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick());
+ AddAssert("Offset is adjusted", () => offsetControl.Current.Value == initial_offset - average_error);
+ }
+
[Test]
public void TestNotEnoughTimedHitEvents()
{
AddStep("Set short reference score", () =>
{
+ // 50 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows
List hitEvents =
[
- // 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows
new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
- new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
];
+ for (int i = 0; i < 49; i++)
+ {
+ hitEvents.Add(new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null));
+ }
+
foreach (var ev in hitEvents)
ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@@ -123,13 +159,14 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestCalibrationFromZero()
{
+ ScoreInfo referenceScore = null!;
const double average_error = -4.5;
AddAssert("Offset is neutral", () => offsetControl.Current.Value == 0);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any());
AddStep("Set reference score", () =>
{
- offsetControl.ReferenceScore.Value = new ScoreInfo
+ offsetControl.ReferenceScore.Value = referenceScore = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
@@ -137,11 +174,13 @@ namespace osu.Game.Tests.Visual.Gameplay
});
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any());
+ AddAssert("Offset is still neutral", () => offsetControl.Current.Value == 0);
AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error);
-
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value);
- AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
+
+ recreateControl();
+ AddStep("Set same reference score", () => offsetControl.ReferenceScore.Value = referenceScore);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any());
}
@@ -151,6 +190,7 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestCalibrationFromNonZero()
{
+ ScoreInfo referenceScore = null!;
const double average_error = -4.5;
const double initial_offset = -2;
@@ -158,7 +198,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any());
AddStep("Set reference score", () =>
{
- offsetControl.ReferenceScore.Value = new ScoreInfo
+ offsetControl.ReferenceScore.Value = referenceScore = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
@@ -166,11 +206,13 @@ namespace osu.Game.Tests.Visual.Gameplay
});
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any());
+ AddAssert("Offset still not adjusted", () => offsetControl.Current.Value == initial_offset);
AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value == initial_offset - average_error);
-
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value);
- AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
+
+ recreateControl();
+ AddStep("Set same reference score", () => offsetControl.ReferenceScore.Value = referenceScore);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any());
}
@@ -217,12 +259,10 @@ namespace osu.Game.Tests.Visual.Gameplay
});
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any());
+ AddAssert("Offset still not adjusted", () => offsetControl.Current.Value == initial_offset);
AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value, () => Is.EqualTo(initial_offset - average_error));
-
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value);
- AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
- AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any());
AddStep("Clean up beatmap", () => Realm.Write(r => r.RemoveAll()));
}
@@ -246,10 +286,106 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any());
AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error);
-
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value);
- AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
- AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any());
+ }
+
+ [Test]
+ public void TestAutomaticAdjustment()
+ {
+ const double average_error = -4.5;
+
+ AddStep("enable automatic adjust", () => localConfig.SetValue(OsuSetting.AutomaticallyAdjustBeatmapOffset, true));
+ AddAssert("offset zero", () => offsetControl.Current.Value == 0);
+
+ AddStep("set reference score", () =>
+ {
+ offsetControl.ReferenceScore.Value = new ScoreInfo
+ {
+ HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error),
+ BeatmapInfo = Beatmap.Value.BeatmapInfo,
+ };
+ });
+
+ AddAssert("no calibration button", () => !offsetControl.ChildrenOfType().Any(b => b.IsPresent));
+ AddAssert("offset adjustment text displayed", () => offsetControl.ChildrenOfType().Any(t => t.Text.ToString().Contains("adjusted")));
+ AddAssert("offset adjusted", () => offsetControl.Current.Value == -average_error);
+
+ AddStep("set reference score", () =>
+ {
+ offsetControl.ReferenceScore.Value = new ScoreInfo
+ {
+ HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(0),
+ BeatmapInfo = Beatmap.Value.BeatmapInfo,
+ };
+ });
+
+ AddAssert("no calibration button", () => !offsetControl.ChildrenOfType().Any(b => b.IsPresent));
+ AddAssert("offset adjustment text not displayed", () => !offsetControl.ChildrenOfType().Any(t => t.Text.ToString().Contains("adjusted")));
+ AddAssert("offset still", () => offsetControl.Current.Value == -average_error);
+
+ AddStep("adjust offset manually", () => offsetControl.Current.Value = 0);
+ AddUntilStep("calibration button displayed", () => offsetControl.ChildrenOfType().Any());
+
+ AddStep("press button", () => offsetControl.ChildrenOfType().Single().TriggerClick());
+ AddAssert("offset adjusted", () => offsetControl.Current.Value == -average_error);
+ AddUntilStep("button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value);
+ }
+
+ [Test]
+ public void TestAutomaticAdjustmentWithUnstableRate()
+ {
+ const double average_error = -25;
+ const int spread = 25;
+ const double expected_offset = 12.9; // due to high UR (~147). see BeatmapOffsetControl.computeSuggestedOffset()
+
+ AddStep("enable automatic adjust", () => localConfig.SetValue(OsuSetting.AutomaticallyAdjustBeatmapOffset, true));
+ AddAssert("offset zero", () => offsetControl.Current.Value == 0);
+
+ AddStep("set reference score", () =>
+ {
+ offsetControl.ReferenceScore.Value = new ScoreInfo
+ {
+ // distribute the hit events such that it produces ~147 UR. setup taken from UnstableRateTest.
+ HitEvents = Enumerable.Range((int)average_error - spread, spread * 2 + 1)
+ .Select(t => new HitEvent(t, 1.0, HitResult.Great, new HitObject(), null, null))
+ .ToList(),
+
+ BeatmapInfo = Beatmap.Value.BeatmapInfo,
+ };
+ });
+
+ AddAssert("no calibration button", () => !offsetControl.ChildrenOfType().Any(b => b.IsPresent));
+ AddAssert("offset adjustment text displayed", () => offsetControl.ChildrenOfType().Any(t => t.Text.ToString().Contains("adjusted")));
+ AddAssert("offset adjusted", () => offsetControl.Current.Value == expected_offset);
+
+ AddStep("adjust offset manually", () => offsetControl.Current.Value = 0);
+ AddUntilStep("calibration button displayed", () => offsetControl.ChildrenOfType().Any());
+
+ AddStep("press button", () => offsetControl.ChildrenOfType().Single().TriggerClick());
+ AddAssert("offset adjusted", () => offsetControl.Current.Value == expected_offset);
+ AddUntilStep("button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value);
+ }
+
+ [Test]
+ public void TestNegativeZero()
+ {
+ AddAssert("assert", () => BeatmapOffsetControl.GetOffsetExplanatoryText(-0.0001).ToString(), () => Is.EqualTo("0 ms"));
+ }
+
+ private void recreateControl()
+ {
+ AddStep("Create control", () =>
+ {
+ Child = new PlayerSettingsGroup("Some settings")
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Children = new Drawable[]
+ {
+ offsetControl = new BeatmapOffsetControl()
+ }
+ };
+ });
}
}
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs
index 21b6495865..844f5cba01 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs
@@ -40,11 +40,16 @@ namespace osu.Game.Tests.Visual.Gameplay
RelativeSizeAxes = Axes.Both,
},
breakTracker = new TestBreakTracker(),
- breakOverlay = new BreakOverlay(true, new ScoreProcessor(new OsuRuleset()))
+ breakOverlay = new BreakOverlay(new ScoreProcessor(new OsuRuleset()))
{
ProcessCustomClock = false,
BreakTracker = breakTracker,
- }
+ },
+ new LetterboxOverlay
+ {
+ ProcessCustomClock = false,
+ BreakTracker = breakTracker,
+ },
};
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs
index db06329d74..9c93eb375c 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs
@@ -108,7 +108,10 @@ namespace osu.Game.Tests.Visual.Gameplay
public bool IsRunning => true;
- public double TrueGameplayRate { set => adjustableAudioComponent.Tempo.Value = value; }
+ public double TrueGameplayRate
+ {
+ set => adjustableAudioComponent.Tempo.Value = value;
+ }
private readonly AudioAdjustments adjustableAudioComponent = new AudioAdjustments();
@@ -120,6 +123,7 @@ namespace osu.Game.Tests.Visual.Gameplay
public double FramesPerSecond => throw new NotImplementedException();
public FrameTimeInfo TimeInfo => throw new NotImplementedException();
public double StartTime => throw new NotImplementedException();
+ public double GameplayStartTime => throw new NotImplementedException();
public IAdjustableAudioComponent AdjustmentsFromMods => adjustableAudioComponent;
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs
index c2999e3f5a..dfaebccf32 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs
@@ -131,10 +131,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () =>
{
- mainContainer.Child = new FrameStabilityContainer(gameplayStartTime)
- {
- AllowBackwardsSeeks = true,
- }.WithChild(consumer = new ClockConsumingChild());
+ mainContainer.Child = new FrameStabilityContainer(gameplayStartTime).WithChild(consumer = new ClockConsumingChild());
});
private void seekManualTo(double time) => AddStep($"seek manual clock to {time}", () => manualClock.CurrentTime = time);
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
index 1787230117..1219522bfb 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
@@ -1,12 +1,13 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
+using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Extensions.PolygonExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -15,143 +16,66 @@ using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Leaderboards;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Scoring;
+using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
-using osuTK;
+using osu.Game.Screens.Select.Leaderboards;
+using osu.Game.Tests.Gameplay;
+using osuTK.Graphics;
namespace osu.Game.Tests.Visual.Gameplay
{
- [TestFixture]
public partial class TestSceneGameplayLeaderboard : OsuTestScene
{
- private TestGameplayLeaderboard leaderboard;
+ private Box? blackBackground;
+ private DrawableGameplayLeaderboard leaderboard = null!;
- private readonly BindableLong playerScore = new BindableLong();
+ [Cached]
+ private readonly LeaderboardManager leaderboardManager = new LeaderboardManager();
+
+ [Cached]
+ private readonly GameplayState gameplayState;
public TestSceneGameplayLeaderboard()
{
- AddStep("toggle expanded", () =>
+ var localScore = new ScoreInfo
{
- if (leaderboard != null)
- leaderboard.Expanded.Value = !leaderboard.Expanded.Value;
+ User = new APIUser { Username = "You", Id = 3 }
+ };
+
+ gameplayState = TestGameplayState.Create(new OsuRuleset(), null, new Score { ScoreInfo = localScore }, new Bindable(LocalUserPlayingState.Playing));
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ LoadComponent(leaderboardManager);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ AddStep("toggle collapsed", () =>
+ {
+ if (leaderboard.IsNotNull())
+ leaderboard.CollapseDuringGameplay.Value = !leaderboard.CollapseDuringGameplay.Value;
});
- AddSliderStep("set player score", 50, 5000000, 1222333, v => playerScore.Value = v);
+ AddStep("toggle black background", () => blackBackground?.FadeTo(1 - blackBackground.Alpha, 300, Easing.OutQuint));
+
+ AddSliderStep("set player score", 50, 1_000_000, 700_000, v => gameplayState.ScoreProcessor.TotalScore.Value = v);
}
[Test]
- public void TestLayoutWithManyScores()
+ public void TestDisplay()
{
- createLeaderboard();
-
- AddStep("add many scores in one go", () =>
+ AddStep("set scores", () =>
{
- for (int i = 0; i < 32; i++)
- createRandomScore(new APIUser { Username = $"Player {i + 1}" });
+ var friend = new APIUser { Username = "Friend", Id = 1337 };
- // Add player at end to force an animation down the whole list.
- playerScore.Value = 0;
- createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true);
- });
-
- // Gameplay leaderboard has custom scroll logic, which when coupled with LayoutDuration
- // has caused layout to not work in the past.
-
- AddUntilStep("wait for fill flow layout",
- () => leaderboard.ChildrenOfType>().First().ScreenSpaceDrawQuad.Intersects(leaderboard.ScreenSpaceDrawQuad));
-
- AddUntilStep("wait for some scores not masked away",
- () => leaderboard.ChildrenOfType().Any(s => leaderboard.ScreenSpaceDrawQuad.Contains(s.ScreenSpaceDrawQuad.Centre)));
-
- AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad));
-
- AddStep("change score to middle", () => playerScore.Value = 1000000);
- AddWaitStep("wait for movement", 5);
- AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad));
-
- AddStep("change score to first", () => playerScore.Value = 5000000);
- AddWaitStep("wait for movement", 5);
- AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad));
- }
-
- [Test]
- public void TestPlayerScore()
- {
- createLeaderboard();
- addLocalPlayer();
-
- var player2Score = new BindableLong(1234567);
- var player3Score = new BindableLong(1111111);
-
- AddStep("add player 2", () => createLeaderboardScore(player2Score, new APIUser { Username = "Player 2" }));
- AddStep("add player 3", () => createLeaderboardScore(player3Score, new APIUser { Username = "Player 3" }));
-
- AddUntilStep("is player 2 position #1", () => leaderboard.CheckPositionByUsername("Player 2", 1));
- AddUntilStep("is player position #2", () => leaderboard.CheckPositionByUsername("You", 2));
- AddUntilStep("is player 3 position #3", () => leaderboard.CheckPositionByUsername("Player 3", 3));
-
- AddStep("set score above player 3", () => player2Score.Value = playerScore.Value - 500);
- AddUntilStep("is player position #1", () => leaderboard.CheckPositionByUsername("You", 1));
- AddUntilStep("is player 2 position #2", () => leaderboard.CheckPositionByUsername("Player 2", 2));
- AddUntilStep("is player 3 position #3", () => leaderboard.CheckPositionByUsername("Player 3", 3));
-
- AddStep("set score below players", () => player2Score.Value = playerScore.Value - 123456);
- AddUntilStep("is player position #1", () => leaderboard.CheckPositionByUsername("You", 1));
- AddUntilStep("is player 3 position #2", () => leaderboard.CheckPositionByUsername("Player 3", 2));
- AddUntilStep("is player 2 position #3", () => leaderboard.CheckPositionByUsername("Player 2", 3));
- }
-
- [Test]
- public void TestRandomScores()
- {
- createLeaderboard();
- addLocalPlayer();
-
- int playerNumber = 1;
- AddRepeatStep("add player with random score", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 10);
- }
-
- [Test]
- public void TestExistingUsers()
- {
- createLeaderboard();
- addLocalPlayer();
-
- AddStep("add peppy", () => createRandomScore(new APIUser { Username = "peppy", Id = 2 }));
- AddStep("add smoogipoo", () => createRandomScore(new APIUser { Username = "smoogipoo", Id = 1040328 }));
- AddStep("add flyte", () => createRandomScore(new APIUser { Username = "flyte", Id = 3103765 }));
- AddStep("add frenzibyte", () => createRandomScore(new APIUser { Username = "frenzibyte", Id = 14210502 }));
- }
-
- [Test]
- public void TestMaxHeight()
- {
- createLeaderboard();
- addLocalPlayer();
-
- int playerNumber = 1;
- AddRepeatStep("add 3 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3);
- checkHeight(4);
-
- AddRepeatStep("add 4 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 4);
- checkHeight(8);
-
- AddRepeatStep("add 4 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 4);
- checkHeight(8);
-
- void checkHeight(int panelCount)
- => AddAssert($"leaderboard height is {panelCount} panels high", () => leaderboard.DrawHeight == (GameplayLeaderboardScore.PANEL_HEIGHT + leaderboard.Spacing) * panelCount);
- }
-
- [Test]
- public void TestFriendScore()
- {
- APIUser friend = new APIUser { Username = "my friend", Id = 10000 };
-
- createLeaderboard();
- addLocalPlayer();
-
- AddStep("Add friend to API", () =>
- {
var api = (DummyAPIAccess)API;
api.Friends.Clear();
@@ -162,29 +86,101 @@ namespace osu.Game.Tests.Visual.Gameplay
TargetID = friend.OnlineID,
TargetUser = friend
});
+
+ // this is dodgy but anything less dodgy is a lot of work
+ ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[]
+ {
+ new ScoreInfo { User = new APIUser { Username = "Top", Id = 2 }, TotalScore = 900_000, Accuracy = 0.99, MaxCombo = 999 },
+ new ScoreInfo { User = new APIUser { Username = "Second", Id = 14 }, TotalScore = 800_000, Accuracy = 0.9, MaxCombo = 888 },
+ new ScoreInfo { User = friend, TotalScore = 700_000, Accuracy = 0.88, MaxCombo = 777 },
+ }, 3, null);
});
- int playerNumber = 1;
+ createLeaderboard();
- AddRepeatStep("add 3 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3);
- AddUntilStep("no pink color scores",
- () => leaderboard.ChildrenOfType().Select(b => ((Colour4)b.Colour).ToHex()),
- () => Does.Not.Contain("#FF549A"));
-
- AddRepeatStep("add 3 friend score", () => createRandomScore(friend), 3);
- AddUntilStep("at least one friend score is pink",
- () => leaderboard.GetAllScoresForUsername("my friend")
- .SelectMany(score => score.ChildrenOfType())
- .Select(b => ((Colour4)b.Colour).ToHex()),
- () => Does.Contain("#FF549A"));
+ AddStep("set score to 650k", () => gameplayState.ScoreProcessor.TotalScore.Value = 650_000);
+ AddUntilStep("wait for 4th spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(4));
+ AddStep("set score to 750k", () => gameplayState.ScoreProcessor.TotalScore.Value = 750_000);
+ AddUntilStep("wait for 3rd spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(3));
+ AddStep("set score to 850k", () => gameplayState.ScoreProcessor.TotalScore.Value = 850_000);
+ AddUntilStep("wait for 2nd spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(2));
+ AddStep("set score to 950k", () => gameplayState.ScoreProcessor.TotalScore.Value = 950_000);
+ AddUntilStep("wait for 1st spot", () => leaderboard.TrackedScore!.ScorePosition.Value, () => Is.EqualTo(1));
}
- private void addLocalPlayer()
+ [Test]
+ public void TestLayoutWithManyScores()
{
- AddStep("add local player", () =>
+ AddStep("set scores", () =>
{
- playerScore.Value = 1222333;
- createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true);
+ var scores = new List();
+
+ for (int i = 0; i < 32; i++)
+ scores.Add(new ScoreInfo { User = new APIUser { Username = $"Player {i + 1}" }, TotalScore = RNG.Next(700_000, 1_000_000) });
+
+ // this is dodgy but anything less dodgy is a lot of work
+ ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(scores, scores.Count, null);
+ gameplayState.ScoreProcessor.TotalScore.Value = 0;
+ });
+
+ createLeaderboard();
+
+ // Gameplay leaderboard has custom scroll logic, which when coupled with LayoutDuration
+ // has caused layout to not work in the past.
+
+ AddUntilStep("wait for fill flow layout",
+ () => leaderboard.ChildrenOfType>().First().ScreenSpaceDrawQuad.Intersects(leaderboard.ScreenSpaceDrawQuad));
+
+ AddUntilStep("wait for some scores not masked away",
+ () => leaderboard.ChildrenOfType().Any(s => leaderboard.ScreenSpaceDrawQuad.Contains(s.ScreenSpaceDrawQuad.Centre)));
+
+ AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad));
+
+ AddStep("change score to middle", () => gameplayState.ScoreProcessor.TotalScore.Value = 850_000);
+ AddWaitStep("wait for movement", 5);
+ AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad));
+
+ AddStep("change score to first", () => gameplayState.ScoreProcessor.TotalScore.Value = 1_000_000);
+ AddWaitStep("wait for movement", 5);
+ AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad));
+ }
+
+ [Test]
+ public void TestExistingUsers()
+ {
+ AddStep("set scores", () =>
+ {
+ // this is dodgy but anything less dodgy is a lot of work
+ ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[]
+ {
+ new ScoreInfo { User = new APIUser { Username = "peppy", Id = 2 }, TotalScore = 900_000, Accuracy = 0.99, MaxCombo = 999 },
+ new ScoreInfo { User = new APIUser { Username = "smoogipoo", Id = 1040328 }, TotalScore = 800_000, Accuracy = 0.9, MaxCombo = 888 },
+ new ScoreInfo { User = new APIUser { Username = "flyte", Id = 3103765 }, TotalScore = 700_000, Accuracy = 0.9, MaxCombo = 888 },
+ new ScoreInfo { User = new APIUser { Username = "frenzibyte", Id = 14210502 }, TotalScore = 600_000, Accuracy = 0.9, MaxCombo = 777 },
+ }, 4, null);
+ });
+
+ createLeaderboard();
+ }
+
+ [Test]
+ public void TestQuitScore()
+ {
+ AddStep("set scores", () =>
+ {
+ // this is dodgy but anything less dodgy is a lot of work
+ ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[]
+ {
+ new ScoreInfo { User = new APIUser { Username = "Quit", Id = 3 }, TotalScore = 100_000, Accuracy = 0.99, MaxCombo = 999 },
+ }, 1, null);
+ });
+
+ createLeaderboard();
+
+ AddStep("mark score as quit", () =>
+ {
+ var quitScore = this.ChildrenOfType().Single().Scores.Single(s => s.User.Username == "Quit");
+ quitScore.HasQuit.Value = true;
});
}
@@ -192,36 +188,33 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("create leaderboard", () =>
{
- Child = leaderboard = new TestGameplayLeaderboard
+ SoloGameplayLeaderboardProvider soloGameplayLeaderboardProvider;
+
+ Child = new DependencyProvidingContainer
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Scale = new Vector2(2),
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies = new (Type, object)[]
+ {
+ (typeof(IGameplayLeaderboardProvider), soloGameplayLeaderboardProvider = new SoloGameplayLeaderboardProvider()),
+ },
+ Children = new Drawable[]
+ {
+ soloGameplayLeaderboardProvider,
+ blackBackground = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = Color4.Black,
+ Alpha = 0f,
+ },
+ leaderboard = new DrawableGameplayLeaderboard
+ {
+ CollapseDuringGameplay = { Value = false },
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ }
+ }
};
});
}
-
- private void createRandomScore(APIUser user) => createLeaderboardScore(new BindableLong(RNG.Next(0, 5_000_000)), user);
-
- private void createLeaderboardScore(BindableLong score, APIUser user, bool isTracked = false)
- {
- var leaderboardScore = leaderboard.Add(user, isTracked);
- leaderboardScore.TotalScore.BindTo(score);
- }
-
- private partial class TestGameplayLeaderboard : GameplayLeaderboard
- {
- public float Spacing => Flow.Spacing.Y;
-
- public bool CheckPositionByUsername(string username, int? expectedPosition)
- {
- var scoreItem = Flow.FirstOrDefault(i => i.User?.Username == username);
-
- return scoreItem != null && scoreItem.ScorePosition == expectedPosition;
- }
-
- public IEnumerable GetAllScoresForUsername(string username)
- => Flow.Where(i => i.User?.Username == username);
- }
}
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs
index 21c83d521c..84b312d5ee 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs
@@ -9,7 +9,6 @@ using osu.Game.Audio;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects.Drawables;
-using osu.Game.Screens.Play;
using osu.Game.Skinning;
namespace osu.Game.Tests.Visual.Gameplay
@@ -21,6 +20,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private bool seek;
[Test]
+ [FlakyTest]
public void TestAllSamplesStopDuringSeek()
{
DrawableSlider? slider = null;
@@ -73,8 +73,8 @@ namespace osu.Game.Tests.Visual.Gameplay
//
// We want to keep seeking while asserting various test conditions, so
// continue to seek until we unset the flag.
- var gameplayClockContainer = Player.ChildrenOfType().First();
- gameplayClockContainer.Seek(gameplayClockContainer.CurrentTime > 30000 ? 0 : 60000);
+ var gameplayClockContainer = Player?.GameplayClockContainer;
+ gameplayClockContainer?.Seek(gameplayClockContainer.CurrentTime > 30000 ? 0 : 60000);
}
}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
index 6981591193..894b51ddcb 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs
@@ -65,30 +65,30 @@ namespace osu.Game.Tests.Visual.Gameplay
{
new HitCircle
{
- HitWindows = new HitWindows(),
+ HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
},
new HitCircle
{
- HitWindows = new HitWindows(),
+ HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) }
},
new HitCircle
{
- HitWindows = new HitWindows(),
+ HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) },
},
new HitCircle
{
- HitWindows = new HitWindows(),
+ HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
},
new Slider
{
- HitWindows = new HitWindows(),
+ HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, Vector2.UnitY * 200 }),
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT) },
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs
new file mode 100644
index 0000000000..47791dd462
--- /dev/null
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs
@@ -0,0 +1,166 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Track;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Rendering;
+using osu.Framework.Graphics.Textures;
+using osu.Framework.IO.Stores;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Configuration;
+using osu.Game.Database;
+using osu.Game.IO;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Online.Spectator;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Scoring;
+using osu.Game.Screens.Play;
+using osu.Game.Screens.Select.Leaderboards;
+using osu.Game.Skinning;
+using osu.Game.Tests.Gameplay;
+using osu.Game.Tests.Visual.Spectator;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+ [Description(@"Exercises the appearance of the HUD overlay on various skin and ruleset combinations.")]
+ public partial class TestSceneHUDOverlayRulesetLayouts : OsuTestScene, IStorageResourceProvider
+ {
+ private readonly Dictionary skins = new Dictionary();
+
+ [Resolved]
+ private GameHost host { get; set; } = null!;
+
+ [Resolved]
+ private RulesetStore rulesets { get; set; } = null!;
+
+ [Resolved]
+ private OsuConfigManager configManager { get; set; } = null!;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ skins["argon"] = new ArgonSkin(this);
+ skins["triangles"] = new TrianglesSkin(this);
+ skins["legacy"] = new DefaultLegacySkin(this);
+ }
+
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddToggleStep("toggle leaderboard", b => configManager.SetValue(OsuSetting.GameplayLeaderboard, b));
+ }
+
+ [Test]
+ public void TestLayout(
+ [Values("argon", "triangles", "legacy")]
+ string skinName,
+ [Values("osu", "taiko", "fruits", "mania")]
+ string rulesetName)
+ {
+ AddStep("create content", () =>
+ {
+ var rulesetInfo = rulesets.GetRuleset(rulesetName);
+ var ruleset = rulesetInfo!.CreateInstance();
+ var beatmap = ruleset.CreateBeatmapConverter(new Beatmap()).Convert();
+ var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap);
+
+ ISkin provider = ruleset.CreateSkinTransformer(skins[skinName], beatmap)!;
+
+ var gameplayState = TestGameplayState.Create(ruleset);
+ ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Playing;
+ var spectatorClient = new TestSpectatorClient();
+
+ for (int i = 0; i < 15; ++i)
+ {
+ ((ISpectatorClient)spectatorClient).UserStartedWatching([
+ new SpectatorUser
+ {
+ OnlineID = i,
+ Username = $"User {i}"
+ }
+ ]);
+ }
+
+ GameplayClockContainer gameplayClock;
+
+ List<(Type, object)> dependencies =
+ [
+ (typeof(GameplayState), gameplayState),
+ (typeof(ScoreProcessor), gameplayState.ScoreProcessor),
+ (typeof(HealthProcessor), gameplayState.HealthProcessor),
+ (typeof(IGameplayClock), gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false)),
+ (typeof(SpectatorClient), spectatorClient),
+ (typeof(IGameplayLeaderboardProvider), new TestGameplayLeaderboardProvider()),
+ ];
+
+ if (drawableRuleset is IDrawableScrollingRuleset scrolling)
+ dependencies.Add((typeof(IScrollingInfo), scrolling.ScrollingInfo));
+
+ Child = new DependencyProvidingContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies = dependencies.ToArray(),
+ Children = new Drawable[]
+ {
+ spectatorClient,
+ new SkinProvidingContainer(provider)
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ drawableRuleset,
+ new HUDOverlay(drawableRuleset, [])
+ {
+ RelativeSizeAxes = Axes.Both,
+ }
+ }
+ }
+ }
+ };
+
+ gameplayClock.Start();
+ });
+ }
+
+ private class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider
+ {
+ IBindableList IGameplayLeaderboardProvider.Scores => Scores;
+ public BindableList Scores { get; } = new BindableList();
+
+ public TestGameplayLeaderboardProvider()
+ {
+ for (int i = 0; i < 20; ++i)
+ {
+ Scores.Add(new GameplayLeaderboardScore(new ScoreInfo
+ {
+ User = new APIUser { Username = $"User {i}" },
+ TotalScore = (20 - i) * 50_000,
+ Accuracy = i * 0.05,
+ MaxCombo = i * 50,
+ }, i == 19, GameplayLeaderboardScore.ComboDisplayMode.Highest));
+ }
+ }
+ }
+
+ #region IResourceStorageProvider
+
+ public IRenderer Renderer => host.Renderer;
+ public AudioManager AudioManager => Audio;
+ public IResourceStore Files => null!;
+ public new IResourceStore Resources => base.Resources;
+ public IResourceStore CreateTextureLoaderStore(IResourceStore