From c48aed88cd21d42bde3e75e89166fac062caa972 Mon Sep 17 00:00:00 2001 From: mahula Date: Mon, 19 Jan 2026 10:52:37 +0100 Subject: [PATCH] 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. --- .../component/TipTap/HashtagSuggestion.cy.tsx | 293 +++++++++++++++++ .../TipTap/ItemMentionSuggestion.cy.tsx | 304 ++++++++++++++++++ .../component/TipTap/SuggestionList.cy.tsx | 183 +++++++++++ lib/cypress/component/TipTap/TestEditor.tsx | 26 +- 4 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 lib/cypress/component/TipTap/HashtagSuggestion.cy.tsx create mode 100644 lib/cypress/component/TipTap/ItemMentionSuggestion.cy.tsx create mode 100644 lib/cypress/component/TipTap/SuggestionList.cy.tsx diff --git a/lib/cypress/component/TipTap/HashtagSuggestion.cy.tsx b/lib/cypress/component/TipTap/HashtagSuggestion.cy.tsx new file mode 100644 index 00000000..1dcbd92d --- /dev/null +++ b/lib/cypress/component/TipTap/HashtagSuggestion.cy.tsx @@ -0,0 +1,293 @@ +/// +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( + , + ) + + 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( + , + ) + + cy.get('.ProseMirror').type('#') + cy.get('.tippy-content button').should('have.length', 5) + }) + + it('does not show popup when # is inside a word', () => { + mount( + , + ) + + cy.get('.ProseMirror').type('email#test') + cy.get('.tippy-content').should('be.visible') + }) + }) + + describe('Filtering', () => { + it('filters tags based on typed query', () => { + mount( + , + ) + + 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( + , + ) + + 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() + + 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( + { + 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( + { + 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( + , + ) + + 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( + { + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + { + 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( + { + 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( + , + ) + + 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]/) + }) + }) +}) + diff --git a/lib/cypress/component/TipTap/ItemMentionSuggestion.cy.tsx b/lib/cypress/component/TipTap/ItemMentionSuggestion.cy.tsx new file mode 100644 index 00000000..f1b1f845 --- /dev/null +++ b/lib/cypress/component/TipTap/ItemMentionSuggestion.cy.tsx @@ -0,0 +1,304 @@ +/// +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( + , + ) + + 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( + , + ) + + cy.get('.ProseMirror').type('@') + cy.get('.tippy-content button').should('have.length', 5) + }) + }) + + describe('Filtering', () => { + it('filters items based on typed query', () => { + mount( + , + ) + + 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( + , + ) + + cy.get('.ProseMirror').type('@ALICE') + cy.get('.tippy-content button').should('contain.text', '@Alice') + }) + + it('matches items containing query (not just starts with)', () => { + mount( + , + ) + + 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( + , + ) + + 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( + { + 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( + { + 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( + , + ) + + 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( + { + 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( + , + ) + + 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( + , + ) + + 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( + { + 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( + { + 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( + , + ) + + 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( + , + ) + + cy.get('.ProseMirror').type('@') + cy.get('.tippy-content button').should('have.length', 8) + }) + }) +}) + diff --git a/lib/cypress/component/TipTap/SuggestionList.cy.tsx b/lib/cypress/component/TipTap/SuggestionList.cy.tsx new file mode 100644 index 00000000..9bb6bedf --- /dev/null +++ b/lib/cypress/component/TipTap/SuggestionList.cy.tsx @@ -0,0 +1,183 @@ +/// +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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + const command = cy.stub() + + mount() + + 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() + const command = cy.stub() + + mount() + + 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() + const command = cy.stub() + + mount() + + 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() + const command = cy.stub().as('command') + + mount() + + 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() + const command = cy.stub() + + mount().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() + cy.get('button').first().should('have.class', 'tw:bg-base-200') + }, + ) + }) + }) +}) + diff --git a/lib/cypress/component/TipTap/TestEditor.tsx b/lib/cypress/component/TipTap/TestEditor.tsx index 391a97a5..8ac79ed2 100644 --- a/lib/cypress/component/TipTap/TestEditor.tsx +++ b/lib/cypress/component/TipTap/TestEditor.tsx @@ -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,