Files
qtalker---/frontend/src/components/GhostLog.test.tsx
Indiana e4a834620f feat: Ghost Log HUD + evil meter + tells (Workstream D)
App-shell HUD (mounted in App.tsx alongside HauntingLayer, visible on
every screen) that shows ambient idle status until a séance is active,
then streams `tell` WS frames as terminal-style log lines with a
"hacker witch" crescent-arc evil-meter gauge (occult sigils/runes fused
with Transcript.tsx's monospace log vocabulary).

- lib/evilMeter.ts: pure function computing a malevolent<->benevolent
  belief from the accumulated tell history — starts wide/uncertain,
  narrows geometrically and shifts per tell (deterministic hash of the
  tell text, since tells never leak ground truth), and snaps to
  definitive certainty on a successful ritual_complete's
  revealed.alignment. Fully unit tested (narrowing, ordering,
  determinism, ritual override, idle/empty history).
- lib/ghostLogBus.ts: tiny typed event bus (mirrors lib/haunting.ts's
  HauntBus) so the app-shell-level GhostLog can react to live séance
  frames — SeanceProvider is only mounted inside the séance route, so a
  shell-level sibling can't read its context directly.
- state/seance.tsx: publish entity/tell/ritual_complete onto the bus,
  and session_end on provider teardown so the HUD falls back to idle
  when the seeker leaves the séance page.
- lib/types.ts: add the `tell` and `ritual_complete` server frames from
  the Character Depth spec's Contract section (only what this
  workstream consumes).
- components/GhostLog.tsx/.css: the HUD itself, plus i18n keys in
  en.json/es.json.

168/168 frontend tests pass (137 pre-existing + 20 evilMeter + 11
GhostLog); tsc -b and the i18n coverage pretest are clean.

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

140 lines
5.2 KiB
TypeScript

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 <SeanceProvider> does in
// production: by emitting frames onto the bus and letting React flush.
function emit<K extends keyof GhostLogEventMap>(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(<GhostLog />)
expect(
screen.getByText('the veil is quiet — no presence detected'),
).toBeTruthy()
})
it('renders no evil-meter gauge while idle', () => {
render(<GhostLog />)
expect(screen.queryByTestId('evil-meter')).toBeNull()
})
it('carries the idle class on the root element', () => {
const { container } = render(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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(<GhostLog />)
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()
})
})