/* The transcript, and the state panel it sits beside.
*
* The transcript assertions are mostly about what is *not* there: no turn ids,
* no branch words, no per-message delete, and no unnamed glyph buttons. The
* baseline measured sixteen of the last on a two-turn story before M8.
*/
import { screen, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../../api'
import { StatePanel } from './panels/StatePanel'
import { Transcript } from './Transcript'
import { narratorAction, playerAction, renderWith } from '../../test/helpers'
function transcript(props = {}) {
return renderWith(
,
)
}
beforeEach(() => { vi.restoreAllMocks() })
describe('transcript presentation (§6, §83, §84)', () => {
it('shows the story, and distinguishes the player from the narrator', async () => {
const { container } = await transcript()
expect(screen.getByText('I walk into the tavern.')).toBeInTheDocument()
expect(screen.getByText('The tavern is warm and loud.')).toBeInTheDocument()
expect(container.querySelectorAll('.turn-player')).toHaveLength(1)
expect(container.querySelectorAll('.turn-narrator')).toHaveLength(1)
})
it('shows no turn ids or implementation metadata (§84)', async () => {
const { container } = await transcript()
const text = container.textContent
expect(text).not.toMatch(/\bid\b/i)
expect(text).not.toMatch(/depth/i)
expect(text).not.toMatch(/branch_id/)
})
it('uses no branch vocabulary (§27, §64)', async () => {
const { container } = await transcript()
const text = container.textContent.toLowerCase()
for (const word of ['branch', 'fork', 'node', 'merge', 'head']) {
expect(text).not.toContain(word)
}
})
it('offers no per-message delete', async () => {
await transcript()
expect(screen.queryByRole('button', { name: /delete/i })).toBeNull()
})
it('gives every control a real accessible name, not a glyph', async () => {
const { container } = await transcript()
const buttons = Array.from(container.querySelectorAll('.turn-tools button'))
expect(buttons.length).toBeGreaterThan(0)
for (const button of buttons) {
const name = (button.getAttribute('aria-label') || button.textContent).trim()
// A one- or two-character name is a glyph, which is what the baseline had.
expect(name.length).toBeGreaterThan(2)
}
})
it('offers Inspect context on a narrator turn only', async () => {
const { container } = await transcript()
const narrator = container.querySelector('.turn-narrator')
const player = container.querySelector('.turn-player')
expect(within(narrator).getByRole('button', { name: 'Inspect context' })).toBeInTheDocument()
expect(within(player).queryByRole('button', { name: 'Inspect context' })).toBeNull()
})
it('names the retry control for what it does, on each kind of turn', async () => {
const { container } = await transcript()
expect(within(container.querySelector('.turn-narrator'))
.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
expect(within(container.querySelector('.turn-player'))
.getByRole('button', { name: 'Say this differently' })).toBeInTheDocument()
})
it('renders narrator Markdown safely (§76, H06)', async () => {
const { container } = await transcript({
actions: [narratorAction({ text: '## A heading\n\n- one\n\n' })],
})
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('.md-heading')).toHaveTextContent('A heading')
expect(container.querySelector('.md-list li')).toHaveTextContent('one')
expect(container.textContent).toContain('')
})
it('announces itself as a log for a screen reader (§80)', async () => {
await transcript()
expect(screen.getByRole('log', { name: 'Story transcript' })).toBeInTheDocument()
})
it('says Narrating while waiting for the first token (§16)', async () => {
// Scoped to the transcript's own indicator: the toast host also publishes a
// status region, and matching on the role alone would find both.
const { container } = await transcript({ waitingForFirstToken: true, streaming: '' })
const indicator = container.querySelector('.thinking')
expect(indicator).toHaveAttribute('role', 'status')
expect(indicator).toHaveTextContent('Narrating…')
})
it('does not show the storage marker, or turn it into a blockquote', async () => {
// A player action is stored as "> I walk into the tavern." — the marker is
// what tells the prompt whose turn it is. `>` is also Markdown for a
// blockquote, so leaving it in rendered every player turn as a quote by
// accident, stacking the renderer's rule on the one .turn-player draws.
const { container } = await transcript({
actions: [playerAction({ text: '> I walk into the tavern.' })],
firstNarrationId: null,
})
const player = container.querySelector('.turn-player')
expect(player).toHaveTextContent('I walk into the tavern.')
expect(player.textContent).not.toContain('>')
expect(player.querySelector('blockquote')).toBeNull()
})
it('leaves a narrator blockquote alone', async () => {
const { container } = await transcript({
actions: [narratorAction({ text: '> She said nothing.' })],
})
expect(container.querySelector('.turn-narrator blockquote')).toHaveTextContent(
'She said nothing.')
})
it('invites a first line when the story is empty', async () => {
await transcript({ actions: [], firstNarrationId: null, total: 0 })
expect(screen.getByText(/A blank page/)).toBeInTheDocument()
})
})
describe('state panel (§18, §31)', () => {
const STATE = {
empty: false,
groups: [
{
title: 'Characters',
rows: [{ key: 'mara', label: 'Mara', detail: 'at the tavern' }],
},
{
title: 'Important Facts',
rows: [{ key: 'f1', label: 'The key bears a broken circle', detail: '' }],
},
],
document: {},
}
it('renders the story concepts the server grouped, not raw JSON', async () => {
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
const { container } = await renderWith(
,
)
expect(screen.getByText('Characters')).toBeInTheDocument()
expect(screen.getByText('Mara')).toBeInTheDocument()
expect(screen.getByText('Important Facts')).toBeInTheDocument()
// No JSON, and nothing a reader would have to parse.
expect(container.querySelector('pre')).toBeNull()
expect(container.textContent).not.toContain('{')
})
it('uses no RPG vocabulary of its own (§18)', async () => {
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
const { container } = await renderWith(
,
)
const text = container.textContent.toLowerCase()
for (const word of ['hp', 'mana', 'quest', 'stat', 'cooldown', 'xp']) {
expect(text).not.toContain(word)
}
})
it('offers a correction route that is not raw JSON (§20, §30)', async () => {
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
await renderWith(
,
)
expect(screen.getByRole('button', { name: 'Correct something' })).toBeInTheDocument()
})
it('says plainly when the story has established nothing yet', async () => {
vi.spyOn(api, 'getNarrativeState').mockResolvedValue({
empty: true, groups: [], document: {},
})
await renderWith(
,
)
expect(screen.getByText(/Nothing established yet/)).toBeInTheDocument()
})
})