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>
140 lines
5.2 KiB
TypeScript
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()
|
|
})
|
|
})
|