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 })) })