Workstream F of the character-depth/ghost-log spec.
- InventoryPanel.tsx: essence balance, owned unlocks/items (terminal
listing + Codex-style rarity-glow borders on items), and a buy flow
for a small fixed unlock catalog (currently just "listening_tool",
the only key the contract names) with afford/can't-afford button
states. Prices are fetched from a best-guess /api/inventory/catalog
endpoint and fall back to a flagged "(est.)" price sourced from the
spec's own worked example when that endpoint isn't available yet —
the contract doesn't define a price-list REST shape.
- SigilDesigner.tsx + lib/sigil.ts: constrained geometric builder —
points snap to 24 fixed clock-face slots around a circle, capped at
12 to match the backend's payload limit, connected in placement
order. Five hand-drawn stroke-only rune glyphs (eye/crescent/key/
spiral/thorn) overlay the center. Point-cap enforcement, rune
selection and payload-shape building are pure functions in
lib/sigil.ts, unit-tested directly. Saves via POST
/api/inventory/sigils (path inferred; payload shape matches the
contract exactly: {"points": [[x,y],...], "rune": str}). Art
direction: the builder itself reads like a plotting/debug tool
(crosshair cursor, monospace coordinate HUD) while the rendered
lines + rune glow violet, consistent with GhostGlyph's conventions.
- lib/evp.ts: new evpThresholdDb(hasListeningTool) pure function and
EvpListener.start() now accepts { hasListeningTool } to lower the
EVP anomaly threshold (8dB -> 4dB) when the unlock is owned — wired
from useAuth().user.unlocks in SeancePage's EvpPanel, a real
gameplay effect on which faint signals register as anomalies.
- api.ts User type gains unlocks/essence per the contract's /auth/me
extension; new InventoryPage.tsx mounts both components behind auth
at /inventory, linked from the séance nav.
166/166 tests pass (137 pre-existing + 29 new), i18n coverage check
clean, tsc -b clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
137 lines
5.3 KiB
TypeScript
137 lines
5.3 KiB
TypeScript
import { render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import '../i18n'
|
|
import { InventoryPanel } from './InventoryPanel'
|
|
|
|
function mockFetch(handlers: {
|
|
items?: unknown
|
|
itemsStatus?: number
|
|
catalog?: unknown
|
|
catalogStatus?: number
|
|
purchaseStatus?: number
|
|
}) {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input)
|
|
if (url.includes('/api/inventory/items')) {
|
|
return new Response(JSON.stringify(handlers.items ?? { items: [] }), {
|
|
status: handlers.itemsStatus ?? 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
if (url.includes('/api/inventory/catalog')) {
|
|
if (handlers.catalogStatus && handlers.catalogStatus !== 200) {
|
|
return new Response('not found', { status: handlers.catalogStatus })
|
|
}
|
|
return new Response(JSON.stringify(handlers.catalog ?? { unlocks: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
if (url.includes('/api/inventory/unlocks/') && init?.method === 'POST') {
|
|
return new Response('{}', { status: handlers.purchaseStatus ?? 200 })
|
|
}
|
|
return new Response('not found', { status: 404 })
|
|
}),
|
|
)
|
|
}
|
|
|
|
describe('InventoryPanel', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('shows the essence balance prominently', async () => {
|
|
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
|
|
render(<InventoryPanel unlocks={[]} essence={128} />)
|
|
expect(screen.getByTestId('essence-value').textContent).toBe('128')
|
|
await screen.findByRole('button', { name: /buy/i }) // let pending fetches settle
|
|
})
|
|
|
|
it('lists owned unlocks with an [OWNED] tag and hides them from the buy catalog', async () => {
|
|
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
|
|
render(<InventoryPanel unlocks={['listening_tool']} essence={5} />)
|
|
expect(await screen.findByText('listening_tool')).toBeTruthy()
|
|
expect(screen.getByText('[OWNED]')).toBeTruthy()
|
|
expect(screen.queryByRole('button', { name: /buy/i })).toBeNull()
|
|
})
|
|
|
|
it('disables the buy button when the user cannot afford the unlock', async () => {
|
|
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
|
|
render(<InventoryPanel unlocks={[]} essence={10} />)
|
|
const btn = await screen.findByRole('button', { name: /buy/i })
|
|
await waitFor(() => expect(btn).toHaveProperty('disabled', true))
|
|
})
|
|
|
|
it('enables the buy button when the user can afford the unlock', async () => {
|
|
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
|
|
render(<InventoryPanel unlocks={[]} essence={100} />)
|
|
const btn = await screen.findByRole('button', { name: /buy/i })
|
|
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
|
|
})
|
|
|
|
it('falls back to a flagged estimated price when the catalog endpoint is unavailable', async () => {
|
|
mockFetch({ catalogStatus: 404 })
|
|
render(<InventoryPanel unlocks={[]} essence={100} />)
|
|
await waitFor(() => expect(screen.getByText(/\(est\.\)/)).toBeTruthy())
|
|
const btn = screen.getByRole('button', { name: /buy/i })
|
|
expect(btn).toHaveProperty('disabled', false) // 100 essence affords the 40 fallback price
|
|
})
|
|
|
|
it('purchasing successfully moves the unlock into the owned list and deducts essence', async () => {
|
|
mockFetch({
|
|
catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] },
|
|
purchaseStatus: 200,
|
|
})
|
|
const user = userEvent.setup()
|
|
render(<InventoryPanel unlocks={[]} essence={100} />)
|
|
const btn = await screen.findByRole('button', { name: /buy/i })
|
|
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
|
|
await user.click(btn)
|
|
|
|
await waitFor(() => expect(screen.getByTestId('essence-value').textContent).toBe('60'))
|
|
expect(screen.getByText('[OWNED]')).toBeTruthy()
|
|
expect(screen.queryByRole('button', { name: /buy/i })).toBeNull()
|
|
})
|
|
|
|
it('shows an insufficient-funds message on a 402 without changing essence', async () => {
|
|
mockFetch({
|
|
catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] },
|
|
purchaseStatus: 402,
|
|
})
|
|
const user = userEvent.setup()
|
|
render(<InventoryPanel unlocks={[]} essence={100} />)
|
|
const btn = await screen.findByRole('button', { name: /buy/i })
|
|
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
|
|
await user.click(btn)
|
|
|
|
await screen.findByRole('alert')
|
|
expect(screen.getByTestId('essence-value').textContent).toBe('100')
|
|
})
|
|
|
|
it('renders items with a rarity data attribute when payload carries a known rarity', async () => {
|
|
mockFetch({
|
|
items: {
|
|
items: [
|
|
{
|
|
item_type: 'trinket',
|
|
item_key: 'silver-locket',
|
|
payload: { rarity: 'rare' },
|
|
obtained_at: '2026-01-01T00:00:00Z',
|
|
},
|
|
],
|
|
},
|
|
})
|
|
render(<InventoryPanel unlocks={[]} essence={0} />)
|
|
const key = await screen.findByText('silver-locket')
|
|
const card = key.closest('[data-rarity]')
|
|
expect(card?.getAttribute('data-rarity')).toBe('rare')
|
|
})
|
|
})
|