// 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.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Collections; using osu.Game.Database; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Setup; using osu.Game.Storyboards; using osu.Game.Tests.Resources; using osuTK; using osuTK.Input; using SharpCompress.Archives; using SharpCompress.Archives.Zip; namespace osu.Game.Tests.Visual.Editing { public partial class TestSceneEditorBeatmapCreation : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); protected override bool IsolateSavingFromDatabase => false; [Resolved] private BeatmapManager beatmapManager { get; set; } = null!; [Resolved] private RealmAccess realm { get; set; } = null!; private Guid currentBeatmapSetID => EditorBeatmap.BeatmapInfo.BeatmapSet?.ID ?? Guid.Empty; public override void SetUpSteps() { base.SetUpSteps(); // if we save a beatmap with a hash collision, things fall over. // probably needs a more solid resolution in the future but this will do for now. AddStep("make new beatmap unique", () => EditorBeatmap.Metadata.Title = Guid.NewGuid().ToString()); } protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => new DummyWorkingBeatmap(Audio, null); [Test] public void TestCreateNewBeatmap() { AddAssert("status is none", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.None); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap in database", () => beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID)?.Value.DeletePending == false); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); } [Test] public void TestExitWithoutSave() { EditorBeatmap editorBeatmap = null!; AddStep("store editor beatmap", () => editorBeatmap = EditorBeatmap); AddStep("exit without save", () => Editor.Exit()); AddStep("hold to confirm", () => { var confirmButton = DialogOverlay.CurrentDialog.ChildrenOfType().First(); InputManager.MoveMouseTo(confirmButton); InputManager.PressButton(MouseButton.Left); }); AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen()); AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.AsNonNull().ID)?.Value.DeletePending == true); } [Test] public void TestAddAudioTrack() { AddStep("enter compose mode", () => InputManager.Key(Key.F1)); AddUntilStep("wait for timeline load", () => Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddStep("enter setup mode", () => InputManager.Key(Key.F4)); AddAssert("track is virtual", () => Beatmap.Value.Track is TrackVirtual); AddAssert("switch track to real track", () => { var setup = Editor.ChildrenOfType().First(); string temp = TestResources.GetTestBeatmapForImport(); string extractedFolder = $"{temp}_extracted"; Directory.CreateDirectory(extractedFolder); try { using (var zip = ZipArchive.Open(temp)) zip.WriteToDirectory(extractedFolder); bool success = setup.ChildrenOfType().First().ChangeAudioTrack(new FileInfo(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3"))); // ensure audio file is copied to beatmap as "audio.mp3" rather than original filename. Assert.That(Beatmap.Value.Metadata.AudioFile == "audio.mp3"); return success; } finally { File.Delete(temp); Directory.Delete(extractedFolder, true); } }); AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual); AddUntilStep("track length changed", () => Beatmap.Value.Track.Length > 60000); AddStep("test play", () => Editor.TestGameplay()); AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); AddStep("confirm save", () => InputManager.Key(Key.Number1)); AddUntilStep("wait for return to editor", () => Editor.IsCurrentScreen()); AddAssert("track is still not virtual", () => Beatmap.Value.Track is not TrackVirtual); AddAssert("track length correct", () => Beatmap.Value.Track.Length > 60000); AddUntilStep("track not playing", () => !EditorClock.IsRunning); AddStep("play track", () => InputManager.Key(Key.Space)); AddUntilStep("wait for track playing", () => EditorClock.IsRunning); } [Test] public void TestCreateNewDifficulty([Values] bool sameRuleset) { string firstDifficultyName = Guid.NewGuid().ToString(); string secondDifficultyName = Guid.NewGuid().ToString(); AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { new HitCircle { Position = new Vector2(0), StartTime = 0 }, new HitCircle { Position = OsuPlayfield.BASE_SIZE, StartTime = 1000 } })); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap persisted", () => { var beatmap = beatmapManager.QueryBeatmap(b => b.DifficultyName == firstDifficultyName); var set = beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID); return beatmap != null && beatmap.DifficultyName == firstDifficultyName && set != null && set.PerformRead(s => s.Beatmaps.Single().ID == beatmap.ID); }); AddAssert("can save again", () => Editor.Save()); AddStep("create new difficulty", () => Editor.CreateNewDifficulty(sameRuleset ? new OsuRuleset().RulesetInfo : new CatchRuleset().RulesetInfo)); if (sameRuleset) { 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 != firstDifficultyName; }); AddAssert("created difficulty has timing point", () => { var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); AddAssert("created difficulty has no objects", () => EditorBeatmap.HitObjects.Count == 0); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = secondDifficultyName); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap persisted", () => { var beatmap = beatmapManager.QueryBeatmap(b => b.DifficultyName == secondDifficultyName); var set = beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID); return beatmap != null && beatmap.DifficultyName == secondDifficultyName && set != null && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Beatmaps.Any(b => b.DifficultyName == secondDifficultyName) && s.Beatmaps.All(b => s.Status == BeatmapOnlineStatus.LocallyModified)); }); } [Test] public void TestCopyDifficulty() { string originalDifficultyName = Guid.NewGuid().ToString(); string copyDifficultyName = $"{originalDifficultyName} (copy)"; AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = originalDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { new HitCircle { Position = new Vector2(0), StartTime = 0 }, new HitCircle { Position = OsuPlayfield.BASE_SIZE, StartTime = 1000 } })); AddStep("set approach rate", () => EditorBeatmap.Difficulty.ApproachRate = 4); AddStep("set combo colours", () => { var beatmapSkin = EditorBeatmap.BeatmapSkin.AsNonNull(); beatmapSkin.ComboColours.Clear(); beatmapSkin.ComboColours.AddRange(new[] { new Colour4(255, 0, 0, 255), new Colour4(0, 0, 255, 255) }); }); AddStep("set status & online ID", () => { EditorBeatmap.BeatmapInfo.OnlineID = 123456; EditorBeatmap.BeatmapInfo.Status = BeatmapOnlineStatus.WIP; }); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap persisted", () => { var beatmap = beatmapManager.QueryBeatmap(b => b.DifficultyName == originalDifficultyName); var set = beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID); return beatmap != null && beatmap.DifficultyName == originalDifficultyName && set != null && set.PerformRead(s => s.Beatmaps.Single().ID == beatmap.ID); }); AddAssert("can save again", () => Editor.Save()); AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new OsuRuleset().RulesetInfo)); AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog); AddStep("confirm creation as a copy", () => DialogOverlay.CurrentDialog!.Buttons.ElementAt(1).TriggerClick()); AddUntilStep("wait for created", () => { string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != originalDifficultyName; }); AddAssert("created difficulty has copy suffix in name", () => EditorBeatmap.BeatmapInfo.DifficultyName == copyDifficultyName); AddAssert("created difficulty has timing point", () => { var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); AddAssert("created difficulty has objects", () => EditorBeatmap.HitObjects.Count == 2); AddAssert("approach rate correctly copied", () => EditorBeatmap.Difficulty.ApproachRate == 4); AddAssert("combo colours correctly copied", () => EditorBeatmap.BeatmapSkin.AsNonNull().ComboColours.Count == 2); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); AddAssert("online ID not copied", () => EditorBeatmap.BeatmapInfo.OnlineID == -1); AddStep("save beatmap", () => Editor.Save()); BeatmapInfo? refetchedBeatmap = null; Live? refetchedBeatmapSet = null; AddStep("refetch from database", () => { refetchedBeatmap = beatmapManager.QueryBeatmap(b => b.DifficultyName == copyDifficultyName); refetchedBeatmapSet = beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID); }); AddAssert("new beatmap persisted", () => { return refetchedBeatmap != null && refetchedBeatmap.DifficultyName == copyDifficultyName && refetchedBeatmapSet != null && refetchedBeatmapSet.PerformRead(s => s.Beatmaps.Count == 2 && s.Beatmaps.Any(b => b.DifficultyName == originalDifficultyName) && s.Beatmaps.Any(b => b.DifficultyName == copyDifficultyName)); }); AddAssert("old beatmap file not deleted", () => refetchedBeatmapSet.AsNonNull().PerformRead(s => s.Files.Count == 2)); } [Test] public void TestCopyDifficultyDoesNotChangeCollections() { string originalDifficultyName = Guid.NewGuid().ToString(); AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = originalDifficultyName); AddStep("save beatmap", () => Editor.Save()); string originalMd5 = string.Empty; BeatmapCollection collection = null!; AddStep("setup a collection with original beatmap", () => { collection = new BeatmapCollection("test copy"); collection.BeatmapMD5Hashes.Add(originalMd5 = EditorBeatmap.BeatmapInfo.MD5Hash); realm.Write(r => { r.Add(collection); }); }); AddAssert("collection contains original beatmap", () => !string.IsNullOrEmpty(originalMd5) && collection.BeatmapMD5Hashes.Contains(originalMd5)); AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new OsuRuleset().RulesetInfo)); AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog); AddStep("confirm creation as a copy", () => DialogOverlay.CurrentDialog!.Buttons.ElementAt(1).TriggerClick()); AddUntilStep("wait for created", () => { string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != originalDifficultyName; }); AddStep("save without changes", () => Editor.Save()); AddAssert("collection still points to old beatmap", () => !collection.BeatmapMD5Hashes.Contains(EditorBeatmap.BeatmapInfo.MD5Hash) && collection.BeatmapMD5Hashes.Contains(originalMd5)); AddStep("clean up collection", () => { realm.Write(r => { r.Remove(collection); }); }); } [Test] public void TestCreateMultipleNewDifficultiesSucceeds() { Guid setId = Guid.Empty; AddStep("retrieve set ID", () => setId = EditorBeatmap.BeatmapInfo.BeatmapSet!.ID); AddStep("set difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = "New Difficulty"); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); return set != null && set.PerformRead(s => s.Beatmaps.Count == 1 && s.Files.Count == 1); }); AddStep("try to create new difficulty", () => Editor.CreateNewDifficulty(new OsuRuleset().RulesetInfo)); 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 != "New Difficulty"; }); AddAssert("new difficulty has correct name", () => EditorBeatmap.BeatmapInfo.DifficultyName == "New Difficulty (1)"); AddAssert("new difficulty persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); return set != null && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Files.Count == 2); }); } [Test] public void TestSavingBeatmapFailsWithSameNamedDifficulties([Values] bool sameRuleset) { Guid setId = Guid.Empty; const string duplicate_difficulty_name = "duplicate"; AddStep("retrieve set ID", () => setId = EditorBeatmap.BeatmapInfo.BeatmapSet!.ID); AddStep("set difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = duplicate_difficulty_name); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); return set != null && set.PerformRead(s => s.Beatmaps.Count == 1 && s.Files.Count == 1); }); AddStep("create new difficulty", () => Editor.CreateNewDifficulty(sameRuleset ? new OsuRuleset().RulesetInfo : new CatchRuleset().RulesetInfo)); if (sameRuleset) { 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 != duplicate_difficulty_name; }); AddStep("set difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = duplicate_difficulty_name); AddStep("try to save beatmap", () => Editor.Save()); AddAssert("beatmap set not corrupted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); // the difficulty was already created at the point of the switch. // what we want to check is that both difficulties do not use the same file. return set != null && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Files.Count == 2); }); } [Test] public void TestExitBlockedWhenSavingBeatmapWithSameNamedDifficulties() { Guid setId = Guid.Empty; const string duplicate_difficulty_name = "duplicate"; AddStep("retrieve set ID", () => setId = EditorBeatmap.BeatmapInfo.BeatmapSet!.ID); AddStep("set difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = duplicate_difficulty_name); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); return set != null && set.PerformRead(s => s.Beatmaps.Count == 1 && s.Files.Count == 1); }); AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new CatchRuleset().RulesetInfo)); AddUntilStep("wait for created", () => { string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != duplicate_difficulty_name; }); AddUntilStep("wait for editor load", () => Editor.IsLoaded && DialogOverlay.IsLoaded); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { new Fruit { StartTime = 0 }, new Fruit { StartTime = 1000 } })); AddStep("set difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = duplicate_difficulty_name); AddUntilStep("wait for has unsaved changes", () => Editor.HasUnsavedChanges); AddStep("exit", () => Editor.Exit()); AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is PromptForSaveDialog); AddStep("attempt to save", () => DialogOverlay.CurrentDialog!.PerformOkAction()); AddAssert("editor is still current", () => Editor.IsCurrentScreen()); } [Test] public void TestCreateNewDifficultyForInconvertibleRuleset() { Guid setId = Guid.Empty; AddStep("retrieve set ID", () => setId = EditorBeatmap.BeatmapInfo.BeatmapSet!.ID); AddStep("save beatmap", () => Editor.Save()); AddStep("try to create new taiko difficulty", () => Editor.CreateNewDifficulty(new TaikoRuleset().RulesetInfo)); AddUntilStep("wait for created", () => { string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName == "New Difficulty"; }); AddAssert("new difficulty persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); return set != null && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Files.Count == 2); }); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { new Hit { StartTime = 0 }, new Hit { StartTime = 1000 } })); AddStep("save beatmap", () => Editor.Save()); AddStep("try to create new catch difficulty", () => Editor.CreateNewDifficulty(new CatchRuleset().RulesetInfo)); AddUntilStep("wait for created", () => { string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName == "New Difficulty (1)"; }); AddAssert("new difficulty persisted", () => { var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); return set != null && set.PerformRead(s => s.Beatmaps.Count == 3 && s.Files.Count == 3); }); } } }