feat(WebSpeechRecognition): add WebSpeechRecognition service (#4140)

* test: 代码重构

* test: 更新单元测试

* test: 更新单元测试

* test: 移动报错单元测试到其他集合

* refactor: 精简代码

* refactor: 移动报错单元测试到其他集合

* test: 增加 ButtonTestBase 隔离单元测试

* refactor: 精简代码

* refactor: 移动到新文件夹

* refactor: 移动语音识别到单独文件夹

* feat: 增加语音识别

* refactor: 增加脚本

* doc: 增加示例

* refactor: 移除不需要的回调方法

* doc: 更新示例

* refactor: 更新异常回调

* doc: 更新示例

* feat: 增加回调方法

* test: 更新单元测试

* doc: 更新示例
This commit is contained in:
Argo Zhang 2024-08-24 23:23:58 +08:00 committed by GitHub
parent 5a2b1caf5c
commit db419c17be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 800 additions and 115 deletions

View File

@ -19,3 +19,17 @@
</div>
</div>
</DemoBlock>
<DemoBlock Title="@Localizer["WebSpeechRecognitionTitle"]"
Introduction="@Localizer["WebSpeechRecognitionIntro"]"
Name="Recognition">
<div class="row">
<div class="col-12 col-sm-6">
<Button Text="@_buttonRecognitionText" OnClickWithoutRender="OnStartRecognition" IsAsync="true" Icon="fa-fw fa-solid fa-microphone"></Button>
<div class="mt-3">@_result</div>
</div>
<div class="col-12 col-sm-6">
<SpeechWave Show="_starRecognition" ShowUsedTime="false" class="my-3"></SpeechWave>
</div>
</div>
</DemoBlock>

View File

@ -14,6 +14,9 @@ public partial class WebSpeeches
[Inject, NotNull]
private WebSpeechService? WebSpeechService { get; set; }
[Inject, NotNull]
private ToastService? ToastService { get; set; }
[Inject, NotNull]
private IStringLocalizer<WebSpeeches>? Localizer { get; set; }
@ -27,6 +30,11 @@ public partial class WebSpeeches
private readonly List<SelectedItem> _voices = [];
private readonly List<WebSpeechSynthesisVoice> _speechVoices = [];
private bool _starRecognition;
private WebSpeechRecognition _recognition = default!;
private string? _buttonRecognitionText;
private string? _result;
/// <summary>
/// <inheritdoc/>
/// </summary>
@ -35,6 +43,7 @@ public partial class WebSpeeches
{
await base.OnInitializedAsync();
// create synthesizer
_entry = await WebSpeechService.CreateSynthesizerAsync();
_entry.OnEndAsync = SpeakAsync;
@ -49,6 +58,33 @@ public partial class WebSpeeches
_text = Localizer["WebSpeechText"];
_buttonText = Localizer["WebSpeechSpeakButtonText"];
_buttonStopText = Localizer["WebSpeechStopButtonText"];
// create recognition
_buttonRecognitionText = Localizer["WebSpeechRecognitionButtonText"];
_recognition = await WebSpeechService.CreateRecognitionAsync();
_recognition.OnSpeechStartAsync = () =>
{
_starRecognition = true;
StateHasChanged();
return Task.CompletedTask;
};
_recognition.OnSpeechEndAsync = () =>
{
_starRecognition = false;
StateHasChanged();
return Task.CompletedTask;
};
_recognition.OnErrorAsync = async e =>
{
e.ParseErrorMessage(Localizer);
await ToastService.Error("Recognition", e.Message);
};
_recognition.OnResultAsync = e =>
{
_result = e.Transcript;
StateHasChanged();
return Task.CompletedTask;
};
}
private async Task OnStart()
@ -78,4 +114,11 @@ public partial class WebSpeeches
await _entry.CancelAsync();
_tcs?.TrySetResult();
}
private async Task OnStartRecognition()
{
_result = "";
await _recognition.StartAsync();
StateHasChanged();
}
}

View File

@ -0,0 +1,56 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
namespace BootstrapBlazor.Components;
/// <summary>
/// WebSpeechRecognitionError 扩展方法
/// </summary>
public static class WebSpeechRecognitionErrorExtensions
{
/// <summary>
/// 生成错误消息方法
/// </summary>
/// <param name="error"></param>
/// <param name="localizer"></param>
public static void ParseErrorMessage(this WebSpeechRecognitionError error, IStringLocalizer localizer)
{
if (error.Error == "no-speech")
{
error.Message = localizer["RecognitionErrorNoSpeech"];
}
else if (error.Error == "aborted")
{
error.Message = localizer["RecognitionErrorAborted"];
}
else if (error.Error == "audio-capture")
{
error.Message = localizer["RecognitionErrorAudioCapture"];
}
else if (error.Error == "network")
{
error.Message = localizer["RecognitionErrorNetwork"];
}
else if (error.Error == "not-allowed")
{
error.Message = localizer["RecognitionErrorNotAllowed"];
}
else if (error.Error == "service-not-allowed")
{
error.Message = localizer["RecognitionErrorServiceNotAllowed"];
}
else if (error.Error == "bad-grammar")
{
error.Message = localizer["RecognitionErrorBadGrammar"];
}
else if (error.Error == "language-not-supported")
{
error.Message = localizer["RecognitionErrorLanguageNotSupported"];
}
else if (error.Error == "not-support")
{
error.Message = localizer["RecognitionErrorNotSupported"];
}
}
}

View File

@ -6306,7 +6306,19 @@
"WebSpeechNormalIntro": "After entering the text, select the corresponding language to read aloud",
"WebSpeechSpeakButtonText": "Speak",
"WebSpeechStopButtonText": "Stop",
"WebSpeechText": "Welcome blazor"
"WebSpeechText": "Welcome blazor",
"WebSpeechRecognitionTitle": "Speech Recognition",
"WebSpeechRecognitionIntro": "Input voice through microphone for voice recognition",
"WebSpeechRecognitionButtonText": "Recognition",
"RecognitionErrorNotAllowed": "The user agent disallowed any speech input from occurring for reasons of security, privacy or user preference",
"RecognitionErrorNoSpeech": "No speech was detected",
"RecognitionErrorAborted": "Speech input was aborted in some manner, perhaps by some user-agent-specific behavior like a button the user can press to cancel speech input",
"RecognitionErrorAudioCapture": "Audio capture failed",
"RecognitionErrorNetwork": "Network communication required for completing the recognition failed",
"RecognitionErrorNotSupported": "The browser does not support speech recognition",
"RecognitionErrorServiceNotAllowed": "The user agent disallowed the requested speech recognition service, either because the user agent doesn't support it or because of reasons of security, privacy or user preference. In this case it would allow another more suitable speech recognition service to be used instead",
"RecognitionErrorBadGrammar": "There was an error in the speech recognition grammar or semantic tags, or the chosen grammar format or semantic tag format was unsupported",
"RecognitionErrorLanguageNotSupported": "The user agent does not support the language specified in the value of lang attribute of the SpeechRecognition object. The set of supported languages is browser-dependent, and from frontend code there is no way to programmatically determine what languages a user's browser supports for speech recognition"
},
"BootstrapBlazor.Server.Components.Samples.ListGroups": {
"ListGroupsTitle": "ListGroup",

View File

@ -6306,7 +6306,19 @@
"WebSpeechNormalIntro": "输入文字后选择相对应的语种进行朗读",
"WebSpeechSpeakButtonText": "朗读",
"WebSpeechStopButtonText": "停止",
"WebSpeechText": "开始朗读一段文字"
"WebSpeechText": "开始朗读一段文字",
"WebSpeechRecognitionTitle": "语音识别",
"WebSpeechRecognitionIntro": "通过麦克风输入语音,进行语音识别",
"WebSpeechRecognitionButtonText": "语音识别",
"RecognitionErrorNotAllowed": "用户已拒绝访问硬件设备",
"RecognitionErrorNoSpeech": "未检测到语音",
"RecognitionErrorAborted": "用户已取消",
"RecognitionErrorAudioCapture": "硬件设备无法捕获音频",
"RecognitionErrorNetwork": "网络错误",
"RecognitionErrorNotSupported": "浏览器不支持语音识别",
"RecognitionErrorServiceNotAllowed": "服务不允许",
"RecognitionErrorBadGrammar": "语法错误",
"RecognitionErrorLanguageNotSupported": "语言不支持"
},
"BootstrapBlazor.Server.Components.Samples.ListGroups": {
"ListGroupsTitle": "ListGroup 列表框",

View File

@ -279,7 +279,7 @@ public partial class DateTimeRange
{
base.OnInitialized();
if (EditContext != null && FieldIdentifier != null)
if (FieldIdentifier != null)
{
var pi = FieldIdentifier.Value.Model.GetType().GetPropertyByName(FieldIdentifier.Value.FieldName);
if (pi != null)

View File

@ -1,30 +0,0 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
using Microsoft.Extensions.Logging;
namespace BootstrapBlazor.Components;
/// <summary>
/// Web Speech 服务
/// </summary>
public class WebSpeechService(IJSRuntime runtime, IComponentIdGenerator ComponentIdGenerator, ILogger<WebSpeechService> logger)
{
private JSModule? Module { get; set; }
/// <summary>
/// 语音合成方法
/// </summary>
/// <returns></returns>
public async Task<WebSpeechSynthesizer> CreateSynthesizerAsync()
{
if (Module == null)
{
var moduleName = "./_content/BootstrapBlazor/modules/speech.js";
logger.LogInformation("load module {moduleName}", moduleName);
Module = await runtime.LoadModule(moduleName);
}
return new WebSpeechSynthesizer(Module, ComponentIdGenerator);
}
}

View File

@ -208,7 +208,7 @@ public partial class Transfer<TValue>
OnSetItemClass ??= _ => null;
// 处理 Required 标签
if (EditContext != null && FieldIdentifier != null)
if (FieldIdentifier != null)
{
var pi = FieldIdentifier.Value.Model.GetType().GetPropertyByName(FieldIdentifier.Value.FieldName);
if (pi != null)

View File

@ -0,0 +1,173 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
namespace BootstrapBlazor.Components;
/// <summary>
/// WebSpeechRecognition 类
/// </summary>
public class WebSpeechRecognition(JSModule module, IComponentIdGenerator componentIdGenerator)
{
private DotNetObjectReference<WebSpeechRecognition>? _interop;
private string? _id;
/// <summary>
/// fired when the speech recognition service has begun listening to incoming audio with intent to recognize grammars associated with the current SpeechRecognition.
/// </summary>
public Func<Task>? OnStartAsync { get; set; }
/// <summary>
/// fired when the speech recognition service has disconnected.
/// </summary>
public Func<Task>? OnEndAsync { get; set; }
/// <summary>
/// fired when sound recognized by the speech recognition service as speech has been detected.
/// </summary>
public Func<Task>? OnSpeechStartAsync { get; set; }
/// <summary>
/// fired when speech recognized by the speech recognition service has stopped being detected.
/// </summary>
public Func<Task>? OnSpeechEndAsync { get; set; }
/// <summary>
/// fired when the speech recognition service returns a result — a word or phrase has been positively recognized and this has been communicated back to the app
/// </summary>
public Func<WebSpeechRecognitionEvent, Task>? OnResultAsync { get; set; }
/// <summary>
/// fired when a speech recognition error occurs.
/// </summary>
public Func<WebSpeechRecognitionError, Task>? OnErrorAsync { get; set; }
/// <summary>
/// fired when the speech recognition service returns a final result with no significant recognition.
/// </summary>
public Func<WebSpeechRecognitionError, Task>? OnNoMatchAsync { get; set; }
/// <summary>
/// 开始识别方法
/// </summary>
public async Task StartAsync()
{
_id = componentIdGenerator.Generate(this);
_interop = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("start", _id, _interop, new
{
TriggerStart = OnStartAsync != null,
TriggerSpeechStart = OnSpeechStartAsync != null
});
}
/// <summary>
/// 结束识别方法
/// </summary>
/// <returns></returns>
public async Task StopAsync()
{
await module.InvokeVoidAsync("stop", _id);
}
/// <summary>
/// 中断识别方法
/// </summary>
/// <returns></returns>
public async Task AbortAsync()
{
await module.InvokeVoidAsync("abort", _id);
}
/// <summary>
/// 开始识别回调方法由 Javascript 调用
/// </summary>
/// <returns></returns>
[JSInvokable]
public async Task TriggerStartCallback()
{
if (OnStartAsync != null)
{
await OnStartAsync();
}
}
/// <summary>
/// 语音开始回调方法由 Javascript 调用
/// </summary>
/// <returns></returns>
[JSInvokable]
public async Task TriggerSpeechStartCallback()
{
if (OnSpeechStartAsync != null)
{
await OnSpeechStartAsync();
}
}
/// <summary>
/// 语音结束回调方法由 Javascript 调用
/// </summary>
/// <returns></returns>
[JSInvokable]
public async Task TriggerSpeechEndCallback()
{
if (OnSpeechEndAsync != null)
{
await OnSpeechEndAsync();
}
}
/// <summary>
/// 异常回调方法由 Javascript 调用
/// </summary>
/// <returns></returns>
[JSInvokable]
public async Task TriggerErrorCallback(WebSpeechRecognitionError error)
{
if (OnErrorAsync != null)
{
await OnErrorAsync(error);
}
}
/// <summary>
/// 识别结果回调方法由 Javascript 调用
/// </summary>
/// <returns></returns>
[JSInvokable]
public async Task TriggerResultCallback(WebSpeechRecognitionEvent @event)
{
if (OnResultAsync != null)
{
await OnResultAsync(@event);
}
}
/// <summary>
/// 无识别结果回调方法由 Javascript 调用
/// </summary>
/// <returns></returns>
[JSInvokable]
public async Task TriggerNoMatchCallback(WebSpeechRecognitionError error)
{
if (OnNoMatchAsync != null)
{
await OnNoMatchAsync(error);
}
}
/// <summary>
/// 朗读结束回调方法由 Javascript 调用
/// </summary>
/// <returns></returns>
[JSInvokable]
public async Task TriggerEndCallback()
{
if (OnEndAsync != null)
{
await OnEndAsync();
}
}
}

View File

@ -0,0 +1,21 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
namespace BootstrapBlazor.Components;
/// <summary>
/// WebSpeechRecognitionError 类
/// </summary>
public class WebSpeechRecognitionError
{
/// <summary>
/// A string naming the type of error. no-speech aborted audio-capture network not-allowed service-not-allowed bad-grammar language-not-supported.
/// </summary>
public string? Error { get; set; }
/// <summary>
/// A string containing more details about the error that was raised. Note that the spec does not define the exact wording of these messages — this is up to the implementors to decide upon.
/// </summary>
public string? Message { get; set; }
}

View File

@ -0,0 +1,21 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
namespace BootstrapBlazor.Components;
/// <summary>
/// WebSpeechRecognitionEvent 类
/// </summary>
public class WebSpeechRecognitionEvent
{
/// <summary>
/// 获得/设置 可信度
/// </summary>
public float Confidence { get; set; }
/// <summary>
/// 获得/设置 识别文本内容
/// </summary>
public string? Transcript { get; set; }
}

View File

@ -0,0 +1,47 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
using Microsoft.Extensions.Logging;
namespace BootstrapBlazor.Components;
/// <summary>
/// Web Speech 服务
/// </summary>
public class WebSpeechService(IJSRuntime runtime, IComponentIdGenerator ComponentIdGenerator, ILogger<WebSpeechService> logger)
{
private JSModule? SynthesisModule { get; set; }
private JSModule? RecognitionModule { get; set; }
/// <summary>
/// 语音合成方法
/// </summary>
/// <returns></returns>
public async Task<WebSpeechSynthesizer> CreateSynthesizerAsync()
{
if (SynthesisModule == null)
{
var moduleName = "./_content/BootstrapBlazor/modules/synthesis.js";
logger.LogInformation("load module {moduleName}", moduleName);
SynthesisModule = await runtime.LoadModule(moduleName);
}
return new WebSpeechSynthesizer(SynthesisModule, ComponentIdGenerator);
}
/// <summary>
/// 语音识别方法
/// </summary>
/// <returns></returns>
public async Task<WebSpeechRecognition> CreateRecognitionAsync()
{
if (RecognitionModule == null)
{
var moduleName = "./_content/BootstrapBlazor/modules/recognition.js";
logger.LogInformation("load module {moduleName}", moduleName);
RecognitionModule = await runtime.LoadModule(moduleName);
}
return new WebSpeechRecognition(RecognitionModule, ComponentIdGenerator);
}
}

View File

@ -0,0 +1,63 @@
import Data from "./data.js"
export async function start(id, invoke, option) {
const speechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition;
if (speechRecognition === null) {
invoke.invokeMethodAsync("TriggerErrorCallback", {
error: 'not-support',
message: 'SpeechRecognition is not supported in this browser.'
});
}
const recognition = new speechRecognition();
if (option.triggerStart || true) {
recognition.onstart = () => {
invoke.invokeMethodAsync("TriggerStartCallback");
}
}
if (option.triggerSpeechStart || true) {
recognition.onspeechstart = () => {
invoke.invokeMethodAsync("TriggerSpeechStartCallback");
}
}
recognition.onspeechend = () => {
recognition.stop();
invoke.invokeMethodAsync("TriggerSpeechEndCallback");
}
recognition.onnomatch = e => {
invoke.invokeMethodAsync("TriggerNoMatchCallback", {
error: 'no-match',
message: 'No match found.'
});
}
recognition.onend = () => {
invoke.invokeMethodAsync("TriggerEndCallback");
}
recognition.onerror = e => {
invoke.invokeMethodAsync("TriggerErrorCallback", {
error: e.error,
message: e.message
});
}
recognition.onresult = e => {
const transcript = e.results[0][0];
invoke.invokeMethodAsync("TriggerResultCallback", {
transcript: transcript.transcript,
confidence: transcript.confidence
});
}
recognition.lang = 'zh-CN';
recognition.maxAlternatives = 1;
recognition.interimResults = false;
recognition.continuous = false;
recognition.start();
}
export function stop(id) {
const synth = window.speechSynthesis;
synth.pause();
}
export function abort(id) {
const synth = window.speechSynthesis;
synth.resume();
}

View File

@ -8,7 +8,7 @@ using Microsoft.Extensions.Localization;
namespace UnitTest.Components;
public class ButtonTest : BootstrapBlazorTestBase
public class ButtonTest : ButtonTestBase
{
[Fact]
public void ButtonStyle_Ok()
@ -33,6 +33,35 @@ public class ButtonTest : BootstrapBlazorTestBase
Assert.Contains("btn-round", cut.Markup);
}
[Fact]
public void Popover_Ok()
{
var cut = Context.RenderComponent<Button>(pb =>
{
pb.AddChildContent<Popover>(pb =>
{
pb.Add(t => t.Title, "popover-title");
});
});
cut.Contains("data-bs-toggle=\"popover\" data-bs-original-title=\"popover-title\" data-bs-placement=\"top\" data-bs-custom-class=\"shadow\" data-bs-trigger=\"focus hover\"");
// 切换 Disabled 状态移除 Popover
cut.SetParametersAndRender(pb =>
{
pb.Add(b => b.IsDisabled, true);
});
var button = cut.Find("button");
var d = button.GetAttribute("disabled");
Assert.Equal("disabled", d);
cut.SetParametersAndRender(pb =>
{
pb.Add(b => b.IsDisabled, false);
});
button = cut.Find("button");
Assert.False(button.HasAttribute("disabled"));
}
[Fact]
public void ButtonType_Ok()
{
@ -55,6 +84,23 @@ public class ButtonTest : BootstrapBlazorTestBase
Assert.Contains("type=\"reset\"", cut.Markup);
}
[Fact]
public void Text_Ok()
{
var cut = Context.RenderComponent<Button>(pb =>
{
pb.Add(b => b.Text, "Test");
});
Assert.Contains("<span>Test</span>", cut.Markup);
cut.SetParametersAndRender(pb =>
{
pb.Add(a => a.Text, null);
pb.AddChildContent("Button-Test");
});
Assert.Contains("Button-Test", cut.Markup);
}
[Theory]
[InlineData(Color.Primary, "btn-primary")]
[InlineData(Color.Secondary, "btn-secondary")]
@ -184,23 +230,6 @@ public class ButtonTest : BootstrapBlazorTestBase
Assert.True(cut.Instance.IsDisabled);
}
[Fact]
public void Text_Ok()
{
var cut = Context.RenderComponent<Button>(pb =>
{
pb.Add(b => b.Text, "Test");
});
Assert.Contains("<span>Test</span>", cut.Markup);
cut.SetParametersAndRender(pb =>
{
pb.Add(a => a.Text, null);
pb.AddChildContent("Button-Test");
});
Assert.Contains("Button-Test", cut.Markup);
}
[Fact]
public void IsOutline_Ok()
{
@ -285,35 +314,6 @@ public class ButtonTest : BootstrapBlazorTestBase
await cut1.InvokeAsync(() => cut1.Instance.ShowTooltip());
}
[Fact]
public void Popover_Ok()
{
var cut = Context.RenderComponent<Button>(pb =>
{
pb.AddChildContent<Popover>(pb =>
{
pb.Add(t => t.Title, "popover-title");
});
});
cut.Contains("data-bs-toggle=\"popover\" data-bs-original-title=\"popover-title\" data-bs-placement=\"top\" data-bs-custom-class=\"shadow\" data-bs-trigger=\"focus hover\"");
// 切换 Disabled 状态移除 Popover
cut.SetParametersAndRender(pb =>
{
pb.Add(b => b.IsDisabled, true);
});
var button = cut.Find("button");
var d = button.GetAttribute("disabled");
Assert.Equal("disabled", d);
cut.SetParametersAndRender(pb =>
{
pb.Add(b => b.IsDisabled, false);
});
button = cut.Find("button");
Assert.False(button.HasAttribute("disabled"));
}
[Fact]
public async Task ValidateFormButton_Ok()
{

View File

@ -40,7 +40,7 @@ public class TableEnumFilterTest : BootstrapBlazorTestBase
}
[Fact]
public void Count_Ok()
public async Task Count_Ok()
{
var cut = Context.RenderComponent<EnumFilter>(pb =>
{
@ -56,14 +56,14 @@ public class TableEnumFilterTest : BootstrapBlazorTestBase
Assert.Empty(filter.Filters);
var com = cut.FindComponent<Select<string?>>().Instance;
cut.InvokeAsync(() => com.SetValue("Middle"));
await cut.InvokeAsync(() => com.SetValue("Middle"));
filter = cut.Instance.GetFilterConditions();
Assert.NotNull(filter.Filters);
Assert.Single(filter.Filters);
com = cut.FindComponents<Select<string?>>()[1].Instance;
cut.InvokeAsync(() => com.SetValue("Primary"));
await cut.InvokeAsync(() => com.SetValue("Primary"));
filter = cut.Instance.GetFilterConditions();
Assert.NotNull(filter.Filters);
@ -76,35 +76,6 @@ public class TableEnumFilterTest : BootstrapBlazorTestBase
Assert.ThrowsAny<InvalidOperationException>(() => Context.RenderComponent<EnumFilter>());
}
[Fact]
public void IsHeaderRow_OnFilterValueChanged()
{
var cut = Context.RenderComponent<BootstrapBlazorRoot>(pb =>
{
pb.AddChildContent<Table<Foo>>(pb =>
{
pb.Add(a => a.Items, new List<Foo>() { new() });
pb.Add(a => a.RenderMode, TableRenderMode.Table);
pb.Add(a => a.ShowFilterHeader, true);
pb.Add(a => a.TableColumns, new RenderFragment<Foo>(foo => builder =>
{
var index = 0;
builder.OpenComponent<TableColumn<Foo, EnumEducation?>>(index++);
builder.AddAttribute(index++, nameof(TableColumn<Foo, EnumEducation?>.Field), foo.Education);
builder.AddAttribute(index++, nameof(TableColumn<Foo, EnumEducation?>.FieldExpression), foo.GenerateValueExpression(nameof(foo.Education), typeof(EnumEducation?)));
builder.AddAttribute(index++, nameof(TableColumn<Foo, EnumEducation?>.Filterable), true);
builder.CloseComponent();
}));
});
});
var items = cut.FindAll(".dropdown-item");
cut.InvokeAsync(() => items[1].Click());
var conditions = cut.FindComponent<EnumFilter>().Instance.GetFilterConditions();
Assert.NotNull(conditions.Filters);
Assert.Single(conditions.Filters);
}
[Fact]
public void SetFilterConditions_Ok()
{

View File

@ -60,4 +60,33 @@ public class TableTestEFCore : EFCoreTableTestBase
// 数据库 Sqlite Contains 使用 InStr 来比较,区分大小写
}
[Fact]
public void IsHeaderRow_OnFilterValueChanged()
{
var cut = Context.RenderComponent<BootstrapBlazorRoot>(pb =>
{
pb.AddChildContent<Table<Foo>>(pb =>
{
pb.Add(a => a.Items, new List<Foo>() { new() });
pb.Add(a => a.RenderMode, TableRenderMode.Table);
pb.Add(a => a.ShowFilterHeader, true);
pb.Add(a => a.TableColumns, new RenderFragment<Foo>(foo => builder =>
{
var index = 0;
builder.OpenComponent<TableColumn<Foo, EnumEducation?>>(index++);
builder.AddAttribute(index++, nameof(TableColumn<Foo, EnumEducation?>.Field), foo.Education);
builder.AddAttribute(index++, nameof(TableColumn<Foo, EnumEducation?>.FieldExpression), foo.GenerateValueExpression(nameof(foo.Education), typeof(EnumEducation?)));
builder.AddAttribute(index++, nameof(TableColumn<Foo, EnumEducation?>.Filterable), true);
builder.CloseComponent();
}));
});
});
var items = cut.FindAll(".dropdown-item");
items[1].Click();
var conditions = cut.FindComponent<EnumFilter>().Instance.GetFilterConditions();
Assert.NotNull(conditions.Filters);
Assert.Single(conditions.Filters);
}
}

View File

@ -375,6 +375,16 @@ public class UploadTest : BootstrapBlazorTestBase
cut.Contains("btn-xs");
}
[Fact]
public void ButtonUpload_ChildContent()
{
var cut = Context.RenderComponent<ButtonUpload<string>>(pb =>
{
pb.Add(a => a.ChildContent, builder => builder.AddContent(0, new MarkupString("<div>test-child-content</div>")));
});
cut.Contains("<div>test-child-content</div>");
}
[Fact]
public void ButtonUpload_IsDisabled_Ok()
{

View File

@ -0,0 +1,92 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace UnitTest.Core;
[Collection("ButtonTestContext")]
public class ButtonTestBase
{
protected TestContext Context { get; }
protected ICacheManager Cache { get; }
public ButtonTestBase()
{
Context = ButtonTestHost.Instance;
Cache = ButtonTestHost.Cache;
}
}
[CollectionDefinition("ButtonTestContext")]
public class ButtonTestCollection : ICollectionFixture<ButtonTestHost>
{
}
public class ButtonTestHost : IDisposable
{
[NotNull]
internal static TestContext? Instance { get; private set; }
[NotNull]
internal static ICacheManager? Cache { get; private set; }
public ButtonTestHost()
{
Instance = new TestContext();
// Mock 脚本
Instance.JSInterop.Mode = JSRuntimeMode.Loose;
ConfigureServices(Instance.Services);
ConfigureConfiguration(Instance.Services);
// 渲染 BootstrapBlazorRoot 组件 激活 ICacheManager 接口
Cache = Instance.Services.GetRequiredService<ICacheManager>();
}
protected virtual void ConfigureServices(IServiceCollection services)
{
services.AddBootstrapBlazor();
services.ConfigureJsonLocalizationOptions(op =>
{
op.IgnoreLocalizerMissing = false;
});
services.AddSingleton<ILookupService, FooLookupService>();
}
protected virtual void ConfigureConfiguration(IServiceCollection services)
{
// 增加单元测试 appsettings.json 配置文件
services.AddConfiguration();
}
public void Dispose()
{
Instance.Dispose();
GC.SuppressFinalize(this);
}
class FooLookupService : LookupServiceBase
{
public override IEnumerable<SelectedItem>? GetItemsByKey(string? key, object? data)
{
IEnumerable<SelectedItem>? ret = null;
if (key == "FooLookup")
{
ret = new SelectedItem[]
{
new("v1", "LookupService-Test-1"),
new("v2", "LookupService-Test-2")
};
}
return ret;
}
}
}

View File

@ -5,7 +5,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Globalization;
namespace UnitTest.Core;

View File

@ -0,0 +1,152 @@
// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
using Microsoft.Extensions.DependencyInjection;
namespace UnitTest.Services;
public class WebSpeechRecognitionTest : BootstrapBlazorTestBase
{
[Fact]
public async Task StartAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
await recognition.StartAsync();
WebSpeechRecognitionEvent? result = null;
recognition.OnResultAsync = @event =>
{
result = @event;
return Task.CompletedTask;
};
await recognition.TriggerResultCallback(new WebSpeechRecognitionEvent()
{
Confidence = 0.9f,
Transcript = "test"
});
Assert.NotNull(result);
Assert.Equal(0.9f, result.Confidence);
Assert.Equal("test", result.Transcript);
}
[Fact]
public async Task OnErrorAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
await recognition.StartAsync();
WebSpeechRecognitionError? error = null;
recognition.OnErrorAsync = err =>
{
error = err;
return Task.CompletedTask;
};
Assert.Null(error);
await recognition.TriggerErrorCallback(new WebSpeechRecognitionError
{
Error = "no-speech",
Message = "test"
});
Assert.NotNull(error);
Assert.Equal("no-speech", error.Error);
Assert.Equal("test", error.Message);
}
[Fact]
public async Task StopAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
await recognition.StopAsync();
}
[Fact]
public async Task AbortAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
await recognition.AbortAsync();
}
[Fact]
public async Task OnEndAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
var end = false;
recognition.OnEndAsync = () =>
{
end = true;
return Task.CompletedTask;
};
Assert.False(end);
await recognition.TriggerEndCallback();
Assert.True(end);
}
[Fact]
public async Task OnNoMatchAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
WebSpeechRecognitionError? error = null;
recognition.OnNoMatchAsync = err =>
{
error = err;
return Task.CompletedTask;
};
Assert.Null(error);
await recognition.TriggerNoMatchCallback(new WebSpeechRecognitionError()
{
Error = "no-match",
Message = "test"
});
Assert.NotNull(error);
}
[Fact]
public async Task OnStartAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
bool start = false;
recognition.OnStartAsync = () =>
{
start = true;
return Task.CompletedTask;
};
await recognition.TriggerStartCallback();
Assert.True(start);
}
[Fact]
public async Task OnSpeechStartAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
bool start = false;
recognition.OnSpeechStartAsync = () =>
{
start = true;
return Task.CompletedTask;
};
await recognition.TriggerSpeechStartCallback();
Assert.True(start);
}
[Fact]
public async Task OnSpeechEndAsync_Ok()
{
var service = Context.Services.GetRequiredService<WebSpeechService>();
var recognition = await service.CreateRecognitionAsync();
bool end = false;
recognition.OnSpeechEndAsync = () =>
{
end = true;
return Task.CompletedTask;
};
await recognition.TriggerSpeechEndCallback();
Assert.True(end);
}
}

View File

@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace UnitTest.Services;
public class WebSpeechServiceTest : BootstrapBlazorTestBase
public class WebSpeechSynthesizerTest : BootstrapBlazorTestBase
{
[Fact]
public async Task SpeakAsync_Ok()