test(app): freeze GameWindow lifecycle boundaries
Pin the accepted startup, input, frame, resize, shutdown, and native-window order before Slice 8 moves those edges, while deleting only unread duplicate state and test-only GameWindow facades. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
20ebf217e2
commit
476c2e6de1
7 changed files with 380 additions and 56 deletions
|
|
@ -14,7 +14,7 @@ audit.
|
|||
|
||||
## Progress ledger
|
||||
|
||||
- [ ] A — freeze construction/callback/shutdown order and delete proven dead or
|
||||
- [x] A — freeze construction/callback/shutdown order and delete proven dead or
|
||||
test-facade residue.
|
||||
- [ ] B — make native-window callback intake an explicit reversible owner and
|
||||
define host-quiescence failure semantics.
|
||||
|
|
@ -401,6 +401,12 @@ fixes these audited gaps:
|
|||
`_streamingRadius`, unused snap constants, `IsPlayerGuid`, `IsDoorName`, and
|
||||
test-only forwarding helpers. Tests call canonical owners directly.
|
||||
|
||||
Result: the exact native attributes/callbacks, input subscription priority,
|
||||
frame-root/session-start boundary, framebuffer behavior, shutdown stages, and
|
||||
native-window-last edge are frozen. Dead duplicate state and two test-only
|
||||
window facades are removed. Three corrected-diff reviews are clean; 56 focused
|
||||
tests and the complete App suite (2,991 pass / 3 intentional skips) pass.
|
||||
|
||||
### B — native window binding and host quiescence
|
||||
|
||||
- Implement exact attach/reverse-detach/rollback and the no-throw quiescence
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ public sealed class GameWindow : IDisposable
|
|||
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
|
||||
private Shader? _terrainModernShader;
|
||||
private CameraController? _cameraController;
|
||||
private IMouse? _capturedMouse;
|
||||
private IDatReaderWriter? _dats;
|
||||
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
|
||||
private float _lastMouseX
|
||||
|
|
@ -118,7 +117,6 @@ public sealed class GameWindow : IDisposable
|
|||
_localPlayerTeleportSink = new();
|
||||
private AcDream.App.Streaming.LocalPlayerTeleportController?
|
||||
_localPlayerTeleport;
|
||||
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
|
||||
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
|
||||
new(nearRadius: 4, farRadius: 12);
|
||||
private int _nearRadius
|
||||
|
|
@ -317,37 +315,6 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
|
||||
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
|
||||
|
||||
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
|
||||
/// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a
|
||||
/// 2m residual fades within ~300ms without visible snap.</summary>
|
||||
private const float SnapResidualDecayRate = 8.0f;
|
||||
/// <summary>
|
||||
/// When the prediction error exceeds this many meters, we treat the
|
||||
/// update as a teleport / rubber-band and hard-snap (no soft lerp).
|
||||
/// Prevents the soft-snap logic from trying to smooth a genuine portal
|
||||
/// or force-move event.
|
||||
///
|
||||
/// <para>
|
||||
/// Matches retail's <c>GetAutonomyBlipDistance</c> (ACE
|
||||
/// <c>PhysicsObj.cs:545</c>): 20m for creatures, 25m for players.
|
||||
/// We use 20m as a conservative default — any delta larger than this
|
||||
/// must be a teleport (portal, recall, spawn). A running character
|
||||
/// with 1-second UpdatePosition cadence at 9.5 m/s produces deltas
|
||||
/// of ~9.5m, well below this threshold, so normal movement flows
|
||||
/// through the interpolation queue instead of hard-snapping.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private const float SnapHardSnapThreshold = 20.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
|
||||
/// remote entity, dead-reckoning continues but the "origin" for
|
||||
/// predicted position is the server pos. This matches retail's snap
|
||||
/// behavior — the server is authoritative, we just interpolate between
|
||||
/// authoritative samples.
|
||||
/// </summary>
|
||||
private const float DeadReckonMaxPredictSeconds = 1.0f;
|
||||
|
||||
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
|
||||
// Exposed publicly so plugins + UI panels can bind directly.
|
||||
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
||||
|
|
@ -611,8 +578,6 @@ public sealed class GameWindow : IDisposable
|
|||
// path. Populated for remotes plus the PlayerModeController local entry
|
||||
// (player); pruned only by logical LiveEntityRuntime teardown.
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||
private static bool IsDoorName(string? name) => name == "Door";
|
||||
// ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184
|
||||
// Slice 2a — the DR tick's stale-velocity anim-stop was its only user).
|
||||
public GameWindow(
|
||||
|
|
@ -2349,7 +2314,6 @@ public sealed class GameWindow : IDisposable
|
|||
if (_options.LegacyStreamRadius is { } sr)
|
||||
{
|
||||
_nearRadius = sr;
|
||||
_streamingRadius = sr; // keep debug overlay in sync
|
||||
_farRadius = System.Math.Max(sr, _farRadius);
|
||||
}
|
||||
Console.WriteLine(
|
||||
|
|
@ -3340,8 +3304,6 @@ public sealed class GameWindow : IDisposable
|
|||
session.SendTurbineChatTo(
|
||||
roomId, chatType, dispatchType, senderGuid, text, cookie),
|
||||
Log: Console.WriteLine);
|
||||
internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
|
||||
entity.Id;
|
||||
/// <summary>
|
||||
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
|
||||
/// Routes fog presets into the weather system's sticky override
|
||||
|
|
@ -3427,7 +3389,6 @@ public sealed class GameWindow : IDisposable
|
|||
// held is handled separately by the gameplay input owner.
|
||||
bool needsRawCursor = _cameraController?.IsFlyMode == true;
|
||||
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
|
||||
_capturedMouse = needsRawCursor ? mouse : null;
|
||||
}
|
||||
|
||||
// Performance overlay state — updated every ~0.5s and written to the
|
||||
|
|
@ -4217,10 +4178,6 @@ public sealed class GameWindow : IDisposable
|
|||
_debugVm?.AddToast(text);
|
||||
}
|
||||
|
||||
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
|
||||
AcDream.App.Input.MovementResult result) =>
|
||||
AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result);
|
||||
|
||||
/// <summary>
|
||||
/// Item-target-mode world pick at the current cursor. The renderer supplies the
|
||||
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -21,7 +20,7 @@ public sealed class LocalPlayerOutboundCombatStyleTests
|
|||
|
||||
MovementResult movement = controller.CaptureMovementResult(
|
||||
mouseLookEvent: false);
|
||||
RawMotionState outbound = GameWindow.BuildOutboundRawMotionState(
|
||||
RawMotionState outbound = LocalPlayerOutboundController.BuildRawMotionState(
|
||||
movement);
|
||||
|
||||
Assert.Equal(combatStyle, movement.CurrentStyle);
|
||||
|
|
@ -43,7 +42,7 @@ public sealed class LocalPlayerOutboundCombatStyleTests
|
|||
{
|
||||
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||
|
||||
RawMotionState outbound = GameWindow.BuildOutboundRawMotionState(
|
||||
RawMotionState outbound = LocalPlayerOutboundController.BuildRawMotionState(
|
||||
controller.CaptureMovementResult(mouseLookEvent: false));
|
||||
|
||||
Assert.Equal(RawMotionState.Default.CurrentStyle, outbound.CurrentStyle);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using Xunit;
|
||||
|
|
@ -259,7 +258,7 @@ public sealed class PlayerMouseLookMovementTests
|
|||
|
||||
controller.BeginMouseLook(new MovementInput(Run: true));
|
||||
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
|
||||
RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press);
|
||||
RawMotionState wire = LocalPlayerOutboundController.BuildRawMotionState(press);
|
||||
|
||||
Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey);
|
||||
Assert.True(press.IsRunning);
|
||||
|
|
@ -325,7 +324,7 @@ public sealed class PlayerMouseLookMovementTests
|
|||
|
||||
controller.BeginMouseLook(held);
|
||||
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
|
||||
RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press);
|
||||
RawMotionState wire = LocalPlayerOutboundController.BuildRawMotionState(press);
|
||||
|
||||
Assert.Equal(MotionCommand.SideStepRight, press.SidestepCommand);
|
||||
Assert.True(press.SidestepUsesRunHold);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Temporary source-shape freeze for the Slice 8 cutover. Each later checkpoint
|
||||
/// replaces the corresponding assertion with functional tests on the extracted
|
||||
/// owner; this file prevents a mechanical move from silently reordering host
|
||||
/// behavior before that owner exists.
|
||||
/// </summary>
|
||||
public sealed class GameWindowSlice8BoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Run_PreservesNativeAttributeAndCallbackOrder()
|
||||
{
|
||||
string body = MethodBody("public void Run()", "private void OnLoad()");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
body,
|
||||
"startupStore.LoadDisplay()",
|
||||
"QualitySettings.WithEnvOverrides(startupBase)",
|
||||
"_displayFramePacing.InitializeStartup(startupDisplay.VSync)",
|
||||
"VSync = startupPacing.UseVSync",
|
||||
"Samples = startupQuality.MsaaSamples",
|
||||
"Window.Create(options)",
|
||||
"_displayFramePacing.BindSurface(",
|
||||
"_window.Load += OnLoad;",
|
||||
"_window.Update += OnUpdate;",
|
||||
"_window.Render += OnRender;",
|
||||
"_window.Render += _displayFramePacing.OnFrameRendered;",
|
||||
"_window.Closing += OnClosing;",
|
||||
"_window.FocusChanged += OnFocusChanged;",
|
||||
"_window.Move += _displayFramePacing.OnWindowMoved;",
|
||||
"_window.StateChanged += _displayFramePacing.OnWindowStateChanged;",
|
||||
"_window.FramebufferResize += OnFramebufferResize;",
|
||||
"_window.Run();");
|
||||
|
||||
string[] registrations =
|
||||
[
|
||||
"_window.Load += OnLoad;",
|
||||
"_window.Update += OnUpdate;",
|
||||
"_window.Render += OnRender;",
|
||||
"_window.Render += _displayFramePacing.OnFrameRendered;",
|
||||
"_window.Closing += OnClosing;",
|
||||
"_window.FocusChanged += OnFocusChanged;",
|
||||
"_window.Move += _displayFramePacing.OnWindowMoved;",
|
||||
"_window.StateChanged += _displayFramePacing.OnWindowStateChanged;",
|
||||
"_window.FramebufferResize += OnFramebufferResize;",
|
||||
];
|
||||
foreach (string registration in registrations)
|
||||
Assert.Equal(1, CountOccurrences(body, registration));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionStart_FollowsFrameGraphPublication()
|
||||
{
|
||||
string body = MethodBody(
|
||||
"private void OnLoad()",
|
||||
"private AcDream.App.Net.LiveSessionLifecycleHost");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
body,
|
||||
"_gl = GL.GetApi(_window!);",
|
||||
"_input = _window!.CreateInput();",
|
||||
"_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);",
|
||||
"_mouseSource = new AcDream.App.Input.SilkMouseSource(",
|
||||
"_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(",
|
||||
"_movementInput.Bind(_inputDispatcher);",
|
||||
"_cameraInput.Bind(_inputDispatcher);",
|
||||
"_inputDispatcher.Fired += OnInputAction;",
|
||||
"mouse.MouseMove +=",
|
||||
"_cameraController = new CameraController(orbit, fly);",
|
||||
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
|
||||
"LoadAndApplyPersistedSettings();",
|
||||
"_uiHost = new AcDream.App.UI.UiHost(",
|
||||
"_uiHost.WireMouse(m)",
|
||||
"_uiHost.WireKeyboard(kb)",
|
||||
"_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(",
|
||||
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
|
||||
"_renderFrameOrchestrator =",
|
||||
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
|
||||
"_liveSessionController.Start(");
|
||||
Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start("));
|
||||
int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal);
|
||||
string postStart = body[start..];
|
||||
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
|
||||
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(
|
||||
postStart,
|
||||
@"(?m)^\s+_[A-Za-z]\w*\s*(?:\?\?=|=)"));
|
||||
Assert.DoesNotContain("new ", postStart, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".Mount(", postStart, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".Bind(", postStart, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".Attach(", postStart, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(".Compose(", postStart, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("PrepareResources(", postStart, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputAction_PreservesRetailAcceptedPriority()
|
||||
{
|
||||
string body = MethodBody(
|
||||
"private void OnInputAction(",
|
||||
"private void ToggleLiveCombatMode()");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
body,
|
||||
"HandlePointerAction(action, activation)",
|
||||
"action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp",
|
||||
"HandleScrollAction(action);",
|
||||
"HandleCombatAction(action, activation)",
|
||||
"activation != AcDream.UI.Abstractions.Input.ActivationType.Press",
|
||||
"_retailUiRuntime?.HandleInputAction(action)",
|
||||
"_selectionInteractions?.HandleInputAction(action)",
|
||||
"HandlePressedMovementAction(action)",
|
||||
"switch (action)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder()
|
||||
{
|
||||
string body = MethodBody(
|
||||
"private void OnFramebufferResize(",
|
||||
"private void ApplyDisplayWindowState(");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
body,
|
||||
"if (newSize.X <= 0 || newSize.Y <= 0) return;",
|
||||
"_gl?.Viewport(",
|
||||
"_viewportAspect.Update(",
|
||||
"_cameraController?.SetAspect(",
|
||||
"_devToolsFramePresenter?.ResetLayout(");
|
||||
Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRenderFocusAndCloseRemainNarrowHostEdges()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
string update = Slice(
|
||||
source,
|
||||
"private void OnUpdate(double dt)",
|
||||
"private void OnCameraModeChanged(");
|
||||
string render = Slice(
|
||||
source,
|
||||
"private void OnRender(double deltaSeconds)",
|
||||
"private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot");
|
||||
string focus = Slice(
|
||||
source,
|
||||
"private void OnFocusChanged(bool focused)",
|
||||
"public void Dispose()");
|
||||
string close = Slice(
|
||||
source,
|
||||
"private void OnClosing()",
|
||||
"private ResourceShutdownTransaction CreateShutdownTransaction()");
|
||||
|
||||
Assert.Equal(1, CountOccurrences(update, "_updateFrameOrchestrator.Tick("));
|
||||
Assert.Equal(1, CountOccurrences(render, "_renderFrameOrchestrator!.Render("));
|
||||
AssertAppearsInOrder(
|
||||
render,
|
||||
"Vector2D<int> size = _window!.Size;",
|
||||
"_renderFrameOrchestrator!.Render(",
|
||||
"new AcDream.App.Rendering.RenderFrameInput(");
|
||||
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();");
|
||||
AssertAppearsInOrder(close, "=> CompleteShutdown();", "_shutdown.CompleteOrThrow();");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
string manifest = Slice(
|
||||
source,
|
||||
"private ResourceShutdownTransaction CreateShutdownTransaction()",
|
||||
"private void OnFocusChanged(bool focused)");
|
||||
string dispose = Slice(
|
||||
source,
|
||||
"public void Dispose()",
|
||||
"private sealed class NullAnimLoader");
|
||||
|
||||
string[] stages =
|
||||
[
|
||||
"new ResourceShutdownStage(\"session lifetime\"",
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"new ResourceShutdownStage(\"session dependents\"",
|
||||
"new ResourceShutdownStage(\"live entities\"",
|
||||
"new ResourceShutdownStage(\"live entity dependents\"",
|
||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||
"new ResourceShutdownStage(\"render frontends\"",
|
||||
"new ResourceShutdownStage(\"shared texture owners\"",
|
||||
"new ResourceShutdownStage(\"mesh adapter\"",
|
||||
"new ResourceShutdownStage(\"remaining render owners\"",
|
||||
"new ResourceShutdownStage(\"frame flight owner\"",
|
||||
"new ResourceShutdownStage(\"content mappings\"",
|
||||
"new ResourceShutdownStage(\"input\"",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"",
|
||||
];
|
||||
AssertAppearsInOrder(manifest, stages);
|
||||
Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage("));
|
||||
foreach (string stage in stages)
|
||||
Assert.Equal(1, CountOccurrences(manifest, stage));
|
||||
AssertAppearsInOrder(
|
||||
dispose,
|
||||
"CompleteShutdown();",
|
||||
"_window?.Dispose();",
|
||||
"_window = null;");
|
||||
Assert.Equal(1, CountOccurrences(dispose, "CompleteShutdown();"));
|
||||
Assert.Equal(1, CountOccurrences(dispose, "_window?.Dispose();"));
|
||||
Assert.Equal(1, CountOccurrences(dispose, "_window = null;"));
|
||||
int nativeReleased = dispose.IndexOf("_window = null;", StringComparison.Ordinal)
|
||||
+ "_window = null;".Length;
|
||||
string afterNativeRelease = dispose[nativeReleased..];
|
||||
int fallbackDocumentation = afterNativeRelease.IndexOf(
|
||||
"/// <summary>",
|
||||
StringComparison.Ordinal);
|
||||
if (fallbackDocumentation >= 0)
|
||||
afterNativeRelease = afterNativeRelease[..fallbackDocumentation];
|
||||
Assert.All(
|
||||
afterNativeRelease,
|
||||
character => Assert.True(char.IsWhiteSpace(character) || character == '}'));
|
||||
}
|
||||
|
||||
private static string MethodBody(string start, string end) =>
|
||||
Slice(GameWindowSource(), start, end);
|
||||
|
||||
private static string Slice(string source, string start, string end)
|
||||
{
|
||||
int first = source.IndexOf(start, StringComparison.Ordinal);
|
||||
int last = source.IndexOf(end, first + 1, StringComparison.Ordinal);
|
||||
Assert.True(first >= 0, $"Missing source boundary: {start}");
|
||||
Assert.True(last > first, $"Missing source boundary: {end}");
|
||||
return source[first..last];
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string value)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += value.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] fragments)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string fragment in fragments)
|
||||
{
|
||||
int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next >= 0, $"Missing expected source fragment: {fragment}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {fragment}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
|
|
@ -78,4 +78,85 @@ public sealed class ResourceShutdownTransactionTests
|
|||
Assert.Equal(1, calls);
|
||||
Assert.True(transaction.IsComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PersistentFailuresInOneStageAreAllAttemptedAndNamed()
|
||||
{
|
||||
int firstCalls = 0;
|
||||
int secondCalls = 0;
|
||||
var transaction = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("owners",
|
||||
[
|
||||
new("first", () =>
|
||||
{
|
||||
firstCalls++;
|
||||
throw new InvalidOperationException("first failure");
|
||||
}),
|
||||
new("second", () =>
|
||||
{
|
||||
secondCalls++;
|
||||
throw new InvalidOperationException("second failure");
|
||||
}),
|
||||
]));
|
||||
|
||||
AggregateException error = Assert.Throws<AggregateException>(
|
||||
transaction.CompleteOrThrow);
|
||||
|
||||
Assert.True(firstCalls > 0);
|
||||
Assert.Equal(firstCalls, secondCalls);
|
||||
Assert.Contains(
|
||||
error.InnerExceptions,
|
||||
exception => exception.Message.Contains("first", StringComparison.Ordinal));
|
||||
Assert.Contains(
|
||||
error.InnerExceptions,
|
||||
exception => exception.Message.Contains("second", StringComparison.Ordinal));
|
||||
Assert.False(transaction.IsComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitRetryAfterFailedCompletionDoesNotReplaySuccessfulOperations()
|
||||
{
|
||||
int completeCalls = 0;
|
||||
int pendingCalls = 0;
|
||||
bool allowPending = false;
|
||||
var transaction = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("owners",
|
||||
[
|
||||
new("complete", () => completeCalls++),
|
||||
new("pending", () =>
|
||||
{
|
||||
pendingCalls++;
|
||||
if (!allowPending)
|
||||
throw new InvalidOperationException("not yet");
|
||||
}),
|
||||
]));
|
||||
|
||||
Assert.Throws<AggregateException>(transaction.CompleteOrThrow);
|
||||
int pendingCallsBeforeRetry = pendingCalls;
|
||||
allowPending = true;
|
||||
|
||||
transaction.CompleteOrThrow();
|
||||
|
||||
Assert.Equal(1, completeCalls);
|
||||
Assert.Equal(pendingCallsBeforeRetry + 1, pendingCalls);
|
||||
Assert.True(transaction.IsComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyStagesConvergeWithoutSkippingLaterOperations()
|
||||
{
|
||||
int calls = 0;
|
||||
var transaction = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("empty", []),
|
||||
new ResourceShutdownStage("owner",
|
||||
[
|
||||
new("owner", () => calls++),
|
||||
]),
|
||||
new ResourceShutdownStage("empty tail", []));
|
||||
|
||||
transaction.CompleteOrThrow();
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
Assert.True(transaction.IsComplete);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Vfx;
|
||||
|
|
@ -472,7 +471,6 @@ public sealed class EntityScriptActivatorTests
|
|||
|
||||
Assert.Contains("globally unique", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(1, p.Runner.ActiveOwnerCount);
|
||||
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -569,8 +567,13 @@ public sealed class EntityScriptActivatorTests
|
|||
[Fact]
|
||||
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null);
|
||||
var p = BuildPipeline(
|
||||
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
|
||||
var activator = new EntityScriptActivator(
|
||||
p.Runner,
|
||||
p.Sink,
|
||||
p.Poses,
|
||||
StaticResolver(0xAAu));
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1_000_123u,
|
||||
|
|
@ -582,9 +585,11 @@ public sealed class EntityScriptActivatorTests
|
|||
};
|
||||
|
||||
activator.OnCreate(entity);
|
||||
p.Runner.Tick(0.001f);
|
||||
|
||||
Assert.Equal(entity.Id, GameWindow.ParticleEntityKey(entity));
|
||||
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
|
||||
Assert.Single(p.Recording.Calls);
|
||||
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
|
||||
Assert.NotEqual(entity.ServerGuid, p.Recording.Calls[0].EntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue