acdream/tests/AcDream.Core.Tests/Items/VendorStateTests.cs
Erik 609a2dfda0
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(runtime/core): Slice 5.3 review corrections — retirement/transit close, per-unit pricing, guarded auto-close dispatch
The adversarial review's three blocking findings, each fixed at root:

1. A vendor session now CLOSES when its entity retires (despawn,
   death, ObjectDelete) and at teleport BEGIN
   (HasPendingTeleportStart || IsTeleportActive at the existing
   per-frame seam — both hosts funnel through
   RuntimeWorldTransitState.TryQueueTeleportStart, which flips the
   pending flag strictly before activation). The previous permissive
   early-return stranded the session forever: panel pinned to a stale
   guid, ActiveVendorId swallowing Use for the rest of the session.
2. VendorShopItem carries the desc's stack size, and
   VendorPricing.PerUnitValue ports retail's stack-total division
   (VendorProfile::VendorSellPrice 0x005D1B00: <= 0 guard, integer
   division) — a stack of 50 arrows now prices per arrow, not at 50x.
3. VendorState.Close() guards its observer fanout with the
   dispatcher's catch-and-log semantics — a throwing panel listener
   can no longer propagate into the unprotected per-frame path.

Register honesty rides along: the 0.6 m UseRadius fallback was
acdream's invention (ACE's CheckClose has no fallback; retail passes
the raw authored radius) — removed, the watcher now uses the raw
radius and AP-160's citations are corrected and extended with the
accepted-position-snapshot cadence; AD-72 files VendorPricing's
double-vs-x87-extended narrowing (AD-33's class, bounded by the
±0.1 margin).

Nine tests added. Clean-room complete solution: 11,311 passed /
4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 16:15:57 +02:00

175 lines
5.8 KiB
C#

using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class VendorStateTests
{
[Fact]
public void Apply_ZeroGuid_IsANoOp()
{
var state = new VendorState();
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
Assert.False(state.Apply(0u, default, Array.Empty<VendorShopItem>()));
Assert.Equal(0u, state.VendorId);
Assert.Empty(changes);
}
[Fact]
public void Apply_NewVendor_PublishesOpenedAndStoresSnapshot()
{
var state = new VendorState();
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
var profile = new VendorShopProfile(
MerchandiseItemTypes: (uint)ItemType.MeleeWeapon,
MerchandiseMinValue: 1,
MerchandiseMaxValue: 5000,
DealMagicalItems: true,
BuyPrice: 0.5f,
SellPrice: 1.5f,
AlternateCurrencyWcid: 0,
AlternateCurrencyAmount: 0,
AlternateCurrencyPluralName: string.Empty);
var items = new[]
{
new VendorShopItem(0x50000A01u, 3, 42u, "Iron Dagger", (uint)ItemType.MeleeWeapon, 0x06001234u, 25),
};
Assert.True(state.Apply(0x40000001u, profile, items));
Assert.Equal(0x40000001u, state.VendorId);
Assert.Equal(profile, state.Profile);
Assert.Same(items, state.Items);
var change = Assert.Single(changes);
Assert.Equal(VendorStateTransitionKind.Opened, change.Kind);
Assert.Equal(0u, change.PreviousVendorId);
Assert.Equal(0x40000001u, change.VendorId);
}
[Fact]
public void Apply_SameVendorAgain_PublishesRefreshedNotOpened()
{
var state = new VendorState();
state.Apply(0x40000002u, default, Array.Empty<VendorShopItem>());
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
// Slice 6 territory (a post-buy/sell ApproachVendor refresh) — but
// the state owner's job of distinguishing "same shop" from "new
// shop" belongs here regardless of what triggers the repeat call.
Assert.True(state.Apply(0x40000002u, default, Array.Empty<VendorShopItem>()));
var change = Assert.Single(changes);
Assert.Equal(VendorStateTransitionKind.Refreshed, change.Kind);
Assert.Equal(0x40000002u, change.PreviousVendorId);
Assert.Equal(0x40000002u, change.VendorId);
}
[Fact]
public void Apply_DifferentVendor_PublishesOpenedWithPreviousId()
{
var state = new VendorState();
state.Apply(0x40000003u, default, Array.Empty<VendorShopItem>());
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
Assert.True(state.Apply(0x40000004u, default, Array.Empty<VendorShopItem>()));
var change = Assert.Single(changes);
Assert.Equal(VendorStateTransitionKind.Opened, change.Kind);
Assert.Equal(0x40000003u, change.PreviousVendorId);
Assert.Equal(0x40000004u, change.VendorId);
Assert.Equal(0x40000004u, state.VendorId);
}
[Fact]
public void Close_WithNothingOpen_IsANoOp()
{
var state = new VendorState();
Assert.False(state.Close());
}
[Fact]
public void Close_ClearsSnapshotAndPublishesClosed()
{
var state = new VendorState();
state.Apply(0x40000005u, default, new[]
{
new VendorShopItem(0x50000A02u, 1, 7u, "Rock", (uint)ItemType.Misc, 0u, 1),
});
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
Assert.True(state.Close());
Assert.Equal(0u, state.VendorId);
Assert.Equal(default(VendorShopProfile), state.Profile);
Assert.Empty(state.Items);
var change = Assert.Single(changes);
Assert.Equal(VendorStateTransitionKind.Closed, change.Kind);
Assert.Equal(0x40000005u, change.PreviousVendorId);
Assert.Equal(0u, change.VendorId);
}
[Fact]
public void Close_ThrowingObserver_DoesNotPropagateAndStillClosesTheSession()
{
// Slice 5.3 review fix 3: unlike Reset(), Close() must never
// rethrow — its production caller (RuntimeVendorRangeQuery.
// EnforceRange) runs inside an unprotected per-frame callback.
var state = new VendorState();
state.Apply(0x40000007u, default, Array.Empty<VendorShopItem>());
bool secondObserverRan = false;
state.Changed += _ => throw new InvalidOperationException("boom");
state.Changed += _ => secondObserverRan = true;
Exception? thrown = Record.Exception(() => { state.Close(); });
Assert.Null(thrown);
Assert.True(secondObserverRan);
Assert.Equal(0u, state.VendorId);
}
[Fact]
public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother()
{
var state = new VendorState();
state.Apply(0x40000006u, default, Array.Empty<VendorShopItem>());
bool fail = true;
int delivered = 0;
state.Changed += _ =>
{
if (fail)
{
fail = false;
throw new InvalidOperationException("transient");
}
};
state.Changed += transition =>
{
Assert.Equal(VendorStateTransitionKind.Reset, transition.Kind);
delivered++;
};
Assert.Throws<AggregateException>(() => state.Reset());
Assert.Equal(1, delivered);
Assert.Equal(0u, state.VendorId);
// Second reset: nothing left to clear, but observers still run
// (mirrors ExternalContainerState.Reset — the retry is what proves
// one failing observer above didn't wedge state.VendorId).
Assert.False(state.Reset());
Assert.Equal(2, delivered);
}
}