FakeClaude, test per area e renderer diviso in due file
- tests/FakeClaude: eseguibile finto 'claude' configurabile con variabili d'ambiente (registro di argomenti/cwd/stdin, output, exit code, ritardo), con le prove FK-01…FK-05. - Test organizzati per livello (Gui/, Process/, Support/) e struttura descritta in TESTING.md. - MarkdownView: la parte sulle immagini passa in MarkdownView.Images.cs (partial), senza cambiare il comportamento. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51993dd0c8
commit
5870de6fda
14 files changed
+292
-119
No files matched your search
@@ -17,7 +17,7 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md
|
|||||||
- Build: `dotnet build` dalla radice (soluzione `agent-wiki-ui.slnx`: app in `src/agent-wiki-ui/`, test in `tests/`; `net10.0`, Avalonia)
|
- Build: `dotnet build` dalla radice (soluzione `agent-wiki-ui.slnx`: app in `src/agent-wiki-ui/`, test in `tests/`; `net10.0`, Avalonia)
|
||||||
- GUI: `dotnet run --project src/agent-wiki-ui`; CLI: `dotnet run --project src/agent-wiki-ui -- "prompt"`
|
- GUI: `dotnet run --project src/agent-wiki-ui`; CLI: `dotnet run --project src/agent-wiki-ui -- "prompt"`
|
||||||
- Publish single-file (uno per sistema): `scripts/publish.sh` (Linux/WSL/macOS) o `scripts/publish.ps1` (Windows PowerShell): creano `dist/win-x64/` e `dist/linux-x64/` (`dist/` è ignorata da git); i due script vanno tenuti allineati, oppure a mano `dotnet publish src/agent-wiki-ui -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64`
|
- Publish single-file (uno per sistema): `scripts/publish.sh` (Linux/WSL/macOS) o `scripts/publish.ps1` (Windows PowerShell): creano `dist/win-x64/` e `dist/linux-x64/` (`dist/` è ignorata da git); i due script vanno tenuti allineati, oppure a mano `dotnet publish src/agent-wiki-ui -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64`
|
||||||
- Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale). Per ora coprono solo le immagini del renderer (MD-14, MD-19…MD-30); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo.
|
- Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale; `tests/FakeClaude` è il `claude` finto per i test di processo). Per ora coprono solo le immagini del renderer (MD-14, MD-19…MD-30); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo.
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa de
|
|||||||
- [Core/AppInfo.cs](src/agent-wiki-ui/Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente).
|
- [Core/AppInfo.cs](src/agent-wiki-ui/Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente).
|
||||||
- [Gui/MainViewModel.cs](src/agent-wiki-ui/Gui/MainViewModel.cs): stato e logica della chat (invio, apertura/eliminazione, salvataggio subito all'invio e di nuovo alla risposta, riapertura dell'ultima chat all'avvio, interruttore CLI: cambiare motore apre una nuova chat; aggiornamento dei CLI con un solo aggiornamento alla volta, bloccando invio e cambio motore).
|
- [Gui/MainViewModel.cs](src/agent-wiki-ui/Gui/MainViewModel.cs): stato e logica della chat (invio, apertura/eliminazione, salvataggio subito all'invio e di nuovo alla risposta, riapertura dell'ultima chat all'avvio, interruttore CLI: cambiare motore apre una nuova chat; aggiornamento dei CLI con un solo aggiornamento alla volta, bloccando invio e cambio motore).
|
||||||
- [Gui/MainWindow.axaml](src/agent-wiki-ui/Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](src/agent-wiki-ui/Gui/App.axaml): token di colore come risorse di tema chiaro/scuro e stili; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione.
|
- [Gui/MainWindow.axaml](src/agent-wiki-ui/Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](src/agent-wiki-ui/Gui/App.axaml): token di colore come risorse di tema chiaro/scuro e stili; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione.
|
||||||
- [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); le immagini si mostrano solo da file locale (ADR-0006), mai da URL; i percorsi relativi si cercano nella cartella dell'exe e poi nelle superiori (radice della wiki).
|
- [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); la parte sulle immagini è in [Gui/MarkdownView.Images.cs](src/agent-wiki-ui/Gui/MarkdownView.Images.cs) (`partial`). Le immagini si mostrano solo da file locale (ADR-0006), mai da URL; i percorsi relativi si cercano nella cartella dell'exe e poi nelle superiori (radice della wiki).
|
||||||
- Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig.
|
- Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig.
|
||||||
|
|
||||||
## Vincoli non ovvi
|
## Vincoli non ovvi
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj" />
|
<Project Path="tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj" />
|
||||||
|
<Project Path="tests/FakeClaude/FakeClaude.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
+2
-2
@@ -65,9 +65,9 @@ Decisione e motivazioni: [ADR-0001](adr/0001-gui-multipiattaforma-avalonia.md).
|
|||||||
Decisione: [ADR-0003](adr/0003-strategia-di-test.md). Piano, soglie e matrice: [TESTING.md](TESTING.md). Da questa fase in poi vale la regola: ogni nuova funzione arriva con i suoi test.
|
Decisione: [ADR-0003](adr/0003-strategia-di-test.md). Piano, soglie e matrice: [TESTING.md](TESTING.md). Da questa fase in poi vale la regola: ogni nuova funzione arriva con i suoi test.
|
||||||
|
|
||||||
- [ ] Verificare le compatibilità dell'ADR-0003 (xUnit, Avalonia.Headless, coverlet, `dotnet test`) e portarlo ad "Accettata"
|
- [ ] Verificare le compatibilità dell'ADR-0003 (xUnit, Avalonia.Headless, coverlet, `dotnet test`) e portarlo ad "Accettata"
|
||||||
- [ ] Ristrutturare in `src/` e `tests/` con soluzione unica e `InternalsVisibleTo`
|
- [x] Ristrutturare in `src/` e `tests/` con soluzione unica e `InternalsVisibleTo`
|
||||||
- [ ] Predisposizioni di testabilità: `ChatStore` a istanza, `IClaudeRunner`
|
- [ ] Predisposizioni di testabilità: `ChatStore` a istanza, `IClaudeRunner`
|
||||||
- [ ] `FakeClaude`: eseguibile finto per i test di processo
|
- [x] `FakeClaude`: eseguibile finto per i test di processo (`tests/FakeClaude`, prove FK-01…FK-05)
|
||||||
- [ ] Test `Core/` (CL, ST) e `MainViewModel` (VM)
|
- [ ] Test `Core/` (CL, ST) e `MainViewModel` (VM)
|
||||||
- [ ] Test del renderer markdown (MD) e della GUI headless (UI)
|
- [ ] Test del renderer markdown (MD) e della GUI headless (UI)
|
||||||
- [ ] Test end-to-end e smoke di publish per `win-x64` e `linux-x64` (PUB)
|
- [ ] Test end-to-end e smoke di publish per `win-x64` e `linux-x64` (PUB)
|
||||||
|
|||||||
+26
-3
@@ -21,13 +21,27 @@ ADR (se serve) → MVP.md (requisito e criterio) → riga in questa matrice →
|
|||||||
| **P** — processo | avvio reale di un processo (`Claude`) | `FakeClaude` nel PATH |
|
| **P** — processo | avvio reale di un processo (`Claude`) | `FakeClaude` nel PATH |
|
||||||
| **E** — end-to-end | l'eseguibile pubblicato (CLI, 127, exit code) | publish + processo figlio + `FakeClaude` |
|
| **E** — end-to-end | l'eseguibile pubblicato (CLI, 127, exit code) | publish + processo figlio + `FakeClaude` |
|
||||||
|
|
||||||
## Struttura prevista
|
## Struttura
|
||||||
```
|
```
|
||||||
agent-wiki-ui.slnx
|
agent-wiki-ui.slnx
|
||||||
src/agent-wiki-ui/ app (Core/, Gui/, Program.cs)
|
src/agent-wiki-ui/ app (Core/, Gui/, Program.cs)
|
||||||
tests/agent-wiki-ui.Tests/ U, R, G, P, E (Trait "Category": Unit | Gui | Process | E2E)
|
tests/agent-wiki-ui.Tests/ Support/ (TestApp, PngFactory), Gui/ (R, G), Process/ (P), Core/ (U, quando arrivano), E2E/ (E)
|
||||||
tests/FakeClaude/ eseguibile finto: registra argv/cwd/stdin; testo, stderr, exit code e ritardo da variabili d'ambiente
|
tests/FakeClaude/ eseguibile finto `claude` (vedi sotto)
|
||||||
```
|
```
|
||||||
|
Una cartella per livello, creata quando c'è il primo test; il livello si dichiara anche con `[Trait("Category", "Unit" | "Gui" | "Process" | "E2E")]`.
|
||||||
|
|
||||||
|
### FakeClaude
|
||||||
|
Piccolo eseguibile (`AssemblyName` `claude`, nel PATH dei test) che prende il posto del vero motore. Un test lo configura con variabili d'ambiente del processo che avvia:
|
||||||
|
|
||||||
|
| Variabile | Effetto |
|
||||||
|
|---|---|
|
||||||
|
| `FAKE_CLAUDE_LOG` | file in cui accoda una riga JSON per invocazione: `{"args": [...], "cwd": "...", "stdin": "..."}` |
|
||||||
|
| `FAKE_CLAUDE_OUT` | testo scritto su stdout (anche per `--version`) |
|
||||||
|
| `FAKE_CLAUDE_ERR` | testo scritto su stderr |
|
||||||
|
| `FAKE_CLAUDE_EXIT` | exit code (predefinito 0) |
|
||||||
|
| `FAKE_CLAUDE_DELAY_MS` | attesa prima di rispondere |
|
||||||
|
|
||||||
|
Legge stdin fino alla fine e lo registra: se il chiamante non lo chiude, il processo non termina (così CL-05 è verificabile). Il percorso del file compilato lo fornisce l'helper di test `FakeClaudePath`.
|
||||||
|
|
||||||
## Predisposizioni di testabilità (prerequisito dei test)
|
## Predisposizioni di testabilità (prerequisito dei test)
|
||||||
- [ ] `ChatStore` come istanza con cartella iniettata (oggi statico su `LocalApplicationData`)
|
- [ ] `ChatStore` come istanza con cartella iniettata (oggi statico su `LocalApplicationData`)
|
||||||
@@ -242,6 +256,15 @@ Per **ogni** motore, la stessa batteria di contratto:
|
|||||||
| EN-17 | CLI `--engine <id> "prompt"`; id sconosciuto o prompt mancante ⇒ uso su stderr ed exit 2; senza `--engine` ⇒ Claude Code | E | [ ] |
|
| EN-17 | CLI `--engine <id> "prompt"`; id sconosciuto o prompt mancante ⇒ uso su stderr ed exit 2; senza `--engine` ⇒ Claude Code | E | [ ] |
|
||||||
| EN-18 | Rilevamento nel PATH: nome esatto su Linux, `.exe` su Windows; cartella inesistente nel PATH ignorata | U | [ ] |
|
| EN-18 | Rilevamento nel PATH: nome esatto su Linux, `.exe` su Windows; cartella inesistente nel PATH ignorata | U | [ ] |
|
||||||
|
|
||||||
|
### FakeClaude — `tests/FakeClaude`
|
||||||
|
| ID | Requisito | Liv. | Test | |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| FK-01 | Registra argomenti (anche con virgolette e spazi, uno per elemento), cartella di lavoro e stdin | P | `Registra_argomenti_cwd_e_stdin` | [x] |
|
||||||
|
| FK-02 | Scrive su stdout e stderr il testo configurato | P | `Output_e_stderr_configurati` | [x] |
|
||||||
|
| FK-03 | L'exit code configurato è quello del processo | P | `ExitCode_configurato` | [x] |
|
||||||
|
| FK-04 | Il ritardo configurato è rispettato | P | `Ritardo_configurato` | [x] |
|
||||||
|
| FK-05 | Termina solo dopo la chiusura di stdin | P | `Attende_la_chiusura_di_stdin` | [x] |
|
||||||
|
|
||||||
## Come si eseguono (a implementazione avvenuta)
|
## Come si eseguono (a implementazione avvenuta)
|
||||||
Comandi previsti, da confermare con ADR-0003 → "Da verificare":
|
Comandi previsti, da confermare con ADR-0003 → "Da verificare":
|
||||||
- Tutti i test: `dotnet test`
|
- Tutti i test: `dotnet test`
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using Avalonia.Automation;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Documents;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Markdig.Syntax;
|
||||||
|
using Markdig.Syntax.Inlines;
|
||||||
|
|
||||||
|
namespace AgentWikiUi.Gui;
|
||||||
|
|
||||||
|
/// <summary>Immagini nel markdown (ADR-0006): solo file locali, caricamento in background, ripiego sul testo alternativo.</summary>
|
||||||
|
partial class MarkdownView
|
||||||
|
{
|
||||||
|
// Immagini (ADR-0006): solo file locali, entro questi limiti
|
||||||
|
const long MaxImageBytes = 20L * 1024 * 1024;
|
||||||
|
const int MaxDecodeWidth = 1600;
|
||||||
|
const double MaxInlineImageWidth = 480;
|
||||||
|
static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"];
|
||||||
|
|
||||||
|
/// <summary>Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero.</summary>
|
||||||
|
internal int PendingImages { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Cartella da cui si cercano i percorsi relativi delle immagini: l'exe, poi le cartelle superiori (ADR-0006). Modificabile dai test.</summary>
|
||||||
|
internal string BaseDirectory { get; set; } = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
static string AltText(LinkInline img) => string.Concat(img.OfType<LiteralInline>().Select(l => l.Content.ToString()));
|
||||||
|
|
||||||
|
/// <summary>Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi.
|
||||||
|
/// Un percorso relativo si cerca in <paramref name="baseDir"/> e poi nelle cartelle superiori (radice del progetto del motore): vince la prima in cui il file esiste.</summary>
|
||||||
|
internal static string? ResolveImagePath(string? url, string baseDir)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||||
|
if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri.IsFile ? uri.LocalPath : null;
|
||||||
|
var relative = Uri.UnescapeDataString(url);
|
||||||
|
var first = Path.GetFullPath(relative, baseDir);
|
||||||
|
for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
var candidate = Path.GetFullPath(relative, dir.FullName);
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return first; // non esiste da nessuna parte: il controllo successivo lo scarta
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica.</summary>
|
||||||
|
static bool IsImageCandidate(string? path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (path is null || !ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) return false;
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
return info.Exists && info.Length <= MaxImageBytes;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decodifica l'immagine (chiamata fuori dal thread della GUI); nullo se illeggibile, mai eccezioni.</summary>
|
||||||
|
static Bitmap? DecodeImage(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
var bmp = new Bitmap(fs);
|
||||||
|
if (bmp.PixelSize.Width <= MaxDecodeWidth) return bmp;
|
||||||
|
bmp.Dispose();
|
||||||
|
fs.Position = 0;
|
||||||
|
return Bitmap.DecodeToWidth(fs, MaxDecodeWidth);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segnaposto `[alt]` che diventa l'immagine a decodifica finita; se la decodifica fallisce resta il testo.
|
||||||
|
Control ImageHost(string path, string alt, double maxWidth)
|
||||||
|
{
|
||||||
|
var host = new Decorator { HorizontalAlignment = HorizontalAlignment.Left, Child = new TextBlock { Text = "[" + alt + "]", TextWrapping = TextWrapping.Wrap } };
|
||||||
|
PendingImages++;
|
||||||
|
_ = LoadInto(host, path, alt, maxWidth);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadInto(Decorator host, string path, string alt, double maxWidth)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await Task.Run(() => DecodeImage(path)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth);
|
||||||
|
}
|
||||||
|
finally { PendingImages--; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static Image ImageOf(Bitmap bmp, string alt, double maxWidth)
|
||||||
|
{
|
||||||
|
var img = new Image
|
||||||
|
{
|
||||||
|
Source = bmp, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly,
|
||||||
|
MaxWidth = Math.Min(maxWidth, bmp.Size.Width), HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
};
|
||||||
|
AutomationProperties.SetName(img, alt);
|
||||||
|
ToolTip.SetTip(img, alt);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragrafo fatto solo di immagini: blocchi alla larghezza della colonna. Nullo se non è così o nessuna è plausibile.
|
||||||
|
Control? ImageParagraph(ParagraphBlock p)
|
||||||
|
{
|
||||||
|
if (p.Inline is null) return null;
|
||||||
|
var images = new List<LinkInline>();
|
||||||
|
foreach (var node in p.Inline)
|
||||||
|
{
|
||||||
|
if (node is LinkInline { IsImage: true } img) images.Add(img);
|
||||||
|
else if (node is not LineBreakInline && !(node is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) return null;
|
||||||
|
}
|
||||||
|
if (images.Count == 0) return null;
|
||||||
|
var paths = images.Select(i => ResolveImagePath(i.Url, BaseDirectory)).ToList();
|
||||||
|
if (!paths.Any(IsImageCandidate)) return null;
|
||||||
|
var panel = new StackPanel { Spacing = 8 };
|
||||||
|
for (var i = 0; i < images.Count; i++)
|
||||||
|
{
|
||||||
|
var image = images[i];
|
||||||
|
panel.Children.Add(IsImageCandidate(paths[i]) ? ImageHost(paths[i]!, AltText(image), double.PositiveInfinity)
|
||||||
|
: new SelectableTextBlock { Text = "[" + AltText(image) + "]", TextWrapping = TextWrapping.Wrap });
|
||||||
|
}
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Automation;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Documents;
|
using Avalonia.Controls.Documents;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Input.Platform;
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
|
||||||
using Markdig;
|
using Markdig;
|
||||||
using Markdig.Extensions.Tables;
|
using Markdig.Extensions.Tables;
|
||||||
using Markdig.Syntax;
|
using Markdig.Syntax;
|
||||||
@@ -15,23 +13,12 @@ using Markdig.Syntax.Inlines;
|
|||||||
namespace AgentWikiUi.Gui;
|
namespace AgentWikiUi.Gui;
|
||||||
|
|
||||||
/// <summary>Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui.</summary>
|
/// <summary>Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui.</summary>
|
||||||
class MarkdownView : Decorator
|
partial class MarkdownView : Decorator
|
||||||
{
|
{
|
||||||
public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(nameof(Source));
|
public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(nameof(Source));
|
||||||
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
|
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build();
|
||||||
// Immagini (ADR-0006): solo file locali, entro questi limiti
|
|
||||||
const long MaxImageBytes = 20L * 1024 * 1024;
|
|
||||||
const int MaxDecodeWidth = 1600;
|
|
||||||
const double MaxInlineImageWidth = 480;
|
|
||||||
static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"];
|
|
||||||
static readonly FontFamily Mono = new("Cascadia Code, Consolas, DejaVu Sans Mono, Liberation Mono, monospace");
|
static readonly FontFamily Mono = new("Cascadia Code, Consolas, DejaVu Sans Mono, Liberation Mono, monospace");
|
||||||
|
|
||||||
/// <summary>Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero.</summary>
|
|
||||||
internal int PendingImages { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Cartella da cui si cercano i percorsi relativi delle immagini: l'exe, poi le cartelle superiori (ADR-0006). Modificabile dai test.</summary>
|
|
||||||
internal string BaseDirectory { get; set; } = AppContext.BaseDirectory;
|
|
||||||
|
|
||||||
public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); }
|
public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); }
|
||||||
|
|
||||||
public MarkdownView()
|
public MarkdownView()
|
||||||
@@ -135,104 +122,6 @@ class MarkdownView : Decorator
|
|||||||
return new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), CornerRadius = new(6), ClipToBounds = true, Child = grid };
|
return new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), CornerRadius = new(6), ClipToBounds = true, Child = grid };
|
||||||
}
|
}
|
||||||
|
|
||||||
static string AltText(LinkInline img) => string.Concat(img.OfType<LiteralInline>().Select(l => l.Content.ToString()));
|
|
||||||
|
|
||||||
/// <summary>Percorso locale a cui punta la destinazione di un'immagine; nullo per URL remoti o altri schemi.
|
|
||||||
/// Un percorso relativo si cerca in <paramref name="baseDir"/> e poi nelle cartelle superiori (radice del progetto del motore): vince la prima in cui il file esiste.</summary>
|
|
||||||
internal static string? ResolveImagePath(string? url, string baseDir)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
|
||||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri.IsFile ? uri.LocalPath : null;
|
|
||||||
var relative = Uri.UnescapeDataString(url);
|
|
||||||
var first = Path.GetFullPath(relative, baseDir);
|
|
||||||
for (var dir = new DirectoryInfo(baseDir); dir is not null; dir = dir.Parent)
|
|
||||||
{
|
|
||||||
var candidate = Path.GetFullPath(relative, dir.FullName);
|
|
||||||
if (File.Exists(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return first; // non esiste da nessuna parte: il controllo successivo lo scarta
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica.</summary>
|
|
||||||
static bool IsImageCandidate(string? path)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (path is null || !ImageExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) return false;
|
|
||||||
var info = new FileInfo(path);
|
|
||||||
return info.Exists && info.Length <= MaxImageBytes;
|
|
||||||
}
|
|
||||||
catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Decodifica l'immagine (chiamata fuori dal thread della GUI); nullo se illeggibile, mai eccezioni.</summary>
|
|
||||||
static Bitmap? DecodeImage(string path)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var fs = File.OpenRead(path);
|
|
||||||
var bmp = new Bitmap(fs);
|
|
||||||
if (bmp.PixelSize.Width <= MaxDecodeWidth) return bmp;
|
|
||||||
bmp.Dispose();
|
|
||||||
fs.Position = 0;
|
|
||||||
return Bitmap.DecodeToWidth(fs, MaxDecodeWidth);
|
|
||||||
}
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Segnaposto `[alt]` che diventa l'immagine a decodifica finita; se la decodifica fallisce resta il testo.
|
|
||||||
Control ImageHost(string path, string alt, double maxWidth)
|
|
||||||
{
|
|
||||||
var host = new Decorator { HorizontalAlignment = HorizontalAlignment.Left, Child = new TextBlock { Text = "[" + alt + "]", TextWrapping = TextWrapping.Wrap } };
|
|
||||||
PendingImages++;
|
|
||||||
_ = LoadInto(host, path, alt, maxWidth);
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task LoadInto(Decorator host, string path, string alt, double maxWidth)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (await Task.Run(() => DecodeImage(path)) is { } bmp) host.Child = ImageOf(bmp, alt, maxWidth);
|
|
||||||
}
|
|
||||||
finally { PendingImages--; }
|
|
||||||
}
|
|
||||||
|
|
||||||
static Image ImageOf(Bitmap bmp, string alt, double maxWidth)
|
|
||||||
{
|
|
||||||
var img = new Image
|
|
||||||
{
|
|
||||||
Source = bmp, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly,
|
|
||||||
MaxWidth = Math.Min(maxWidth, bmp.Size.Width), HorizontalAlignment = HorizontalAlignment.Left,
|
|
||||||
};
|
|
||||||
AutomationProperties.SetName(img, alt);
|
|
||||||
ToolTip.SetTip(img, alt);
|
|
||||||
return img;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paragrafo fatto solo di immagini: blocchi alla larghezza della colonna. Nullo se non è così o nessuna è plausibile.
|
|
||||||
Control? ImageParagraph(ParagraphBlock p)
|
|
||||||
{
|
|
||||||
if (p.Inline is null) return null;
|
|
||||||
var images = new List<LinkInline>();
|
|
||||||
foreach (var node in p.Inline)
|
|
||||||
{
|
|
||||||
if (node is LinkInline { IsImage: true } img) images.Add(img);
|
|
||||||
else if (node is not LineBreakInline && !(node is LiteralInline lit && string.IsNullOrWhiteSpace(lit.Content.ToString()))) return null;
|
|
||||||
}
|
|
||||||
if (images.Count == 0) return null;
|
|
||||||
var paths = images.Select(i => ResolveImagePath(i.Url, BaseDirectory)).ToList();
|
|
||||||
if (!paths.Any(IsImageCandidate)) return null;
|
|
||||||
var panel = new StackPanel { Spacing = 8 };
|
|
||||||
for (var i = 0; i < images.Count; i++)
|
|
||||||
{
|
|
||||||
var image = images[i];
|
|
||||||
panel.Children.Add(IsImageCandidate(paths[i]) ? ImageHost(paths[i]!, AltText(image), double.PositiveInfinity)
|
|
||||||
: new SelectableTextBlock { Text = "[" + AltText(image) + "]", TextWrapping = TextWrapping.Wrap });
|
|
||||||
}
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Testo con inline; i link sono Run colorati e il clic si risolve con l'hit-test sul testo.
|
// Testo con inline; i link sono Run colorati e il clic si risolve con l'hit-test sul testo.
|
||||||
SelectableTextBlock Text(ContainerInline? inline, bool bold = false, double size = 0)
|
SelectableTextBlock Text(ContainerInline? inline, bool bold = false, double size = 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- Doppio di test di `claude` (vedi docs/TESTING.md → FakeClaude): l'eseguibile si chiama `claude` per poterlo mettere nel PATH -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<AssemblyName>claude</AssemblyName>
|
||||||
|
<RootNamespace>FakeClaude</RootNamespace>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
// Configurazione e formato del registro: docs/TESTING.md → FakeClaude.
|
||||||
|
var stdin = Console.In.ReadToEnd(); // resta in attesa finché il chiamante non chiude stdin
|
||||||
|
|
||||||
|
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_LOG") is { Length: > 0 } log)
|
||||||
|
File.AppendAllText(log, JsonSerializer.Serialize(new { args, cwd = Directory.GetCurrentDirectory(), stdin }) + "\n");
|
||||||
|
|
||||||
|
if (int.TryParse(Environment.GetEnvironmentVariable("FAKE_CLAUDE_DELAY_MS"), out var delay) && delay > 0)
|
||||||
|
Thread.Sleep(delay);
|
||||||
|
|
||||||
|
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_OUT") is { } o) Console.Out.Write(o);
|
||||||
|
if (Environment.GetEnvironmentVariable("FAKE_CLAUDE_ERR") is { } e) Console.Error.Write(e);
|
||||||
|
|
||||||
|
return int.TryParse(Environment.GetEnvironmentVariable("FAKE_CLAUDE_EXIT"), out var code) ? code : 0;
|
||||||
File renamed without changes.
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AgentWikiUi.Tests;
|
||||||
|
|
||||||
|
/// <summary>Il doppio di test di <c>claude</c> (docs/TESTING.md → FakeClaude, FK-01…FK-05).</summary>
|
||||||
|
[Trait("Category", "Process")]
|
||||||
|
public sealed class FakeClaudeTests : IDisposable
|
||||||
|
{
|
||||||
|
readonly string dir = Directory.CreateTempSubdirectory("awui-fake-").FullName;
|
||||||
|
|
||||||
|
public void Dispose() => Directory.Delete(dir, true);
|
||||||
|
|
||||||
|
string LogPath => Path.Combine(dir, "log.jsonl");
|
||||||
|
|
||||||
|
Process Start(IEnumerable<string> args, Dictionary<string, string>? env = null, string? cwd = null)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo(FakeClaudePath.Exe)
|
||||||
|
{
|
||||||
|
UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true,
|
||||||
|
WorkingDirectory = cwd ?? dir,
|
||||||
|
};
|
||||||
|
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||||
|
psi.Environment["FAKE_CLAUDE_LOG"] = LogPath;
|
||||||
|
foreach (var (k, v) in env ?? []) psi.Environment[k] = v;
|
||||||
|
return Process.Start(psi)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registra_argomenti_cwd_e_stdin()
|
||||||
|
{
|
||||||
|
var cwd = Directory.CreateDirectory(Path.Combine(dir, "lavoro")).FullName;
|
||||||
|
using var p = Start(["-p", "dì \"ciao\" a tutti", "--model", "m"], cwd: cwd);
|
||||||
|
p.StandardInput.Write("dati da stdin");
|
||||||
|
p.StandardInput.Close();
|
||||||
|
Assert.True(p.WaitForExit(10_000));
|
||||||
|
|
||||||
|
using var line = JsonDocument.Parse(File.ReadAllLines(LogPath).Single());
|
||||||
|
var r = line.RootElement;
|
||||||
|
Assert.Equal(["-p", "dì \"ciao\" a tutti", "--model", "m"], r.GetProperty("args").EnumerateArray().Select(a => a.GetString()));
|
||||||
|
Assert.Equal(cwd, r.GetProperty("cwd").GetString());
|
||||||
|
Assert.Equal("dati da stdin", r.GetProperty("stdin").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Output_e_stderr_configurati()
|
||||||
|
{
|
||||||
|
using var p = Start([], new() { ["FAKE_CLAUDE_OUT"] = "risposta", ["FAKE_CLAUDE_ERR"] = "avviso" });
|
||||||
|
p.StandardInput.Close();
|
||||||
|
Assert.Equal("risposta", p.StandardOutput.ReadToEnd());
|
||||||
|
Assert.Equal("avviso", p.StandardError.ReadToEnd());
|
||||||
|
Assert.True(p.WaitForExit(10_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, 0)]
|
||||||
|
[InlineData("3", 3)]
|
||||||
|
[InlineData("127", 127)]
|
||||||
|
public void ExitCode_configurato(string? configured, int expected)
|
||||||
|
{
|
||||||
|
using var p = Start([], configured is null ? null : new() { ["FAKE_CLAUDE_EXIT"] = configured });
|
||||||
|
p.StandardInput.Close();
|
||||||
|
Assert.True(p.WaitForExit(10_000));
|
||||||
|
Assert.Equal(expected, p.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ritardo_configurato()
|
||||||
|
{
|
||||||
|
using var p = Start([], new() { ["FAKE_CLAUDE_DELAY_MS"] = "600" });
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
p.StandardInput.Close();
|
||||||
|
Assert.True(p.WaitForExit(10_000));
|
||||||
|
Assert.True(sw.ElapsedMilliseconds >= 500, $"terminato dopo {sw.ElapsedMilliseconds} ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Attende_la_chiusura_di_stdin()
|
||||||
|
{
|
||||||
|
using var p = Start([]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.False(p.WaitForExit(700), "il processo è terminato con stdin ancora aperto");
|
||||||
|
p.StandardInput.Close();
|
||||||
|
Assert.True(p.WaitForExit(10_000));
|
||||||
|
}
|
||||||
|
finally { if (!p.HasExited) p.Kill(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace AgentWikiUi.Tests;
|
||||||
|
|
||||||
|
/// <summary>Percorso del <c>claude</c> finto compilato (tests/FakeClaude), nella stessa configurazione dei test.</summary>
|
||||||
|
static class FakeClaudePath
|
||||||
|
{
|
||||||
|
public static string Exe
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// …/tests/agent-wiki-ui.Tests/bin/<config>/net10.0/
|
||||||
|
var tfm = new DirectoryInfo(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar));
|
||||||
|
var config = tfm.Parent!;
|
||||||
|
var testsRoot = config.Parent!.Parent!.Parent!;
|
||||||
|
return Path.Combine(testsRoot.FullName, "FakeClaude", "bin", config.Name, tfm.Name, OperatingSystem.IsWindows() ? "claude.exe" : "claude");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/agent-wiki-ui/agent-wiki-ui.csproj" />
|
<ProjectReference Include="../../src/agent-wiki-ui/agent-wiki-ui.csproj" />
|
||||||
|
<!-- solo compilato (non referenziato): i test avviano l'eseguibile -->
|
||||||
|
<ProjectReference Include="../FakeClaude/FakeClaude.csproj" ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
Reference in new issue
Block a user