Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

200 changes: 200 additions & 0 deletions apps/web/src/components/designer/__tests__/ai-context-chip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// @vitest-environment happy-dom
/**
* The block picked in the builder rides along in Quackback AI's composer as
* a chip; its X takes it out of the chat and lets go of the block.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'

// This month's website captures, read only once a website is typed.
const usage = vi.hoisted(() => ({ data: undefined as unknown, asked: [] as boolean[] }))
vi.mock('@tanstack/react-query', () => ({
useSuspenseQuery: () => ({ data: { available: true } }),
useQuery: (options: { enabled?: boolean }) => {
usage.asked.push(options.enabled !== false)
return { data: options.enabled === false ? undefined : usage.data }
},
}))
vi.mock('@/lib/server/functions/site-capture', () => ({ siteCaptureUsageFn: vi.fn() }))
vi.mock('@/lib/client/queries/portal-design', () => ({
portalDesignQueries: { aiStatus: () => ({}) },
}))
vi.mock('@/lib/client/image-palette', () => ({}))
vi.mock('@/components/admin/settings/appearance/ask-ai-panel', () => ({
AskAiPanel: () => null,
PaletteSwatches: () => null,
}))

const { DesignerAi } = await import('../designer-ai')
const { defaultDesign } = await import('@/lib/shared/design/schema')

afterEach(() => {
cleanup()
usage.data = undefined
usage.asked = []
})

/** A design matched to a brand: its own colour, Home on. */
function matched() {
const design = defaultDesign()
design.brand.light.accent = '#0058a3'
design.nav.home.on = true
return design
}

function show(
selection: string | null,
selectionName: string | null,
turns: unknown[] = [],
element: { element: 'button'; part: string | null } | null = null,
elementName: string | null = null,
design = defaultDesign()
) {
const onCollapse = vi.fn()
const onClearSelection = vi.fn()
const ask = vi.fn()
render(
<DesignerAi
ai={{ busy: false, limited: null, ask, turns } as never}
draft={{ design } as never}
selection={selection as never}
selectionName={selectionName}
element={element}
elementName={elementName}
pageName="Home"
update={vi.fn()}
onClearSelection={onClearSelection}
onCollapse={onCollapse}
/>
)
return { onClearSelection, ask, onCollapse }
}

describe('the AI context chip', () => {
it('names the picked block and its page, and its X deselects the block', () => {
const { onClearSelection } = show('block-topposts1', 'Top posts')
expect(screen.getByText('On Home')).toBeTruthy()
fireEvent.click(
screen.getByRole('button', { name: 'Remove Top posts from the chat and deselect it' })
)
expect(onClearSelection).toHaveBeenCalledOnce()
})

it('sends a request about the picked block with the block', () => {
const { ask } = show('block-topposts1', 'Top posts')
const box = screen.getByLabelText('Ask Quackback AI')
fireEvent.change(box, { target: { value: 'fewer posts' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(ask).toHaveBeenCalledWith('fewer posts', 'home', undefined, 'topposts1', undefined)
})

it('sends the element picked inside the selection, so "just this" names it', () => {
const { ask } = show('hero', 'Hero', [], { element: 'button', part: 'hero.home.cta' })
const box = screen.getByLabelText('Ask Quackback AI')
fireEvent.change(box, { target: { value: 'just this one black' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(ask).toHaveBeenCalledWith(
'just this one black',
'band',
undefined,
undefined,
undefined,
{
element: 'button',
part: 'hero.home.cta',
}
)
})

it('names the element picked inside the section, with the section beside it', () => {
const { onClearSelection } = show(
'hero',
'Hero',
[],
{ element: 'button', part: 'hero.home.cta' },
'Call to action'
)
expect(screen.getByText('Call to action')).toBeTruthy()
expect(screen.getByText('Hero on Home')).toBeTruthy()
expect(screen.getByLabelText('Ask Quackback AI').getAttribute('placeholder')).toBe(
'Ask about Call to action'
)
fireEvent.click(
screen.getByRole('button', { name: 'Remove Call to action from the chat and deselect it' })
)
expect(onClearSelection).toHaveBeenCalledOnce()
})

it('carries nothing when nothing is picked', () => {
show(null, null)
expect(screen.queryByRole('button', { name: /from the chat and deselect it/ })).toBeNull()
})
})

describe('Ask AI in its pane', () => {
it('is a plain chat: one line before the first request, the box under it', () => {
show(null, null)
const log = screen.getByRole('log', { name: 'Conversation' })
expect(log.textContent).toContain('Ask for a change')
expect(log.querySelector('form, input')).toBeNull()
expect(screen.getByLabelText('Ask Quackback AI')).toBeTruthy()
expect(log.textContent).toContain('Paste your website or screenshots to match your brand.')
expect(log.textContent).not.toMatch(/captures/i)
})

it('offers Match my brand first, which points at the box and sends nothing', () => {
const { ask } = show(null, null)
expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
'Match my brand',
])
fireEvent.click(screen.getByRole('button', { name: 'Match my brand' }))
const box = screen.getByLabelText('Ask Quackback AI') as HTMLTextAreaElement
expect(box.value).toBe('')
expect(box.getAttribute('placeholder')).toBe('Paste your website or screenshots')
expect(document.activeElement).toBe(box)
expect(ask).not.toHaveBeenCalled()
// Once the request it asked for is sent, the box says what it says again.
fireEvent.change(box, { target: { value: 'acme.com' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(ask).toHaveBeenCalledOnce()
expect(box.getAttribute('placeholder')).not.toBe('Paste your website or screenshots')
})

it('offers next edits once matched, with no subline, each put in the box', () => {
show(null, null, [], null, null, matched())
const log = screen.getByRole('log', { name: 'Conversation' })
expect(log.textContent).not.toContain('Paste your website')
expect(screen.queryByRole('button', { name: 'Match my brand' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Try a dark hero' }))
const box = screen.getByLabelText('Ask Quackback AI') as HTMLTextAreaElement
expect(box.value).toBe('Try a dark hero')
expect(document.activeElement).toBe(box)
})

it('says how many captures are left only once a website is typed and few remain', () => {
usage.data = { configured: true, used: 85, limit: 90 }
show(null, null)
expect(screen.queryByText(/captures left/)).toBeNull()
expect(usage.asked.every((asked) => !asked)).toBe(true)
fireEvent.change(screen.getByLabelText('Ask Quackback AI'), {
target: { value: 'acme.com' },
})
expect(screen.getByText('5 website captures left this month.')).toBeTruthy()
usage.data = { configured: true, used: 10, limit: 90 }
fireEvent.change(screen.getByLabelText('Ask Quackback AI'), {
target: { value: 'acme.com ' },
})
expect(screen.queryByText(/captures left/)).toBeNull()
})

it('shows the conversation once it has begun', () => {
show(null, null, [{ id: 1 }])
expect(screen.queryByText('Ask for a change')).toBeNull()
})

it('folds to its rail from its heading', () => {
const { onCollapse } = show(null, null)
fireEvent.click(screen.getByRole('button', { name: 'Collapse Ask AI' }))
expect(onCollapse).toHaveBeenCalledOnce()
})
})
119 changes: 119 additions & 0 deletions apps/web/src/components/designer/__tests__/ai-pane.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// @vitest-environment happy-dom
/**
* Ask AI's pane on the left: open at its width, or a thin rail whose button
* opens it; its edge resizes by the arrow keys within 300 to 480, and the
* pane remembers how it was left, per person.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'

vi.mock('@/lib/client/hooks/use-root-context', () => ({
useSessionContext: () => ({ user: { id: 'user_1' } }),
}))

const { AiPane, AI_RAIL_WIDTH, paneTransition } = await import('../ai-pane')
const { aiPaneKey, useAiPane } = await import('../use-ai-pane')

afterEach(() => {
cleanup()
localStorage.clear()
})

function pane(over: Partial<Parameters<typeof AiPane>[0]['pane']> = {}) {
return {
collapsed: false,
width: 360,
resizing: false,
expand: vi.fn(),
collapse: vi.fn(),
resize: vi.fn(),
setResizing: vi.fn(),
...over,
}
}

function show(state: ReturnType<typeof pane>) {
render(
<TooltipProvider>
<AiPane pane={state} hidden={false}>
<p>The chat</p>
</AiPane>
</TooltipProvider>
)
}

describe('AiPane', () => {
it('holds the chat when open, with an edge to resize it', () => {
show(pane())
expect(screen.getByText('The chat')).toBeTruthy()
const edge = screen.getByRole('separator', { name: 'Resize Ask AI' })
expect(edge.getAttribute('aria-valuenow')).toBe('360')
expect(edge.getAttribute('aria-valuemin')).toBe('300')
expect(edge.getAttribute('aria-valuemax')).toBe('480')
})

it('resizes by the arrow keys', () => {
const state = pane()
show(state)
fireEvent.keyDown(screen.getByRole('separator'), { key: 'ArrowRight' })
expect(state.resize).toHaveBeenCalledWith(376)
})

it('is a rail with Ask AI on it when collapsed, which opens it', () => {
const state = pane({ collapsed: true })
show(state)
expect(screen.queryByText('The chat')).toBeNull()
expect(screen.queryByRole('separator')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Ask AI' }))
expect(state.expand).toHaveBeenCalledOnce()
expect(AI_RAIL_WIDTH).toBeLessThan(60)
})

it('follows the pointer without easing while dragged, and moves at once for less motion', () => {
expect(paneTransition(true)).toEqual({ duration: 0 })
expect((paneTransition(false) as { duration: number }).duration).toBeLessThanOrEqual(0.2)
expect(paneTransition(false, true)).toEqual({ duration: 0 })
})
})

describe('useAiPane', () => {
it('remembers collapsing and the width, for the person signed in', () => {
const { result } = renderHook(() => useAiPane(false))
act(() => result.current.collapse())
expect(JSON.parse(localStorage.getItem(aiPaneKey('user_1'))!)).toEqual({
collapsed: true,
width: 360,
})
act(() => result.current.resize(420))
expect(JSON.parse(localStorage.getItem(aiPaneKey('user_1'))!).width).toBe(420)
})

it('opens as it was left', () => {
localStorage.setItem(aiPaneKey('user_1'), JSON.stringify({ collapsed: true, width: 400 }))
const { result } = renderHook(() => useAiPane(false))
expect(result.current.collapsed).toBe(true)
expect(result.current.width).toBe(400)
})

it('opens on "/" outside a field, and not while previewing', () => {
localStorage.setItem(aiPaneKey('user_1'), JSON.stringify({ collapsed: true }))
const onShow = vi.fn()
const { result, rerender } = renderHook(({ previewing }) => useAiPane(previewing, onShow), {
initialProps: { previewing: true },
})
act(() => void window.dispatchEvent(new KeyboardEvent('keydown', { key: '/' })))
expect(result.current.collapsed).toBe(true)
rerender({ previewing: false })
act(() => void window.dispatchEvent(new KeyboardEvent('keydown', { key: '/' })))
expect(result.current.collapsed).toBe(false)
expect(onShow).toHaveBeenCalledOnce()
})

it('does not remember a width mid-drag', () => {
const { result } = renderHook(() => useAiPane(false))
act(() => result.current.resize(440, false))
expect(result.current.width).toBe(440)
expect(localStorage.getItem(aiPaneKey('user_1'))).toBeNull()
})
})
29 changes: 29 additions & 0 deletions apps/web/src/components/designer/__tests__/designer-ai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/server/functions/portal-design-ai', () => ({}))
vi.mock('@/lib/client/mutations/settings', () => ({}))
vi.mock('@/lib/client/queries/portal-design', () => ({}))

const { aiScopeOf } = await import('../designer-ai')

describe('aiScopeOf', () => {
it('keeps a request to the part selected, and a block to itself', () => {
expect(aiScopeOf(null)).toEqual({ scope: 'site' })
expect(aiScopeOf('site')).toEqual({ scope: 'brand' })
expect(aiScopeOf('hero')).toEqual({ scope: 'band' })
expect(aiScopeOf('feed')).toEqual({ scope: 'feed' })
expect(aiScopeOf('block-linksblock1')).toEqual({ scope: 'home', blockId: 'linksblock1' })
expect(aiScopeOf('site:brand')).toEqual({ scope: 'brand' })
expect(aiScopeOf('site:styles')).toEqual({ scope: 'brand' })
// An item of the menu is in the header.
expect(aiScopeOf('page:help')).toEqual({ scope: 'header' })
expect(aiScopeOf('link:docs')).toEqual({ scope: 'header' })
})

it('keeps a request about a page’s section to that page', () => {
expect(aiScopeOf('entries')).toEqual({ scope: 'changelog' })
expect(aiScopeOf('topics')).toEqual({ scope: 'help' })
expect(aiScopeOf('popular')).toEqual({ scope: 'help' })
expect(aiScopeOf('board')).toEqual({ scope: 'roadmap' })
})
})
Loading
Loading