From 3898fbc156126d4e3e54ab92a66103b48c9fbcae Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 7 Jul 2026 00:38:18 +0900 Subject: [PATCH] #34 --- backend/spec/models/setting_spec.rb | 34 ++++++ backend/spec/models/user_theme_slot_spec.rb | 40 +++++++ backend/spec/requests/user_settings_spec.rb | 98 ++++++++++++++++ .../spec/requests/user_theme_slots_spec.rb | 104 +++++++++++++++++ .../src/components/common/TabGroup.test.tsx | 12 ++ frontend/src/lib/keyboardShortcuts.test.ts | 65 +++++++++++ frontend/src/lib/settings.test.ts | 108 ++++++++++++++++++ frontend/src/lib/useKeyboardShortcuts.tsx | 21 +++- .../src/pages/posts/PostListPage.test.tsx | 41 ++++++- frontend/src/pages/posts/PostNewPage.test.tsx | 10 +- .../src/pages/posts/PostSearchPage.test.tsx | 40 +++++++ .../src/pages/tags/NicoTagListPage.test.tsx | 2 + frontend/src/pages/tags/TagListPage.test.tsx | 51 ++++++++- .../pages/theatres/TheatreDetailPage.test.tsx | 1 + frontend/src/pages/users/SettingPage.test.tsx | 30 +++-- frontend/src/pages/users/SettingPage.tsx | 9 +- frontend/src/test/render.tsx | 18 ++- frontend/src/test/setup.ts | 7 ++ 18 files changed, 660 insertions(+), 31 deletions(-) create mode 100644 backend/spec/models/setting_spec.rb create mode 100644 backend/spec/models/user_theme_slot_spec.rb create mode 100644 backend/spec/requests/user_settings_spec.rb create mode 100644 backend/spec/requests/user_theme_slots_spec.rb create mode 100644 frontend/src/lib/keyboardShortcuts.test.ts create mode 100644 frontend/src/lib/settings.test.ts diff --git a/backend/spec/models/setting_spec.rb b/backend/spec/models/setting_spec.rb new file mode 100644 index 0000000..41ae262 --- /dev/null +++ b/backend/spec/models/setting_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Setting, type: :model do + it 'accepts the default typed user settings' do + setting = described_class.new({ user: create(:user) }.merge(described_class.defaults)) + + expect(setting).to be_valid + end + + it 'requires one settings row per user' do + user = create(:user) + described_class.create!({ user: }.merge(described_class.defaults)) + + duplicate = described_class.new({ user: }.merge(described_class.defaults)) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + + it 'validates enum-like settings columns' do + setting = described_class.new( + { user: create(:user), + theme: 'neon', + auto_fetch_title: 'sometimes', + auto_fetch_thumbnail: 'sometimes', + wiki_editor_mode: 'sideways' }) + + expect(setting).not_to be_valid + expect(setting.errors[:theme]).to be_present + expect(setting.errors[:auto_fetch_title]).to be_present + expect(setting.errors[:auto_fetch_thumbnail]).to be_present + expect(setting.errors[:wiki_editor_mode]).to be_present + end +end diff --git a/backend/spec/models/user_theme_slot_spec.rb b/backend/spec/models/user_theme_slot_spec.rb new file mode 100644 index 0000000..9ca4a2c --- /dev/null +++ b/backend/spec/models/user_theme_slot_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe UserThemeSlot, type: :model do + it 'accepts a theme slot for one user, base theme, and slot number' do + slot = described_class.new(user: create(:user), + base_theme: 'light', + slot_no: 1, + tokens: { 'background' => '0 0% 100%' }) + + expect(slot).to be_valid + end + + it 'requires unique slot numbers per user and base theme' do + user = create(:user) + described_class.create!(user:, + base_theme: 'dark', + slot_no: 2, + tokens: { 'background' => '222.2 84% 4.9%' }) + + duplicate = described_class.new(user:, + base_theme: 'dark', + slot_no: 2, + tokens: { 'background' => '0 0% 100%' }) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + + it 'validates base theme, slot number, and token object shape' do + slot = described_class.new(user: create(:user), + base_theme: 'system', + slot_no: 4, + tokens: 'not-object') + + expect(slot).not_to be_valid + expect(slot.errors[:base_theme]).to be_present + expect(slot.errors[:slot_no]).to be_present + expect(slot.errors[:tokens]).to be_present + end +end diff --git a/backend/spec/requests/user_settings_spec.rb b/backend/spec/requests/user_settings_spec.rb new file mode 100644 index 0000000..74a7661 --- /dev/null +++ b/backend/spec/requests/user_settings_spec.rb @@ -0,0 +1,98 @@ +require 'rails_helper' + +RSpec.describe 'user settings', type: :request do + describe 'GET /users/settings' do + it 'requires a current user' do + sign_out + + get '/users/settings' + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns defaults and creates a missing settings row' do + user = create(:user) + sign_in_as(user) + + expect { + get '/users/settings' + }.to change(Setting, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json).to eq( + 'theme' => 'system', + 'auto_fetch_title' => 'manual', + 'auto_fetch_thumbnail' => 'manual', + 'wiki_editor_mode' => 'split') + expect(user.reload.setting).to be_present + end + end + + describe 'PATCH /users/settings' do + it 'requires a current user' do + sign_out + + patch '/users/settings', params: { theme: 'dark' }, as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'updates multiple typed settings at once' do + user = create(:user) + sign_in_as(user) + + patch '/users/settings', + params: { + theme: 'dark', + auto_fetch_title: 'auto', + auto_fetch_thumbnail: 'off', + wiki_editor_mode: 'preview' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(json).to include( + 'theme' => 'dark', + 'auto_fetch_title' => 'auto', + 'auto_fetch_thumbnail' => 'off', + 'wiki_editor_mode' => 'preview') + expect(user.reload.setting).to have_attributes( + theme: 'dark', + auto_fetch_title: 'auto', + auto_fetch_thumbnail: 'off', + wiki_editor_mode: 'preview') + end + + it 'does not treat Rails parameter wrapping as an editable setting' do + user = create(:user) + sign_in_as(user) + + patch '/users/settings', + params: { theme: 'light', user_setting: { theme: 'dark' } }, + as: :json + + expect(response).to have_http_status(:ok) + expect(user.reload.setting.theme).to eq('light') + end + + it 'returns validation_error for type mismatches' do + sign_in_as(create(:user)) + + patch '/users/settings', params: { theme: 1 }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error') + expect(json.fetch('errors')).to include( + 'theme' => ['値の型が不正です.']) + end + + it 'returns validation_error for invalid enum values' do + sign_in_as(create(:user)) + + patch '/users/settings', params: { theme: 'neon' }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error') + expect(json.fetch('errors').fetch('theme')).to be_present + end + end +end diff --git a/backend/spec/requests/user_theme_slots_spec.rb b/backend/spec/requests/user_theme_slots_spec.rb new file mode 100644 index 0000000..404cde9 --- /dev/null +++ b/backend/spec/requests/user_theme_slots_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +RSpec.describe 'user theme slots', type: :request do + describe 'GET /users/theme_slots' do + it 'requires a current user' do + sign_out + + get '/users/theme_slots' + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns only the current user slots in stable order' do + user = create(:user) + other_user = create(:user) + sign_in_as(user) + UserThemeSlot.create!(user:, base_theme: 'light', slot_no: 2, tokens: { 'a' => 1 }) + UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 1, tokens: { 'b' => 2 }) + UserThemeSlot.create!(user: other_user, + base_theme: 'dark', + slot_no: 3, + tokens: { 'c' => 3 }) + + get '/users/theme_slots' + + expect(response).to have_http_status(:ok) + expect(json.map { |slot| [slot.fetch('base_theme'), slot.fetch('slot_no')] }).to eq( + [['dark', 1], ['light', 2]]) + expect(json.map { |slot| slot.fetch('tokens') }).to eq( + [{ 'b' => 2 }, { 'a' => 1 }]) + end + end + + describe 'PUT /users/theme_slots/:base_theme/:slot_no' do + it 'requires a current user' do + sign_out + + put '/users/theme_slots/light/1', + params: { tokens: { background: '0 0% 100%' } }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a theme slot' do + user = create(:user) + sign_in_as(user) + + expect { + put '/users/theme_slots/light/1', + params: { tokens: { background: '0 0% 100%', tagColours: { nico: '#ffffff' } } }, + as: :json + }.to change(UserThemeSlot, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json).to include('base_theme' => 'light', 'slot_no' => 1) + expect(json.fetch('tokens')).to include( + 'background' => '0 0% 100%', + 'tagColours' => { 'nico' => '#ffffff' }) + expect(user.theme_slots.find_by!(base_theme: 'light', slot_no: 1).tokens).to include( + 'background' => '0 0% 100%') + end + + it 'updates an existing theme slot instead of creating a duplicate' do + user = create(:user) + sign_in_as(user) + UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 2, tokens: { 'old' => true }) + + expect { + put '/users/theme_slots/dark/2', + params: { tokens: { background: '222.2 84% 4.9%' } }, + as: :json + }.not_to change(UserThemeSlot, :count) + + expect(response).to have_http_status(:ok) + expect(user.theme_slots.find_by!(base_theme: 'dark', slot_no: 2).tokens).to eq( + 'background' => '222.2 84% 4.9%') + end + + it 'rejects invalid path parameters and token shapes' do + sign_in_as(create(:user)) + + put '/users/theme_slots/system/4', + params: { tokens: 'not-object' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'base_theme' => ['値が不正です.']) + end + + it 'rejects non-object tokens for valid slots' do + sign_in_as(create(:user)) + + put '/users/theme_slots/light/1', + params: { tokens: 'not-object' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'tokens' => ['JSON object で指定してください.']) + end + end +end diff --git a/frontend/src/components/common/TabGroup.test.tsx b/frontend/src/components/common/TabGroup.test.tsx index 424be1c..ae5cdd6 100644 --- a/frontend/src/components/common/TabGroup.test.tsx +++ b/frontend/src/components/common/TabGroup.test.tsx @@ -20,4 +20,16 @@ describe ('TabGroup', () => { expect (screen.getByText ('Alpha')).toBeInTheDocument () expect (screen.queryByText ('Beta')).not.toBeInTheDocument () }) + + it ('renders tabs as buttons and shows the right addon', () => { + render ( + 件数 20}> + Plaza + , + ) + + expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument () + expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument () + expect (screen.getByText ('件数 20')).toBeInTheDocument () + }) }) diff --git a/frontend/src/lib/keyboardShortcuts.test.ts b/frontend/src/lib/keyboardShortcuts.test.ts new file mode 100644 index 0000000..a436fad --- /dev/null +++ b/frontend/src/lib/keyboardShortcuts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_KEY_BINDINGS, + formatKeyBinding, + getConflictingShortcutActionIds, + isEditableEventTarget, + keyBindingFromKeyboardEvent, + resolveShortcutScope, + SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts' + +describe ('keyboardShortcuts', () => { + it ('defines the default key bindings used by the settings screen', () => { + expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({ + label: 'ショートカット一覧を開く', + scope: 'global', + defaultBinding: { key: '?', shift: true } }) + expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' }) + expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' }) + expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' }) + expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' }) + }) + + it ('formats key bindings for display', () => { + expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?') + expect (formatKeyBinding ({ key: '/' })).toBe ('/') + expect (formatKeyBinding ({ key: 'p' })).toBe ('P') + expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight') + expect (formatKeyBinding (null)).toBe ('未割当') + }) + + it ('normalises keyboard events without assigning space by default', () => { + const event = { key: 'P', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false } as KeyboardEvent + + expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' }) + expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' }) + }) + + it ('detects conflicting action bindings', () => { + const conflicts = getConflictingShortcutActionIds ({ + ...DEFAULT_KEY_BINDINGS, + 'navigation.posts': { key: 'p' }, + 'navigation.tags': { key: 'p' } }) + + expect (conflicts.has ('navigation.posts')).toBe (true) + expect (conflicts.has ('navigation.tags')).toBe (true) + expect (conflicts.has ('navigation.wiki')).toBe (false) + }) + + it ('resolves route scopes and editable event targets', () => { + const input = document.createElement ('input') + const div = document.createElement ('div') + div.setAttribute ('role', 'textbox') + + expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch') + expect (resolveShortcutScope ('/users/settings')).toBe ('settings') + expect (resolveShortcutScope ('/materials/1')).toBe ('materials') + expect (isEditableEventTarget (input)).toBe (true) + expect (isEditableEventTarget (div)).toBe (true) + expect (isEditableEventTarget (document.createElement ('button'))).toBe (false) + }) +}) diff --git a/frontend/src/lib/settings.test.ts b/frontend/src/lib/settings.test.ts new file mode 100644 index 0000000..9ec1d66 --- /dev/null +++ b/frontend/src/lib/settings.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { applyClientAnimationMode, + applyThemeTokens, + buildThemeTokens, + CLIENT_SETTINGS_STORAGE_KEY, + DARK_THEME_TOKENS, + DEFAULT_POST_LIST_ORDER, + getClientListLimit, + getClientListOrder, + getClientThemeMode, + LIGHT_THEME_TOKENS, + loadClientSettings, + setClientListSettings, + setClientThemeAppearance } from '@/lib/settings' + +import type { FetchPostsOrder } from '@/types' + +const installMatchMedia = (matches: boolean) => { + Object.defineProperty (window, 'matchMedia', { + configurable: true, + value: vi.fn ().mockImplementation (() => ({ + matches, + addEventListener: vi.fn (), + removeEventListener: vi.fn () })) }) +} + +describe ('settings', () => { + beforeEach (() => { + localStorage.clear () + document.documentElement.removeAttribute ('data-animation') + document.documentElement.removeAttribute ('style') + document.documentElement.className = '' + installMatchMedia (false) + }) + + it ('stores list settings under the shared client settings key', () => { + setClientListSettings ('postSearch', { + limit: 50, + order: 'title:asc' }) + + expect (getClientListLimit ('postSearch')).toBe (50) + expect (getClientListOrder ('postSearch')).toBe ('title:asc') + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } }) + }) + + it ('ignores invalid list limits and keeps stored order strings', () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } })) + + expect (getClientListLimit ('postList')).toBeNull () + expect (getClientListOrder ('postList')).toBe ( + DEFAULT_POST_LIST_ORDER) + }) + + it ('stores theme mode and light/dark slot selections in client settings', () => { + setClientThemeAppearance ({ + themeMode: 'dark', + activeLightThemeSlotNo: 2, + activeDarkThemeSlotNo: 3 }) + + expect (getClientThemeMode ()).toBe ('dark') + expect (loadClientSettings ().appearance).toMatchObject ({ + themeMode: 'dark', + activeLightThemeSlotNo: 2, + activeDarkThemeSlotNo: 3 }) + }) + + it ('applies animation mode to the document dataset', () => { + applyClientAnimationMode ('off') + expect (document.documentElement.dataset.animation).toBe ('off') + + applyClientAnimationMode ('reduced') + expect (document.documentElement.dataset.animation).toBe ('reduced') + + applyClientAnimationMode ('normal') + expect (document.documentElement.dataset.animation).toBeUndefined () + }) + + it ('normalises TopNav menu link tokens to complete CSS colours', () => { + const tokens = buildThemeTokens ('light', { + link: '221.2 83.2% 53.3%', + topNavMenuLink: '221.2 83.2% 53.3%' }) + + expect (tokens.link).toBe ('221.2 83.2% 53.3%') + expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)') + }) + + it ('derives TopNav colours without mixing mobile active backgrounds', () => { + expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe ( + LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop) + expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe ( + DARK_THEME_TOKENS.topNavActiveBackground) + }) + + it ('sets shadcn tokens and TopNav tokens with their different value formats', () => { + const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' }) + + applyThemeTokens (tokens) + + expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe ( + '213.1 93.9% 67.8%') + expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe ( + 'hsl(213.1 93.9% 67.8%)') + }) +}) diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx index 71c1f0f..cc650bd 100644 --- a/frontend/src/lib/useKeyboardShortcuts.tsx +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -291,10 +291,27 @@ export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => { const { registerHandlers } = useKeyboardShortcutSettings () + const handlersRef = useRef (handlers) + const actionIdsKey = Object.keys (handlers).sort ().join ('|') + + useEffect (() => { + handlersRef.current = handlers + }, [handlers]) + + const stableHandlers = useMemo (() => { + return actionIdsKey.split ('|').filter (actionId => actionId !== '') + .reduce ((nextHandlers, actionId) => { + const key = actionId as ShortcutActionId + nextHandlers[key] = () => { + handlersRef.current[key]?.() + } + return nextHandlers + }, { }) + }, [actionIdsKey]) useEffect ( - () => registerHandlers (handlers), - [handlers, registerHandlers], + () => registerHandlers (stableHandlers), + [registerHandlers, stableHandlers], ) } diff --git a/frontend/src/pages/posts/PostListPage.test.tsx b/frontend/src/pages/posts/PostListPage.test.tsx index fb75b8c..3a3bd0d 100644 --- a/frontend/src/pages/posts/PostListPage.test.tsx +++ b/frontend/src/pages/posts/PostListPage.test.tsx @@ -1,9 +1,10 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import PostListPage from '@/pages/posts/PostListPage' import { buildPost, buildTag, buildWikiPage } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings' const postsApi = vi.hoisted (() => ({ fetchPosts: vi.fn (), @@ -18,6 +19,11 @@ vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/wiki', () => wikiApi) describe ('PostListPage', () => { + beforeEach (() => { + localStorage.clear () + vi.clearAllMocks () + }) + it ('loads posts from the current query and renders the plaza tab', async () => { const tag = buildTag ({ name: '虹夏' }) postsApi.fetchPosts.mockResolvedValueOnce ({ @@ -36,6 +42,7 @@ describe ('PostListPage', () => { match: 'all', page: 2, limit: 20, + order: 'original_created_at:desc', }), ) }) @@ -71,4 +78,36 @@ describe ('PostListPage', () => { '/wiki/%E8%99%B9%E5%A4%8F', ) }) + + it ('uses stored list limit but keeps the fixed plaza order', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postList: { limit: 50, order: 'title:asc' } } })) + postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts' }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenCalledWith ( + expect.objectContaining ({ + limit: 50, + order: 'original_created_at:desc', + }), + ) + }) + }) + + it ('updates the limit from the tab right addon and resets to page 1', async () => { + postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts?page=3' }) + + fireEvent.change (await screen.findByLabelText ('件数'), { target: { value: '100' } }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx index 1402ed0..066e796 100644 --- a/frontend/src/pages/posts/PostNewPage.test.tsx +++ b/frontend/src/pages/posts/PostNewPage.test.tsx @@ -30,16 +30,12 @@ describe ('PostNewPage', () => { expect (screen.getByText ('403')).toBeInTheDocument () }) - it ('submits a new post with manual title and thumbnail settings', async () => { + it ('submits a new post with manual title and thumbnail fetch UI', async () => { api.apiPost.mockResolvedValueOnce ({}) api.apiGet.mockResolvedValue ([]) renderWithProviders () - const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' }) - fireEvent.click (checkboxes[0]) - fireEvent.click (checkboxes[1]) - const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) @@ -97,10 +93,6 @@ describe ('PostNewPage', () => { renderWithProviders () - const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' }) - fireEvent.click (checkboxes[0]) - fireEvent.click (checkboxes[1]) - const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) diff --git a/frontend/src/pages/posts/PostSearchPage.test.tsx b/frontend/src/pages/posts/PostSearchPage.test.tsx index d406666..06e9127 100644 --- a/frontend/src/pages/posts/PostSearchPage.test.tsx +++ b/frontend/src/pages/posts/PostSearchPage.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import PostSearchPage from '@/pages/posts/PostSearchPage' import { buildPost, buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings' const postsApi = vi.hoisted (() => ({ fetchPosts: vi.fn (), @@ -18,6 +19,7 @@ vi.mock ('@/lib/api', () => api) describe ('PostSearchPage', () => { beforeEach (() => { + localStorage.clear () vi.clearAllMocks () api.apiGet.mockResolvedValue ([]) }) @@ -40,6 +42,7 @@ describe ('PostSearchPage', () => { tags: 'x', match: 'any', page: 2, + limit: 20, }), ) }) @@ -81,4 +84,41 @@ describe ('PostSearchPage', () => { expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () }) + + it ('uses URL limit before localStorage and stores the active list settings', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postSearch: { limit: 100, order: 'title:asc' } } })) + postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) + + renderWithProviders ( + , + { route: '/posts/search?limit=50&order=created_at%3Adesc' }, + ) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenCalledWith ( + expect.objectContaining ({ limit: 50, order: 'created_at:desc' }), + ) + }) + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { postSearch: { limit: 50, order: 'created_at:desc' } } }) + }) + + it ('moves the compact limit control outside the search form and resets page on change', async () => { + postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts/search?page=4' }) + + const limitSelect = await screen.findByLabelText ('件数') + expect (limitSelect.closest ('form')).toBeNull () + + fireEvent.change (limitSelect, { target: { value: '100' } }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/tags/NicoTagListPage.test.tsx b/frontend/src/pages/tags/NicoTagListPage.test.tsx index c5efcd3..7d0d740 100644 --- a/frontend/src/pages/tags/NicoTagListPage.test.tsx +++ b/frontend/src/pages/tags/NicoTagListPage.test.tsx @@ -6,6 +6,7 @@ import { dateString } from '@/lib/utils' import { buildTag, buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import type { ReactNode } from 'react' import type { NicoTag } from '@/types' const api = vi.hoisted (() => ({ @@ -27,6 +28,7 @@ const scrollIntoView = vi.fn () vi.mock ('@/lib/api', () => api) vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, useDialogue: () => dialogue, })) diff --git a/frontend/src/pages/tags/TagListPage.test.tsx b/frontend/src/pages/tags/TagListPage.test.tsx index f13f3eb..3d0e9ae 100644 --- a/frontend/src/pages/tags/TagListPage.test.tsx +++ b/frontend/src/pages/tags/TagListPage.test.tsx @@ -1,9 +1,10 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import TagListPage from '@/pages/tags/TagListPage' import { buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings' const tagsApi = vi.hoisted (() => ({ fetchTags: vi.fn (), @@ -12,6 +13,11 @@ const tagsApi = vi.hoisted (() => ({ vi.mock ('@/lib/tags', () => tagsApi) describe ('TagListPage', () => { + beforeEach (() => { + localStorage.clear () + vi.clearAllMocks () + }) + it ('loads tags from URL filters and renders the results table', async () => { tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [buildTag ({ @@ -38,6 +44,8 @@ describe ('TagListPage', () => { name: '虹', category: 'character', page: 3, + limit: 20, + order: 'post_count:desc', postCountGTE: 5, deprecated: true, }), @@ -48,7 +56,7 @@ describe ('TagListPage', () => { '/tags/7', ) expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) - expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1') + expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1') expect (screen.getAllByText ('廃止')).toHaveLength (2) }) @@ -58,7 +66,7 @@ describe ('TagListPage', () => { renderWithProviders (, { route: '/tags' }) fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) - fireEvent.change (screen.getAllByRole ('combobox')[0], { + fireEvent.change (screen.getAllByRole ('combobox')[1], { target: { value: 'character' }, }) fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!) @@ -82,4 +90,41 @@ describe ('TagListPage', () => { expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () }) + + it ('uses URL limit before localStorage and preserves the query order', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { tagList: { limit: 100, order: 'name:asc' } } })) + tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [], count: 0 }) + + renderWithProviders ( + , + { route: '/tags?limit=50&order=updated_at%3Adesc' }, + ) + + await waitFor (() => { + expect (tagsApi.fetchTags).toHaveBeenCalledWith ( + expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }), + ) + }) + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { tagList: { limit: 50, order: 'updated_at:desc' } } }) + }) + + it ('keeps the compact limit control outside the search form and resets page on change', async () => { + tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 }) + + renderWithProviders (, { route: '/tags?page=4' }) + + const limitSelect = await screen.findByLabelText ('件数') + expect (limitSelect.closest ('form')).toBeNull () + + fireEvent.change (limitSelect, { target: { value: '100' } }) + + await waitFor (() => { + expect (tagsApi.fetchTags).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx index 745ea4e..2023a4e 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -41,6 +41,7 @@ const postEmbed = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, useDialogue: () => dialogue, })) vi.mock ('@/components/PostEmbed', () => ({ diff --git a/frontend/src/pages/users/SettingPage.test.tsx b/frontend/src/pages/users/SettingPage.test.tsx index 0595642..d88798a 100644 --- a/frontend/src/pages/users/SettingPage.test.tsx +++ b/frontend/src/pages/users/SettingPage.test.tsx @@ -6,6 +6,7 @@ import { buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' const api = vi.hoisted (() => ({ + apiGet: vi.fn (), apiPut: vi.fn (), isApiError: vi.fn (), })) @@ -27,12 +28,23 @@ describe ('SettingPage', () => { beforeEach (() => { vi.clearAllMocks () api.isApiError.mockReturnValue (false) + api.apiGet.mockImplementation ((path: string) => { + if (path === '/users/settings') + return Promise.resolve ({ + theme: 'system', + auto_fetch_title: 'manual', + auto_fetch_thumbnail: 'manual', + wiki_editor_mode: 'split' }) + if (path === '/users/theme_slots') + return Promise.resolve ([]) + return Promise.resolve ({}) + }) }) it ('shows loading when user is absent', () => { renderWithProviders () - expect (screen.getByText ('Loading...')).toBeInTheDocument () + expect (screen.getAllByText ('Loading...').length).toBeGreaterThan (0) }) it ('updates the current user name', async () => { @@ -42,8 +54,9 @@ describe ('SettingPage', () => { renderWithProviders () - fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } }) - fireEvent.click (screen.getByRole ('button', { name: '更新' })) + const nameInput = (await screen.findAllByRole ('textbox'))[0] + fireEvent.change (nameInput, { target: { value: 'new' } }) + fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0]) await waitFor (() => { expect (api.apiPut).toHaveBeenCalledWith ( @@ -55,7 +68,7 @@ describe ('SettingPage', () => { const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('name')).toBe ('new') expect (setUser).toHaveBeenCalled () - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' }) + expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' }) }) it ('shows validation errors returned for the name field', async () => { @@ -75,10 +88,11 @@ describe ('SettingPage', () => { renderWithProviders () - fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } }) - fireEvent.click (screen.getByRole ('button', { name: '更新' })) + const nameInput = (await screen.findAllByRole ('textbox'))[0] + fireEvent.change (nameInput, { target: { value: '' } }) + fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0]) - expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument () - expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true') + expect ((await screen.findAllByText ('名前は必須です.')).length).toBeGreaterThan (0) + expect (nameInput).toHaveAttribute ('aria-invalid', 'true') }) }) diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index a72b6ab..5619a6e 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom' @@ -735,6 +735,9 @@ const SettingPage: FC = ({ user, setUser }) => { state: SettingsTabDirtyState, ) => { setDirtyStates (current => { + if (!(state.dirty) && current[tab] == null) + return current + const next = { ...current } if (!(state.dirty)) @@ -827,7 +830,7 @@ const SettingPage: FC = ({ user, setUser }) => { }, nextThemeSlots) } - const discardAllDirtyChanges = async (): Promise => { + const discardAllDirtyChanges = useCallback (async (): Promise => { const currentDirtyStates = Object.values (dirtyStates) for (const dirtyState of currentDirtyStates) @@ -837,7 +840,7 @@ const SettingPage: FC = ({ user, setUser }) => { } setDirtyStates ({ }) - } + }, [dirtyStates]) const discardCurrentTabChanges = async (): Promise => { const currentDirtyState = dirtyStates[activeTab] diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx index c768200..ce8a1d9 100644 --- a/frontend/src/test/render.tsx +++ b/frontend/src/test/render.tsx @@ -3,6 +3,10 @@ import { render } from '@testing-library/react' import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter } from 'react-router-dom' +import DialogueProvider from '@/components/dialogues/DialogueProvider' +import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' +import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' + import type { ReactElement, ReactNode } from 'react' type Options = { @@ -15,21 +19,25 @@ export const renderWithProviders = ( ) => { const queryClient = new QueryClient ({ defaultOptions: { - queries: { retry: false }, - }, + queries: { retry: false } }, }) const Wrapper = ({ children }: { children: ReactNode }) => ( - {children} + + + + {children} + + + ) return { queryClient, - ...render (ui, { wrapper: Wrapper }), - } + ...render (ui, { wrapper: Wrapper }) } } diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 12f1e30..ef2725c 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -20,3 +20,10 @@ window.requestAnimationFrame = callback => { callback (0) return 0 } + +Object.defineProperty (window, 'matchMedia', { + configurable: true, + value: vi.fn ().mockImplementation (() => ({ + addEventListener: vi.fn (), + removeEventListener: vi.fn (), + matches: false })) })