/* 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() }) })