cflx 0.6.11

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
// @vitest-environment jsdom

import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';

import { ProposalChat } from '../ProposalChat';

const sendMessageMock = vi.fn();
const stopMock = vi.fn();
const sendElicitationResponseMock = vi.fn();

let hookState: {
  messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; timestamp: string }>;
  status: 'ready' | 'submitted' | 'streaming' | 'recovering' | 'error';
  error: string | null;
  activeElicitation: null;
  wsConnected: boolean;
  submissionLock: { isLocked: boolean; clearVersion: number };
} = {
  messages: [],
  status: 'ready',
  error: null,
  activeElicitation: null,
  wsConnected: false,
  submissionLock: {
    isLocked: false,
    clearVersion: 0,
  },
};

vi.mock('../../hooks/useProposalChat', () => ({
    useProposalChat: () => ({
      ...hookState,
      sendMessage: sendMessageMock,
      stop: stopMock,
      sendElicitationResponse: sendElicitationResponseMock,
    }),

}));

vi.mock('../ProposalChangesList', () => ({
  ProposalChangesList: () => <div>changes</div>,
}));

vi.mock('../ProposalActions', () => ({
  ProposalActions: () => <div>actions</div>,
}));

beforeEach(() => {
  if (!Element.prototype.scrollIntoView) {
    Element.prototype.scrollIntoView = vi.fn();
  }
});

afterEach(() => {
  cleanup();
  sendMessageMock.mockReset();
  stopMock.mockReset();
  sendElicitationResponseMock.mockReset();
  hookState = {
    messages: [],
    status: 'ready',
    error: null,
    activeElicitation: null,
    wsConnected: false,
    submissionLock: {
      isLocked: false,
      clearVersion: 0,
    },
  };
});


describe('ProposalChat', () => {
  it('shows reconnect recovery placeholder when recovering status', () => {
    hookState.status = 'recovering';

    render(
      <ProposalChat
        projectId="project-1"
        sessionId="session-1"
        onBack={vi.fn()}
        onMerge={vi.fn()}
        onClose={vi.fn()}
      />,
    );

    expect(screen.getByPlaceholderText('Reconnecting to recover active turn...')).toBeTruthy();
  });

  it('shows disconnected placeholder when websocket unavailable', () => {
    render(
      <ProposalChat
        projectId="project-1"
        sessionId="session-1"
        onBack={vi.fn()}
        onMerge={vi.fn()}
        onClose={vi.fn()}
      />,
    );

    expect(
      screen.getByPlaceholderText('Disconnected. Message will be queued and sent on reconnect.'),
    ).toBeTruthy();
    expect(screen.getByTitle('Disconnected')).toBeTruthy();
  });

  it('shows normal placeholder when connected and ready', () => {
    hookState.wsConnected = true;
    hookState.status = 'ready';

    render(
      <ProposalChat
        projectId="project-1"
        sessionId="session-1"
        onBack={vi.fn()}
        onMerge={vi.fn()}
        onClose={vi.fn()}
      />,
    );

    expect(
      screen.getByPlaceholderText('Type a message... (Shift+Enter for newline, Enter to send)'),
    ).toBeTruthy();
  });

  it('sends example prompt through hook', () => {
    render(
      <ProposalChat
        projectId="project-1"
        sessionId="session-1"
        onBack={vi.fn()}
        onMerge={vi.fn()}
        onClose={vi.fn()}
      />,
    );

    fireEvent.click(screen.getByText('Summarize the current proposal and open risks'));

    expect(sendMessageMock).toHaveBeenCalledTimes(1);
    expect(sendMessageMock).toHaveBeenCalledWith('Summarize the current proposal and open risks');
  });
});