import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { MemoryRouter, Routes, Route } from 'react-router-dom' // §8.12 Option B — asking-while-reading on the canonical `main` view. The AI // "Ask" affordance (selection tooltip + prompt bar) has no chat surface on // main (RFCDiscussionPanel renders there, not ChatPanel). These tests cover the // transparent branch-cut: Ask on main cuts an edit branch, navigates to it, and // runs the question there; Ask on a branch is unchanged; a rejected cut degrades // gracefully; Flag on main still opens a discussion thread. const getRFC = vi.fn() const getRFCMain = vi.fn() const getBranch = vi.fn() const listModels = vi.fn() const getCollection = vi.fn() const getThreadMessages = vi.fn() const promoteToBranch = vi.fn() const startEditBranch = vi.fn() const streamChatTurn = vi.fn() const createThread = vi.fn() vi.mock('../api', () => ({ getRFC: (...a) => getRFC(...a), getRFCMain: (...a) => getRFCMain(...a), getBranch: (...a) => getBranch(...a), listModels: (...a) => listModels(...a), getCollection: (...a) => getCollection(...a), getThreadMessages: (...a) => getThreadMessages(...a), promoteToBranch: (...a) => promoteToBranch(...a), startEditBranch: (...a) => startEditBranch(...a), streamChatTurn: (...a) => streamChatTurn(...a), createThread: (...a) => createThread(...a), acceptChange: vi.fn(), declineChange: vi.fn(), editMetadata: vi.fn(), manualFlush: vi.fn(), reaskChange: vi.fn(), resolveThread: vi.fn(), setBranchVisibility: vi.fn(), claimOwnership: vi.fn(), retireRFC: vi.fn(), unretireRFC: vi.fn(), })) vi.mock('../lib/entryPaths', () => ({ entryPath: () => '/p/ohm/c/rfc-app/e/the-entry', entryPrPath: () => '/pr', useProjectId: () => 'ohm', useCollectionId: () => 'rfc-app', })) vi.mock('../lib/analytics', () => ({ EVENTS: { RFC_VIEWED: 'rfc_viewed' }, track: vi.fn() })) // Stub the heavy children; expose just the callbacks/data the flow needs. vi.mock('./MarkdownSourceEditor.jsx', () => ({ default: () => null })) vi.mock('./MarkdownPreview.jsx', () => ({ default: () =>
})) vi.mock('./RFCDiscussionPanel.jsx', () => ({ default: () =>
})) vi.mock('./ChatPanel.jsx', () => ({ default: ({ messages }) => (
{(messages || []).map((m, i) => (
{m.text}
))}
), })) vi.mock('./SelectionTooltip.jsx', () => ({ default: ({ onAsk, onFlag, disabled }) => (
), })) vi.mock('./PromptBar.jsx', () => ({ default: ({ onSubmit, disabled }) => ( ), })) vi.mock('./ChangePanel.jsx', () => ({ default: () => null, diffWords: () => [] })) vi.mock('./PRModal.jsx', () => ({ default: () => null })) vi.mock('./GraduateDialog.jsx', () => ({ default: () => null })) vi.mock('./InvitationsModal.jsx', () => ({ default: () => null })) vi.mock('./MetadataFieldsPanel.jsx', () => ({ default: () => null })) import RFCView from './RFCView.jsx' const ACTIVE_ENTRY = { id: 'RFC-0001', title: 'The Entry', state: 'active', owners: [], meta: {}, proposed_use_case: '', tags: [], } const MAIN_VIEW = { slug: 'the-entry', branches: [], open_prs: [] } function branchPayload(branch, { main_thread_id = 't-1' } = {}) { return { slug: 'the-entry', title: 'The Entry', branch_name: branch, body: '# Body\n', body_sha: 'abc', main_thread_id, threads: [], changes: [], visibility: {}, grants: [], creator: 'me', capabilities: { can_contribute: true, can_change_branch_settings: true }, } } const VIEWER = { gitea_login: 'me', role: 'contributor' } function renderView(viewer = VIEWER, initialBranch = null) { const path = initialBranch ? `/e/the-entry?branch=${initialBranch}` : '/e/the-entry' return render( } /> , ) } describe('RFCView — §8.12 main-view Ask (Option B)', () => { beforeEach(() => { vi.clearAllMocks() getRFC.mockResolvedValue(ACTIVE_ENTRY) getRFCMain.mockResolvedValue(MAIN_VIEW) listModels.mockResolvedValue({ models: [{ id: 'claude' }], default: 'claude' }) getCollection.mockResolvedValue({ fields: null }) getThreadMessages.mockResolvedValue({ messages: [] }) getBranch.mockImplementation((_slug, branch) => Promise.resolve(branchPayload(branch))) promoteToBranch.mockResolvedValue({ branch_name: 'edit-me-1' }) startEditBranch.mockResolvedValue({ branch_name: 'edit-the-entry' }) createThread.mockResolvedValue({ thread_id: 1, message_id: 1 }) streamChatTurn.mockImplementation(async (_slug, _branch, _threadId, { text, quote }, cb) => { cb.onChunk?.('Here is the answer.') cb.onChanges?.({ message_id: 42 }) cb.onDone?.() return { assistantId: 42, userMsgId: 41 } }) }) it('cuts an edit branch and runs the question (with the quote) there', async () => { renderView() await screen.findByTestId('discussion-panel') // we start on main fireEvent.click(screen.getByTestId('prompt-submit')) // The active-RFC dispatch is promote-to-branch. await waitFor(() => expect(promoteToBranch).toHaveBeenCalledWith('the-entry')) // Once the new branch view loads, the turn runs there — not on main — // with the selected quote intact. await waitFor(() => expect(streamChatTurn).toHaveBeenCalled()) const [slug, branch, threadId, payload] = streamChatTurn.mock.calls[0] expect(slug).toBe('the-entry') expect(branch).toBe('edit-me-1') expect(threadId).toBe('t-1') expect(payload).toMatchObject({ text: 'Why this approach?', quote: 'the selected quote' }) // The chat panel is now mounted (we left main) and shows the streamed answer. const panel = await screen.findByTestId('chat-panel') await waitFor(() => expect(panel).toHaveTextContent('Here is the answer.')) }) it('uses start-edit-branch for a super-draft entry', async () => { getRFC.mockResolvedValue({ ...ACTIVE_ENTRY, state: 'super-draft' }) getBranch.mockImplementation((_slug, branch) => Promise.resolve(branchPayload(branch === 'main' ? 'main' : 'edit-the-entry'))) renderView() await screen.findByTestId('discussion-panel') fireEvent.click(screen.getByTestId('tooltip-ask')) await waitFor(() => expect(startEditBranch).toHaveBeenCalledWith('the-entry')) expect(promoteToBranch).not.toHaveBeenCalled() await waitFor(() => expect(streamChatTurn).toHaveBeenCalled()) expect(streamChatTurn.mock.calls[0][1]).toBe('edit-the-entry') }) it('asking from an existing branch runs directly, without cutting a new one', async () => { renderView(VIEWER, 'edit-me-1') await screen.findByTestId('chat-panel') fireEvent.click(screen.getByTestId('prompt-submit')) await waitFor(() => expect(streamChatTurn).toHaveBeenCalled()) expect(promoteToBranch).not.toHaveBeenCalled() expect(startEditBranch).not.toHaveBeenCalled() expect(streamChatTurn.mock.calls[0][1]).toBe('edit-me-1') }) it('surfaces an error and does not chat when the branch cut is rejected', async () => { promoteToBranch.mockRejectedValue(new Error('not a contributor')) renderView() await screen.findByTestId('discussion-panel') fireEvent.click(screen.getByTestId('prompt-submit')) await waitFor(() => expect(promoteToBranch).toHaveBeenCalled()) // Explicit error path — not a silent no-op — and no spurious chat turn. expect(await screen.findByText(/not a contributor/)).toBeInTheDocument() expect(streamChatTurn).not.toHaveBeenCalled() }) it('Flag on the canonical view still creates a discussion thread (no branch cut)', async () => { renderView() await screen.findByTestId('discussion-panel') fireEvent.click(screen.getByTestId('tooltip-flag')) await waitFor(() => expect(createThread).toHaveBeenCalled()) const [, branch, body] = createThread.mock.calls[0] expect(branch).toBe('main') expect(body).toMatchObject({ thread_kind: 'flag' }) expect(promoteToBranch).not.toHaveBeenCalled() expect(streamChatTurn).not.toHaveBeenCalled() }) it('signed-out viewer gets a read-only path on main — no prompt bar, Ask disabled, no cut', async () => { renderView(null) await screen.findByTestId('discussion-panel') expect(screen.queryByTestId('prompt-submit')).toBeNull() expect(screen.getByTestId('tooltip-ask')).toBeDisabled() expect(promoteToBranch).not.toHaveBeenCalled() expect(startEditBranch).not.toHaveBeenCalled() }) })