Move the retail one-request-at-a-time gate, shared use busy references, external-container state, item mana, shortcuts, and desired-component snapshots into one Runtime-owned graph over J3's exact ClientObjectTable. Retained UI and session routing now borrow that owner; reset and shutdown preserve the existing order while failure/reentrancy tests protect the transaction boundary. Co-authored-by: Codex <codex@openai.com>
262 lines
8.3 KiB
C#
262 lines
8.3 KiB
C#
using AcDream.Core.Items;
|
|
|
|
namespace AcDream.Core.Tests.Items;
|
|
|
|
public sealed class InventoryTransactionStateTests
|
|
{
|
|
private const uint Player = 0x50000001u;
|
|
private const uint Pack = 0x50000002u;
|
|
private const uint First = 0x60000001u;
|
|
private const uint Second = 0x60000002u;
|
|
|
|
[Fact]
|
|
public void ReserveClosesReentrantDispatchWindowAndSuccessfulSendCommits()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
bool secondDispatched = true;
|
|
|
|
Assert.True(state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
First,
|
|
() =>
|
|
{
|
|
secondDispatched = state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
Second,
|
|
static () => true);
|
|
return true;
|
|
}));
|
|
|
|
Assert.False(secondDispatched);
|
|
Assert.True(state.TryGetPending(out PendingInventoryRequest pending));
|
|
Assert.Equal(First, pending.ItemId);
|
|
Assert.True(pending.Dispatched);
|
|
}
|
|
|
|
[Fact]
|
|
public void DispatchFailureOrExceptionReleasesExactProvisionalOwner()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
|
|
Assert.False(state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
First,
|
|
static () => false));
|
|
Assert.False(state.HasPendingRequest);
|
|
|
|
Assert.Throws<InvalidOperationException>(() => state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
First,
|
|
static () => throw new InvalidOperationException("transport")));
|
|
Assert.False(state.HasPendingRequest);
|
|
Assert.True(state.CanBeginRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedPrePublicationCallbackRollsBackOnlyItsReservation()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
state.TryReserve(
|
|
InventoryRequestKind.Pickup,
|
|
First,
|
|
out _,
|
|
static _ => throw new InvalidOperationException("projection")));
|
|
|
|
Assert.False(state.HasPendingRequest);
|
|
Assert.True(state.CanBeginRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoritativeResponseClearsBeforeReentrantCompletionObserver()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
bool replacementDispatched = false;
|
|
state.RequestCompleted += request =>
|
|
{
|
|
Assert.Equal(First, request.ItemId);
|
|
Assert.False(state.HasPendingRequest);
|
|
replacementDispatched = state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
Second,
|
|
static () => true);
|
|
};
|
|
|
|
Assert.True(state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
First,
|
|
static () => true));
|
|
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
|
|
|
|
Assert.True(replacementDispatched);
|
|
Assert.True(state.TryGetPending(out PendingInventoryRequest pending));
|
|
Assert.Equal(Second, pending.ItemId);
|
|
}
|
|
|
|
[Fact]
|
|
public void OptimisticMoveAndUnrelatedResponsesDoNotCompleteRequest()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
|
|
Assert.True(state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
First,
|
|
() => objects.MoveItemOptimistic(First, Player, 0)));
|
|
Assert.True(objects.ApplyConfirmedServerMove(Second, Player, 1u, 1));
|
|
Assert.True(state.HasPendingRequest);
|
|
|
|
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
|
|
Assert.False(state.HasPendingRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReusedGuidResponseCannotCompletePriorObjectIdentity()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
ClientObject original = objects.Get(First)!;
|
|
|
|
Assert.True(state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
First,
|
|
static () => true));
|
|
|
|
var replacement = new ClientObject
|
|
{
|
|
ObjectId = First,
|
|
Name = "replacement",
|
|
Type = ItemType.Misc,
|
|
};
|
|
objects.AddOrUpdate(replacement);
|
|
objects.MoveItem(First, Pack, 0);
|
|
Assert.NotSame(original, replacement);
|
|
|
|
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
|
|
Assert.True(state.HasPendingRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void UseReservationTransfersToUseDoneOrCancelsBeforeDispatch()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
|
|
ItemUseRequestReservation cancelled = state.BeginUseRequestReservation();
|
|
Assert.Equal(1, state.BusyCount);
|
|
cancelled.CancelBeforeDispatch();
|
|
cancelled.CancelBeforeDispatch();
|
|
Assert.Equal(0, state.BusyCount);
|
|
|
|
ItemUseRequestReservation dispatched = state.BeginUseRequestReservation();
|
|
dispatched.MarkDispatched();
|
|
dispatched.CancelBeforeDispatch();
|
|
Assert.Equal(1, state.BusyCount);
|
|
state.CompleteUse(0u);
|
|
Assert.Equal(0, state.BusyCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetInvalidatesLateUseReservationAndClearsPendingRequest()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
Assert.True(state.TryReserve(
|
|
InventoryRequestKind.Pickup,
|
|
First,
|
|
out _));
|
|
ItemUseRequestReservation stale = state.BeginUseRequestReservation();
|
|
|
|
state.ResetSession();
|
|
stale.CancelBeforeDispatch();
|
|
|
|
Assert.Equal(0, state.BusyCount);
|
|
Assert.False(state.HasPendingRequest);
|
|
Assert.True(state.CanBeginRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObserverFailureIsIsolatedAndRecorded()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
int delivered = 0;
|
|
state.StateChanged += () =>
|
|
throw new InvalidOperationException("observer");
|
|
state.StateChanged += () => delivered++;
|
|
|
|
state.IncrementBusyCount();
|
|
|
|
Assert.Equal(1, delivered);
|
|
Assert.Equal(1, state.DispatchFailureCount);
|
|
Assert.IsType<InvalidOperationException>(state.LastDispatchFailure);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObjectTableClearReleasesRequestAndNotifiesProjectionOwner()
|
|
{
|
|
var objects = CreateTable();
|
|
using var state = new InventoryTransactionState(objects);
|
|
int cleared = 0;
|
|
state.ObjectTableCleared += () => cleared++;
|
|
Assert.True(state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
First,
|
|
static () => true));
|
|
|
|
objects.Clear();
|
|
|
|
Assert.Equal(1, cleared);
|
|
Assert.False(state.HasPendingRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisposeDetachesFromBorrowedObjectTable()
|
|
{
|
|
var objects = CreateTable();
|
|
var state = new InventoryTransactionState(objects);
|
|
int completed = 0;
|
|
state.RequestCompleted += _ => completed++;
|
|
Assert.True(state.TryDispatch(
|
|
InventoryRequestKind.PutInContainer,
|
|
First,
|
|
static () => true));
|
|
|
|
state.Dispose();
|
|
state.Dispose();
|
|
objects.ApplyConfirmedServerMove(First, Player, 0u, 0);
|
|
|
|
Assert.True(state.IsDisposed);
|
|
Assert.Equal(0, completed);
|
|
Assert.Throws<ObjectDisposedException>(() =>
|
|
state.TryReserve(
|
|
InventoryRequestKind.Pickup,
|
|
First,
|
|
out _));
|
|
}
|
|
|
|
private static ClientObjectTable CreateTable()
|
|
{
|
|
var objects = new ClientObjectTable();
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = First,
|
|
Name = "first",
|
|
Type = ItemType.Misc,
|
|
});
|
|
objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = Second,
|
|
Name = "second",
|
|
Type = ItemType.Misc,
|
|
});
|
|
objects.MoveItem(First, Pack, 0);
|
|
objects.MoveItem(Second, Pack, 1);
|
|
return objects;
|
|
}
|
|
}
|