コミットを比較

..
4 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 5303eaa31a AI: address review on PR #463 2026-11-12 19:39:55 +09:00
Codex Night Worker f39aa04e9a AI: address issue #448 2026-11-12 19:36:05 +09:00
みてるぞ f05d8a4b3a 集計で独裁者以外がほとんど検索条件を指定できないバグ (#444) (#452)
Reviewed-on: #452
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-09 00:03:26 +09:00
みてるぞ dbae864f24 広場のタグ検索がスマホで利かない問題 (#435) (#453)
Reviewed-on: #453
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-08 14:52:38 +09:00
6個のファイルの変更、259行の追加、27行の削除
+44
ファイルの表示
@@ -1,9 +1,25 @@
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { CSSProperties, ReactNode } from 'react'
vi.mock ('framer-motion', async importOriginal => {
const original = await importOriginal<typeof import ('framer-motion')> ()
return {
...original,
motion: {
...original.motion,
div: ({ children, style, layoutId }: {
children?: ReactNode
style?: CSSProperties
layoutId?: string }) => (
<div style={style} data-layout-id={layoutId}>{children}</div>) } }
})
const dndKit = vi.hoisted (() => ({
useDraggable: vi.fn (),
useDroppable: vi.fn (),
@@ -97,4 +113,32 @@ describe ('DraggableDroppableTagRow', () => {
}),
)
})
it.each ([false, true]) (
'separates same-id internal and external animation identities (sp=%s)',
sp => {
const internal = buildTag ({ id: 7, name: 'internal_collision' })
const external = buildTag ({
id: 7, name: 'nico:external_collision', category: 'nico' })
renderWithProviders (
<>
{[internal, external].map (item => (
<DraggableDroppableTagRow
key={item.category}
tag={item}
nestLevel={0}
pathKey={`cat-${ item.category }-7`}
suppressClickRef={{ current: false }}
sp={sp}/>))}
</>)
const prefix = sp ? 'tag-sp-' : 'tag-'
for (const item of [internal, external])
{
expect (screen.getByRole ('link', { name: item.name })
.closest ('[data-layout-id]')).toHaveAttribute (
'data-layout-id', `${ prefix }${ item.category }-7`)
}
})
})
+1 -1
ファイルの表示
@@ -137,7 +137,7 @@ const DraggableDroppableTagRow: FC<Props> = ({
transition={{ layout: layoutTransition }}
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
: `tag-${ sp ? 'sp-' : '' }${ tag.category }-${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>)}
</div>)
+120
ファイルの表示
@@ -0,0 +1,120 @@
import { 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'
import TagSearch from '@/components/TagSearch'
import * as api from '@/lib/api'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const LocationOutput = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderSearch = () => renderWithProviders (
<>
<TagSearch/>
<LocationOutput/>
</>, { route: '/ja/posts?page=3' })
describe ('TagSearch', () => {
beforeEach (() => {
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
})
it ('searches through native submission without an Enter key event', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
expect (input.form).toBeInstanceOf (HTMLFormElement)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
expect (input).toHaveValue ('虹夏 not:test type:or')
})
it ('requests a search action from the software keyboard', () => {
renderSearch ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
})
it ('searches through the form when Enter is pressed', async () => {
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it.each ([
{ label: 'composition flag', composing: true, keyCode: 13 },
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: composing, keyCode })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: false, keyCode: 13 })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('blocks direct submission during composition and allows it after confirmation', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
fireEvent.submit (input.form!)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
fireEvent.compositionEnd (input)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it ('selects the active suggestion without submitting the form', async () => {
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹')
await screen.findByText ('虹夏')
await user.keyboard ('{ArrowDown}{Enter}')
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
})
+44 -24
ファイルの表示
@@ -1,6 +1,6 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox'
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
import type { Tag } from '@/types'
@@ -20,6 +20,7 @@ const TagSearch: FC = () => {
const locale = useLocale ()
const location = useLocation ()
const navigate = useLocaleNavigate ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('')
@@ -74,7 +75,34 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false)
}, [locale])
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
if (compositionFlgRef.current)
return
const parts = search.split (' ')
const match =
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match })).toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| ev.nativeEvent.isComposing
|| ev.nativeEvent.keyCode === 229)
{
if (ev.key === 'Enter')
ev.preventDefault ()
return
}
switch (ev.key)
{
case 'ArrowDown':
@@ -90,13 +118,12 @@ const TagSearch: FC = () => {
break
case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
{
const selected = suggestions[activeIndex]
if (selected)
handleTagSelect (selected)
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
if (selected == null)
break
ev.preventDefault ()
handleTagSelect (selected)
}
break
@@ -105,20 +132,6 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
const parts = search.split (' ')
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match }))
.toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
}
const handleTagSelect = (tag: Tag) => {
@@ -144,8 +157,9 @@ const TagSearch: FC = () => {
}, [location.search])
return (
<div className="relative w-full">
<form onSubmit={handleSubmit} className="relative w-full">
<input type="text"
enterKeyHint="search"
data-shortcut-focus-search="true"
placeholder={message ('labels.tagSearch').text}
value={search}
@@ -153,12 +167,18 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
onCompositionStart={() => {
compositionFlgRef.current = true
}}
onCompositionEnd={() => {
compositionFlgRef.current = false
}}
className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
</form>)
}
export default TagSearch
+47
ファイルの表示
@@ -0,0 +1,47 @@
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import TagSidebar from '@/components/TagSidebar'
import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
vi.mock ('@/components/TagSearch', () => ({ default: () => null }))
vi.mock ('@/components/layout/SidebarComponent', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</> }))
vi.mock ('framer-motion', async importOriginal => {
const original = await importOriginal<typeof import ('framer-motion')> ()
return {
...original,
motion: {
...original.motion,
div: ({ children, layoutId }: {
children?: ReactNode
layoutId?: string }) => (
<div data-layout-id={layoutId}>{children}</div>) } }
})
describe ('TagSidebar', () => {
it ('keeps same-id internal and external tags with distinct identities', () => {
const errors = vi.spyOn (console, 'error').mockImplementation (() => {})
const internal = buildTag ({ id: 7, name: 'internal_collision' })
const external = buildTag ({
id: 7, name: 'nico:external_collision', category: 'nico' })
renderWithProviders (
<TagSidebar posts={[buildPost ({ tags: [internal, external] })]}/>)
for (const item of [internal, external])
{
const link = screen.getByRole ('link', { name: item.name })
expect (link).toBeInTheDocument ()
expect (link.closest ('[data-layout-id]')).toHaveAttribute (
'data-layout-id', `tag-${ item.category }-7`)
}
expect (errors.mock.calls.filter (args =>
args.some (value => String (value).includes ('same key')))).toEqual ([])
})
})
+3 -2
ファイルの表示
@@ -84,7 +84,8 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
<ul>
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden">
<li key={`${ cat }-${ tag.id }`}
className="mb-1 min-w-0 max-w-full overflow-hidden">
{animationsOff
? (
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
@@ -94,7 +95,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: layoutTransition }}
layoutId={`tag-${ tag.id }`}>
layoutId={`tag-${ cat }-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>)}
</li>))) : [])}