Skip to content

Commit 7778022

Browse files
committed
feat(avalonia): implement ExamplesBrowserWindow
- Create ExamplesBrowserViewModel with search and preview - Implement ExamplesBrowserWindow.axaml with split view - Left panel: searchable examples list with filtering - Right panel: code preview with monospace font - Reactive search with 300ms throttle for performance - Preview loads automatically on selection with 100ms debounce - Add comprehensive unit tests for ViewModel - Use IDisposable pattern for reactive subscriptions
1 parent 8897f00 commit 7778022

File tree

4 files changed

+425
-0
lines changed

4 files changed

+425
-0
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using ARMEmulator.Models;
2+
using ARMEmulator.Services;
3+
using ARMEmulator.ViewModels;
4+
using FluentAssertions;
5+
using NSubstitute;
6+
7+
namespace ARMEmulator.Tests.ViewModels;
8+
9+
/// <summary>
10+
/// Tests for ExamplesBrowserViewModel.
11+
/// </summary>
12+
public sealed class ExamplesBrowserViewModelTests
13+
{
14+
[Fact]
15+
public async Task Constructor_LoadsExamplesAsync()
16+
{
17+
// Arrange
18+
var mockApi = Substitute.For<IApiClient>();
19+
mockApi.GetExamplesAsync(Arg.Any<CancellationToken>())
20+
.Returns([
21+
new ExampleInfo("hello", "Hello World", 100),
22+
new ExampleInfo("fibonacci", "Fibonacci", 250)
23+
]);
24+
25+
// Act
26+
using var vm = new ExamplesBrowserViewModel(mockApi);
27+
await vm.LoadExamplesAsync();
28+
29+
// Assert
30+
vm.Examples.Should().HaveCount(2);
31+
vm.Examples[0].Name.Should().Be("hello");
32+
vm.Examples[1].Name.Should().Be("fibonacci");
33+
}
34+
35+
[Fact]
36+
public async Task SearchText_FiltersExamples()
37+
{
38+
// Arrange
39+
var mockApi = Substitute.For<IApiClient>();
40+
mockApi.GetExamplesAsync(Arg.Any<CancellationToken>())
41+
.Returns([
42+
new ExampleInfo("hello", "Hello World", 100),
43+
new ExampleInfo("fibonacci", "Fibonacci", 250),
44+
new ExampleInfo("factorial", "Factorial", 200)
45+
]);
46+
47+
using var vm = new ExamplesBrowserViewModel(mockApi);
48+
await vm.LoadExamplesAsync();
49+
50+
// Act
51+
vm.SearchText = "fib";
52+
await Task.Delay(350); // Wait for throttle
53+
54+
// Assert
55+
vm.FilteredExamples.Should().HaveCount(1);
56+
vm.FilteredExamples[0].Name.Should().Be("fibonacci");
57+
}
58+
59+
[Fact]
60+
public async Task SearchText_IsCaseInsensitive()
61+
{
62+
// Arrange
63+
var mockApi = Substitute.For<IApiClient>();
64+
mockApi.GetExamplesAsync(Arg.Any<CancellationToken>())
65+
.Returns([
66+
new ExampleInfo("hello", "Hello World", 100)
67+
]);
68+
69+
using var vm = new ExamplesBrowserViewModel(mockApi);
70+
await vm.LoadExamplesAsync();
71+
72+
// Act
73+
vm.SearchText = "HELLO";
74+
await Task.Delay(350); // Wait for throttle
75+
76+
// Assert
77+
vm.FilteredExamples.Should().HaveCount(1);
78+
}
79+
80+
[Fact]
81+
public async Task SearchText_MatchesNameAndDescription()
82+
{
83+
// Arrange
84+
var mockApi = Substitute.For<IApiClient>();
85+
mockApi.GetExamplesAsync(Arg.Any<CancellationToken>())
86+
.Returns([
87+
new ExampleInfo("test1", "Example with loops", 100),
88+
new ExampleInfo("loops", "Loop demonstration", 150)
89+
]);
90+
91+
using var vm = new ExamplesBrowserViewModel(mockApi);
92+
await vm.LoadExamplesAsync();
93+
94+
// Act
95+
vm.SearchText = "loop";
96+
await Task.Delay(350); // Wait for throttle
97+
98+
// Assert
99+
vm.FilteredExamples.Should().HaveCount(2);
100+
}
101+
102+
[Fact]
103+
public async Task SelectedExample_LoadsContent()
104+
{
105+
// Arrange
106+
var mockApi = Substitute.For<IApiClient>();
107+
mockApi.GetExamplesAsync(Arg.Any<CancellationToken>())
108+
.Returns([new ExampleInfo("hello", "Hello World", 100)]);
109+
mockApi.GetExampleContentAsync("hello", Arg.Any<CancellationToken>())
110+
.Returns(".global _start\n_start:\n MOV R0, #42\n");
111+
112+
using var vm = new ExamplesBrowserViewModel(mockApi);
113+
await vm.LoadExamplesAsync();
114+
115+
// Act
116+
vm.SelectedExample = vm.Examples[0];
117+
await Task.Delay(150); // Wait for debounce
118+
119+
// Assert
120+
vm.PreviewContent.Should().Contain("MOV R0, #42");
121+
}
122+
123+
[Fact]
124+
public async Task LoadExamplesAsync_HandlesError()
125+
{
126+
// Arrange
127+
var mockApi = Substitute.For<IApiClient>();
128+
mockApi.GetExamplesAsync(Arg.Any<CancellationToken>())
129+
.Returns<ImmutableArray<ExampleInfo>>(_ => throw new ApiException("Network error"));
130+
131+
using var vm = new ExamplesBrowserViewModel(mockApi);
132+
133+
// Act
134+
await vm.LoadExamplesAsync();
135+
136+
// Assert
137+
vm.Examples.Should().BeEmpty();
138+
vm.ErrorMessage.Should().Contain("Failed to load");
139+
}
140+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
using System.Reactive.Disposables;
2+
using System.Reactive.Linq;
3+
using ARMEmulator.Models;
4+
using ARMEmulator.Services;
5+
using ReactiveUI;
6+
7+
// ReactiveUI uses reflection for WhenAnyValue and RaiseAndSetIfChanged
8+
#pragma warning disable IL2026
9+
10+
namespace ARMEmulator.ViewModels;
11+
12+
/// <summary>
13+
/// ViewModel for the Examples Browser window.
14+
/// Provides searchable list of example programs with preview.
15+
/// </summary>
16+
public sealed class ExamplesBrowserViewModel : ReactiveObject, IDisposable
17+
{
18+
private readonly IApiClient api;
19+
private readonly CompositeDisposable disposables = [];
20+
private ImmutableArray<ExampleInfo> examples = [];
21+
private ImmutableArray<ExampleInfo> filteredExamples = [];
22+
private ExampleInfo? selectedExample;
23+
private string searchText = "";
24+
private string previewContent = "";
25+
private string? errorMessage;
26+
private bool isLoading;
27+
28+
public ExamplesBrowserViewModel(IApiClient api)
29+
{
30+
this.api = api;
31+
32+
// Update filtered examples when search text or examples change
33+
_ = this.WhenAnyValue(x => x.SearchText, x => x.Examples)
34+
.Throttle(TimeSpan.FromMilliseconds(300))
35+
.ObserveOn(RxApp.MainThreadScheduler)
36+
.Subscribe(_ => UpdateFilteredExamples())
37+
.DisposeWith(disposables);
38+
39+
// Load preview content when selection changes
40+
// Fire-and-forget is acceptable - exceptions are handled in LoadPreviewContentAsync
41+
#pragma warning disable VSTHRD101
42+
_ = this.WhenAnyValue(x => x.SelectedExample)
43+
.Throttle(TimeSpan.FromMilliseconds(100))
44+
.ObserveOn(RxApp.MainThreadScheduler)
45+
.Subscribe(async example => await LoadPreviewContentAsync(example))
46+
.DisposeWith(disposables);
47+
#pragma warning restore VSTHRD101
48+
}
49+
50+
/// <summary>All available examples.</summary>
51+
public ImmutableArray<ExampleInfo> Examples
52+
{
53+
get => examples;
54+
private set => this.RaiseAndSetIfChanged(ref examples, value);
55+
}
56+
57+
/// <summary>Filtered examples based on search text.</summary>
58+
public ImmutableArray<ExampleInfo> FilteredExamples
59+
{
60+
get => filteredExamples;
61+
private set => this.RaiseAndSetIfChanged(ref filteredExamples, value);
62+
}
63+
64+
/// <summary>Currently selected example.</summary>
65+
public ExampleInfo? SelectedExample
66+
{
67+
get => selectedExample;
68+
set => this.RaiseAndSetIfChanged(ref selectedExample, value);
69+
}
70+
71+
/// <summary>Search text for filtering examples.</summary>
72+
public string SearchText
73+
{
74+
get => searchText;
75+
set => this.RaiseAndSetIfChanged(ref searchText, value);
76+
}
77+
78+
/// <summary>Preview content of selected example.</summary>
79+
public string PreviewContent
80+
{
81+
get => previewContent;
82+
private set => this.RaiseAndSetIfChanged(ref previewContent, value);
83+
}
84+
85+
/// <summary>Error message if loading fails.</summary>
86+
public string? ErrorMessage
87+
{
88+
get => errorMessage;
89+
private set => this.RaiseAndSetIfChanged(ref errorMessage, value);
90+
}
91+
92+
/// <summary>Whether examples are currently loading.</summary>
93+
public bool IsLoading
94+
{
95+
get => isLoading;
96+
private set => this.RaiseAndSetIfChanged(ref isLoading, value);
97+
}
98+
99+
/// <summary>
100+
/// Loads the list of examples from the backend.
101+
/// </summary>
102+
public async Task LoadExamplesAsync()
103+
{
104+
try {
105+
IsLoading = true;
106+
ErrorMessage = null;
107+
108+
var exampleList = await api.GetExamplesAsync();
109+
Examples = exampleList;
110+
FilteredExamples = exampleList;
111+
}
112+
catch (Exception ex) {
113+
ErrorMessage = $"Failed to load examples: {ex.Message}";
114+
Examples = [];
115+
FilteredExamples = [];
116+
}
117+
finally {
118+
IsLoading = false;
119+
}
120+
}
121+
122+
/// <summary>
123+
/// Updates filtered examples based on current search text.
124+
/// </summary>
125+
private void UpdateFilteredExamples()
126+
{
127+
if (string.IsNullOrWhiteSpace(SearchText)) {
128+
FilteredExamples = Examples;
129+
return;
130+
}
131+
132+
var query = SearchText;
133+
FilteredExamples = Examples
134+
.Where(e => e.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
135+
e.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
136+
.ToImmutableArray();
137+
}
138+
139+
/// <summary>
140+
/// Loads preview content for the selected example.
141+
/// </summary>
142+
private async Task LoadPreviewContentAsync(ExampleInfo? example)
143+
{
144+
if (example is null) {
145+
PreviewContent = "";
146+
return;
147+
}
148+
149+
try {
150+
var content = await api.GetExampleContentAsync(example.Name);
151+
PreviewContent = content;
152+
}
153+
catch (Exception ex) {
154+
PreviewContent = $"Error loading preview: {ex.Message}";
155+
}
156+
}
157+
158+
public void Dispose() => disposables.Dispose();
159+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<Window xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
xmlns:vm="using:ARMEmulator.ViewModels"
4+
x:Class="ARMEmulator.Views.ExamplesBrowserWindow"
5+
x:DataType="vm:ExamplesBrowserViewModel"
6+
Title="Browse Examples"
7+
Width="900"
8+
Height="600"
9+
WindowStartupLocation="CenterOwner"
10+
Icon="/Assets/avalonia-logo.ico">
11+
12+
<DockPanel>
13+
<!-- Action Buttons -->
14+
<StackPanel DockPanel.Dock="Bottom"
15+
Orientation="Horizontal"
16+
HorizontalAlignment="Right"
17+
Margin="16"
18+
Spacing="12">
19+
<Button Content="Cancel" MinWidth="80" Click="CancelButton_Click" />
20+
<Button Content="Load" MinWidth="80" Click="LoadButton_Click"
21+
IsEnabled="{Binding SelectedExample, Converter={x:Static ObjectConverters.IsNotNull}}"
22+
IsDefault="True" />
23+
</StackPanel>
24+
25+
<!-- Split View -->
26+
<Grid Margin="16">
27+
<Grid.ColumnDefinitions>
28+
<ColumnDefinition Width="300" MinWidth="200" />
29+
<ColumnDefinition Width="5" />
30+
<ColumnDefinition Width="*" MinWidth="300" />
31+
</Grid.ColumnDefinitions>
32+
33+
<!-- Left: Examples List -->
34+
<DockPanel Grid.Column="0">
35+
<!-- Search -->
36+
<TextBox DockPanel.Dock="Top"
37+
Text="{Binding SearchText}"
38+
Watermark="Search examples..."
39+
Margin="0,0,0,8" />
40+
41+
<!-- Examples List -->
42+
<ListBox ItemsSource="{Binding FilteredExamples}"
43+
SelectedItem="{Binding SelectedExample}">
44+
<ListBox.ItemTemplate>
45+
<DataTemplate>
46+
<StackPanel Spacing="2">
47+
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
48+
<TextBlock Text="{Binding Description}"
49+
FontSize="11"
50+
Foreground="{DynamicResource SystemBaseMediumColor}" />
51+
<TextBlock Text="{Binding Size, StringFormat='{}{0} bytes'}"
52+
FontSize="10"
53+
Foreground="{DynamicResource SystemBaseLowColor}" />
54+
</StackPanel>
55+
</DataTemplate>
56+
</ListBox.ItemTemplate>
57+
</ListBox>
58+
</DockPanel>
59+
60+
<!-- Splitter -->
61+
<GridSplitter Grid.Column="1" Width="5" />
62+
63+
<!-- Right: Preview -->
64+
<DockPanel Grid.Column="2">
65+
<TextBlock DockPanel.Dock="Top" Text="Preview" FontWeight="SemiBold" Margin="0,0,0,8" />
66+
<TextBox Text="{Binding PreviewContent}"
67+
IsReadOnly="True"
68+
AcceptsReturn="True"
69+
TextWrapping="NoWrap"
70+
FontFamily="Consolas,Menlo,Monaco,monospace"
71+
FontSize="12" />
72+
</DockPanel>
73+
</Grid>
74+
</DockPanel>
75+
</Window>

0 commit comments

Comments
 (0)