acdream/tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs
Erik c68ad1e646
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(vendor): 6b/6c review corrections — pre-send guards, accumulating staging, trade-note exemption, drag-over tab switch, full-stack sells
All thirteen findings, each anchored in recovered bytes or pc reads:

Buy All now runs retail's four PRE-SEND guards in order (pyreal and
alt-currency affordability, container and item slot capacity; strings
recovered from .rdata at 0x007b57b4/0x007b5750) — a rejected batch can
no longer destroy the staged list. Staged adds ACCUMULATE with the
5000 cap ("I can't possibly sell you that much!..." @0x007b59d8) and
the shop rows decrement/restore per RemoveFromShop. The max-value sell
rejection exempts trade notes — the raw bytes at 0x005d1add are `not`
(bitwise), not the pseudo-C's misleading `!`, and the early ret skips
the min check too. BF_RETAINED gates selling end to end (the bit was
already on ClientObject; AP-164's three claims were all false once
traced — RETIRED). Dragging over the vendor window auto-opens the
Selling tab per UpdateDragOver — with a correction to the review's own
citation: token 0x100000cd is the SELLING page, the guard is
"don't reopen the current tab." Sells are full-stack-only (three
retail sites; "Cannot sell part of a stack" @0x007b57ec) and Sell Item
acts on the global selection unconditionally. The confirm string gains
its byte-true trailing '?', dies with the session, staged-row
highlights repaint, dead guids unstage with retail's shopping-list
notice, and move-to-use no longer walks to targets the dispatch would
refuse.

AP-162 narrowed, AP-164 retired, AP-167/AP-168 filed honest.

Clean-room complete solution: 11,508 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 12:50:28 +02:00

251 lines
8.9 KiB
C#

using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
/// <summary>
/// Conformance tests for <see cref="VendorSellAcceptability"/> — the port of
/// <c>VendorSellUI::DragItemAcceptable</c> (<c>pc:201195-201307</c>) chained
/// into <c>VendorProfile::InqAcceptability</c> (<c>pc:484768-484797</c>).
/// </summary>
public sealed class VendorSellAcceptabilityTests
{
private const uint Armor = (uint)ItemType.Armor;
private const uint Weapon = (uint)ItemType.Weapon;
private const uint NoLimit = VendorSellAcceptability.NoLimit;
[Fact]
public void NotOwnedByPlayerIsRejectedBeforeAnyOtherCheck()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: false,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.NotOwnedByPlayer, rejection);
}
[Fact]
public void ANonEmptyContainerBypassesTheTypeAndValueFilterEntirely()
{
// pc:201229-201233: GetNumContainedItems > 0 -> accept unconditionally,
// even though the container's OWN type (Weapon) does not intersect
// the vendor's merchandise mask (Armor) and it has zero value.
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 3,
itemTypeMask: Weapon,
perUnitValue: 0,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.None, rejection);
}
[Fact]
public void AWrongItemTypeIsRejected()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Weapon,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.WrongType, rejection);
}
[Fact]
public void ZeroPerUnitValueIsRejectedAsNoValue()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 0,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.NoValue, rejection);
}
[Fact]
public void AboveTheAuthoredMaxValueIsRejectedAsTooValuable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 1001,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.TooValuable, rejection);
}
[Fact]
public void ExactlyAtTheAuthoredMaxValueIsAcceptable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 1000,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.None, rejection);
}
[Fact]
public void BelowTheAuthoredMinValueIsRejectedAsTooCheap()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 4,
merchandiseItemTypes: Armor,
merchandiseMinValue: 5u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.TooCheap, rejection);
}
[Fact]
public void NoLimitSentinelDisablesBothMaxAndMinChecks()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: int.MaxValue - 1,
merchandiseItemTypes: Armor,
merchandiseMinValue: NoLimit,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.None, rejection);
}
/// <summary>
/// F3 (Slice 6b/6c review, byte-verified at 0x005d1add): a trade note
/// above the vendor's max value is EXEMPT — the actual x86 is
/// <c>(~(itemTypeMask &gt;&gt; 16)) &amp; 4</c>, and PromissoryNote's bit
/// (0x00040000) lands exactly on that mask, zeroing the result.
/// </summary>
[Fact]
public void PromissoryNoteAboveMaxValueIsExemptFromTooValuable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: (uint)ItemType.PromissoryNote,
perUnitValue: 999_999,
merchandiseItemTypes: (uint)ItemType.PromissoryNote,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.None, rejection);
}
/// <summary>An ordinary (non-note) item above max value is still rejected — the exemption is note-specific.</summary>
[Fact]
public void NonPromissoryNoteAboveMaxValueIsStillTooValuable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 999_999,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.TooValuable, rejection);
}
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>
/// (<see cref="PublicWeenieFlags.Retained"/>, 0x01000000) folds into
/// the SAME <see cref="VendorSellRejection.WrongType"/> outcome as a
/// genuine type mismatch — retail's InqAcceptability ORs the two
/// conditions together (pc:005d1aa7).
/// </summary>
[Fact]
public void RetainedItemIsRejectedAsWrongTypeEvenWhenTheTypeMaskMatches()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit,
publicWeenieBitfield: (uint)PublicWeenieFlags.Retained);
Assert.Equal(VendorSellRejection.WrongType, rejection);
}
/// <summary>An item with OTHER bitfield bits set (not Retained) is unaffected.</summary>
[Fact]
public void NonRetainedBitfieldDoesNotAffectAcceptability()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit,
publicWeenieBitfield: (uint)PublicWeenieFlags.Stuck);
Assert.Equal(VendorSellRejection.None, rejection);
}
[Fact]
public void AnOrdinaryAcceptableItemReturnsNone()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 500,
merchandiseItemTypes: Armor,
merchandiseMinValue: 1u,
merchandiseMaxValue: 10_000u);
Assert.Equal(VendorSellRejection.None, rejection);
}
// ---- MessageFor: exact retail strings, recovered from the decompiled ----
// ---- binary's data segment (see VendorSellAcceptability's doc comment). ----
[Theory]
[InlineData(VendorSellRejection.NotOwnedByPlayer, "You can only sell items you are carrying")]
[InlineData(VendorSellRejection.CannotBeSoldHere, "That item cannot be sold here")]
[InlineData(VendorSellRejection.NoValue, "That item has no value and cannot be sold")]
[InlineData(VendorSellRejection.TooCheap, "That item is too cheap to sell here")]
[InlineData(VendorSellRejection.TooValuable, "That item is too valuable to sell here")]
[InlineData(VendorSellRejection.WrongType, "You cannot sell that here")]
public void MessageForReturnsRetailsExactString(VendorSellRejection rejection, string expected)
{
Assert.Equal(expected, VendorSellAcceptability.MessageFor(rejection));
}
[Fact]
public void MessageForAcceptableReturnsNull()
{
Assert.Null(VendorSellAcceptability.MessageFor(VendorSellRejection.None));
}
}