Skip to content

Commit d45e9d6

Browse files
committed
test: add coverage for mirror fallback, SileroVad registry, and voice text append
- Fix ModelRegistryTests.AllModels_ContainsAll for SileroVad (6 → 7) - Add SileroVad type and optional model registry tests - Add ModelManager tests: HuggingFace fallback, user mirror, non-HF URL passthrough - Add OverlayViewModel tests: text append, partial transcription, space handling Made-with: Cursor
1 parent 308c56f commit d45e9d6

File tree

3 files changed

+235
-1
lines changed

3 files changed

+235
-1
lines changed

tests/LiveLingo.Core.Tests/Models/ModelManagerTests.cs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,94 @@ public async Task MigrateStoragePathAsync_PreservesNestedDirectories()
565565
}
566566
}
567567

568+
[Fact]
569+
public async Task EnsureModelAsync_FallsBackToMirror_WhenHuggingFaceUnreachable()
570+
{
571+
var requestedUrls = new List<string>();
572+
var handler = new FallbackHttpMessageHandler(req =>
573+
{
574+
requestedUrls.Add(req.RequestUri!.ToString());
575+
if (req.RequestUri.Host == "huggingface.co")
576+
throw new HttpRequestException("DNS failure",
577+
new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.HostNotFound));
578+
});
579+
var options = Options.Create(new CoreOptions { ModelStoragePath = _tempDir });
580+
var mgr = new ModelManager(options, new HttpClient(handler), _logger);
581+
582+
var desc = new ModelDescriptor("fallback-test", "Fallback Test",
583+
"https://huggingface.co/test/model/resolve/main/model.bin", 100, ModelType.SpeechToText);
584+
585+
await mgr.EnsureModelAsync(desc, null, CancellationToken.None);
586+
587+
Assert.Contains(requestedUrls, u => u.Contains("hf-mirror.com"));
588+
Assert.True(File.Exists(Path.Combine(_tempDir, "fallback-test", "manifest.json")));
589+
}
590+
591+
[Fact]
592+
public async Task EnsureModelAsync_UsesUserMirror_WhenConfigured()
593+
{
594+
var requestedUrls = new List<string>();
595+
var handler = new FakeHttpMessageHandler(req => requestedUrls.Add(req.RequestUri!.ToString()));
596+
var options = Options.Create(new CoreOptions
597+
{
598+
ModelStoragePath = _tempDir,
599+
HuggingFaceMirror = "https://my-mirror.example.com"
600+
});
601+
var mgr = new ModelManager(options, new HttpClient(handler), _logger);
602+
603+
var desc = new ModelDescriptor("mirror-test", "Mirror Test",
604+
"https://huggingface.co/test/model/resolve/main/model.bin", 100, ModelType.Translation);
605+
606+
await mgr.EnsureModelAsync(desc, null, CancellationToken.None);
607+
608+
Assert.All(requestedUrls, u => Assert.DoesNotContain("huggingface.co", u));
609+
Assert.Contains(requestedUrls, u => u.Contains("my-mirror.example.com"));
610+
}
611+
612+
[Fact]
613+
public async Task EnsureModelAsync_DoesNotRewrite_NonHuggingFaceUrl()
614+
{
615+
var requestedUrls = new List<string>();
616+
var handler = new FakeHttpMessageHandler(req => requestedUrls.Add(req.RequestUri!.ToString()));
617+
var options = Options.Create(new CoreOptions
618+
{
619+
ModelStoragePath = _tempDir,
620+
HuggingFaceMirror = "https://my-mirror.example.com"
621+
});
622+
var mgr = new ModelManager(options, new HttpClient(handler), _logger);
623+
624+
var desc = new ModelDescriptor("nonhf-test", "NonHF Test",
625+
"https://dl.fbaipublicfiles.com/fasttext/model.ftz", 100, ModelType.LanguageDetection);
626+
627+
await mgr.EnsureModelAsync(desc, null, CancellationToken.None);
628+
629+
Assert.Contains(requestedUrls, u => u.Contains("dl.fbaipublicfiles.com"));
630+
Assert.DoesNotContain(requestedUrls, u => u.Contains("my-mirror.example.com"));
631+
}
632+
633+
[Fact]
634+
public async Task EnsureModelAsync_NoFallback_WhenUserMirrorIsSet()
635+
{
636+
var handler = new FallbackHttpMessageHandler(req =>
637+
{
638+
if (req.RequestUri!.Host == "my-mirror.example.com")
639+
throw new HttpRequestException("Mirror also failed",
640+
new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.HostNotFound));
641+
});
642+
var options = Options.Create(new CoreOptions
643+
{
644+
ModelStoragePath = _tempDir,
645+
HuggingFaceMirror = "https://my-mirror.example.com"
646+
});
647+
var mgr = new ModelManager(options, new HttpClient(handler), _logger);
648+
649+
var desc = new ModelDescriptor("nofallback-test", "NoFallback",
650+
"https://huggingface.co/test/model.bin", 100, ModelType.Translation);
651+
652+
await Assert.ThrowsAsync<HttpRequestException>(
653+
() => mgr.EnsureModelAsync(desc, null, CancellationToken.None));
654+
}
655+
568656
private sealed class FakeHttpMessageHandler : HttpMessageHandler
569657
{
570658
private readonly Action<HttpRequestMessage>? _inspector;
@@ -587,4 +675,26 @@ protected override Task<HttpResponseMessage> SendAsync(
587675
return Task.FromResult(response);
588676
}
589677
}
678+
679+
private sealed class FallbackHttpMessageHandler : HttpMessageHandler
680+
{
681+
private readonly Action<HttpRequestMessage>? _inspector;
682+
683+
public FallbackHttpMessageHandler(Action<HttpRequestMessage>? inspector = null)
684+
{
685+
_inspector = inspector;
686+
}
687+
688+
protected override Task<HttpResponseMessage> SendAsync(
689+
HttpRequestMessage request, CancellationToken ct)
690+
{
691+
_inspector?.Invoke(request);
692+
var content = new byte[100];
693+
Array.Fill(content, (byte)'x');
694+
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
695+
{
696+
Content = new ByteArrayContent(content)
697+
});
698+
}
699+
}
590700
}

tests/LiveLingo.Core.Tests/Models/ModelRegistryTests.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ public void OptionalModels_ContainsQwen()
3131
[Fact]
3232
public void AllModels_ContainsAll()
3333
{
34-
Assert.Equal(6, ModelRegistry.AllModels.Count);
34+
Assert.Equal(7, ModelRegistry.AllModels.Count);
3535
Assert.Contains(ModelRegistry.Qwen25_15B, ModelRegistry.AllModels);
3636
Assert.Contains(ModelRegistry.WhisperBase, ModelRegistry.AllModels);
37+
Assert.Contains(ModelRegistry.SileroVad, ModelRegistry.AllModels);
3738
}
3839

3940
[Fact]
@@ -125,6 +126,19 @@ public void AllModels_HaveNonEmptyDisplayNames()
125126
Assert.False(string.IsNullOrEmpty(model.DisplayName), $"Model {model.Id} has empty DisplayName");
126127
}
127128

129+
[Fact]
130+
public void SileroVad_HasCorrectType()
131+
{
132+
Assert.Equal(ModelType.VoiceActivityDetection, ModelRegistry.SileroVad.Type);
133+
Assert.True(ModelRegistry.SileroVad.SizeBytes > 0);
134+
}
135+
136+
[Fact]
137+
public void OptionalModels_ContainsSileroVad()
138+
{
139+
Assert.Contains(ModelRegistry.SileroVad, ModelRegistry.OptionalModels);
140+
}
141+
128142
[Theory]
129143
[InlineData("opus-mt-zh-en", "MarianMT Chinese\u2192English")]
130144
[InlineData("opus-mt-en-zh", "MarianMT English\u2192Chinese")]

tests/LiveLingo.Desktop.Tests/ViewModels/OverlayVoiceInputTests.cs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,116 @@ await _pipeline.Received().ProcessAsync(
274274
Arg.Any<CancellationToken>());
275275
}
276276

277+
[Fact]
278+
public async Task ToggleVoice_AppendsToExistingSourceText()
279+
{
280+
var vm = CreateVm();
281+
vm.SourceText = "existing text";
282+
vm.VoiceState = VoiceInputState.Idle;
283+
284+
_coordinator.State.Returns(VoiceInputState.Idle);
285+
_coordinator.StartRecordingAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
286+
.Returns(new SpeechInputResult(true, null, SpeechInputErrorCode.None));
287+
288+
await vm.ToggleVoiceInputCommand.ExecuteAsync(null);
289+
290+
vm.VoiceState = VoiceInputState.Recording;
291+
292+
_coordinator.StopAndTranscribeAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
293+
.Returns(new SpeechInputResult(true, "new voice", SpeechInputErrorCode.None));
294+
295+
await vm.ToggleVoiceInputCommand.ExecuteAsync(null);
296+
297+
Assert.Equal("existing text new voice", vm.SourceText);
298+
}
299+
300+
[Fact]
301+
public async Task ToggleVoice_EmptySourceText_NoLeadingSpace()
302+
{
303+
var vm = CreateVm();
304+
vm.SourceText = string.Empty;
305+
vm.VoiceState = VoiceInputState.Idle;
306+
307+
_coordinator.State.Returns(VoiceInputState.Idle);
308+
_coordinator.StartRecordingAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
309+
.Returns(new SpeechInputResult(true, null, SpeechInputErrorCode.None));
310+
311+
await vm.ToggleVoiceInputCommand.ExecuteAsync(null);
312+
313+
vm.VoiceState = VoiceInputState.Recording;
314+
315+
_coordinator.StopAndTranscribeAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
316+
.Returns(new SpeechInputResult(true, "hello", SpeechInputErrorCode.None));
317+
318+
await vm.ToggleVoiceInputCommand.ExecuteAsync(null);
319+
320+
Assert.Equal("hello", vm.SourceText);
321+
}
322+
323+
[Fact]
324+
public async Task PartialTranscription_AppendsToExistingSourceText()
325+
{
326+
var coordinator = Substitute.For<ISpeechInputCoordinator>();
327+
Action<string>? partialHandler = null;
328+
coordinator.When(c => c.PartialTranscription += Arg.Any<Action<string>>())
329+
.Do(ci => partialHandler = ci.Arg<Action<string>>());
330+
331+
var vm = CreateVm(coordinator);
332+
vm.SourceText = "before";
333+
334+
coordinator.State.Returns(VoiceInputState.Idle);
335+
coordinator.StartRecordingAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
336+
.Returns(new SpeechInputResult(true, null, SpeechInputErrorCode.None));
337+
338+
await vm.ToggleVoiceInputCommand.ExecuteAsync(null);
339+
340+
vm.VoiceState = VoiceInputState.Recording;
341+
Assert.NotNull(partialHandler);
342+
partialHandler!.Invoke("partial result");
343+
344+
Assert.Equal("before partial result", vm.SourceText);
345+
}
346+
347+
[Fact]
348+
public async Task PartialTranscription_IgnoredWhenNotRecording()
349+
{
350+
var coordinator = Substitute.For<ISpeechInputCoordinator>();
351+
Action<string>? partialHandler = null;
352+
coordinator.When(c => c.PartialTranscription += Arg.Any<Action<string>>())
353+
.Do(ci => partialHandler = ci.Arg<Action<string>>());
354+
355+
var vm = CreateVm(coordinator);
356+
vm.SourceText = "original";
357+
358+
Assert.NotNull(partialHandler);
359+
partialHandler!.Invoke("should be ignored");
360+
361+
Assert.Equal("original", vm.SourceText);
362+
}
363+
364+
[Fact]
365+
public async Task ToggleVoice_ExistingTextEndingWithSpace_NoDoubleSpace()
366+
{
367+
var vm = CreateVm();
368+
vm.SourceText = "ends with space ";
369+
vm.VoiceState = VoiceInputState.Idle;
370+
371+
_coordinator.State.Returns(VoiceInputState.Idle);
372+
_coordinator.StartRecordingAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
373+
.Returns(new SpeechInputResult(true, null, SpeechInputErrorCode.None));
374+
375+
await vm.ToggleVoiceInputCommand.ExecuteAsync(null);
376+
377+
vm.VoiceState = VoiceInputState.Recording;
378+
379+
_coordinator.StopAndTranscribeAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
380+
.Returns(new SpeechInputResult(true, "next", SpeechInputErrorCode.None));
381+
382+
await vm.ToggleVoiceInputCommand.ExecuteAsync(null);
383+
384+
Assert.Equal("ends with space next", vm.SourceText);
385+
}
386+
277387
private sealed class DeterministicTranslationEngine : ITranslationEngine
278388
{
279389
public IReadOnlyList<LanguageInfo> SupportedLanguages { get; } =

0 commit comments

Comments
 (0)