diff --git a/backend/spec/requests/theatre_comments_spec.rb b/backend/spec/requests/theatre_comments_spec.rb
index 5f41395..78295be 100644
--- a/backend/spec/requests/theatre_comments_spec.rb
+++ b/backend/spec/requests/theatre_comments_spec.rb
@@ -80,6 +80,26 @@ RSpec.describe 'TheatreComments', type: :request do
expect(response).to have_http_status(:ok)
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
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
describe 'POST /theatres/:theatre_id/comments' do
diff --git a/backend/spec/requests/theatre_programmes_spec.rb b/backend/spec/requests/theatre_programmes_spec.rb
index 529b583..f37fa66 100644
--- a/backend/spec/requests/theatre_programmes_spec.rb
+++ b/backend/spec/requests/theatre_programmes_spec.rb
@@ -27,5 +27,12 @@ RSpec.describe 'TheatreProgrammes', type: :request do
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)
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
diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb
index 4a5a198..7fd3a0a 100644
--- a/backend/spec/requests/theatres_spec.rb
+++ b/backend/spec/requests/theatres_spec.rb
@@ -361,6 +361,24 @@ RSpec.describe 'Theatres API', type: :request do
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
sign_in_as(member)
@@ -391,7 +409,7 @@ RSpec.describe 'Theatres API', type: :request do
expect(response).to have_http_status(:ok)
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
expect(event.post).to eq(niconico_post)
diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx
new file mode 100644
index 0000000..3ba3409
--- /dev/null
+++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx
@@ -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 }
+ }) =>
Embed:{post.title || post.url}
,
+}))
+vi.mock ('@/components/PostEditForm', () => ({
+ default: () => Post edit form
,
+}))
+vi.mock ('framer-motion', () => ({
+ motion: {
+ aside: ({ children }: { children?: ReactNode }) => ,
+ div: ({ children }: { children?: ReactNode }) => {children}
,
+ main: ({ children }: { children?: ReactNode }) => {children},
+ },
+}))
+
+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 (
+
+ }/>
+ ,
+ { 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)
+ })
+})
diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts
index 2092619..94a09ed 100644
--- a/frontend/src/test/factories.ts
+++ b/frontend/src/test/factories.ts
@@ -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 => ({
id: 1,
@@ -72,3 +81,62 @@ export const buildMaterial = (overrides: Partial = {}): Material => ({
updatedByUser: { id: 2, name: 'updater' },
...overrides,
})
+
+export const buildTheatre = (overrides: Partial = {}): 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 => ({
+ 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 => ({
+ 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 => ({
+ theatreId: 1,
+ position: 1,
+ post: buildPost (),
+ createdAt: '2026-01-02T03:04:05.000Z',
+ ...overrides,
+})
+
+export const buildTheatrePostSelectionWeights = (
+ overrides: Partial = {},
+): TheatrePostSelectionWeights => ({
+ tagPenalties: [],
+ lightestPosts: [],
+ heaviestPosts: [],
+ ...overrides,
+})