feat(vendor): Slice 6b/6c — move-to-use, buy staging, selling; the vendor arc is functionally complete
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
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
C1 an out-of-range Use now approaches first via the existing client-predicted BeginApproach (Pickup's far-range shape mirrored; retail's ItemHolder::UseObject @0x00588A80 has no range check and the dispatch stays immediate). C2 Add-to-List stages into the Buying tab via VendorStagingList (RemoveProfileFromList's two shapes, pc:200497-200537), Buy All sends ONE batched 0x005F and flushes staging on send exactly as retail does (SendShopEvent -> Flush, pc:204075-204076 — not UseDone-gated), and X-close over a non-empty staging list shows retail's confirm string recovered verbatim from the binary data segment (0x007b5bd8) through the existing dialog factory. C3 the Selling tab's list is the sole drop target (retail's single IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports InqAcceptability with all rejection strings recovered verbatim from the raw data segment; the sell side prices with BuyPrice (retail's inverted naming: what the vendor PAYS) and 0x0060 carries no trailing currency field, unlike Buy. C4 the status-bar reproduction test PASSES against the production toolbar mount — retail's toolbar shows count + name with the split bar and NO price parenthetical (that figure is the vendor row's own cost text); no code change, the live gate referees. C5 pack order verified correct, untouched. Register: AP-161 narrowed to its two pre-existing cosmetic gaps; AP-162 extended over Buy All; AP-164 (non-sellable bitfield unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal test, bounded), AP-166 (purse text + pending-sell highlight cosmetic) filed. Clean-room complete solution: 11,482 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ab3146ba88
commit
92ea3977b6
18 changed files with 2578 additions and 78 deletions
|
|
@ -430,19 +430,95 @@ public sealed class SelectionInteractionControllerTests
|
|||
Assert.Equal(0, h.Items.BusyCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C1 (Slice 6b move-to-use, docs/research/2026-08-08-slice6b-vendor-
|
||||
/// completion-research.md Q2): an out-of-range Use kicks off the SAME
|
||||
/// local client-predicted MoveToObject approach Pickup's far-range
|
||||
/// branch already installs, giving the walk immediate visual feel. The
|
||||
/// wire send is never gated on arrival — retail's
|
||||
/// <c>ItemHolder::UseObject @ 0x00588A80</c> has no range check and
|
||||
/// sends unconditionally, so the dispatch and the approach both happen
|
||||
/// at click time, in that order. A later natural MoveTo completion must
|
||||
/// not re-dispatch (Use has no post-arrival token the way Pickup does).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FarUseSendsImmediatelyWithoutClientApproachAndDoesNotRetry()
|
||||
public void FarUseApproachesThenDispatchesImmediatelyAndDoesNotRetryOnArrival()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetApproach(closeRange: false);
|
||||
|
||||
h.Controller.SendUse(Target);
|
||||
|
||||
PlayerInteractionMovementSinkAssertSingleApproach(h, Target);
|
||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
||||
Assert.Empty(h.Movement.Approaches);
|
||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C1 cancellation coverage: a second far Use command (the player picked
|
||||
/// a new target, i.e. "moved on") supersedes the first local approach
|
||||
/// cleanly — no exception, no missing/duplicated dispatch, no leaked
|
||||
/// pending-pickup state (Use never arms one).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NewFarUseCommandSupersedesThePreviousApproachCleanly()
|
||||
{
|
||||
const uint otherTarget = 0x7000_0099u;
|
||||
var h = new Harness();
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = otherTarget,
|
||||
Name = "Other",
|
||||
Type = ItemType.Creature,
|
||||
Useability = ItemUseability.Remote,
|
||||
});
|
||||
h.SetApproach(closeRange: false);
|
||||
|
||||
h.Controller.SendUse(Target);
|
||||
h.SetApproach(closeRange: false, serverGuid: otherTarget);
|
||||
h.Controller.SendUse(otherTarget);
|
||||
|
||||
Assert.Equal(2, h.Movement.Approaches.Count);
|
||||
Assert.Equal(Target, h.Movement.Approaches[0].Target.ServerGuid);
|
||||
Assert.Equal(otherTarget, h.Movement.Approaches[1].Target.ServerGuid);
|
||||
Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses);
|
||||
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
||||
Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C1 cancellation coverage: the underlying MoveTo controller cancelling
|
||||
/// out from under a far Use's local approach (player moved away with
|
||||
/// WASD, or any other source of <see cref="WeenieError"/>) must not
|
||||
/// retract or duplicate the Use, which already went out unconditionally
|
||||
/// at click time — Use holds no pending-pickup state for
|
||||
/// <c>OnMoveToCancelled</c> to touch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MovingAwayDuringAFarUseApproachDoesNotAffectTheAlreadyDispatchedUse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetApproach(closeRange: false);
|
||||
|
||||
h.Controller.SendUse(Target);
|
||||
h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled);
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||
}
|
||||
|
||||
private static void PlayerInteractionMovementSinkAssertSingleApproach(
|
||||
Harness h, uint expectedTarget)
|
||||
{
|
||||
InteractionApproach approach = Assert.Single(h.Movement.Approaches);
|
||||
Assert.Equal(expectedTarget, approach.Target.ServerGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarriedDirectUseBypassesWorldApproachAndWaitsForUseDone()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ public sealed class ItemInteractionControllerTests
|
|||
// TryBuy must see this as false and release the reservation
|
||||
// rather than mark it dispatched for a request nothing sent.
|
||||
public bool SendBuySucceeds = true;
|
||||
// Slice 6b/6c: the batched Buy All / Sell send delegates — same
|
||||
// "no live session" no-op simulation shape as SendBuySucceeds.
|
||||
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
|
||||
public bool SendBuyAllSucceeds = true;
|
||||
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
|
||||
public bool SendSellSucceeds = true;
|
||||
public readonly List<string> Toasts = new();
|
||||
public readonly List<string> SystemMessages = new();
|
||||
public readonly List<CombatMode> CombatModeRequests = new();
|
||||
|
|
@ -107,6 +113,20 @@ public sealed class ItemInteractionControllerTests
|
|||
return false;
|
||||
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
|
||||
return true;
|
||||
},
|
||||
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
|
||||
{
|
||||
if (!SendBuyAllSucceeds)
|
||||
return false;
|
||||
BuyAlls.Add((vendorGuid, items, alternateCurrencyId));
|
||||
return true;
|
||||
},
|
||||
sendSell: (vendorGuid, items) =>
|
||||
{
|
||||
if (!SendSellSucceeds)
|
||||
return false;
|
||||
Sells.Add((vendorGuid, items));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2308,4 +2328,182 @@ public sealed class ItemInteractionControllerTests
|
|||
Assert.Single(h.Buys);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
// ── Slice 6b: TryBuyAll ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryBuyAll_Succeeds_SendsTheBatchAndTakesTheSharedUseReservation()
|
||||
{
|
||||
var h = new Harness();
|
||||
var items = new (int Amount, uint ItemGuid)[]
|
||||
{
|
||||
(1, 0x50002000u),
|
||||
(25, 0x50002001u),
|
||||
};
|
||||
|
||||
bool result = h.Controller.TryBuyAll(0x40001000u, items, alternateCurrencyId: 0u);
|
||||
|
||||
Assert.True(result);
|
||||
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> sent, uint currency) = Assert.Single(h.BuyAlls);
|
||||
Assert.Equal(0x40001000u, vendorGuid);
|
||||
Assert.Equal(items, sent);
|
||||
Assert.Equal(0u, currency);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuyAll_RidesTheSameOneRequestAtATimeGateAsOrdinaryUse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Controller.IncrementBusyCount();
|
||||
|
||||
bool result = h.Controller.TryBuyAll(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(h.BuyAlls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u)]
|
||||
[InlineData(0x40001000u)]
|
||||
public void TryBuyAll_ZeroVendorOrEmptyList_IsRejectedWithoutTakingAReservation(uint vendorGuid)
|
||||
{
|
||||
var h = new Harness();
|
||||
var items = vendorGuid == 0u
|
||||
? new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }
|
||||
: Array.Empty<(int Amount, uint ItemGuid)>();
|
||||
|
||||
bool result = h.Controller.TryBuyAll(vendorGuid, items, 0u);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(h.BuyAlls);
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuyAll_CompleteUse_ReleasesTheReservation()
|
||||
{
|
||||
var h = new Harness();
|
||||
Assert.True(h.Controller.TryBuyAll(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u));
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
|
||||
h.Controller.CompleteUse(0);
|
||||
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuyAll_NoSessionToSendOn_ReleasesTheReservation()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SendBuyAllSucceeds = false;
|
||||
|
||||
bool result = h.Controller.TryBuyAll(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(h.BuyAlls);
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
// ── Slice 6c: TrySell ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TrySell_Succeeds_SendsTheBatchAndTakesTheSharedUseReservation()
|
||||
{
|
||||
var h = new Harness();
|
||||
var items = new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) };
|
||||
|
||||
bool result = h.Controller.TrySell(0x40001000u, items);
|
||||
|
||||
Assert.True(result);
|
||||
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> sent) = Assert.Single(h.Sells);
|
||||
Assert.Equal(0x40001000u, vendorGuid);
|
||||
Assert.Equal(items, sent);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySell_RidesTheSameOneRequestAtATimeGateAsOrdinaryUse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Controller.IncrementBusyCount();
|
||||
|
||||
bool result = h.Controller.TrySell(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) });
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(h.Sells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySell_ASecondSellWhileTheFirstIsInFlight_IsRejected()
|
||||
{
|
||||
var h = new Harness();
|
||||
Assert.True(h.Controller.TrySell(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }));
|
||||
|
||||
bool second = h.Controller.TrySell(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) });
|
||||
|
||||
Assert.False(second);
|
||||
Assert.Single(h.Sells);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u)]
|
||||
[InlineData(0x40001000u)]
|
||||
public void TrySell_ZeroVendorOrEmptyList_IsRejectedWithoutTakingAReservation(uint vendorGuid)
|
||||
{
|
||||
var h = new Harness();
|
||||
var items = vendorGuid == 0u
|
||||
? new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }
|
||||
: Array.Empty<(int Amount, uint ItemGuid)>();
|
||||
|
||||
bool result = h.Controller.TrySell(vendorGuid, items);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(h.Sells);
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySell_CompleteUse_ReleasesTheReservationAndReenablesFurtherRequests()
|
||||
{
|
||||
var h = new Harness();
|
||||
Assert.True(h.Controller.TrySell(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }));
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
|
||||
h.Controller.CompleteUse(0);
|
||||
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
Assert.True(h.Controller.TrySell(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) }));
|
||||
Assert.Equal(2, h.Sells.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySell_NoSessionToSendOn_ReleasesTheReservation_AndASubsequentSellWorks()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SendSellSucceeds = false;
|
||||
|
||||
bool result = h.Controller.TrySell(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) });
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(h.Sells);
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
|
||||
h.SendSellSucceeds = true;
|
||||
bool second = h.Controller.TrySell(
|
||||
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) });
|
||||
|
||||
Assert.True(second);
|
||||
Assert.Single(h.Sells);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -645,4 +645,117 @@ public class SelectedObjectControllerTests
|
|||
Assert.False(healthMeterEl.Visible);
|
||||
Assert.Empty(h.QueryHealthCalls);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// C4 (Slice 6b/6c contract, Q5 — user evidence is the axiom): reproduce
|
||||
// the user's live-session report of a missing "(250,000)" figure near a
|
||||
// selected vendor stack, against the REAL production wiring — a real
|
||||
// ClientObjectTable + VendorState + VendorSplitPolicy (mirroring
|
||||
// InteractionRetainedUiComposition's exact IsVendorSplitExempt/
|
||||
// StackSize/name lambdas verbatim, not the fake dictionary-backed
|
||||
// Harness the rest of this file uses), mounted onto the real dat-
|
||||
// fixture toolbar layout (FixtureLoader.LoadToolbar(), LayoutDesc
|
||||
// 0x21000016) through the production SelectedObjectController.Bind
|
||||
// entry point.
|
||||
//
|
||||
// docs/research/2026-08-08-slice6b-vendor-completion-research.md Q5
|
||||
// concluded from static reading that retail's toolbar strip never
|
||||
// shows a price/value suffix in the object name (only "{count}
|
||||
// {name}") and that a "(250,000)" figure belongs to the VENDOR ROW's
|
||||
// own price text (VendorUiController._itemCostText), a separate
|
||||
// widget — with every acdream link in the chain (name formatting,
|
||||
// slider seed, the shared VendorSplitPolicy mask, the materializer's
|
||||
// field mapping) already matching that shape on paper. This test
|
||||
// settles whether that is genuinely what the user saw (expected: this
|
||||
// test passes, and the caller stops here per the contract, reporting
|
||||
// this as the live-probe evidence) or whether the real wiring chain
|
||||
// has a defect static reading could not see (expected: this test
|
||||
// fails, and the failure is what gets fixed).
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation()
|
||||
{
|
||||
const uint vendorGuid = 0x70000010u;
|
||||
const uint tradeNotesGuid = 0x60009001u;
|
||||
|
||||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||||
var objects = new ClientObjectTable();
|
||||
var vendor = new VendorState();
|
||||
var selection = new SelectionState();
|
||||
var splitQuantity = new StackSplitQuantityState();
|
||||
|
||||
// A materialized vendor shop item — VendorShopItemMaterializer.
|
||||
// ToWeenieData's exact shape: StackSize is the item's own
|
||||
// authored per-unit stack depth (its DescStackSize wire field),
|
||||
// ContainerId is the vendor's own guid.
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = tradeNotesGuid,
|
||||
Name = "Trade Note",
|
||||
PluralName = "Trade Notes",
|
||||
Type = ItemType.PromissoryNote,
|
||||
StackSize = 250,
|
||||
ContainerId = vendorGuid,
|
||||
});
|
||||
vendor.Apply(
|
||||
vendorGuid,
|
||||
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
|
||||
Array.Empty<VendorShopItem>());
|
||||
|
||||
SelectedObjectController controller = SelectedObjectController.Bind(
|
||||
layout,
|
||||
selection,
|
||||
subscribeHealthChanged: _ => { },
|
||||
unsubscribeHealthChanged: _ => { },
|
||||
subscribeItemManaChanged: _ => { },
|
||||
unsubscribeItemManaChanged: _ => { },
|
||||
isHealthTarget: _ => false,
|
||||
isOwnedByPlayer: _ => false,
|
||||
// Production's EXACT name resolver (InteractionRetainedUiComposition.cs:676).
|
||||
name: guid => objects.Get(guid)?.GetAppropriateName(),
|
||||
healthPercent: _ => 0f,
|
||||
hasHealth: _ => false,
|
||||
// Production's EXACT stackSize resolver (InteractionRetainedUiComposition.cs:679-680).
|
||||
stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0),
|
||||
sendQueryHealth: _ => { },
|
||||
manaPercent: _ => 0f,
|
||||
sendQueryItemMana: _ => { },
|
||||
datFont: null,
|
||||
splitQuantity: splitQuantity,
|
||||
subscribeObjectUpdated: _ => { },
|
||||
unsubscribeObjectUpdated: _ => { },
|
||||
// Production's EXACT isVendorSplitExempt predicate, verbatim
|
||||
// from InteractionRetainedUiComposition.cs:698-702.
|
||||
isVendorSplitExempt: guid =>
|
||||
vendor.VendorId != 0u
|
||||
&& objects.Get(guid) is { } vendorCandidate
|
||||
&& vendorCandidate.ContainerId == vendor.VendorId
|
||||
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
|
||||
|
||||
selection.Select(tradeNotesGuid, SelectionChangeSource.Vendor);
|
||||
|
||||
var nameElement = layout.FindElement(SelectedObjectController.NameId);
|
||||
Assert.NotNull(nameElement);
|
||||
UiText nameLabel = Assert.Single(nameElement!.Children.OfType<UiText>());
|
||||
string renderedName = string.Concat(
|
||||
nameLabel.LinesProvider().Select(static line => line.Text));
|
||||
|
||||
var slider = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(SelectedObjectController.StackSizeSliderId));
|
||||
|
||||
// Retail's toolbar name text: "{stackSize} {name}" — count is the
|
||||
// raw authored stack (250), independent of the vendor-exempt SEED.
|
||||
// No parenthetical value anywhere in this string.
|
||||
Assert.Equal("250 Trade Notes", renderedName);
|
||||
Assert.DoesNotContain("250,000", renderedName);
|
||||
Assert.DoesNotContain("(", renderedName);
|
||||
// The slider is visible (a real multi-unit stack) and seeds to 1 —
|
||||
// PromissoryNote intersects VendorSplitPolicy.SplitExemptMask.
|
||||
Assert.True(slider.Visible);
|
||||
Assert.Equal(1u, splitQuantity.Value);
|
||||
Assert.Equal(250u, splitQuantity.Maximum);
|
||||
|
||||
controller.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@ public sealed class VendorUiControllerTests
|
|||
private const uint ArmorItemGuid = 0x60000101u;
|
||||
private const uint FoodItemGuid = 0x60000102u;
|
||||
private const uint StackedItemGuid = 0x60000103u;
|
||||
// Slice 6b: a second same-category shop item, so two DIFFERENT items
|
||||
// both appear in the SAME category-filtered Items list simultaneously
|
||||
// (Armor and Food are different table entries and can't both show at
|
||||
// once without touching the category dropdown).
|
||||
private const uint AnotherArmorItemGuid = 0x60000104u;
|
||||
// Slice 6c: player-OWNED pack items (never vendor stock) dragged onto
|
||||
// the Selling tab.
|
||||
private const uint PlayerOwnedArmorGuid = 0x60000201u;
|
||||
private const uint PlayerOwnedWeaponGuid = 0x60000202u;
|
||||
private const uint PlayerOwnedArmorGuid2 = 0x60000205u;
|
||||
|
||||
private sealed class TestElement : UiElement { }
|
||||
|
||||
|
|
@ -172,11 +182,28 @@ public sealed class VendorUiControllerTests
|
|||
public readonly UiButton CloseButton;
|
||||
public readonly UiButton BuyButton;
|
||||
public readonly UiButton AddButton;
|
||||
// Slice 6b: "Buying" tab staging widgets.
|
||||
public readonly UiItemList BuyingList = new();
|
||||
public readonly UiButton BuyItemButton;
|
||||
public readonly UiButton BuyAllButton;
|
||||
public readonly UiButton BuyClearItemButton;
|
||||
public readonly UiButton BuyClearListButton;
|
||||
// Slice 6c: "Selling" tab staging widgets.
|
||||
public readonly UiItemList SellingList = new();
|
||||
public readonly UiButton SellItemButton;
|
||||
public readonly UiButton SellAllButton;
|
||||
public readonly UiButton SellClearItemButton;
|
||||
public readonly UiButton SellClearListButton;
|
||||
public readonly RetailWindowHandle Window;
|
||||
public readonly VendorUiController Controller;
|
||||
public readonly List<uint> Examines = new();
|
||||
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
|
||||
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
|
||||
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
|
||||
public readonly List<string> SystemMessages = new();
|
||||
public readonly ItemInteractionController ItemInteraction;
|
||||
public readonly RetailDialogFactory Dialogs;
|
||||
public ImportedLayout? ShownDialog;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
|
|
@ -194,6 +221,30 @@ public sealed class VendorUiControllerTests
|
|||
AddButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.AddButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
BuyItemButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.BuyItemButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
BuyAllButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.BuyAllButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
BuyClearItemButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.BuyClearItemButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
BuyClearListButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.BuyClearListButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
SellItemButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.SellItemButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
SellAllButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.SellAllButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
SellClearItemButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.SellClearItemButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
SellClearListButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.SellClearListButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
|
||||
root.AddChild(CloseButton);
|
||||
root.AddChild(ItemsTab);
|
||||
|
|
@ -209,6 +260,16 @@ public sealed class VendorUiControllerTests
|
|||
ItemsPage.AddChild(ItemCostText);
|
||||
ItemsPage.AddChild(BuyButton);
|
||||
ItemsPage.AddChild(AddButton);
|
||||
BuyingPage.AddChild(BuyingList);
|
||||
BuyingPage.AddChild(BuyItemButton);
|
||||
BuyingPage.AddChild(BuyAllButton);
|
||||
BuyingPage.AddChild(BuyClearItemButton);
|
||||
BuyingPage.AddChild(BuyClearListButton);
|
||||
SellingPage.AddChild(SellingList);
|
||||
SellingPage.AddChild(SellItemButton);
|
||||
SellingPage.AddChild(SellAllButton);
|
||||
SellingPage.AddChild(SellClearItemButton);
|
||||
SellingPage.AddChild(SellClearListButton);
|
||||
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
|
|
@ -226,6 +287,16 @@ public sealed class VendorUiControllerTests
|
|||
[VendorUiController.ItemCostTextId] = ItemCostText,
|
||||
[VendorUiController.BuyButtonId] = BuyButton,
|
||||
[VendorUiController.AddButtonId] = AddButton,
|
||||
[VendorUiController.BuyingListId] = BuyingList,
|
||||
[VendorUiController.BuyItemButtonId] = BuyItemButton,
|
||||
[VendorUiController.BuyAllButtonId] = BuyAllButton,
|
||||
[VendorUiController.BuyClearItemButtonId] = BuyClearItemButton,
|
||||
[VendorUiController.BuyClearListButtonId] = BuyClearListButton,
|
||||
[VendorUiController.SellingListId] = SellingList,
|
||||
[VendorUiController.SellItemButtonId] = SellItemButton,
|
||||
[VendorUiController.SellAllButtonId] = SellAllButton,
|
||||
[VendorUiController.SellClearItemButtonId] = SellClearItemButton,
|
||||
[VendorUiController.SellClearListButtonId] = SellClearListButton,
|
||||
});
|
||||
|
||||
Window = RetailWindowFrame.Mount(
|
||||
|
|
@ -255,8 +326,21 @@ public sealed class VendorUiControllerTests
|
|||
{
|
||||
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
|
||||
return true;
|
||||
},
|
||||
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
|
||||
{
|
||||
BuyAlls.Add((vendorGuid, items, alternateCurrencyId));
|
||||
return true;
|
||||
},
|
||||
sendSell: (vendorGuid, items) =>
|
||||
{
|
||||
Sells.Add((vendorGuid, items));
|
||||
return true;
|
||||
});
|
||||
|
||||
Dialogs = new RetailDialogFactory(Screen, _ =>
|
||||
ShownDialog = FixtureLoader.LoadConfirmationDialog());
|
||||
|
||||
Controller = VendorUiController.Bind(
|
||||
layout,
|
||||
State,
|
||||
|
|
@ -272,7 +356,9 @@ public sealed class VendorUiControllerTests
|
|||
SplitQuantity,
|
||||
datFont: null,
|
||||
debugFont: null,
|
||||
static _ => (0u, 0, 0))!;
|
||||
static _ => (0u, 0, 0),
|
||||
dialogs: Dialogs,
|
||||
systemMessage: SystemMessages.Add)!;
|
||||
Screen.WindowManager.AttachController(WindowNames.Vendor, Controller);
|
||||
}
|
||||
}
|
||||
|
|
@ -281,6 +367,17 @@ public sealed class VendorUiControllerTests
|
|||
float sellRate = 1.5f, uint altCurrency = 0u, string altName = "", uint altAmount = 0u) =>
|
||||
new(0u, 0u, 0u, false, 1.0f, sellRate, altCurrency, altAmount, altName);
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6c: a profile shaped for <see cref="VendorSellAcceptability"/>
|
||||
/// coverage — <see cref="Profile"/>'s all-zero merchandise fields would
|
||||
/// reject every real item (MaxValue=0 rejects anything with value > 0).
|
||||
/// </summary>
|
||||
private static VendorShopProfile SellProfile(
|
||||
uint merchandiseItemTypes,
|
||||
uint minValue = 0u,
|
||||
uint maxValue = VendorSellAcceptability.NoLimit) =>
|
||||
new(merchandiseItemTypes, minValue, maxValue, false, 1.0f, 1.5f, 0u, 0u, "");
|
||||
|
||||
private static string GetText(UiText text)
|
||||
=> string.Concat(text.LinesProvider().Select(line => line.Text));
|
||||
|
||||
|
|
@ -528,13 +625,11 @@ public sealed class VendorUiControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void AddButton_IsPermanentlyDisabled_RegardlessOfSelection()
|
||||
public void AddButton_EnablesWithSelection_NowThatStagingIsWired()
|
||||
{
|
||||
// F8 (Slice 6 review): "Add to List" has no wired OnClick at all
|
||||
// (staging into the "Buying" tab is deferred, contract decision 6)
|
||||
// — an enabled button that silently does nothing on click is a
|
||||
// dead-affordance bug, worse than a disabled one. It must never
|
||||
// enable, with or without a selection.
|
||||
// Slice 6b (AP-161 F8 residual closes): "Add to List" now stages
|
||||
// into the "Buying" tab and enables with selection exactly like Buy
|
||||
// — an enabled Add is no longer a dead affordance.
|
||||
var h = new Harness();
|
||||
Assert.False(h.AddButton.Enabled);
|
||||
|
||||
|
|
@ -543,8 +638,8 @@ public sealed class VendorUiControllerTests
|
|||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
// F4/F6 auto-selects the sole item on open -- Add stays disabled.
|
||||
Assert.False(h.AddButton.Enabled);
|
||||
// F4/F6 auto-selects the sole item on open -- Add now enables too.
|
||||
Assert.True(h.AddButton.Enabled);
|
||||
|
||||
h.State.Close();
|
||||
Assert.False(h.AddButton.Enabled);
|
||||
|
|
@ -957,7 +1052,7 @@ public sealed class VendorUiControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SellingTab_SwitchesPageButPopulatesNoSellContent()
|
||||
public void SellingTab_SwitchesPageAndStartsWithAnEmptyStagedSellList()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
|
|
@ -970,13 +1065,15 @@ public sealed class VendorUiControllerTests
|
|||
Assert.True(h.SellingPage.Visible);
|
||||
Assert.False(h.ItemsPage.Visible);
|
||||
Assert.False(h.BuyingPage.Visible);
|
||||
// Slice 6 fence: no sell-list/price/button wiring exists at all —
|
||||
// the page is exactly the authored-empty container it started as.
|
||||
Assert.Empty(h.SellingPage.Children);
|
||||
// Slice 6c: the Selling tab's own widgets are wired now (staging
|
||||
// list + four buttons), but nothing is STAGED without a drag/drop —
|
||||
// the list itself stays empty.
|
||||
Assert.NotEmpty(h.SellingPage.Children);
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyingTab_SwitchesPageButPopulatesNoBuyContent()
|
||||
public void BuyingTab_SwitchesPageAndStartsWithAnEmptyStagedBuyList()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
|
|
@ -988,7 +1085,11 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
Assert.True(h.BuyingPage.Visible);
|
||||
Assert.False(h.ItemsPage.Visible);
|
||||
Assert.Empty(h.BuyingPage.Children);
|
||||
// Slice 6b: the Buying tab's own widgets are wired now (staging list
|
||||
// + four buttons), but nothing is STAGED until "Add to List" is
|
||||
// pressed — the list itself stays empty.
|
||||
Assert.NotEmpty(h.BuyingPage.Children);
|
||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1237,4 +1338,552 @@ public sealed class VendorUiControllerTests
|
|||
Assert.Equal(string.Empty, GetText(h.ItemNameText));
|
||||
Assert.False(h.BuyButton.Enabled);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Slice 6b — Buying tab staging (Add to List, Buy Item, Buy All, Clear)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void AddToBuyList_StagesTheSelectedItem()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
// F4/F6 auto-selects the sole item on open.
|
||||
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddToBuyList_ReAddingTheSameItemUpsertsRatherThanDuplicatingTheRow()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddToBuyList_NothingSelected_IsANoOp()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
|
||||
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
|
||||
});
|
||||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
h.ItemList.GetItem(1)!.Clicked?.Invoke();
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
Assert.Equal(2, h.BuyingList.GetNumUIItems());
|
||||
|
||||
h.BuyAllButton.OnClick!.Invoke();
|
||||
|
||||
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items, uint currency) =
|
||||
Assert.Single(h.BuyAlls);
|
||||
Assert.Equal(VendorGuid, vendorGuid);
|
||||
Assert.Equal(
|
||||
new (int Amount, uint ItemGuid)[] { (1, ArmorItemGuid), (1, AnotherArmorItemGuid) },
|
||||
items);
|
||||
Assert.Equal(0u, currency);
|
||||
// Retail flushes m_buyList immediately after the send, not gated on
|
||||
// a server response (pc:204075-204076) — see BuyAllButtonPressed's
|
||||
// own doc comment.
|
||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyAllButton_WithNothingStaged_IsANoOp()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
|
||||
|
||||
h.BuyAllButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Empty(h.BuyAlls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
|
||||
h.BuyItemButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(new[] { (VendorGuid, ArmorItemGuid, 1, 0u) }, h.Buys);
|
||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutBuying()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
|
||||
});
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
h.ItemList.GetItem(1)!.Clicked?.Invoke();
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
Assert.Equal(2, h.BuyingList.GetNumUIItems());
|
||||
// AnotherArmorItemGuid is currently selected (last clicked).
|
||||
|
||||
h.BuyClearItemButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId);
|
||||
Assert.Empty(h.Buys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyClearListButton_ClearsEveryStagedEntryWithoutBuying()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
h.BuyClearListButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||
Assert.Empty(h.Buys);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Slice 6c — Selling tab drag-to-sell staging
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1)
|
||||
{
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = guid,
|
||||
Name = $"Item {guid:X8}",
|
||||
Type = type,
|
||||
Value = value,
|
||||
StackSize = stackSize,
|
||||
});
|
||||
h.Objects.MoveItem(guid, Harness.PlayerGuid, h.Objects.GetContents(Harness.PlayerGuid).Count);
|
||||
}
|
||||
|
||||
private static ItemDragPayload DragFromInventory(uint guid) =>
|
||||
new(guid, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_TargetIsNotTheSellingList_RejectsRegardlessOfAcceptability()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
|
||||
ItemDragAcceptance result = h.Controller.OnDragOver(
|
||||
h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
||||
Assert.Equal(ItemDragAcceptance.Reject, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_AcceptableItemOverSellingList_Accepts()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
|
||||
ItemDragAcceptance result = h.Controller.OnDragOver(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
||||
Assert.Equal(ItemDragAcceptance.Accept, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDragOver_UnacceptableItemOverSellingList_RejectsSilently()
|
||||
{
|
||||
var h = new Harness();
|
||||
// Vendor only deals in Armor -- a Weapon is a type mismatch.
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100);
|
||||
|
||||
ItemDragAcceptance result = h.Controller.OnDragOver(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
|
||||
|
||||
Assert.Equal(ItemDragAcceptance.Reject, result);
|
||||
// silent=1 on hover -- no rejection string yet (VendorSellUI::
|
||||
// OnItemListDragOver, pc:201320-201339).
|
||||
Assert.Empty(h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
|
||||
h.Controller.HandleDropRelease(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
||||
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
||||
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
|
||||
Assert.True(h.SellingPage.Visible);
|
||||
Assert.False(h.ItemsPage.Visible);
|
||||
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
|
||||
Assert.Empty(h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_WrongTargetList_IsIgnored()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
|
||||
// gmVendorUI::HandleDropRelease's IsAncestorOfMe gate — a drop on
|
||||
// ANY other list in the panel (here, the Items list) is a structural
|
||||
// no-op, never reaching AcceptDragObject.
|
||||
h.Controller.HandleDropRelease(
|
||||
h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_UnacceptableType_ShowsTheGenericRejectionMessageAndDoesNotStage()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100);
|
||||
|
||||
h.Controller.HandleDropRelease(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
|
||||
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_NoValueItem_ShowsTheNoValueRejectionMessage()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, value: 0);
|
||||
|
||||
h.Controller.HandleDropRelease(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
Assert.Equal(new[] { "That item has no value and cannot be sold" }, h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_NotOwnedByPlayer_ShowsTheOwnershipRejectionMessage()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
// Registered but never moved into the player's own container --
|
||||
// ContainerId/WielderId both stay 0, so IsOwnedByPlayer is false.
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = PlayerOwnedArmorGuid,
|
||||
Name = "Someone else's chainmail",
|
||||
Type = ItemType.Armor,
|
||||
Value = 100,
|
||||
StackSize = 1,
|
||||
});
|
||||
|
||||
h.Controller.HandleDropRelease(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100);
|
||||
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2));
|
||||
Assert.Equal(2, h.SellingList.GetNumUIItems());
|
||||
|
||||
h.SellAllButton.OnClick!.Invoke();
|
||||
|
||||
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
||||
Assert.Equal(VendorGuid, vendorGuid);
|
||||
Assert.Equal(
|
||||
new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid), (1, PlayerOwnedArmorGuid2) },
|
||||
items);
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SellAllButton_WithNothingStaged_IsANoOp()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
|
||||
h.SellAllButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Empty(h.Sells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SellItemButton_SellsTheSelectedStagedItemAndRemovesItUnconditionallyOnSuccess()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
||||
|
||||
h.SellItemButton.OnClick!.Invoke();
|
||||
|
||||
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
||||
Assert.Equal(VendorGuid, vendorGuid);
|
||||
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items);
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100);
|
||||
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2));
|
||||
Assert.Equal(2, h.SellingList.GetNumUIItems());
|
||||
// PlayerOwnedArmorGuid2 is currently selected (last dropped).
|
||||
|
||||
h.SellClearItemButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
||||
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
|
||||
Assert.Empty(h.Sells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SellClearListButton_ClearsEveryStagedEntryWithoutSelling()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
||||
h.SellClearListButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
Assert.Empty(h.Sells);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Slice 6b/6c — X-close staging confirmation + session-boundary clears
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void CloseButtonPressed_WithNoStaging_HidesImmediatelyWithoutADialog()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
Assert.True(h.Window.IsVisible);
|
||||
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
Assert.False(h.Window.IsVisible);
|
||||
Assert.False(h.Dialogs.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButtonPressed_WithStagedBuyItems_ShowsConfirmDialogInsteadOfHidingImmediately()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.True(h.Dialogs.IsOpen);
|
||||
Assert.NotNull(h.ShownDialog);
|
||||
// Exact retail string, read from the decompiled binary's data
|
||||
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment.
|
||||
Assert.Equal(
|
||||
"You have not completed all transactions. Are you sure you want to leave this vendor",
|
||||
string.Join(" ", Assert.IsType<UiText>(h.ShownDialog!.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseConfirmDialog_Accepted_HidesTheWindowAndLeavesStagingIntact()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
Assert.IsType<UiButton>(h.ShownDialog!.FindElement(
|
||||
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
||||
|
||||
Assert.False(h.Window.IsVisible);
|
||||
Assert.False(h.Dialogs.IsOpen);
|
||||
// Retail's CloseVendorDialogCallback never touches m_buyList/
|
||||
// m_sellList -- staging survives so the next open shows it again.
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseConfirmDialog_Rejected_KeepsTheWindowOpenAndStagingIntact()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
Assert.IsType<UiButton>(h.ShownDialog!.FindElement(
|
||||
RetailConfirmationDialogView.RejectButtonId)).OnClick!();
|
||||
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.False(h.Dialogs.IsOpen);
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButtonPressed_WhileAConfirmationIsAlreadyUp_DoesNotOpenASecondOne()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
Assert.Equal(1, h.Dialogs.ActiveCount);
|
||||
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(1, h.Dialogs.ActiveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionClose_ClearsBothStagingLists()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
||||
|
||||
h.State.Close();
|
||||
|
||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionReset_ClearsBothStagingLists()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
h.State.Reset();
|
||||
|
||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpeningADifferentVendor_ClearsStaleStagingFromThePreviousVendor()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
|
||||
const uint otherVendor = 0x70000099u;
|
||||
h.State.Apply(otherVendor, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
||||
});
|
||||
|
||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshedTransition_SameVendor_DoesNotClearAnUntouchedStagingList()
|
||||
{
|
||||
var h = new Harness();
|
||||
var items = new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
};
|
||||
h.State.Apply(VendorGuid, Profile(), items);
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
|
||||
// Same vendor id re-approaching -- sameVendor==1, a Refreshed
|
||||
// transition (e.g. post buy/sell ApproachVendor refresh).
|
||||
h.State.Apply(VendorGuid, Profile(), items);
|
||||
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,4 +124,75 @@ public sealed class VendorRequestsTests
|
|||
|
||||
Assert.Equal(viaList, viaSingle);
|
||||
}
|
||||
|
||||
// ---- Slice 6c: BuildSell (0x0060) — no trailing currency field ---------
|
||||
|
||||
[Fact]
|
||||
public void BuildSell_SingleItem_WritesEnvelopeSequenceOpcodeVendorCountAndItemWithNoTrailer()
|
||||
{
|
||||
byte[] body = VendorRequests.BuildSell(
|
||||
gameActionSequence: 9,
|
||||
vendorGuid: 0x40001000u,
|
||||
items: new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) });
|
||||
|
||||
// envelope(4) + seq(4) + opcode(4) + vendorGuid(4) + itemCount(4)
|
||||
// + 1*(amount(4)+guid(4)) = 28. Note: 4 bytes SHORTER than the
|
||||
// equivalent single-item Buy payload (32) — Sell has no trailing
|
||||
// alternateCurrencyId.
|
||||
Assert.Equal(28, body.Length);
|
||||
Assert.Equal(VendorRequests.GameActionEnvelope,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
|
||||
Assert.Equal(9u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
Assert.Equal(VendorRequests.SellOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0x0060u, VendorRequests.SellOpcode);
|
||||
Assert.Equal(0x40001000u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal(1u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
Assert.Equal(1,
|
||||
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
|
||||
Assert.Equal(0x50002000u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSell_MultipleItems_WritesEachAmountGuidPairInOrderWithNoTrailer()
|
||||
{
|
||||
byte[] body = VendorRequests.BuildSell(
|
||||
gameActionSequence: 4,
|
||||
vendorGuid: 0x40001000u,
|
||||
items: new (int Amount, uint ItemGuid)[]
|
||||
{
|
||||
(1, 0x50002000u),
|
||||
(10, 0x50002001u),
|
||||
});
|
||||
|
||||
// 20 + 2*8 = 36.
|
||||
Assert.Equal(36, body.Length);
|
||||
Assert.Equal(2u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
Assert.Equal(1,
|
||||
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
|
||||
Assert.Equal(0x50002000u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
|
||||
Assert.Equal(10,
|
||||
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(28)));
|
||||
Assert.Equal(0x50002001u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(32)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSell_EmptyList_WritesAZeroCountAndNoItemPairs()
|
||||
{
|
||||
byte[] body = VendorRequests.BuildSell(
|
||||
gameActionSequence: 1,
|
||||
vendorGuid: 0x40001000u,
|
||||
items: Array.Empty<(int Amount, uint ItemGuid)>());
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(0u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,4 +61,70 @@ public sealed class WorldSessionVendorTests
|
|||
|
||||
Assert.Equal(expected, captured);
|
||||
}
|
||||
|
||||
// ---- Slice 6b: the batched "Buy All" SendBuy overload -------------------
|
||||
|
||||
[Fact]
|
||||
public void SendBuy_BatchedOverload_EmitsBytesIdenticalToVendorRequestsBuildBuyWithAList()
|
||||
{
|
||||
using var session = NewSession();
|
||||
byte[]? captured = null;
|
||||
session.GameActionCapture = body => captured = body;
|
||||
|
||||
var items = new (int Amount, uint ItemGuid)[]
|
||||
{
|
||||
(1, 0x50002000u),
|
||||
(10, 0x50002001u),
|
||||
};
|
||||
session.SendBuy(0x40001000u, items, 0u);
|
||||
|
||||
byte[] expected = VendorRequests.BuildBuy(
|
||||
gameActionSequence: 1,
|
||||
vendorGuid: 0x40001000u,
|
||||
items: items,
|
||||
alternateCurrencyId: 0u);
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(expected, captured);
|
||||
}
|
||||
|
||||
// ---- Slice 6c: SendSell ---------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SendSell_EmitsBytesIdenticalToVendorRequestsBuildSell()
|
||||
{
|
||||
using var session = NewSession();
|
||||
byte[]? captured = null;
|
||||
session.GameActionCapture = body => captured = body;
|
||||
|
||||
var items = new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) };
|
||||
session.SendSell(0x40001000u, items);
|
||||
|
||||
byte[] expected = VendorRequests.BuildSell(
|
||||
gameActionSequence: 1,
|
||||
vendorGuid: 0x40001000u,
|
||||
items: items);
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(expected, captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendSell_IncrementsTheSharedGameActionSequenceLikeEveryOtherSend()
|
||||
{
|
||||
using var session = NewSession();
|
||||
byte[]? captured = null;
|
||||
session.GameActionCapture = body => captured = body;
|
||||
|
||||
session.SendTalk("first"); // consumes sequence 1
|
||||
var items = new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) };
|
||||
session.SendSell(0x40001000u, items); // should be sequence 2
|
||||
|
||||
byte[] expected = VendorRequests.BuildSell(
|
||||
gameActionSequence: 2,
|
||||
vendorGuid: 0x40001000u,
|
||||
items: items);
|
||||
|
||||
Assert.Equal(expected, captured);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs
Normal file
174
tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
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);
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
}
|
||||
169
tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs
Normal file
169
tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for <see cref="VendorStagingList"/> — retail's
|
||||
/// <c>gmVendorUI::RemoveProfileFromList</c> (<c>pc:200497-200537</c>) removal
|
||||
/// semantics, and the Add/Clear staging shape both the Buying and Selling
|
||||
/// tabs share (Slice 6b/6c).
|
||||
/// </summary>
|
||||
public sealed class VendorStagingListTests
|
||||
{
|
||||
private const uint ItemA = 0x60000101u;
|
||||
private const uint ItemB = 0x60000102u;
|
||||
|
||||
[Fact]
|
||||
public void AddAppendsANewEntry()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
|
||||
list.Add(ItemA, 5);
|
||||
|
||||
VendorStagingEntry entry = Assert.Single(list.Entries);
|
||||
Assert.Equal(ItemA, entry.ItemGuid);
|
||||
Assert.Equal(5, entry.Quantity);
|
||||
Assert.False(list.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddingTheSameGuidTwiceUpsertsRatherThanDuplicating()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
|
||||
list.Add(ItemA, 5);
|
||||
list.Add(ItemA, 20);
|
||||
|
||||
VendorStagingEntry entry = Assert.Single(list.Entries);
|
||||
Assert.Equal(20, entry.Quantity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 5)]
|
||||
[InlineData(ItemA, 0)]
|
||||
[InlineData(ItemA, -1)]
|
||||
public void AddIgnoresAZeroGuidOrNonPositiveQuantity(uint guid, int quantity)
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
|
||||
list.Add(guid, quantity);
|
||||
|
||||
Assert.True(list.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFiresOnAddAndNotOnANoOpAdd()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
int fired = 0;
|
||||
list.Changed += () => fired++;
|
||||
|
||||
list.Add(ItemA, 5);
|
||||
Assert.Equal(1, fired);
|
||||
|
||||
list.Add(0u, 5); // no-op: zero guid
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
|
||||
// ---- Remove: retail's amount==-1 (0xffffffff) "full removal" sentinel ----
|
||||
|
||||
[Fact]
|
||||
public void RemoveWithNegativeOneAmountRemovesTheWholeEntryRegardlessOfQuantity()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 100);
|
||||
|
||||
bool removed = list.Remove(ItemA, -1);
|
||||
|
||||
Assert.True(removed);
|
||||
Assert.True(list.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveWithAnAmountAtOrAboveTheStagedQuantityRemovesTheWholeEntry()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 5);
|
||||
|
||||
Assert.True(list.Remove(ItemA, 5));
|
||||
|
||||
Assert.True(list.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveWithAPartialAmountDecrementsInPlace()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 10);
|
||||
|
||||
Assert.True(list.Remove(ItemA, 3));
|
||||
|
||||
VendorStagingEntry entry = Assert.Single(list.Entries);
|
||||
Assert.Equal(ItemA, entry.ItemGuid);
|
||||
Assert.Equal(7, entry.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveOfAnUnstagedGuidIsANoOp()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 5);
|
||||
|
||||
bool removed = list.Remove(ItemB, -1);
|
||||
|
||||
Assert.False(removed);
|
||||
Assert.Single(list.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveOnlyTouchesTheMatchingEntry()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 5);
|
||||
list.Add(ItemB, 9);
|
||||
|
||||
list.Remove(ItemA, -1);
|
||||
|
||||
VendorStagingEntry remaining = Assert.Single(list.Entries);
|
||||
Assert.Equal(ItemB, remaining.ItemGuid);
|
||||
Assert.Equal(9, remaining.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetFindsAStagedEntryByGuid()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 5);
|
||||
|
||||
Assert.True(list.TryGet(ItemA, out VendorStagingEntry entry));
|
||||
Assert.Equal(5, entry.Quantity);
|
||||
Assert.False(list.TryGet(ItemB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearRemovesEveryEntryAndFiresChangedOnce()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
list.Add(ItemA, 5);
|
||||
list.Add(ItemB, 9);
|
||||
int fired = 0;
|
||||
list.Changed += () => fired++;
|
||||
|
||||
list.Clear();
|
||||
|
||||
Assert.True(list.IsEmpty);
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearOnAnAlreadyEmptyListDoesNotFireChanged()
|
||||
{
|
||||
var list = new VendorStagingList();
|
||||
int fired = 0;
|
||||
list.Changed += () => fired++;
|
||||
|
||||
list.Clear();
|
||||
|
||||
Assert.Equal(0, fired);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue