上映会改修 (#302) #357
@@ -80,6 +80,26 @@ RSpec.describe 'TheatreComments', type: :request do
|
|||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
|
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it '削除済みコメントは deleted として返し、本文を隠す' do
|
||||||
|
comment_2.discard!
|
||||||
|
|
||||||
|
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
deleted_comment = response.parsed_body.find { _1['no'] == 2 }
|
||||||
|
expect(deleted_comment).to include(
|
||||||
|
'deleted' => true,
|
||||||
|
'content' => nil
|
||||||
|
)
|
||||||
|
|
||||||
|
visible_comment = response.parsed_body.find { _1['no'] == 3 }
|
||||||
|
expect(visible_comment).to include(
|
||||||
|
'deleted' => false,
|
||||||
|
'content' => 'third comment'
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST /theatres/:theatre_id/comments' do
|
describe 'POST /theatres/:theatre_id/comments' do
|
||||||
|
|||||||
@@ -27,5 +27,12 @@ RSpec.describe 'TheatreProgrammes', type: :request do
|
|||||||
expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first'])
|
expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first'])
|
||||||
expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url)
|
expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'filters programmes by position_gt' do
|
||||||
|
get "/theatres/#{theatre.id}/programmes", params: { position_gt: 1 }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { _1['position'] }).to eq([2])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -361,6 +361,24 @@ RSpec.describe 'Theatres API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns 401 when not logged in' do
|
||||||
|
sign_out
|
||||||
|
|
||||||
|
expect { do_request }.not_to change(TheatreSkipVote, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 when post_id is invalid' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: 'invalid' }
|
||||||
|
}.not_to change(TheatreSkipVote, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
it 'records a vote and returns the current vote status before majority' do
|
it 'records a vote and returns the current vote status before majority' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
@@ -391,7 +409,7 @@ RSpec.describe 'Theatres API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json['skipped']).to eq(true)
|
expect(json['skipped']).to eq(true)
|
||||||
expect(json['post_id']).to eq(second_niconico_post.id)
|
expect([second_niconico_post.id, youtube_post.id]).to include(json['post_id'])
|
||||||
|
|
||||||
event = TheatreSkipEvent.last
|
event = TheatreSkipEvent.last
|
||||||
expect(event.post).to eq(niconico_post)
|
expect(event.post).to eq(niconico_post)
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { Route, Routes } from 'react-router-dom'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
|
||||||
|
import { buildPost,
|
||||||
|
buildTheatre,
|
||||||
|
buildTheatreComment,
|
||||||
|
buildTheatreInfo,
|
||||||
|
buildTheatrePostSelectionWeights,
|
||||||
|
buildTheatreProgramme,
|
||||||
|
buildUser } from '@/test/factories'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const api = vi.hoisted (() => ({
|
||||||
|
apiDelete: vi.fn (),
|
||||||
|
apiGet: vi.fn (),
|
||||||
|
apiPatch: vi.fn (),
|
||||||
|
apiPost: vi.fn (),
|
||||||
|
apiPut: vi.fn (),
|
||||||
|
isApiError: vi.fn (() => false),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const postsApi = vi.hoisted (() => ({
|
||||||
|
fetchPost: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dialogue = vi.hoisted (() => ({
|
||||||
|
confirm: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
vi.mock ('@/lib/posts', () => postsApi)
|
||||||
|
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||||
|
useDialogue: () => dialogue,
|
||||||
|
}))
|
||||||
|
vi.mock ('@/components/PostEmbed', () => ({
|
||||||
|
default: ({ post }: {
|
||||||
|
post: { title: string | null; url: string }
|
||||||
|
}) => <div>Embed:{post.title || post.url}</div>,
|
||||||
|
}))
|
||||||
|
vi.mock ('@/components/PostEditForm', () => ({
|
||||||
|
default: () => <div>Post edit form</div>,
|
||||||
|
}))
|
||||||
|
vi.mock ('framer-motion', () => ({
|
||||||
|
motion: {
|
||||||
|
aside: ({ children }: { children?: ReactNode }) => <aside>{children}</aside>,
|
||||||
|
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||||
|
main: ({ children }: { children?: ReactNode }) => <main>{children}</main>,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const currentPost = buildPost ({
|
||||||
|
id: 10,
|
||||||
|
title: '上映中の投稿',
|
||||||
|
url: 'https://www.nicovideo.jp/watch/sm10',
|
||||||
|
})
|
||||||
|
const theatre = buildTheatre ({ id: 7, name: '上映室' })
|
||||||
|
const programme = buildTheatreProgramme ({
|
||||||
|
theatreId: 7,
|
||||||
|
position: 3,
|
||||||
|
post: currentPost,
|
||||||
|
})
|
||||||
|
const weights = buildTheatrePostSelectionWeights ({
|
||||||
|
lightestPosts: [{
|
||||||
|
post: currentPost,
|
||||||
|
penalty: 2,
|
||||||
|
weight: 0.5,
|
||||||
|
tags: [],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
|
||||||
|
renderWithProviders (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/theatres/7' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockDefaultApi = () => {
|
||||||
|
api.apiGet.mockImplementation ((path: string) => {
|
||||||
|
switch (path)
|
||||||
|
{
|
||||||
|
case '/theatres/7':
|
||||||
|
return Promise.resolve (theatre)
|
||||||
|
|
||||||
|
case '/theatres/7/comments':
|
||||||
|
return Promise.resolve ([
|
||||||
|
buildTheatreComment ({
|
||||||
|
theatreId: 7,
|
||||||
|
no: 2,
|
||||||
|
user: { id: 1, name: 'tester' },
|
||||||
|
content: '視聴コメント',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case '/theatres/7/programmes':
|
||||||
|
return Promise.resolve ([programme])
|
||||||
|
|
||||||
|
case '/theatres/7/post_selection_weights':
|
||||||
|
return Promise.resolve (weights)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Promise.reject (new Error (`Unexpected GET ${ path }`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
api.apiPut.mockImplementation ((path: string) => {
|
||||||
|
switch (path)
|
||||||
|
{
|
||||||
|
case '/theatres/7/watching':
|
||||||
|
return Promise.resolve (buildTheatreInfo ({
|
||||||
|
postId: currentPost.id,
|
||||||
|
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
postElapsedMs: 1_000,
|
||||||
|
watchingUsers: [{ id: 1, name: 'tester' }],
|
||||||
|
skipVote: {
|
||||||
|
votesCount: 0,
|
||||||
|
requiredCount: 2,
|
||||||
|
watchingUsersCount: 1,
|
||||||
|
voted: false,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
case '/theatres/7/skip_vote':
|
||||||
|
return Promise.resolve (buildTheatreInfo ({
|
||||||
|
postId: currentPost.id,
|
||||||
|
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
postElapsedMs: 2_000,
|
||||||
|
watchingUsers: [{ id: 1, name: 'tester' }],
|
||||||
|
skipVote: {
|
||||||
|
votesCount: 1,
|
||||||
|
requiredCount: 2,
|
||||||
|
watchingUsersCount: 1,
|
||||||
|
voted: true,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
api.apiDelete.mockResolvedValue (undefined)
|
||||||
|
api.apiPatch.mockResolvedValue (undefined)
|
||||||
|
api.apiPost.mockResolvedValue (undefined)
|
||||||
|
postsApi.fetchPost.mockResolvedValue (currentPost)
|
||||||
|
dialogue.confirm.mockResolvedValue (true)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe ('TheatreDetailPage', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
mockDefaultApi ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('loads theatre state, comments, current post, programme history, and weights', async () => {
|
||||||
|
renderPage ()
|
||||||
|
|
||||||
|
expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument ()
|
||||||
|
expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument ()
|
||||||
|
expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument ()
|
||||||
|
expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('penalty 2')).toBeInTheDocument ()
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (postsApi.fetchPost).toHaveBeenCalledWith ('10')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('votes to skip the current post', async () => {
|
||||||
|
renderPage ()
|
||||||
|
|
||||||
|
await screen.findByText ('Embed:上映中の投稿')
|
||||||
|
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' }))
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (api.apiPut).toHaveBeenCalledWith (
|
||||||
|
'/theatres/7/skip_vote',
|
||||||
|
{ post_id: 10 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' }))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('deletes an owned comment after confirmation', async () => {
|
||||||
|
renderPage ()
|
||||||
|
|
||||||
|
fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0])
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (dialogue.confirm).toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2')
|
||||||
|
})
|
||||||
|
expect (await screen.findAllByText ('削除されました.')).toHaveLength (2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
import type { Material, Post, Tag, User, WikiPage } from '@/types'
|
import type { Material,
|
||||||
|
Post,
|
||||||
|
Tag,
|
||||||
|
Theatre,
|
||||||
|
TheatreComment,
|
||||||
|
TheatreInfo,
|
||||||
|
TheatrePostSelectionWeights,
|
||||||
|
TheatreProgramme,
|
||||||
|
User,
|
||||||
|
WikiPage } from '@/types'
|
||||||
|
|
||||||
export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
|
export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -72,3 +81,62 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
|
|||||||
updatedByUser: { id: 2, name: 'updater' },
|
updatedByUser: { id: 2, name: 'updater' },
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const buildTheatre = (overrides: Partial<Theatre> = {}): Theatre => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'テスト劇場',
|
||||||
|
opensAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
closesAt: null,
|
||||||
|
createdByUser: { id: 1, name: 'creator' },
|
||||||
|
createdAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
updatedAt: '2026-01-03T03:04:05.000Z',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const buildTheatreInfo = (
|
||||||
|
overrides: Partial<TheatreInfo> = {},
|
||||||
|
): TheatreInfo => ({
|
||||||
|
hostFlg: false,
|
||||||
|
postId: null,
|
||||||
|
postStartedAt: null,
|
||||||
|
postElapsedMs: null,
|
||||||
|
watchingUsers: [],
|
||||||
|
skipVote: {
|
||||||
|
votesCount: 0,
|
||||||
|
requiredCount: 1,
|
||||||
|
watchingUsersCount: 0,
|
||||||
|
voted: false,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const buildTheatreComment = (
|
||||||
|
overrides: Partial<TheatreComment> = {},
|
||||||
|
): TheatreComment => ({
|
||||||
|
theatreId: 1,
|
||||||
|
no: 1,
|
||||||
|
deleted: false,
|
||||||
|
user: { id: 1, name: 'tester' },
|
||||||
|
content: 'テストコメント',
|
||||||
|
createdAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
...overrides,
|
||||||
|
} as TheatreComment)
|
||||||
|
|
||||||
|
export const buildTheatreProgramme = (
|
||||||
|
overrides: Partial<TheatreProgramme> = {},
|
||||||
|
): TheatreProgramme => ({
|
||||||
|
theatreId: 1,
|
||||||
|
position: 1,
|
||||||
|
post: buildPost (),
|
||||||
|
createdAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const buildTheatrePostSelectionWeights = (
|
||||||
|
overrides: Partial<TheatrePostSelectionWeights> = {},
|
||||||
|
): TheatrePostSelectionWeights => ({
|
||||||
|
tagPenalties: [],
|
||||||
|
lightestPosts: [],
|
||||||
|
heaviestPosts: [],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする