Corregge il crash in chiusura e rende recuperabile una strategia rimossa
Chiusura. Il gestore annullava la chiusura, aspettava lo spegnimento del motore e la richiedeva alla fine. Un secondo clic sulla X durante l'attesa usciva pero' SENZA annullare: la finestra entrava nella propria sequenza di chiusura e la Close() del primo tentativo ci finiva dentro, sollevando "non e' possibile chiamare Close durante la chiusura di un oggetto Window". Ora ogni tentativo successivo viene annullato e la chiusura vera si rimanda a un frame nuovo del dispatcher, cosi' non puo' mai eseguire dentro il gestore. La correzione ovvia — annullare tutto quando lo spegnimento e' in corso — sarebbe stata peggiore del difetto: la chiusura finale ripassa dallo stesso gestore, veniva annullata anche lei e la finestra non si chiudeva piu'. Passa solo quella, riconosciuta da un flag alzato prima di chiamarla. Strategia. Un aggiornamento che rimuove una strategia lascia il suo nome nella configurazione dell'utente, perche' l'installazione la conserva — ed e' giusto, le tarature sono sue. Il campo pero' era di sola lettura: l'unica via d'uscita era modificare il JSON a mano. Ora e' un elenco a discesa che propone solo cio' che il programma sa costruire, quindi non ci si puo' scrivere un nome inesistente, e un valore obsoleto si segnala da se' all'apertura della pagina invece di aspettare che qualcuno prema AVVIA. All'avvio l'applicazione lo dice e porta in Impostazioni. Stessa cosa per gli altri campi a insieme chiuso — barre, tipo di ordine, livello del registro e i booleani — che ora si scelgono e non si scrivono. Trovato provando il giro completo a video: applicare due volte lo stesso lotto di modifiche falliva con "The node already has a parent", perche' la pagina lo applica prima a una copia temporanea per validarlo e poi al file vero, e un JsonNode appartiene a un albero solo. ConfigWriter ora clona. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,12 @@
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<Product>Encelado</Product>
|
||||
<Company>Encelado</Company>
|
||||
<Version>3.2.0</Version>
|
||||
<!-- Numero delle compilazioni di sviluppo: è quello che compare nella finestra
|
||||
mentre si lavora. La versione RILASCIATA viene dal tag git — vedi
|
||||
build/Release.proj — e questo serve solo da seme quando non esiste ancora
|
||||
nessun tag. Tenerlo allineato all'ultimo rilascio evita di leggere in
|
||||
finestra un numero che non corrisponde a niente. -->
|
||||
<Version>3.5.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
Cose da fare:
|
||||
- Non caricare i sorgenti del programma nella release. Deve solo essere presente la versione portable e la versione installabile.
|
||||
- Il nome del setup deve essere Encelado_Versione.exe (ad esempio Encelado_4.14.0.exe)
|
||||
|
||||
Altre cose da fare:
|
||||
-
|
||||
@@ -64,11 +64,18 @@ public sealed class BotConfig
|
||||
throw new InvalidOperationException($"Symbol '{s.Symbol}' is configured more than once.");
|
||||
}
|
||||
|
||||
// Il messaggio dice anche come uscirne. Questo caso capita quando un
|
||||
// aggiornamento toglie una strategia e l'installazione conserva — a
|
||||
// ragione — l'encelado.json dell'utente: senza l'indicazione, l'unica via
|
||||
// d'uscita apparente è modificare il file a mano.
|
||||
if (!StrategyFactory.IsKnown(s.Strategy))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Symbol '{s.Symbol}' uses unknown strategy '{s.Strategy}'. " +
|
||||
$"Available: {string.Join(", ", StrategyFactory.Available)}.");
|
||||
$"La strategia '{s.Strategy}' configurata su {s.Symbol} non esiste più.\n\n" +
|
||||
$"Disponibili: {string.Join(", ", StrategyFactory.Available)}.\n\n" +
|
||||
"Aprila da Impostazioni → Strategia e scegline una dall'elenco: " +
|
||||
"capita dopo un aggiornamento, perché l'installazione non sovrascrive " +
|
||||
"la tua configurazione.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,14 @@ public static class ConfigWriter
|
||||
throw new ArgumentException($"Percorso vuoto.", nameof(path));
|
||||
}
|
||||
|
||||
// Un JsonNode può appartenere a un solo albero: assegnarne uno che ne ha già
|
||||
// uno solleva «The node already has a parent». Succede sistematicamente qui,
|
||||
// perché la pagina delle impostazioni applica lo stesso lotto di modifiche due
|
||||
// volte — prima a una copia temporanea per validarlo, poi al file vero. Clonare
|
||||
// rende l'insieme delle modifiche riutilizzabile, che è come un chiamante si
|
||||
// aspetta che si comporti.
|
||||
value = value?.DeepClone();
|
||||
|
||||
JsonNode current = root;
|
||||
|
||||
for (int i = 0; i < segments.Length - 1; i++)
|
||||
|
||||
@@ -12,6 +12,7 @@ using Encelado.Bot.Engine;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Bot.Ui;
|
||||
using Encelado.Bot.Ui.Pages;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Bot;
|
||||
|
||||
@@ -43,6 +44,7 @@ public partial class MainWindow : Window, IUiActions
|
||||
|
||||
private bool _busy;
|
||||
private bool _closing;
|
||||
private bool _closed;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -152,6 +154,54 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
|
||||
RefreshSettings();
|
||||
AvvisaSeLaStrategiaNonEsistePiu();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Se la configurazione nomina una strategia che non esiste più, lo dice adesso e
|
||||
/// porta dov'è la soluzione.
|
||||
/// <para>
|
||||
/// Prima il problema veniva a galla solo premendo AVVIA, e sembrava un guasto: il
|
||||
/// bot rifiutava di partire con un errore su un nome che l'utente non ricordava di
|
||||
/// aver scritto. Capita perché l'installazione conserva l'<c>encelado.json</c>
|
||||
/// esistente — che è giusto, le tarature sono sue — quindi un aggiornamento che
|
||||
/// rimuove una strategia lascia dietro un riferimento morto.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void AvvisaSeLaStrategiaNonEsistePiu()
|
||||
{
|
||||
List<string> rotte = [.. _config.EnabledSymbols
|
||||
.Where(static s => !StrategyFactory.IsKnown(s.Strategy))
|
||||
.Select(static s => $"{s.Symbol}: '{s.Strategy}'")];
|
||||
|
||||
if (rotte.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Warn($"strategia non più disponibile in configurazione — {string.Join(", ", rotte)}");
|
||||
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"La configurazione fa riferimento a una strategia che non esiste più:\n\n" +
|
||||
string.Join("\n", rotte) + "\n\n" +
|
||||
$"Disponibili: {string.Join(", ", StrategyFactory.Available)}.\n\n" +
|
||||
"Succede dopo un aggiornamento, perché l'installazione non sovrascrive la tua " +
|
||||
"configurazione. Vai in Impostazioni → Strategia, scegline una dall'elenco e salva: " +
|
||||
"il bot non può partire finché resta così.",
|
||||
"Strategia non disponibile",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
|
||||
// Portarlo dove si risolve vale più che dirgli dove andare.
|
||||
foreach (NavItem item in Nav.Items.OfType<NavItem>())
|
||||
{
|
||||
if (item.Title == "Impostazioni")
|
||||
{
|
||||
Nav.SelectedItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadLogo()
|
||||
@@ -536,10 +586,32 @@ public partial class MainWindow : Window, IUiActions
|
||||
// Shutdown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Fermare il motore è asincrono e la chiusura di una finestra non lo è: si annulla
|
||||
/// la chiusura, si aspetta, e la si richiede quando lo spegnimento è finito davvero.
|
||||
/// <para>
|
||||
/// Il punto delicato è il secondo tentativo. Chi non vede succedere niente preme la
|
||||
/// X un'altra volta, e prima questo ramo usciva <b>senza</b> annullare: la seconda
|
||||
/// chiusura andava a buon fine, la finestra entrava nella propria sequenza di
|
||||
/// chiusura, e la <c>Close()</c> in fondo al primo tentativo ci finiva dentro —
|
||||
/// <c>«Non è possibile […] chiamare Close durante la chiusura di un oggetto
|
||||
/// Window»</c>. Ogni tentativo successivo va annullato: il primo possiede lo
|
||||
/// spegnimento e lo porterà a termine.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
// Si annullano i tentativi dell'utente, non la chiusura finale: quella
|
||||
// arriva da ChiudiDavvero, che alza _closed prima di chiamare Close().
|
||||
// Senza questa distinzione la finestra annulla anche se stessa e non si
|
||||
// chiude più.
|
||||
if (!_closed)
|
||||
{
|
||||
e.Cancel = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -557,8 +629,6 @@ public partial class MainWindow : Window, IUiActions
|
||||
return;
|
||||
}
|
||||
|
||||
// Stopping the engine is asynchronous, so cancel this close and re-issue it
|
||||
// once the shutdown has actually finished.
|
||||
e.Cancel = true;
|
||||
_closing = true;
|
||||
_timer.Stop();
|
||||
@@ -569,8 +639,31 @@ public partial class MainWindow : Window, IUiActions
|
||||
window.Close();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _supervisor.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Uno spegnimento andato storto non deve lasciare la finestra inchiodata:
|
||||
// si registra e si chiude comunque.
|
||||
Log.Error("errore durante lo spegnimento", ex);
|
||||
}
|
||||
|
||||
// Su un frame nuovo del dispatcher, non nella continuazione di OnClosing. Se il
|
||||
// Task si completa in modo sincrono la ripresa avviene ancora dentro il callback
|
||||
// di chiusura, ed è lì che Close() solleva l'eccezione.
|
||||
_ = Dispatcher.BeginInvoke(DispatcherPriority.Normal, ChiudiDavvero);
|
||||
}
|
||||
|
||||
private void ChiudiDavvero()
|
||||
{
|
||||
if (_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_closed = true;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,17 @@
|
||||
ToolTip="{Binding FullTooltip}"/>
|
||||
|
||||
<Grid Grid.Column="2">
|
||||
|
||||
<!--
|
||||
Due controlli nella stessa cella, uno solo visibile. I campi con un insieme
|
||||
di valori ammessi si scelgono da un elenco e non si scrivono: da lì non può
|
||||
uscire un nome inventato, e quando un aggiornamento toglie una voce l'elenco
|
||||
smette semplicemente di proporla.
|
||||
-->
|
||||
<TextBox Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
|
||||
IsReadOnly="{Binding IsReadOnly}"
|
||||
ToolTip="{Binding FullTooltip}">
|
||||
ToolTip="{Binding FullTooltip}"
|
||||
Visibility="{Binding IsFreeText, Converter={StaticResource BoolVis}}">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox" BasedOn="{StaticResource {x:Type TextBox}}">
|
||||
<Style.Triggers>
|
||||
@@ -50,6 +58,25 @@
|
||||
</TextBox.Style>
|
||||
</TextBox>
|
||||
|
||||
<ComboBox ItemsSource="{Binding Options}"
|
||||
SelectedItem="{Binding Value, Mode=TwoWay}"
|
||||
IsEnabled="{Binding IsEditable}"
|
||||
ToolTip="{Binding FullTooltip}"
|
||||
Visibility="{Binding IsList, Converter={StaticResource BoolVis}}">
|
||||
<ComboBox.Style>
|
||||
<Style TargetType="ComboBox" BasedOn="{StaticResource {x:Type ComboBox}}">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasError}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsDirty}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ComboBox.Style>
|
||||
</ComboBox>
|
||||
|
||||
<!-- Lucchetto sui campi fissati dalla strategia. -->
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="11"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
|
||||
|
||||
@@ -64,6 +64,35 @@ public sealed class SettingField : INotifyPropertyChanged
|
||||
|
||||
public IReadOnlyList<string> Choices { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// I valori fra cui si può scegliere, o vuoto se il campo è a testo libero.
|
||||
/// <para>
|
||||
/// I booleani entrano qui da sé: "sì" e "no" scritti a mano sono due modi per
|
||||
/// sbagliare, e nessuno dei due aggiunge niente rispetto a sceglierli.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Options => Kind switch
|
||||
{
|
||||
SettingKind.Choice => Choices,
|
||||
SettingKind.Boolean => ["sì", "no"],
|
||||
_ => [],
|
||||
};
|
||||
|
||||
/// <summary>Vero se il campo si compila da un elenco. Vedi <see cref="Options"/>.</summary>
|
||||
public bool IsList => Options.Count > 0;
|
||||
|
||||
/// <summary>Vero se il campo si scrive. È l'opposto di <see cref="IsList"/>.</summary>
|
||||
public bool IsFreeText => !IsList;
|
||||
|
||||
/// <summary>
|
||||
/// Vero quando il valore salvato non è più fra quelli ammessi — tipicamente dopo un
|
||||
/// aggiornamento che ha tolto una strategia. Il campo resta visibile con il suo
|
||||
/// errore, ma l'elenco non lo ripropone: da lì si esce solo scegliendo un valore
|
||||
/// che esiste.
|
||||
/// </summary>
|
||||
public bool IsObsolete => IsList && !IsReadOnly &&
|
||||
!Options.Contains(_value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Unit suffix shown after the box, e.g. "%" or "secondi".</summary>
|
||||
public string Suffix { get; init; } = string.Empty;
|
||||
|
||||
@@ -87,6 +116,7 @@ public sealed class SettingField : INotifyPropertyChanged
|
||||
_value = value;
|
||||
Raise();
|
||||
Raise(nameof(IsDirty));
|
||||
Raise(nameof(IsObsolete));
|
||||
Validate();
|
||||
}
|
||||
}
|
||||
@@ -121,9 +151,13 @@ public sealed class SettingField : INotifyPropertyChanged
|
||||
{
|
||||
_value = value;
|
||||
Original = value;
|
||||
Error = null;
|
||||
Raise(nameof(Value));
|
||||
Raise(nameof(IsDirty));
|
||||
|
||||
// Validato subito, non solo quando qualcuno lo tocca: un valore diventato non
|
||||
// valido perché l'aggiornamento ha tolto una strategia deve segnalarsi da sé
|
||||
// all'apertura della pagina, non restare lì con l'aria di andare bene.
|
||||
Validate();
|
||||
}
|
||||
|
||||
public void Revert() => Load(Original);
|
||||
@@ -176,7 +210,7 @@ public sealed class SettingField : INotifyPropertyChanged
|
||||
case SettingKind.Choice:
|
||||
if (Choices.Count > 0 && !Choices.Contains(_value, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Error = $"valori ammessi: {string.Join(", ", Choices)}";
|
||||
Error = $"'{_value}' non è più disponibile — scegli fra: {string.Join(", ", Choices)}";
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
@@ -21,7 +22,7 @@ public static class SettingsCatalogue
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
return
|
||||
SettingGroup[] gruppi =
|
||||
[
|
||||
Strategy(config, strategyName),
|
||||
Sizing(config),
|
||||
@@ -30,6 +31,17 @@ public static class SettingsCatalogue
|
||||
Engine(config),
|
||||
Logging(config),
|
||||
];
|
||||
|
||||
// Validati subito, non alla prima modifica. Un valore diventato non valido
|
||||
// perché un aggiornamento ha tolto una strategia deve segnalarsi da sé
|
||||
// all'apertura della pagina: aspettare che qualcuno lo tocchi significa non
|
||||
// dirglielo mai, visto che è proprio il campo che nessuno guarda.
|
||||
foreach (SettingField campo in gruppi.SelectMany(static g => g.Fields))
|
||||
{
|
||||
campo.Validate();
|
||||
}
|
||||
|
||||
return gruppi;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
@@ -64,21 +76,29 @@ public static class SettingsCatalogue
|
||||
"mai stati validati su dati ETH.",
|
||||
});
|
||||
|
||||
// Scelta e non sola lettura, anche se l'elenco ha una voce sola. Un
|
||||
// aggiornamento che toglie una strategia lascia nel file dell'utente un nome
|
||||
// che non esiste più, e con il campo bloccato l'unico modo per uscirne sarebbe
|
||||
// aprire il JSON a mano. L'elenco propone solo ciò che l'applicazione sa
|
||||
// costruire, quindi da qui non si può scrivere un nome sbagliato.
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "symbols[0].strategy",
|
||||
Label = "Strategia",
|
||||
Initial = strategyName,
|
||||
Kind = SettingKind.Text,
|
||||
IsReadOnly = true,
|
||||
ReadOnlyReason = "ne esiste una sola. Le altre sono state cancellate, non disattivate.",
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = StrategyFactory.Available,
|
||||
Tooltip =
|
||||
"Il modello che genera i segnali.\n\n" +
|
||||
"'trend-filter' compra quando il prezzo sta sopra la media a 100 giorni di una " +
|
||||
"certa percentuale, e vende quando scende sotto della stessa percentuale. Nient'altro: " +
|
||||
"nessun trailing stop, nessun target, nessun filtro di volatilità.\n\n" +
|
||||
"Sette modelli più elaborati sono stati scritti e misurati prima di questo. Tutti " +
|
||||
"hanno perso, o contro il mercato o contro il semplice comprare e tenere.",
|
||||
"hanno perso, o contro il mercato o contro il semplice comprare e tenere: per questo " +
|
||||
"l'elenco ne contiene una sola.\n\n" +
|
||||
"Se qui compare un errore, la configurazione porta il nome di una strategia rimossa " +
|
||||
"da un aggiornamento — succede perché l'installazione conserva il tuo encelado.json. " +
|
||||
"Scegli quella disponibile e salva.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
|
||||
@@ -223,9 +223,13 @@
|
||||
RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<!-- Il bordo segue quello del ComboBox invece di essere fisso:
|
||||
serve a far comparire il rosso dell'errore e l'azzurro delle
|
||||
modifiche non salvate anche sui campi a scelta. -->
|
||||
<Border x:Name="bg" Background="{StaticResource Panel2}"
|
||||
BorderBrush="{StaticResource Line}" BorderThickness="1"
|
||||
CornerRadius="7">
|
||||
BorderBrush="{Binding BorderBrush,
|
||||
RelativeSource={RelativeSource AncestorType=ComboBox}}"
|
||||
BorderThickness="1" CornerRadius="7">
|
||||
<Path x:Name="arrow" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
Margin="0,0,10,0" Data="M 0 0 L 4 4 L 8 0"
|
||||
Stroke="{StaticResource Dim}" StrokeThickness="1.4"/>
|
||||
|
||||
@@ -188,7 +188,12 @@ public class ConfigValidationTests
|
||||
config.Symbols[0].Strategy = "moon-phase";
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
Assert.Contains("unknown strategy", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Il messaggio deve nominare il colpevole, le alternative e la via d'uscita:
|
||||
// questo errore lo incontra chi aggiorna, non chi sviluppa.
|
||||
Assert.Contains("moon-phase", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("trend-filter", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("Impostazioni", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -161,6 +161,32 @@ public class ConfigPathWriterTests : IDisposable
|
||||
|
||||
Assert.Equal(before, File.ReadAllText(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoStessoLottoSiPuoApplicareDueVolte()
|
||||
{
|
||||
// La pagina delle impostazioni lo fa sempre: una volta su una copia temporanea
|
||||
// per validare, una sul file vero. Senza clonare, la seconda falliva con
|
||||
// «The node already has a parent» — un JsonNode appartiene a un albero solo.
|
||||
string primo = Write(Sample);
|
||||
string secondo = Write(Sample);
|
||||
|
||||
Dictionary<string, JsonNode?> modifiche = new()
|
||||
{
|
||||
["symbols[0].strategy"] = JsonValue.Create("trend-filter"),
|
||||
["risk.stakePct"] = JsonValue.Create(0.5),
|
||||
};
|
||||
|
||||
ConfigWriter.Apply(primo, modifiche);
|
||||
ConfigWriter.Apply(secondo, modifiche);
|
||||
|
||||
foreach (string percorso in new[] { primo, secondo })
|
||||
{
|
||||
BotConfig c = ConfigLoader.Load(percorso, out _);
|
||||
Assert.Equal("trend-filter", c.Symbols[0].Strategy);
|
||||
Assert.Equal(0.5, c.Risk.StakePct, 9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Ui;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Chiudere una finestra mentre uno spegnimento asincrono è in corso.
|
||||
/// <para>
|
||||
/// Il difetto: il gestore annullava la chiusura, aspettava lo spegnimento e poi
|
||||
/// richiamava <c>Close()</c>. Un secondo clic sulla X durante l'attesa usciva dal
|
||||
/// gestore <b>senza</b> annullare, la finestra entrava nella propria sequenza di
|
||||
/// chiusura, e la <c>Close()</c> del primo tentativo ci finiva dentro:
|
||||
/// <c>«Non è possibile […] chiamare Close durante la chiusura di un oggetto Window»</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("wpf")]
|
||||
public class WindowShutdownTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Il meccanismo esatto del difetto: <c>Close()</c> chiamata mentre la finestra è
|
||||
/// dentro la propria sequenza di chiusura non è ammessa, e non lo è nemmeno dopo
|
||||
/// aver annullato — l'annullamento vale per l'uscita dal gestore, non per il tempo
|
||||
/// in cui il gestore sta girando.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChiudereDentroIlGestoreDiChiusuraSollevaEccezione()
|
||||
{
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
Window finestra = new();
|
||||
Exception? errore = null;
|
||||
|
||||
finestra.Closing += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
|
||||
try
|
||||
{
|
||||
// È qui che finiva la Close() del primo tentativo quando un secondo
|
||||
// clic la faceva riprendere troppo presto.
|
||||
finestra.Close();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errore = ex;
|
||||
}
|
||||
};
|
||||
|
||||
finestra.Close();
|
||||
|
||||
Assert.NotNull(errore);
|
||||
Assert.Contains("Close", errore!.Message, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La correzione: la chiusura vera si rimanda a un frame nuovo del dispatcher, così
|
||||
/// non può mai eseguire dentro il gestore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RimandarlaAUnFrameNuovoNonSollevaNiente()
|
||||
{
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
Window finestra = new();
|
||||
Exception? errore = null;
|
||||
bool chiusa = false;
|
||||
|
||||
finestra.Closing += (_, e) =>
|
||||
{
|
||||
if (chiusa)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = true;
|
||||
|
||||
_ = finestra.Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
chiusa = true;
|
||||
finestra.Close();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errore = ex;
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
finestra.Close();
|
||||
|
||||
// Fa girare la coda del dispatcher fino a quando la chiusura rimandata è
|
||||
// stata eseguita.
|
||||
finestra.Dispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
|
||||
|
||||
Assert.Null(errore);
|
||||
Assert.True(chiusa);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La distinzione che serve fra i due tipi di chiusura: quelle dell'utente durante
|
||||
/// lo spegnimento vanno annullate, quella finale no.
|
||||
/// <para>
|
||||
/// Annullarle tutte è il modo ovvio di correggere il difetto originale, ed è
|
||||
/// sbagliato: la chiusura finale ripassa dallo stesso gestore, viene annullata
|
||||
/// anche lei, e la finestra non si chiude più. Il bot resta aperto per sempre —
|
||||
/// un difetto peggiore di quello che si voleva correggere.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LaChiusuraFinalePassaMentreQuelleDellUtenteNo()
|
||||
{
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
Window finestra = new();
|
||||
bool inChiusura = false;
|
||||
bool finale = false;
|
||||
int annullate = 0;
|
||||
|
||||
finestra.Closing += (_, e) =>
|
||||
{
|
||||
if (inChiusura)
|
||||
{
|
||||
if (!finale)
|
||||
{
|
||||
annullate++;
|
||||
e.Cancel = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
inChiusura = true;
|
||||
e.Cancel = true;
|
||||
};
|
||||
|
||||
finestra.Close(); // primo tentativo: prende in carico
|
||||
finestra.Close(); // l'utente insiste
|
||||
finestra.Close(); // e ancora
|
||||
Assert.Equal(2, annullate);
|
||||
|
||||
finale = true;
|
||||
finestra.Close(); // la chiusura finale dello spegnimento
|
||||
|
||||
Assert.Equal(2, annullate);
|
||||
Assert.False(finestra.IsVisible);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnaChiusuraAnnullataLasciaLaFinestraUtilizzabile()
|
||||
{
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
Window finestra = new();
|
||||
int tentativi = 0;
|
||||
|
||||
finestra.Closing += (_, e) =>
|
||||
{
|
||||
tentativi++;
|
||||
e.Cancel = true;
|
||||
};
|
||||
|
||||
finestra.Close();
|
||||
finestra.Close();
|
||||
finestra.Close();
|
||||
|
||||
Assert.Equal(3, tentativi);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Una strategia rimasta in configurazione dopo che un aggiornamento l'ha rimossa.
|
||||
/// <para>
|
||||
/// Capita perché l'installazione conserva l'<c>encelado.json</c> dell'utente — che è
|
||||
/// giusto, le tarature sono sue — quindi un nome tolto dal programma sopravvive nel
|
||||
/// file. Deve essere una cosa che si vede e si corregge, non un vicolo cieco.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class StrategiaObsoletaTests
|
||||
{
|
||||
private static BotConfig ConfigCon(string strategia)
|
||||
{
|
||||
BotConfig c = new();
|
||||
|
||||
// Validate() controlla le credenziali per prime: senza, il test si fermerebbe
|
||||
// lì invece di arrivare al controllo sulla strategia.
|
||||
c.Alpaca.KeyId = "PKTESTTESTTESTTESTTE";
|
||||
c.Alpaca.SecretKey = "segretosegretosegretosegretosegretosegre";
|
||||
|
||||
c.Symbols.Add(new SymbolConfig
|
||||
{
|
||||
Symbol = "BTC/USD",
|
||||
Strategy = strategia,
|
||||
Enabled = true,
|
||||
Parameters = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["period"] = 100,
|
||||
["band"] = 0.02,
|
||||
},
|
||||
});
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
private static SettingField CampoStrategia(BotConfig config) =>
|
||||
SettingsCatalogue.Build(config, config.Symbols[0].Strategy)
|
||||
.SelectMany(static g => g.Fields)
|
||||
.First(static f => f.Path == "symbols[0].strategy");
|
||||
|
||||
[Fact]
|
||||
public void IlCampoStrategiaSiSceglieDaUnElenco()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
|
||||
|
||||
Assert.True(campo.IsList, "la strategia deve essere un elenco, non un testo libero");
|
||||
Assert.False(campo.IsFreeText);
|
||||
Assert.Equal(StrategyFactory.Available, campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LElencoProponeSoloCioCheIlProgrammaSaCostruire()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
|
||||
|
||||
Assert.All(campo.Options, static nome =>
|
||||
Assert.True(StrategyFactory.IsKnown(nome), $"'{nome}' è nell'elenco ma non è costruibile"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IlCampoStrategiaEModificabile()
|
||||
{
|
||||
// Bloccarlo perché la strategia è una sola è esattamente ciò che rendeva
|
||||
// impossibile correggere un valore obsoleto senza aprire il JSON.
|
||||
SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
|
||||
|
||||
Assert.False(campo.IsReadOnly);
|
||||
Assert.True(campo.IsEditable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnaStrategiaRimossaSiSegnalaDaSolaAllApertura()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
|
||||
|
||||
Assert.True(campo.HasError, "il campo doveva segnalare l'errore senza essere toccato");
|
||||
Assert.True(campo.IsObsolete);
|
||||
Assert.Contains("adaptive-regime", campo.Error!, StringComparison.Ordinal);
|
||||
Assert.Contains("trend-filter", campo.Error!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LElencoNonRipropoleUnaStrategiaRimossa()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
|
||||
|
||||
Assert.DoesNotContain("adaptive-regime", campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SceglierneUnaValidaRisolveEDiventaSalvabile()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
|
||||
Assert.True(campo.HasError);
|
||||
|
||||
campo.Value = StrategyFactory.Default;
|
||||
|
||||
Assert.False(campo.HasError);
|
||||
Assert.False(campo.IsObsolete);
|
||||
Assert.True(campo.IsDirty, "la scelta va salvata, quindi deve risultare modificata");
|
||||
Assert.Equal(StrategyFactory.Default, campo.ToJson()!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaConfigurazioneSpiegaComeUscirneInveceDiDireSoloCheEInvalida()
|
||||
{
|
||||
InvalidOperationException ex =
|
||||
Assert.Throws<InvalidOperationException>(() => ConfigCon("adaptive-regime").Validate());
|
||||
|
||||
Assert.Contains("adaptive-regime", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("Impostazioni", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnaConfigurazioneValidaNonSiLamenta()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon(StrategyFactory.Default));
|
||||
|
||||
Assert.False(campo.HasError);
|
||||
Assert.False(campo.IsObsolete);
|
||||
Assert.False(campo.IsDirty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>I campi con un insieme chiuso di valori non devono essere scrivibili.</summary>
|
||||
public class CampiAScelaTests
|
||||
{
|
||||
private static IReadOnlyList<SettingField> Campi()
|
||||
{
|
||||
BotConfig c = new();
|
||||
c.Symbols.Add(new SymbolConfig { Symbol = "BTC/USD", Strategy = "trend-filter", Enabled = true });
|
||||
return [.. SettingsCatalogue.Build(c, "trend-filter").SelectMany(static g => g.Fields)];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("symbols[0].strategy")]
|
||||
[InlineData("engine.timeFrame")]
|
||||
[InlineData("logging.level")]
|
||||
[InlineData("engine.entryOrderType")]
|
||||
public void SiCompilanoDaUnElenco(string percorso)
|
||||
{
|
||||
SettingField campo = Campi().First(f => f.Path == percorso);
|
||||
|
||||
Assert.True(campo.IsList, $"{percorso} deve essere un elenco");
|
||||
Assert.NotEmpty(campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AncheIBooleaniSonoUnElenco()
|
||||
{
|
||||
// "sì" e "no" scritti a mano sono due modi per sbagliare.
|
||||
SettingField campo = Campi().First(static f => f.Path == "engine.dryRun");
|
||||
|
||||
Assert.True(campo.IsList);
|
||||
Assert.Equal(["sì", "no"], campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void INumeriRestanoDaScrivere()
|
||||
{
|
||||
SettingField campo = Campi().First(static f => f.Path == "risk.stakePct");
|
||||
|
||||
Assert.True(campo.IsFreeText);
|
||||
Assert.Empty(campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OgniValoreInizialeDiUnElencoEFraLeOpzioni()
|
||||
{
|
||||
// Con la configurazione di fabbrica nessun campo a scelta deve partire in
|
||||
// errore: se succede, catalogo e valori consegnati sono fuori sincrono.
|
||||
foreach (SettingField campo in Campi().Where(static f => f.IsList && !f.IsReadOnly))
|
||||
{
|
||||
Assert.False(campo.IsObsolete,
|
||||
$"{campo.Path} vale '{campo.Value}' che non è fra {string.Join(", ", campo.Options)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user