Files
qtalker---/frontend/src/components/SigilDesigner.test.tsx
Indiana ebe6a4b0b8 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>
2026-07-23 16:26:18 +00:00

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')
})
})