import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal"; const mockStartMilestoneInterview = vi.fn(); const mockStartSliceInterview = vi.fn(); const mockRespondToMilestoneInterview = vi.fn(); const mockRespondToSliceInterview = vi.fn(); const mockApplyMilestoneInterview = vi.fn(); const mockApplySliceInterview = vi.fn(); const mockSkipMilestoneInterview = vi.fn(); const mockSkipSliceInterview = vi.fn(); const mockConnectMilestoneInterviewStream = vi.fn(); const mockConnectSliceInterviewStream = vi.fn(); const mockAcquireSessionLock = vi.fn(); const mockReleaseSessionLock = vi.fn(); const mockForceAcquireSessionLock = vi.fn(); const mockFetchAiSession = vi.fn(); const mockParseConversationHistory = vi.fn(); vi.mock("../api", () => ({ startMilestoneInterview: (...args: any[]) => mockStartMilestoneInterview(...args), startSliceInterview: (...args: any[]) => mockStartSliceInterview(...args), respondToMilestoneInterview: (...args: any[]) => mockRespondToMilestoneInterview(...args), respondToSliceInterview: (...args: any[]) => mockRespondToSliceInterview(...args), applyMilestoneInterview: (...args: any[]) => mockApplyMilestoneInterview(...args), applySliceInterview: (...args: any[]) => mockApplySliceInterview(...args), skipMilestoneInterview: (...args: any[]) => mockSkipMilestoneInterview(...args), skipSliceInterview: (...args: any[]) => mockSkipSliceInterview(...args), connectMilestoneInterviewStream: (...args: any[]) => mockConnectMilestoneInterviewStream(...args), connectSliceInterviewStream: (...args: any[]) => mockConnectSliceInterviewStream(...args), acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args), releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args), forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args), fetchAiSession: (...args: any[]) => mockFetchAiSession(...args), parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args), })); vi.mock("../hooks/useSessionLock", () => ({ useSessionLock: vi.fn(() => ({ isLockedByOther: false, takeControl: vi.fn(), isLoading: false, })), })); vi.mock("../hooks/useAiSessionSync", () => ({ useAiSessionSync: vi.fn(() => ({ activeTabMap: new Map(), broadcastUpdate: vi.fn(), broadcastCompleted: vi.fn(), broadcastLock: vi.fn(), broadcastUnlock: vi.fn(), broadcastHeartbeat: vi.fn(), })), })); vi.mock("../utils/getSessionTabId", () => ({ getSessionTabId: vi.fn(() => "test-tab-id"), })); vi.mock("lucide-react", () => ({ X: () => X, Loader2: ({ className }: any) => Loader, CheckCircle: () => CheckCircle, ArrowRight: () => ArrowRight, Sparkles: () => Sparkles, ChevronRight: () => ChevronRight, ChevronDown: () => ChevronDown, Minimize2: () => Minimize2, })); const SAMPLE_QUESTION = { id: "scope", type: "single_select" as const, question: "What is the target scope?", description: "Pick the size for this feature.", options: [ { id: "mvp", label: "MVP" }, { id: "full", label: "Full" }, ], }; describe("MilestoneSliceInterviewModal", () => { let streamHandlers: any; beforeEach(() => { mockStartMilestoneInterview.mockReset(); mockStartSliceInterview.mockReset(); mockRespondToMilestoneInterview.mockReset(); mockRespondToSliceInterview.mockReset(); mockApplyMilestoneInterview.mockReset(); mockApplySliceInterview.mockReset(); mockSkipMilestoneInterview.mockReset(); mockSkipSliceInterview.mockReset(); mockConnectMilestoneInterviewStream.mockReset(); mockConnectSliceInterviewStream.mockReset(); mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null }); mockReleaseSessionLock.mockResolvedValue(undefined); mockForceAcquireSessionLock.mockResolvedValue(undefined); mockFetchAiSession.mockReset(); mockParseConversationHistory.mockReset(); mockParseConversationHistory.mockReturnValue([]); // Setup stream handlers capture mockConnectMilestoneInterviewStream.mockImplementation((sessionId, projectId, handlers) => { streamHandlers = handlers; return { close: vi.fn(), isConnected: vi.fn(() => true), }; }); mockConnectSliceInterviewStream.mockImplementation((sessionId, projectId, handlers) => { streamHandlers = handlers; return { close: vi.fn(), isConnected: vi.fn(() => true), }; }); }); describe("initial view", () => { it("renders with correct title for milestone", () => { render( ); expect(screen.getByText("Plan Milestone: Test Milestone")).toBeDefined(); }); it("renders with correct title for slice", () => { render( ); expect(screen.getByText("Plan Slice: Test Slice")).toBeDefined(); }); it("shows three action buttons: Start Interview, Use Mission Context, Cancel", () => { render( ); expect(screen.getByText("Start Interview")).toBeDefined(); expect(screen.getByText("Use Mission Context")).toBeDefined(); expect(screen.getByText("Cancel")).toBeDefined(); }); it("calls onClose when Cancel is clicked", () => { const onClose = vi.fn(); render( ); fireEvent.click(screen.getByText("Cancel")); expect(onClose).toHaveBeenCalled(); }); }); describe("Start Interview button", () => { it("calls startMilestoneInterview for targetType=milestone", async () => { mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" }); render( ); fireEvent.click(screen.getByText("Start Interview")); await waitFor(() => { expect(mockStartMilestoneInterview).toHaveBeenCalledWith("MS-001", "test-project"); }); }); it("calls startSliceInterview for targetType=slice", async () => { mockStartSliceInterview.mockResolvedValue({ sessionId: "session-123" }); render( ); fireEvent.click(screen.getByText("Start Interview")); await waitFor(() => { expect(mockStartSliceInterview).toHaveBeenCalledWith("SL-001", "test-project"); }); }); it("shows loading state after clicking Start Interview", async () => { mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" }); mockConnectMilestoneInterviewStream.mockReturnValue({ close: vi.fn(), isConnected: vi.fn(() => false), }); render( ); fireEvent.click(screen.getByText("Start Interview")); await waitFor(() => { expect(screen.getByText("Preparing next question...")).toBeDefined(); }); }); }); describe("Use Mission Context button", () => { it("calls skipMilestoneInterview and onApplied for targetType=milestone", async () => { mockSkipMilestoneInterview.mockResolvedValue({ id: "MS-001", title: "Test Milestone" }); const onApplied = vi.fn(); render( ); fireEvent.click(screen.getByText("Use Mission Context")); await waitFor(() => { expect(mockSkipMilestoneInterview).toHaveBeenCalledWith("MS-001", "test-project"); expect(onApplied).toHaveBeenCalled(); }); }); it("calls skipSliceInterview and onApplied for targetType=slice", async () => { mockSkipSliceInterview.mockResolvedValue({ id: "SL-001", title: "Test Slice" }); const onApplied = vi.fn(); render( ); fireEvent.click(screen.getByText("Use Mission Context")); await waitFor(() => { expect(mockSkipSliceInterview).toHaveBeenCalledWith("SL-001", "test-project"); expect(onApplied).toHaveBeenCalled(); }); }); it("does not call any interview API when Cancel is clicked", () => { const onClose = vi.fn(); render( ); fireEvent.click(screen.getByText("Cancel")); expect(mockStartMilestoneInterview).not.toHaveBeenCalled(); expect(mockSkipMilestoneInterview).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); }); }); describe("question flow", () => { it("shows question after interview starts and AI responds", async () => { mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" }); render( ); fireEvent.click(screen.getByText("Start Interview")); // Simulate AI response with question await waitFor(() => { // Loading state should appear first expect(screen.getByText(/Preparing next question/)).toBeDefined(); }); // Simulate question event from stream act(() => { streamHandlers.onQuestion(SAMPLE_QUESTION); }); await waitFor(() => { expect(screen.getByText("What is the target scope?")).toBeDefined(); expect(screen.getByText("Pick the size for this feature.")).toBeDefined(); }); }); }); describe("summary and apply", () => { it("shows summary view after interview completes", async () => { mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" }); render( ); fireEvent.click(screen.getByText("Start Interview")); // Wait for loading state await waitFor(() => { expect(screen.getByText(/Preparing next question/)).toBeDefined(); }); // Simulate summary event act(() => { if (streamHandlers?.onSummary) { streamHandlers.onSummary({ description: "Refined description", }); } }); // Give React time to update await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); // Summary should show Refined Scope header expect(screen.getByText("Refined Scope")).toBeDefined(); }); }); describe("resume session rehydration", () => { const mockSessionAwaitingInput = { id: "session-resume-123", type: "milestone_interview" as const, status: "awaiting_input" as const, title: "Plan milestone scope", projectId: "proj-1", lockedByTab: null, updatedAt: new Date().toISOString(), inputPayload: JSON.stringify({ targetType: "milestone", targetId: "MS-001", targetTitle: "Test Milestone", missionContext: "Test Mission", }), conversationHistory: JSON.stringify([ { question: { id: "q1", type: "text", question: "What is the scope?" }, response: { q1: "MVP" } } ]), currentQuestion: JSON.stringify(SAMPLE_QUESTION), result: null, thinkingOutput: "", error: null, createdAt: new Date().toISOString(), lockedAt: null, }; const mockSessionGenerating = { ...mockSessionAwaitingInput, id: "session-resume-456", status: "generating" as const, currentQuestion: null, thinkingOutput: "Analyzing requirements...", }; const mockSessionError = { ...mockSessionAwaitingInput, id: "session-resume-789", status: "error" as const, currentQuestion: null, result: null, error: "AI service unavailable", }; it("restores awaiting_input session with question when resumeSessionId is provided", async () => { mockFetchAiSession.mockResolvedValue(mockSessionAwaitingInput); render( , ); await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-123"); }); await waitFor(() => { expect(screen.getByText("What is the target scope?")).toBeDefined(); expect(screen.getByText("Pick the size for this feature.")).toBeDefined(); }); }); it("reconnects to stream for generating session when resumeSessionId is provided", async () => { mockFetchAiSession.mockResolvedValue(mockSessionGenerating); render( , ); await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-456"); expect(mockConnectMilestoneInterviewStream).toHaveBeenCalled(); }); await waitFor(() => { expect(screen.getByText(/AI is thinking/)).toBeDefined(); }); }); it("shows error state for error session when resumeSessionId is provided", async () => { mockFetchAiSession.mockResolvedValue(mockSessionError); render( , ); await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-resume-789"); }); await waitFor(() => { expect(screen.getByText("AI service unavailable")).toBeDefined(); }); }); it("does not resume when resumeSessionId is not provided", async () => { render( , ); expect(mockFetchAiSession).not.toHaveBeenCalled(); expect(screen.getByText("Start Interview")).toBeDefined(); }); }); });