import { describe, it, expect, beforeEach, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { EnvSetup } from './EnvSetup' import { useSession } from '@/store/session' import { sayHi } from '@/lib/api' vi.mock('@/lib/api', async (orig) => ({ ...(await orig()), sayHi: vi.fn(), })) const mockSayHi = vi.mocked(sayHi) function renderPage() { return render( , ) } /** Connect a board with a live node URL — the common precondition. */ function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') { useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl }) } describe('EnvSetup — Meet your node', () => { beforeEach(() => { useSession.getState().reset() sessionStorage.clear() mockSayHi.mockReset() }) it('renders the phase strip set to setup and the heading', () => { renderPage() expect(screen.getByTestId('phase-strip')).toBeInTheDocument() expect(screen.getByRole('heading', { name: /meet your node/i })).toBeInTheDocument() expect( screen.getByTestId('phase-strip').querySelector('[data-phase="setup"]'), ).toHaveAttribute('data-state', 'active') }) it('prompts to claim a board first when not connected', () => { renderPage() expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() }) it('shows the node as Connected with the board url when claimed', () => { connect('http://192.168.1.7:8080') renderPage() const link = screen.getByRole('link', { name: /open your node/i }) expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080') expect(link).toHaveAttribute('target', '_blank') expect(screen.getByText(/^connected$/i)).toBeInTheDocument() }) it('falls back to the claim prompt when connected but there is no nodeUrl', () => { connect(null) renderPage() expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument() expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() }) it('says hi to the agent and shows its reply', async () => { mockSayHi.mockResolvedValue("Hi! I'm your node — I can read your sensor and drive the matrix.") connect() renderPage() fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i })) expect(await screen.findByTestId('node-reply')).toHaveTextContent(/read your sensor/i) expect(screen.getByRole('button', { name: /your node replied/i })).toBeInTheDocument() }) it('requires a domain even after the agent replies', async () => { mockSayHi.mockResolvedValue('hello there') connect() renderPage() fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i })) await screen.findByTestId('node-reply') expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() }) it('keeps Proceed gated when a domain is named but the agent has not replied', () => { connect() useSession.getState().setDomain('air quality') renderPage() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() }) it('enables Proceed once the agent has replied AND a domain is named', async () => { mockSayHi.mockResolvedValue('hello there') connect() useSession.getState().setDomain('structural stress') renderPage() const proceed = screen.getByRole('button', { name: /proceed/i }) expect(proceed).toBeDisabled() fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i })) await screen.findByTestId('node-reply') expect(proceed).toBeEnabled() expect(useSession.getState().stats.calls).toBe(0) }) it('surfaces an error when the node does not answer', async () => { mockSayHi.mockRejectedValue(new Error('your node did not answer — is it online?')) connect() renderPage() fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i })) expect(await screen.findByRole('alert')).toHaveTextContent(/did not answer/i) }) })