mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-03 14:07:21 +00:00
Added progress messages and quick access to exported files (#1112)
This commit is contained in:
1 parent
7f72da628e
commit
e0d8e18f34
33 files changed
+1130
-23
No files matched your search
@@ -0,0 +1,28 @@
|
||||
using AIStudio.Components;
|
||||
|
||||
namespace AIStudio.Tests.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the time below the progress bar of an assistant is written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Minutes and seconds suffice for almost every run; the hours come in front only once there are any,
|
||||
/// and they keep counting past a day instead of starting over.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ElapsedTimeTests
|
||||
{
|
||||
[TestCase(0, ExpectedResult = "0:00")]
|
||||
[TestCase(9, ExpectedResult = "0:09")]
|
||||
[TestCase(61, ExpectedResult = "1:01")]
|
||||
[TestCase(59 * 60 + 59, ExpectedResult = "59:59")]
|
||||
[TestCase(60 * 60, ExpectedResult = "1:00:00")]
|
||||
[TestCase(25 * 60 * 60 + 3 * 60 + 4, ExpectedResult = "25:03:04")]
|
||||
public string ATimeSpanIsWrittenAsMinutesAndSecondsWithHoursWhereThereAreAny(int seconds) => ElapsedTime.Format(TimeSpan.FromSeconds(seconds));
|
||||
|
||||
[Test]
|
||||
public void ANegativeTimeSpanCountsAsNone() => Assert.That(ElapsedTime.Format(TimeSpan.FromSeconds(-5)), Is.EqualTo("0:00"), "A clock which was set back must not show a negative time.");
|
||||
|
||||
[Test]
|
||||
public void FractionsOfASecondAreCutOff() => Assert.That(ElapsedTime.Format(TimeSpan.FromMilliseconds(1_999)), Is.EqualTo("0:01"));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using AIStudio.Tools;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which notifications a file export sends, and in which order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fast export shows its success right away; a slow one first shows that it runs. That notification
|
||||
/// waits for the user and must therefore never come after the end of the export: it would stay until
|
||||
/// the user closes it, claiming an export which is long over.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class FileExportFeedbackTests
|
||||
{
|
||||
private const string FILE_PATH = "/home/user/Documents/report.docx";
|
||||
private const string ICON = "icon";
|
||||
private const string MESSAGE = "The export succeeded.";
|
||||
|
||||
[Test]
|
||||
public async Task AFastExportShowsNoRunningNotification()
|
||||
{
|
||||
var sent = new SentNotifications();
|
||||
await using (var feedback = new FileExportFeedback(sent.Send, token => Task.Delay(Timeout.Infinite, token)))
|
||||
{
|
||||
feedback.ShowRunning(FILE_PATH);
|
||||
await feedback.SucceededAsync(ICON, MESSAGE, FILE_PATH, true);
|
||||
}
|
||||
|
||||
Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_SUCCESS }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ASlowExportShowsThatItRunsAndThenItsSuccessUnderTheSameKey()
|
||||
{
|
||||
var sent = new SentNotifications();
|
||||
await using (var feedback = new FileExportFeedback(sent.Send, _ => Task.CompletedTask))
|
||||
{
|
||||
feedback.ShowRunning(FILE_PATH);
|
||||
await sent.RunningShown;
|
||||
await feedback.SucceededAsync(ICON, MESSAGE, FILE_PATH, true);
|
||||
}
|
||||
|
||||
Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_RUNNING, Event.SHOW_FILE_EXPORT_SUCCESS }));
|
||||
var running = (DataFileExportRunningMessage)sent.Data[0];
|
||||
var success = (DataFileExportSuccessMessage)sent.Data[1];
|
||||
Assert.That(success.Key, Is.EqualTo(running.Key), "The success has to replace the notification that the export runs.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AFailedExportRemovesItsRunningNotification()
|
||||
{
|
||||
var sent = new SentNotifications();
|
||||
await using (var feedback = new FileExportFeedback(sent.Send, _ => Task.CompletedTask))
|
||||
{
|
||||
feedback.ShowRunning(FILE_PATH);
|
||||
await sent.RunningShown;
|
||||
}
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_RUNNING, Event.HIDE_FILE_EXPORT_RUNNING }));
|
||||
Assert.That(((DataFileExportRunningMessage)sent.Data[1]).Key, Is.EqualTo(((DataFileExportRunningMessage)sent.Data[0]).Key));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnExceptionRemovesTheRunningNotification()
|
||||
{
|
||||
var sent = new SentNotifications();
|
||||
Assert.ThrowsAsync<IOException>(async () =>
|
||||
{
|
||||
await using var feedback = new FileExportFeedback(sent.Send, _ => Task.CompletedTask);
|
||||
feedback.ShowRunning(FILE_PATH);
|
||||
await sent.RunningShown;
|
||||
throw new IOException("The target file is locked.");
|
||||
});
|
||||
|
||||
Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_RUNNING, Event.HIDE_FILE_EXPORT_RUNNING }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AFastFailedExportSendsNothing()
|
||||
{
|
||||
var sent = new SentNotifications();
|
||||
await using (var feedback = new FileExportFeedback(sent.Send, token => Task.Delay(Timeout.Infinite, token)))
|
||||
feedback.ShowRunning(FILE_PATH);
|
||||
|
||||
Assert.That(sent.Events, Is.Empty, "The error message of the caller says everything.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheRunningNotificationNeverComesAfterTheEnd()
|
||||
{
|
||||
//
|
||||
// The notification that the export runs becomes due while the end of the export is being sent.
|
||||
// The wait ignores its cancellation, as a timer would which fires at that very moment:
|
||||
//
|
||||
var due = new TaskCompletionSource();
|
||||
var sendingSuccess = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseSuccess = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var sent = new SentNotifications(async () =>
|
||||
{
|
||||
sendingSuccess.SetResult();
|
||||
await releaseSuccess.Task;
|
||||
});
|
||||
|
||||
await using (var feedback = new FileExportFeedback(sent.Send, _ => due.Task))
|
||||
{
|
||||
feedback.ShowRunning(FILE_PATH);
|
||||
var ending = feedback.SucceededAsync(ICON, MESSAGE, FILE_PATH, true);
|
||||
|
||||
await sendingSuccess.Task;
|
||||
due.SetResult();
|
||||
releaseSuccess.SetResult();
|
||||
await ending;
|
||||
}
|
||||
|
||||
// Disposing waits for the delayed notification, so whatever it would send is in by now:
|
||||
Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_SUCCESS }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records what a FileExportFeedback sends, in place of the message bus.
|
||||
/// </summary>
|
||||
/// <param name="whileSendingSuccess">Runs while the success is being sent, before it counts as sent.</param>
|
||||
private sealed class SentNotifications(Func<Task>? whileSendingSuccess = null)
|
||||
{
|
||||
private readonly TaskCompletionSource runningShown = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public List<Event> Events { get; } = [];
|
||||
|
||||
public List<object> Data { get; } = [];
|
||||
|
||||
public Task RunningShown => this.runningShown.Task;
|
||||
|
||||
public async Task Send(Event triggeredEvent, object data)
|
||||
{
|
||||
if (triggeredEvent is Event.SHOW_FILE_EXPORT_SUCCESS && whileSendingSuccess is not null)
|
||||
await whileSendingSuccess();
|
||||
|
||||
this.Events.Add(triggeredEvent);
|
||||
this.Data.Add(data);
|
||||
if (triggeredEvent is Event.SHOW_FILE_EXPORT_RUNNING)
|
||||
this.runningShown.TrySetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using MudBlazor;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the notification that a file export runs never appears in a window after the export ended.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The notifications of an export are sent in order, but they may take effect out of it: the one that
|
||||
/// the export runs comes from a timer and waits in the queue of the renderer, while the end usually
|
||||
/// comes from the renderer itself and takes effect at once.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class FileExportNotificationsTests
|
||||
{
|
||||
private const string KEY = "export";
|
||||
private const string FILE_PATH = "/home/user/Documents/report.docx";
|
||||
|
||||
private static readonly DataFileExportRunningMessage RUNNING = new(KEY, FILE_PATH);
|
||||
private static readonly DataFileExportSuccessMessage SUCCESS = new(KEY, "icon", "The export succeeded.", FILE_PATH, true);
|
||||
|
||||
[Test]
|
||||
public void TheSuccessReplacesTheRunningNotification()
|
||||
{
|
||||
var snackbar = new RecordingSnackbar();
|
||||
var notifications = new FileExportNotifications(snackbar);
|
||||
|
||||
notifications.ShowRunning(RUNNING);
|
||||
notifications.ShowSuccess(SUCCESS, false);
|
||||
|
||||
Assert.That(snackbar.Calls, Is.EqualTo(new[] { $"add FileExportRunningMessage {KEY}", $"remove {KEY}", $"add FileExportSuccessMessage {KEY}" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARunningNotificationWhichComesAfterTheSuccessIsDropped()
|
||||
{
|
||||
var snackbar = new RecordingSnackbar();
|
||||
var notifications = new FileExportNotifications(snackbar);
|
||||
|
||||
notifications.ShowSuccess(SUCCESS, false);
|
||||
notifications.ShowRunning(RUNNING);
|
||||
|
||||
Assert.That(snackbar.Calls, Is.EqualTo(new[] { $"remove {KEY}", $"add FileExportSuccessMessage {KEY}" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARunningNotificationWhichComesAfterItsRemovalIsDropped()
|
||||
{
|
||||
var snackbar = new RecordingSnackbar();
|
||||
var notifications = new FileExportNotifications(snackbar);
|
||||
|
||||
notifications.HideRunning(RUNNING);
|
||||
notifications.ShowRunning(RUNNING);
|
||||
|
||||
Assert.That(snackbar.Calls, Is.EqualTo(new[] { $"remove {KEY}" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheEndOfOneExportDoesNotSilenceAnother()
|
||||
{
|
||||
var snackbar = new RecordingSnackbar();
|
||||
var notifications = new FileExportNotifications(snackbar);
|
||||
|
||||
notifications.ShowSuccess(SUCCESS, false);
|
||||
notifications.ShowRunning(RUNNING with { Key = "another export" });
|
||||
|
||||
Assert.That(snackbar.Calls, Does.Contain("add FileExportRunningMessage another export"));
|
||||
}
|
||||
|
||||
[TestCase(false, ExpectedResult = true)]
|
||||
[TestCase(true, ExpectedResult = false)]
|
||||
public bool TheSuccessWaitsForTheUserUnlessTheyChoseOtherwise(bool closesAutomatically)
|
||||
{
|
||||
var snackbar = new RecordingSnackbar();
|
||||
new FileExportNotifications(snackbar).ShowSuccess(SUCCESS, closesAutomatically);
|
||||
|
||||
return snackbar.RequiresInteraction.Single();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records which notifications are added and removed, by their key, and whether a component
|
||||
/// notification waits for the user.
|
||||
/// </summary>
|
||||
private sealed class RecordingSnackbar : ISnackbar
|
||||
{
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public List<bool> RequiresInteraction { get; } = [];
|
||||
|
||||
public SnackbarConfiguration Configuration { get; } = new();
|
||||
|
||||
public IEnumerable<Snackbar> ShownSnackbars => [];
|
||||
|
||||
public event Action? OnSnackbarsUpdated
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public Snackbar? Add(string message, Severity severity = Severity.Normal, Action<SnackbarOptions>? configure = null, string? key = null) => this.Record("string", key);
|
||||
|
||||
public Snackbar? Add(MarkupString message, Severity severity = Severity.Normal, Action<SnackbarOptions>? configure = null, string? key = null) => this.Record(nameof(MarkupString), key);
|
||||
|
||||
public Snackbar? Add(RenderFragment message, Severity severity = Severity.Normal, Action<SnackbarOptions>? configure = null, string? key = null) => this.Record(nameof(RenderFragment), key);
|
||||
|
||||
public Snackbar? Add<T>(Dictionary<string, object>? componentParameters = null, Severity severity = Severity.Normal, Action<SnackbarOptions>? configure = null, string? key = null) where T : IComponent
|
||||
{
|
||||
var options = new SnackbarOptions(severity, this.Configuration);
|
||||
configure?.Invoke(options);
|
||||
this.RequiresInteraction.Add(options.RequireInteraction);
|
||||
|
||||
return this.Record(typeof(T).Name, key);
|
||||
}
|
||||
|
||||
public void Clear() => this.Calls.Add("clear");
|
||||
|
||||
public void Remove(Snackbar snackbar) => this.Calls.Add("remove one");
|
||||
|
||||
public void RemoveByKey(string key) => this.Calls.Add($"remove {key}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private Snackbar? Record(string content, string? key)
|
||||
{
|
||||
this.Calls.Add($"add {content} {key}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user