From d1bab299abd8543093df614488a20eb065225485 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 23 Sep 2026 18:22:24 +0900 Subject: [PATCH] =?UTF-8?q?#419=20=E3=83=95=E3=83=AD=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controllers/nico_tags_controller.rb | 1 + .../DraggableDroppableTagRow.test.tsx | 29 +++++++- .../src/components/TagDetailSidebar.test.tsx | 66 +++++++++++++++++++ frontend/src/components/TagLink.test.tsx | 27 ++++++++ frontend/src/lib/posts.test.ts | 21 ++++++ frontend/src/lib/prefetchers.test.ts | 15 +++++ .../src/pages/posts/PostHistoryPage.test.tsx | 44 +++++++++++++ frontend/src/pages/tags/TagListPage.test.tsx | 47 ++++++++++++- 8 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/TagDetailSidebar.test.tsx create mode 100644 frontend/src/pages/posts/PostHistoryPage.test.tsx diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index c897fe0..ea02a12 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -24,6 +24,7 @@ class NicoTagsController < ApplicationController ExternalTag .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ 'ON post_tag_max.external_tag_id = external_tags.id') + .includes(linked_tags: { tag_name: :wiki_page }) if name q = q.where(('external_tags.name LIKE ? ' + "OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"), diff --git a/frontend/src/components/DraggableDroppableTagRow.test.tsx b/frontend/src/components/DraggableDroppableTagRow.test.tsx index 8469831..43a32aa 100644 --- a/frontend/src/components/DraggableDroppableTagRow.test.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.test.tsx @@ -13,11 +13,14 @@ vi.mock ('@dnd-kit/core', () => dndKit) const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 }) -const renderRow = (activeDndId?: string) => { +const renderRow = ( + activeDndId?: string, + renderedTag = tag, +) => { renderWithProviders ( , @@ -72,4 +75,26 @@ describe ('DraggableDroppableTagRow', () => { renderRow ('tag-node:other') expect (tagBody ()).toHaveStyle ({ visibility: 'visible' }) }) + + it ('disables drag and drop for external tags', () => { + const external = buildTag ({ + id: 7, + name: 'nico:external', + category: 'nico', + }) + + renderRow (undefined, external) + + expect (dndKit.useDraggable).toHaveBeenCalledWith ( + expect.objectContaining ({ + disabled: true, + }), + ) + + expect (dndKit.useDroppable).toHaveBeenCalledWith ( + expect.objectContaining ({ + disabled: true, + }), + ) + }) }) diff --git a/frontend/src/components/TagDetailSidebar.test.tsx b/frontend/src/components/TagDetailSidebar.test.tsx new file mode 100644 index 0000000..e89683f --- /dev/null +++ b/frontend/src/components/TagDetailSidebar.test.tsx @@ -0,0 +1,66 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import TagDetailSidebar from '@/components/TagDetailSidebar' +import { setClientTagRelationDisplayMode } from '@/lib/settings' +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/DraggableDroppableTagRow', () => ({ + default: ({ tag }: { tag: { name: string } }) => ( + {tag.name} + ), +})) + +vi.mock ('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: ReactNode }) => <>{children}, + DragOverlay: ({ children }: { children: ReactNode }) => <>{children}, + MeasuringStrategy: { Always: 'always' }, + MouseSensor: vi.fn (), + TouchSensor: vi.fn (), + pointerWithin: vi.fn (), + useDroppable: vi.fn (() => ({ + setNodeRef: vi.fn (), + isOver: false, + })), + useSensor: vi.fn (() => ({ })), + useSensors: vi.fn (() => []), +})) + +describe ('TagDetailSidebar', () => { + beforeEach (() => { + localStorage.clear () + vi.clearAllMocks () + }) + + it ('keeps internal and external tags with the same numeric id in flat mode', () => { + setClientTagRelationDisplayMode ('flat') + + const internal = buildTag ({ + id: 7, + name: 'internal_collision', + category: 'general', + }) + const external = buildTag ({ + id: 7, + name: 'nico:external_collision', + category: 'nico', + }) + + renderWithProviders ( + , + ) + + expect (screen.getByText ('internal_collision')).toBeInTheDocument () + expect (screen.getByText ('nico:external_collision')).toBeInTheDocument () + }) +}) diff --git a/frontend/src/components/TagLink.test.tsx b/frontend/src/components/TagLink.test.tsx index e6a073c..8922265 100644 --- a/frontend/src/components/TagLink.test.tsx +++ b/frontend/src/components/TagLink.test.tsx @@ -57,4 +57,31 @@ describe ('TagLink', () => { expect (screen.getByText ('正式名')).toBeInTheDocument () expect (screen.queryByRole ('link')).not.toBeInTheDocument () }) + + it ('does not show a missing-information marker for external tags', () => { + renderWithProviders ( + , + ) + + expect ( + screen.getByRole ('link', { name: 'nico:external' }), + ).toBeInTheDocument () + + expect ( + screen.queryByRole ('link', { name: '!' }), + ).not.toBeInTheDocument () + + expect ( + screen.queryByTitle ('nico:external Wiki が存在しません.'), + ).not.toBeInTheDocument () + }) }) diff --git a/frontend/src/lib/posts.test.ts b/frontend/src/lib/posts.test.ts index 600df31..972775d 100644 --- a/frontend/src/lib/posts.test.ts +++ b/frontend/src/lib/posts.test.ts @@ -116,4 +116,25 @@ describe ('posts API functions', () => { { params: { page: 2, limit: 50 } }, ) }) + + it ('maps an explicit external tag history filter to external_tag', async () => { + api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 }) + + await fetchPostChanges ({ + externalTag: '7', + page: 2, + limit: 50, + }) + + expect (api.apiGet).toHaveBeenCalledWith ( + '/posts/versions', + { + params: { + external_tag: '7', + page: 2, + limit: 50, + }, + }, + ) + }) }) diff --git a/frontend/src/lib/prefetchers.test.ts b/frontend/src/lib/prefetchers.test.ts index 8a341a3..b147c79 100644 --- a/frontend/src/lib/prefetchers.test.ts +++ b/frontend/src/lib/prefetchers.test.ts @@ -137,4 +137,19 @@ describe ('prefetchForURL', () => { expect (tagsApi.fetchTags).not.toHaveBeenCalled () expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled () }) + + it ('prefetches external tag post history without treating it as an internal tag', async () => { + await prefetchForURL ( + qc (), + 'http://localhost/posts/changes?external_tag=12&page=2&limit=50', + ) + + expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({ + externalTag: '12', + page: 2, + limit: 50, + }) + + expect (tagsApi.fetchTag).not.toHaveBeenCalled () + }) }) diff --git a/frontend/src/pages/posts/PostHistoryPage.test.tsx b/frontend/src/pages/posts/PostHistoryPage.test.tsx new file mode 100644 index 0000000..44b2ee5 --- /dev/null +++ b/frontend/src/pages/posts/PostHistoryPage.test.tsx @@ -0,0 +1,44 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import PostHistoryPage from '@/pages/posts/PostHistoryPage' +import { renderWithProviders } from '@/test/render' + +const postsApi = vi.hoisted (() => ({ + fetchPostChanges: vi.fn (), + updatePost: vi.fn (), +})) + +const tagsApi = vi.hoisted (() => ({ + fetchTag: vi.fn (), +})) + +vi.mock ('@/lib/posts', () => postsApi) +vi.mock ('@/lib/tags', () => tagsApi) + +describe ('PostHistoryPage', () => { + beforeEach (() => { + vi.clearAllMocks () + postsApi.fetchPostChanges.mockResolvedValue ({ + versions: [], + count: 0, + }) + }) + + it ('filters by external_tag without resolving the id as an internal tag', async () => { + renderWithProviders ( + , + { route: '/posts/changes?external_tag=7' }, + ) + + await waitFor (() => { + expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({ + externalTag: '7', + page: 1, + limit: 20, + }) + }) + + expect (tagsApi.fetchTag).not.toHaveBeenCalled () + }) +}) diff --git a/frontend/src/pages/tags/TagListPage.test.tsx b/frontend/src/pages/tags/TagListPage.test.tsx index 3d0e9ae..a2d6763 100644 --- a/frontend/src/pages/tags/TagListPage.test.tsx +++ b/frontend/src/pages/tags/TagListPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagListPage from '@/pages/tags/TagListPage' @@ -127,4 +127,49 @@ describe ('TagListPage', () => { ) }) }) + + it ('keeps colliding internal and external tags on their own routes', async () => { + const internal = buildTag ({ + id: 7, + name: 'internal_collision', + category: 'general', + }) + const external = buildTag ({ + id: 7, + name: 'nico:external_collision', + category: 'nico', + }) + + tagsApi.fetchTags.mockResolvedValueOnce ({ + tags: [internal, external], + count: 2, + }) + + renderWithProviders (, { route: '/tags' }) + + const internalLink = + await screen.findByRole ('link', { name: 'internal_collision' }) + const externalLink = + screen.getByRole ('link', { name: 'nico:external_collision' }) + + expect (internalLink).toHaveAttribute ('href', '/tags/7') + expect (externalLink).toHaveAttribute ( + 'href', + '/tags/nico?name=nico%3Aexternal_collision', + ) + + const internalRow = internalLink.closest ('tr') + const externalRow = externalLink.closest ('tr') + + expect (internalRow).not.toBeNull () + expect (externalRow).not.toBeNull () + + expect ( + within (internalRow!).getByRole ('link', { name: '耕作履歴' }), + ).toHaveAttribute ('href', '/posts/changes?tag=7') + + expect ( + within (externalRow!).getByRole ('link', { name: '耕作履歴' }), + ).toHaveAttribute ('href', '/posts/changes?external_tag=7') + }) })