Files
agent-wiki-ui/tests/agent-wiki-ui.Tests/MarkdownImageTests.cs
T
davideandClaude Sonnet 5.5 15c664007d Immagini nel markdown da file locale e struttura src/tests
Il renderer mostra le immagini ![alt](percorso) da file locali (ADR-0006):
segnaposto [alt] e decodifica in background, ripiego sul testo per URL,
file mancanti, corrotti o troppo grandi. Nessuna richiesta di rete.

Primo progetto di test (xUnit v3 + Avalonia Headless, MD-14 e MD-19…MD-27).
Il codice passa in src/agent-wiki-ui/, c'è la soluzione agent-wiki-ui.slnx;
aggiornati script di publish, comandi e link nei documenti.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:22:39 +02:00

209 lines
7.2 KiB
C#

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;
/// <summary>Immagini nel markdown (ADR-0006): matrice MD-14 e MD-19…MD-26 in docs/TESTING.md.</summary>
[Trait("Category", "Gui")]
public sealed class MarkdownImageTests : IDisposable
{
readonly string dir = Directory.CreateTempSubdirectory("awui-img-").FullName;
readonly List<string> 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)
{
var view = new MarkdownView();
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<Image> Images(Visual v) => v.GetVisualDescendants().OfType<Image>().ToList();
static string Text(Visual v) => string.Concat(v.GetVisualDescendants().OfType<TextBlock>()
.Select(t => t.Inlines is { Count: > 0 } ? string.Concat(t.Inlines.OfType<Run>().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<Bitmap>(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));
}
}