// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Comments; using osu.Game.Overlays.Comments.Buttons; using osuTK.Input; namespace osu.Game.Tests.Visual.Online { public partial class TestSceneCommentActions : OsuManualInputManagerTestScene { private Container content = null!; protected override Container Content => content; private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; [Cached(typeof(IDialogOverlay))] private readonly DialogOverlay dialogOverlay = new DialogOverlay(); [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); private CommentsContainer commentsContainer = null!; private readonly ManualResetEventSlim requestLock = new ManualResetEventSlim(); [BackgroundDependencyLoader] private void load() { base.Content.AddRange(new Drawable[] { new PopoverContainer { RelativeSizeAxes = Axes.Both, Child = content = new OsuScrollContainer { RelativeSizeAxes = Axes.Both } }, dialogOverlay }); } [SetUpSteps] public void SetUp() { Schedule(() => { API.Login("test", "test"); Child = commentsContainer = new CommentsContainer(); }); } [Test] public void TestNonOwnCommentCantBeDeleted() { addTestComments(); AddUntilStep("First comment has button", () => { var comments = this.ChildrenOfType(); var ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); return ourComment != null && ourComment.ChildrenOfType().Any(x => x.Text == "delete"); }); AddAssert("Second doesn't", () => { var comments = this.ChildrenOfType(); var ourComment = comments.Single(x => x.Comment.Id == 2); return ourComment.ChildrenOfType().All(x => x.Text != "delete"); }); } [Test] public void TestDeletion() { DrawableComment? ourComment = null; addTestComments(); AddUntilStep("Comment exists", () => { var comments = this.ChildrenOfType(); ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); return ourComment != null; }); AddStep("It has delete button", () => { var btn = ourComment.ChildrenOfType().Single(x => x.Text == "delete"); InputManager.MoveMouseTo(btn); }); AddStep("Click delete button", () => { InputManager.Click(MouseButton.Left); }); AddStep("Setup request handling", () => { requestLock.Reset(); dummyAPI.HandleRequest = request => { if (!(request is CommentDeleteRequest req)) return false; if (req.CommentId != 1) return false; CommentBundle cb = new CommentBundle { Comments = new List { new Comment { Id = 2, Message = "This is a comment by another user", UserId = API.LocalUser.Value.Id + 1, CreatedAt = DateTimeOffset.Now, User = new APIUser { Id = API.LocalUser.Value.Id + 1, Username = "Another user" } }, }, IncludedComments = new List(), PinnedComments = new List(), }; Task.Run(() => { requestLock.Wait(10000); req.TriggerSuccess(cb); }); return true; }; }); AddStep("Confirm dialog", () => InputManager.Key(Key.Number1)); AddAssert("Loading spinner shown", () => commentsContainer.ChildrenOfType().Any(d => d.IsPresent)); AddStep("Complete request", () => requestLock.Set()); AddUntilStep("Comment is deleted locally", () => this.ChildrenOfType().Single(x => x.Comment.Id == 1).WasDeleted); } [Test] public void TestDeletionFail() { DrawableComment? ourComment = null; bool delete = false; addTestComments(); AddUntilStep("Comment exists", () => { var comments = this.ChildrenOfType(); ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); return ourComment != null; }); AddStep("It has delete button", () => { var btn = ourComment.ChildrenOfType().Single(x => x.Text == "delete"); InputManager.MoveMouseTo(btn); }); AddStep("Click delete button", () => { InputManager.Click(MouseButton.Left); }); AddStep("Setup request handling", () => { dummyAPI.HandleRequest = request => { if (request is not CommentDeleteRequest req) return false; req.TriggerFailure(new InvalidOperationException()); delete = true; return false; }; }); AddStep("Confirm dialog", () => InputManager.Key(Key.Number1)); AddUntilStep("Deletion requested", () => delete); AddUntilStep("Comment is available", () => { return !this.ChildrenOfType().Single(x => x.Comment.Id == 1).WasDeleted; }); AddAssert("Loading spinner hidden", () => { return ourComment.ChildrenOfType().All(d => !d.IsPresent); }); AddAssert("Actions available", () => { return ourComment.ChildrenOfType().Single(x => x.Name == @"Actions buttons").IsPresent; }); } [Test] public void TestReport() { const string report_text = "I don't like this comment"; DrawableComment? targetComment = null; CommentReportRequest? request = null; addTestComments(); AddUntilStep("Comment exists", () => { var comments = this.ChildrenOfType(); targetComment = comments.SingleOrDefault(x => x.Comment.Id == 2); return targetComment != null; }); AddStep("Setup request handling", () => { requestLock.Reset(); dummyAPI.HandleRequest = r => { if (!(r is CommentReportRequest req)) return false; Task.Run(() => { request = req; requestLock.Wait(10000); req.TriggerSuccess(); }); return true; }; }); AddStep("Click the button", () => { var btn = targetComment.ChildrenOfType().Single(x => x.Text == "report"); InputManager.MoveMouseTo(btn); InputManager.Click(MouseButton.Left); }); AddStep("Try to report", () => { var btn = this.ChildrenOfType().Single().ChildrenOfType().Single(); InputManager.MoveMouseTo(btn); InputManager.Click(MouseButton.Left); }); AddWaitStep("Wait", 3); AddAssert("Nothing happened", () => this.ChildrenOfType().Any()); AddStep("Set report data", () => { var field = this.ChildrenOfType().Single().ChildrenOfType().Single(); field.Current.Value = report_text; var reason = this.ChildrenOfType>().Single(); reason.Current.Value = CommentReportReason.Other; }); AddStep("Try to report", () => { var btn = this.ChildrenOfType().Single().ChildrenOfType().Single(); InputManager.MoveMouseTo(btn); InputManager.Click(MouseButton.Left); }); AddWaitStep("Wait", 3); AddAssert("Overlay closed", () => !this.ChildrenOfType().Any()); AddAssert("Loading spinner shown", () => targetComment.ChildrenOfType().Any(d => d.IsPresent)); AddStep("Complete request", () => requestLock.Set()); AddUntilStep("Request sent", () => request != null); AddAssert("Request is correct", () => request != null && request.CommentID == 2 && request.Comment == report_text && request.Reason == CommentReportReason.Other); } [Test] public void TestReply() { addTestComments(); DrawableComment? targetComment = null; AddUntilStep("Comment exists", () => { var comments = this.ChildrenOfType(); targetComment = comments.SingleOrDefault(x => x.Comment.Id == 2); return targetComment != null; }); AddStep("Setup request handling", () => { requestLock.Reset(); dummyAPI.HandleRequest = r => { if (!(r is CommentPostRequest req)) return false; if (req.ParentCommentId != 2) throw new ArgumentException("Wrong parent ID in request!"); if (req.CommentableId != 123 || req.Commentable != CommentableType.Beatmapset) throw new ArgumentException("Wrong commentable data in request!"); Task.Run(() => { requestLock.Wait(10000); req.TriggerSuccess(new CommentBundle { Comments = new List { new Comment { Id = 98, Message = req.Message, LegacyName = "FirstUser", CreatedAt = DateTimeOffset.Now, VotesCount = 98, ParentId = req.ParentCommentId, } } }); }); return true; }; }); AddStep("Click reply button", () => { var btn = targetComment.ChildrenOfType().Skip(1).First(); var texts = btn.ChildrenOfType(); InputManager.MoveMouseTo(texts.Skip(1).First()); InputManager.Click(MouseButton.Left); }); AddAssert("There is 0 replies", () => { var replLabel = targetComment.ChildrenOfType().First().ChildrenOfType().First(); return replLabel.Text.ToString().Contains('0') && targetComment!.Comment.RepliesCount == 0; }); AddStep("Focus field", () => { InputManager.MoveMouseTo(targetComment.ChildrenOfType().First()); InputManager.Click(MouseButton.Left); }); AddStep("Enter text", () => { targetComment.ChildrenOfType().First().Current.Value = "random reply"; }); AddStep("Submit", () => { InputManager.Key(Key.Enter); }); AddStep("Complete request", () => requestLock.Set()); AddUntilStep("There is 1 reply", () => { var replLabel = targetComment.ChildrenOfType().First().ChildrenOfType().First(); return replLabel.Text.ToString().Contains('1') && targetComment!.Comment.RepliesCount == 1; }); AddUntilStep("Submitted comment shown", () => { var r = targetComment.ChildrenOfType().Skip(1).FirstOrDefault(); return r != null && r.Comment.Message == "random reply"; }); } private void addTestComments() { AddStep("set up response", () => { CommentBundle cb = new CommentBundle { Comments = new List { new Comment { Id = 1, Message = "This is our comment", UserId = API.LocalUser.Value.Id, CreatedAt = DateTimeOffset.Now, User = API.LocalUser.Value, }, new Comment { Id = 2, Message = "This is a comment by another user", UserId = API.LocalUser.Value.Id + 1, CreatedAt = DateTimeOffset.Now, User = new APIUser { Id = API.LocalUser.Value.Id + 1, Username = "Another user" } }, }, IncludedComments = new List(), PinnedComments = new List(), }; setUpCommentsResponse(cb); }); AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); } private void setUpCommentsResponse(CommentBundle commentBundle) { dummyAPI.HandleRequest = request => { if (!(request is GetCommentsRequest getCommentsRequest)) return false; getCommentsRequest.TriggerSuccess(commentBundle); return true; }; } } }