Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PanoramicData.Blazor.Test/Components/PDChatTests.Agents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public void The_agent_picker_is_hidden_with_fewer_than_two_agents(int count)
var component = RenderChat(new FakeChatService { Agents = [.. new[] { _merlin, _alice }.Take(count)] });

component.FindAll(".pdchat-agent-picker").Should().BeEmpty();
component.FindAll(".chat-input-accessories").Should().BeEmpty("there is nothing else to show there");
component.Find(".chat-input-accessories").QuerySelectorAll("select").Should().BeEmpty();
}

/// <summary>Verifies that no picker is shown when the service does not list agents at all.</summary>
Expand All @@ -38,7 +38,7 @@ public void The_agent_picker_is_shown_in_the_input_area_with_two_agents()
{
var component = RenderChat(new FakeChatService { Agents = [_merlin, _alice] });

var select = component.Find(".chat-input-container .chat-input-accessories .pdchat-agent-picker select");
var select = component.Find(".chat-input-accessories .pdchat-agent-picker select");
select.GetAttribute("title").Should().Be("Who you are talking to");
var options = select.QuerySelectorAll("option");
options.Select(option => option.TextContent).Should().Equal("Merlin", "Alice");
Expand Down
1 change: 1 addition & 0 deletions PanoramicData.Blazor.Test/Components/PDChatTests.Fakes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ private sealed partial class FakeChatService : IChatService
public PDChatButtonPosition ToastAnchor { get; set; } = PDChatButtonPosition.BottomRight;
public PDChatVoiceEndpoints? VoiceEndpoints { get; set; }
public IReadOnlyList<PDChatAgentOption>? Agents { get; set; }
public IReadOnlyList<PDChatModelOption>? Models { get; set; }

public IReadOnlyList<ChatMessage> Messages => Store;
public bool SupportsConversations { get; init; }
Expand Down
75 changes: 75 additions & 0 deletions PanoramicData.Blazor.Test/Components/PDChatTests.Models.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using AwesomeAssertions;
using Bunit;
using Microsoft.AspNetCore.Components;
using PanoramicData.Blazor.Interfaces;
using PanoramicData.Blazor.Models;

namespace PanoramicData.Blazor.Test;

/// <summary>
/// The model picker: offered in the input toolbar only when the service lists two or more models, and choosing one sets
/// <see cref="IChatService.SelectedModelId"/>.
/// </summary>
public partial class PDChatTests
{
private static readonly PDChatModelOption _quick = new("quick", "Quick", "Small and fast");
private static readonly PDChatModelOption _thorough = new("thorough", "Thorough");

/// <summary>Verifies that no model picker is shown with no models, or with only one to choose.</summary>
[Theory]
[InlineData(0)]
[InlineData(1)]
public void The_model_picker_is_hidden_with_fewer_than_two_models(int count)
=> RenderChat(new FakeChatService { Models = [.. new[] { _quick, _thorough }.Take(count)] })
.FindAll(".pdchat-model-picker").Should().BeEmpty();

/// <summary>Verifies that two or more models are offered in the toolbar, labelled, the first selected by default.</summary>
[Fact]
public void The_model_picker_is_shown_in_the_toolbar_with_two_models()
{
var picker = RenderChat(new FakeChatService { Models = [_quick, _thorough] }).Find(".chat-input-accessories .pdchat-model-picker");

picker.QuerySelector(".pdchat-toolbar-label")!.TextContent.Should().Be("Model");
var options = picker.QuerySelectorAll("option");
options.Select(option => option.TextContent).Should().Equal("Quick", "Thorough");
options[0].GetAttribute("title").Should().Be("Small and fast");
options[0].HasAttribute("selected").Should().BeTrue();
}

/// <summary>Verifies that choosing a model sets the service's selected model, and an unknown one is ignored.</summary>
[Fact]
public async Task Choosing_a_model_sets_the_selected_model()
{
var service = new FakeChatService { Models = [_quick, _thorough] };
var component = RenderChat(service);

await component.Find(".pdchat-model-picker select").ChangeAsync(new ChangeEventArgs { Value = "mallory" });
((IChatService)service).SelectedModelId.Should().BeNull();

await component.Find(".pdchat-model-picker select").ChangeAsync(new ChangeEventArgs { Value = "thorough" });
((IChatService)service).SelectedModelId.Should().Be("thorough");
component.Find(".pdchat-model-picker option[value=thorough]").HasAttribute("selected").Should().BeTrue();
}

/// <summary>Verifies that the agent and model pickers are separate, labelled choices in the same toolbar.</summary>
[Fact]
public void The_agent_and_model_pickers_are_both_labelled()
{
var toolbar = RenderChat(new FakeChatService { Agents = [_merlin, _alice], Models = [_quick, _thorough] })
.Find(".chat-input-accessories");

toolbar.QuerySelectorAll(".pdchat-toolbar-label").Select(label => label.TextContent).Should().Equal("Agent", "Model");
}

/// <summary>Verifies that the demo service offers models, agents and simulated voice.</summary>
[Fact]
public void The_demo_service_offers_the_whole_toolbar()
{
using var service = new PanoramicData.Blazor.Services.DumbChatService();
IChatService chat = service;

chat.Models.Should().HaveCountGreaterThan(1);
chat.Agents.Should().HaveCountGreaterThan(1);
chat.VoiceEndpoints.Should().Be(PDChatVoiceEndpoints.Simulated);
}
}
6 changes: 3 additions & 3 deletions PanoramicData.Blazor.Test/Components/PDChatTests.Service.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ public async Task Mute_toggles_the_service_and_raises_the_event()
var service = new FakeChatService();
var component = RenderChat(service, p => p.Add(x => x.OnMuteToggled, () => toggles++));

await HeaderButton(component, "Mute").ClickAsync(new MouseEventArgs());
await InputToolbarButton(component, "Notification sounds: on").ClickAsync(new MouseEventArgs());

service.IsMuted.Should().BeTrue();
HeaderButton(component, "Unmute").TextContent.Should().Contain("🔇");
InputToolbarButton(component, "Notification sounds: off").TextContent.Should().Contain("🔕");
toggles.Should().Be(1);
}

Expand All @@ -37,7 +37,7 @@ public async Task A_mute_change_from_the_service_is_reflected()

await component.InvokeAsync(() => service.AnnounceMute(true));

component.WaitForAssertion(() => HeaderButton(component, "Unmute").Should().NotBeNull(), Patience);
component.WaitForAssertion(() => InputToolbarButton(component, "Notification sounds: off").Should().NotBeNull(), Patience);
}

/// <summary>Verifies that live-status and configuration announcements re-render the chat.</summary>
Expand Down
137 changes: 122 additions & 15 deletions PanoramicData.Blazor.Test/Components/PDChatTests.Voice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public void Voice_Mode_is_offered_but_off_by_default()
var component = RenderChat(VoiceService());

var toggle = component.Find(".pdchat-voice-toggle");
toggle.TextContent.Should().Contain("Voice");
toggle.GetAttribute("aria-label").Should().Be("Voice: speak instead of typing");
toggle.GetAttribute("title").Should().Be("Voice: speak instead of typing");
toggle.GetAttribute("aria-pressed").Should().Be("false");
component.FindAll(".pdchat-voice-status").Should().BeEmpty();
Expand All @@ -45,7 +45,7 @@ public void The_Voice_control_is_in_the_input_area()
var component = RenderChat(VoiceService());

component.FindAll(".pdchat-header .pdchat-voice-toggle").Should().BeEmpty();
component.FindAll(".chat-input-container .chat-input-accessories .pdchat-voice-toggle").Should().ContainSingle();
component.FindAll(".chat-input-accessories .pdchat-voice-toggle").Should().ContainSingle();
}

/// <summary>
Expand Down Expand Up @@ -176,7 +176,7 @@ public async Task Dictation_is_never_auto_sent_while_the_text_box_has_focus()
service.Sent.Should().BeEmpty();
component.Find(".pdchat-voice-status").TextContent.Trim().Should().Be("Listening. You are editing, so press Send when ready.");

await component.Find(".chat-input-container > button").ClickAsync(new MouseEventArgs());
await component.Find(".chat-input-accessories .chat-send").ClickAsync(new MouseEventArgs());

service.Sent.Should().ContainSingle().Which.Message.Should().Be("Is it done?");
module.Invocations["pause"].Should().ContainSingle().Which.Arguments[0].Should().Be(true);
Expand Down Expand Up @@ -317,10 +317,128 @@ public async Task A_refused_microphone_is_explained()
component.Find(".pdchat-voice-status").TextContent.Should().Contain("microphone could not be opened");
}

private static FakeChatService VoiceService(TimeSpan? autoSendDelay = null)
/// <summary>Verifies that answers are not read aloud by default: dictation is sent and the microphone keeps listening.</summary>
[Fact]
public async Task Without_read_aloud_dictation_is_sent_and_nothing_is_spoken()
{
var module = SetUpVoiceModule();
var service = VoiceService(readAloud: false);
var component = await RenderListeningAsync(service);
component.Find(".pdchat-read-aloud-toggle").GetAttribute("aria-pressed").Should().Be("false");

await AskAsync(component, service, "Is it done?");
await component.InvokeAsync(() => service.Receive(Message("Yes.")));

module.Invocations["speak"].Should().BeEmpty();
module.Invocations["pause"].Should().BeEmpty("the microphone only pauses while an answer is to be spoken");
component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening);
}

/// <summary>Verifies that read-aloud works without the microphone: the answer to a typed message is spoken.</summary>
[Fact]
public async Task With_read_aloud_and_no_microphone_a_typed_question_is_answered_aloud()
{
var module = SetUpVoiceModule();
var service = VoiceService(readAloud: false);
var component = RenderChat(service);
await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new());
((IChatService)service).IsReadAloudEnabled.Should().BeTrue();

await component.Find("textarea").InputAsync(new Microsoft.AspNetCore.Components.ChangeEventArgs { Value = "Is it done?" });
await component.Find(".chat-input-accessories .chat-send").ClickAsync(new MouseEventArgs());
await component.InvokeAsync(() => service.Receive(Message("Yes.")));

module.Invocations["speak"].Should().ContainSingle().Which.Arguments[1].Should().Be("Yes.");
module.Invocations["start"].Should().BeEmpty("reading aloud never opens the microphone");
component.Instance.VoiceState.Should().Be(PDChatVoiceState.Off);
}

/// <summary>Verifies that turning read-aloud off mid-answer stops speaking and the microphone listens again.</summary>
[Fact]
public async Task Turning_read_aloud_off_mid_answer_stops_it_and_listening_resumes()
{
var module = SetUpVoiceModule();
var service = VoiceService();
var component = await RenderListeningAsync(service);
await AskAsync(component, service, "Is it done?");
await component.InvokeAsync(() => service.Receive(Message("Yes.")));
component.Instance.VoiceState.Should().Be(PDChatVoiceState.Speaking);

await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new());

module.Invocations["stopSpeaking"].Should().ContainSingle();
module.Invocations["pause"].Select(call => call.Arguments[0]).Should().Equal(true, false);
component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening);
}

/// <summary>Verifies that read-aloud and notification sounds are separate choices, so all four combinations exist.</summary>
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task Read_aloud_and_notification_sounds_are_independent(bool muted, bool readAloud)
{
var service = VoiceService(readAloud: false);
var component = RenderChat(service);

if (muted)
{
await component.Find(".pdchat-mute-toggle").ClickAsync(new());
}

if (readAloud)
{
await component.Find(".pdchat-read-aloud-toggle").ClickAsync(new());
}

service.IsMuted.Should().Be(muted);
((IChatService)service).IsReadAloudEnabled.Should().Be(readAloud);
component.Find(".pdchat-mute-toggle").TextContent.Should().Be(muted ? "🔕" : "🔔");
component.Find(".pdchat-read-aloud-toggle").GetAttribute("aria-pressed").Should().Be(readAloud ? "true" : "false");
}

/// <summary>Verifies that the microphone control is an icon alone, named for screen readers by its label.</summary>
[Fact]
public void The_microphone_control_is_an_icon_only()
{
var toggle = RenderChat(VoiceService()).Find(".pdchat-voice-toggle");

toggle.TextContent.Trim().Should().Be("🎙️");
toggle.GetAttribute("aria-label").Should().Be("Voice: speak instead of typing");
}

/// <summary>Verifies that the toolbar sits above the input, leaving the text box and Send the whole row.</summary>
[Fact]
public void The_toolbar_sits_above_a_full_width_input_row()
{
var component = RenderChat(VoiceService());

var toolbar = component.Find(".chat-input-accessories");
toolbar.NextElementSibling!.ClassList.Should().Contain("chat-input-container");
component.Find(".chat-input-container").Children.Select(child => child.LocalName).Should().Equal("textarea");
toolbar.LastElementChild!.ClassList.Should().Contain("chat-send");
component.FindAll(".pdchat-header .pdchat-mute-toggle, .pdchat-header-btn[title^='Notification']").Should().BeEmpty();
toolbar.QuerySelector(".pdchat-mute-toggle").Should().NotBeNull();
}

/// <summary>Verifies that notification sounds can still be switched where typing is not permitted.</summary>
[Fact]
public void Notification_sounds_stay_reachable_where_typing_is_not_permitted()
{
var service = VoiceService();
((IChatService)service).IsInputPermitted = false;
var component = RenderChat(service);

component.FindAll(".chat-input-accessories .pdchat-mute-toggle").Should().ContainSingle();
component.FindAll(".pdchat-voice-toggle, .pdchat-read-aloud-toggle").Should().BeEmpty();
}

private static FakeChatService VoiceService(TimeSpan? autoSendDelay = null, bool readAloud = true)
{
var service = new FakeChatService { VoiceEndpoints = _voiceEndpoints };
((IChatService)service).VoiceAutoSendDelay = autoSendDelay ?? TimeSpan.Zero;
((IChatService)service).IsReadAloudEnabled = readAloud;
return service;
}

Expand Down Expand Up @@ -365,17 +483,6 @@ public async Task Simulated_Voice_Mode_uses_its_own_module()
component.Instance.VoiceState.Should().Be(PDChatVoiceState.Listening);
}

/// <summary>Verifies that the demo service shows Voice Mode and the agent picker with no speech service behind it.</summary>
[Fact]
public void The_demo_service_offers_simulated_Voice_Mode_and_agents()
{
using var service = new PanoramicData.Blazor.Services.DumbChatService();
IChatService chat = service;

chat.VoiceEndpoints.Should().Be(PDChatVoiceEndpoints.Simulated);
chat.Agents.Should().HaveCountGreaterThan(1);
}

private BunitJSModuleInterop SetUpVoiceModule()
{
var module = JSInterop.SetupModule(VoiceModulePath);
Expand Down
4 changes: 4 additions & 0 deletions PanoramicData.Blazor.Test/Components/PDChatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ private static IElement HeaderButton<TComponent>(IRenderedComponent<TComponent>
where TComponent : IComponent
=> component.Find($".pdchat-header-btn[title='{title}']");

private static IElement InputToolbarButton<TComponent>(IRenderedComponent<TComponent> component, string title)
where TComponent : IComponent
=> component.Find($".chat-input-accessories .pdchat-toolbar-btn[title='{title}']");

private static ChatMessage Message(string text, MessageType type = MessageType.Normal) => new()
{
Id = Guid.NewGuid(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,22 @@ public async Task SendAsync_is_the_same_as_pressing_send()
events.Should().Equal("input:spoken", "input:spoken", "send");
}

/// <summary>Verifies that host controls are shown between the text box and Send, and not at all when absent.</summary>
/// <summary>Verifies that host controls and Send share a toolbar above a full-width text box.</summary>
[Fact]
public void Input_accessories_sit_beside_send()
public void Input_accessories_sit_in_a_toolbar_above_the_input()
{
RenderLive([]).FindAll(".chat-input-accessories").Should().BeEmpty();
RenderLive([]).Find(".chat-input-accessories").Children.Select(child => child.ClassName).Should().Equal("btn btn-sm btn-secondary chat-send");

var component = Render<PDMessages>(parameters => parameters
.Add(p => p.IsLive, true)
.Add(p => p.InputAccessories, builder => builder.AddMarkupContent(0, "<button class=\"extra\">Extra</button>")));

var row = component.Find(".chat-input-container");
row.Children.Select(child => child.LocalName).Should().Equal("textarea", "div", "button");
row.QuerySelector(".chat-input-accessories .extra").Should().NotBeNull();
var toolbar = component.Find(".chat-input-accessories");
toolbar.GetAttribute("role").Should().Be("toolbar");
toolbar.QuerySelector(".extra").Should().NotBeNull();
toolbar.NextElementSibling!.ClassList.Should().Contain("chat-input-container");
component.Find(".chat-input-container").Children.Select(child => child.LocalName).Should().Equal("textarea");
toolbar.LastElementChild!.ClassList.Should().Contain("chat-send");
}

/// <summary>Verifies that the text box takes focus when shown, unless that is turned off.</summary>
Expand Down
4 changes: 4 additions & 0 deletions PanoramicData.Blazor/Interfaces/ChatServiceDefaultState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ internal sealed class ChatServiceDefaultState

public string? SelectedAgentId { get; set; }

public string? SelectedModelId { get; set; }

public bool IsReadAloudEnabled { get; set; }

/// <summary>
/// Gets or sets the handlers subscribed to the default <see cref="IChatService.OnConversationMessageReceived"/>.
/// They are kept so that subscribing and unsubscribing behave as they would on any event, but a service that
Expand Down
26 changes: 26 additions & 0 deletions PanoramicData.Blazor/Interfaces/IChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,32 @@ TimeSpan VoiceAutoSendDelay
set => ChatServiceDefaultState.For(this).SelectedAgentId = value;
}

/// <summary>
/// Gets or sets whether the answer to each message the user sends is read aloud. False by default. Independent of
/// both the microphone and <see cref="IsMuted"/>, which governs notification sounds. Needs <see cref="VoiceEndpoints"/>.
/// </summary>
bool IsReadAloudEnabled
{
get => ChatServiceDefaultState.For(this).IsReadAloudEnabled;
set => ChatServiceDefaultState.For(this).IsReadAloudEnabled = value;
}

/// <summary>
/// Gets the models the user may choose to answer, or null (the default) when the host offers no choice.
/// A picker is shown in the input toolbar only when there are two or more.
/// </summary>
IReadOnlyList<PDChatModelOption>? Models => null;

/// <summary>
/// Gets or sets the <see cref="PDChatModelOption.Id"/> of the model that answers, or null (the default) for the
/// host's default. The chat sets this when the user picks a model; a host that needs to react implements the setter.
/// </summary>
string? SelectedModelId
{
get => ChatServiceDefaultState.For(this).SelectedModelId;
set => ChatServiceDefaultState.For(this).SelectedModelId = value;
}

/// <summary>
/// Gets or sets whether the chat should auto-restore when new messages arrive.
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions PanoramicData.Blazor/Models/PDChatModelOption.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace PanoramicData.Blazor.Models;

/// <summary>
/// One of the models a host offers in <see cref="PanoramicData.Blazor.PDChat"/>'s input toolbar, for the user to choose
/// which model answers.
/// </summary>
/// <param name="Id">The identifier the host recognises, stored in <see cref="Interfaces.IChatService.SelectedModelId"/>.</param>
/// <param name="Name">The name shown in the picker.</param>
/// <param name="Description">An optional description, shown as the option's tooltip.</param>
public sealed record PDChatModelOption(string Id, string Name, string? Description = null);
Loading
Loading