From 967713c01a912fbff16acac706978d649f276390 Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 6 Mar 2026 23:43:41 -0500 Subject: [PATCH] test(perf): add performance benchmarks for rendering pipeline Add performance benchmark tests for critical rendering paths including Measure/Arrange operations on flat layouts (100 children), deep nesting (20 levels), and Grid layouts (10x10). Include HitTest performance validation. Tests use Stopwatch with generous upper bounds to catch regressions while avoiding flaky failures on slow CI machines. Benchmarks verify operations complete within acceptable time budgets (5-10ms thresholds). --- .notes/note-1772851947498-ujdbzwenk.json | 8 - tests/Rendering/PerformanceBenchmarkTests.cs | 519 +++++++++++++++++++ 2 files changed, 519 insertions(+), 8 deletions(-) delete mode 100644 .notes/note-1772851947498-ujdbzwenk.json create mode 100644 tests/Rendering/PerformanceBenchmarkTests.cs diff --git a/.notes/note-1772851947498-ujdbzwenk.json b/.notes/note-1772851947498-ujdbzwenk.json deleted file mode 100644 index 26cbda7..0000000 --- a/.notes/note-1772851947498-ujdbzwenk.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "note-1772851947498-ujdbzwenk", - "title": "Working list", - "content": " --- \n Senior Architect Review: OpenMaui Linux Platform v1.0.0 \n \n Executive Summary \n\n OpenMaui Linux is a substantial and ambitious .NET MAUI platform backend — approximately 324 source files,\n 30,000+ lines of view code alone — implementing a full Linux desktop experience via SkiaSharp rendering with\n GTK4/X11/Wayland integration. The architecture demonstrates strong software engineering fundamentals with\n consistent patterns, clean separation of concerns, and comprehensive control coverage.\n\n Overall Score: 7.8 / 10\n\n The project is architecturally sound for a v1.0 release but has specific areas that need attention before it\n can be considered production-hardened.\n\n ---\n 1. Architecture — Score: 9/10\n\n Strengths:\n - Clean 4-layer architecture: MAUI Virtual Views → Handlers → Skia Platform Views → Native Window\n - Proper use of the MAUI handler pattern (ViewHandler) with\n PropertyMapper/CommandMapper\n - 47 handlers covering all standard MAUI controls\n - Comprehensive BindableProperty system (33 base properties + ~200 across controls) enabling full XAML/data\n binding support\n - Dirty-region rendering engine with configurable thresholds\n - Optional GPU acceleration path with CPU fallback\n - Well-designed service layer (18+ platform services registered via DI)\n - Display server abstraction (X11, Wayland, GTK modes)\n\n Concerns:\n - LinuxApplication is a God Object: the main class + 2 partials span ~48KB total. Consider extracting an\n EventLoop, WindowManager, and InputRouter as separate concerns\n - LinuxViewRenderer at ~600 lines handles both view creation and navigation — these are distinct\n responsibilities\n - Static mutable state in several places (GestureManager, LinuxViewRenderer.CurrentMauiShell,\n LinuxApplication.Current) creates implicit coupling and testability issues\n\n ---\n 2. Native Interop Safety — Score: 5/10 (Critical)\n\n This is the highest-risk area in the codebase. Multiple memory leak and resource management issues were\n found:\n\n Critical Issues\n\n ┌────────────────────┬─────────────────────────────────────┬────────────────────────────────────────────┐\n │ Issue │ Location │ Impact │\n ├────────────────────┼─────────────────────────────────────┼────────────────────────────────────────────┤\n │ │ │ Memory leak — delegates accumulate │\n │ GTK idle callbacks │ Native/GtkNative.cs:168-173 │ indefinitely. GLibNative.cs has the │\n │ never removed │ │ correct pattern with cleanup, but │\n │ │ │ GtkNative.cs does not │\n ├────────────────────┼─────────────────────────────────────┼────────────────────────────────────────────┤\n │ dlopen handles │ WebKitNative.cs:132,167 │ Handle leak on every Initialize() call │\n │ never closed │ │ │\n ├────────────────────┼─────────────────────────────────────┼────────────────────────────────────────────┤\n │ GTK signals not │ GtkHostWindow.cs:331-343, │ 8+ signal handlers remain connected to │\n │ disconnected in │ GtkSkiaSurfaceWidget.cs:383-396 │ destroyed widgets — potential │\n │ Dispose │ │ use-after-free │\n ├────────────────────┼─────────────────────────────────────┼────────────────────────────────────────────┤\n │ CSS providers │ GtkThemeService.cs:55,73 │ Each ApplyTheme() leaks the previous │\n │ never unreferenced │ │ provider (GObject ref count leak) │\n ├────────────────────┼─────────────────────────────────────┼────────────────────────────────────────────┤\n │ WebKit signal │ │ │\n │ handlers connected │ WebKitNative.cs:304-325 │ g_signal_handler_disconnect never called │\n │ but never │ │ │\n │ disconnected │ │ │\n └────────────────────┴─────────────────────────────────────┴────────────────────────────────────────────┘\n\n High Issues\n\n ┌──────────────────────────┬─────────────────────────────────────────┬─────────────────────────────────┐\n │ Issue │ Location │ Impact │\n ├──────────────────────────┼─────────────────────────────────────────┼─────────────────────────────────┤\n │ X11 cursors never freed │ X11Window.cs:181-184 │ 3 cursor allocations leak on │\n │ │ │ every window disposal │\n ├──────────────────────────┼─────────────────────────────────────────┼─────────────────────────────────┤\n │ Partial initialization │ MonitorService.cs:107-111, │ XOpenDisplay result not closed │\n │ failures leak handles │ X11InputMethodService.cs:44-49 │ if later steps fail │\n ├──────────────────────────┼─────────────────────────────────────────┼─────────────────────────────────┤\n │ Inconsistent IntPtr.Zero │ │ Some P/Invoke returns │\n │ validation │ Multiple Native files │ validated, others not — no │\n │ │ │ consistent pattern │\n └──────────────────────────┴─────────────────────────────────────────┴─────────────────────────────────┘\n\n Recommendation\n\n Create a SafeNativeHandle wrapper (similar to SafeHandle in .NET) for all P/Invoke handles. Implement\n IDisposable cleanup methods that mirror the allocation calls. This is the single most important change\n before production use.\n\n ---\n 3. Error Handling — Score: 6/10\n\n Silent Exception Swallowing\n\n 22 empty catch { } blocks found across 7 files:\n\n - SystemThemeService.cs — 7 empty catches\n - SkiaWebView.cs — 5 empty catches\n - NotificationService.cs — 4 empty catches\n - Fcitx5InputMethodService.cs — 3 empty catches\n\n These silently swallow all exceptions including OutOfMemoryException, StackOverflowException, and\n AccessViolationException. At minimum, these should:\n 1. Catch specific exception types\n 2. Log via DiagnosticLog.Error even if swallowed\n 3. Never swallow fatal exceptions\n\n DiagnosticLog Design Issue\n\n DiagnosticLog.Error() bypasses the IsEnabled check and always writes to stderr (line 68-79). This is\n arguably correct for errors but should be documented as intentional behavior.\n\n ---\n 4. Thread Safety — Score: 7.5/10\n\n Good patterns:\n - GLibNative._callbackLock properly guards callback list access\n - SkiaRenderingEngine._dirtyLock protects dirty region management\n - LinuxDispatcher correctly wraps dispatched actions in try-catch\n - FontFallbackManager uses proper double-check locking for singleton\n - WaylandWindow properly frees GCHandles in Dispose\n\n Concerns:\n - GestureManager is entirely static with cached MethodInfo fields — no synchronization if handlers are\n attached from multiple threads\n - No CancellationToken support in async image loading paths — cancellation relies on object disposal\n - GTK thread marshaling via IdleAdd is correct but not validated (what if the GTK loop isn't running?)\n\n ---\n 5. Test Coverage — Score: 3/10 (Critical)\n\n This is the second highest-risk area:\n\n Coverage by Module\n\n ┌────────────────┬──────────────┬────────────┬──────────┐\n │ Module │ Source Files │ Test Files │ Coverage │\n ├────────────────┼──────────────┼────────────┼──────────┤\n │ Handlers │ 46 │ 0 │ 0% │\n ├────────────────┼──────────────┼────────────┼──────────┤\n │ Services │ 108 │ 1 │ ~1% │\n ├────────────────┼──────────────┼────────────┼──────────┤\n │ Native/Interop │ 24 │ 0 │ 0% │\n ├────────────────┼──────────────┼────────────┼──────────┤\n │ Dispatching │ 3 │ 0 │ 0% │\n ├────────────────┼──────────────┼────────────┼──────────┤\n │ Rendering │ 11 │ 1 │ ~9% │\n ├────────────────┼──────────────┼────────────┼──────────┤\n │ Views │ 79 │ 30 │ ~38% │\n ├────────────────┼──────────────┼────────────┼──────────┤\n │ Converters │ 6 │ 0 │ 0% │\n └────────────────┴──────────────┴────────────┴──────────┘\n\n Test Quality Issues\n\n 1. ~80% of tests are trivial property get/set validations — they verify label.Text = \"X\";\n label.Text.Should().Be(\"X\") which tests the C# auto-property mechanism, not application logic\n 2. Zero handler tests — the entire bridge layer between MAUI and the platform views has no test coverage.\n This is the most bug-prone layer (property mapping, event forwarding, lifecycle management)\n 3. Zero behavioral tests — no tests verify what happens when:\n - A slider's Minimum is set higher than Maximum\n - Null is assigned to a required property\n - A disabled control receives input\n - The navigation stack is popped when empty\n 4. Moq is installed but never used — dependency injection and mocking are completely unused despite being\n available\n 5. No parameterized tests — zero [Theory]/[InlineData] usage, leading to massive duplication\n 6. \"Does Not Throw\" anti-pattern — 20+ tests only verify Record.Exception(() => ...).Should().BeNull() which\n proves the code doesn't crash, not that it works correctly\n\n Priority Test Additions\n\n 1. Handler layer tests (property mapping, event forwarding, connect/disconnect lifecycle)\n 2. Edge case tests (null inputs, boundary values, invalid state transitions)\n 3. Layout integration tests (Measure/Arrange with complex hierarchies)\n 4. Service tests with mocked native dependencies\n\n ---\n 6. View Layer — Score: 9/10\n\n The Skia view implementations are the strongest part of the codebase:\n\n - Consistent patterns across all 79 files (BindableProperty declarations, invalidation cascading, event\n publishing)\n - Zero TODO/FIXME comments — code appears complete\n - Comprehensive accessibility — full IAccessible implementation with AT-SPI2 bridge\n - RTL support via FlowDirection property\n - Visual State Manager integration for theming\n - Text rendering with HarfBuzz shaping, emoji support, and 6-level font fallback chain\n - IME support for Entry/Editor with IBus, Fcitx5, and XIM backends\n\n Minor items:\n - TabItem.cs:12 uses null! — should be nullable or initialized\n - 3 diagnostic log calls in SkiaButton reference \"Round\" specifically — should be generalized\n\n ---\n 7. API Design — Score: 8/10\n\n Good:\n - Clean public API via UseOpenMauiLinux() extension method\n - LinuxApplicationOptions provides reasonable defaults with override capability\n - Handler registration follows standard MAUI conventions exactly\n - BindableProperty system enables full XAML compatibility\n\n Improvement Areas:\n - GestureManager configurable thresholds are public static properties — should be in LinuxApplicationOptions\n or a dedicated config object\n - SkiaRenderingEngine.MaxDirtyRegions and RegionMergeThreshold as static properties break testability\n - Several services use singleton pattern with static Instance rather than DI — inconsistent with the\n DI-first approach used elsewhere\n\n ---\n 8. Documentation — Score: 8.5/10\n\n - Comprehensive README with quick-start, control inventory, and service listing\n - Architecture notes document (docs/architectnotes.md, 480 lines) covers design decisions\n - GETTING_STARTED, FAQ, API, CUSTOM_CONTROLS guides present\n - CHANGELOG and ROADMAP maintained\n - XML doc comments on public API throughout the view layer\n\n Missing:\n - No inline architecture decision records (ADRs) for key decisions (why SkiaSharp over Avalonia? why\n reflection for gesture dispatch?)\n - No threading model documentation (when is GTK thread required? what can run on background threads?)\n - No contribution guide for adding new controls (step-by-step handler + view creation)\n\n ---\n 9. Packaging & Distribution — Score: 8/10\n\n - NuGet .csproj and .nuspec properly configured for v1.0.0\n - Build targets file for auto-import\n - Two project templates (code-based and XAML-based)\n - Visual Studio extension project present\n - Dependencies pinned to specific versions (MAUI 9.0.40, SkiaSharp 2.88.9)\n\n Concern:\n - Template projects reference OpenMaui.Controls.Linux version 1.0.* — wildcard versions are generally\n discouraged for reproducible builds\n\n ---\n 10. Performance Architecture — Score: 7.5/10\n\n Good:\n - Dirty region tracking avoids full-screen redraws\n - Region merging with configurable overlap threshold\n - GPU acceleration option with automatic CPU fallback\n - TextRenderCache and ResourceCache for expensive operations\n - VirtualizationManager for large collections\n - [Conditional(\"DEBUG\")] on diagnostic logging for zero-cost in release\n\n Concerns:\n - No benchmarks or performance tests exist\n - FontFallbackManager.ShapeTextWithFallback appears in hot render paths — should be profiled\n - No object pooling for frequently allocated SKPaint/SKFont instances (beyond ResourceCache)\n - 32 dirty region limit before full redraw may be too aggressive for complex UIs\n\n ---\n Priority Recommendations\n\n P0 — Before Production Use\n\n ┌─────┬───────────────────────────────────────────────────────────────────────────────────────┬─────────┐\n │ # │ Issue │ Effort │\n ├─────┼───────────────────────────────────────────────────────────────────────────────────────┼─────────┤\n │ 1 │ Fix native resource leaks — GTK signal disconnection in Dispose, GtkNative idle │ 2-3 │\n │ │ callback cleanup, X11 cursor freeing, WebKit dlopen/signal cleanup │ days │\n ├─────┼───────────────────────────────────────────────────────────────────────────────────────┼─────────┤\n │ 2 │ Replace empty catch {} blocks with specific exception handling and logging │ 1 day │\n ├─────┼───────────────────────────────────────────────────────────────────────────────────────┼─────────┤\n │ 3 │ Add handler layer tests — at minimum: LabelHandler, EntryHandler, ButtonHandler, │ 3-5 │\n │ │ NavigationPageHandler, CollectionViewHandler │ days │\n └─────┴───────────────────────────────────────────────────────────────────────────────────────┴─────────┘\n\n P1 — Near-Term Stability\n\n ┌─────┬─────────────────────────────────────────────────────────────────────────────────────┬──────────┐\n │ # │ Issue │ Effort │\n ├─────┼─────────────────────────────────────────────────────────────────────────────────────┼──────────┤\n │ 4 │ Add edge-case and null-input tests across all views │ 2-3 days │\n ├─────┼─────────────────────────────────────────────────────────────────────────────────────┼──────────┤\n │ 5 │ Extract EventLoop, WindowManager, InputRouter from LinuxApplication │ 2-3 days │\n ├─────┼─────────────────────────────────────────────────────────────────────────────────────┼──────────┤\n │ 6 │ Standardize singleton pattern — migrate static Instance services to DI registration │ 1-2 days │\n ├─────┼─────────────────────────────────────────────────────────────────────────────────────┼──────────┤\n │ 7 │ Document threading model and native handle ownership │ 1 day │\n └─────┴─────────────────────────────────────────────────────────────────────────────────────┴──────────┘\n\n P2 — Hardening\n\n ┌─────┬───────────────────────────────────────────────────────────────────────┬──────────┐\n │ # │ Issue │ Effort │\n ├─────┼───────────────────────────────────────────────────────────────────────┼──────────┤\n │ 8 │ Create SafeNativeHandle wrappers for all P/Invoke handle types │ 2-3 days │\n ├─────┼───────────────────────────────────────────────────────────────────────┼──────────┤\n │ 9 │ Add performance benchmarks for rendering pipeline │ 2 days │\n ├─────┼───────────────────────────────────────────────────────────────────────┼──────────┤\n │ 10 │ Refactor static configuration to injectable options pattern │ 1-2 days │\n ├─────┼───────────────────────────────────────────────────────────────────────┼──────────┤\n │ 11 │ Add parameterized tests with [Theory] to reduce test code duplication │ 2 days │\n ├─────┼───────────────────────────────────────────────────────────────────────┼──────────┤\n │ 12 │ Add integration test suite for layout Measure/Arrange pipeline │ 3 days │\n └─────┴───────────────────────────────────────────────────────────────────────┴──────────┘\n\n ---\n Scoring Summary\n\n ┌──────────────────────────┬────────┬────────┬───────────┐\n │ Area │ Score │ Weight │ Weighted │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Architecture │ 9/10 │ 20% │ 1.80 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Native Interop Safety │ 5/10 │ 15% │ 0.75 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Error Handling │ 6/10 │ 10% │ 0.60 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Thread Safety │ 7.5/10 │ 10% │ 0.75 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Test Coverage │ 3/10 │ 15% │ 0.45 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ View Layer Quality │ 9/10 │ 10% │ 0.90 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ API Design │ 8/10 │ 5% │ 0.40 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Documentation │ 8.5/10 │ 5% │ 0.43 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Packaging │ 8/10 │ 5% │ 0.40 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Performance Architecture │ 7.5/10 │ 5% │ 0.38 │\n ├──────────────────────────┼────────┼────────┼───────────┤\n │ Overall │ │ 100% │ 6.86 / 10 │\n └──────────────────────────┴────────┴────────┴───────────┘\n\n The project has excellent architecture and view-layer quality, but native resource management and test\n coverage are the two areas that pose real production risk. The P0 items above should be addressed before the\n v1.0.0 label carries confidence for production workloads.\n\n", - "createdAt": 1772851947495, - "updatedAt": 1772855998586, - "tags": [] -} \ No newline at end of file diff --git a/tests/Rendering/PerformanceBenchmarkTests.cs b/tests/Rendering/PerformanceBenchmarkTests.cs new file mode 100644 index 0000000..0a57689 --- /dev/null +++ b/tests/Rendering/PerformanceBenchmarkTests.cs @@ -0,0 +1,519 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using FluentAssertions; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Platform; +using SkiaSharp; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Maui.Controls.Linux.Tests.Rendering; + +/// +/// Minimal concrete SkiaView for benchmarking. Uses base MeasureOverride +/// (respects WidthRequest/HeightRequest) and does trivial drawing. +/// +internal class BenchView : SkiaView +{ + protected override void OnDraw(SKCanvas canvas, SKRect bounds) + { + // Minimal draw — fill rect to simulate real view cost + using var paint = new SKPaint { Color = SKColors.Gray }; + canvas.DrawRect(bounds, paint); + } +} + +/// +/// Performance benchmarks for the rendering pipeline. +/// These tests verify that critical paths complete within acceptable time budgets. +/// Times are measured with Stopwatch and validated against generous upper bounds +/// to avoid flaky failures on slow CI machines, while still catching regressions. +/// +public class MeasureArrangePerformanceTests : ITestOutputHelper +{ + private readonly ITestOutputHelper _output; + + public MeasureArrangePerformanceTests(ITestOutputHelper output) + { + _output = output; + } + + // ITestOutputHelper implementation for direct construction in theory tests + void ITestOutputHelper.WriteLine(string message) => _output.WriteLine(message); + void ITestOutputHelper.WriteLine(string format, params object[] args) => _output.WriteLine(format, args); + + [Fact] + public void Measure_FlatLayout_100Children_Under5ms() + { + // Arrange — flat stack with 100 children + var stack = new SkiaStackLayout + { + Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical + }; + for (int i = 0; i < 100; i++) + stack.AddChild(new BenchView { WidthRequest = 200, HeightRequest = 30 }); + + // Warmup + stack.Measure(new Size(800, 10000)); + + // Act + var sw = Stopwatch.StartNew(); + const int iterations = 100; + for (int i = 0; i < iterations; i++) + { + stack.InvalidateMeasure(); + stack.Measure(new Size(800, 10000)); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"Measure 100-child flat stack: {avgMs:F3} ms/iteration ({iterations} iterations)"); + + // Assert — should be well under 5ms per measure + avgMs.Should().BeLessThan(5.0, "measuring a 100-child flat layout should be fast"); + } + + [Fact] + public void Arrange_FlatLayout_100Children_Under5ms() + { + // Arrange + var stack = new SkiaStackLayout + { + Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical + }; + for (int i = 0; i < 100; i++) + stack.AddChild(new BenchView { WidthRequest = 200, HeightRequest = 30 }); + + stack.Measure(new Size(800, 10000)); + + // Warmup + stack.Arrange(new Rect(0, 0, 800, 10000)); + + // Act + var sw = Stopwatch.StartNew(); + const int iterations = 100; + for (int i = 0; i < iterations; i++) + { + stack.Arrange(new Rect(0, 0, 800, 10000)); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"Arrange 100-child flat stack: {avgMs:F3} ms/iteration ({iterations} iterations)"); + + avgMs.Should().BeLessThan(5.0, "arranging a 100-child flat layout should be fast"); + } + + [Fact] + public void Measure_DeepNesting_20Levels_Under5ms() + { + // Arrange — deeply nested layout (20 levels, 1 child each) + var root = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + var current = root; + for (int i = 0; i < 19; i++) + { + var child = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + current.AddChild(child); + current = child; + } + current.AddChild(new BenchView { WidthRequest = 100, HeightRequest = 50 }); + + // Warmup + root.Measure(new Size(800, 600)); + + // Act + var sw = Stopwatch.StartNew(); + const int iterations = 100; + for (int i = 0; i < iterations; i++) + { + root.InvalidateMeasure(); + root.Measure(new Size(800, 600)); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"Measure 20-deep nested layout: {avgMs:F3} ms/iteration ({iterations} iterations)"); + + avgMs.Should().BeLessThan(5.0, "measuring a 20-level deep layout should be fast"); + } + + [Fact] + public void MeasureArrange_Grid_10x10_Under10ms() + { + // Arrange — 10x10 grid (100 cells) + var grid = new SkiaGrid(); + for (int r = 0; r < 10; r++) + grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength(40)); + for (int c = 0; c < 10; c++) + grid.ColumnDefinitions.Add(new Microsoft.Maui.Platform.GridLength(80)); + + for (int r = 0; r < 10; r++) + for (int c = 0; c < 10; c++) + grid.AddChild(new BenchView { WidthRequest = 70, HeightRequest = 30 }, r, c); + + // Warmup + grid.Measure(new Size(800, 600)); + grid.Arrange(new Rect(0, 0, 800, 600)); + + // Act + var sw = Stopwatch.StartNew(); + const int iterations = 50; + for (int i = 0; i < iterations; i++) + { + grid.InvalidateMeasure(); + grid.Measure(new Size(800, 600)); + grid.Arrange(new Rect(0, 0, 800, 600)); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"Measure+Arrange 10x10 grid: {avgMs:F3} ms/iteration ({iterations} iterations)"); + + avgMs.Should().BeLessThan(10.0, "measure+arrange of a 10x10 grid should complete quickly"); + } +} + +public class HitTestPerformanceTests +{ + private readonly ITestOutputHelper _output; + + public HitTestPerformanceTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void HitTest_FlatLayout_100Children_Under1ms() + { + // Arrange — layout with 100 children arranged vertically + var stack = new SkiaStackLayout + { + Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical + }; + for (int i = 0; i < 100; i++) + stack.AddChild(new BenchView { WidthRequest = 400, HeightRequest = 30 }); + + stack.Measure(new Size(400, 3000)); + stack.Arrange(new Rect(0, 0, 400, 3000)); + + // Warmup + stack.HitTest(200, 1500); + + // Act — hit test at various points + var sw = Stopwatch.StartNew(); + const int iterations = 1000; + for (int i = 0; i < iterations; i++) + { + float y = (i % 3000); + stack.HitTest(200, y); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"HitTest 100-child flat stack: {avgMs:F4} ms/hit ({iterations} iterations)"); + + avgMs.Should().BeLessThan(1.0, "hit testing a 100-child flat layout should be sub-millisecond"); + } + + [Fact] + public void HitTest_DeepNesting_20Levels_Under1ms() + { + // Arrange — deeply nested layout + var root = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + var current = root; + for (int i = 0; i < 19; i++) + { + var child = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + current.AddChild(child); + current = child; + } + current.AddChild(new BenchView { WidthRequest = 100, HeightRequest = 50 }); + + root.Measure(new Size(800, 600)); + root.Arrange(new Rect(0, 0, 800, 600)); + + // Warmup + root.HitTest(50, 25); + + // Act + var sw = Stopwatch.StartNew(); + const int iterations = 1000; + for (int i = 0; i < iterations; i++) + { + root.HitTest(50, 25); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"HitTest 20-deep nested layout: {avgMs:F4} ms/hit ({iterations} iterations)"); + + avgMs.Should().BeLessThan(1.0, "hit testing a 20-level deep layout should be sub-millisecond"); + } + + [Fact] + public void HitTest_Miss_Outside_Under01ms() + { + // Arrange + var stack = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + for (int i = 0; i < 50; i++) + stack.AddChild(new BenchView { WidthRequest = 400, HeightRequest = 30 }); + + stack.Measure(new Size(400, 1500)); + stack.Arrange(new Rect(0, 0, 400, 1500)); + + // Act — hit test outside bounds (should short-circuit) + var sw = Stopwatch.StartNew(); + const int iterations = 10000; + for (int i = 0; i < iterations; i++) + { + stack.HitTest(999, 999); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"HitTest miss (outside bounds): {avgMs:F5} ms/hit ({iterations} iterations)"); + + avgMs.Should().BeLessThan(0.1, "hit test miss should short-circuit extremely fast"); + } +} + +public class DirtyRegionPerformanceTests +{ + private readonly ITestOutputHelper _output; + + public DirtyRegionPerformanceTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void InvalidateRegion_ManySmallRegions_MergesEfficiently() + { + // Arrange — simulate rapid invalidation of small adjacent regions + // We test the merge logic directly on SkiaView's Invalidate which + // calls through to the rendering engine. Since we can't create a + // real rendering engine without X11, test the view invalidation path. + var views = new List(); + var stack = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + for (int i = 0; i < 50; i++) + { + var v = new BenchView { WidthRequest = 400, HeightRequest = 20 }; + views.Add(v); + stack.AddChild(v); + } + + stack.Measure(new Size(400, 1000)); + stack.Arrange(new Rect(0, 0, 400, 1000)); + + // Act — invalidate each child rapidly + var sw = Stopwatch.StartNew(); + const int iterations = 100; + for (int iter = 0; iter < iterations; iter++) + { + foreach (var v in views) + v.Invalidate(); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"Invalidate 50 views: {avgMs:F3} ms/batch ({iterations} iterations)"); + + avgMs.Should().BeLessThan(5.0, "invalidating 50 views in a batch should be fast"); + } + + [Fact] + public void InvalidateMeasure_Propagation_Under1ms() + { + // Arrange — deep tree, invalidate at leaf, check propagation speed + var root = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + var current = root; + SkiaStackLayout? leaf = null; + for (int i = 0; i < 15; i++) + { + var child = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + current.AddChild(child); + current = child; + leaf = child; + } + var leafView = new BenchView { WidthRequest = 100, HeightRequest = 30 }; + current.AddChild(leafView); + + root.Measure(new Size(800, 600)); + root.Arrange(new Rect(0, 0, 800, 600)); + + // Act — invalidate from the leaf repeatedly + var sw = Stopwatch.StartNew(); + const int iterations = 1000; + for (int i = 0; i < iterations; i++) + { + leafView.InvalidateMeasure(); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"InvalidateMeasure propagation (15 deep): {avgMs:F4} ms/call ({iterations} iterations)"); + + avgMs.Should().BeLessThan(1.0, "invalidation propagation through 15 levels should be sub-millisecond"); + } +} + +public class DrawPerformanceTests +{ + private readonly ITestOutputHelper _output; + + public DrawPerformanceTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void Draw_FlatLayout_100Children_Under10ms() + { + // Arrange + var stack = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + for (int i = 0; i < 100; i++) + stack.AddChild(new BenchView { WidthRequest = 400, HeightRequest = 30 }); + + stack.Measure(new Size(400, 3000)); + stack.Arrange(new Rect(0, 0, 400, 3000)); + + using var bitmap = new SKBitmap(400, 3000); + using var canvas = new SKCanvas(bitmap); + + // Warmup + stack.Draw(canvas); + + // Act + var sw = Stopwatch.StartNew(); + const int iterations = 50; + for (int i = 0; i < iterations; i++) + { + canvas.Clear(); + stack.Draw(canvas); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"Draw 100-child flat stack: {avgMs:F3} ms/frame ({iterations} iterations)"); + + avgMs.Should().BeLessThan(10.0, "drawing 100 simple views should be fast"); + } + + [Fact] + public void Draw_Labels_50Items_Under20ms() + { + // Arrange — labels are more expensive (text shaping) + var stack = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + for (int i = 0; i < 50; i++) + stack.AddChild(new SkiaLabel { Text = $"Item {i}: Sample label text", FontSize = 14 }); + + stack.Measure(new Size(400, 2000)); + stack.Arrange(new Rect(0, 0, 400, 2000)); + + using var bitmap = new SKBitmap(400, 2000); + using var canvas = new SKCanvas(bitmap); + + // Warmup + stack.Draw(canvas); + + // Act + var sw = Stopwatch.StartNew(); + const int iterations = 20; + for (int i = 0; i < iterations; i++) + { + canvas.Clear(); + stack.Draw(canvas); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"Draw 50 labels with text: {avgMs:F3} ms/frame ({iterations} iterations)"); + + avgMs.Should().BeLessThan(20.0, "drawing 50 labels should complete within frame budget"); + } + + [Fact] + public void Draw_MixedControls_Under15ms() + { + // Arrange — realistic mix of controls + var stack = new SkiaStackLayout { Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical }; + + for (int i = 0; i < 10; i++) + { + stack.AddChild(new SkiaLabel { Text = $"Section {i}", FontSize = 18, FontAttributes = FontAttributes.Bold }); + stack.AddChild(new SkiaEntry { Text = $"Input {i}", Placeholder = "Enter text..." }); + stack.AddChild(new SkiaButton { Text = $"Button {i}" }); + stack.AddChild(new SkiaCheckBox { IsChecked = i % 2 == 0 }); + stack.AddChild(new SkiaProgressBar { Progress = i / 10.0 }); + } + + stack.Measure(new Size(400, 5000)); + stack.Arrange(new Rect(0, 0, 400, 5000)); + + using var bitmap = new SKBitmap(400, 5000); + using var canvas = new SKCanvas(bitmap); + + // Warmup + stack.Draw(canvas); + + // Act + var sw = Stopwatch.StartNew(); + const int iterations = 20; + for (int i = 0; i < iterations; i++) + { + canvas.Clear(); + stack.Draw(canvas); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"Draw 50 mixed controls: {avgMs:F3} ms/frame ({iterations} iterations)"); + + avgMs.Should().BeLessThan(15.0, "drawing a realistic mix of 50 controls should fit in a frame budget"); + } +} + +public class ResourceCachePerformanceTests +{ + private readonly ITestOutputHelper _output; + + public ResourceCachePerformanceTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void TypefaceCache_HitRate_Under01ms() + { + // Arrange + using var cache = new Microsoft.Maui.Platform.Linux.Rendering.ResourceCache(); + + // Warmup — populate cache + var style = new SKFontStyle(400, 5, SKFontStyleSlant.Upright); + cache.GetTypeface("Sans", style); + cache.GetTypeface("Serif", style); + cache.GetTypeface("Monospace", style); + + // Act — repeated cache hits + var sw = Stopwatch.StartNew(); + const int iterations = 10000; + for (int i = 0; i < iterations; i++) + { + string family = (i % 3) switch + { + 0 => "Sans", + 1 => "Serif", + _ => "Monospace" + }; + cache.GetTypeface(family, style); + } + sw.Stop(); + + double avgMs = sw.Elapsed.TotalMilliseconds / iterations; + _output.WriteLine($"ResourceCache typeface lookup: {avgMs:F5} ms/lookup ({iterations} iterations)"); + + avgMs.Should().BeLessThan(0.1, "cached typeface lookup should be extremely fast"); + } +}