docs
This commit is contained in:
362
apps/web/src/app/features/npc/merchant.store.spec.ts
Normal file
362
apps/web/src/app/features/npc/merchant.store.spec.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import type {
|
||||
ExchangeResult,
|
||||
ExchangeView,
|
||||
NpcInteraction,
|
||||
ShopView,
|
||||
} from '../../core/api/game-api.models';
|
||||
import { GameApiService } from '../../core/api/game-api.service';
|
||||
import { MerchantStore } from './merchant.store';
|
||||
|
||||
function interaction(
|
||||
actionTypes: Array<'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE'> = [
|
||||
'TALK',
|
||||
'OPEN_SHOP',
|
||||
'OPEN_EXCHANGE',
|
||||
],
|
||||
): NpcInteraction {
|
||||
return {
|
||||
npc: {
|
||||
id: 'npc-1',
|
||||
key: 'borin-quartermaster',
|
||||
name: 'Borin',
|
||||
title: 'Quartermaster of the Border Watch',
|
||||
description: 'A broad, grey-bearded man.',
|
||||
portraitPath: '/images/npcs/borin.png',
|
||||
artworkPath: null,
|
||||
capabilities: ['DIALOGUE', 'MERCHANT', 'RESOURCE_EXCHANGE'],
|
||||
},
|
||||
dialogue: { key: 'borin-default', text: 'Show me what you have.', responses: [] },
|
||||
availableActions: actionTypes.map((type) => ({
|
||||
type,
|
||||
label: type,
|
||||
key: type === 'TALK' ? null : 'some-key',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function exchangeView(overrides: Partial<ExchangeView> = {}): ExchangeView {
|
||||
return {
|
||||
profileKey: 'borin-trade-in',
|
||||
profileName: 'Border Watch Trade-In',
|
||||
npcKey: 'borin-quartermaster',
|
||||
offers: [
|
||||
{
|
||||
itemKey: 'ash-pelt',
|
||||
itemName: 'Ashen Pelt',
|
||||
iconPath: '/images/items/ash-pelt.png',
|
||||
quantityCarried: 8,
|
||||
inputQuantity: 1,
|
||||
silverPerStep: 5,
|
||||
reputationPerStep: 2,
|
||||
factionKey: 'border-guard',
|
||||
factionName: 'Border Watch',
|
||||
renownMilestoneKey: 'first-goods-returned',
|
||||
},
|
||||
{
|
||||
itemKey: 'tough-hide',
|
||||
itemName: 'Tough Hide',
|
||||
iconPath: '/images/items/tough-hide.png',
|
||||
quantityCarried: 7,
|
||||
inputQuantity: 5,
|
||||
silverPerStep: 40,
|
||||
reputationPerStep: 10,
|
||||
factionKey: 'border-guard',
|
||||
factionName: 'Border Watch',
|
||||
renownMilestoneKey: null,
|
||||
},
|
||||
],
|
||||
capacities: [{ category: 'HIDE', current: 8, capacity: 5, bag: null }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function shopView(): ShopView {
|
||||
return {
|
||||
shopKey: 'borin-supplies',
|
||||
shopName: "Quartermaster's Supplies",
|
||||
npcKey: 'borin-quartermaster',
|
||||
silver: 100,
|
||||
offers: [
|
||||
{
|
||||
itemKey: 'small-healing-potion',
|
||||
itemName: 'Small Healing Potion',
|
||||
itemDescription: 'A bitter draught.',
|
||||
iconPath: '/images/items/potion.png',
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: 1,
|
||||
unlocked: true,
|
||||
affordable: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function tradeResult(): ExchangeResult {
|
||||
return {
|
||||
profileKey: 'borin-trade-in',
|
||||
consumed: [{ itemKey: 'ash-pelt', itemName: 'Ashen Pelt', quantity: 5 }],
|
||||
rewards: { silver: 25, regionalReputation: 10, worldRenown: 1 },
|
||||
balances: { silver: 25, regionalReputation: 10, worldRenown: 2 },
|
||||
reputationRankChanged: false,
|
||||
newReputationRank: null,
|
||||
renownMilestonesCompleted: ['first-goods-returned'],
|
||||
capacities: [{ category: 'HIDE', current: 3, capacity: 5, bag: null }],
|
||||
};
|
||||
}
|
||||
|
||||
function createApi(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
getNpcInteraction: vi.fn(() => of(interaction())),
|
||||
getTradeIn: vi.fn(() => of(exchangeView())),
|
||||
getShop: vi.fn(() => of(shopView())),
|
||||
tradeIn: vi.fn(() => of(tradeResult())),
|
||||
getCharacter: vi.fn(() =>
|
||||
of({
|
||||
id: 'character-1',
|
||||
name: 'Aric Duskwalker',
|
||||
renown: 2,
|
||||
silver: 25,
|
||||
currentHp: 119,
|
||||
maxHp: 119,
|
||||
}),
|
||||
),
|
||||
purchase: vi.fn(() =>
|
||||
of({
|
||||
shopKey: 'borin-supplies',
|
||||
itemKey: 'small-healing-potion',
|
||||
itemName: 'Small Healing Potion',
|
||||
quantity: 1,
|
||||
silverSpent: 12,
|
||||
silverBalance: 88,
|
||||
}),
|
||||
),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStore(api: ReturnType<typeof createApi>): MerchantStore {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: GameApiService, useValue: api }],
|
||||
});
|
||||
return TestBed.inject(MerchantStore);
|
||||
}
|
||||
|
||||
describe('MerchantStore', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('loads the NPC and the panels the server offered', async () => {
|
||||
const api = createApi();
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
expect(store.interaction()?.npc.name).toBe('Borin');
|
||||
expect(store.exchange()?.offers).toHaveLength(2);
|
||||
expect(store.shop()?.offers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not probe an endpoint the NPC did not offer', async () => {
|
||||
// A person with no shop should not have their shop fetched. The server
|
||||
// decides which interactions exist.
|
||||
const api = createApi({
|
||||
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'OPEN_EXCHANGE']))),
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
expect(api.getShop).not.toHaveBeenCalled();
|
||||
expect(store.shop()).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps a selection to what is actually carried', async () => {
|
||||
const store = createStore(createApi());
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('ash-pelt', 999);
|
||||
|
||||
expect(store.selection()['ash-pelt']).toBe(8);
|
||||
});
|
||||
|
||||
it('rounds a batch rule down to whole steps', async () => {
|
||||
// Tough Hide trades five at a time and seven are carried, so five is the
|
||||
// most that can be handed over -- never seven.
|
||||
const store = createStore(createApi());
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('tough-hide', 7);
|
||||
|
||||
expect(store.selection()['tough-hide']).toBe(5);
|
||||
});
|
||||
|
||||
it('never selects a partial batch', async () => {
|
||||
const store = createStore(createApi());
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('tough-hide', 4);
|
||||
|
||||
expect(store.selection()['tough-hide']).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a negative quantity', async () => {
|
||||
const store = createStore(createApi());
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('ash-pelt', -5);
|
||||
|
||||
expect(store.selection()['ash-pelt']).toBe(0);
|
||||
});
|
||||
|
||||
it('previews the payout the selection implies', async () => {
|
||||
const store = createStore(createApi());
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('ash-pelt', 4);
|
||||
store.setQuantity('tough-hide', 5);
|
||||
|
||||
// 4 pelts at 5 silver, plus one hide batch at 40.
|
||||
expect(store.preview()).toEqual({ silver: 60, reputation: 18 });
|
||||
});
|
||||
|
||||
it('selects everything tradeable, in whole steps only', async () => {
|
||||
const store = createStore(createApi());
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.selectAll();
|
||||
|
||||
expect(store.selection()).toEqual({ 'ash-pelt': 8, 'tough-hide': 5 });
|
||||
});
|
||||
|
||||
it('sends only keys and quantities, then re-reads from the server', async () => {
|
||||
const api = createApi();
|
||||
const store = createStore(api);
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('ash-pelt', 5);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(api.tradeIn).toHaveBeenCalledWith('borin-quartermaster', [
|
||||
{ itemKey: 'ash-pelt', quantity: 5 },
|
||||
]);
|
||||
// Carried goods, capacity and Silver all moved at once, so the view is
|
||||
// re-fetched rather than patched locally.
|
||||
expect(api.getTradeIn).toHaveBeenCalledTimes(2);
|
||||
expect(store.lastTrade()?.rewards.silver).toBe(25);
|
||||
expect(store.selection()).toEqual({});
|
||||
expect(store.actionError()).toBeNull();
|
||||
});
|
||||
|
||||
it('pushes the new Silver back to the shared character state', async () => {
|
||||
// The purse in the top bar reads from `WorldStore`. Without this the
|
||||
// player sells four pelts and watches their Silver stay put.
|
||||
const api = createApi();
|
||||
const store = createStore(api);
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('ash-pelt', 5);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(api.getCharacter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('will not trade with nothing selected', async () => {
|
||||
const api = createApi();
|
||||
const store = createStore(api);
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(api.tradeIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a rejected trade as a readable message and keeps the selection', async () => {
|
||||
const api = createApi({
|
||||
tradeIn: vi.fn(() =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 409,
|
||||
error: { code: 'EXCHANGE_INSUFFICIENT_QUANTITY' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
const store = createStore(api);
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('ash-pelt', 5);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(store.actionError()).toBe('You are not carrying that many.');
|
||||
expect(store.lastTrade()).toBeNull();
|
||||
expect(store.selection()['ash-pelt']).toBe(5);
|
||||
});
|
||||
|
||||
it('falls back to a generic message rather than leaking an unknown code', async () => {
|
||||
const api = createApi({
|
||||
tradeIn: vi.fn(() =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({ status: 500, error: { code: 'WAT' } }),
|
||||
),
|
||||
),
|
||||
});
|
||||
const store = createStore(api);
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
store.setQuantity('ash-pelt', 1);
|
||||
await store.tradeSelected();
|
||||
|
||||
expect(store.actionError()).toBe("That isn't possible right now.");
|
||||
});
|
||||
|
||||
it('reports being unable to reach the NPC', async () => {
|
||||
const api = createApi({
|
||||
getNpcInteraction: vi.fn(() =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 409,
|
||||
error: { code: 'NPC_UNAVAILABLE' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
const store = createStore(api);
|
||||
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
expect(store.error()).toBe('You are not where this person is.');
|
||||
expect(store.interaction()).toBeNull();
|
||||
});
|
||||
|
||||
it('refreshes the shop after buying, so the purse cannot go stale', async () => {
|
||||
const api = createApi();
|
||||
const store = createStore(api);
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
await store.buy('small-healing-potion');
|
||||
|
||||
expect(api.purchase).toHaveBeenCalledWith(
|
||||
'borin-quartermaster',
|
||||
'small-healing-potion',
|
||||
1,
|
||||
);
|
||||
expect(api.getShop).toHaveBeenCalledTimes(2);
|
||||
expect(store.lastPurchase()?.silverSpent).toBe(12);
|
||||
});
|
||||
|
||||
it('starts on the dialogue panel and switches on request', async () => {
|
||||
const store = createStore(createApi());
|
||||
await store.load('borin-quartermaster');
|
||||
|
||||
expect(store.panel()).toBe('DIALOGUE');
|
||||
store.showPanel('EXCHANGE');
|
||||
expect(store.panel()).toBe('EXCHANGE');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user