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>
186 lines
7.0 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|