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:
134
frontend/src/components/SigilDesigner.test.tsx
Normal file
134
frontend/src/components/SigilDesigner.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
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 { SigilDesigner } from './SigilDesigner'
|
||||
import { RUNES, SIGIL_MAX_POINTS, SIGIL_SLOT_COUNT } from '../lib/sigil'
|
||||
|
||||
function anchorButtons() {
|
||||
return screen.getAllByRole('button', { name: /anchor point/i })
|
||||
}
|
||||
|
||||
describe('SigilDesigner', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders all 24 clock-face anchor slots and starts with zero points', () => {
|
||||
render(<SigilDesigner />)
|
||||
expect(anchorButtons()).toHaveLength(SIGIL_SLOT_COUNT)
|
||||
expect(screen.getByTestId('sigil-point-count').textContent).toBe(`0/${SIGIL_MAX_POINTS}`)
|
||||
})
|
||||
|
||||
it('adds a point on click, up to the 12-point cap, and refuses a 13th', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SigilDesigner />)
|
||||
const anchors = anchorButtons()
|
||||
|
||||
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
|
||||
await user.click(anchors[i])
|
||||
}
|
||||
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
|
||||
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
|
||||
)
|
||||
|
||||
// clicking a 13th, distinct, previously-unselected slot must be a no-op.
|
||||
await user.click(anchors[SIGIL_MAX_POINTS])
|
||||
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
|
||||
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
|
||||
)
|
||||
expect(anchors[SIGIL_MAX_POINTS]).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('toggling a placed point removes it, freeing a slot even while capped', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SigilDesigner />)
|
||||
const anchors = anchorButtons()
|
||||
|
||||
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
|
||||
await user.click(anchors[i])
|
||||
}
|
||||
await user.click(anchors[0]) // remove the first point
|
||||
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
|
||||
`${SIGIL_MAX_POINTS - 1}/${SIGIL_MAX_POINTS}`,
|
||||
)
|
||||
expect(anchors[0]).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await user.click(anchors[SIGIL_MAX_POINTS]) // now there's room
|
||||
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
|
||||
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('offers the fixed rune set and lets the seeker pick one', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SigilDesigner />)
|
||||
|
||||
// First rune defaults active.
|
||||
const firstBtn = screen.getByRole('button', { name: RUNES[0].label })
|
||||
expect(firstBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
const secondBtn = screen.getByRole('button', { name: RUNES[1].label })
|
||||
expect(secondBtn).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await user.click(secondBtn)
|
||||
expect(secondBtn).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(firstBtn).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('save is disabled with zero points, and posts the {points, rune} payload shape', async () => {
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 's1',
|
||||
name: 'untitled sigil',
|
||||
design: { points: [], rune: 'eye' },
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<SigilDesigner />)
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /inscribe sigil/i })
|
||||
expect(saveBtn).toHaveProperty('disabled', true)
|
||||
|
||||
const anchors = anchorButtons()
|
||||
await user.click(anchors[0])
|
||||
await user.click(anchors[6])
|
||||
expect(saveBtn).toHaveProperty('disabled', false)
|
||||
|
||||
await user.click(saveBtn)
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toBe('/api/inventory/sigils')
|
||||
expect(init?.method).toBe('POST')
|
||||
const body = JSON.parse(String(init?.body))
|
||||
expect(body.design.rune).toBe(RUNES[0].id)
|
||||
expect(Array.isArray(body.design.points)).toBe(true)
|
||||
expect(body.design.points).toHaveLength(2)
|
||||
for (const p of body.design.points) {
|
||||
expect(p).toHaveLength(2)
|
||||
expect(typeof p[0]).toBe('number')
|
||||
expect(typeof p[1]).toBe('number')
|
||||
}
|
||||
|
||||
await screen.findByText(/bound/i)
|
||||
})
|
||||
|
||||
it('shows an error state when saving fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('nope', { status: 500 })),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(<SigilDesigner />)
|
||||
await user.click(anchorButtons()[0])
|
||||
await user.click(screen.getByRole('button', { name: /inscribe sigil/i }))
|
||||
await screen.findByRole('alert')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user