Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9453e20cfc | ||
|
|
e080a0e867 |
@@ -13,7 +13,12 @@
|
|||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
<Product>Encelado</Product>
|
<Product>Encelado</Product>
|
||||||
<Company>Encelado</Company>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
Cose da fare:
|
Altre 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)
|
|
||||||
|
|
||||||
+20
-14
@@ -531,16 +531,29 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Setup>$(CartellaPacchetti)\Encelado_$(V).exe</Setup>
|
<Setup>$(CartellaPacchetti)\Encelado_$(V).exe</Setup>
|
||||||
|
<Portabile>$(CartellaPacchetti)\Encelado_$(V)_portabile.zip</Portabile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<Error Condition="!Exists('$(Setup)')" Text="Installatore non trovato: $(Setup)" />
|
<Error Condition="!Exists('$(Setup)')" Text="Installatore non trovato: $(Setup)" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
La copia portabile per chi non vuole installare niente.
|
||||||
|
|
||||||
|
In AutoBidder è il solo .exe, perché lì la pubblicazione è un file unico.
|
||||||
|
Qui l'applicazione ha bisogno di encelado.json accanto a sé, quindi è uno
|
||||||
|
zip della cartella. Si crea qui e non nel rilascio: entrambi i pacchetti
|
||||||
|
devono esistere anche costruendo senza pubblicare su Gitea.
|
||||||
|
-->
|
||||||
|
<Delete Files="$(Portabile)" ContinueOnError="true" />
|
||||||
|
<ZipDirectory SourceDirectory="$(CartellaPubblicazione)" DestinationFile="$(Portabile)" />
|
||||||
|
|
||||||
<!-- Adesso: il pacchetto c'e', il tag puo' esistere. -->
|
<!-- Adesso: il pacchetto c'e', il tag puo' esistere. -->
|
||||||
<CallTarget Targets="CreaTag" />
|
<CallTarget Targets="CreaTag" />
|
||||||
|
|
||||||
<Message Importance="High" Text=" " />
|
<Message Importance="High" Text=" " />
|
||||||
<Message Importance="High" Text="Pacchetto pronto:" />
|
<Message Importance="High" Text="Pacchetto pronto:" />
|
||||||
<Message Importance="High" Text=" $(Setup)" />
|
<Message Importance="High" Text=" $(Setup)" />
|
||||||
|
<Message Importance="High" Text=" $(Portabile)" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<!-- ═════════════════════ Versione e tag ═════════════════════ -->
|
<!-- ═════════════════════ Versione e tag ═════════════════════ -->
|
||||||
@@ -676,24 +689,17 @@
|
|||||||
<Message Importance="High" Text=" release creata" />
|
<Message Importance="High" Text=" release creata" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Allegati: l'installatore e una copia portabile. Chi non vuole installare
|
Allegati: l'installatore e la copia portabile, entrambi già costruiti da
|
||||||
niente deve continuare a poter scaricare l'applicazione e basta.
|
Pacchetto. Nient'altro — in particolare nessun sorgente: si pubblica il
|
||||||
|
programma, non il progetto.
|
||||||
|
|
||||||
In AutoBidder la copia portabile è il solo .exe, perché lì la
|
Gli archivi "Source code" che Gitea mostra da sé sulla pagina della release
|
||||||
pubblicazione è un file unico. Qui è una cartella — l'applicazione ha
|
non arrivano da qui: li genera il server dal tag, e si tolgono solo dalla
|
||||||
bisogno di encelado.json accanto a sé — quindi la copia portabile è uno
|
sua configurazione (DISABLE_DOWNLOAD_SOURCE_ARCHIVES in app.ini).
|
||||||
zip di quella cartella.
|
|
||||||
-->
|
-->
|
||||||
<PropertyGroup>
|
|
||||||
<Portabile>$(CartellaPacchetti)\Encelado_$(V)_portabile.zip</Portabile>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<Delete Files="$(Portabile)" ContinueOnError="true" />
|
|
||||||
<ZipDirectory SourceDirectory="$(CartellaPubblicazione)" DestinationFile="$(Portabile)" />
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Allegato Include="$(Setup)" />
|
<Allegato Include="$(Setup)" />
|
||||||
<Allegato Include="$(Portabile)" />
|
<Allegato Include="$(CartellaPacchetti)\Encelado_$(V)_portabile.zip" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Exec Condition="Exists('%(Allegato.FullPath)')" EchoOff="true" StandardOutputImportance="low"
|
<Exec Condition="Exists('%(Allegato.FullPath)')" EchoOff="true" StandardOutputImportance="low"
|
||||||
|
|||||||
@@ -64,11 +64,18 @@ public sealed class BotConfig
|
|||||||
throw new InvalidOperationException($"Symbol '{s.Symbol}' is configured more than once.");
|
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))
|
if (!StrategyFactory.IsKnown(s.Strategy))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Symbol '{s.Symbol}' uses unknown strategy '{s.Strategy}'. " +
|
$"La strategia '{s.Strategy}' configurata su {s.Symbol} non esiste più.\n\n" +
|
||||||
$"Available: {string.Join(", ", StrategyFactory.Available)}.");
|
$"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));
|
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;
|
JsonNode current = root;
|
||||||
|
|
||||||
for (int i = 0; i < segments.Length - 1; i++)
|
for (int i = 0; i < segments.Length - 1; i++)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using Encelado.Bot.Engine;
|
|||||||
using Encelado.Bot.Logging;
|
using Encelado.Bot.Logging;
|
||||||
using Encelado.Bot.Ui;
|
using Encelado.Bot.Ui;
|
||||||
using Encelado.Bot.Ui.Pages;
|
using Encelado.Bot.Ui.Pages;
|
||||||
|
using Encelado.Core.Strategies;
|
||||||
|
|
||||||
namespace Encelado.Bot;
|
namespace Encelado.Bot;
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ public partial class MainWindow : Window, IUiActions
|
|||||||
|
|
||||||
private bool _busy;
|
private bool _busy;
|
||||||
private bool _closing;
|
private bool _closing;
|
||||||
|
private bool _closed;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,54 @@ public partial class MainWindow : Window, IUiActions
|
|||||||
}
|
}
|
||||||
|
|
||||||
RefreshSettings();
|
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()
|
private void LoadLogo()
|
||||||
@@ -536,10 +586,32 @@ public partial class MainWindow : Window, IUiActions
|
|||||||
// Shutdown
|
// 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)
|
private async void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||||
{
|
{
|
||||||
if (_closing)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,8 +629,6 @@ public partial class MainWindow : Window, IUiActions
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stopping the engine is asynchronous, so cancel this close and re-issue it
|
|
||||||
// once the shutdown has actually finished.
|
|
||||||
e.Cancel = true;
|
e.Cancel = true;
|
||||||
_closing = true;
|
_closing = true;
|
||||||
_timer.Stop();
|
_timer.Stop();
|
||||||
@@ -569,8 +639,31 @@ public partial class MainWindow : Window, IUiActions
|
|||||||
window.Close();
|
window.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _supervisor.DisposeAsync().ConfigureAwait(true);
|
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();
|
Close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,17 @@
|
|||||||
ToolTip="{Binding FullTooltip}"/>
|
ToolTip="{Binding FullTooltip}"/>
|
||||||
|
|
||||||
<Grid Grid.Column="2">
|
<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}"
|
<TextBox Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
|
||||||
IsReadOnly="{Binding IsReadOnly}"
|
IsReadOnly="{Binding IsReadOnly}"
|
||||||
ToolTip="{Binding FullTooltip}">
|
ToolTip="{Binding FullTooltip}"
|
||||||
|
Visibility="{Binding IsFreeText, Converter={StaticResource BoolVis}}">
|
||||||
<TextBox.Style>
|
<TextBox.Style>
|
||||||
<Style TargetType="TextBox" BasedOn="{StaticResource {x:Type TextBox}}">
|
<Style TargetType="TextBox" BasedOn="{StaticResource {x:Type TextBox}}">
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
@@ -50,6 +58,25 @@
|
|||||||
</TextBox.Style>
|
</TextBox.Style>
|
||||||
</TextBox>
|
</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. -->
|
<!-- Lucchetto sui campi fissati dalla strategia. -->
|
||||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="11"
|
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="11"
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
|
HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
|
||||||
|
|||||||
@@ -64,6 +64,35 @@ public sealed class SettingField : INotifyPropertyChanged
|
|||||||
|
|
||||||
public IReadOnlyList<string> Choices { get; init; } = [];
|
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>
|
/// <summary>Unit suffix shown after the box, e.g. "%" or "secondi".</summary>
|
||||||
public string Suffix { get; init; } = string.Empty;
|
public string Suffix { get; init; } = string.Empty;
|
||||||
|
|
||||||
@@ -87,6 +116,7 @@ public sealed class SettingField : INotifyPropertyChanged
|
|||||||
_value = value;
|
_value = value;
|
||||||
Raise();
|
Raise();
|
||||||
Raise(nameof(IsDirty));
|
Raise(nameof(IsDirty));
|
||||||
|
Raise(nameof(IsObsolete));
|
||||||
Validate();
|
Validate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,9 +151,13 @@ public sealed class SettingField : INotifyPropertyChanged
|
|||||||
{
|
{
|
||||||
_value = value;
|
_value = value;
|
||||||
Original = value;
|
Original = value;
|
||||||
Error = null;
|
|
||||||
Raise(nameof(Value));
|
Raise(nameof(Value));
|
||||||
Raise(nameof(IsDirty));
|
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);
|
public void Revert() => Load(Original);
|
||||||
@@ -176,7 +210,7 @@ public sealed class SettingField : INotifyPropertyChanged
|
|||||||
case SettingKind.Choice:
|
case SettingKind.Choice:
|
||||||
if (Choices.Count > 0 && !Choices.Contains(_value, StringComparer.OrdinalIgnoreCase))
|
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;
|
break;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Encelado.Bot.Configuration;
|
using Encelado.Bot.Configuration;
|
||||||
|
using Encelado.Core.Strategies;
|
||||||
|
|
||||||
namespace Encelado.Bot.Ui;
|
namespace Encelado.Bot.Ui;
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ public static class SettingsCatalogue
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(config);
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
|
||||||
return
|
SettingGroup[] gruppi =
|
||||||
[
|
[
|
||||||
Strategy(config, strategyName),
|
Strategy(config, strategyName),
|
||||||
Sizing(config),
|
Sizing(config),
|
||||||
@@ -30,6 +31,17 @@ public static class SettingsCatalogue
|
|||||||
Engine(config),
|
Engine(config),
|
||||||
Logging(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.",
|
"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
|
g.Fields.Add(new SettingField
|
||||||
{
|
{
|
||||||
Path = "symbols[0].strategy",
|
Path = "symbols[0].strategy",
|
||||||
Label = "Strategia",
|
Label = "Strategia",
|
||||||
Initial = strategyName,
|
Initial = strategyName,
|
||||||
Kind = SettingKind.Text,
|
Kind = SettingKind.Choice,
|
||||||
IsReadOnly = true,
|
Choices = StrategyFactory.Available,
|
||||||
ReadOnlyReason = "ne esiste una sola. Le altre sono state cancellate, non disattivate.",
|
|
||||||
Tooltip =
|
Tooltip =
|
||||||
"Il modello che genera i segnali.\n\n" +
|
"Il modello che genera i segnali.\n\n" +
|
||||||
"'trend-filter' compra quando il prezzo sta sopra la media a 100 giorni di una " +
|
"'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: " +
|
"certa percentuale, e vende quando scende sotto della stessa percentuale. Nient'altro: " +
|
||||||
"nessun trailing stop, nessun target, nessun filtro di volatilità.\n\n" +
|
"nessun trailing stop, nessun target, nessun filtro di volatilità.\n\n" +
|
||||||
"Sette modelli più elaborati sono stati scritti e misurati prima di questo. Tutti " +
|
"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
|
g.Fields.Add(new SettingField
|
||||||
|
|||||||
@@ -223,9 +223,13 @@
|
|||||||
RelativeSource={RelativeSource TemplatedParent}}">
|
RelativeSource={RelativeSource TemplatedParent}}">
|
||||||
<ToggleButton.Template>
|
<ToggleButton.Template>
|
||||||
<ControlTemplate TargetType="ToggleButton">
|
<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}"
|
<Border x:Name="bg" Background="{StaticResource Panel2}"
|
||||||
BorderBrush="{StaticResource Line}" BorderThickness="1"
|
BorderBrush="{Binding BorderBrush,
|
||||||
CornerRadius="7">
|
RelativeSource={RelativeSource AncestorType=ComboBox}}"
|
||||||
|
BorderThickness="1" CornerRadius="7">
|
||||||
<Path x:Name="arrow" HorizontalAlignment="Right" VerticalAlignment="Center"
|
<Path x:Name="arrow" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
Margin="0,0,10,0" Data="M 0 0 L 4 4 L 8 0"
|
Margin="0,0,10,0" Data="M 0 0 L 4 4 L 8 0"
|
||||||
Stroke="{StaticResource Dim}" StrokeThickness="1.4"/>
|
Stroke="{StaticResource Dim}" StrokeThickness="1.4"/>
|
||||||
|
|||||||
@@ -188,7 +188,12 @@ public class ConfigValidationTests
|
|||||||
config.Symbols[0].Strategy = "moon-phase";
|
config.Symbols[0].Strategy = "moon-phase";
|
||||||
|
|
||||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -161,6 +161,32 @@ public class ConfigPathWriterTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal(before, File.ReadAllText(path));
|
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>
|
/// <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