- Open your node: shows a 'Connected' badge (+ board name) once the board is claimed. - Say hi: replaces the liveness-poll self-test with an actual round-trip to the agent on the team's own board — sends a greeting, shows the agent's reply, and only unlocks once it answers. New blocking /nodes/:teamId/say-hi route (greeting never flashes, so no hang risk) + bridge.sayHi + client sayHi(). The /prompt route stays fire-and-forget for the flash path. - Pick your domain already captures to the store and syncs to the server DB via useCollectiveSync -> pushTeam, available to later steps/judging. - Proceed gates on connected + agent-replied + domain. Tests updated + say-hi coverage (route + page). Co-Authored-By: Claude Opus 4.8 <[email protected]>
115 lines
4.3 KiB
TypeScript
115 lines
4.3 KiB
TypeScript
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<typeof import('@/lib/api')>()),
|
|
sayHi: vi.fn(),
|
|
}))
|
|
const mockSayHi = vi.mocked(sayHi)
|
|
|
|
function renderPage() {
|
|
return render(
|
|
<MemoryRouter>
|
|
<EnvSetup />
|
|
</MemoryRouter>,
|
|
)
|
|
}
|
|
|
|
/** 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)
|
|
})
|
|
})
|