import { act, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import '../i18n' import { GhostLog } from './GhostLog' import { ghostLogBus } from '../lib/ghostLogBus' import type { GhostLogEventMap } from '../lib/ghostLogBus' // GhostLog subscribes to the shared, module-level ghostLogBus (it's mounted // at the app-shell level with no props — see the component's header // comment for why). Tests drive it the same way does in // production: by emitting frames onto the bus and letting React flush. function emit(type: K, detail: GhostLogEventMap[K]) { act(() => { ghostLogBus.emit(type, detail) }) } describe('GhostLog — idle state', () => { afterEach(() => { emit('session_end', {}) }) it('shows ambient status text with no active entity/session', () => { render() expect( screen.getByText('the veil is quiet — no presence detected'), ).toBeTruthy() }) it('renders no evil-meter gauge while idle', () => { render() expect(screen.queryByTestId('evil-meter')).toBeNull() }) it('carries the idle class on the root element', () => { const { container } = render() expect(container.querySelector('.ghost-log--idle')).not.toBeNull() expect(container.querySelector('.ghost-log--active')).toBeNull() }) }) describe('GhostLog — active session with tells', () => { afterEach(() => { emit('session_end', {}) }) it('switches out of idle once an entity frame arrives', () => { render() emit('entity', { name: 'The Hollow Choir' }) expect(screen.queryByText('the veil is quiet — no presence detected')).toBeNull() expect(screen.getByText('The Hollow Choir')).toBeTruthy() }) it('shows a waiting line before any tells arrive', () => { render() emit('entity', { name: 'The Hollow Choir' }) expect(screen.getByText('listening for tells…')).toBeTruthy() }) it('streams tell frames in as log lines, most recent last (Transcript convention)', () => { render() emit('entity', { name: 'The Hollow Choir' }) emit('tell', { text: 'the entity avoided a direct question' }) emit('tell', { text: 'a cold draft crossed the room' }) expect(screen.getByText('the entity avoided a direct question')).toBeTruthy() expect(screen.getByText('a cold draft crossed the room')).toBeTruthy() const lines = Array.from(document.querySelectorAll('.ghost-log-line .ghost-log-text')).map( (el) => el.textContent, ) // entity name row + two tell lines, tells in arrival order expect(lines).toEqual([ 'The Hollow Choir', 'the entity avoided a direct question', 'a cold draft crossed the room', ]) }) it('renders an evil-meter gauge once active, reflecting the tell history', () => { render() emit('entity', { name: 'The Hollow Choir' }) emit('tell', { text: 'the entity avoided a direct question' }) const gauge = screen.getByTestId('evil-meter') expect(gauge).toBeTruthy() const meterEl = gauge.querySelector('[role="meter"]') expect(meterEl).not.toBeNull() expect(meterEl?.getAttribute('aria-valuemin')).toBe('0') expect(meterEl?.getAttribute('aria-valuemax')).toBe('1') // starts near the uncertain midpoint before any tells narrow it expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeGreaterThanOrEqual(0) expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(1) }) it('snaps the gauge to certainty on a successful ritual_complete', () => { render() emit('entity', { name: 'The Hollow Choir' }) emit('tell', { text: 'the entity avoided a direct question' }) emit('ritual_complete', { success: true, revealed: { alignment: 0.92 } }) const gauge = screen.getByTestId('evil-meter') expect(gauge.className).toContain('evil-meter--certain') const meterEl = gauge.querySelector('[role="meter"]') expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeCloseTo(0.92, 3) }) it('does not snap to certainty on a failed ritual_complete', () => { render() emit('entity', { name: 'The Hollow Choir' }) emit('ritual_complete', { success: false, revealed: null }) const gauge = screen.getByTestId('evil-meter') expect(gauge.className).not.toContain('evil-meter--certain') }) it('resets back to idle on session_end (leaving the séance)', () => { render() emit('entity', { name: 'The Hollow Choir' }) emit('tell', { text: 'a cold draft crossed the room' }) emit('session_end', {}) expect( screen.getByText('the veil is quiet — no presence detected'), ).toBeTruthy() expect(screen.queryByText('a cold draft crossed the room')).toBeNull() }) it('starts a fresh log when a new entity frame supersedes the old one', () => { render() emit('entity', { name: 'The Hollow Choir' }) emit('tell', { text: 'a cold draft crossed the room' }) emit('entity', { name: 'The Weeping Usher' }) expect(screen.getByText('The Weeping Usher')).toBeTruthy() expect(screen.queryByText('a cold draft crossed the room')).toBeNull() }) })