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