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.Collections;
6using System.Collections.Generic;
7using System.Diagnostics;
8using System.Linq;
9using System.Reflection;
10using NUnit.Framework;
11using NUnit.Framework.Internal;
12using osu.Framework.Allocation;
13using osu.Framework.Audio;
14using osu.Framework.Bindables;
15using osu.Framework.Configuration;
16using osu.Framework.Development;
17using osu.Framework.Extensions;
18using osu.Framework.Extensions.IEnumerableExtensions;
19using osu.Framework.Extensions.ObjectExtensions;
20using osu.Framework.Extensions.TypeExtensions;
21using osu.Framework.Graphics;
22using osu.Framework.Graphics.Containers;
23using osu.Framework.Graphics.Shapes;
24using osu.Framework.Graphics.Sprites;
25using osu.Framework.Graphics.UserInterface;
26using osu.Framework.Input;
27using osu.Framework.Input.Bindings;
28using osu.Framework.Input.Events;
29using osu.Framework.IO.Stores;
30using osu.Framework.Platform;
31using osu.Framework.Testing.Drawables;
32using osu.Framework.Testing.Drawables.Steps;
33using osu.Framework.Timing;
34using osuTK;
35using osuTK.Graphics;
36using osuTK.Input;
37using Logger = osu.Framework.Logging.Logger;
38
39namespace osu.Framework.Testing
40{
41 [Cached]
42 public class TestBrowser : KeyBindingContainer<TestBrowserAction>, IKeyBindingHandler<TestBrowserAction>, IHandleGlobalKeyboardInput
43 {
44 public TestScene CurrentTest { get; private set; }
45
46 private BasicTextBox searchTextBox;
47 private SearchContainer<TestGroupButton> leftFlowContainer;
48 private Container testContentContainer;
49 private Container compilingNotice;
50
51 public readonly List<Type> TestTypes = new List<Type>();
52
53 private ConfigManager<TestBrowserSetting> config;
54
55 private DynamicClassCompiler<TestScene> backgroundCompiler;
56
57 private bool interactive;
58
59 private readonly List<Assembly> assemblies;
60
61 /// <summary>
62 /// Creates a new TestBrowser that displays the TestCases of every assembly that start with either "osu" or the specified namespace (if it isn't null)
63 /// </summary>
64 /// <param name="assemblyNamespace">Assembly prefix which is used to match assemblies whose tests should be displayed</param>
65 public TestBrowser(string assemblyNamespace = null)
66 {
67 assemblies = AppDomain.CurrentDomain.GetAssemblies().Where(n =>
68 {
69 Debug.Assert(n.FullName != null);
70 return n.FullName.StartsWith("osu", StringComparison.Ordinal) || assemblyNamespace != null && n.FullName.StartsWith(assemblyNamespace, StringComparison.Ordinal);
71 }).ToList();
72
73 //we want to build the lists here because we're interested in the assembly we were *created* on.
74 foreach (Assembly asm in assemblies.ToList())
75 {
76 var tests = asm.GetLoadableTypes().Where(isValidVisualTest).ToList();
77
78 if (!tests.Any())
79 {
80 assemblies.Remove(asm);
81 continue;
82 }
83
84 foreach (Type type in tests)
85 TestTypes.Add(type);
86 }
87
88 TestTypes.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
89 }
90
91 private bool isValidVisualTest(Type t) => t.IsSubclassOf(typeof(TestScene)) && !t.IsAbstract && t.IsPublic && !t.GetCustomAttributes<HeadlessTestAttribute>().Any();
92
93 private void updateList(ValueChangedEvent<Assembly> args)
94 {
95 leftFlowContainer.Clear();
96
97 //Add buttons for each TestCase.
98 string namespacePrefix = TestTypes.Select(t => t.Namespace).GetCommonPrefix();
99
100 leftFlowContainer.AddRange(TestTypes.Where(t => t.Assembly == args.NewValue)
101 .GroupBy(
102 t =>
103 {
104 string group = t.Namespace?.AsSpan(namespacePrefix.Length).TrimStart('.').ToString();
105 return string.IsNullOrWhiteSpace(group) ? "Ungrouped" : group;
106 },
107 t => t,
108 (group, types) => new TestGroup { Name = group, TestTypes = types.ToArray() }
109 ).OrderBy(g => g.Name)
110 .Select(t => new TestGroupButton(type => LoadTest(type), t)));
111 }
112
113 internal readonly BindableDouble PlaybackRate = new BindableDouble(1) { MinValue = 0, MaxValue = 2, Default = 1 };
114 internal readonly Bindable<Assembly> Assembly = new Bindable<Assembly>();
115 internal readonly Bindable<bool> RunAllSteps = new Bindable<bool>();
116 internal readonly Bindable<RecordState> RecordState = new Bindable<RecordState>();
117 internal readonly BindableInt CurrentFrame = new BindableInt { MinValue = 0, MaxValue = 0 };
118
119 private TestBrowserToolbar toolbar;
120 private Container leftContainer;
121 private Container mainContainer;
122
123 private const float test_list_width = 200;
124
125 private Action exit;
126
127 private readonly BindableDouble audioRateAdjust = new BindableDouble(1);
128
129 [BackgroundDependencyLoader]
130 private void load(Storage storage, GameHost host, FrameworkConfigManager frameworkConfig, FontStore fonts, AudioManager audio)
131 {
132 interactive = host.Window != null;
133 config = new TestBrowserConfig(storage);
134
135 exit = host.Exit;
136
137 audio.AddAdjustment(AdjustableProperty.Frequency, audioRateAdjust);
138
139 var rateAdjustClock = new StopwatchClock(true);
140 var framedClock = new FramedClock(rateAdjustClock);
141
142 Children = new Drawable[]
143 {
144 mainContainer = new Container
145 {
146 RelativeSizeAxes = Axes.Both,
147 Padding = new MarginPadding { Left = test_list_width },
148 Children = new Drawable[]
149 {
150 new SafeAreaContainer
151 {
152 SafeAreaOverrideEdges = Edges.Right | Edges.Bottom,
153 RelativeSizeAxes = Axes.Both,
154 Child = testContentContainer = new Container
155 {
156 Clock = framedClock,
157 RelativeSizeAxes = Axes.Both,
158 Padding = new MarginPadding { Top = 50 },
159 Child = compilingNotice = new Container
160 {
161 Alpha = 0,
162 Anchor = Anchor.Centre,
163 Origin = Anchor.Centre,
164 Masking = true,
165 Depth = float.MinValue,
166 CornerRadius = 5,
167 AutoSizeAxes = Axes.Both,
168 Children = new Drawable[]
169 {
170 new Box
171 {
172 RelativeSizeAxes = Axes.Both,
173 Colour = Color4.Black,
174 },
175 new SpriteText
176 {
177 Font = FrameworkFont.Regular.With(size: 30),
178 Text = @"Compiling new version..."
179 }
180 },
181 }
182 }
183 },
184 toolbar = new TestBrowserToolbar
185 {
186 RelativeSizeAxes = Axes.X,
187 Height = 50,
188 },
189 }
190 },
191 leftContainer = new Container
192 {
193 RelativeSizeAxes = Axes.Y,
194 Size = new Vector2(test_list_width, 1),
195 Masking = true,
196 Children = new Drawable[]
197 {
198 new SafeAreaContainer
199 {
200 SafeAreaOverrideEdges = Edges.Left | Edges.Top | Edges.Bottom,
201 RelativeSizeAxes = Axes.Both,
202 Child = new Box
203 {
204 Colour = FrameworkColour.GreenDark,
205 RelativeSizeAxes = Axes.Both
206 }
207 },
208 new FillFlowContainer
209 {
210 Direction = FillDirection.Vertical,
211 RelativeSizeAxes = Axes.Both,
212 Children = new Drawable[]
213 {
214 searchTextBox = new TestBrowserTextBox
215 {
216 Height = 25,
217 RelativeSizeAxes = Axes.X,
218 PlaceholderText = "type to search",
219 Depth = -1,
220 },
221 new BasicScrollContainer
222 {
223 RelativeSizeAxes = Axes.Both,
224 Masking = false,
225 Child = leftFlowContainer = new SearchContainer<TestGroupButton>
226 {
227 Padding = new MarginPadding { Top = 3, Bottom = 20 },
228 Direction = FillDirection.Vertical,
229 AutoSizeAxes = Axes.Y,
230 RelativeSizeAxes = Axes.X,
231 }
232 }
233 }
234 }
235 }
236 },
237 };
238
239 searchTextBox.OnCommit += delegate
240 {
241 var firstTest = leftFlowContainer.Where(b => b.IsPresent).SelectMany(b => b.FilterableChildren).OfType<TestSubButton>()
242 .FirstOrDefault(b => b.MatchingFilter)?.TestType;
243 if (firstTest != null)
244 LoadTest(firstTest);
245 };
246
247 searchTextBox.Current.ValueChanged += e => leftFlowContainer.SearchTerm = e.NewValue;
248
249 if (RuntimeInfo.IsDesktop)
250 {
251 backgroundCompiler = new DynamicClassCompiler<TestScene>();
252 backgroundCompiler.CompilationStarted += compileStarted;
253 backgroundCompiler.CompilationFinished += compileFinished;
254 backgroundCompiler.CompilationFailed += compileFailed;
255
256 try
257 {
258 backgroundCompiler.Start();
259 }
260 catch
261 {
262 //it's okay for this to fail for now.
263 }
264 }
265
266 foreach (Assembly asm in assemblies)
267 toolbar.AddAssembly(asm.GetName().Name, asm);
268
269 Assembly.BindValueChanged(updateList);
270 RunAllSteps.BindValueChanged(v => runTests(null));
271 PlaybackRate.BindValueChanged(e =>
272 {
273 rateAdjustClock.Rate = e.NewValue;
274 audioRateAdjust.Value = e.NewValue;
275 }, true);
276 }
277
278 protected override void Dispose(bool isDisposing)
279 {
280 base.Dispose(isDisposing);
281 backgroundCompiler?.Dispose();
282 }
283
284 private void compileStarted() => Schedule(() =>
285 {
286 compilingNotice.Show();
287 compilingNotice.FadeColour(Color4.White);
288 });
289
290 private void compileFailed(Exception ex) => Schedule(() =>
291 {
292 Logger.Error(ex, "Error with dynamic compilation!");
293
294 compilingNotice.FadeIn(100, Easing.OutQuint).Then().FadeOut(800, Easing.InQuint);
295 compilingNotice.FadeColour(Color4.Red, 100);
296 });
297
298 private void compileFinished(Type newType) => Schedule(() =>
299 {
300 compilingNotice.FadeOut(800, Easing.InQuint);
301 compilingNotice.FadeColour(Color4.YellowGreen, 100);
302
303 if (newType == null)
304 return;
305
306 int i = TestTypes.FindIndex(t => t.Name == newType.Name && t.Assembly.GetName().Name == newType.Assembly.GetName().Name);
307
308 if (i < 0)
309 TestTypes.Add(newType);
310 else
311 TestTypes[i] = newType;
312
313 try
314 {
315 LoadTest(newType, isDynamicLoad: true);
316 }
317 catch (Exception e)
318 {
319 compileFailed(e);
320 }
321 });
322
323 protected override void LoadComplete()
324 {
325 base.LoadComplete();
326
327 if (CurrentTest == null)
328 {
329 var lastTest = config.Get<string>(TestBrowserSetting.LastTest);
330
331 var foundTest = TestTypes.Find(t => t.FullName == lastTest);
332
333 LoadTest(foundTest);
334 }
335 }
336
337 private void toggleTestList()
338 {
339 if (leftContainer.Width > 0)
340 {
341 leftContainer.Width = 0;
342 mainContainer.Padding = new MarginPadding();
343 }
344 else
345 {
346 leftContainer.Width = test_list_width;
347 mainContainer.Padding = new MarginPadding { Left = test_list_width };
348 }
349 }
350
351 protected override bool OnKeyDown(KeyDownEvent e)
352 {
353 if (!e.Repeat)
354 {
355 switch (e.Key)
356 {
357 case Key.Escape:
358 exit();
359 return true;
360 }
361 }
362
363 return base.OnKeyDown(e);
364 }
365
366 public override IEnumerable<IKeyBinding> DefaultKeyBindings => new[]
367 {
368 new KeyBinding(new[] { InputKey.Control, InputKey.F }, TestBrowserAction.Search),
369 new KeyBinding(new[] { InputKey.Control, InputKey.R }, TestBrowserAction.Reload), // for macOS
370
371 new KeyBinding(new[] { InputKey.Super, InputKey.F }, TestBrowserAction.Search), // for macOS
372 new KeyBinding(new[] { InputKey.Super, InputKey.R }, TestBrowserAction.Reload), // for macOS
373
374 new KeyBinding(new[] { InputKey.Control, InputKey.H }, TestBrowserAction.ToggleTestList),
375 };
376
377 public bool OnPressed(TestBrowserAction action)
378 {
379 switch (action)
380 {
381 case TestBrowserAction.Search:
382 if (leftContainer.Width == 0) toggleTestList();
383 GetContainingInputManager().ChangeFocus(searchTextBox);
384 return true;
385
386 case TestBrowserAction.Reload:
387 LoadTest(CurrentTest.GetType());
388 return true;
389
390 case TestBrowserAction.ToggleTestList:
391 toggleTestList();
392 return true;
393 }
394
395 return false;
396 }
397
398 public void OnReleased(TestBrowserAction action)
399 {
400 }
401
402 public void LoadTest(Type testType = null, Action onCompletion = null, bool isDynamicLoad = false)
403 {
404 if (CurrentTest?.Parent != null)
405 {
406 testContentContainer.Remove(CurrentTest.Parent);
407 CurrentTest.Dispose();
408 }
409
410 var lastTest = CurrentTest;
411
412 CurrentTest = null;
413
414 if (testType == null && TestTypes.Count > 0)
415 testType = TestTypes[0];
416
417 config.SetValue(TestBrowserSetting.LastTest, testType?.FullName ?? string.Empty);
418
419 if (testType == null)
420 return;
421
422 var newTest = (TestScene)Activator.CreateInstance(testType);
423
424 Debug.Assert(newTest != null);
425
426 const string dynamic_prefix = "dynamic";
427
428 // if we are a dynamically compiled type (via DynamicClassCompiler) we should update the dropdown accordingly.
429 if (isDynamicLoad)
430 {
431 newTest.DynamicCompilationOriginal = lastTest?.DynamicCompilationOriginal ?? lastTest ?? newTest;
432 toolbar.AddAssembly($"{dynamic_prefix} ({testType.Name})", testType.Assembly);
433 }
434 else
435 {
436 TestTypes.RemoveAll(t =>
437 {
438 Debug.Assert(t.Assembly.FullName != null);
439 return t.Assembly.FullName.Contains(dynamic_prefix);
440 });
441
442 newTest.DynamicCompilationOriginal = newTest;
443 }
444
445 Assembly.Value = testType.Assembly;
446
447 CurrentTest = newTest;
448 CurrentTest.OnLoadComplete += _ => Schedule(() => finishLoad(newTest, onCompletion));
449
450 updateButtons();
451 resetRecording();
452
453 testContentContainer.Add(new ErrorCatchingDelayedLoadWrapper(CurrentTest, isDynamicLoad)
454 {
455 OnCaughtError = compileFailed
456 });
457 }
458
459 private void resetRecording()
460 {
461 CurrentFrame.Value = 0;
462 if (RecordState.Value == Testing.RecordState.Stopped)
463 RecordState.Value = Testing.RecordState.Normal;
464 }
465
466 private void finishLoad(TestScene newTest, Action onCompletion)
467 {
468 if (CurrentTest != newTest)
469 {
470 // There could have been multiple loads fired after us. In such a case we want to silently remove ourselves.
471 testContentContainer.Remove(newTest.Parent);
472 return;
473 }
474
475 updateButtons();
476
477 bool hadTestAttributeTest = false;
478
479 foreach (var m in newTest.GetType().GetMethods())
480 {
481 var name = m.Name;
482
483 if (name == nameof(TestScene.TestConstructor) || m.GetCustomAttribute(typeof(IgnoreAttribute), false) != null)
484 continue;
485
486 if (name.StartsWith("Test", StringComparison.Ordinal))
487 name = name.Substring(4);
488
489 int runCount = 1;
490
491 if (m.GetCustomAttribute(typeof(RepeatAttribute), false) != null)
492 {
493 var count = m.GetCustomAttributesData().Single(a => a.AttributeType == typeof(RepeatAttribute)).ConstructorArguments.Single().Value;
494 Debug.Assert(count != null);
495
496 runCount += (int)count;
497 }
498
499 for (int i = 0; i < runCount; i++)
500 {
501 string repeatSuffix = i > 0 ? $" ({i + 1})" : string.Empty;
502
503 var methodWrapper = new MethodWrapper(m.GetType(), m);
504
505 if (methodWrapper.GetCustomAttributes<TestAttribute>(false).SingleOrDefault() != null)
506 {
507 var parameters = m.GetParameters();
508
509 if (parameters.Length > 0)
510 {
511 var valueMatrix = new List<List<object>>();
512
513 foreach (var p in methodWrapper.GetParameters())
514 {
515 var valueAttrib = p.GetCustomAttributes<ValuesAttribute>(false).SingleOrDefault();
516 if (valueAttrib == null)
517 throw new ArgumentException($"Parameter is present on a {nameof(TestAttribute)} method without values specification.", p.ParameterInfo.Name);
518
519 List<object> choices = new List<object>();
520
521 foreach (var choice in valueAttrib.GetData(p))
522 choices.Add(choice);
523
524 valueMatrix.Add(choices);
525 }
526
527 foreach (var combination in valueMatrix.CartesianProduct())
528 {
529 hadTestAttributeTest = true;
530 CurrentTest.AddLabel($"{name}({string.Join(", ", combination)}){repeatSuffix}");
531 handleTestMethod(m, combination.ToArray());
532 }
533 }
534 else
535 {
536 hadTestAttributeTest = true;
537 CurrentTest.AddLabel($"{name}{repeatSuffix}");
538 handleTestMethod(m);
539 }
540 }
541
542 foreach (var tc in m.GetCustomAttributes(typeof(TestCaseAttribute), false).OfType<TestCaseAttribute>())
543 {
544 hadTestAttributeTest = true;
545 CurrentTest.AddLabel($"{name}({string.Join(", ", tc.Arguments)}){repeatSuffix}");
546
547 handleTestMethod(m, tc.Arguments);
548 }
549
550 foreach (var tcs in m.GetCustomAttributes(typeof(TestCaseSourceAttribute), false).OfType<TestCaseSourceAttribute>())
551 {
552 IEnumerable sourceValue = getTestCaseSourceValue(m, tcs);
553
554 if (sourceValue == null)
555 {
556 Debug.Assert(tcs.SourceName != null);
557 throw new InvalidOperationException($"The value of the source member {tcs.SourceName} must be non-null.");
558 }
559
560 foreach (var argument in sourceValue)
561 {
562 hadTestAttributeTest = true;
563
564 if (argument is IEnumerable argumentsEnumerable)
565 {
566 var arguments = argumentsEnumerable.Cast<object>().ToArray();
567
568 CurrentTest.AddLabel($"{name}({string.Join(", ", arguments)}){repeatSuffix}");
569 handleTestMethod(m, arguments);
570 }
571 else
572 {
573 CurrentTest.AddLabel($"{name}({argument}){repeatSuffix}");
574 handleTestMethod(m, argument);
575 }
576 }
577 }
578 }
579 }
580
581 // even if no [Test] or [TestCase] methods were found, [SetUp] steps should be added.
582 if (!hadTestAttributeTest)
583 addSetUpSteps();
584
585 backgroundCompiler?.SetRecompilationTarget(CurrentTest);
586 runTests(onCompletion);
587 updateButtons();
588
589 void addSetUpSteps()
590 {
591 var setUpMethods = ReflectionUtils.GetMethodsWithAttribute(newTest.GetType(), typeof(SetUpAttribute), true)
592 .Where(m => m.Name != nameof(TestScene.SetUpTestForNUnit));
593
594 if (setUpMethods.Any())
595 {
596 CurrentTest.AddStep(new SingleStepButton(true)
597 {
598 Text = "[SetUp]",
599 LightColour = Color4.Teal,
600 Action = () => setUpMethods.ForEach(s => s.Invoke(CurrentTest, null))
601 });
602 }
603
604 CurrentTest.RunSetUpSteps();
605 }
606
607 void handleTestMethod(MethodInfo methodInfo, params object[] arguments)
608 {
609 addSetUpSteps();
610 methodInfo.Invoke(CurrentTest, arguments);
611 CurrentTest.RunTearDownSteps();
612 }
613 }
614
615 private static IEnumerable getTestCaseSourceValue(MethodInfo testMethod, TestCaseSourceAttribute tcs)
616 {
617 var sourceDeclaringType = tcs.SourceType ?? testMethod.DeclaringType;
618 Debug.Assert(sourceDeclaringType != null);
619
620 if (tcs.SourceType != null && tcs.SourceName == null)
621 return (IEnumerable)Activator.CreateInstance(tcs.SourceType);
622
623 var sourceMembers = sourceDeclaringType.AsNonNull().GetMember(tcs.SourceName, BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.FlattenHierarchy);
624 if (sourceMembers.Length == 0)
625 throw new InvalidOperationException($"No static member with the name of {tcs.SourceName} exists in {sourceDeclaringType} or its base types.");
626
627 if (sourceMembers.Length > 1)
628 throw new NotSupportedException($"There are multiple members with the same source name ({tcs.SourceName}) (e.g. method overloads).");
629
630 var sourceMember = sourceMembers.Single();
631
632 switch (sourceMember)
633 {
634 case FieldInfo sf:
635 return (IEnumerable)sf.GetValue(null);
636
637 case PropertyInfo sp:
638 if (!sp.CanRead)
639 throw new InvalidOperationException($"The source property {sp.Name} in {sp.DeclaringType.ReadableName()} must have a getter.");
640
641 return (IEnumerable)sp.GetValue(null);
642
643 case MethodInfo sm:
644 var methodParamsLength = sm.GetParameters().Length;
645 if (methodParamsLength != (tcs.MethodParams?.Length ?? 0))
646 throw new InvalidOperationException($"The given source method parameters count doesn't match the method. (attribute has {tcs.MethodParams?.Length ?? 0}, method has {methodParamsLength})");
647
648 return (IEnumerable)sm.Invoke(null, tcs.MethodParams);
649
650 default:
651 throw new NotSupportedException($"{sourceMember.MemberType} is not a supported member type for {nameof(TestCaseSourceAttribute)} (must be static field, property or method)");
652 }
653 }
654
655 private void runTests(Action onCompletion)
656 {
657 int actualStepCount = 0;
658 CurrentTest.RunAllSteps(onCompletion, e => Logger.Log($@"Error on step: {e}"), s =>
659 {
660 if (!interactive || RunAllSteps.Value)
661 return false;
662
663 if (actualStepCount > 0)
664 // stop once one actual step has been run.
665 return true;
666
667 if (!s.IsSetupStep && !(s is LabelStep))
668 actualStepCount++;
669
670 return false;
671 });
672 }
673
674 private void updateButtons()
675 {
676 foreach (var b in leftFlowContainer.Children)
677 b.Current = CurrentTest.GetType();
678 }
679
680 private class ErrorCatchingDelayedLoadWrapper : DelayedLoadWrapper
681 {
682 private readonly bool catchErrors;
683 private bool hasCaught;
684
685 public Action<Exception> OnCaughtError;
686
687 public ErrorCatchingDelayedLoadWrapper(Drawable content, bool catchErrors)
688 : base(content, 0)
689 {
690 this.catchErrors = catchErrors;
691 }
692
693 public override bool UpdateSubTree()
694 {
695 try
696 {
697 return base.UpdateSubTree();
698 }
699 catch (Exception e)
700 {
701 if (!catchErrors)
702 throw;
703
704 // without this we will enter an infinite loading loop (DelayedLoadWrapper will see the child removed below and retry).
705 hasCaught = true;
706
707 OnCaughtError?.Invoke(e);
708 RemoveInternal(Content);
709 }
710
711 return false;
712 }
713
714 protected override bool ShouldLoadContent => !hasCaught;
715 }
716
717 private class TestBrowserTextBox : BasicTextBox
718 {
719 protected override float LeftRightPadding => TestButtonBase.LEFT_TEXT_PADDING;
720
721 public TestBrowserTextBox()
722 {
723 TextFlow.Height = 0.75f;
724 }
725 }
726 }
727
728 internal enum RecordState
729 {
730 /// <summary>
731 /// The game is playing back normally.
732 /// </summary>
733 Normal,
734
735 /// <summary>
736 /// Drawn game frames are currently being recorded.
737 /// </summary>
738 Recording,
739
740 /// <summary>
741 /// The default game playback is stopped, recorded frames are being played back.
742 /// </summary>
743 Stopped
744 }
745
746 public enum TestBrowserAction
747 {
748 ToggleTestList,
749 Reload,
750 Search
751 }
752}