A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Diagnostics;
6using System.Linq;
7using System.Threading;
8using System.Threading.Tasks;
9using NUnit.Framework;
10using NUnit.Framework.Internal;
11using osu.Framework.Development;
12using osu.Framework.Extensions.TypeExtensions;
13using osu.Framework.Graphics;
14using osu.Framework.Graphics.Containers;
15using osu.Framework.Graphics.Shapes;
16using osu.Framework.Graphics.Sprites;
17using osu.Framework.Platform;
18using osu.Framework.Testing.Drawables.Steps;
19using osu.Framework.Threading;
20using osuTK;
21using osuTK.Graphics;
22
23namespace osu.Framework.Testing
24{
25 [ExcludeFromDynamicCompile]
26 [TestFixture]
27 public abstract class TestScene : Container, IDynamicallyCompile
28 {
29 public readonly FillFlowContainer<Drawable> StepsContainer;
30 private readonly Container content;
31
32 protected override Container<Drawable> Content => content;
33
34 protected virtual ITestSceneTestRunner CreateRunner() => new TestSceneTestRunner();
35
36 private GameHost host;
37 private Task runTask;
38 private ITestSceneTestRunner runner;
39
40 public object DynamicCompilationOriginal { get; internal set; }
41
42 [OneTimeSetUp]
43 public void SetupGameHost()
44 {
45 host = new TestRunHeadlessGameHost($"{GetType().Name}-{Guid.NewGuid()}");
46 runner = CreateRunner();
47
48 if (!(runner is Game game))
49 throw new InvalidCastException($"The test runner must be a {nameof(Game)}.");
50
51 runTask = Task.Factory.StartNew(() => host.Run(game), TaskCreationOptions.LongRunning);
52
53 while (!game.IsLoaded)
54 {
55 checkForErrors();
56 Thread.Sleep(10);
57 }
58 }
59
60 protected internal override void AddInternal(Drawable drawable) =>
61 throw new InvalidOperationException($"Modifying {nameof(InternalChildren)} will cause critical failure. Use {nameof(Add)} instead.");
62
63 protected internal override void ClearInternal(bool disposeChildren = true) =>
64 throw new InvalidOperationException($"Modifying {nameof(InternalChildren)} will cause critical failure. Use {nameof(Clear)} instead.");
65
66 protected internal override bool RemoveInternal(Drawable drawable) =>
67 throw new InvalidOperationException($"Modifying {nameof(InternalChildren)} will cause critical failure. Use {nameof(Remove)} instead.");
68
69 [OneTimeTearDown]
70 public void DestroyGameHost()
71 {
72 host.Exit();
73
74 try
75 {
76 runTask.Wait();
77 }
78 finally
79 {
80 host.Dispose();
81
82 try
83 {
84 // clean up after each run
85 host.Storage.DeleteDirectory(string.Empty);
86 }
87 catch
88 {
89 }
90 }
91 }
92
93 [SetUp]
94 public void SetUpTestForNUnit()
95 {
96 if (DebugUtils.IsNUnitRunning)
97 {
98 // Since the host is created in OneTimeSetUp, all game threads will have the fixture's execution context
99 // This is undesirable since each test is run using those same threads, so we must make sure the execution context
100 // for the game threads refers to the current _test_ execution context for each test
101 var executionContext = TestExecutionContext.CurrentContext;
102
103 foreach (var thread in host.Threads)
104 {
105 thread.Scheduler.Add(() =>
106 {
107 TestExecutionContext.CurrentContext.CurrentResult = executionContext.CurrentResult;
108 TestExecutionContext.CurrentContext.CurrentTest = executionContext.CurrentTest;
109 TestExecutionContext.CurrentContext.CurrentCulture = executionContext.CurrentCulture;
110 TestExecutionContext.CurrentContext.CurrentPrincipal = executionContext.CurrentPrincipal;
111 TestExecutionContext.CurrentContext.CurrentRepeatCount = executionContext.CurrentRepeatCount;
112 TestExecutionContext.CurrentContext.CurrentUICulture = executionContext.CurrentUICulture;
113 });
114 }
115
116 if (TestContext.CurrentContext.Test.MethodName != nameof(TestConstructor))
117 schedule(() => StepsContainer.Clear());
118
119 RunSetUpSteps();
120 }
121 }
122
123 [TearDown]
124 public void RunTests()
125 {
126 RunTearDownSteps();
127
128 checkForErrors();
129 runner.RunTestBlocking(this);
130 checkForErrors();
131 }
132
133 private void checkForErrors()
134 {
135 if (host.ExecutionState == ExecutionState.Stopping)
136 runTask.Wait();
137
138 if (runTask.Exception != null)
139 throw runTask.Exception;
140 }
141
142 /// <summary>
143 /// Tests any steps and assertions in the constructor of this <see cref="TestScene"/>.
144 /// This test must run before any other tests, as it relies on <see cref="StepsContainer"/> not being cleared and not having any elements.
145 /// </summary>
146 [Test, Order(int.MinValue)]
147 public void TestConstructor()
148 {
149 }
150
151 protected TestScene()
152 {
153 DynamicCompilationOriginal = this;
154
155 Name = RemovePrefix(GetType().ReadableName());
156
157 RelativeSizeAxes = Axes.Both;
158 Masking = true;
159
160 base.AddInternal(new Container
161 {
162 RelativeSizeAxes = Axes.Both,
163 Children = new Drawable[]
164 {
165 new Box
166 {
167 Colour = new Color4(25, 25, 25, 255),
168 RelativeSizeAxes = Axes.Y,
169 Width = steps_width,
170 },
171 scroll = new BasicScrollContainer
172 {
173 Width = steps_width,
174 Depth = float.MinValue,
175 RelativeSizeAxes = Axes.Y,
176 Child = StepsContainer = new FillFlowContainer<Drawable>
177 {
178 Direction = FillDirection.Vertical,
179 Spacing = new Vector2(3),
180 RelativeSizeAxes = Axes.X,
181 AutoSizeAxes = Axes.Y,
182 Padding = new MarginPadding(10),
183 Child = new SpriteText
184 {
185 Font = FrameworkFont.Condensed.With(size: 16),
186 Text = Name,
187 Margin = new MarginPadding { Bottom = 5 },
188 }
189 },
190 },
191 new Container
192 {
193 Masking = true,
194 Padding = new MarginPadding
195 {
196 Left = steps_width + padding,
197 Right = padding,
198 Top = padding,
199 Bottom = padding,
200 },
201 RelativeSizeAxes = Axes.Both,
202 Child = content = new DrawFrameRecordingContainer
203 {
204 Masking = true,
205 RelativeSizeAxes = Axes.Both
206 }
207 },
208 }
209 });
210 }
211
212 private const float steps_width = 180;
213 private const float padding = 0;
214
215 private int actionIndex;
216 private int actionRepetition;
217 private ScheduledDelegate stepRunner;
218 private readonly ScrollContainer<Drawable> scroll;
219
220 public void RunAllSteps(Action onCompletion = null, Action<Exception> onError = null, Func<StepButton, bool> stopCondition = null, StepButton startFromStep = null)
221 {
222 // schedule once as we want to ensure we have run our LoadComplete before attempting to execute steps.
223 // a user may be adding a step in LoadComplete.
224 Schedule(() =>
225 {
226 stepRunner?.Cancel();
227 foreach (var step in StepsContainer.FlowingChildren.OfType<StepButton>())
228 step.Reset();
229
230 actionIndex = startFromStep != null ? StepsContainer.IndexOf(startFromStep) + 1 : -1;
231 actionRepetition = 0;
232 runNextStep(onCompletion, onError, stopCondition);
233 });
234 }
235
236 private StepButton loadableStep => actionIndex >= 0 ? StepsContainer.Children.ElementAtOrDefault(actionIndex) as StepButton : null;
237
238 protected virtual double TimePerAction => 200;
239
240 private void runNextStep(Action onCompletion, Action<Exception> onError, Func<StepButton, bool> stopCondition)
241 {
242 try
243 {
244 if (loadableStep != null)
245 {
246 scroll.ScrollIntoView(loadableStep);
247 loadableStep.PerformStep();
248 }
249 }
250 catch (Exception e)
251 {
252 onError?.Invoke(e);
253 return;
254 }
255
256 string text = ".";
257
258 if (actionRepetition == 0)
259 {
260 text = $"{(int)Time.Current}: ".PadLeft(7);
261
262 if (actionIndex < 0)
263 text += $"{GetType().ReadableName()}";
264 else
265 text += $"step {actionIndex + 1} {loadableStep?.ToString() ?? string.Empty}";
266 }
267
268 Console.Write(text);
269
270 actionRepetition++;
271
272 if (actionRepetition > (loadableStep?.RequiredRepetitions ?? 1) - 1)
273 {
274 actionIndex++;
275 actionRepetition = 0;
276 Console.WriteLine();
277
278 if (loadableStep != null && stopCondition?.Invoke(loadableStep) == true)
279 return;
280 }
281
282 if (actionIndex > StepsContainer.Children.Count - 1)
283 {
284 onCompletion?.Invoke();
285 return;
286 }
287
288 if (Parent != null)
289 stepRunner = Scheduler.AddDelayed(() => runNextStep(onCompletion, onError, stopCondition), TimePerAction);
290 }
291
292 public void AddStep(StepButton step) => schedule(() => StepsContainer.Add(step));
293
294 private bool addStepsAsSetupSteps;
295
296 public StepButton AddStep(string description, Action action)
297 {
298 var step = new SingleStepButton(addStepsAsSetupSteps)
299 {
300 Text = description,
301 Action = action
302 };
303
304 AddStep(step);
305
306 return step;
307 }
308
309 public LabelStep AddLabel(string description)
310 {
311 var step = new LabelStep
312 {
313 Text = description,
314 };
315
316 step.Action = () =>
317 {
318 // kinda hacky way to avoid this doesn't get triggered by automated runs.
319 if (step.IsHovered)
320 RunAllSteps(startFromStep: step, stopCondition: s => s is LabelStep);
321 };
322
323 AddStep(step);
324
325 return step;
326 }
327
328 protected void AddRepeatStep(string description, Action action, int invocationCount) => schedule(() =>
329 {
330 StepsContainer.Add(new RepeatStepButton(action, invocationCount, addStepsAsSetupSteps)
331 {
332 Text = description,
333 });
334 });
335
336 protected void AddToggleStep(string description, Action<bool> action) => schedule(() =>
337 {
338 StepsContainer.Add(new ToggleStepButton(action)
339 {
340 Text = description
341 });
342 });
343
344 protected void AddUntilStep(string description, Func<bool> waitUntilTrueDelegate) => schedule(() =>
345 {
346 StepsContainer.Add(new UntilStepButton(waitUntilTrueDelegate, addStepsAsSetupSteps)
347 {
348 Text = description ?? @"Until",
349 });
350 });
351
352 protected void AddWaitStep(string description, int waitCount) => schedule(() =>
353 {
354 StepsContainer.Add(new RepeatStepButton(() => { }, waitCount, addStepsAsSetupSteps)
355 {
356 Text = description ?? @"Wait",
357 });
358 });
359
360 protected void AddSliderStep<T>(string description, T min, T max, T start, Action<T> valueChanged) where T : struct, IComparable<T>, IConvertible, IEquatable<T> => schedule(() =>
361 {
362 StepsContainer.Add(new StepSlider<T>(description, min, max, start)
363 {
364 ValueChanged = valueChanged,
365 });
366 });
367
368 protected void AddAssert(string description, Func<bool> assert, string extendedDescription = null) => schedule(() =>
369 {
370 StepsContainer.Add(new AssertButton(addStepsAsSetupSteps)
371 {
372 Text = description,
373 ExtendedDescription = extendedDescription,
374 CallStack = new StackTrace(1),
375 Assertion = assert,
376 });
377 });
378
379 internal void RunSetUpSteps()
380 {
381 addStepsAsSetupSteps = true;
382 foreach (var method in ReflectionUtils.GetMethodsWithAttribute(GetType(), typeof(SetUpStepsAttribute), true))
383 method.Invoke(this, null);
384 addStepsAsSetupSteps = false;
385 }
386
387 internal void RunTearDownSteps()
388 {
389 foreach (var method in ReflectionUtils.GetMethodsWithAttribute(GetType(), typeof(TearDownStepsAttribute), true))
390 method.Invoke(this, null);
391 }
392
393 /// <summary>
394 /// Remove the "TestScene" prefix from a name.
395 /// </summary>
396 /// <param name="name"></param>
397 public static string RemovePrefix(string name)
398 {
399 return name.Replace("TestCase", string.Empty) // TestScene used to be called TestCase. This handles consumer projects which haven't updated their naming for the near future.
400 .Replace(nameof(TestScene), string.Empty);
401 }
402
403 // should run inline where possible. this is to fix RunAllSteps potentially finding no steps if the steps are added in LoadComplete (else they get forcefully scheduled too late)
404 private void schedule(Action action) => Scheduler.Add(action, false);
405 }
406}