// 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.IO; using System.Linq; using System.Threading.Tasks; 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.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Storyboards; using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual { public abstract class OsuTestScene : TestScene { [Cached(typeof(Bindable))] [Cached(typeof(IBindable))] private NonNullableBindable beatmap; protected Bindable Beatmap => beatmap; [Cached] [Cached(typeof(IBindable))] protected readonly Bindable Ruleset = new Bindable(); [Cached] [Cached(Type = typeof(IBindable>))] protected readonly Bindable> Mods = new Bindable>(Array.Empty()); protected new DependencyContainer Dependencies { get; private set; } private readonly Lazy localStorage; protected Storage LocalStorage => localStorage.Value; private readonly Lazy contextFactory; protected IAPIProvider API { get { if (UseOnlineAPI) throw new InvalidOperationException($"Using the {nameof(OsuTestScene)} dummy API is not supported when {nameof(UseOnlineAPI)} is true"); return dummyAPI; } } private DummyAPIAccess dummyAPI; protected DatabaseContextFactory ContextFactory => contextFactory.Value; /// /// Whether this test scene requires real-world API access. /// If true, this will bypass the local and use the provided one. /// protected virtual bool UseOnlineAPI => false; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { // This is the earliest we can get OsuGameBase, which is used by the dummy working beatmap to find textures var working = new DummyWorkingBeatmap(parent.Get(), parent.Get()); beatmap = new NonNullableBindable(working) { Default = working }; beatmap.BindValueChanged(b => ScheduleAfterChildren(() => { // compare to last beatmap as sometimes the two may share a track representation (optimisation, see WorkingBeatmap.TransferTo) if (b.OldValue?.TrackLoaded == true && b.OldValue?.Track != b.NewValue?.Track) b.OldValue.RecycleTrack(); })); Dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); if (!UseOnlineAPI) { dummyAPI = new DummyAPIAccess(); Dependencies.CacheAs(dummyAPI); Add(dummyAPI); } return Dependencies; } protected override Container Content => content ?? base.Content; private readonly Container content; protected OsuTestScene() { localStorage = new Lazy(() => new NativeStorage($"{GetType().Name}-{Guid.NewGuid()}")); contextFactory = new Lazy(() => { var factory = new DatabaseContextFactory(LocalStorage); factory.ResetDatabase(); using (var usage = factory.Get()) usage.Migrate(); return factory; }); base.Content.Add(content = new DrawSizePreservingFillContainer()); } [Resolved] private AudioManager audio { get; set; } protected virtual IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset); protected WorkingBeatmap CreateWorkingBeatmap(RulesetInfo ruleset) => CreateWorkingBeatmap(CreateBeatmap(ruleset), null); protected virtual WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, Clock, audio); [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { Ruleset.Value = rulesets.AvailableRulesets.First(); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (beatmap?.Value.TrackLoaded == true) beatmap.Value.Track.Stop(); if (contextFactory.IsValueCreated) contextFactory.Value.ResetDatabase(); if (localStorage.IsValueCreated) { try { localStorage.Value.DeleteDirectory("."); } catch { // we don't really care if this fails; it will just leave folders lying around from test runs. } } } protected override ITestSceneTestRunner CreateRunner() => new OsuTestSceneTestRunner(); public class ClockBackedTestWorkingBeatmap : TestWorkingBeatmap { private readonly Track track; private readonly TrackVirtualStore store; /// /// Create an instance which creates a for the provided ruleset when requested. /// /// The target ruleset. /// A clock which should be used instead of a stopwatch for virtual time progression. /// Audio manager. Required if a reference clock isn't provided. public ClockBackedTestWorkingBeatmap(RulesetInfo ruleset, IFrameBasedClock referenceClock, AudioManager audio) : this(new TestBeatmap(ruleset), null, referenceClock, audio) { } /// /// Create an instance which provides the when requested. /// /// The beatmap /// The storyboard. /// An optional clock which should be used instead of a stopwatch for virtual time progression. /// Audio manager. Required if a reference clock isn't provided. /// The length of the returned virtual track. public ClockBackedTestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio, double length = 60000) : base(beatmap, storyboard) { if (referenceClock != null) { store = new TrackVirtualStore(referenceClock); audio.AddItem(store); track = store.GetVirtual(length); } else track = audio?.Tracks.GetVirtual(length); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); store?.Dispose(); } protected override Track GetTrack() => track; public class TrackVirtualStore : AudioCollectionManager, ITrackStore { private readonly IFrameBasedClock referenceClock; public TrackVirtualStore(IFrameBasedClock referenceClock) { this.referenceClock = referenceClock; } public Track Get(string name) => throw new NotImplementedException(); public Task GetAsync(string name) => throw new NotImplementedException(); public Stream GetStream(string name) => throw new NotImplementedException(); public IEnumerable GetAvailableResources() => throw new NotImplementedException(); public Track GetVirtual(double length = double.PositiveInfinity) { var track = new TrackVirtualManual(referenceClock) { Length = length }; AddItem(track); return track; } } /// /// A virtual track which tracks a reference clock. /// public class TrackVirtualManual : Track { private readonly IFrameBasedClock referenceClock; private readonly ManualClock clock = new ManualClock(); private bool running; /// /// Local offset added to the reference clock to resolve correct time. /// private double offset; public TrackVirtualManual(IFrameBasedClock referenceClock) { this.referenceClock = referenceClock; Length = double.PositiveInfinity; } public override bool Seek(double seek) { offset = Math.Clamp(seek, 0, Length); lastReferenceTime = null; return offset == seek; } public override void Start() { running = true; } public override void Reset() { Seek(0); base.Reset(); } public override void Stop() { if (running) { running = false; // on stopping, the current value should be transferred out of the clock, as we can no longer rely on // the referenceClock (which will still be counting time). offset = clock.CurrentTime; lastReferenceTime = null; } } public override bool IsRunning => running; private double? lastReferenceTime; public override double CurrentTime => clock.CurrentTime; protected override void UpdateState() { base.UpdateState(); if (running) { double refTime = referenceClock.CurrentTime; if (!lastReferenceTime.HasValue) { // if the clock just started running, the current value should be transferred to the offset // (to zero the progression of time). offset -= refTime; } lastReferenceTime = refTime; } clock.CurrentTime = Math.Min((lastReferenceTime ?? 0) + offset, Length); if (CurrentTime >= Length) { Stop(); RaiseCompleted(); } } } } public class OsuTestSceneTestRunner : OsuGameBase, ITestSceneTestRunner { private TestSceneTestRunner.TestRunner runner; protected override void LoadAsyncComplete() { // this has to be run here rather than LoadComplete because // TestScene.cs is checking the IsLoaded state (on another thread) and expects // the runner to be loaded at that point. Add(runner = new TestSceneTestRunner.TestRunner()); } public void RunTestBlocking(TestScene test) => runner.RunTestBlocking(test); } } }