Files
aiturk-hermes-ide/apps/desktop/src/plugins/hermes-bots/group-chat-parts.test.tsx
T

214 lines
7.3 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { translateBots } from './i18n-test-helper'
import type { GroupMember } from './types'
// Group-composer mentions (#89049): the core composer's @-completion area
// doesn't mount inside workspace tiles, so the room's composers wrap the SDK
// input with a member-scoped popover of their own. Everything it inserts has
// to be a string parseGroupChatMentions resolves.
const { host } = vi.hoisted(() => ({ host: {} as Record<string, unknown> }))
vi.mock('@hermes/plugin-sdk', async () => {
const { pluginSdkMock } = await import('./group-test-utils')
const base = await pluginSdkMock(host)
return {
...base,
Button: (props: React.ComponentProps<'button'>) => <button type="button" {...props} />,
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
Codicon: () => null,
Input: (props: React.ComponentProps<'input'>) => <input {...props} />,
RowButton: (props: React.ComponentProps<'button'>) => <button type="button" {...props} />,
Textarea: (props: React.ComponentProps<'textarea'>) => <textarea {...props} />,
useI18n: () => ({ t: (_key: string, fallback: string) => fallback }),
// The plugin bundle normally lands via `ctx.i18n.register` at load, so
// without this every localized label renders empty.
usePluginI18n: () => translateBots
}
})
const MEMBERS: GroupMember[] = [
{ handle: 'alpha', name: 'alpha', title: '' },
{ handle: 'builder', name: 'builder', title: '' }
]
/** Render the composer the way a room does: value owned by the caller (the
* draft atom in production), popover scoped to the seated members. */
async function mount(initial = '') {
const { GroupMentionInput } = await import('./group-chat-parts')
const onChange = vi.fn()
const onSubmitDraft = vi.fn()
function Harness() {
const [value, setValue] = useState(initial)
return (
<GroupMentionInput
aria-label="Message Core"
members={MEMBERS}
onChange={next => {
onChange(next)
setValue(next)
}}
onSubmitDraft={onSubmitDraft}
value={value}
/>
)
}
render(<Harness />)
return { input: screen.getByLabelText('Message Core') as HTMLTextAreaElement, onChange, onSubmitDraft }
}
/** Type `text`, then park the caret at `caret` (default: end of the text).
* The click is how the component re-reads the caret without a keystroke —
* jsdom does not preserve a selection across React's controlled re-write. */
function typeInto(input: HTMLTextAreaElement, text: string, caret = text.length) {
fireEvent.change(input, { target: { value: text } })
input.setSelectionRange(caret, caret)
fireEvent.click(input)
}
const options = () => screen.queryAllByRole('button').map(button => button.textContent || '')
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('the @-token at the caret', () => {
it('opens the popover on a token that begins a word', async () => {
const { input } = await mount()
typeInto(input, 'hey @al')
expect(options().some(label => label.startsWith('@alpha'))).toBe(true)
})
it('offers @everyone and @all as the room-wide broadcast quick picks', async () => {
const { input } = await mount()
typeInto(input, '@')
expect(options().some(label => label.startsWith('@everyone'))).toBe(true)
expect(options().some(label => label.startsWith('@all'))).toBe(true)
})
it('narrows to @everyone as the query grows', async () => {
const { input } = await mount()
typeInto(input, 'ping @every')
expect(options().filter(label => label.startsWith('@everyone'))).toHaveLength(1)
expect(options().some(label => label.startsWith('@alpha'))).toBe(false)
})
it('stays closed mid-word, on plain text, and with the caret before the @', async () => {
const { input } = await mount()
typeInto(input, 'email me a@b')
expect(options()).toHaveLength(0)
typeInto(input, 'plain text')
expect(options()).toHaveLength(0)
typeInto(input, 'hey @al', 3)
expect(options()).toHaveLength(0)
})
})
describe('insertion', () => {
it('writes exactly "@handle " — the shape parseGroupChatMentions resolves', async () => {
const { input, onChange } = await mount()
typeInto(input, 'hey @al')
const option = screen.getAllByRole('button').find(button => button.textContent?.startsWith('@alpha'))
fireEvent.mouseDown(option!)
expect(onChange).toHaveBeenLastCalledWith('hey @alpha ')
})
it('preventDefaults the mousedown so the input keeps focus', async () => {
const { input } = await mount()
typeInto(input, 'hey @al')
const option = screen.getAllByRole('button').find(button => button.textContent?.startsWith('@alpha'))
// fireEvent returns false when a handler called preventDefault.
expect(fireEvent.mouseDown(option!)).toBe(false)
})
it('replaces the whole token, not just the typed suffix', async () => {
const { input, onChange } = await mount()
typeInto(input, '@bui and then some', 4)
const option = screen.getAllByRole('button').find(button => button.textContent?.startsWith('@builder'))
fireEvent.mouseDown(option!)
expect(onChange).toHaveBeenLastCalledWith('@builder and then some')
})
it('lists every seated member under a bare @, keyed by handle', async () => {
const { input } = await mount()
typeInto(input, '@')
// Handles only — a profile name that never resolved would surface as
// "@undefined" and route to nobody.
expect(options().some(label => label.startsWith('@alpha'))).toBe(true)
expect(options().some(label => label.startsWith('@builder'))).toBe(true)
expect(options().some(label => label.startsWith('@undefined'))).toBe(false)
})
})
// #89884: the composer used to be a single-line Input whose form submitted on
// every Enter, so multi-line room prompts were impossible.
describe('keyboard (#89884)', () => {
it('submits on Enter and leaves Shift+Enter to the textarea', async () => {
const { input, onSubmitDraft } = await mount('a room prompt')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSubmitDraft).toHaveBeenCalledTimes(1)
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
expect(onSubmitDraft).toHaveBeenCalledTimes(1)
})
it('inserts the highlighted mention on Enter while the popover is open', async () => {
const { input, onChange, onSubmitDraft } = await mount()
typeInto(input, 'hey @alp')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onChange).toHaveBeenLastCalledWith('hey @alpha ')
expect(onSubmitDraft).not.toHaveBeenCalled()
})
// #93528: Enter here confirms composed Chinese/Japanese/Korean text. It must
// neither insert a mention nor submit the draft. isComposing covers Chromium;
// keyCode 229 covers macOS Chinese IMEs that fire Enter after compositionend
// with isComposing already false.
it('swallows IME composition Enters (#93528)', async () => {
const { input, onSubmitDraft } = await mount('中文')
fireEvent.keyDown(input, { isComposing: true, key: 'Enter' })
fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 })
expect(onSubmitDraft).not.toHaveBeenCalled()
})
})