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>
135 lines
4.7 KiB
TypeScript
135 lines
4.7 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 { 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')
|
|
})
|
|
})
|