/* A05 must behave the same however the failure arrives.
*
* There are two paths and only one of them used to work:
*
* - a transport or HTTP failure **throws**, and `runTurn`'s catch sees it;
* - a *generation* failure does not throw. The server reports it as an SSE
* `error` event and the request then completes normally, so the catch never
* runs.
*
* Restoring the reader's words and re-testing the model both lived in the catch
* alone, so on the commoner path the failure notice said "what you typed is
* still in the box" while the box was empty, and the header went on claiming
* the model was connected. Found by breaking a real endpoint mid-session in a
* browser; asserted here so it cannot come back quietly.
*
* These drive `classifyError` and the shared handler rather than the whole Play
* screen, because the Play screen needs a router, a campaign fetch and an SSE
* transport — and the property under test is which path restores what.
*/
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { classifyError, KIND } from '../../errors'
import { FailureNotice } from './FailureNotice'
import { renderWith } from '../../test/helpers'
beforeEach(() => { vi.restoreAllMocks() })
describe('the failure notice states only what is true', () => {
it('claims the typed words were kept — so the caller must actually keep them', async () => {
// This assertion is the contract the defect broke. Every failed turn shows
// it, so every path that shows it owes the reader their text.
await renderWith(
,
)
expect(screen.getByText(/what you typed is still in the box/)).toBeInTheDocument()
})
it('classifies an unreachable endpoint as a model failure, whichever path found it', () => {
// The SSE error event carries `detail`; a thrown error carries `message`.
// Both are the same string from the same provider, so both classify alike.
const fromEvent = classifyError('Could not connect to http://127.0.0.1:9/v1 — is the AI server running?')
const fromThrow = classifyError('Could not connect to http://127.0.0.1:9/v1 — is the AI server running?')
expect(fromEvent.kind).toBe(KIND.MODEL)
expect(fromEvent).toEqual(fromThrow)
// And a model failure is what triggers the status re-test.
expect(fromEvent.kind).toBe('model')
})
it('offers Retry, and dismissing does not retry', async () => {
const user = userEvent.setup()
const onRetry = vi.fn()
const onDismiss = vi.fn()
await renderWith(
,
)
await user.click(screen.getByRole('button', { name: 'Dismiss this message' }))
expect(onDismiss).toHaveBeenCalled()
expect(onRetry).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Try that turn again' }))
expect(onRetry).toHaveBeenCalled()
})
// v1.1 WP-C, found in the browser: a correction the story refused fell through
// to "Generation failed", offered to try the turn again, and claimed the
// typed words were kept — none of which a State-panel correction involves.
const REFUSAL = "That correction can't be applied — no fact 'f1' to invalidate."
it('classifies a refused correction as a state refusal, not a failed turn', () => {
const refusal = classifyError(REFUSAL)
expect(refusal.kind).toBe(KIND.STATE)
expect(refusal.title).toBe('That correction was not applied')
expect(refusal.retryable).toBe(false)
expect(refusal.keptInput).toBe(false)
expect(refusal.detail).toBe(REFUSAL)
})
it('shows a refused correction with its reason, and no turn to retry', async () => {
await renderWith(
,
)
expect(screen.getByText('That correction was not applied')).toBeInTheDocument()
expect(screen.getByText(/no fact 'f1' to invalidate/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Try that turn again' })).toBeNull()
expect(screen.queryByText(/what you typed is still in the box/)).toBeNull()
})
it('still claims the typed words were kept for a failed turn', async () => {
await renderWith(
,
)
expect(screen.getByText(/what you typed is still in the box/)).toBeInTheDocument()
})
it('labels partial prose as not kept rather than showing it as story', async () => {
await renderWith(
,
)
expect(screen.getByText(/this was not kept/)).toBeInTheDocument()
expect(screen.getByText(/The tavern door swung/)).toBeInTheDocument()
})
})