feat(frontend): unlocks/inventory UI, sigil designer, listening-tool EVP threshold
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>
This commit is contained in:
136
frontend/src/components/InventoryPanel.test.tsx
Normal file
136
frontend/src/components/InventoryPanel.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user