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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ jobs:
${{ runner.os }}-nuget-
- name: Restore
run: dotnet restore ArcChat.slnx
- name: Build source generators
run: dotnet build tools/ArcChat.IconCodegen/ArcChat.IconCodegen.csproj --no-restore
- name: Format
run: dotnet format ArcChat.slnx --verify-no-changes --no-restore --verbosity minimal
- name: Build
Expand Down
3 changes: 3 additions & 0 deletions ArcChat.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
<Project Path="integrations/ArcChat.Integrations.Mcp.Tests/ArcChat.Integrations.Mcp.Tests.csproj" />
</Folder>
<Folder Name="/native/" />
<Folder Name="/tools/">
<Project Path="tools/ArcChat.IconCodegen/ArcChat.IconCodegen.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ArcChat.Architecture.Tests/ArcChat.Architecture.Tests.csproj" />
</Folder>
Expand Down
4 changes: 2 additions & 2 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<Exec Command="pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File &quot;$(ArcChatRepositoryRoot)scripts\verify-forbidden-patterns.ps1&quot;" />
</Target>

<Target Name="VerifyNotice">
<Message Importance="High" Text="NOTICE verification is active once NC03 vendors desktop resources; no vendored resources are present in NC01." />
<Target Name="VerifyNotice" BeforeTargets="CoreCompile" DependsOnTargets="ConvertLocales" Condition="'$(MSBuildProjectName)' == 'ArcChat.Desktop'">
<Exec Command="pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File &quot;$(ArcChatRepositoryRoot)scripts\verify-notice.ps1&quot;" />
</Target>
</Project>
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
<PackageVersion Include="Avalonia.Headless" Version="12.0.3" />
<PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="12.0.3" />
<PackageVersion Include="Avalonia.Skia" Version="12.0.3" />
<PackageVersion Include="Avalonia.Svg.Skia" Version="11.3.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
Expand Down Expand Up @@ -51,6 +52,7 @@

<!-- Analyzers: NC01 code-quality gates. -->
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.300" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4" />
<GlobalPackageReference Include="Meziantou.Analyzer" Version="3.0.91" PrivateAssets="all" />
<GlobalPackageReference Include="Roslynator.Analyzers" Version="4.15.0" PrivateAssets="all" />
Expand Down
204 changes: 204 additions & 0 deletions apps/desktop/ArcChat.Desktop.UiTests/AccessibilityBaselineTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Copyright (c) ArcForges. Licensed under the MIT License.

using ArcChat.Desktop.Features.Settings;
using ArcChat.Desktop.Navigation;
using ArcChat.Desktop.ViewModels;
using ArcChat.Desktop.Views;
using ArcChat.UI.Controls;
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using FluentAssertions;
using Xunit;

namespace ArcChat.Desktop.UiTests;

public sealed class AccessibilityBaselineTests
{
[Fact]
public static async Task ShellInteractiveControlsExposeAutomationNamesAndTabTraversal()
{
using HeadlessUnitTestSession session = HeadlessUnitTestSession.StartNew(typeof(TestAppBuilder));
await session.Dispatch(
() =>
{
using MainWindowViewModel viewModel = new MainWindowViewModel(new AppNavigator());
MainWindow window = new MainWindow
{
DataContext = viewModel,
};

try
{
window.Show();
window.Activate();
Dispatcher.UIThread.RunJobs();

AssertNamedInteractiveControls(window);
AssertTabVisitsVisibleControls(window);
}
finally
{
window.Close();
}
},
CancellationToken.None);
}

[Fact]
public static async Task SettingsInteractiveControlsExposeAutomationNamesAndTabTraversal()
{
using HeadlessUnitTestSession session = HeadlessUnitTestSession.StartNew(typeof(TestAppBuilder));
await session.Dispatch(
() =>
{
using SettingsViewModel viewModel = new SettingsViewModel();
SettingsView settingsView = new SettingsView
{
DataContext = viewModel,
};
Window window = new Window
{
Width = 720,
Height = 480,
Content = settingsView,
};

try
{
window.Show();
window.Activate();
Dispatcher.UIThread.RunJobs();

TabItem[] tabs = window.GetVisualDescendants().OfType<TabItem>().ToArray();
foreach (TabItem tab in tabs)
{
tab.IsSelected = true;
Dispatcher.UIThread.RunJobs();

AssertNamedInteractiveControls(window);
AssertTabVisitsVisibleControls(window);
}
}
finally
{
window.Close();
}
},
CancellationToken.None);
}

private static void AssertNamedInteractiveControls(TopLevel topLevel)
{
string[] missingNames = FindInteractiveControls(topLevel)
.Where(static control => string.IsNullOrWhiteSpace(AutomationProperties.GetName(control)))
.Select(Describe)
.ToArray();

_ = missingNames.Should().BeEmpty("every tab-reachable interactive control must expose an automation name");
}

private static void AssertTabVisitsVisibleControls(Window window)
{
Control[] interactiveControls = FindInteractiveControls(window);
string[] expectedNames = interactiveControls
.Select(AutomationProperties.GetName)
.OfType<string>()
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.Ordinal)
.ToArray();
_ = expectedNames.Should().NotBeEmpty();

Control first = interactiveControls[0];
_ = first.Focus(NavigationMethod.Tab, KeyModifiers.None);
Dispatcher.UIThread.RunJobs();

HashSet<string> visited = new HashSet<string>(StringComparer.Ordinal);
for (int index = 0; index < expectedNames.Length * 4; index++)
{
if (window.FocusManager?.GetFocusedElement() is IInputElement focused
&& TryReadAutomationName(focused, out string? name))
{
_ = visited.Add(name);
}

window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, "\t");
Dispatcher.UIThread.RunJobs();
}

foreach (string expectedName in expectedNames)
{
_ = visited.Should().Contain(expectedName);
}
}

private static Control[] FindInteractiveControls(TopLevel topLevel)
{
return topLevel.GetVisualDescendants()
.OfType<Control>()
.Where(IsUserFacingInteractiveControl)
.Where(static control => control.IsVisible && control.Focusable && control.Bounds.Width > 0 && control.Bounds.Height > 0)
.ToArray();
}

private static bool IsUserFacingInteractiveControl(Control control)
{
if (control.Name?.StartsWith("PART_", StringComparison.Ordinal) == true)
{
return false;
}

if (control is TabItem tabItem && !tabItem.IsSelected)
{
return false;
}

return control is Button
or CheckBox
or ComboBox
or GridSplitter
or IconButton
or NumericUpDown
or TabItem
or TextBox;
}

private static bool TryReadAutomationName(IInputElement inputElement, out string name)
{
if (inputElement is Control control)
{
string? automationName = SelfAndAncestors(control)
.Select(AutomationProperties.GetName)
.Where(static candidateName => !string.IsNullOrWhiteSpace(candidateName))
.FirstOrDefault();
if (automationName is not null)
{
name = automationName;
return true;
}
}

name = string.Empty;
return false;
}

private static IEnumerable<Control> SelfAndAncestors(Control control)
{
yield return control;

foreach (Control ancestor in control.GetVisualAncestors().OfType<Control>())
{
yield return ancestor;
}
}

private static string Describe(Control control)
{
return string.IsNullOrWhiteSpace(control.Name)
? control.GetType().Name
: control.GetType().Name + "#" + control.Name;
}
}
55 changes: 55 additions & 0 deletions apps/desktop/ArcChat.Desktop.UiTests/AppNavigatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) ArcForges. Licensed under the MIT License.

using ArcChat.Desktop.Navigation;
using FluentAssertions;
using Xunit;

namespace ArcChat.Desktop.UiTests;

public sealed class AppNavigatorTests
{
[Fact]
public static void NavigatorPublishesCurrentDestinationAndHistory()
{
AppNavigator navigator = new AppNavigator();
List<Destination> observed = new List<Destination>();
using IDisposable subscription = navigator.CurrentDestination.Subscribe(new DestinationObserver(observed.Add));

navigator.Navigate(new NewChat());
navigator.Navigate(new Settings(SettingsSection.Providers));

_ = navigator.Back().Should().BeTrue();
_ = navigator.Current.Should().BeOfType<NewChat>();
_ = navigator.Forward().Should().BeTrue();
_ = navigator.Current.Should().BeOfType<Settings>();
_ = navigator.Forward().Should().BeFalse();

_ = observed.Select(destination => destination.Id)
.Should()
.Equal("home", "new-chat", "settings", "new-chat", "settings");
}

private sealed class DestinationObserver : IObserver<Destination>
{
private readonly Action<Destination> onNext;

public DestinationObserver(Action<Destination> onNext)
{
this.onNext = onNext;
}

public void OnCompleted()
{
}

public void OnError(Exception error)
{
ArgumentNullException.ThrowIfNull(error);
}

public void OnNext(Destination value)
{
this.onNext(value);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Skia" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" />
Expand All @@ -20,4 +21,8 @@
<ItemGroup>
<ProjectReference Include="..\ArcChat.Desktop\ArcChat.Desktop.csproj" />
</ItemGroup>

<ItemGroup>
<AvaloniaResource Include="Resources\IconReferences.axaml" />
</ItemGroup>
</Project>
5 changes: 5 additions & 0 deletions apps/desktop/ArcChat.Desktop.UiTests/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) ArcForges. Licensed under the MIT License.

using Xunit;

[assembly: CollectionBehavior(DisableTestParallelization = true)]
53 changes: 53 additions & 0 deletions apps/desktop/ArcChat.Desktop.UiTests/IconResourceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) ArcForges. Licensed under the MIT License.

using System.Reflection;
using Avalonia.Headless;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using FluentAssertions;
using Xunit;

namespace ArcChat.Desktop.UiTests;

public sealed class IconResourceTests
{
[Fact]
public static async Task IconReferencesLoadFromAxaml()
{
using HeadlessUnitTestSession session = HeadlessUnitTestSession.StartNew(typeof(TestAppBuilder));
await session.Dispatch(
() =>
{
Uri iconReferences = new Uri("avares://ArcChat.Desktop.UiTests/Resources/IconReferences.axaml");
Action load = () => _ = AvaloniaXamlLoader.Load(iconReferences, iconReferences);

_ = load.Should().NotThrow();
},
CancellationToken.None);
}

[Fact]
public static async Task GeneratedIconUrisResolveAvaloniaResources()
{
using HeadlessUnitTestSession session = HeadlessUnitTestSession.StartNew(typeof(TestAppBuilder));
await session.Dispatch(
() =>
{
Type iconsType = typeof(App).Assembly.GetType("ArcChat.Desktop.Resources.Icons", throwOnError: true)
?? throw new InvalidOperationException("Generated icon type was not found.");
PropertyInfo[] iconProperties = iconsType.GetProperties(BindingFlags.Public | BindingFlags.Static)
.Where(static property => property.PropertyType == typeof(string))
.OrderBy(static property => property.Name, StringComparer.Ordinal)
.ToArray();

_ = iconProperties.Should().HaveCount(89);
foreach (PropertyInfo property in iconProperties)
{
string uri = property.GetValue(null).Should().BeOfType<string>().Subject;
using Stream stream = AssetLoader.Open(new Uri(uri));
_ = stream.CanRead.Should().BeTrue(property.Name);
}
},
CancellationToken.None);
}
}
Loading
Loading