Files
qtalker---/frontend/src/components/JudgmentPanel.test.tsx
Indiana a52a781880 feat(frontend): ritual mini-game, judgment UI, and consequences (Workstream E)
Implements the Character Depth / Ghost Log spec's Workstream E:

- RitualPanel.tsx: a 4-step "focus the channel" hold-to-charge sequence
  (align/breathe/trace/lock), sending ritual_step frames as the seeker
  progresses. Success reveals the entity's true hidden traits; failure
  reveals nothing. Sequencing/timing logic lives in the pure, unit-tested
  lib/ritual.ts rather than inline in the component.
- JudgmentPanel.tsx: Trust/Banish/Cross Over/Test verdict buttons with
  rune-style SVG icons, sending the judgment frame and rendering a
  distinct treatment per judgment_result consequence — reward,
  escalation (also spikes the ambient haunting), withdrawal, resisted,
  neutral, and a calm glyph-fade farewell for crossed_over (deliberately
  not the reward treatment, since it's a goodbye).
- lib/haunting.ts: IdleEscalator gains forceEscalate()/forcedUntil so a
  judgment's "escalation" consequence can spike the ambient haunting
  immediately instead of waiting on the 90s idle clock; exports a
  sharedIdleEscalator singleton and a forceEscalate() free function.
  HauntingLayer now paces itself off that shared instance instead of a
  private one, so the forced spike actually reaches the running layer.
- state/seance.tsx: new ritual/judgmentResult state, a local_ritual_start
  action, and reducer cases for the ritual_complete/judgment_result server
  frames; SeanceContext exported for component testing; startRitual/
  sendRitualStep/sendJudgment added to the provider API.
- lib/types.ts: EntityTraits/JudgmentVerdict/JudgmentConsequence types and
  the new client/server WS frames, per the spec's Contract section.
- i18n: seance.ritual.* / seance.judgment.* keys in en.json and es.json.

Fast-forwarded this worktree's branch onto master first — it had been
created from a stale ancestor commit predating the frontend scaffold
entirely, with zero commits of its own ahead of that point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:29:29 +00:00

186 lines
7.0 KiB
TypeScript

import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { JudgmentPanel } from './JudgmentPanel'
import { SeanceContext, initialSeanceState } from '../state/seance'
import type { JudgmentResult, SeanceApi, SeanceState } from '../state/seance'
import type { SpiritEntity } from '../lib/types'
import * as haunting from '../lib/haunting'
const entity: SpiritEntity = {
id: 'e-1',
name: 'Mirela',
epithet: 'the Drowned',
persona: 'a soft-spoken drowned girl',
rarity: 'rare',
voice: { voice_id: 'v-1', pitch: 0.5, rate: 0.5, noise: 0.3, echo: 0.4 },
visual: { hue: 200, form: 'wisp' },
quotes: [],
contact_count: 1,
discovered_at: '2026-01-01T00:00:00.000Z',
}
function renderPanel(stateOverrides: Partial<SeanceState> = {}) {
const sendJudgment = vi.fn()
const state: SeanceState = { ...initialSeanceState, entity, ...stateOverrides }
const api = { state, sendJudgment } as unknown as SeanceApi
const utils = render(
<SeanceContext.Provider value={api}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
return { ...utils, sendJudgment }
}
function result(overrides: Partial<JudgmentResult> = {}): JudgmentResult {
return {
correct: false,
favorDelta: 0,
essenceDelta: 0,
atPeace: false,
consequence: 'neutral',
at: 1,
...overrides,
}
}
describe('JudgmentPanel', () => {
it('shows an empty state with no summoned entity, and no buttons', () => {
renderPanel({ entity: null })
expect(screen.getByText(/no presence to judge/i)).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
})
it.each([
['trust', /^trust$/i],
['banish', /^banish$/i],
['cross_over', /cross over/i],
['test', /^test$/i],
] as const)('sends a judgment frame with verdict %s on click', (verdict, label) => {
const { sendJudgment } = renderPanel()
fireEvent.click(screen.getByRole('button', { name: label }))
expect(sendJudgment).toHaveBeenCalledTimes(1)
expect(sendJudgment).toHaveBeenCalledWith(verdict)
})
it('disables the verdict buttons while a judgment is pending', () => {
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /^trust$/i }))
for (const btn of screen.getAllByRole('button')) {
expect(btn).toBeDisabled()
}
expect(screen.getByText(/awaiting the verdict/i)).toBeTruthy()
})
it('ignores further clicks while a judgment is already pending', () => {
const { sendJudgment } = renderPanel()
fireEvent.click(screen.getByRole('button', { name: /^trust$/i }))
fireEvent.click(screen.getByRole('button', { name: /^banish$/i }))
expect(sendJudgment).toHaveBeenCalledTimes(1)
})
it('renders a reward treatment for a correct trust', () => {
renderPanel({ judgmentResult: result({ correct: true, favorDelta: 0.05, essenceDelta: 15, consequence: 'reward' }) })
expect(screen.getByText(/bond holds/i)).toBeTruthy()
expect(screen.getByText(/\+0\.05/)).toBeTruthy()
expect(screen.getByText(/\+15/)).toBeTruthy()
})
it('renders a distinct withdrawal treatment for a wrong banish', () => {
renderPanel({ judgmentResult: result({ favorDelta: -0.06, consequence: 'withdrawal' }) })
expect(screen.getByText(/recoils/i)).toBeTruthy()
})
it('renders a resisted treatment for cross_over on a demon', () => {
renderPanel({ judgmentResult: result({ consequence: 'resisted' }) })
expect(screen.getByText(/will not go/i)).toBeTruthy()
})
it('renders a neutral treatment for a test verdict', () => {
renderPanel({ judgmentResult: result({ consequence: 'neutral' }) })
expect(screen.getByText(/no verdict rendered/i)).toBeTruthy()
})
it('renders a distinct calm farewell (not the reward treatment) for crossed_over, with the entity glyph', () => {
const { container } = renderPanel({
judgmentResult: result({ correct: true, atPeace: true, essenceDelta: 40, consequence: 'crossed_over' }),
})
expect(screen.getByText(/at peace/i)).toBeTruthy()
expect(screen.queryByText(/bond holds/i)).toBeNull() // not the reward copy
expect(container.querySelector('.judgment-farewell')).not.toBeNull()
expect(container.querySelector('.farewell-glyph')).not.toBeNull()
expect(container.querySelector('.consequence-reward')).toBeNull()
})
// A judgment_result always *arrives* after mount (the reducer starts
// with judgmentResult: null and updates it once the server answers) —
// simulate that transition with a rerender rather than mounting with
// the result already present, matching how SeanceProvider actually
// drives this component.
function renderThenDeliver(consequence: JudgmentResult['consequence'], overrides: Partial<JudgmentResult> = {}) {
const sendJudgment = vi.fn()
const state: SeanceState = { ...initialSeanceState, entity, judgmentResult: null }
const api = { state, sendJudgment } as unknown as SeanceApi
const { rerender } = render(
<SeanceContext.Provider value={api}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
const nextApi = { ...api, state: { ...state, judgmentResult: result({ consequence, ...overrides }) } }
rerender(
<SeanceContext.Provider value={nextApi}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
}
it('calls forceEscalate once judgment_result arrives with consequence: escalation', () => {
const spy = vi.spyOn(haunting, 'forceEscalate').mockImplementation(() => undefined)
renderThenDeliver('escalation', { favorDelta: -0.12 })
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()
})
it('does not call forceEscalate for any other consequence', () => {
const spy = vi.spyOn(haunting, 'forceEscalate').mockImplementation(() => undefined)
renderThenDeliver('reward')
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
})
describe('JudgmentPanel — pending clears on a fresh result', () => {
beforeEach(() => {
vi.spyOn(haunting, 'forceEscalate').mockImplementation(() => undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('clears the pending state once judgmentResult changes (rerender simulates the reducer answering)', () => {
const sendJudgment = vi.fn()
const state: SeanceState = { ...initialSeanceState, entity, judgmentResult: null }
const api = { state, sendJudgment } as unknown as SeanceApi
const { rerender } = render(
<SeanceContext.Provider value={api}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
fireEvent.click(screen.getByRole('button', { name: /^test$/i }))
expect(screen.getByText(/awaiting the verdict/i)).toBeTruthy()
const nextApi = { ...api, state: { ...state, judgmentResult: result({ consequence: 'neutral' }) } }
rerender(
<SeanceContext.Provider value={nextApi}>
<JudgmentPanel />
</SeanceContext.Provider>,
)
expect(screen.queryByText(/awaiting the verdict/i)).toBeNull()
for (const btn of screen.getAllByRole('button')) {
expect(btn).not.toBeDisabled()
}
})
})