New UT to cover web socket. Use view component.

This commit is contained in:
AnduinXue
2023-12-10 17:01:08 +00:00
parent 026d168756
commit c3c9ce7e48
12 changed files with 198 additions and 192 deletions
+3 -1
View File
@@ -3,6 +3,8 @@
*.lock.json
package-lock.json
*.user
*.min.css
*.min.js
bin/
obj/
node_modules/
@@ -13,4 +15,4 @@ bundle.js
appsettings.Production.json
appsettings.Development.json
*.log
TestResults/
TestResults/
@@ -8,16 +8,13 @@ namespace Aiursoft.ChessServer.Controllers;
[Route("games")]
public class GamesController : Controller
{
private readonly ILogger<GamesController> _logger;
private readonly WebSocketPusher _pusher;
private readonly InMemoryDatabase _database;
public GamesController(
ILogger<GamesController> logger,
WebSocketPusher pusher,
InMemoryDatabase database)
{
_logger = logger;
_pusher = pusher;
_database = database;
}
@@ -40,25 +37,16 @@ public class GamesController : Controller
public async Task GetWebSocket([FromRoute] int id)
{
var game = _database.GetOrAddGame(id);
ISubscription? subscription = null;
await _pusher.Accept(HttpContext);
var subscription = game.Channel.Subscribe(_pusher.Send);
try
{
subscription = game.Channel.Subscribe(async t =>
{
await _pusher.SendMessage(t);
_logger.LogInformation("Message was sent to client with ID: {PusherId}", _pusher.Id);
});
_logger.LogInformation("Game {Id} registering listener {PusherId} done. Waiting for close. Now it has {Count} subscribers", id, _pusher.Id, game.Channel.GetListenerCount());
await _pusher.PendingClose();
await _pusher.Wait();
}
finally
{
await _pusher.Close();
subscription!.UnRegister();
_logger.LogInformation("Game {Id}'s pusher {PusherId} connection was interrupted. Now it has {Count} subscribers", id, _pusher.Id, game.Channel.GetListenerCount());
}
}
@@ -0,0 +1,7 @@
namespace Aiursoft.ChessServer.Models;
public class Player
{
public string NickName { get; set; } = "Anonymous";
public Guid Id { get; set; } = Guid.NewGuid();
}
@@ -9,8 +9,6 @@ public class WebSocketPusher : IScopedDependency
private bool _dropped;
private WebSocket? _ws;
public readonly Guid Id = Guid.NewGuid();
public bool Connected => !_dropped && _ws?.State == WebSocketState.Open;
public async Task Accept(HttpContext context)
@@ -18,11 +16,11 @@ public class WebSocketPusher : IScopedDependency
_ws = await context.WebSockets.AcceptWebSocketAsync();
}
public async Task SendMessage(string message)
public async Task Send(string message)
{
if (_dropped)
{
return;
throw new InvalidOperationException("WebSocket is dropped!");
}
try
{
@@ -34,11 +32,11 @@ public class WebSocketPusher : IScopedDependency
}
}
public async Task PendingClose()
public async Task Wait()
{
try
{
var buffer = new ArraySegment<byte>(new byte[4096 * 20]);
var buffer = new byte[1024 * 4];
while (true)
{
await (_ws?.ReceiveAsync(buffer, CancellationToken.None) ?? throw new InvalidOperationException("WebSocket is not connected!"));
@@ -1,64 +1,5 @@
@model int
@section Styles {
<link href="~/node_modules/@@chrisoakman/chessboardjs/dist/chessboard-1.0.0.min.css" rel="stylesheet" />
<style>
.hidden {
display: none;
}
</style>
}
<div class="row">
<div class="col-md-6">
<div id="board" style="width: 100%"></div>
</div>
<div class="col-md-12">
@* Make the following p auto warp *@
<p id="status" class="text-center text-wrap text-primary mt-1"></p>
<p id="fen" class="text-wrap text-muted hidden"></p>
</div>
</div>
<div class="modal fade" id="chooseCharacter" tabindex="-1" aria-labelledby="chooseCharacter" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Choose your character</h5>
</div>
<div class="modal-body">
<button type="button" id="chooseWhite" class="btn">
<img src="/img/chesspieces/wikipedia/wK.png" alt="White">
</button>
<button type="button" id="chooseBlack" class="btn">
<img src="/img/chesspieces/wikipedia/bK.png" alt="Black">
</button>
<button type="button" data-bs-dismiss="modal" id="choosePeek" class="btn btn-primary">Peek~👀</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script type="module" src="~/node_modules/@@chrisoakman/chessboardjs/dist/chessboard-1.0.0.min.js"></script>
<script type="module" src="~/site.js"></script>
<script type="module">
// Ask the user to enter w or b:
import Game from "/site.js";
let game = new Game(@Model);
game.chooseCharacter();
$("#chooseWhite").on("click", function () {
game.setPlayer('w');
game.startGame();
});
$("#chooseBlack").on("click", function () {
game.setPlayer('b');
game.startGame();
});
$('#choosePeek').on('click', function () {
game.startGame();
});
</script>
}
<vc:chess-board game-id="@Model"></vc:chess-board>
</div>
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
namespace Aiursoft.ChessServer.Views.Shared.Components;
public class ChessBoard : ViewComponent
{
public IViewComponentResult Invoke(int gameId)
{
return View(new ChessBoardModel
{
GameId = gameId
});
}
}
public class ChessBoardModel
{
public int GameId { get; set; }
}
@@ -0,0 +1,20 @@
@model Aiursoft.ChessServer.Views.Shared.Components.ChessBoardModel
<div class="col-md-6">
<div id="board" style="width: 100%" class="w-100"></div>
</div>
<div class="col-md-12">
<p id="status" class="text-center text-wrap text-primary mt-1"></p>
</div>
<script type="module">
import initGameBoard from "/scripts/chessboard.js";
window.addEventListener('DOMContentLoaded', () => {
// Ask the user to enter w or b:
const player = prompt("Please enter your color (w or b):");
initGameBoard(player, @Model.GameId);
});
</script>
@@ -1,44 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Aiursoft Chess Server</title>
<link rel="stylesheet" href="~/node_modules/bootstrap/dist/css/bootstrap.min.css" />
@await RenderSectionAsync("Styles", required: false)
<link rel="stylesheet" href="~/node_modules/@@chrisoakman/chessboardjs/dist/chessboard-1.0.0.min.css" />
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container-fluid">
<a class="navbar-brand" asp-controller="Home" asp-action="Index">Aiursoft 国际象棋服务器</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
@* <li class="nav-item"> *@
@* <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">关于</a> *@
@* </li> *@
</ul>
</div>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container-fluid">
<a class="navbar-brand" asp-controller="Home" asp-action="Index">Aiursoft Chess Server</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
@* <li class="nav-item"> *@
@* <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">关于</a> *@
@* </li> *@
</ul>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; @DateTime.UtcNow.Year - Aiursoft.ChessServer
</div>
</footer>
<script src="~/node_modules/jquery/dist/jquery.min.js"></script>
<script src="~/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; @DateTime.UtcNow.Year - Aiursoft.ChessServer
</div>
</footer>
<script type="module" src="~/node_modules/jquery/dist/jquery.min.js" defer></script>
<script type="module" src="~/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" defer></script>
<script type="module" src="~/node_modules/@@chrisoakman/chessboardjs/dist/chessboard-1.0.0.min.js" defer></script>
</body>
</html>
</html>
@@ -1,4 +1,5 @@
@using System.Collections.Generic
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Identity
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Aiursoft.ChessServer
@@ -1,57 +1,4 @@
import { Chess } from "/node_modules/chess.js/dist/esm/chess.js";
const WHITE = "w";
const BLACK = "b";
const SPECTATOR = "";
/**
* representing current Game
*
* ## example
* ```
* let gameId = 0;
* let game = new Game(gameId);
* ```
*
* @param {number} gameId current id of game
*/
function Game(gameId) {
this.gameId = gameId;
this.player = "";
this.chooseCharacterModal = new bootstrap.Modal(
document.getElementById("chooseCharacter")
);
this.chooseCharacter = () => {
this.chooseCharacterModal.show();
};
this.setPlayer = (player) => {
if ([WHITE, BLACK].includes(player)) {
this.player = player;
} else {
this.player = SPECTATOR;
}
};
/**
* let's start to play!
* but before, you need to choose a character
*/
this.startGame = () => {
this.chooseCharacterModal.hide();
initGameBoard(this.player, this.gameId);
if (this.player === SPECTATOR) {
document.getElementById("board").style.cursor = "not-allowed";
}
};
}
const statusControl = $("#status");
const fenControl = $("#fen");
const initGameBoard = function (player, gameId) {
$.get("/games/" + gameId + ".fen", function (fen) {
let board = null;
@@ -69,10 +16,9 @@ const initGameBoard = function (player, gameId) {
// only pick up pieces for the side to move
if (
(game.turn() === "w" && piece.search(/^b/) !== -1) ||
(game.turn() === "b" && piece.search(/^w/) !== -1)
) {
return false;
(game.turn() === "w" && piece.search(/^b/) !== -1) ||
(game.turn() === "b" && piece.search(/^w/) !== -1)) {
return false
}
}
@@ -98,18 +44,14 @@ const initGameBoard = function (player, gameId) {
board.position(game.fen());
}
const statusControl = $("#status");
function updateStatusText() {
let status;
let moveColor = "White";
if (game.turn() === "b") {
moveColor = "Black";
}
if (game.isCheckmate()) {
status =
"Game over, " +
moveColor +
" is in checkmate, and winner is " +
(game.turn() === "w" ? "Black" : "White");
} if (game.isCheckmate()) {
status = "Game over, " + moveColor + " is in checkmate, and winner is " + (game.turn() === "w" ? "Black" : "White");
} else if (game.isDraw()) {
status = "Game over, drawn position";
} else {
@@ -119,7 +61,6 @@ const initGameBoard = function (player, gameId) {
}
}
statusControl.html(status);
fenControl.html(game.fen());
}
const config = {
@@ -129,18 +70,13 @@ const initGameBoard = function (player, gameId) {
position: fen,
onDragStart: onDragStart,
onSnapEnd: onSnapEnd,
onDrop: onDrop,
onDrop: onDrop
};
board = ChessBoard("board", config);
function refresh(newFen) {
game = new Chess(newFen);
board.position(newFen);
// Hack here: Set the position again after a short delay to avoid thread conflicts.
setTimeout(function () {
board.position(newFen);
}, 300);
console.log("Got fen " + newFen + ". refreshing board...");
updateStatusText();
}
@@ -149,7 +85,7 @@ const initGameBoard = function (player, gameId) {
const wsScheme = window.location.protocol === "https:" ? "wss://" : "ws://";
const socket = new WebSocket(
wsScheme + window.location.host + "/games/" + gameId + ".ws"
wsScheme + window.location.host + "/games/" + gameId + ".ws"
);
socket.onmessage = function (event) {
refresh(event.data);
@@ -157,7 +93,6 @@ const initGameBoard = function (player, gameId) {
// Auto reconnect.
socket.onclose = function () {
alert("Socket closed. Reconnecting...");
setTimeout(function () {
initGameBoard(player, gameId);
}, 1000);
@@ -166,4 +101,4 @@ const initGameBoard = function (player, gameId) {
};
// noinspection JSUnusedGlobalSymbols
export default Game;
export default initGameBoard;
+67 -1
View File
@@ -1,7 +1,9 @@
using Aiursoft.CSTools.Tools;
using System.Net.WebSockets;
using Aiursoft.CSTools.Tools;
using Microsoft.Extensions.Hosting;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using static Aiursoft.WebTools.Extends;
// ReSharper disable StringLiteralTypo
namespace Aiursoft.ChessServer.Tests;
@@ -73,4 +75,68 @@ public class BasicTests
var response = await _http.PostAsync(_endpointUrl + url, new StringContent(""));
Assert.AreEqual(400, (int)response.StatusCode);
}
[TestMethod]
[DataRow(7)]
[DataRow(8)]
[DataRow(9)]
public async Task TestConnect(int gameId)
{
var tester = new WebSocketTester();
var socket = new ClientWebSocket();
await socket.ConnectAsync(new Uri(_endpointUrl.Replace("http", "ws") + $"/games/{gameId}.ws"),
CancellationToken.None);
await Task.Factory.StartNew(() => tester.Monitor(socket));
await _http.PostAsync(_endpointUrl + $"/games/{gameId}/move/w/e4", new StringContent(""));
await Task.Delay(50);
Assert.AreEqual("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", tester.LastMessage);
await _http.PostAsync(_endpointUrl + $"/games/{gameId}/move/b/e5", new StringContent(""));
await Task.Delay(50);
Assert.AreEqual("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", tester.LastMessage);
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
}
[TestMethod]
[DataRow(10)]
public async Task TestGameWithReconnection(int gameId)
{
var socket1 = new ClientWebSocket();
var tester1 = new WebSocketTester();
await socket1.ConnectAsync(new Uri(_endpointUrl.Replace("http", "ws") + $"/games/{gameId}.ws"),
CancellationToken.None);
await Task.Factory.StartNew(() => tester1.Monitor(socket1));
var socket2 = new ClientWebSocket();
var tester2 = new WebSocketTester();
await socket2.ConnectAsync(new Uri(_endpointUrl.Replace("http", "ws") + $"/games/{gameId}.ws"),
CancellationToken.None);
await Task.Factory.StartNew(() => tester2.Monitor(socket2));
await _http.PostAsync(_endpointUrl + $"/games/{gameId}/move/w/e4", new StringContent(""));
await Task.Delay(50);
Assert.AreEqual("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", tester1.LastMessage);
Assert.AreEqual("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", tester2.LastMessage);
await _http.PostAsync(_endpointUrl + $"/games/{gameId}/move/b/e5", new StringContent(""));
await Task.Delay(50);
Assert.AreEqual("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", tester1.LastMessage);
Assert.AreEqual("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", tester2.LastMessage);
await socket1.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
var socket3 = new ClientWebSocket();
var tester3 = new WebSocketTester();
await socket3.ConnectAsync(new Uri(_endpointUrl.Replace("http", "ws") + $"/games/{gameId}.ws"),
CancellationToken.None);
await Task.Factory.StartNew(() => tester3.Monitor(socket3));
await _http.PostAsync(_endpointUrl + $"/games/{gameId}/move/w/Nf3", new StringContent(""));
await Task.Delay(50);
Assert.AreEqual("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", tester1.LastMessage);
Assert.AreEqual("rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", tester2.LastMessage);
Assert.AreEqual("rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", tester3.LastMessage);
}
}
@@ -0,0 +1,29 @@
using System.Net.WebSockets;
using System.Text;
namespace Aiursoft.ChessServer.Tests;
public class WebSocketTester
{
public string LastMessage { get; private set; } = string.Empty;
public async Task Monitor(WebSocket socket)
{
var buffer = new ArraySegment<byte>(new byte[2048]);
while (true)
{
var result = await socket.ReceiveAsync(buffer, CancellationToken.None);
switch (result.MessageType)
{
case WebSocketMessageType.Close:
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
return;
case WebSocketMessageType.Text:
LastMessage = Encoding.UTF8.GetString(buffer.Array!, 0, result.Count);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
}
}