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 = {}) { const sendJudgment = vi.fn() const state: SeanceState = { ...initialSeanceState, entity, ...stateOverrides } const api = { state, sendJudgment } as unknown as SeanceApi const utils = render( , ) return { ...utils, sendJudgment } } function result(overrides: Partial = {}): 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 = {}) { const sendJudgment = vi.fn() const state: SeanceState = { ...initialSeanceState, entity, judgmentResult: null } const api = { state, sendJudgment } as unknown as SeanceApi const { rerender } = render( , ) const nextApi = { ...api, state: { ...state, judgmentResult: result({ consequence, ...overrides }) } } rerender( , ) } 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( , ) 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( , ) expect(screen.queryByText(/awaiting the verdict/i)).toBeNull() for (const btn of screen.getAllByRole('button')) { expect(btn).not.toBeDisabled() } }) })