From 1b22ed43bd3e4c69bc16b7fdb15568ad9e9a3ee8 Mon Sep 17 00:00:00 2001 From: Codex Night Worker Date: Fri, 13 Nov 2026 12:48:30 +0900 Subject: [PATCH] AI: address review on PR #470 --- .../src/components/PostFormTagsArea.test.tsx | 32 +++++++--- frontend/src/components/TagSearch.test.tsx | 39 +++++++++++- .../src/components/common/TagInput.test.tsx | 63 ++++++++++++++++--- 3 files changed, 113 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/PostFormTagsArea.test.tsx b/frontend/src/components/PostFormTagsArea.test.tsx index 5687cd2..ebda4a3 100644 --- a/frontend/src/components/PostFormTagsArea.test.tsx +++ b/frontend/src/components/PostFormTagsArea.test.tsx @@ -12,24 +12,36 @@ const api = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) describe ('PostFormTagsArea', () => { - it ('updates text and fetches autocomplete for the selected token', async () => { + it ('replaces the selected token and restores the caret without losing other tags', async () => { const setTags = vi.fn () api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })]) - renderWithProviders () + const { rerender } = renderWithProviders ( + ) - const textarea = screen.getByRole ('textbox') + const textarea = screen.getByRole ('textbox') fireEvent.focus (textarea) - fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } }) - fireEvent.change (textarea, { target: { value: '虹夏' } }) + fireEvent.change (textarea, { target: { value: 'ぼっち 虹な 後続' } }) + rerender () + fireEvent.select (textarea, { target: { selectionStart: 6, selectionEnd: 6 } }) await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( - '/tags/autocomplete', - { locale: 'ja', params: { q: '虹', nico: '0' }, - signal: expect.any (AbortSignal) }, - ) + '/tags/autocomplete', { + locale: 'ja', params: { q: '虹な', nico: '0' }, + signal: expect.any (AbortSignal) }) }) - expect (setTags).toHaveBeenCalledWith ('虹夏') + expect (setTags).toHaveBeenCalledWith ('ぼっち 虹な 後続') + + fireEvent.mouseDown (await screen.findByText ('虹夏')) + expect (setTags).toHaveBeenLastCalledWith ('ぼっち 虹夏 後続') + rerender () + + await waitFor (() => { + expect (textarea).toHaveFocus () + expect (textarea.selectionStart).toBe (7) + expect (textarea.selectionEnd).toBe (7) + }) + expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () }) }) diff --git a/frontend/src/components/TagSearch.test.tsx b/frontend/src/components/TagSearch.test.tsx index b66e411..4f87e6d 100644 --- a/frontend/src/components/TagSearch.test.tsx +++ b/frontend/src/components/TagSearch.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from '@testing-library/react' +import { act, fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,6 +8,8 @@ import * as api from '@/lib/api' import { buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import type { Tag } from '@/types' + const LocationOutput = () => { const location = useLocation () @@ -117,4 +119,39 @@ describe ('TagSearch', () => { expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3') expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () }) + + it ('aborts obsolete requests and ignores their late suggestions', async () => { + const responses: ((tags: Tag[]) => void)[] = [] + vi.mocked (api.apiGet).mockImplementation (() => new Promise (resolve => { + responses.push (resolve) + })) + const { unmount } = renderSearch () + const input = screen.getByRole ('textbox') + + fireEvent.change (input, { target: { value: '虹' } }) + const previousSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal + expect (previousSignal).toBeInstanceOf (AbortSignal) + expect (previousSignal?.aborted).toBe (false) + + fireEvent.change (input, { target: { value: 'ぼ' } }) + expect (previousSignal?.aborted).toBe (true) + expect (api.apiGet).toHaveBeenLastCalledWith ('/tags/autocomplete', { + locale: 'ja', params: { q: 'ぼ', nico: '1' }, + signal: expect.any (AbortSignal) }) + const currentSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal + + await act (async () => { + responses[1] ([buildTag ({ name: 'ぼっち' })]) + }) + expect (screen.getByText ('ぼっち')).toBeInTheDocument () + + await act (async () => { + responses[0] ([buildTag ({ name: '虹夏' })]) + }) + expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () + expect (screen.getByText ('ぼっち')).toBeInTheDocument () + + unmount () + expect (currentSignal?.aborted).toBe (true) + }) }) diff --git a/frontend/src/components/common/TagInput.test.tsx b/frontend/src/components/common/TagInput.test.tsx index af8cb9a..1a4ab7d 100644 --- a/frontend/src/components/common/TagInput.test.tsx +++ b/frontend/src/components/common/TagInput.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagInput from '@/components/common/TagInput' @@ -20,18 +20,20 @@ describe ('TagInput', () => { const setValue = vi.fn () api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })]) - render () + const { rerender } = render ( + ) fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } }) + rerender () await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( - '/tags/autocomplete', - { locale: 'ja', params: { q: '虹夏', nico: '0' }, - signal: expect.any (AbortSignal) }, - ) + '/tags/autocomplete', { + locale: 'ja', params: { q: '虹夏', nico: '0' }, + signal: expect.any (AbortSignal) }) }) expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') + expect (await screen.findByText ('虹夏')).toBeInTheDocument () }) it ('does not fetch when the last token is blank', () => { @@ -54,10 +56,51 @@ describe ('TagInput', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( - '/tags/autocomplete', - { locale: 'ja', params: { q: '虹夏', nico: '1' }, - signal: expect.any (AbortSignal) }, - ) + '/tags/autocomplete', { + locale: 'ja', params: { q: '虹夏', nico: '1' }, + signal: expect.any (AbortSignal) }) }) }) + + it ('preserves negation and blocks suggestion selection during composition', async () => { + const setValue = vi.fn () + api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏' })]) + const { rerender } = render () + const input = screen.getByRole ('textbox') + + fireEvent.change (input, { target: { value: 'ぼっち NOT:虹' } }) + rerender () + await screen.findByText ('虹夏') + expect (api.apiGet).toHaveBeenLastCalledWith ( + '/tags/autocomplete', { + locale: 'ja', params: { q: '虹', nico: '0' }, + signal: expect.any (AbortSignal) }) + + fireEvent.keyDown (input, { key: 'ArrowDown' }) + fireEvent.compositionStart (input) + expect (fireEvent.keyDown (input, { key: 'Enter' })).toBe (false) + expect (setValue).toHaveBeenCalledTimes (1) + + fireEvent.compositionEnd (input) + fireEvent.keyDown (input, { key: 'Enter' }) + + expect (setValue).toHaveBeenLastCalledWith ('ぼっち not:虹夏 ') + expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () + }) + + it.each ([false, true]) ('includes unused tags only with includeUnused=%s', async unusedFlg => { + const tag = buildTag ({ name: '虹夏', postCount: 0 }) + const response = Promise.resolve ([tag]) + api.apiGet.mockReturnValueOnce (response) + const props = { setValue: vi.fn (), includeUnused: unusedFlg } + const { rerender } = render () + + fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹' } }) + rerender () + await act (async () => { + await response + }) + + expect (screen.queryByText ('虹夏') != null).toBe (unusedFlg) + }) })