Immagini nel markdown da file locale e struttura src/tests
Il renderer mostra le immagini  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>
This commit is contained in:
1 parent
af171201b5
commit
15c664007d
32 files changed
+506
-29
No files matched your search
@@ -14,24 +14,24 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md
|
||||
|
||||
## Comandi
|
||||
|
||||
- Build: `dotnet build` (agent-wiki-ui.csproj, `net10.0`, Avalonia)
|
||||
- GUI: `dotnet run`; CLI: `dotnet run -- "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 -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64`
|
||||
- Test: non esistono ancora (pianificati, Fase 6: `dotnet test`; il piano è in docs/TESTING.md). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo.
|
||||
- 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"`
|
||||
- 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-27); 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
|
||||
|
||||
Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa del codice.
|
||||
|
||||
- [Program.cs](Program.cs): top-level statements. Con argomenti è la CLI (`EngineRunner.RunInherited`, motore scelto con `--engine <id>` come primo argomento, default Claude Code); senza avvia Avalonia (`App`).
|
||||
- [Core/Engine.cs](Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo).
|
||||
- [Core/EngineRunner.cs](Core/EngineRunner.cs): **unico punto di avvio dei motori** (cartella dell'exe, stdin chiuso, `ArgumentList`): CLI con output ereditato, GUI con output catturato e interpretato da `Engine.Parse`, e `RunToolAsync` per i comandi di servizio (`--version`, `update`) con tempo massimo.
|
||||
- [Core/EngineUpdater.cs](Core/EngineUpdater.cs): controllo delle versioni in sola lettura (`CheckAsync`, `IsNewer`) e aggiornamento di un CLI con il suo `update` ufficiale (ADR-0004): versione prima/dopo, esito leggibile, metodo di installazione dedotto dal percorso reale e comando manuale da suggerire (l'app lo mostra, non lo esegue).
|
||||
- [Core/ChatStore.cs](Core/ChatStore.cs): cronologia, un JSON per chat in `<LocalApplicationData>/<nome-app>/chats/<guid>.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code).
|
||||
- [Core/AppInfo.cs](Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente).
|
||||
- [Gui/MainViewModel.cs](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](Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](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](Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`).
|
||||
- [Program.cs](src/agent-wiki-ui/Program.cs): top-level statements. Con argomenti è la CLI (`EngineRunner.RunInherited`, motore scelto con `--engine <id>` come primo argomento, default Claude Code); senza avvia Avalonia (`App`).
|
||||
- [Core/Engine.cs](src/agent-wiki-ui/Core/Engine.cs): astrazione "motore" (ADR-0002): `Engine` (id, nome, eseguibile, modelli, effort, `BuildArgs`, `Parse`, `FetchLatestVersionAsync` per il controllo delle versioni), `ClaudeEngine`, `CodexEngine`, registro `Engines` e ricerca nel PATH (`PathSearch`). Gli elenchi modelli sono fissi, da aggiornare quando ne escono di nuovi; `DefaultModelId` indica il modello più piccolo, scelto all'avvio insieme a effort `low` (se esce un modello più piccolo, aggiornarlo).
|
||||
- [Core/EngineRunner.cs](src/agent-wiki-ui/Core/EngineRunner.cs): **unico punto di avvio dei motori** (cartella dell'exe, stdin chiuso, `ArgumentList`): CLI con output ereditato, GUI con output catturato e interpretato da `Engine.Parse`, e `RunToolAsync` per i comandi di servizio (`--version`, `update`) con tempo massimo.
|
||||
- [Core/EngineUpdater.cs](src/agent-wiki-ui/Core/EngineUpdater.cs): controllo delle versioni in sola lettura (`CheckAsync`, `IsNewer`) e aggiornamento di un CLI con il suo `update` ufficiale (ADR-0004): versione prima/dopo, esito leggibile, metodo di installazione dedotto dal percorso reale e comando manuale da suggerire (l'app lo mostra, non lo esegue).
|
||||
- [Core/ChatStore.cs](src/agent-wiki-ui/Core/ChatStore.cs): cronologia, un JSON per chat in `<LocalApplicationData>/<nome-app>/chats/<guid>.json` (nome file = id della chat, validato come Guid prima dell'uso; campi `Engine` e `SessionId` opzionali: i file vecchi sono chat di Claude Code).
|
||||
- [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/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.
|
||||
- Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig.
|
||||
|
||||
## Vincoli non ovvi
|
||||
|
||||
@@ -11,13 +11,13 @@ Eseguibile C# (.NET 10, Windows e Linux) che lancia Claude Code (`claude -p`) o
|
||||
|
||||
GUI (nessun argomento): finestra di chat in stile claude.ai, con la conversazione in markdown in alto e il campo messaggio in basso (`Invio` invia, `Maiusc+Invio` va a capo, "Nuova chat" azzera il contesto). Il riquadro **Impostazioni** (in alto a destra) permette di scegliere la **CLI** (Claude Code o Codex), il **modello** e l'**effort** di ragionamento. All'avvio, e a ogni cambio di CLI, sono scelti il modello più piccolo (Haiku 5.5 per Claude Code, GPT-5.6 Luna per Codex) ed effort `low`, per risposte rapide e economiche; "Predefinito" = nessun flag, vale la config del motore. Le chat si salvano su disco e l'ultima si riapre all'avvio. Sempre nel riquadro, la sezione **Aggiornamenti** dice se Claude Code e Codex hanno una versione nuova (pulsante evidenziato) e li aggiorna uno alla volta.
|
||||
```
|
||||
dotnet run
|
||||
dotnet run --project src/agent-wiki-ui
|
||||
```
|
||||
|
||||
CLI (con argomenti): il prompt viene stampato su terminale, l'exit code è quello del motore (127 se non è nel PATH). Di default usa Claude Code; `--engine codex` usa Codex.
|
||||
```
|
||||
dotnet run -- "spiega questo progetto"
|
||||
dotnet run -- --engine codex "spiega questo progetto"
|
||||
dotnet run --project src/agent-wiki-ui -- "spiega questo progetto"
|
||||
dotnet run --project src/agent-wiki-ui -- --engine codex "spiega questo progetto"
|
||||
```
|
||||
|
||||
## Creare gli eseguibili (Windows e Linux)
|
||||
@@ -49,7 +49,7 @@ Altri identificatori (per esempio `win-arm64`, `linux-arm64`) si passano allo sc
|
||||
|
||||
**Senza script** (identico su qualsiasi sistema; sostituisci `<rid>` con `win-x64` o `linux-x64`):
|
||||
```
|
||||
dotnet publish -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o dist/<rid>
|
||||
dotnet publish src/agent-wiki-ui -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o dist/<rid>
|
||||
```
|
||||
Senza `-o` il risultato è in `bin/Release/net10.0/<rid>/publish/`.
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/agent-wiki-ui/agent-wiki-ui.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/agent-wiki-ui.Tests/agent-wiki-ui.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
+1
-1
@@ -8,7 +8,7 @@ Il motore va installato **nello stesso ambiente in cui gira l'app**:
|
||||
| Dove avvii l'app | Dove installare il motore |
|
||||
|---|---|
|
||||
| Linux nativo | nel terminale Linux |
|
||||
| WSL (anche con `dotnet run`) | nel terminale della distribuzione WSL |
|
||||
| WSL (anche con `dotnet run --project src/agent-wiki-ui`) | nel terminale della distribuzione WSL |
|
||||
| Windows (`.exe`) | in Windows (PowerShell), non in WSL |
|
||||
|
||||
Dentro WSL il PATH di Windows è visibile (compare `/mnt/c/…`): se in WSL non c'è un `claude` o un `codex`, il comando può risolvere nella versione Windows. Controllalo con `command -v claude` e `command -v codex`.
|
||||
|
||||
+3
-3
@@ -21,7 +21,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile
|
||||
|
||||
**Struttura della finestra**
|
||||
- **Barra laterale** (sinistra, comprimibile con il pulsante a tre linee): logo e nome dell'app, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata".
|
||||
- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link. Il rendering è un renderer proprio ([MarkdownView.cs](../Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice.
|
||||
- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link, immagini. Le immagini `` si mostrano se il percorso è locale (assoluto, relativo alla cartella dell'exe o `file://`) e il file è un'immagine leggibile entro i limiti (caricata in background: prima compare `[alt]`, poi l'immagine); altrimenti, e sempre per gli URL `http(s)`, compare il testo alternativo `[alt]` e non si scarica nulla ([ADR-0006](adr/0006-immagini-nel-markdown.md)). Il rendering è un renderer proprio ([MarkdownView.cs](../src/agent-wiki-ui/Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice.
|
||||
- **Schermata iniziale** (chat vuota): solo logo, titolo e sottotitolo (nessun suggerimento precompilato).
|
||||
- **Composer** (in basso, scheda arrotondata): campo di testo che cresce fino a ~8 righe e pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. Non contiene più i selettori di modello ed effort.
|
||||
- **Impostazioni** (in alto a destra, pulsante con icona a cursori, simmetrico a quello della barra laterale): aprono un riquadro a comparsa con l'interruttore **CLI** (Claude Code, Codex; un motore non installato è mostrato "non installato" e non è selezionabile; disabilitato durante l'attesa), poi **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max). **Valori iniziali**: il modello più piccolo del motore (Claude Code: Haiku 5.5; Codex: GPT-5.6 Luna) ed effort `low`, per risposte rapide e poco costose; sono quelli che si ritrovano all'avvio e a ogni cambio di CLI. Si chiude con `Esc` o con un clic fuori; le scelte restano valide per i messaggi successivi (non sono ancora salvate su disco). Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI.
|
||||
@@ -35,7 +35,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile
|
||||
- Finestra orizzontale con proporzioni 16:10: larga il 75% dell'area utile dello schermo (max 1440 px logici), altezza di conseguenza (max 90% dell'area utile), centrata; minimo 900x600. Su un monitor Full HD 1920x1080 risulta circa 1440x900.
|
||||
|
||||
### Design (UI/UX)
|
||||
Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). I token vivono come risorse di tema in [Gui/App.axaml](../Gui/App.axaml).
|
||||
Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde). I token vivono come risorse di tema in [Gui/App.axaml](../src/agent-wiki-ui/Gui/App.axaml).
|
||||
- **Tema**: segue il sistema (chiaro/scuro) tramite `RequestedThemeVariant="Default"`.
|
||||
- **Token** (chiaro / scuro): sfondo `#FFFFFF` / `#0F172A`, superficie composer `#FFFFFF` / `#1E293B`, testo `#0F172A` / `#F8FAFC`, testo secondario `#475569` / `#94A3B8`, bordi `#CBD5E1` / `#334155`, accento `#15803D` / `#22C55E`, errore `#B91C1C` / `#F87171`. Contrasto testo ≥ 4.5:1.
|
||||
- **Tipografia**: font di sistema (Segoe UI Variable/Segoe UI su Windows; Inter, Ubuntu, Cantarell, Noto Sans, DejaVu Sans su Linux; corpo 15.5, titoli fino a 24), Cascadia Code/Consolas/DejaVu Sans Mono per il codice. Nessun font incorporato (funziona offline).
|
||||
@@ -50,7 +50,7 @@ Le scelte architetturali sono negli [ADR](README.md#indice-adr). Sintesi:
|
||||
- `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting).
|
||||
- `claude` è un eseguibile nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude.
|
||||
- Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante.
|
||||
- L'avvio dei motori è in un solo punto ([Core/EngineRunner.cs](../Core/EngineRunner.cs)), usato da CLI e GUI; ogni motore descrive argomenti e parser in [Core/Engine.cs](../Core/Engine.cs).
|
||||
- L'avvio dei motori è in un solo punto ([Core/EngineRunner.cs](../src/agent-wiki-ui/Core/EngineRunner.cs)), usato da CLI e GUI; ogni motore descrive argomenti e parser in [Core/Engine.cs](../src/agent-wiki-ui/Core/Engine.cs).
|
||||
|
||||
## Fuori scope (MVP)
|
||||
Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, altri motori oltre a Claude Code (Codex, Antigravity: vedi [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)), logging su file, supporto macOS. I test automatici sono pianificati: [TESTING.md](TESTING.md).
|
||||
|
||||
@@ -42,6 +42,7 @@ Da [MVP.md](MVP.md). Restano non spuntati finché non hanno test automatici (reg
|
||||
| [0003](adr/0003-strategia-di-test.md) Strategia di test | Proposta |
|
||||
| [0004](adr/0004-aggiornamento-dei-cli.md) Aggiornamento dei CLI | Proposta |
|
||||
| [0005](adr/0005-windows-senza-console.md) Windows senza console | Proposta |
|
||||
| [0006](adr/0006-immagini-nel-markdown.md) Immagini nel markdown (solo file locali) | Proposta (implementata, test verdi) |
|
||||
|
||||
## Per chiudere l'MVP
|
||||
Proposta, derivata dai punti aperti sopra; da confermare.
|
||||
|
||||
+2
-1
@@ -17,7 +17,7 @@ Indice e regole. Ogni informazione vive in **un solo documento**; gli altri la r
|
||||
Il nome è **`agent-wiki-ui`** (prima `LlmRun`). Questa sezione è l'unica sorgente: gli altri doc scrivono "l'app" o `<nome-app>`.
|
||||
|
||||
Se il nome cambia di nuovo, aggiornare solo questi punti:
|
||||
- [ ] `AssemblyName` in [../agent-wiki-ui.csproj](../agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `<nome-app>.exe` / `<nome-app>`
|
||||
- [ ] `AssemblyName` in [../src/agent-wiki-ui/agent-wiki-ui.csproj](../src/agent-wiki-ui/agent-wiki-ui.csproj) (e il nome del file csproj): da lì il codice legge il nome (`Core/AppInfo.cs`) per titolo finestra, intestazione della barra laterale e cartella dati; l'eseguibile diventa `<nome-app>.exe` / `<nome-app>`
|
||||
- [ ] `RootNamespace` e namespace C# (`AgentWikiUi.*`, anche in `x:Class` e `xmlns` dei file `.axaml`): il nome del namespace non può avere trattini
|
||||
- [ ] **Cartella dati** `<LocalApplicationData>/<nome-app>/chats`: cambiando nome le chat salvate non si trovano più. `ChatStore` sposta una vecchia cartella (`LlmRun`) in quella nuova al primo avvio; per un nuovo cambio aggiungere il nome precedente all'elenco `LegacyNames`
|
||||
- [ ] Titolo di [../README.md](../README.md) e riga iniziale di [../AGENT.md](../AGENT.md) (nome del csproj nei comandi)
|
||||
@@ -52,3 +52,4 @@ Non si scrive nulla nei puntatori: ogni modifica va in AGENT.md. Per un altro st
|
||||
| [0003](adr/0003-strategia-di-test.md) | Strategia di test e regola "nuova funzione = nuovi test" | Proposta |
|
||||
| [0004](adr/0004-aggiornamento-dei-cli.md) | Aggiornamento dei CLI dalle Impostazioni | Proposta |
|
||||
| [0005](adr/0005-windows-senza-console.md) | Windows: eseguibile senza finestra di console | Proposta |
|
||||
| [0006](adr/0006-immagini-nel-markdown.md) | Immagini nel markdown: solo file locali | Proposta |
|
||||
@@ -22,6 +22,7 @@ Dettagli dell'MVP in [MVP.md](MVP.md). Stadio attuale e debiti: [PROJECT_STATE.m
|
||||
## Fase 3 — Oltre
|
||||
- [x] GUI: cronologia chat (salvataggio JSON, barra laterale, riapertura con `--resume`, eliminazione)
|
||||
- [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, rinomina/ricerca chat
|
||||
- [x] GUI: immagini nel markdown da percorso locale, con ripiego sul testo alternativo ([ADR-0006](adr/0006-immagini-nel-markdown.md); test MD-14, MD-19…MD-27); immagini remote rimandate
|
||||
- [ ] Config file accanto all'exe
|
||||
- [ ] Test automatici e CI (vedi Fase 6)
|
||||
|
||||
|
||||
+12
-3
@@ -33,8 +33,8 @@ tests/FakeClaude/ eseguibile finto: registra argv/cwd/stdin; testo,
|
||||
- [ ] `ChatStore` come istanza con cartella iniettata (oggi statico su `LocalApplicationData`)
|
||||
- [ ] `IEngineRunner` dietro `Core/EngineRunner.cs`, iniettato nel `MainViewModel`
|
||||
- [x] `EngineUpdater.CheckAsync` accetta un `HttpClient`: i test usano un gestore finto (rete assente, risposta non valida, versioni di prova)
|
||||
- [ ] `InternalsVisibleTo` verso il progetto di test
|
||||
- [ ] Spostamento in `src/` e `tests/` con soluzione unica
|
||||
- [x] `InternalsVisibleTo` verso il progetto di test
|
||||
- [x] Spostamento in `src/agent-wiki-ui/` e `tests/` con soluzione unica `agent-wiki-ui.slnx`
|
||||
|
||||
## Soglie di copertura (la build fallisce sotto soglia)
|
||||
| Area | Righe | Rami |
|
||||
@@ -163,7 +163,16 @@ Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`;
|
||||
| MD-11 | Link: colore d'accento e sottolineatura; il clic apre solo `http(s)` | R | `Link_http_https` | [ ] |
|
||||
| MD-12 | Link: nessuna apertura se c'è una selezione in corso; cursore a mano sopra il link | R | `Link_con_selezione` | [ ] |
|
||||
| MD-13 | Posizioni dei link corrette con enfasi, codice inline e a capo prima del link | R | `Intervalli_link` | [ ] |
|
||||
| MD-14 | Immagini: si mostra il testo alternativo, non si scarica nulla | R | `Immagini_come_testo` | [ ] |
|
||||
| MD-14 | Immagini con URL `http(s)` o altro schema: si mostra il testo alternativo, nessuna richiesta di rete | R | `Immagini_remote_come_testo` | [x] |
|
||||
| MD-19 | Immagine con percorso assoluto di un file png valido ⇒ controllo immagine con le dimensioni attese (ADR-0006) | R | `Immagine_percorso_assoluto` | [x] |
|
||||
| MD-20 | Percorso relativo risolto rispetto a `AppContext.BaseDirectory`; anche URI `file://` | R | `Immagine_percorso_relativo_e_file_uri` | [x] |
|
||||
| MD-21 | File mancante, illeggibile, corrotto o con estensione non ammessa ⇒ testo alternativo, nessuna eccezione | R | `Immagine_non_valida_ripiego` | [x] |
|
||||
| MD-22 | File oltre il limite di 20 MB ⇒ testo alternativo, file non decodificato | R | `Immagine_oltre_limite` | [x] |
|
||||
| MD-23 | Immagine più larga della colonna ⇒ ridotta in larghezza mantenendo le proporzioni; mai ingrandita oltre l'originale | R | `Immagine_larghezza_massima` | [x] |
|
||||
| MD-24 | Il testo alternativo è nome accessibile e suggerimento dell'immagine | R | `Immagine_testo_alternativo_accessibile` | [x] |
|
||||
| MD-25 | Immagine dentro un link, in una lista o in una tabella ⇒ resa senza rompere intervalli dei link (MD-13) né il layout | R | `Immagine_in_contesti_annidati` | [x] |
|
||||
| MD-26 | Cambio di tema con immagini presenti ⇒ vista ricostruita, immagini ancora mostrate | R | `Immagine_cambio_tema` | [x] |
|
||||
| MD-27 | Il caricamento è in background: subito dopo il rendering c'è il segnaposto `[alt]` e un'immagine in attesa; a fine decodifica compare l'immagine; un file corrotto lascia `[alt]` | R | `Immagine_caricamento_in_background` | [x] |
|
||||
| MD-15 | HTML grezzo disabilitato: i tag compaiono come testo, non come elementi | R | `Html_disabilitato` | [ ] |
|
||||
| MD-16 | Cambio di tema (chiaro/scuro): i colori si aggiornano ricostruendo la vista | R | `Cambio_tema` | [ ] |
|
||||
| MD-17 | Markdown malformato o blocchi sconosciuti non fanno crashare | R | `Markdown_malformato` | [ ] |
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# ADR-0006 — Immagini nel markdown: solo file locali
|
||||
|
||||
- **Stato**: Proposta
|
||||
- **Data**: 2026-10-15
|
||||
- **Collegato a**: [ADR-0001](0001-gui-multipiattaforma-avalonia.md) (GUI), [ADR-0003](0003-strategia-di-test.md) (test)
|
||||
|
||||
## Contesto
|
||||
Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/MarkdownView.cs)) trasforma `` nel solo testo alternativo `[alt]`: non carica nulla. Un motore (Claude Code, Codex) può però produrre o citare un'immagine su disco (un grafico, uno screenshot) e rispondere con ``. Se il percorso è noto e il file esiste, l'utente si aspetta di vederla nella chat. Oggi l'app contatta la rete solo per il controllo delle versioni ([AGENT.md](../../AGENT.md), "Vincoli non ovvi"); aprire le immagini remote cambierebbe questo vincolo.
|
||||
|
||||
## Criteri di decisione
|
||||
1. Mostrare l'immagine quando il percorso è locale e il file è leggibile.
|
||||
2. Nessun accesso alla rete oltre a quello già previsto.
|
||||
3. Nessun crash e nessun blocco della chat per file mancanti, enormi o corrotti: ripiego sul testo alternativo di oggi.
|
||||
4. Coerenza con il resto del renderer: colori dal tema, testo selezionabile, accessibilità.
|
||||
|
||||
## Opzioni considerate
|
||||
| Opzione | Esito | Motivo |
|
||||
|---|---|---|
|
||||
| Lasciare il testo `[alt]` | Scartata | Il percorso noto resta inutilizzato. |
|
||||
| **Solo percorsi locali** | **Scelta** | Soddisfa il caso d'uso, nessuna rete, sola lettura. |
|
||||
| Locali + URL `http(s)` | Rimandata | Una risposta del modello farebbe partire richieste di rete verso indirizzi scelti dal modello (tracciamento, indirizzo IP dell'utente esposto). Si può riprendere con un nuovo ADR e un consenso esplicito. |
|
||||
|
||||
## Decisione
|
||||
- `` mostra l'immagine se la destinazione è un **percorso locale**: assoluto, relativo alla cartella dell'exe (`AppContext.BaseDirectory`, la stessa working directory dei motori) oppure un URI `file://`.
|
||||
- Formati: png, jpg/jpeg, gif (primo fotogramma), webp, bmp. Altre estensioni ⇒ ripiego.
|
||||
- Limiti: file fino a **20 MB**; se l'immagine è più larga di **1600 px** si decodifica a 1600 px di larghezza, per non occupare memoria inutile. In visualizzazione l'immagine non supera la larghezza della colonna dei messaggi (proporzioni mantenute) e non è mai ingrandita oltre l'originale.
|
||||
- Un paragrafo fatto solo di immagini le mostra come blocchi; un'immagine dentro una riga di testo (o dentro un link) è inline, con larghezza massima di 480 px.
|
||||
- **Ripiego** sul testo `[alt]` (come oggi) quando: la destinazione è `http(s)` o altro schema, il file manca o non è leggibile, il formato non è ammesso, il file supera il limite, la decodifica fallisce. Nessun errore a schermo, nessuna eccezione.
|
||||
- **Caricamento in background**: al rendering un controllo di controllo rapido e sincrono (percorso, estensione, esistenza, dimensione) decide se l'immagine è plausibile; se no, compare subito `[alt]`. Se sì, compare `[alt]` come segnaposto e la decodifica avviene su un altro thread; a fine decodifica il segnaposto diventa l'immagine, se la decodifica fallisce resta `[alt]`. Così la GUI non si blocca con file grandi o dischi lenti.
|
||||
- Accessibilità: l'immagine espone `alt` come nome per le tecnologie assistive e come suggerimento al passaggio del mouse.
|
||||
- Nessuna azione al clic (non si apre il file). Il pulsante **Copia** della risposta continua a copiare il markdown originale.
|
||||
|
||||
## Conseguenze
|
||||
**Positive**: le immagini prodotte dai motori si vedono nella chat; nessuna richiesta di rete in più; ripiego sicuro.
|
||||
**Negative**: una risposta può far leggere e mostrare un file immagine qualsiasi dell'utente (sola lettura, solo a schermo, nessun invio altrove); le immagini remote restano non supportate e si vedono come testo.
|
||||
|
||||
## Da verificare
|
||||
- Quali formati decodifica davvero Avalonia 12 con Skia su Windows e Linux (in particolare webp e gif) e se il limite di 20 MB è adeguato.
|
||||
- Il comportamento dei percorsi Windows (`C:\…`, UNC) e di quelli WSL (`/mnt/c/…`).
|
||||
- Se i percorsi relativi vanno risolti anche rispetto a una cartella diversa da quella dell'exe.
|
||||
|
||||
## Riferimenti
|
||||
- [MVP.md](../MVP.md) → "GUI (chat)"; matrice MD-14, MD-19…MD-27 in [TESTING.md](../TESTING.md).
|
||||
+1
-1
@@ -8,7 +8,7 @@ Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
foreach ($rid in $Rids) {
|
||||
Write-Host "== $rid"
|
||||
dotnet publish agent-wiki-ui.csproj -c Release -r $rid --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
|
||||
dotnet publish src/agent-wiki-ui/agent-wiki-ui.csproj -c Release -r $rid --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
|
||||
if ($LASTEXITCODE -ne 0) { throw "publish fallito per $rid" }
|
||||
Get-ChildItem "dist/$rid" -Filter *.pdb -Recurse | Remove-Item # simboli delle librerie native: inutili a chi usa l'app
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ rids=("$@")
|
||||
|
||||
for rid in "${rids[@]}"; do
|
||||
echo "== $rid"
|
||||
dotnet publish agent-wiki-ui.csproj -c Release -r "$rid" --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
|
||||
dotnet publish src/agent-wiki-ui/agent-wiki-ui.csproj -c Release -r "$rid" --self-contained -p:PublishSingleFile=true -o "dist/$rid" --nologo -v quiet
|
||||
find "dist/$rid" -name '*.pdb' -delete # simboli delle librerie native: inutili a chi usa l'app
|
||||
done
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,10 +1,12 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Automation;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Markdig;
|
||||
using Markdig.Extensions.Tables;
|
||||
using Markdig.Syntax;
|
||||
@@ -17,8 +19,16 @@ class MarkdownView : Decorator
|
||||
{
|
||||
public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(nameof(Source));
|
||||
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");
|
||||
|
||||
/// <summary>Immagini ancora in decodifica (ADR-0006); i test attendono che scenda a zero.</summary>
|
||||
internal int PendingImages { get; private set; }
|
||||
|
||||
public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); }
|
||||
|
||||
public MarkdownView()
|
||||
@@ -47,7 +57,7 @@ class MarkdownView : Decorator
|
||||
Control RenderBlock(Block block) => block switch
|
||||
{
|
||||
HeadingBlock h => Text(h.Inline, bold: true, size: h.Level switch { 1 => 24, 2 => 20, 3 => 18, 4 => 16, _ => 15.5 }),
|
||||
ParagraphBlock p => Text(p.Inline),
|
||||
ParagraphBlock p => ImageParagraph(p) ?? Text(p.Inline),
|
||||
FencedCodeBlock or CodeBlock => Code(string.Join('\n', ((LeafBlock)block).Lines.Lines.Take(((LeafBlock)block).Lines.Count).Select(l => l.ToString()))),
|
||||
QuoteBlock q => new Border { BorderBrush = Brush("Border"), BorderThickness = new(3, 0, 0, 0), Padding = new(12, 0, 0, 0), Child = RenderBlocks(q, 8) },
|
||||
ListBlock l => List(l),
|
||||
@@ -122,6 +132,94 @@ class MarkdownView : Decorator
|
||||
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.</summary>
|
||||
internal static string? ResolveImagePath(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) return uri.IsFile ? uri.LocalPath : null;
|
||||
return Path.GetFullPath(Uri.UnescapeDataString(url), AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
/// <summary>Controllo rapido e sincrono: percorso locale, formato ammesso, file esistente entro il limite. Non decodifica.</summary>
|
||||
static bool IsImageCandidate(string? url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = ResolveImagePath(url);
|
||||
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? url)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ResolveImagePath(url) is not { } path) return null;
|
||||
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? url, string alt, double maxWidth)
|
||||
{
|
||||
var host = new Decorator { HorizontalAlignment = HorizontalAlignment.Left, Child = new TextBlock { Text = "[" + alt + "]", TextWrapping = TextWrapping.Wrap } };
|
||||
PendingImages++;
|
||||
_ = LoadInto(host, url, alt, maxWidth);
|
||||
return host;
|
||||
}
|
||||
|
||||
async Task LoadInto(Decorator host, string? url, string alt, double maxWidth)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await Task.Run(() => DecodeImage(url)) 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;
|
||||
if (!images.Any(i => IsImageCandidate(i.Url))) return null;
|
||||
var panel = new StackPanel { Spacing = 8 };
|
||||
foreach (var image in images)
|
||||
panel.Children.Add(IsImageCandidate(image.Url) ? ImageHost(image.Url, 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.
|
||||
SelectableTextBlock Text(ContainerInline? inline, bool bold = false, double size = 0)
|
||||
{
|
||||
@@ -156,7 +254,12 @@ class MarkdownView : Decorator
|
||||
AddInlines(target, em, inner, links, ref pos);
|
||||
break;
|
||||
case LinkInline { IsImage: true } img:
|
||||
Add(target, "[" + string.Concat(img.OfType<LiteralInline>().Select(l => l.Content.ToString())) + "]", st, ref pos);
|
||||
if (IsImageCandidate(img.Url))
|
||||
{
|
||||
target.Add(new InlineUIContainer(ImageHost(img.Url, AltText(img), MaxInlineImageWidth)));
|
||||
pos += 1; // un controllo incorporato occupa una posizione nel testo
|
||||
}
|
||||
else Add(target, "[" + AltText(img) + "]", st, ref pos);
|
||||
break;
|
||||
case LinkInline link:
|
||||
var start = pos;
|
||||
File renamed without changes.
File renamed without changes.
@@ -16,6 +16,10 @@
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="agent-wiki-ui.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.4" />
|
||||
@@ -0,0 +1,208 @@
|
||||
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  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($"");
|
||||
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($"");
|
||||
Assert.Single(Images(relative));
|
||||
|
||||
var uri = new Uri(Png("b.png", 12, 12)).AbsoluteUri;
|
||||
var (_, fileUri) = Show($"");
|
||||
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($"");
|
||||
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($"");
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[enorme]", Text(view));
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void Immagine_larghezza_massima()
|
||||
{
|
||||
var (_, wide) = Show($"})", 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($"})", 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($"})");
|
||||
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  e ancora testo con [un link](https://example.com).
|
||||
|
||||
[](https://example.com)
|
||||
|
||||
- voce 
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
|  | 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($"})");
|
||||
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 = $"";
|
||||
// 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 = $"";
|
||||
WaitForImages(view);
|
||||
Assert.Empty(Images(view));
|
||||
Assert.Contains("[guasta]", Text(view));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace AgentWikiUi.Tests;
|
||||
|
||||
/// <summary>Scrive PNG minimali (colore pieno) senza dipendere da Avalonia.</summary>
|
||||
static class PngFactory
|
||||
{
|
||||
public static byte[] Create(int width, int height)
|
||||
{
|
||||
var raw = new byte[height * (1 + width * 3)];
|
||||
for (var y = 0; y < height; y++)
|
||||
for (var x = 0; x < width; x++) raw[y * (1 + width * 3) + 1 + x * 3] = 200; // filtro 0 per riga, canale rosso
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
var ihdr = new byte[13];
|
||||
WriteInt(ihdr, 0, width); WriteInt(ihdr, 4, height);
|
||||
ihdr[8] = 8; ihdr[9] = 2; // 8 bit, RGB
|
||||
Chunk(ms, "IHDR", ihdr);
|
||||
using (var z = new MemoryStream())
|
||||
{
|
||||
using (var zs = new ZLibStream(z, CompressionLevel.Fastest, leaveOpen: true)) zs.Write(raw);
|
||||
Chunk(ms, "IDAT", z.ToArray());
|
||||
}
|
||||
Chunk(ms, "IEND", []);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
static void WriteInt(byte[] b, int at, int v) { b[at] = (byte)(v >> 24); b[at + 1] = (byte)(v >> 16); b[at + 2] = (byte)(v >> 8); b[at + 3] = (byte)v; }
|
||||
|
||||
static void Chunk(Stream s, string type, byte[] data)
|
||||
{
|
||||
var len = new byte[4]; WriteInt(len, 0, data.Length);
|
||||
s.Write(len);
|
||||
var body = new byte[4 + data.Length];
|
||||
for (var i = 0; i < 4; i++) body[i] = (byte)type[i];
|
||||
data.CopyTo(body, 4);
|
||||
s.Write(body);
|
||||
var crc = new byte[4]; WriteInt(crc, 0, (int)Crc32(body));
|
||||
s.Write(crc);
|
||||
}
|
||||
|
||||
static uint Crc32(byte[] data)
|
||||
{
|
||||
var crc = 0xFFFFFFFFu;
|
||||
foreach (var b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1;
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Themes.Fluent;
|
||||
using AgentWikiUi.Tests;
|
||||
|
||||
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
|
||||
|
||||
namespace AgentWikiUi.Tests;
|
||||
|
||||
public class TestApp : Application
|
||||
{
|
||||
public override void Initialize() => Styles.Add(new FluentTheme());
|
||||
}
|
||||
|
||||
public static class TestAppBuilder
|
||||
{
|
||||
// Skia reale (non il disegno finto): serve per decodificare davvero le immagini
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<TestApp>()
|
||||
.UseSkia()
|
||||
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>AgentWikiUi.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" Version="12.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/agent-wiki-ui/agent-wiki-ui.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user