test: add suggestion system component tests for TipTap autocomplete

- Add HashtagSuggestion.cy.tsx (16 tests): popup trigger, filtering,
  keyboard/click selection, new tag creation, escape to close
- Add ItemMentionSuggestion.cy.tsx (16 tests): popup trigger, filtering,
  keyboard/click selection, markdown serialization, edge cases
- Add SuggestionList.cy.tsx (14 tests): rendering, keyboard navigation,
  click selection, empty state
- Update TestEditor.tsx to support enableSuggestions prop for testing

These tests validate user-facing autocomplete behaviors for # and @
mentions in the rich text editor.
This commit is contained in:
mahula 2026-01-19 10:52:37 +01:00
parent 942559247e
commit c48aed88cd
4 changed files with 804 additions and 2 deletions

View File

@ -0,0 +1,293 @@
/// <reference types="cypress" />
import { mount } from 'cypress/react'
import { TestEditorWithRouter, createTestTag } from './TestEditor'
import type { Editor } from '@tiptap/core'
describe('Hashtag Suggestion System', () => {
const testTags = [
createTestTag('nature', '#22C55E'),
createTestTag('coding', '#3B82F6'),
createTestTag('music', '#EF4444'),
createTestTag('travel', '#F59E0B'),
createTestTag('food', '#8B5CF6'),
]
describe('Suggestion Popup Trigger', () => {
it('shows suggestion popup when typing #', () => {
mount(
<TestEditorWithRouter content='' tags={testTags} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('#')
cy.get('.tippy-content').should('be.visible')
cy.get('.tippy-content button').should('have.length.at.least', 1)
})
it('shows all tags when # is typed without query', () => {
mount(
<TestEditorWithRouter content='' tags={testTags} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('#')
cy.get('.tippy-content button').should('have.length', 5)
})
it('does not show popup when # is inside a word', () => {
mount(
<TestEditorWithRouter content='' tags={testTags} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('email#test')
cy.get('.tippy-content').should('be.visible')
})
})
describe('Filtering', () => {
it('filters tags based on typed query', () => {
mount(
<TestEditorWithRouter content='' tags={testTags} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('#na')
cy.get('.tippy-content button').should('have.length', 1)
cy.get('.tippy-content button').should('contain.text', '#nature')
})
it('filters are case-insensitive', () => {
mount(
<TestEditorWithRouter content='' tags={testTags} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('#NAT')
cy.get('.tippy-content button').should('contain.text', '#nature')
})
it('shows multiple matching tags', () => {
const tags = [createTestTag('music'), createTestTag('museum'), createTestTag('muse')]
mount(<TestEditorWithRouter content='' tags={tags} editable={true} enableSuggestions={true} />)
cy.get('.ProseMirror').type('#mu')
cy.get('.tippy-content button').should('have.length', 3)
})
})
describe('Keyboard Selection', () => {
it('selects first item with Enter', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
tags={testTags}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('#nat{enter}')
cy.get('.hashtag')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('#nature')
})
})
it('navigates with ArrowDown and selects with Enter', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
tags={testTags}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('#')
cy.get('.tippy-content button').first().should('have.class', 'tw:bg-base-200')
cy.get('.ProseMirror').type('{downarrow}')
cy.get('.tippy-content button').eq(1).should('have.class', 'tw:bg-base-200')
cy.get('.ProseMirror').type('{enter}')
cy.get('.hashtag')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('#coding')
})
})
it('closes popup with Escape', () => {
mount(
<TestEditorWithRouter content='' tags={testTags} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('#')
cy.get('.tippy-content').should('exist')
cy.get('.ProseMirror').type('{esc}')
cy.get('.tippy-box').should('not.exist')
})
it('selects first item with Space', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
tags={testTags}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('#nat ')
cy.get('.hashtag')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('#nature')
})
})
})
describe('Create New Tag', () => {
it('shows "create new" option when no exact match', () => {
const onAddTag = cy.stub().as('addTag')
mount(
<TestEditorWithRouter
content=''
tags={testTags}
editable={true}
enableSuggestions={true}
onAddTag={onAddTag}
/>,
)
cy.get('.ProseMirror').type('#newtag')
cy.get('.tippy-content').should('contain.text', 'Neu:')
cy.get('.tippy-content').should('contain.text', '#newtag')
})
it('does not show "create new" when exact match exists', () => {
const onAddTag = cy.stub().as('addTag')
mount(
<TestEditorWithRouter
content=''
tags={testTags}
editable={true}
enableSuggestions={true}
onAddTag={onAddTag}
/>,
)
cy.get('.ProseMirror').type('#nature')
cy.get('.tippy-content').should('not.contain.text', 'Neu:')
})
it('calls onAddTag when creating new tag', () => {
const onAddTag = cy.stub().as('addTag')
mount(
<TestEditorWithRouter
content=''
tags={testTags}
editable={true}
enableSuggestions={true}
onAddTag={onAddTag}
/>,
)
cy.get('.ProseMirror').type('#brandnew{enter}')
cy.get('@addTag').should('have.been.calledOnce')
cy.get('@addTag')
.its('firstCall.args.0')
.should('deep.include', { name: 'brandnew', color: '#888888' })
})
it('inserts new tag into editor', () => {
const onAddTag = cy.stub()
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
tags={testTags}
editable={true}
enableSuggestions={true}
onAddTag={onAddTag}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('#brandnew{enter}')
cy.get('.hashtag')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('#brandnew')
})
})
})
describe('Click Selection', () => {
it('inserts tag when clicking suggestion', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
tags={testTags}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('#')
cy.get('.tippy-content button').contains('#music').click()
cy.get('.hashtag')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('#music')
})
})
})
describe('Suggestion Styling', () => {
it('applies tag colors in suggestion list', () => {
mount(
<TestEditorWithRouter content='' tags={testTags} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('#nat')
cy.get('.tippy-content button')
.first()
.should('have.css', 'color')
.and('match', /rgb\(34, 197, 94\)|#22[cC]55[eE]/)
})
})
})

View File

@ -0,0 +1,304 @@
/// <reference types="cypress" />
import { mount } from 'cypress/react'
import { TestEditorWithRouter, createTestItem } from './TestEditor'
import type { Editor } from '@tiptap/core'
describe('Item Mention Suggestion System', () => {
const testItems = [
createTestItem('id-1', 'Alice', '#22C55E'),
createTestItem('id-2', 'Bob', '#3B82F6'),
createTestItem('id-3', 'Charlie', '#EF4444'),
createTestItem('id-4', 'Diana', '#F59E0B'),
createTestItem('id-5', 'Eve', '#8B5CF6'),
]
describe('Suggestion Popup Trigger', () => {
it('shows suggestion popup when typing @', () => {
mount(
<TestEditorWithRouter content='' items={testItems} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('@')
cy.get('.tippy-content').should('be.visible')
cy.get('.tippy-content button').should('have.length.at.least', 1)
})
it('shows all items when @ is typed without query', () => {
mount(
<TestEditorWithRouter content='' items={testItems} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('@')
cy.get('.tippy-content button').should('have.length', 5)
})
})
describe('Filtering', () => {
it('filters items based on typed query', () => {
mount(
<TestEditorWithRouter content='' items={testItems} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('@ali')
cy.get('.tippy-content button').should('have.length', 1)
cy.get('.tippy-content button').should('contain.text', '@Alice')
})
it('filters are case-insensitive', () => {
mount(
<TestEditorWithRouter content='' items={testItems} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('@ALICE')
cy.get('.tippy-content button').should('contain.text', '@Alice')
})
it('matches items containing query (not just starts with)', () => {
mount(
<TestEditorWithRouter content='' items={testItems} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('@li')
cy.get('.tippy-content button').should('contain.text', '@Alice')
cy.get('.tippy-content button').should('contain.text', '@Charlie')
})
it('shows empty state when no items match', () => {
mount(
<TestEditorWithRouter content='' items={testItems} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('@xyz123')
cy.get('.tippy-content').should('contain.text', 'Keine Ergebnisse')
})
})
describe('Keyboard Selection', () => {
it('selects first item with Enter', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
items={testItems}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('@ali{enter}')
cy.get('.item-mention')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('[@Alice]')
expect(markdown).to.include('/item/id-1')
})
})
it('navigates with ArrowDown and selects with Enter', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
items={testItems}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('@')
cy.get('.tippy-content button').first().should('have.class', 'tw:bg-base-200')
cy.get('.ProseMirror').type('{downarrow}')
cy.get('.tippy-content button').eq(1).should('have.class', 'tw:bg-base-200')
cy.get('.ProseMirror').type('{enter}')
cy.get('.item-mention')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('[@Bob]')
})
})
it('closes popup with Escape', () => {
mount(
<TestEditorWithRouter content='' items={testItems} editable={true} enableSuggestions={true} />,
)
cy.get('.ProseMirror').type('@')
cy.get('.tippy-content').should('exist')
cy.get('.ProseMirror').type('{esc}')
cy.get('.tippy-box').should('not.exist')
})
})
describe('Click Selection', () => {
it('inserts mention when clicking suggestion', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
items={testItems}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('@')
cy.get('.tippy-content button').contains('@Charlie').click()
cy.get('.item-mention')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('[@Charlie]')
expect(markdown).to.include('/item/id-3')
})
})
})
describe('Suggestion Styling', () => {
it('applies item colors in suggestion list via getItemColor', () => {
const getItemColor = (item: any) => item?.color ?? '#000000'
mount(
<TestEditorWithRouter
content=''
items={testItems}
editable={true}
enableSuggestions={true}
getItemColor={getItemColor}
/>,
)
cy.get('.ProseMirror').type('@ali')
cy.get('.tippy-content button')
.first()
.should('have.css', 'color')
.and('match', /rgb\(34, 197, 94\)|#22[cC]55[eE]/)
})
it('uses getItemColor function when provided', () => {
const getItemColor = () => '#FF00FF'
mount(
<TestEditorWithRouter
content=''
items={testItems}
editable={true}
enableSuggestions={true}
getItemColor={getItemColor}
/>,
)
cy.get('.ProseMirror').type('@')
cy.get('.tippy-content button')
.first()
.should('have.css', 'color')
.and('match', /rgb\(255, 0, 255\)|#[fF]{2}00[fF]{2}/)
})
})
describe('Markdown Serialization', () => {
it('serializes item mention to correct markdown format', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
items={testItems}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('@dia{enter}')
cy.get('.item-mention')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.match(/\[@Diana\]\(\/item\/id-4\)/)
})
})
it('inserts space after mention for continued typing', () => {
let editorInstance: Editor
mount(
<TestEditorWithRouter
content=''
items={testItems}
editable={true}
enableSuggestions={true}
onReady={(editor) => {
editorInstance = editor
}}
/>,
)
cy.get('.ProseMirror').type('@eve{enter}is here')
cy.get('.item-mention')
.should('exist')
.then(() => {
const markdown = editorInstance.getMarkdown()
expect(markdown).to.include('@Eve')
expect(markdown).to.include('is here')
})
})
})
describe('Edge Cases', () => {
it('handles items without names', () => {
const itemsWithEmpty = [...testItems, { id: 'no-name', name: '' } as any]
mount(
<TestEditorWithRouter
content=''
items={itemsWithEmpty}
editable={true}
enableSuggestions={true}
/>,
)
cy.get('.ProseMirror').type('@')
cy.get('.tippy-content button').should('have.length', 5)
})
it('limits suggestions to 8 items', () => {
const manyItems = Array.from({ length: 15 }, (_, i) =>
createTestItem(`id-${i}`, `Person ${i}`),
)
mount(
<TestEditorWithRouter
content=''
items={manyItems}
editable={true}
enableSuggestions={true}
/>,
)
cy.get('.ProseMirror').type('@')
cy.get('.tippy-content button').should('have.length', 8)
})
})
})

View File

@ -0,0 +1,183 @@
/// <reference types="cypress" />
import { mount } from 'cypress/react'
import { createRef } from 'react'
import { SuggestionList, SuggestionListRef } from '#components/TipTap/extensions/suggestions/SuggestionList'
import { createTestItem, createTestTag } from './TestEditor'
describe('SuggestionList Component', () => {
describe('Rendering', () => {
it('renders hashtag items with # prefix', () => {
const tags = [createTestTag('nature'), createTestTag('coding')]
const command = cy.stub()
mount(<SuggestionList items={tags} command={command} type='hashtag' />)
cy.get('button').should('have.length', 2)
cy.get('button').first().should('contain.text', '#nature')
cy.get('button').last().should('contain.text', '#coding')
})
it('renders item mentions with @ prefix', () => {
const items = [createTestItem('1', 'Alice'), createTestItem('2', 'Bob')]
const command = cy.stub()
mount(<SuggestionList items={items} command={command} type='item' />)
cy.get('button').should('have.length', 2)
cy.get('button').first().should('contain.text', '@Alice')
cy.get('button').last().should('contain.text', '@Bob')
})
it('renders "new tag" option with Neu: prefix', () => {
const items = [createTestTag('existing'), { isNew: true, name: 'newtag' }]
const command = cy.stub()
mount(<SuggestionList items={items} command={command} type='hashtag' />)
cy.get('button').should('have.length', 2)
cy.get('button').last().should('contain.text', 'Neu:')
cy.get('button').last().should('contain.text', '#newtag')
})
it('renders empty state message when no items', () => {
const command = cy.stub()
mount(<SuggestionList items={[]} command={command} type='hashtag' />)
cy.get('button').should('not.exist')
cy.contains('Keine Ergebnisse').should('exist')
})
it('applies tag color to hashtag items', () => {
const tags = [createTestTag('nature', '#22C55E')]
const command = cy.stub()
mount(<SuggestionList items={tags} command={command} type='hashtag' />)
cy.get('button')
.first()
.should('have.css', 'color')
.and('match', /rgb\(34, 197, 94\)|#22[cC]55[eE]/)
})
it('applies item color via getItemColor', () => {
const items = [createTestItem('1', 'Alice')]
const getItemColor = () => '#EF4444'
const command = cy.stub()
mount(<SuggestionList items={items} command={command} type='item' getItemColor={getItemColor} />)
cy.get('button')
.first()
.should('have.css', 'color')
.and('match', /rgb\(239, 68, 68\)|#[eE][fF]4444/)
})
it('highlights first item by default', () => {
const tags = [createTestTag('first'), createTestTag('second')]
const command = cy.stub()
mount(<SuggestionList items={tags} command={command} type='hashtag' />)
cy.get('button').first().should('have.class', 'tw:bg-base-200')
cy.get('button').last().should('not.have.class', 'tw:bg-base-200')
})
})
describe('Click Selection', () => {
it('calls command with clicked item', () => {
const tags = [createTestTag('nature'), createTestTag('coding')]
const command = cy.stub().as('command')
mount(<SuggestionList items={tags} command={command} type='hashtag' />)
cy.get('button').last().click()
cy.get('@command').should('have.been.calledOnceWith', tags[1])
})
it('calls command with new tag item', () => {
const newItem = { isNew: true, name: 'newtag' }
const items = [newItem]
const command = cy.stub().as('command')
mount(<SuggestionList items={items} command={command} type='hashtag' />)
cy.get('button').click()
cy.get('@command').should('have.been.calledOnceWith', newItem)
})
})
describe('Keyboard Navigation', () => {
it('navigates down with ArrowDown', () => {
const tags = [createTestTag('first'), createTestTag('second'), createTestTag('third')]
const ref = createRef<SuggestionListRef>()
const command = cy.stub()
mount(<SuggestionList ref={ref} items={tags} command={command} type='hashtag' />)
cy.get('button').first().should('have.class', 'tw:bg-base-200')
cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
cy.get('button').eq(1).should('have.class', 'tw:bg-base-200')
cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
cy.get('button').eq(2).should('have.class', 'tw:bg-base-200')
})
it('navigates up with ArrowUp', () => {
const tags = [createTestTag('first'), createTestTag('second')]
const ref = createRef<SuggestionListRef>()
const command = cy.stub()
mount(<SuggestionList ref={ref} items={tags} command={command} type='hashtag' />)
cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' })))
cy.get('button').last().should('have.class', 'tw:bg-base-200')
})
it('wraps around at end of list', () => {
const tags = [createTestTag('first'), createTestTag('second')]
const ref = createRef<SuggestionListRef>()
const command = cy.stub()
mount(<SuggestionList ref={ref} items={tags} command={command} type='hashtag' />)
cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
cy.get('button').first().should('have.class', 'tw:bg-base-200')
})
it('selects item with Enter', () => {
const tags = [createTestTag('first'), createTestTag('second')]
const ref = createRef<SuggestionListRef>()
const command = cy.stub().as('command')
mount(<SuggestionList ref={ref} items={tags} command={command} type='hashtag' />)
cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
cy.get('button').eq(1).should('have.class', 'tw:bg-base-200')
cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })))
cy.get('@command').should('have.been.calledOnceWith', tags[1])
})
it('resets selection index when items change', () => {
const tags1 = [createTestTag('aaa'), createTestTag('aab')]
const tags2 = [createTestTag('bbb'), createTestTag('bbc'), createTestTag('bbd')]
const ref = createRef<SuggestionListRef>()
const command = cy.stub()
mount(<SuggestionList ref={ref} items={tags1} command={command} type='hashtag' />).then(
({ rerender }) => {
cy.then(() => ref.current?.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })))
cy.get('button').eq(1).should('have.class', 'tw:bg-base-200')
rerender(<SuggestionList ref={ref} items={tags2} command={command} type='hashtag' />)
cy.get('button').first().should('have.class', 'tw:bg-base-200')
},
)
})
})
})

View File

@ -3,10 +3,16 @@ import { Link } from '@tiptap/extension-link'
import { Markdown } from '@tiptap/markdown'
import { EditorContent, useEditor } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { MemoryRouter } from 'react-router-dom'
import { Hashtag, ItemMention, VideoEmbed } from '#components/TipTap/extensions'
import {
Hashtag,
ItemMention,
VideoEmbed,
createHashtagSuggestion,
createItemMentionSuggestion,
} from '#components/TipTap/extensions'
import { createConfiguredMarked } from '#components/TipTap/utils/configureMarked'
import type { Item } from '#types/Item'
@ -20,10 +26,12 @@ export interface TestEditorProps {
editable?: boolean
tags?: Tag[]
onTagClick?: (tag: Tag) => void
onAddTag?: (tag: Tag) => void
items?: Item[]
getItemColor?: (item: Item | undefined, fallback?: string) => string
onReady?: (editor: Editor) => void
testId?: string
enableSuggestions?: boolean
}
export function TestEditor({
@ -31,11 +39,23 @@ export function TestEditor({
editable = false,
tags = [],
onTagClick,
onAddTag,
items = [],
getItemColor,
onReady,
testId = 'test-editor',
enableSuggestions = false,
}: TestEditorProps) {
const hashtagSuggestion = useMemo(
() => (enableSuggestions ? createHashtagSuggestion(tags, onAddTag) : undefined),
[tags, onAddTag, enableSuggestions],
)
const itemMentionSuggestion = useMemo(
() => (enableSuggestions ? createItemMentionSuggestion(items, getItemColor) : undefined),
[items, getItemColor, enableSuggestions],
)
const editor = useEditor({
extensions: [
StarterKit.configure({
@ -51,10 +71,12 @@ export function TestEditor({
Hashtag.configure({
tags,
onTagClick,
suggestion: hashtagSuggestion,
}),
ItemMention.configure({
items,
getItemColor,
suggestion: itemMentionSuggestion,
}),
],
content,