feat(PostIt): add dedicated blog reader and video playback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
40eb5086c1
commit
c1254a522f
18 changed files with 827 additions and 20 deletions
201
src/PostIt/PostIt.Tests/BlogReaderTests.cs
Normal file
201
src/PostIt/PostIt.Tests/BlogReaderTests.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using System.Net;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using Markdown.Avalonia;
|
||||
using ColorTextBlock.Avalonia;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.Views;
|
||||
using PostIt.Views.Blogs;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Blogspot;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class BlogReaderTests
|
||||
{
|
||||
private static BlogPostDto Post(string article = "Texte du billet.") => new()
|
||||
{
|
||||
Id = 42,
|
||||
Title = "Un billet à lire",
|
||||
Article = article,
|
||||
AuthorId = "user-id-not-login",
|
||||
Author = new BlogPostAuthorDto { UserName = "paul" },
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null)
|
||||
=> new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"),
|
||||
new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } },
|
||||
resources: resources);
|
||||
|
||||
[Fact]
|
||||
public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
Assert.False(vm.IsEditing);
|
||||
Assert.Equal("paul", vm.ReaderAuthor);
|
||||
Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl);
|
||||
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reading_does_not_publish_or_discard_unsaved_editor_changes()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
vm.EditPostCommand.Execute(null);
|
||||
vm.DraftArticle = "Modifications non enregistrées";
|
||||
vm.ReadPostCommand.Execute(null);
|
||||
Assert.False(vm.IsEditing);
|
||||
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
||||
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
||||
Assert.True(vm.IsDraftModified);
|
||||
vm.EditPostCommand.Execute(null);
|
||||
Assert.True(vm.IsEditing);
|
||||
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_post_is_an_explicit_action_and_protects_unsaved_changes()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
vm.SelectedPost = Post();
|
||||
vm.NewPostCommand.Execute(null);
|
||||
Assert.True(vm.IsEditing);
|
||||
Assert.Null(vm.SelectedPost);
|
||||
Assert.Empty(vm.DraftTitle);
|
||||
Assert.Empty(vm.DraftArticle!);
|
||||
vm.DraftTitle = "Nouveau brouillon";
|
||||
vm.NewPostCommand.Execute(null);
|
||||
Assert.Equal("Nouveau brouillon", vm.DraftTitle);
|
||||
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedPost = Post("[Vidéo](clip.mp4)");
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(page);
|
||||
var window = new Window { Width = 800, Height = 700, Content = nav };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
Assert.True(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
||||
Assert.False(page.SaveButton.IsVisible);
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
Assert.Equal(vm.ReaderArticle, reader.Markdown);
|
||||
Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot);
|
||||
Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine).HyperlinkCommand);
|
||||
vm.EditPostCommand.Execute(null);
|
||||
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||
Assert.True(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
||||
Assert.True(page.SaveButton.IsVisible);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
await vm.InitializeAsync();
|
||||
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var nav = new NavigationPage();
|
||||
await nav.PushAsync(page);
|
||||
var window = new Window { Width = 800, Height = 700, Content = nav };
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
var scroll = reader.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
||||
Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height);
|
||||
Assert.True(scroll.Extent.Height > scroll.Viewport.Height);
|
||||
scroll.ScrollToEnd();
|
||||
Assert.True(scroll.Offset.Y > 0);
|
||||
vm.SelectedPost = null;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.Empty(reader.Markdown!);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel()
|
||||
{
|
||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
|
||||
{
|
||||
Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType = new("video/mp4");
|
||||
return response;
|
||||
}));
|
||||
var vm = CreateViewModel(new BlogResourceService(client));
|
||||
vm.SelectedPost = Post();
|
||||
var app = (App)Application.Current!;
|
||||
var root = new MainView();
|
||||
app.AttachMainWindow(root);
|
||||
var template = new FuncDataTemplate<VideoPageViewModel>((_, _) => new ContentPage());
|
||||
app.DataTemplates.Insert(0, template);
|
||||
try
|
||||
{
|
||||
var page = new BlogsPage { DataContext = vm };
|
||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
|
||||
var link = Assert.IsType<CHyperlink>(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)")));
|
||||
Assert.NotNull(link.Command);
|
||||
link.Command(link.CommandParameter!);
|
||||
Assert.NotNull(vm.OpenResourceCommand.ExecutionTask);
|
||||
await vm.OpenResourceCommand.ExecutionTask;
|
||||
var target = Assert.IsType<VideoPageViewModel>(root.NavRoot.NavigationStack.Last().DataContext);
|
||||
Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri);
|
||||
Assert.Equal("video/mp4", target.MimeType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
app.DataTemplates.Remove(template);
|
||||
}
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
|
||||
{
|
||||
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
|
||||
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
|
||||
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Broken_resource_reports_an_error_and_releases_busy_state()
|
||||
{
|
||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound)));
|
||||
var vm = CreateViewModel(new BlogResourceService(client));
|
||||
vm.SelectedPost = Post();
|
||||
await vm.OpenResourceCommand.ExecuteAsync("absent.mp4");
|
||||
Assert.False(vm.IsBusy);
|
||||
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
|
||||
}
|
||||
}
|
||||
137
src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs
Normal file
137
src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class BlogResourceServiceTests
|
||||
{
|
||||
private const string Authority = "https://yavsc.pschneider.fr";
|
||||
|
||||
[Theory]
|
||||
[InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")]
|
||||
[InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")]
|
||||
[InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")]
|
||||
[InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")]
|
||||
[InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")]
|
||||
[InlineData("//elsewhere.test/page", "https://elsewhere.test/page")]
|
||||
public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected)
|
||||
=> Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri);
|
||||
|
||||
[Fact]
|
||||
public void Encodes_login_and_keeps_trailing_slash()
|
||||
=> Assert.Equal(Authority + "/files/paul%20test/",
|
||||
BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri);
|
||||
|
||||
[Theory]
|
||||
[InlineData(Authority + "/files/paul/movie.mp4", true)]
|
||||
[InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)]
|
||||
[InlineData(Authority + "/files/paul/", false)]
|
||||
[InlineData(Authority + "/files/paul", false)]
|
||||
[InlineData(Authority + "/files/", false)]
|
||||
[InlineData(Authority + "/files-other/paul/movie.mp4", false)]
|
||||
[InlineData(Authority + "/page.mp4", false)]
|
||||
[InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)]
|
||||
[InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
[InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
||||
public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected)
|
||||
=> Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority));
|
||||
|
||||
[Theory]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("file:///tmp/video.mp4")]
|
||||
[InlineData("data:video/mp4;base64,AAAA")]
|
||||
public void Rejects_non_web_links(string link)
|
||||
=> Assert.Throws<InvalidOperationException>(() => BlogResourceService.Resolve(link, Authority, "paul"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("video/webm", "movie.jpg", "video/webm")]
|
||||
[InlineData("image/jpeg", "movie.mp4", "image/jpeg")]
|
||||
[InlineData("application/octet-stream", "movie.MP4", "video/mp4")]
|
||||
[InlineData(null, "movie.webm", "video/webm")]
|
||||
[InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")]
|
||||
[InlineData(null, "file.unknown", "application/octet-stream")]
|
||||
public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Head, request.Method);
|
||||
return Response(HttpStatusCode.OK, mime);
|
||||
}));
|
||||
var service = new BlogResourceService(client);
|
||||
Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.MethodNotAllowed)]
|
||||
[InlineData(HttpStatusCode.NotImplemented)]
|
||||
public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status)
|
||||
{
|
||||
var calls = 0;
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
calls++;
|
||||
if (request.Method == HttpMethod.Head)
|
||||
return Response(status, null);
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
Assert.Equal("bytes=0-0", request.Headers.Range?.ToString());
|
||||
return Response(HttpStatusCode.PartialContent, "video/mp4");
|
||||
}));
|
||||
Assert.Equal("video/mp4", await new BlogResourceService(client)
|
||||
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken));
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(_ => Response(status, null)));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => new BlogResourceService(client)
|
||||
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")]
|
||||
[InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
||||
[InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
||||
public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected)
|
||||
{
|
||||
using var client = new HttpClient(new StubHandler(request =>
|
||||
{
|
||||
Assert.Equal(expected, request.RequestUri!.AbsoluteUri);
|
||||
return Response(HttpStatusCode.OK, "image/jpeg");
|
||||
}));
|
||||
var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" };
|
||||
await using var stream = await resolver.ResolveImageResource(link);
|
||||
Assert.NotNull(stream);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("file:///etc/passwd")]
|
||||
[InlineData("data:image/png;base64,AAAA")]
|
||||
public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link)
|
||||
{
|
||||
var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" };
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => resolver.ResolveImageResource(link));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode status, string? mime)
|
||||
{
|
||||
var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) };
|
||||
if (mime is not null)
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
|
||||
return response;
|
||||
}
|
||||
|
||||
internal sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ public class MainPageSaveTests
|
|||
var api = new RecordingYavscApiClient(recorder);
|
||||
var blog = new BlogApiClient(api, "http://localhost/");
|
||||
var viewModel = new BlogsViewModel(blog);
|
||||
viewModel.NewPostCommand.Execute(null);
|
||||
|
||||
var page = new BlogsPage { DataContext = viewModel };
|
||||
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ public class MarkdownEditorScrollTests
|
|||
[InlineData(true)]
|
||||
public void Blog_page_bounds_the_article_viewport(bool preview)
|
||||
{
|
||||
var page = new BlogsPage();
|
||||
var page = new BlogsPage { DataContext = new PostIt.ViewModels.BlogsViewModel { IsEditing = true } };
|
||||
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
|
||||
editor.MarkdownText = LongArticle;
|
||||
editor.IsPreviewVisible = preview;
|
||||
|
|
|
|||
Loading…
Reference in a new issue