Reviewed-on: #357 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #357 でマージされました.
このコミットが含まれているのは:
+1
-1
@@ -64,7 +64,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
|
||||
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
||||
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
||||
<Route index element={<MaterialListPage/>}/>
|
||||
<Route path="new" element={<MaterialNewPage/>}/>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState } from 'react'
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState } from 'react'
|
||||
|
||||
import type { CSSProperties, ForwardedRef } from 'react'
|
||||
|
||||
@@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '
|
||||
type NiconicoPlayerMessage =
|
||||
| { eventName: 'enterProgrammaticFullScreen' }
|
||||
| { eventName: 'exitProgrammaticFullScreen' }
|
||||
| { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } }
|
||||
| { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata }
|
||||
| { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown }
|
||||
| { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string }
|
||||
| { eventName: 'loadComplete'
|
||||
playerId?: string
|
||||
data: { videoInfo: NiconicoVideoInfo } }
|
||||
| { eventName: 'playerMetadataChange'
|
||||
playerId?: string
|
||||
data: NiconicoMetadata }
|
||||
| { eventName: 'playerStatusChange' | 'statusChange'
|
||||
playerId?: string
|
||||
data?: unknown }
|
||||
| { eventName: 'error'
|
||||
playerId?: string
|
||||
data?: unknown
|
||||
code?: string
|
||||
message?: string }
|
||||
|
||||
type NiconicoCommand =
|
||||
| { eventName: 'play'; sourceConnectorType: 1; playerId: string }
|
||||
@@ -30,6 +40,7 @@ type NiconicoCommand =
|
||||
data: { commentVisibility: boolean } }
|
||||
|
||||
const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
|
||||
const LOAD_COMPLETE_TIMEOUT_MS = 8_000
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
@@ -37,14 +48,18 @@ type Props = {
|
||||
height: number
|
||||
style?: CSSProperties
|
||||
onLoadComplete?: (info: NiconicoVideoInfo) => void
|
||||
onMetadataChange?: (meta: NiconicoMetadata) => void }
|
||||
onMetadataChange?: (meta: NiconicoMetadata) => void
|
||||
onError?: (data: unknown) => void }
|
||||
|
||||
|
||||
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
|
||||
const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props
|
||||
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement> (null)
|
||||
const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
|
||||
const loadCompleteTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
|
||||
const playerId = useMemo (
|
||||
() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`,
|
||||
[id])
|
||||
|
||||
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
|
||||
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
|
||||
@@ -64,21 +79,39 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
const styleFullScreen: CSSProperties =
|
||||
fullScreen
|
||||
? { top: 0,
|
||||
left: landscape ? 0 : '100%',
|
||||
position: 'fixed',
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
zIndex: 2_147_483_647,
|
||||
maxWidth: 'none',
|
||||
transformOrigin: '0% 0%',
|
||||
transform: landscape ? 'none' : 'rotate(90deg)',
|
||||
WebkitTransformOrigin: '0% 0%',
|
||||
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
|
||||
left: landscape ? 0 : '100%',
|
||||
position: 'fixed',
|
||||
width: screenWidth,
|
||||
height: screenHeight,
|
||||
zIndex: 2_147_483_647,
|
||||
maxWidth: 'none',
|
||||
transformOrigin: '0% 0%',
|
||||
transform: landscape ? 'none' : 'rotate(90deg)',
|
||||
WebkitTransformOrigin: '0% 0%',
|
||||
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
|
||||
: { }
|
||||
|
||||
const margedStyle: CSSProperties =
|
||||
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
|
||||
|
||||
const clearLoadCompleteTimer = useCallback (() => {
|
||||
if (!(loadCompleteTimerRef.current))
|
||||
return
|
||||
|
||||
clearTimeout (loadCompleteTimerRef.current)
|
||||
loadCompleteTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const startLoadCompleteTimer = useCallback (() => {
|
||||
clearLoadCompleteTimer ()
|
||||
loadCompleteTimerRef.current = setTimeout (() => {
|
||||
onError?.({
|
||||
eventName: 'loadCompleteTimeout',
|
||||
reason: 'niconico video length was not reported by embed',
|
||||
})
|
||||
}, LOAD_COMPLETE_TIMEOUT_MS)
|
||||
}, [clearLoadCompleteTimer, onError])
|
||||
|
||||
const postToPlayer = useCallback ((message: NiconicoCommand) => {
|
||||
const win = iframeRef.current?.contentWindow
|
||||
if (!(win))
|
||||
@@ -132,21 +165,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
useEffect (() => {
|
||||
const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => {
|
||||
if (!(iframeRef.current)
|
||||
|| (event.source !== iframeRef.current.contentWindow)
|
||||
|| (event.origin !== EMBED_ORIGIN))
|
||||
return
|
||||
|| (event.source !== iframeRef.current.contentWindow)
|
||||
|| (event.origin !== EMBED_ORIGIN))
|
||||
return
|
||||
|
||||
const data = event.data
|
||||
|
||||
if (!(data)
|
||||
|| typeof data !== 'object'
|
||||
|| !('eventName' in data))
|
||||
return
|
||||
return
|
||||
|
||||
if (('playerId' in data)
|
||||
&& data.playerId
|
||||
&& data.playerId !== playerId)
|
||||
return
|
||||
return
|
||||
|
||||
if (data.eventName === 'enterProgrammaticFullScreen')
|
||||
{
|
||||
@@ -162,6 +195,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
|
||||
if (data.eventName === 'loadComplete')
|
||||
{
|
||||
clearLoadCompleteTimer ()
|
||||
onLoadComplete?.(data.data.videoInfo)
|
||||
return
|
||||
}
|
||||
@@ -173,13 +207,19 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
}
|
||||
|
||||
if (data.eventName === 'error')
|
||||
console.error ('niconico player error:', data)
|
||||
{
|
||||
clearLoadCompleteTimer ()
|
||||
console.error ('niconico player error:', data)
|
||||
onError?.(data)
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener ('message', onMessage)
|
||||
|
||||
return () => removeEventListener ('message', onMessage)
|
||||
}, [onLoadComplete, onMetadataChange, playerId])
|
||||
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
|
||||
|
||||
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
|
||||
|
||||
useLayoutEffect (() => {
|
||||
if (!(fullScreen))
|
||||
@@ -192,7 +232,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
|
||||
const pollingResize = () => {
|
||||
if (ended)
|
||||
return
|
||||
return
|
||||
|
||||
const isLandscape = innerWidth >= innerHeight
|
||||
const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px`
|
||||
@@ -206,9 +246,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
|
||||
const startPollingResize = () => {
|
||||
if (requestAnimationFrame)
|
||||
requestAnimationFrame (pollingResize)
|
||||
requestAnimationFrame (pollingResize)
|
||||
else
|
||||
pollingResize ()
|
||||
pollingResize ()
|
||||
}
|
||||
|
||||
startPollingResize ()
|
||||
@@ -231,9 +271,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
style={margedStyle}
|
||||
allowFullScreen
|
||||
allow="autoplay"/>)
|
||||
width={width}
|
||||
height={height}
|
||||
style={margedStyle}
|
||||
onLoad={startLoadCompleteTimer}
|
||||
allowFullScreen
|
||||
allow="autoplay"/>)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import YoutubeEmbed from 'react-youtube'
|
||||
|
||||
import NicoViewer from '@/components/NicoViewer'
|
||||
@@ -8,17 +8,97 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import type { FC, RefObject } from 'react'
|
||||
|
||||
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types'
|
||||
import type { YouTubePlayer } from 'react-youtube'
|
||||
|
||||
type YouTubeEvent<T = unknown> = {
|
||||
data: T
|
||||
target: YouTubePlayer }
|
||||
|
||||
type Props = {
|
||||
ref?: RefObject<NiconicoViewerHandle | null>
|
||||
post: Post
|
||||
onLoadComplete?: (info: NiconicoVideoInfo) => void
|
||||
onMetadataChange?: (meta: NiconicoMetadata) => void }
|
||||
onMetadataChange?: (meta: NiconicoMetadata) => void
|
||||
onVideoReady?: (durationMs: number) => void
|
||||
onPlaybackChange?: (currentTimeMs: number) => number | void
|
||||
onError?: (data: unknown) => void }
|
||||
|
||||
|
||||
const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => {
|
||||
const PostEmbed: FC<Props> = ({
|
||||
ref,
|
||||
post,
|
||||
onLoadComplete,
|
||||
onMetadataChange,
|
||||
onVideoReady,
|
||||
onPlaybackChange,
|
||||
onError,
|
||||
}) => {
|
||||
const dialogue = useDialogue ()
|
||||
const [framed, setFramed] = useState (false)
|
||||
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
|
||||
|
||||
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
|
||||
const currentTime = await player.getCurrentTime ()
|
||||
const currentTimeMs = currentTime * 1_000
|
||||
const targetTimeMs = onPlaybackChange?.(currentTimeMs)
|
||||
|
||||
if (typeof targetTimeMs !== 'number')
|
||||
return
|
||||
|
||||
if (Math.abs (currentTimeMs - targetTimeMs) > 5_000)
|
||||
await player.seekTo (targetTimeMs / 1_000, true)
|
||||
}, [onPlaybackChange])
|
||||
|
||||
const handleYoutubeReady = async (event: YouTubeEvent) => {
|
||||
setYoutubePlayer (event.target)
|
||||
|
||||
try
|
||||
{
|
||||
await event.target.playVideo ()
|
||||
|
||||
const duration = await event.target.getDuration ()
|
||||
const durationMs = duration * 1_000
|
||||
onVideoReady?.(durationMs)
|
||||
|
||||
if (!(Number.isFinite (durationMs)) || durationMs <= 0)
|
||||
return
|
||||
|
||||
await reportYoutubePlayback (event.target)
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
onError?.({ platform: 'youtube', error })
|
||||
}
|
||||
}
|
||||
|
||||
const handleYoutubeStateChange = (event: YouTubeEvent<number>) => {
|
||||
void reportYoutubePlayback (event.target)
|
||||
}
|
||||
|
||||
const handleYoutubeError = (event: YouTubeEvent<number>) => {
|
||||
onError?.({ platform: 'youtube', code: event.data })
|
||||
}
|
||||
|
||||
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
|
||||
onVideoReady?.(info.lengthInSeconds * 1_000)
|
||||
onLoadComplete?.(info)
|
||||
}
|
||||
|
||||
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
|
||||
onPlaybackChange?.(meta.currentTime)
|
||||
onMetadataChange?.(meta)
|
||||
}
|
||||
|
||||
useEffect (() => {
|
||||
if (!(youtubePlayer) || !(onPlaybackChange))
|
||||
return
|
||||
|
||||
const timer = setInterval (
|
||||
() => void reportYoutubePlayback (youtubePlayer),
|
||||
1_000)
|
||||
|
||||
return () => clearInterval (timer)
|
||||
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
|
||||
|
||||
const url = new URL (post.url)
|
||||
|
||||
@@ -38,8 +118,9 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
|
||||
id={videoId}
|
||||
width={640}
|
||||
height={360}
|
||||
onLoadComplete={onLoadComplete}
|
||||
onMetadataChange={onMetadataChange}/>)
|
||||
onLoadComplete={handleNiconicoLoadComplete}
|
||||
onMetadataChange={handleNiconicoMetadataChange}
|
||||
onError={onError}/>)
|
||||
}
|
||||
|
||||
case 'twitter.com':
|
||||
@@ -69,7 +150,10 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
|
||||
mute: 0,
|
||||
loop: 1,
|
||||
width: '640',
|
||||
height: '360' } }}/>)
|
||||
height: '360' } }}
|
||||
onReady={handleYoutubeReady}
|
||||
onStateChange={handleYoutubeStateChange}
|
||||
onError={handleYoutubeError}/>)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,12 +55,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
|
||||
{ name: '追加', to: '/materials/new' },
|
||||
{ name: '全体履歴', to: '/materials/changes', visible: false },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
|
||||
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
||||
{ name: <>第 1 会場</>, to: '/theatres/1' },
|
||||
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
|
||||
{ name: <>ニジカ放送局第 1 チャンネル</>,
|
||||
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
|
||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
{ name: '新規', to: '/wiki/new' },
|
||||
@@ -71,6 +65,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
|
||||
visible: wikiPageFlg },
|
||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||
{ name: '上映会 (β)', to: '/theatres/1' }] },
|
||||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||||
{ name: '一覧', to: '/users', visible: false },
|
||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||
@@ -132,8 +128,12 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
|
||||
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
|
||||
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
||||
const moreMenu = menu.filter (item =>
|
||||
!(item.visible ?? true)
|
||||
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
|
||||
const activeIdx =
|
||||
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||||
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
|
||||
|
||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||
|
||||
@@ -244,9 +244,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
<motion.div
|
||||
key="submenu-shell"
|
||||
layout
|
||||
className="relative hidden md:block overflow-hidden
|
||||
className="relative z-20 hidden md:block overflow-hidden
|
||||
bg-yellow-200 dark:bg-red-950"
|
||||
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
|
||||
animate={{ height: submenuHeight }}
|
||||
onMouseLeave={() => {
|
||||
if (moreVsbl)
|
||||
setMoreVsbl (false)
|
||||
@@ -257,7 +257,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
}}>
|
||||
{moreVsbl
|
||||
? (
|
||||
menu.map ((item, i) => (
|
||||
moreMenu.map ((item, i) => (
|
||||
<div key={i} className="relative h-[40px]">
|
||||
<div className="absolute inset-0 flex items-center px-3">
|
||||
<motion.div
|
||||
@@ -267,7 +267,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||
animate: { x: 0, y: 0, opacity: 1 },
|
||||
exit: { x: 40, y: -40, opacity: 0 } })}
|
||||
className="z-10 h-full flex items-center px-3 font-bold w-24">
|
||||
className="z-10 h-full flex items-center px-3 font-bold w-28">
|
||||
<h2>{item.name}</h2>
|
||||
</motion.div>
|
||||
{item.subMenu
|
||||
|
||||
@@ -64,11 +64,14 @@ export const apiPatch = async <T> (
|
||||
): Promise<T> => apiP ('patch', path, body, opt)
|
||||
|
||||
|
||||
export const apiDelete = async (
|
||||
export const apiDelete = async <T = void> (
|
||||
path: string,
|
||||
opt?: Opt,
|
||||
): Promise<void> => {
|
||||
await client.delete (path, withUserCode (opt))
|
||||
): Promise<T> => {
|
||||
const res = await client.delete (path, withUserCode (opt))
|
||||
if (res.data == null || res.data === '')
|
||||
return undefined as T
|
||||
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { User, UserRole } from '@/types'
|
||||
|
||||
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
|
||||
|
||||
|
||||
export const canEditContent = (
|
||||
user: Pick<User, 'role'> | null | undefined,
|
||||
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
|
||||
|
||||
@@ -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 => ({
|
||||
id: 1,
|
||||
@@ -72,3 +81,62 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
|
||||
updatedByUser: { id: 2, name: 'updater' },
|
||||
...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,
|
||||
})
|
||||
|
||||
+56
-5
@@ -223,13 +223,64 @@ export type Theatre = {
|
||||
createdAt: string
|
||||
updatedAt: string }
|
||||
|
||||
export type TheatreComment = {
|
||||
theatreId: number,
|
||||
no: number,
|
||||
user: { id: number, name: string } | null
|
||||
content: string
|
||||
export type TheatreComment =
|
||||
| { theatreId: number
|
||||
no: number
|
||||
deleted: false
|
||||
user: { id: number, name: string } | null
|
||||
content: string
|
||||
createdAt: string }
|
||||
| { theatreId: number
|
||||
no: number
|
||||
deleted: true
|
||||
user: { id: number, name: string } | null
|
||||
content: null,
|
||||
createdAt: string }
|
||||
|
||||
export type TheatreProgramme = {
|
||||
theatreId: number
|
||||
position: number
|
||||
post: Post
|
||||
createdAt: string }
|
||||
|
||||
export type TheatreSkipVoteStatus = {
|
||||
votesCount: number
|
||||
requiredCount: number
|
||||
watchingUsersCount: number
|
||||
voted: boolean }
|
||||
|
||||
export type TheatreInfo = {
|
||||
hostFlg: boolean
|
||||
postId: number | null
|
||||
postStartedAt: string | null
|
||||
postElapsedMs: number | null
|
||||
watchingUsers: Pick<User, 'id' | 'name'>[]
|
||||
skipVote: TheatreSkipVoteStatus
|
||||
skipped?: boolean }
|
||||
|
||||
export type TheatreSkipEvent = {
|
||||
id: number
|
||||
theatreId: number
|
||||
post: Post
|
||||
tags: Tag[]
|
||||
programmePosition: number | null
|
||||
createdAt: string }
|
||||
|
||||
export type TheatrePostWeight = {
|
||||
post: Post
|
||||
weight: number
|
||||
penalty: number
|
||||
tags: Tag[] }
|
||||
|
||||
export type TheatreTagPenalty = {
|
||||
tag: Tag
|
||||
penalty: number }
|
||||
|
||||
export type TheatrePostSelectionWeights = {
|
||||
tagPenalties: TheatreTagPenalty[]
|
||||
lightestPosts: TheatrePostWeight[]
|
||||
heaviestPosts: TheatrePostWeight[] }
|
||||
|
||||
export type User = {
|
||||
id: number
|
||||
name: string | null
|
||||
|
||||
新しい課題から参照
ユーザをブロックする