add closet command for Web CLI

This commit is contained in:
Pig Fang 2020-03-21 17:30:44 +08:00
parent d57dabe188
commit 4c981529e1
11 changed files with 304 additions and 7 deletions

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Texture;
use App\Models\User;
use Illuminate\Http\Request;
class ClosetManagementController extends Controller
{
public function add(Request $request, $uid)
{
/** @var Texture */
$texture = Texture::findOrFail($request->input('tid'));
/** @var User */
$user = User::findOrFail($uid);
$user->closet()->attach($texture->tid, ['item_name' => $texture->name]);
return json('', 0, compact('user', 'texture'));
}
public function remove(Request $request, $uid)
{
/** @var Texture */
$texture = Texture::findOrFail($request->input('tid'));
/** @var User */
$user = User::findOrFail($uid);
$user->closet()->detach($texture->tid);
return json('', 0, compact('user', 'texture'));
}
}

View File

@ -21,7 +21,7 @@
"@hot-loader/react-dom": "^16.11.0",
"@tweenjs/tween.js": "^18.4.2",
"admin-lte": "^3.0.1",
"blessing-skin-shell": "^0.1.0",
"blessing-skin-shell": "^0.2.0",
"echarts": "^4.6.0",
"jquery": "^3.4.1",
"lodash.debounce": "^4.0.8",
@ -224,7 +224,8 @@
"coveragePathIgnorePatterns": [
"/node_modules/",
"<rootDir>/resources/assets/tests/setup",
"<rootDir>/resources/assets/tests/utils"
"<rootDir>/resources/assets/tests/utils",
"<rootDir>/resources/assets/tests/scripts/cli/stdio"
],
"testMatch": [
"<rootDir>/resources/assets/tests/**/*.test.ts",

View File

@ -5,6 +5,7 @@ import { FitAddon } from 'xterm-addon-fit'
import { Shell } from 'blessing-skin-shell'
import 'xterm/css/xterm.css'
import Draggable from 'react-draggable'
import ClosetCommand from './cli/ClosetCommand'
import styles from '@/styles/terminal.module.scss'
let launched = false
@ -29,6 +30,8 @@ const TerminalWindow: React.FC<{ onClose(): void }> = props => {
fitAddon.fit()
const shell = new Shell(terminal)
shell.addExternal('closet', ClosetCommand)
const unbind = terminal.onData(e => shell.input(e))
launched = true

View File

@ -0,0 +1,63 @@
import type { Stdio } from 'blessing-skin-shell'
import * as fetch from '../net'
import { User, Texture } from '../types'
type Subcommand = 'add' | 'remove'
type Response = fetch.ResponseBody<{ user: User; texture: Texture }>
export default async function closet(stdio: Stdio, args: string[]) {
if (args.includes('-h') || args.includes('--help')) {
stdio.println('Usage: closet <add|remove> <uid> <tid>')
return
}
const command = args[0] as Subcommand
const uid = args[1]
const tid = args[2]
if (!command) {
stdio.println('Supported subcommand: add, remove.')
return
}
if (!uid) {
stdio.println('User ID must be provided.')
return
}
if (!tid) {
stdio.println('Texture ID must be provided.')
return
}
switch (command) {
case 'add': {
const { code, data } = await fetch.post<Response>(
`/admin/closet/${uid}`,
{ tid },
)
if (code === 0) {
const { texture, user } = data
stdio.println(
`Texture "${texture.name}" was added to user ${user.nickname}'s closet.`,
)
} else {
stdio.println('Error occurred.')
}
break
}
case 'remove': {
const { code, data } = await fetch.del<Response>(`/admin/closet/${uid}`, {
tid,
})
if (code === 0) {
const { texture, user } = data
stdio.println(
`Texture "${texture.name}" was removed from user ${user.nickname}'s closet.`,
)
} else {
stdio.println('Error occurred.')
}
break
}
}
}

View File

@ -1,3 +1,16 @@
export type User = {
uid: number
email: string
nickname: string
score: number
avatar: number
permission: number
ip: string
last_sign_at: string
register_at: string
verified: boolean
}
export type Player = {
pid: number
name: string

View File

@ -1,7 +1,7 @@
@use '../styles/breakpoints';
.terminal {
z-index: 1060;
z-index: 1040;
position: fixed;
bottom: 7vh;
user-select: none;

View File

@ -0,0 +1,101 @@
import * as fetch from '@/scripts/net'
import runCommand from '@/scripts/cli/ClosetCommand'
import { Stdio } from './stdio'
jest.mock('@/scripts/net')
test('help message', async () => {
let stdio = new Stdio()
await runCommand(stdio, ['-h'])
expect(stdio.getStdout()).toInclude('Usage')
stdio = new Stdio()
await runCommand(stdio, ['--help'])
expect(stdio.getStdout()).toInclude('Usage')
})
test('missing subcommand', async () => {
const stdio = new Stdio()
await runCommand(stdio, [])
expect(fetch.post).not.toBeCalled()
expect(fetch.del).not.toBeCalled()
})
test('unsupported subcommand', async () => {
const stdio = new Stdio()
await runCommand(stdio, ['abc'])
expect(fetch.post).not.toBeCalled()
expect(fetch.del).not.toBeCalled()
})
test('missing uid', async () => {
const stdio = new Stdio()
await runCommand(stdio, ['add'])
expect(stdio.getStdout()).toInclude('User ID')
expect(fetch.post).not.toBeCalled()
expect(fetch.del).not.toBeCalled()
})
test('missing tid', async () => {
const stdio = new Stdio()
await runCommand(stdio, ['add', '1'])
expect(stdio.getStdout()).toInclude('Texture ID')
expect(fetch.post).not.toBeCalled()
expect(fetch.del).not.toBeCalled()
})
describe('add texture', () => {
it('succeeded', async () => {
fetch.post.mockResolvedValue({
code: 0,
data: { user: { nickname: 'kumiko' }, texture: { name: 'eupho' } },
})
const stdio = new Stdio()
await runCommand(stdio, ['add', '1', '2'])
const stdout = stdio.getStdout()
expect(stdout).toInclude('kumiko')
expect(stdout).toInclude('eupho')
expect(fetch.post).toBeCalledWith('/admin/closet/1', { tid: '2' })
})
it('failed', async () => {
fetch.post.mockResolvedValue({ code: 1 })
const stdio = new Stdio()
await runCommand(stdio, ['add', '1', '2'])
const stdout = stdio.getStdout()
expect(stdout).toInclude('Error occurred.')
expect(fetch.post).toBeCalledWith('/admin/closet/1', { tid: '2' })
})
})
describe('remove texture', () => {
it('succeeded', async () => {
fetch.del.mockResolvedValue({
code: 0,
data: { user: { nickname: 'kumiko' }, texture: { name: 'eupho' } },
})
const stdio = new Stdio()
await runCommand(stdio, ['remove', '1', '2'])
const stdout = stdio.getStdout()
expect(stdout).toInclude('kumiko')
expect(stdout).toInclude('eupho')
expect(fetch.del).toBeCalledWith('/admin/closet/1', { tid: '2' })
})
it('failed', async () => {
fetch.del.mockResolvedValue({ code: 1 })
const stdio = new Stdio()
await runCommand(stdio, ['remove', '1', '2'])
const stdout = stdio.getStdout()
expect(stdout).toInclude('Error occurred.')
expect(fetch.del).toBeCalledWith('/admin/closet/1', { tid: '2' })
})
})

View File

@ -0,0 +1,24 @@
export class Stdio {
private stdout = ''
public print(data: string) {
this.stdout += data
}
public println(data: string) {
this.stdout += data
this.stdout += '\r\n'
}
public reset() {
//
}
public free() {
//
}
public getStdout() {
return this.stdout
}
}

View File

@ -140,6 +140,11 @@ Route::prefix('admin')
Route::get('list', 'AdminController@getPlayerData');
});
Route::prefix('closet')->group(function () {
Route::post('{uid}', 'ClosetManagementController@add');
Route::delete('{uid}', 'ClosetManagementController@remove');
});
Route::prefix('reports')->group(function () {
Route::view('', 'admin.reports');
Route::post('', 'ReportController@review');

View File

@ -0,0 +1,53 @@
<?php
namespace Tests;
use App\Models\Texture;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class ClosetManagementControllerTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->actingAs(factory(\App\Models\User::class)->states('admin')->create());
}
public function testAdd()
{
$user = factory(User::class)->create();
$texture = factory(Texture::class)->create();
$this->postJson('/admin/closet/'.$user->uid, ['tid' => $texture->tid])
->assertJson([
'code' => 0,
'data' => [
'user' => $user->toArray(),
'texture' => $texture->toArray(),
],
]);
$item = $user->closet()->first();
$this->assertEquals($texture->tid, $item->tid);
$this->assertEquals($texture->name, $item->pivot->item_name);
}
public function testRemove()
{
$user = factory(User::class)->create();
$texture = factory(Texture::class)->create();
$user->closet()->attach($texture->tid, ['item_name' => '']);
$this->deleteJson('/admin/closet/'.$user->uid, ['tid' => $texture->tid])
->assertJson([
'code' => 0,
'data' => [
'user' => $user->toArray(),
'texture' => $texture->toArray(),
],
]);
$this->assertCount(0, $user->closet);
}
}

View File

@ -2267,10 +2267,10 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c"
integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==
blessing-skin-shell@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/blessing-skin-shell/-/blessing-skin-shell-0.1.0.tgz#6251b154d2e5b225d65f1b6e8c7489bb2eb67303"
integrity sha512-6c7RwPqLS1mXsrLd6QU7xUjr/gfmDgAY5L+CqOMWwwjLfjuohw6SK6cnGyfWxeK8KoDj5zX3QyoYuCKapXlkUg==
blessing-skin-shell@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/blessing-skin-shell/-/blessing-skin-shell-0.2.0.tgz#2454e305c69de134164491d2715a34d599c0d8a1"
integrity sha512-bwVHWep3jlPwgY7rAmr+KZJr1RdfVS9OTQB25avRW4EzONflQRiRgU76T8X/DeWa3PItEEGf3qNUJJuRhN5orw==
bluebird@^3.1.1, bluebird@^3.5.5:
version "3.5.5"