acdream/tests/AcDream.Core.Tests/Items/VendorStateTests.cs
Erik 70f37dbd5c
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
feat(core): Slice 5.2 — VendorState + retail's exact vendor price math
VendorState sits beside ExternalContainerState (contract decision 1)
with the same shape: private setters, Changed event, Reset with
AggregateException fanout; domain-shaped like ContainerContentEntry
since Core cannot reference Core.Net. No Runtime wiring, no UI — 5.3's
job.

VendorPricing ports ShopSystem::BuyPrice/SellPrice (0x006B6120/
0x006B6180) faithfully: retail's literal three-way branch survives,
including the unreachable-with-real-data negative -1 sentinel that
ACE's Math.Max(1, ...) collapse erases — equivalence for legitimate
inputs is hand-proven and documented rather than silently assumed.
Seven conformance tests with hand-derived golden values (float32
semantics verified independently), covering rate=1.0, fractional
rates, value=0, the rounding-sensitive halfway case, stack
multipliers, the ItemType rate-override branch, and the sentinel.

Clean-room complete solution with 5.1+5.2 in place: 11,291 passed /
4 skipped / 0 failed.

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

155 lines
5 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 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);
}
}