diff --git a/app/Http/Controllers/PlayerController.php b/app/Http/Controllers/PlayerController.php index ac5f7b0d..96c8591d 100644 --- a/app/Http/Controllers/PlayerController.php +++ b/app/Http/Controllers/PlayerController.php @@ -55,6 +55,8 @@ class PlayerController extends Controller 'user.player.player-name-length', ['min' => option('player_name_length_min'), 'max' => option('player_name_length_max')] ), + 'score' => auth()->user()->score, + 'cost' => (int) option('score_per_player'), ]); } diff --git a/resources/assets/src/components/ViewerSkeleton.tsx b/resources/assets/src/components/ViewerSkeleton.tsx new file mode 100644 index 00000000..d6d5c357 --- /dev/null +++ b/resources/assets/src/components/ViewerSkeleton.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { trans } from '@/scripts/i18n' + +const ViewerSkeleton: React.FC = () => ( +
+
+
+

+ {trans('general.texturePreview')} +

+
+
+
+
+) + +export default ViewerSkeleton diff --git a/resources/assets/src/scripts/hooks/useTexture.ts b/resources/assets/src/scripts/hooks/useTexture.ts new file mode 100644 index 00000000..8801ec9a --- /dev/null +++ b/resources/assets/src/scripts/hooks/useTexture.ts @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react' +import * as fetch from '../net' +import { Texture, TextureType } from '../types' + +type Response = fetch.ResponseBody + +export default function useTexture(): [ + { url: string; type: TextureType }, + React.Dispatch>, +] { + const [tid, setTid] = useState(0) + const [url, setUrl] = useState('') + const [type, setType] = useState('steve') + + useEffect(() => { + if (tid <= 0) { + setUrl('') + return + } + + const getTexture = async () => { + const { + data: { hash, type }, + } = await fetch.get(`/skinlib/info/${tid}`) + + setUrl(`${blessing.base_url}/textures/${hash}`) + setType(type) + } + getTexture() + }, [tid]) + + return [{ url, type }, setTid] +} diff --git a/resources/assets/src/scripts/route.tsx b/resources/assets/src/scripts/route.tsx index db693d01..2d6b8bb4 100644 --- a/resources/assets/src/scripts/route.tsx +++ b/resources/assets/src/scripts/route.tsx @@ -5,10 +5,7 @@ const virtual = document.createElement('div') export default [ { path: '/', - module: [ - () => import('../styles/home.styl'), - () => import('./home-page'), - ], + module: [() => import('../styles/home.styl'), () => import('./home-page')], }, { path: 'user', @@ -20,7 +17,7 @@ export default [
 
- ) + ), }, { path: 'user/closet', @@ -29,8 +26,14 @@ export default [ }, { path: 'user/player', - component: () => import('../views/user/Players.vue'), - el: virtual, + react: () => import('../views/user/Players'), + el: '#players-list', + frame: () => ( +
+
 
+
+
+ ), }, { path: 'user/player/bind', @@ -39,9 +42,7 @@ export default [ }, { path: 'user/profile', - module: [ - () => import('../views/user/profile/index'), - ], + module: [() => import('../views/user/profile/index')], }, { path: 'user/oauth/manage', @@ -50,9 +51,7 @@ export default [ }, { path: 'admin', - module: [ - () => import('../views/admin/Dashboard'), - ], + module: [() => import('../views/admin/Dashboard')], }, { path: 'admin/users', @@ -71,9 +70,7 @@ export default [ }, { path: 'admin/customize', - module: [ - () => import('../views/admin/Customization'), - ], + module: [() => import('../views/admin/Customization')], }, { path: 'admin/i18n', @@ -92,9 +89,7 @@ export default [ }, { path: 'admin/update', - module: [ - () => import('../views/admin/Update'), - ], + module: [() => import('../views/admin/Update')], }, { path: 'auth/login', diff --git a/resources/assets/src/scripts/types.ts b/resources/assets/src/scripts/types.ts new file mode 100644 index 00000000..1544815e --- /dev/null +++ b/resources/assets/src/scripts/types.ts @@ -0,0 +1,14 @@ +export type Player = { + pid: number + name: string + tid_skin: number + tid_cape: number +} + +export type Texture = { + tid: number + hash: string + type: TextureType +} + +export type TextureType = 'steve' | 'alex' | 'cape' diff --git a/resources/assets/src/views/user/Players.vue b/resources/assets/src/views/user/Players.vue deleted file mode 100644 index 3967b112..00000000 --- a/resources/assets/src/views/user/Players.vue +++ /dev/null @@ -1,292 +0,0 @@ - - - - - diff --git a/resources/assets/src/views/user/Players/ModalAddPlayer.tsx b/resources/assets/src/views/user/Players/ModalAddPlayer.tsx new file mode 100644 index 00000000..b44ae067 --- /dev/null +++ b/resources/assets/src/views/user/Players/ModalAddPlayer.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { toast } from '@/scripts/notify' +import { Player } from '@/scripts/types' +import Modal from '@/components/Modal' + +type Extra = { + score: number + cost: number + rule: string + length: string +} + +interface Props { + show: boolean + onAdd(player: Player): void + onClose(): void +} + +const ModalAddPlayer: React.FC = props => { + const [name, setName] = useState('') + + const handleNameChange = (event: React.ChangeEvent) => { + setName(event.target.value) + } + + const handleConfirm = async () => { + const { code, message, data: player } = await fetch.post< + fetch.ResponseBody + >('/user/player/add', { name }) + if (code === 0) { + toast.success(message) + props.onAdd(player) + } else { + toast.error(message) + } + } + + const handleClose = () => { + setName('') + props.onClose() + } + + const { score, cost, rule, length } = blessing.extra as Extra + const isScoreEnough = score >= cost + + return ( + +
+ + +
+
+
    +
  • {rule}
  • +
  • {length}
  • +
+
+
+ + + {t('user.cur-score')} {score} + +
+
+ ) +} + +export default ModalAddPlayer diff --git a/resources/assets/src/views/user/Players/ModalReset.tsx b/resources/assets/src/views/user/Players/ModalReset.tsx new file mode 100644 index 00000000..ca2995f9 --- /dev/null +++ b/resources/assets/src/views/user/Players/ModalReset.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react' +import { t } from '@/scripts/i18n' +import Modal from '@/components/Modal' + +interface Props { + show: boolean + onSubmit(skin: boolean, cape: boolean): Promise + onClose(): void +} + +const ModalReset: React.FC = props => { + const [skin, setSkin] = useState(false) + const [cape, setCape] = useState(false) + + const handleSkinChange = (event: React.ChangeEvent) => { + setSkin(event.target.checked) + } + + const handleCapeChange = (event: React.ChangeEvent) => { + setCape(event.target.checked) + } + + const handleConfirm = () => { + props.onSubmit(skin, cape) + } + + const handleClose = () => { + setSkin(false) + setCape(false) + props.onClose() + } + + return ( + + + + + ) +} + +export default ModalReset diff --git a/resources/assets/src/views/user/Players/Previewer.tsx b/resources/assets/src/views/user/Players/Previewer.tsx new file mode 100644 index 00000000..f8a58f6a --- /dev/null +++ b/resources/assets/src/views/user/Players/Previewer.tsx @@ -0,0 +1,55 @@ +import React, { useState, useEffect } from 'react' +import ReactDOM from 'react-dom' +import { t } from '@/scripts/i18n' +import ViewerSkeleton from '@/components/ViewerSkeleton' +import Viewer2d from './Viewer2d' + +const Viewer3d = React.lazy(() => import('@/components/Viewer')) + +interface Props { + skin: string + cape: string + isAlex: boolean +} + +const container = document.createElement('div') + +const Previewer: React.FC = props => { + const [is3d, setIs3d] = useState(true) + + useEffect(() => { + const mount = document.querySelector('#previewer')! + mount.appendChild(container) + + return () => { + mount.removeChild(container) + } + }, []) + + const switchMode = () => setIs3d(is => !is) + + const switcher = ( + + ) + + const { skin, cape, isAlex } = props + + return ReactDOM.createPortal( + is3d ? ( + }> + + {switcher} + + + ) : ( + + {switcher} + + ), + container + ) +} + +export default Previewer diff --git a/resources/assets/src/views/user/Players/Row.scss b/resources/assets/src/views/user/Players/Row.scss new file mode 100644 index 00000000..e61ee564 --- /dev/null +++ b/resources/assets/src/views/user/Players/Row.scss @@ -0,0 +1,7 @@ +.row { + cursor: pointer; +} + +.selected { + background-color: #efefef; +} diff --git a/resources/assets/src/views/user/Players/Row.tsx b/resources/assets/src/views/user/Players/Row.tsx new file mode 100644 index 00000000..def196b4 --- /dev/null +++ b/resources/assets/src/views/user/Players/Row.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { t } from '@/scripts/i18n' +import ButtonEdit from '@/components/ButtonEdit' +import { Player } from '@/scripts/types' +import styles from './Row.scss' + +interface Props { + player: Player + selected: boolean + onClick: React.MouseEventHandler + onEditName(player: Player): Promise + onReset(): void + onDelete(player: Player): Promise +} + +const Row: React.FC = props => { + const { player } = props + + const handleEdit = () => { + props.onEditName(player) + } + + const handleDelete = () => { + props.onDelete(player) + } + + const classes = [styles.row] + if (props.selected) { + classes.push(styles.selected) + } + + return ( + + {player.pid} + + {player.name} + + + + + + + + ) +} + +export default Row diff --git a/resources/assets/src/views/user/Players/Viewer2d.scss b/resources/assets/src/views/user/Players/Viewer2d.scss new file mode 100644 index 00000000..b50f4691 --- /dev/null +++ b/resources/assets/src/views/user/Players/Viewer2d.scss @@ -0,0 +1,8 @@ +.texture { + max-height: 64px; + width: 64px; +} + +.line { + width: 80%; +} diff --git a/resources/assets/src/views/user/Players/Viewer2d.tsx b/resources/assets/src/views/user/Players/Viewer2d.tsx new file mode 100644 index 00000000..ad7b6635 --- /dev/null +++ b/resources/assets/src/views/user/Players/Viewer2d.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { t } from '@/scripts/i18n' +import styles from './Viewer2d.scss' + +interface Props { + skin: string + cape: string +} + +const Viewer2d: React.FC = props => { + return ( +
+
+

{t('general.texturePreview')}

+
+
+
+ {t('general.skin')} + {props.skin ? ( + {t('general.skin')} + ) : ( + {t('user.player.texture-empty')} + )} +
+
+ {t('general.cape')} + {props.cape ? ( + {t('general.cape')} + ) : ( + {t('user.player.texture-empty')} + )} +
+
+
{props.children}
+
+ ) +} + +export default Viewer2d diff --git a/resources/assets/src/views/user/Players/index.tsx b/resources/assets/src/views/user/Players/index.tsx new file mode 100644 index 00000000..eaf720a7 --- /dev/null +++ b/resources/assets/src/views/user/Players/index.tsx @@ -0,0 +1,221 @@ +import React, { useState, useEffect } from 'react' +import { hot } from 'react-hot-loader/root' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { showModal, toast } from '@/scripts/notify' +import useTexture from '@/scripts/hooks/useTexture' +import { Player } from '@/scripts/types' +import Loading from '@/components/Loading' +import Row from './Row' +import Previewer from './Previewer' +import ModalAddPlayer from './ModalAddPlayer' +import ModalReset from './ModalReset' + +const Players: React.FC = () => { + const [players, setPlayers] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [selected, setSelected] = useState(0) + const [skin, setSkin] = useTexture() + const [cape, setCape] = useTexture() + const [search, setSearch] = useState('') + const [showModalAddPlayer, setShowModalAddPlayer] = useState(false) + const [showModalReset, setShowModalReset] = useState(false) + + const selectPlayer = (player: Player) => { + setSelected(player.pid) + setSkin(player.tid_skin) + setCape(player.tid_cape) + } + + useEffect(() => { + const getPlayers = async () => { + setIsLoading(true) + const { data } = await fetch.get>( + '/user/player/list', + ) + setPlayers(data) + if (data.length === 1) { + selectPlayer(data[0]) + } + setIsLoading(false) + } + getPlayers() + }, []) + + const handleSearch = (event: React.ChangeEvent) => { + setSearch(event.target.value) + } + + const handleAdd = (player: Player) => { + setPlayers(players => [...players, player]) + } + + const editName = async (player: Player, index: number) => { + let name: string + try { + const { value } = await showModal({ + mode: 'prompt', + text: t('user.changePlayerName'), + input: player.name, + validator: (value: string) => { + if (!value) { + return t('user.emptyPlayerName') + } + }, + }) + name = value + } catch { + return + } + + const { code, message } = await fetch.post( + `/user/player/rename/${player.pid}`, + { name }, + ) + if (code === 0) { + toast.success(message) + setPlayers(players => { + players[index] = { ...player, name } + return players.slice() + }) + } else { + toast.error(message) + } + } + + const resetTexture = async (skin: boolean, cape: boolean) => { + if (!skin && !cape) { + toast.warning(t('user.noClearChoice')) + return + } + + const { code, message } = await fetch.post( + `/user/player/texture/clear/${selected}`, + { type: [skin && 'skin', cape && 'cape'].filter(Boolean) }, + ) + if (code === 0) { + toast.success(message) + if (skin) { + setSkin(0) + } + if (cape) { + setCape(0) + } + setPlayers(players => { + const index = players.findIndex(player => player.pid === selected) + const player = Object.assign({}, players[index]) + if (skin) { + player.tid_skin = 0 + } + if (cape) { + player.tid_cape = 0 + } + players[index] = player + return players.slice() + }) + } else { + toast.error(message) + } + } + + const deletePlayer = async (player: Player) => { + try { + await showModal({ + title: t('user.deletePlayer'), + text: t('user.deletePlayerNotice'), + okButtonType: 'danger', + }) + } catch { + return + } + + const { code, message } = await fetch.post( + `/user/player/delete/${player.pid}`, + ) + if (code === 0) { + toast.success(message) + const { pid } = player + setPlayers(players => players.filter(player => player.pid !== pid)) + } else { + toast.error(message) + } + } + + const openModalAddPlayer = () => setShowModalAddPlayer(true) + const closeModalAddPlayer = () => setShowModalAddPlayer(false) + + const openModalReset = () => setShowModalReset(true) + const closeModalReset = () => setShowModalReset(false) + + return ( + <> +
+
+ +
+
+ + + + + + + + + + {players.length === 0 ? ( + + + + ) : ( + players + .filter(({ name }) => name.includes(search)) + .map((player, i) => ( + selectPlayer(player)} + onEditName={() => editName(player, i)} + onReset={openModalReset} + onDelete={deletePlayer} + /> + )) + )} + +
PID{t('general.player.player-name')}{t('user.player.operation')}
+ {isLoading ? : 'Nothing here.'} +
+
+
+ +
+
+ + + + + ) +} + +export default hot(Players) diff --git a/resources/assets/tests/views/user/Players.test.tsx b/resources/assets/tests/views/user/Players.test.tsx new file mode 100644 index 00000000..099edc86 --- /dev/null +++ b/resources/assets/tests/views/user/Players.test.tsx @@ -0,0 +1,516 @@ +import React from 'react' +import { render, fireEvent, wait } from '@testing-library/react' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { Player } from '@/scripts/types' +import Players from '@/views/user/Players' + +jest.mock('@/scripts/net') + +const fixture: Readonly = Object.freeze({ + pid: 1, + name: 'kumiko', + tid_skin: 1, + tid_cape: 2, +}) + +beforeEach(() => { + const container = document.createElement('div') + container.id = 'previewer' + document.body.appendChild(container) + + blessing.extra = { + rule: 'please follow the rule', + length: 'greater than 3', + score: 1000, + cost: 50, + } +}) + +afterEach(() => { + document.querySelector('#previewer')!.remove() +}) + +test('loading indicator', () => { + fetch.get.mockResolvedValue({ data: [] }) + const { queryByTitle } = render() + expect(queryByTitle('Loading...')).toBeInTheDocument() +}) + +test('search players', async () => { + const fixture2: Player = { pid: 2, name: 'reina', tid_skin: 3, tid_cape: 4 } + fetch.get.mockResolvedValue({ data: [fixture, fixture2] }) + + const { getByPlaceholderText, queryByText } = render() + await wait() + + fireEvent.input(getByPlaceholderText(t('user.typeToSearch')), { + target: { value: 'k' }, + }) + + expect(queryByText(fixture.name)).toBeInTheDocument() + expect(queryByText(fixture2.name)).not.toBeInTheDocument() +}) + +describe('select player automatically', () => { + it('only one player', async () => { + fetch.get.mockResolvedValue({ data: [fixture] }) + render() + await wait() + + expect(fetch.get).toBeCalledWith(`/skinlib/info/${fixture.tid_skin}`) + expect(fetch.get).toBeCalledWith(`/skinlib/info/${fixture.tid_cape}`) + }) + + it('more players', async () => { + const fixture2: Player = { pid: 2, name: 'reina', tid_skin: 3, tid_cape: 4 } + fetch.get.mockResolvedValue({ data: [fixture, fixture2] }) + render() + await wait() + + expect(fetch.get).not.toBeCalledWith(`/skinlib/info/${fixture.tid_skin}`) + expect(fetch.get).not.toBeCalledWith(`/skinlib/info/${fixture.tid_cape}`) + }) +}) + +describe('2d preview', () => { + it('skin and cape', async () => { + fetch.get + .mockResolvedValueOnce({ data: [fixture] }) + .mockResolvedValueOnce({ data: { hash: 'a', type: 'steve' } }) + .mockResolvedValueOnce({ data: { hash: 'b', type: 'cape' } }) + + const { getByAltText, getByText } = render() + await wait() + + fireEvent.click(getByText(t('user.switch2dPreview'))) + + expect(getByAltText(t('general.skin'))).toHaveAttribute( + 'src', + `${blessing.base_url}/textures/a`, + ) + expect(getByAltText(t('general.cape'))).toHaveAttribute( + 'src', + `${blessing.base_url}/textures/b`, + ) + }) + + it('skin only', async () => { + fetch.get + .mockResolvedValueOnce({ data: [{ ...fixture, tid_cape: 0 }] }) + .mockResolvedValueOnce({ data: { hash: 'a', type: 'steve' } }) + + const { getByAltText, queryByAltText, getByText, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.switch2dPreview'))) + + expect(getByAltText(t('general.skin'))).toHaveAttribute( + 'src', + `${blessing.base_url}/textures/a`, + ) + expect(queryByAltText(t('general.cape'))).not.toBeInTheDocument() + expect(queryByText(t('user.player.texture-empty'))).toBeInTheDocument() + }) + + it('cape only', async () => { + fetch.get + .mockResolvedValueOnce({ data: [{ ...fixture, tid_skin: 0 }] }) + .mockResolvedValueOnce({ data: { hash: 'a', type: 'cape' } }) + + const { getByAltText, queryByAltText, getByText, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.switch2dPreview'))) + + expect(getByAltText(t('general.cape'))).toHaveAttribute( + 'src', + `${blessing.base_url}/textures/a`, + ) + expect(queryByAltText(t('general.skin'))).not.toBeInTheDocument() + expect(queryByText(t('user.player.texture-empty'))).toBeInTheDocument() + }) +}) + +describe('create player', () => { + beforeEach(() => { + fetch.get.mockResolvedValue({ data: [] }) + }) + + it('alert if score is enough', async () => { + const { getByRole, getByText, queryByText } = render() + await wait() + + fireEvent.click(getByText(t('user.player.add-player'))) + + expect( + queryByText(`${t('user.cur-score')} ${blessing.extra.score}`), + ).toBeInTheDocument() + expect(getByRole('alert')).toHaveClass('alert-success') + }) + + it('alert if lack of score', async () => { + blessing.extra.score = 0 + const { getByRole, getByText, queryByText } = render() + await wait() + + fireEvent.click(getByText(t('user.player.add-player'))) + + expect( + queryByText(`${t('user.cur-score')} ${blessing.extra.score}`), + ).toBeInTheDocument() + expect(getByRole('alert')).toHaveClass('alert-danger') + }) + + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'success', data: fixture }) + + const { getByText, getByLabelText, getByRole, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.player.add-player'))) + fireEvent.input(getByLabelText(t('general.player.player-name')), { + target: { value: fixture.name }, + }) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith('/user/player/add', { + name: fixture.name, + }) + expect(queryByText('success')).toBeInTheDocument() + expect(getByRole('status')).toHaveClass('alert-success') + expect(queryByText(fixture.pid.toString())).toBeInTheDocument() + expect(queryByText(fixture.name)).toBeInTheDocument() + }) + + it('failed', async () => { + fetch.post.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, getByLabelText, getByRole, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.player.add-player'))) + fireEvent.input(getByLabelText(t('general.player.player-name')), { + target: { value: fixture.name }, + }) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith('/user/player/add', { + name: fixture.name, + }) + expect(queryByText('failed')).toBeInTheDocument() + expect(getByRole('alert')).toHaveClass('alert-danger') + expect(queryByText(fixture.name)).not.toBeInTheDocument() + }) + + it('cancelled', async () => { + const { getByText, getByLabelText, queryByText } = render() + await wait() + + fireEvent.click(getByText(t('user.player.add-player'))) + fireEvent.input(getByLabelText(t('general.player.player-name')), { + target: { value: fixture.name }, + }) + fireEvent.click(getByText(t('general.cancel'))) + await wait() + + expect(fetch.post).not.toBeCalled() + expect(queryByText(fixture.name)).not.toBeInTheDocument() + }) + + it('clear form on close', async () => { + const { getByText, getByLabelText } = render() + await wait() + + fireEvent.click(getByText(t('user.player.add-player'))) + fireEvent.input(getByLabelText(t('general.player.player-name')), { + target: { value: fixture.name }, + }) + fireEvent.click(getByText(t('general.cancel'))) + fireEvent.click(getByText(t('user.player.add-player'))) + + expect(getByLabelText(t('general.player.player-name'))).toHaveValue('') + }) +}) + +describe('edit player name', () => { + beforeEach(() => { + fetch.get.mockResolvedValue({ data: [fixture] }) + }) + + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'success' }) + + const { + getByText, + getByTitle, + getByDisplayValue, + getByRole, + queryByText, + } = render() + await wait() + + fireEvent.click(getByTitle(t('user.player.edit-pname'))) + fireEvent.input(getByDisplayValue(fixture.name), { + target: { value: 'reina' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith(`/user/player/rename/${fixture.pid}`, { + name: 'reina', + }) + expect(queryByText('success')).toBeInTheDocument() + expect(getByRole('status')).toHaveClass('alert-success') + expect(queryByText('reina')).toBeInTheDocument() + }) + + it('empty name', async () => { + const { getByText, getByTitle, getByDisplayValue, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByTitle(t('user.player.edit-pname'))) + fireEvent.input(getByDisplayValue(fixture.name), { + target: { value: '' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).not.toBeCalled() + expect(queryByText(t('user.emptyPlayerName'))).toBeInTheDocument() + + fireEvent.click(getByText(t('general.cancel'))) + }) + + it('failed', async () => { + fetch.post.mockResolvedValue({ code: 1, message: 'failed' }) + + const { + getByText, + getByTitle, + getByDisplayValue, + getByRole, + queryByText, + } = render() + await wait() + + fireEvent.click(getByTitle(t('user.player.edit-pname'))) + fireEvent.input(getByDisplayValue(fixture.name), { + target: { value: 'reina' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith(`/user/player/rename/${fixture.pid}`, { + name: 'reina', + }) + expect(queryByText('failed')).toBeInTheDocument() + expect(getByRole('alert')).toHaveClass('alert-danger') + expect(queryByText(fixture.name)).toBeInTheDocument() + }) + + it('cancelled', async () => { + const { getByText, getByTitle, getByDisplayValue, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByTitle(t('user.player.edit-pname'))) + fireEvent.input(getByDisplayValue(fixture.name), { + target: { value: 'reina' }, + }) + fireEvent.click(getByText(t('general.cancel'))) + await wait() + + expect(fetch.post).not.toBeCalled() + expect(queryByText(fixture.name)).toBeInTheDocument() + }) +}) + +describe('reset texture', () => { + beforeEach(() => { + fetch.get.mockResolvedValue({ data: [fixture] }) + }) + + it('clear skin and cape', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'success' }) + + const { getByText, getByRole, getByLabelText, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.player.delete-texture'))) + fireEvent.click(getByLabelText(t('general.skin'))) + fireEvent.click(getByLabelText(t('general.cape'))) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith( + `/user/player/texture/clear/${fixture.pid}`, + { + type: ['skin', 'cape'], + }, + ) + expect(queryByText('success')).toBeInTheDocument() + expect(getByRole('status')).toHaveClass('alert-success') + }) + + it('clear skin', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'success' }) + + const { getByText, getByRole, getByLabelText, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.player.delete-texture'))) + fireEvent.click(getByLabelText(t('general.skin'))) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith( + `/user/player/texture/clear/${fixture.pid}`, + { + type: ['skin'], + }, + ) + expect(queryByText('success')).toBeInTheDocument() + expect(getByRole('status')).toHaveClass('alert-success') + }) + + it('clear cape', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'success' }) + + const { getByText, getByRole, getByLabelText, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.player.delete-texture'))) + fireEvent.click(getByLabelText(t('general.cape'))) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith( + `/user/player/texture/clear/${fixture.pid}`, + { + type: ['cape'], + }, + ) + expect(queryByText('success')).toBeInTheDocument() + expect(getByRole('status')).toHaveClass('alert-success') + }) + + it('select nothing', async () => { + const { getByText, getByRole, queryByText } = render() + await wait() + + fireEvent.click(getByText(t('user.player.delete-texture'))) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).not.toBeCalled() + expect(queryByText(t('user.noClearChoice'))).toBeInTheDocument() + expect(getByRole('alert')).toHaveClass('alert-warning') + }) + + it('failed', async () => { + fetch.post.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, getByRole, getByLabelText, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.player.delete-texture'))) + fireEvent.click(getByLabelText(t('general.skin'))) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith( + `/user/player/texture/clear/${fixture.pid}`, + { + type: ['skin'], + }, + ) + expect(queryByText('failed')).toBeInTheDocument() + expect(getByRole('alert')).toHaveClass('alert-danger') + }) + + it('cancelled', async () => { + const { getByText, getByRole, getByLabelText, queryByText } = render( + , + ) + await wait() + + fireEvent.click(getByText(t('user.player.delete-texture'))) + fireEvent.click(getByLabelText(t('general.skin'))) + fireEvent.click(getByText(t('general.cancel'))) + await wait() + + expect(fetch.post).not.toBeCalled() + }) +}) + +describe('delete player', () => { + beforeEach(() => { + fetch.get.mockResolvedValue({ data: [fixture] }) + }) + + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'success' }) + + const { getByText, getByRole, queryByText } = render() + await wait() + + fireEvent.click(getByText(t('user.player.delete-player'))) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith(`/user/player/delete/${fixture.pid}`) + expect(getByText('success')).toBeInTheDocument() + expect(getByRole('status')).toHaveClass('alert-success') + expect(queryByText(fixture.name)).not.toBeInTheDocument() + }) + + it('failed', async () => { + fetch.post.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, getByRole, queryByText } = render() + await wait() + + fireEvent.click(getByText(t('user.player.delete-player'))) + fireEvent.click(getByText(t('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith(`/user/player/delete/${fixture.pid}`) + expect(getByText('failed')).toBeInTheDocument() + expect(getByRole('alert')).toHaveClass('alert-danger') + expect(queryByText(fixture.name)).toBeInTheDocument() + }) + + it('cancelled', async () => { + const { getByText, getByRole, queryByText } = render() + await wait() + + fireEvent.click(getByText(t('user.player.delete-player'))) + fireEvent.click(getByText(t('general.cancel'))) + await wait() + + expect(fetch.post).not.toBeCalled() + expect(queryByText(fixture.name)).toBeInTheDocument() + }) +})