acdream/tests/AcDream.Core.Tests/Items/VendorStateTests.cs
Erik e602f84be2
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(ui): Slice 5.4 review corrections — the dropdown renders from its authored popup, retail cost semantics, auto-select, icon overlays
All nine review findings closed at root (one sub-item consciously
deferred):

F1 the category dropdown now draws: sprites/fonts wired and the popup
geometry read from the vendor menu's own authored popup LayoutDesc
0x21000043 (root 0x1000034F — correcting the review's 0x1000014F
transcription) per UIElement_Menu::MakePopup (pc:120705); chat's menu
is untouched and its tests prove it. The new test drives selection
through the REAL open/hit path the review flagged as bypassed.
F2+F3 the selected-item cost display ports VendorItemsUI::UpdateItemsUI
verbatim: quantity via the 0xDC41CB0 split-size mask (whole-stack for
ammo, per-unit for groceries/components; mask lives at the toolbar
SEEDING site pc:198784), plural names with retail's
fall-back-to-singular (pc:409056 — correcting the review's "name+s"
guess), full cost sentences with comma grouping and the player's coin
total, and Buy/Add buttons that disable without a selection.
F4 category switches auto-select the first filtered item (pc:201180).
F5 icon underlay/overlay/effects + plural name forwarded from the
already-parsed wire fields through VendorShopItem to the icon
composer. F6 a DIFFERENT vendor opens on its own first category;
same-vendor refresh preserves per the clamp. F7 scroll resets on
rebuild and authored empty slots fill; the right-click examine route
is consciously DEFERRED (shop items are not in ClientObjectTable and
the appraisal panel hard-requires it — documented, not faked).
F8 VendorState.Apply's fanout gets the same per-listener isolation as
Close/Reset. F9 AP-110/AP-161 wording corrected ("quantity-correct
pricing") and AP-161 rewritten to exactly the remaining conscious
gaps.

Clean-room complete solution with the #348 cursor fix in the same
tree: 11,334 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 18:26:17 +02:00

198 lines
6.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 Apply_ThrowingObserver_DoesNotPropagateAndDoesNotStarveOtherListeners()
{
// Slice 5.4 review finding F8: Apply() ran a bare Changed?.Invoke(...)
// where every other production boundary in this class (Close's
// Slice 5.3 review fix 3, matched here) isolates each listener with
// GetInvocationList() + try/catch/log. Apply() runs from the SAME
// per-frame inbound-message-dispatch boundary Close's doc comment
// already establishes must survive a broken observer.
var state = new VendorState();
bool secondObserverRan = false;
state.Changed += _ => throw new InvalidOperationException("boom");
state.Changed += _ => secondObserverRan = true;
Exception? thrown = Record.Exception(
() => state.Apply(0x40000008u, default, Array.Empty<VendorShopItem>()));
Assert.Null(thrown);
Assert.True(secondObserverRan);
Assert.Equal(0x40000008u, 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);
}
}