using Avalonia; using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.Documents; using Avalonia.Headless.XUnit; using Avalonia.Media.Imaging; using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; using AgentWikiUi.Gui; namespace AgentWikiUi.Tests; /// Immagini nel markdown (ADR-0006): matrice MD-14 e MD-19…MD-26 in docs/TESTING.md. [Trait("Category", "Gui")] public sealed class MarkdownImageTests : IDisposable { readonly string dir = Directory.CreateTempSubdirectory("awui-img-").FullName; readonly List inBase = []; public void Dispose() { Directory.Delete(dir, true); foreach (var f in inBase) File.Delete(f); } string Png(string name, int w, int h) { var path = Path.Combine(dir, name); File.WriteAllBytes(path, PngFactory.Create(w, h)); return path; } static (Window Window, MarkdownView View) Show(string markdown, double width = 500, string? baseDir = null) { var view = new MarkdownView(); if (baseDir is not null) view.BaseDirectory = baseDir; var window = new Window { Width = width, Height = 800, Content = view }; window.Show(); view.Source = markdown; WaitForImages(view); return (window, view); } // il caricamento è in background (ADR-0006): si fa girare la coda della GUI finché tutte le immagini sono decodificate static void WaitForImages(MarkdownView view) { var deadline = DateTime.UtcNow.AddSeconds(10); while (view.PendingImages > 0 && DateTime.UtcNow < deadline) { Dispatcher.UIThread.RunJobs(); Thread.Sleep(5); } Dispatcher.UIThread.RunJobs(); (TopLevel.GetTopLevel(view) as Window)?.UpdateLayout(); Assert.Equal(0, view.PendingImages); } static List Images(Visual v) => v.GetVisualDescendants().OfType().ToList(); static string Text(Visual v) => string.Concat(v.GetVisualDescendants().OfType() .Select(t => t.Inlines is { Count: > 0 } ? string.Concat(t.Inlines.OfType().Select(r => r.Text)) : t.Text)); [AvaloniaTheory] [InlineData("https://example.com/a.png")] [InlineData("http://example.com/a.png")] [InlineData("ftp://example.com/a.png")] [InlineData("data:image/png;base64,AAAA")] public void Immagini_remote_come_testo(string url) { var (_, view) = Show($"Vedi ![logo]({url}) qui"); Assert.Empty(Images(view)); Assert.Contains("[logo]", Text(view)); } [AvaloniaFact] public void Immagine_percorso_assoluto() { var path = Png("a.png", 40, 20); var (_, view) = Show($"![griglia]({path})"); var img = Assert.Single(Images(view)); var bmp = Assert.IsAssignableFrom(img.Source); Assert.Equal(new PixelSize(40, 20), bmp.PixelSize); Assert.Equal(40, img.Bounds.Width, 0.5); Assert.DoesNotContain("[griglia]", Text(view)); } [AvaloniaFact] public void Immagine_percorso_relativo_e_file_uri() { var name = $"awui-{Guid.NewGuid():N}.png"; var inBasePath = Path.Combine(AppContext.BaseDirectory, name); File.WriteAllBytes(inBasePath, PngFactory.Create(10, 10)); inBase.Add(inBasePath); var (_, relative) = Show($"![r]({name})"); Assert.Single(Images(relative)); var uri = new Uri(Png("b.png", 12, 12)).AbsoluteUri; var (_, fileUri) = Show($"![u]({uri})"); Assert.Single(Images(fileUri)); } [AvaloniaTheory] [InlineData("manca")] [InlineData("corrotto")] [InlineData("estensione")] public void Immagine_non_valida_ripiego(string caso) { var path = caso switch { "manca" => Path.Combine(dir, "non-esiste.png"), "corrotto" => Write("rotto.png", "questo non è un png"u8.ToArray()), _ => Write("dati.txt", PngFactory.Create(8, 8)), }; var (_, view) = Show($"![alt]({path})"); Assert.Empty(Images(view)); Assert.Contains("[alt]", Text(view)); } string Write(string name, byte[] bytes) { var path = Path.Combine(dir, name); File.WriteAllBytes(path, bytes); return path; } [AvaloniaFact] public void Immagine_oltre_limite() { var path = Path.Combine(dir, "grande.png"); using (var fs = File.Create(path)) { fs.Write(PngFactory.Create(8, 8)); fs.SetLength(20L * 1024 * 1024 + 1); } var (_, view) = Show($"![enorme]({path})"); Assert.Empty(Images(view)); Assert.Contains("[enorme]", Text(view)); } [AvaloniaFact] public void Immagine_larghezza_massima() { var (_, wide) = Show($"![larga]({Png("larga.png", 2000, 100)})", width: 500); var img = Assert.Single(Images(wide)); Assert.True(img.Bounds.Width <= 500, $"larghezza {img.Bounds.Width}"); Assert.Equal(img.Bounds.Width / 20, img.Bounds.Height, 1); // proporzioni 20:1 var (_, small) = Show($"![piccola]({Png("piccola.png", 50, 50)})", width: 500); Assert.Equal(50, Assert.Single(Images(small)).Bounds.Width, 0.5); // mai ingrandita } [AvaloniaFact] public void Immagine_testo_alternativo_accessibile() { var (_, view) = Show($"![un grafico]({Png("g.png", 10, 10)})"); var img = Assert.Single(Images(view)); Assert.Equal("un grafico", AutomationProperties.GetName(img)); Assert.Equal("un grafico", ToolTip.GetTip(img)); } [AvaloniaFact] public void Immagine_in_contesti_annidati() { var p = Png("n.png", 10, 10); var markdown = $""" Testo ![in riga]({p}) e ancora testo con [un link](https://example.com). [![dentro link]({p})](https://example.com) - voce ![in lista]({p}) | a | b | |---|---| | ![in tabella]({p}) | x | """; var (_, view) = Show(markdown); Assert.Equal(4, Images(view).Count); Assert.Contains("un link", Text(view)); } [AvaloniaFact] public void Immagine_cambio_tema() { var (window, view) = Show($"![t]({Png("t.png", 10, 10)})"); window.RequestedThemeVariant = ThemeVariant.Dark; WaitForImages(view); Assert.Single(Images(view)); window.RequestedThemeVariant = ThemeVariant.Light; WaitForImages(view); Assert.Single(Images(view)); } [AvaloniaFact] public void Immagine_caricamento_in_background() { var path = Png("bg.png", 30, 30); var view = new MarkdownView(); var window = new Window { Width = 500, Height = 800, Content = view }; window.Show(); view.Source = $"![segnaposto]({path})"; // subito dopo il rendering: immagine in attesa e solo il segnaposto (il completamento passa dalla coda della GUI) Assert.Equal(1, view.PendingImages); Assert.Empty(Images(view)); Assert.Contains("[segnaposto]", Text(view)); WaitForImages(view); Assert.Single(Images(view)); Assert.DoesNotContain("[segnaposto]", Text(view)); // file corrotto: la decodifica fallisce e il segnaposto resta var rotto = Write("rotto2.png", "no"u8.ToArray()); view.Source = $"![guasta]({rotto})"; WaitForImages(view); Assert.Empty(Images(view)); Assert.Contains("[guasta]", Text(view)); } // Struttura tipo wiki: /catalogo.json, /figure/, exe in /query/ string Wiki(string name, params string[] files) { var root = Path.Combine(dir, name); Directory.CreateDirectory(Path.Combine(root, "figure")); Directory.CreateDirectory(Path.Combine(root, "query")); foreach (var f in files) File.WriteAllBytes(Path.Combine(root, "figure", f), PngFactory.Create(24, 12)); File.WriteAllText(Path.Combine(root, "catalogo.json"), "{\"figures\":[]}"); return root; } [AvaloniaFact] public void Immagine_percorso_relativo_alla_radice() { var root = Wiki("wiki-a", "fig1.png"); var (_, view) = Show("![Figura 1](figure/fig1.png)", baseDir: Path.Combine(root, "query")); Assert.Single(Images(view)); Assert.DoesNotContain("[Figura 1]", Text(view)); } [AvaloniaFact] public void Immagine_cartella_piu_vicina() { var root = Wiki("wiki-b"); File.WriteAllBytes(Path.Combine(root, "figure", "x.png"), PngFactory.Create(30, 30)); var exeDir = Path.Combine(root, "query"); Directory.CreateDirectory(Path.Combine(exeDir, "figure")); File.WriteAllBytes(Path.Combine(exeDir, "figure", "x.png"), PngFactory.Create(10, 10)); var (_, view) = Show("![x](figure/x.png)", baseDir: exeDir); var bmp = Assert.IsAssignableFrom(Assert.Single(Images(view)).Source); Assert.Equal(new PixelSize(10, 10), bmp.PixelSize); // vince la cartella più vicina all'exe var (_, nowhere) = Show("![y](figure/non-esiste.png)", baseDir: exeDir); Assert.Empty(Images(nowhere)); Assert.Contains("[y]", Text(nowhere)); } [AvaloniaFact] public void Immagine_risposta_con_didascalia() { var root = Wiki("wiki-c", "schema-a.png"); var risposta = """ Lo schema è in Figura 1. ![Figura 1](figure/schema-a.png) Figura 1 - schema generale del sistema · §2.1 r.40 · [[fig-001]] """; var (_, view) = Show(risposta, baseDir: Path.Combine(root, "query")); Assert.Single(Images(view)); var text = Text(view); Assert.Contains("Figura 1 - schema generale", text); Assert.Contains("[[fig-001]]", text); // il link alla nota resta testo: l'app non legge il catalogo JSON } }