diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml new file mode 100644 index 0000000000..12d677dc95 --- /dev/null +++ b/.github/workflows/perf-gate.yml @@ -0,0 +1,212 @@ +name: Scrolling Performance Gate + +on: + push: + branches: [ main, develop ] + paths-ignore: + - '**.md' + pull_request: + branches: [ main, develop ] + paths-ignore: + - '**.md' + +# Only run on Linux to keep results comparable across runs. +# Windows/macOS times vary too much to use as a performance baseline. +permissions: + contents: read + +jobs: + perf-smoke-tests: + name: Performance Smoke Tests (Linux) + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DisableRealDriverIO: "1" + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 # GitVersion needs full history + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + dotnet-quality: 'ga' + + - name: Restore dependencies + run: dotnet restore + + - name: Build (Release) + run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612 + + - name: Build Tests (Debug โ€” smoke tests run in Debug to match CI unit tests) + run: dotnet build Tests/PerformanceTests --no-restore -property:NoWarn=0618%3B0612 + + - name: Run performance smoke tests (Layer 1 gate) + id: smoke_tests + run: | + dotnet test \ + --project Tests/PerformanceTests \ + --no-build \ + --verbosity normal + + - name: Upload smoke test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: perf-smoke-test-logs + path: | + TestResults/ + if-no-files-found: ignore + retention-days: 7 + + perf-benchmarks: + name: Scrolling Benchmarks (Linux, ShortRun) + runs-on: ubuntu-latest + # Only run on pushes to develop/main, not on every PR (slow and not blocking). + if: github.event_name == 'push' + timeout-minutes: 30 + env: + DisableRealDriverIO: "1" + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + dotnet-quality: 'ga' + + - name: Restore dependencies + run: dotnet restore + + - name: Build Release + run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612 + + - name: Run scrolling benchmarks (ShortRun โ‰ˆ 30โ€“60 s) + id: run_benchmarks + run: | + dotnet run \ + --project Tests/Benchmarks \ + --configuration Release \ + --no-build \ + -- \ + --filter '*Scroll*' \ + --job short \ + --exporters json \ + --artifacts ./BenchmarkResults + continue-on-error: true # Don't block the workflow; comparison step decides outcome + + - name: Compare results to baseline + id: compare + run: | + python3 - << 'PYEOF' + import json, os, sys, glob + + REGRESSION_FACTOR = 3.0 # Fail if any benchmark is >3ร— baseline + IMPROVEMENT_FACTOR = 0.8 # Celebrate ๐ŸŽ‰ if any benchmark drops below 0.8ร— baseline + + baseline_path = "Tests/Benchmarks/baseline.json" + results_dir = "BenchmarkResults" + + # --- Load baseline --- + try: + with open(baseline_path) as f: + baseline_data = json.load(f) + baseline = { + f"{b['type']}/{b['method']}/{b['params']}": b["meanNs"] + for b in baseline_data["benchmarks"] + } + except FileNotFoundError: + print("::warning::baseline.json not found โ€” skipping comparison") + sys.exit(0) + + # --- Find BenchmarkDotNet JSON results --- + result_files = glob.glob(f"{results_dir}/**/*.json", recursive=True) + result_files = [f for f in result_files if "results" in f.lower() or "report" in f.lower()] + if not result_files: + print("::warning::No BenchmarkDotNet result files found โ€” skipping comparison") + sys.exit(0) + + # --- Parse results --- + results = {} + for fpath in result_files: + try: + with open(fpath) as f: + data = json.load(f) + for bm in data.get("Benchmarks", []): + key = f"{bm['Type']}/{bm['Method']}/{bm.get('Parameters', '')}" + results[key] = bm.get("Statistics", {}).get("Mean", None) + except Exception as e: + print(f"::warning::Could not parse {fpath}: {e}") + + # --- Build comparison table --- + rows = [] + regressions = [] + improvements = [] + + for key, base_ns in baseline.items(): + if base_ns <= 0: + continue + cur_ns = results.get(key) + if cur_ns is None: + rows.append(f"| {key} | {base_ns/1000:.1f} ยตs | โ€” (not measured) | โ€” |") + continue + + ratio = cur_ns / base_ns + emoji = "โœ…" + if ratio >= REGRESSION_FACTOR: + emoji = "โŒ" + regressions.append((key, base_ns, cur_ns, ratio)) + elif ratio <= IMPROVEMENT_FACTOR: + emoji = "๐ŸŽ‰" + improvements.append((key, base_ns, cur_ns, ratio)) + rows.append( + f"| {key} | {base_ns/1000:.1f} ยตs | {cur_ns/1000:.1f} ยตs | {ratio:.2f}ร— {emoji} |" + ) + + # --- Write step summary --- + summary = "## ๐Ÿ“Š Scrolling Benchmark Comparison\n\n" + summary += "| Benchmark | Baseline | Current | Ratio |\n" + summary += "|-----------|----------|---------|-------|\n" + summary += "\n".join(rows) + "\n\n" + + if improvements: + summary += "### ๐ŸŽ‰ Performance Improvements\n" + for k, b, c, r in improvements: + summary += f"- **{k}**: {b/1000:.1f} ยตs โ†’ {c/1000:.1f} ยตs ({r:.2f}ร—)\n" + summary += "\n" + + if regressions: + summary += "### โŒ Regressions Detected\n" + for k, b, c, r in regressions: + summary += f"- **{k}**: {b/1000:.1f} ยตs โ†’ {c/1000:.1f} ยตs ({r:.2f}ร—) โ€” exceeds {REGRESSION_FACTOR}ร— threshold\n" + summary += "\n" + + with open(os.environ.get("GITHUB_STEP_SUMMARY", "/dev/null"), "a") as f: + f.write(summary) + + print(summary) + + if regressions: + print(f"::error::Performance regressions detected: {len(regressions)} benchmark(s) exceeded {REGRESSION_FACTOR}ร— baseline") + sys.exit(1) + + if improvements: + print(f"Performance improvements detected: {len(improvements)} benchmark(s) improved!") + PYEOF + + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmark-results-${{ github.sha }} + path: BenchmarkResults/ + if-no-files-found: ignore + retention-days: 30 diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index cf510856ff..09ce0f7645 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -93,6 +93,7 @@ + diff --git a/Terminal.sln b/Terminal.sln index 4c9bc79d78..de4c68a853 100644 --- a/Terminal.sln +++ b/Terminal.sln @@ -156,6 +156,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InlineSelect", "Examples\In EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers.Internal", "Terminal.Gui.Analyzers.Internal\Terminal.Gui.Analyzers.Internal.csproj", "{927CCC07-F00C-409C-BE42-458EB03DD4E8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceTests", "Tests\PerformanceTests\PerformanceTests.csproj", "{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -454,6 +456,18 @@ Global {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x64.Build.0 = Release|Any CPU {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.ActiveCfg = Release|Any CPU {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.Build.0 = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x64.ActiveCfg = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x64.Build.0 = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x86.ActiveCfg = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x86.Build.0 = Debug|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|Any CPU.Build.0 = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.ActiveCfg = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.Build.0 = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.ActiveCfg = Release|Any CPU + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -484,6 +498,7 @@ Global {90A42AE4-301D-4B05-8892-60BE5209C1B5} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} {70802F77-F259-44C6-9522-46FCE2FD754E} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} {3116547F-A8F2-4189-BC22-0B47C757164C} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} + {6E98BACA-E6B6-47A0-B45C-B624F8E74EC2} = {A589126F-C71A-4FEE-B7EA-2DCA1ADF6A46} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F8F8A4D-7B8D-4C2A-AC5E-CD7117F74C03} diff --git a/Tests/Benchmarks/README.md b/Tests/Benchmarks/README.md index 334ec2582b..f08f70d27f 100644 --- a/Tests/Benchmarks/README.md +++ b/Tests/Benchmarks/README.md @@ -47,6 +47,9 @@ dotnet run -c Release # Run only DimAuto benchmarks dotnet run -c Release -- --filter '*DimAuto*' +# Run only Scrolling benchmarks +dotnet run -c Release -- --filter '*Scroll*' + # Run only TextFormatter benchmarks dotnet run -c Release -- --filter '*TextFormatter*' ``` @@ -56,6 +59,9 @@ dotnet run -c Release -- --filter '*TextFormatter*' ```bash # Run only the ComplexLayout benchmark dotnet run -c Release -- --filter '*DimAutoBenchmark.ComplexLayout*' + +# Run only TextView scrolling benchmarks +dotnet run -c Release -- --filter '*TextViewScroll*' ``` ### Quick Run (Shorter but Less Accurate) @@ -63,7 +69,7 @@ dotnet run -c Release -- --filter '*DimAutoBenchmark.ComplexLayout*' For faster iteration during development: ```bash -dotnet run -c Release -- --filter '*DimAuto*' -j short +dotnet run -c Release -- --filter '*Scroll*' -j short ``` ### List Available Benchmarks @@ -80,12 +86,52 @@ The `DimAutoBenchmark` class tests layout performance with `Dim.Auto()` in vario - **ComplexLayout**: 20 subviews with mixed Pos/Dim types (tests iteration overhead) - **DeeplyNestedLayout**: 5 levels of nested views with DimAuto (tests recursive performance) +## Scrolling Benchmarks + +The `Scrolling/` directory contains end-to-end scrolling benchmarks that cover the full input โ†’ layout โ†’ draw pipeline. + +### BaselineScrollBenchmark + +Minimal `View` subclass with a large `ContentSize` and no rendering logic. Isolates framework scrolling overhead from any view-specific work. + +- **ViewportScroll_Down / Up**: Direct viewport manipulation (no key injection). Measures pure framework overhead. +- **ViewportScroll_PageDown**: Viewport-sized jump. +- Parameterized by `ContentHeight` = [1 000, 10 000] + +### TextViewScrollBenchmark + +`TextView` with read-only content of 1 000 / 5 000 lines of ~80-char text. + +- **ScrollDown_OneStep / ScrollUp_OneStep**: Single `Key.CursorDown` / `Key.CursorUp` injection. With the caret at the viewport boundary, every keystroke triggers a viewport scroll. +- **PageDown_OneStep**: Single `Key.PageDown` injection. +- Parameterized by `Lines` = [1 000, 5 000] + +### ListViewScrollBenchmark + +`ListView` with 1 000 / 10 000 string items. + +- **ScrollDown_OneStep / ScrollUp_OneStep / PageDown_OneStep** +- Parameterized by `Items` = [1 000, 10 000] + +### TableViewScrollBenchmark + +`TableView` with 100 / 1 000 rows ร— 10 columns. + +- **ScrollDown_OneStep / ScrollUp_OneStep / PageDown_OneStep / ScrollRight_OneStep** +- Parameterized by `Rows` = [100, 1 000] + +### Run all scrolling benchmarks + +```bash +dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*' +``` + ## Adding New Benchmarks -1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`) +1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`, `Scrolling/`) 2. For BenchmarkDotNet: add `[MemoryDiagnoser]`, `[BenchmarkCategory]`, `[Benchmark(Baseline = true)]` 3. For memory profilers: add a `public static void Run()` method and route it from `Program.cs` -4. Use `[GlobalSetup]`/`[GlobalCleanup]` for `Application.Init`/`Shutdown` +4. Use `[GlobalSetup]`/`[GlobalCleanup]` for application init/dispose ## Best Practices @@ -96,10 +142,37 @@ The `DimAutoBenchmark` class tests layout performance with `Dim.Auto()` in vario ## Continuous Integration -Benchmarks are not run automatically in CI. Run them locally when: -- Making performance-critical changes -- Implementing performance optimizations -- Before releasing a new version +### Layer 1: Performance Smoke Tests + +Stopwatch-based xUnit tests in `Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs` run on every CI build via the standard unit test workflow. They use generous thresholds (50โ€“100ร— typical) to catch catastrophic O(nยฒ) regressions without flaking on slow runners. + +Each test: +- Creates a large document (10 000 rows / 100 000 items) +- Measures the cost of a **single viewport draw** after scrolling to the mid-point +- Asserts completion under a generous threshold (e.g., < 200 ms for TableView) + +This detects if a draw function accidentally iterates the entire document instead of just the visible viewport. + +### Layer 2: Baseline Comparison + +The `.github/workflows/perf-gate.yml` workflow runs on every push to `main` / `develop` (not PRs) and: + +1. Runs the `*Scroll*` benchmarks with `--job short` (~30โ€“60 s total) +2. Compares results to `Tests/Benchmarks/baseline.json` +3. **Fails** if any benchmark exceeds **3ร—** the baseline +4. **Celebrates** ๐ŸŽ‰ if any benchmark drops below **0.8ร—** the baseline +5. Posts a markdown comparison table to the GitHub step summary + +### Updating the Baseline + +After a deliberate performance change, re-run the focused scrolling benchmarks, then update `baseline.json`: + +```bash +# Run ShortRun and export JSON results +dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*' -j short --exporters json + +# Inspect the JSON output in BenchmarkDotNet.Artifacts/ and update baseline.json +``` ## Resources diff --git a/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs new file mode 100644 index 0000000000..1cfe1cea72 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/BaselineScrollBenchmark.cs @@ -0,0 +1,113 @@ +using System.Drawing; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Scrolling; + +/// +/// Measures framework-level scrolling overhead using a minimal subclass that has a large +/// but performs no custom rendering. Isolates the viewport-math, layout, and +/// draw-dispatch costs from any view-specific rendering work. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*BaselineScroll*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Scrolling", "Baseline")] +public class BaselineScrollBenchmark +{ + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 80; + + private IApplication _app = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private View _view = null!; + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is { }) + { + _app.End (_session); + } + + _app.Dispose (); + } + + /// Total virtual content height of the view (rows). + [Params (1_000, 10_000)] + public int ContentHeight { get; set; } + + /// + /// Positions the viewport at the mid-point of the document so that each benchmark iteration + /// scrolls from a stable, representative location. + /// + [IterationSetup] + public void IterationSetup () + { + _view.Viewport = _view.Viewport with { Y = ContentHeight / 2 }; + _view.SetNeedsDraw (); + } + + /// Creates the application context, view hierarchy, and performs one warm-up draw. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + _view = new View + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + ViewportSettings = ViewportSettingsFlags.HasVerticalScrollBar + }; + _view.SetContentSize (new Size (SCREEN_WIDTH, ContentHeight)); + _runnable.Add (_view); + + // Warm up: prime JIT and layout caches before measurement. + _app.LayoutAndDraw (true); + } + + /// + /// Scrolls the viewport down by one row and redraws. + /// Measures the minimal per-row cost of the layout+draw pipeline with no content rendering. + /// + [Benchmark (Baseline = true)] + public void ViewportScroll_Down () + { + _view.Viewport = _view.Viewport with { Y = _view.Viewport.Y + 1 }; + _app.LayoutAndDraw (); + } + + /// Scrolls the viewport down by one page (ScreenHeight rows) and redraws. + [Benchmark] + public void ViewportScroll_PageDown () + { + int newY = Math.Min (_view.Viewport.Y + SCREEN_HEIGHT, ContentHeight - SCREEN_HEIGHT); + _view.Viewport = _view.Viewport with { Y = newY }; + _app.LayoutAndDraw (); + } + + /// Scrolls the viewport up by one row and redraws. + [Benchmark] + public void ViewportScroll_Up () + { + _view.Viewport = _view.Viewport with { Y = _view.Viewport.Y - 1 }; + _app.LayoutAndDraw (); + } +} diff --git a/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs new file mode 100644 index 0000000000..0c93bc896b --- /dev/null +++ b/Tests/Benchmarks/Scrolling/ListViewScrollBenchmark.cs @@ -0,0 +1,128 @@ +using System.Collections.ObjectModel; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Scrolling; + +/// +/// Measures end-to-end scrolling performance for . +/// Covers item rendering with mark glyphs, selection role highlighting, +/// and per-item draw cost at varying collection sizes. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*ListViewScroll*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Scrolling", "ListView")] +public class ListViewScrollBenchmark +{ + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 80; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private ListView _listView = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is { }) + { + _app.End (_session); + } + + _app.Dispose (); + } + + /// Total number of items loaded into the . + [Params (1_000, 10_000)] + public int Items { get; set; } + + /// + /// Positions the selection at the last visible row so the next down-arrow triggers a scroll. + /// + [IterationSetup] + public void IterationSetup () + { + _listView.SelectedItem = SCREEN_HEIGHT - 1; + _listView.SetNeedsDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void PageDown_OneStep () + { + _injector.InjectKey (Key.PageDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// With the selection at the viewport boundary this always scrolls. + /// + [Benchmark (Baseline = true)] + public void ScrollDown_OneStep () + { + _injector.InjectKey (Key.CursorDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void ScrollUp_OneStep () + { + _injector.InjectKey (Key.CursorUp); + _app.LayoutAndDraw (); + } + + /// Creates the application, populates the list, and warms up the view. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + _listView = new ListView { X = 0, Y = 0, Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _listView.SetSource (new ObservableCollection (BuildItems (Items))); + _runnable.Add (_listView); + + // Focus so key bindings resolve. + _listView.SetFocus (); + + // Cache injector to avoid per-call lookup overhead. + _injector = _app.GetInputInjector (); + + // Warm up. + _app.LayoutAndDraw (true); + } + + private static List BuildItems (int count) + { + List items = new (count); + + for (var itemIndex = 0; itemIndex < count; itemIndex++) + { + items.Add ($"Item {itemIndex,6}: data value = {itemIndex * 17 % 100:D3}"); + } + + return items; + } +} diff --git a/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs new file mode 100644 index 0000000000..ce239bff07 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/TableViewScrollBenchmark.cs @@ -0,0 +1,156 @@ +using System.Data; +using System.Drawing; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Scrolling; + +/// +/// Measures end-to-end scrolling performance for . +/// Covers cell rendering, column alignment, and header drawing at varying row counts. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*TableViewScroll*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Scrolling", "TableView")] +public class TableViewScrollBenchmark +{ + private const int COLUMN_COUNT = 10; + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 120; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private TableView _tableView = null!; + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is { }) + { + _app.End (_session); + } + + _app.Dispose (); + } + + /// + /// Positions the selected row at the last visible data row so the next down-arrow scrolls. + /// + [IterationSetup] + public void IterationSetup () + { + // TableView reserves row 0 for the header; data rows start at display row 1. + // Place selection at the last visible data row. + int visibleDataRows = SCREEN_HEIGHT - 1; // subtract header row + _tableView.RowOffset = 0; + _tableView.Value = new TableSelection (new Point (0, Math.Min (visibleDataRows - 1, Rows - 1))); + _tableView.SetNeedsDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void PageDown_OneStep () + { + _injector.InjectKey (Key.PageDown); + _app.LayoutAndDraw (); + } + + /// Total number of data rows loaded into the . + [Params (100, 1_000)] + public int Rows { get; set; } + + /// + /// Injects a single keystroke and redraws. + /// With the selection at the viewport boundary this triggers a row scroll. + /// + [Benchmark (Baseline = true)] + public void ScrollDown_OneStep () + { + _injector.InjectKey (Key.CursorDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// Measures horizontal scrolling / column navigation. + /// + [Benchmark] + public void ScrollRight_OneStep () + { + _injector.InjectKey (Key.CursorRight); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// + [Benchmark] + public void ScrollUp_OneStep () + { + _injector.InjectKey (Key.CursorUp); + _app.LayoutAndDraw (); + } + + /// Creates the application, builds the data table, and warms up the view. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + DataTable dt = BuildDataTable (Rows, COLUMN_COUNT); + _tableView = new TableView (new DataTableSource (dt)) { X = 0, Y = 0, Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _runnable.Add (_tableView); + + // Focus so key bindings resolve. + _tableView.SetFocus (); + + // Cache injector to avoid per-call lookup overhead. + _injector = _app.GetInputInjector (); + + // Warm up. + _app.LayoutAndDraw (true); + } + + private static DataTable BuildDataTable (int rows, int cols) + { + DataTable dt = new (); + + for (var colIndex = 0; colIndex < cols; colIndex++) + { + dt.Columns.Add ($"Col{colIndex,2}", typeof (string)); + } + + for (var rowIndex = 0; rowIndex < rows; rowIndex++) + { + var row = new object [cols]; + + for (var colIndex = 0; colIndex < cols; colIndex++) + { + row [colIndex] = $"R{rowIndex}C{colIndex}"; + } + + dt.Rows.Add (row); + } + + return dt; + } +} diff --git a/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs new file mode 100644 index 0000000000..a476def1b6 --- /dev/null +++ b/Tests/Benchmarks/Scrolling/TextViewScrollBenchmark.cs @@ -0,0 +1,144 @@ +using System.Drawing; +using System.Text; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Benchmarks.Scrolling; + +/// +/// Measures end-to-end scrolling performance for . +/// Covers the most complex rendering path in Terminal.Gui: line tracking, word-wrap decisions, +/// tab expansion, selection, and caret movement. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*TextViewScroll*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Scrolling", "TextView")] +public class TextViewScrollBenchmark +{ + private const int SCREEN_HEIGHT = 25; + private const int SCREEN_WIDTH = 80; + + private IApplication _app = null!; + private IInputInjector _injector = null!; + private Runnable _runnable = null!; + private SessionToken? _session; + private TextView _textView = null!; + + /// Disposes the application after all iterations. + [GlobalCleanup] + public void Cleanup () + { + if (_session is { }) + { + _app.End (_session); + } + + _app.Dispose (); + } + + /// + /// Resets the caret to the last line in the first visible page so the next + /// call triggers a viewport scroll. + /// + [IterationSetup] + public void IterationSetup () + { + // Place caret at bottom-right of the visible viewport so CursorDown scrolls. + _textView.InsertionPoint = new Point (0, SCREEN_HEIGHT - 1); + _textView.SetNeedsDraw (); + } + + /// Total number of lines loaded into the . + [Params (1_000, 5_000)] + public int Lines { get; set; } + + /// + /// Injects a single keystroke and redraws. + /// Each iteration rebuilds a full page โ€” measures viewport-sized jump cost. + /// + [Benchmark] + public void PageDown_OneStep () + { + _injector.InjectKey (Key.PageDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// With the caret at the bottom edge of the viewport this always triggers a viewport scroll. + /// + [Benchmark (Baseline = true)] + public void ScrollDown_OneStep () + { + _injector.InjectKey (Key.CursorDown); + _app.LayoutAndDraw (); + } + + /// + /// Injects a single keystroke and redraws. + /// Symmetric reverse-scroll measurement. + /// + [Benchmark] + public void ScrollUp_OneStep () + { + _injector.InjectKey (Key.CursorUp); + _app.LayoutAndDraw (); + } + + /// Creates the application, builds the document, and warms up the view. + [GlobalSetup] + public void Setup () + { + _app = Application.Create (); + _app.Init (DriverRegistry.Names.ANSI); + _app.Driver!.SetScreenSize (SCREEN_WIDTH, SCREEN_HEIGHT); + + _runnable = new Runnable { Width = SCREEN_WIDTH, Height = SCREEN_HEIGHT }; + _session = _app.Begin (_runnable); + + string text = BuildText (Lines); + + _textView = new TextView + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Text = text, + WordWrap = false, + ReadOnly = true + }; + _runnable.Add (_textView); + + // Focus the text view so key bindings resolve to it. + _textView.SetFocus (); + + // Cache injector to avoid per-call lookup overhead. + _injector = _app.GetInputInjector (); + + // Warm up: prime JIT and layout caches. + _app.LayoutAndDraw (true); + } + + private static string BuildText (int lineCount) + { + StringBuilder sb = new (lineCount * 85); + + for (var lineIndex = 0; lineIndex < lineCount; lineIndex++) + { + // ~80-character lines matching the issue specification. + sb.AppendLine ($"Line {lineIndex,6}: The quick brown fox jumps over the lazy dog. Extra padding {lineIndex % 10}."); + } + + return sb.ToString (); + } +} diff --git a/Tests/Benchmarks/baseline.json b/Tests/Benchmarks/baseline.json new file mode 100644 index 0000000000..63a907f4e5 --- /dev/null +++ b/Tests/Benchmarks/baseline.json @@ -0,0 +1,147 @@ +{ + "_comment": "Baseline benchmark results for the scrolling performance gate.", + "_howto": "Re-run 'dotnet run --project Tests/Benchmarks -c Release -- --filter \"*Scroll*\" -j short --exporters json' then update this file.", + "_version": "1", + "benchmarks": [ + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_Down", + "params": "ContentHeight=1000", + "meanNs": 150000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_Down", + "params": "ContentHeight=10000", + "meanNs": 150000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_Up", + "params": "ContentHeight=1000", + "meanNs": 150000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_Up", + "params": "ContentHeight=10000", + "meanNs": 150000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_PageDown", + "params": "ContentHeight=1000", + "meanNs": 150000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "BaselineScrollBenchmark", + "method": "ViewportScroll_PageDown", + "params": "ContentHeight=10000", + "meanNs": 150000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Lines=1000", + "meanNs": 500000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Lines=5000", + "meanNs": 500000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "ScrollUp_OneStep", + "params": "Lines=1000", + "meanNs": 500000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "ScrollUp_OneStep", + "params": "Lines=5000", + "meanNs": 500000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Lines=1000", + "meanNs": 500000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TextViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Lines=5000", + "meanNs": 500000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "ListViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Items=1000", + "meanNs": 200000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "ListViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Items=10000", + "meanNs": 200000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "ListViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Items=1000", + "meanNs": 200000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "ListViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Items=10000", + "meanNs": 200000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TableViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Rows=100", + "meanNs": 300000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TableViewScrollBenchmark", + "method": "ScrollDown_OneStep", + "params": "Rows=1000", + "meanNs": 300000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TableViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Rows=100", + "meanNs": 300000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + }, + { + "type": "TableViewScrollBenchmark", + "method": "PageDown_OneStep", + "params": "Rows=1000", + "meanNs": 300000, + "comment": "Placeholder โ€” run actual benchmarks to set real baseline" + } + ] +} diff --git a/Tests/IntegrationTests/FluentTests/MenuBarTests.cs b/Tests/IntegrationTests/FluentTests/MenuBarTests.cs index b68aa9ad4c..56a8fbd4e8 100644 --- a/Tests/IntegrationTests/FluentTests/MenuBarTests.cs +++ b/Tests/IntegrationTests/FluentTests/MenuBarTests.cs @@ -1093,9 +1093,12 @@ public void PopoverMenu_Overlapping_PaddingView_DrawingContent_Is_Not_Bled_Throu { menuOpen = menuBar!.IsOpen (); - if (a.Popovers!.GetActivePopover () is View popover) + if (a.Popovers!.GetActivePopover () is PopoverMenu popoverMenu) { - popoverScreen = popover.FrameToScreen (); + // Use the actual Menu content view's frame (not the full-screen + // transparent wrapper) so the overlap is the region where the + // menu popup genuinely draws its content. + popoverScreen = popoverMenu.ContentView?.FrameToScreen () ?? Rectangle.Empty; } padScreen = subView!.Padding.GetOrCreateView ().FrameToScreen (); diff --git a/Tests/PerformanceTests/AssemblyInfo.cs b/Tests/PerformanceTests/AssemblyInfo.cs new file mode 100644 index 0000000000..13ce8a0a0c --- /dev/null +++ b/Tests/PerformanceTests/AssemblyInfo.cs @@ -0,0 +1,7 @@ +global using Terminal.Gui.App; +global using Terminal.Gui.Drivers; +global using Terminal.Gui.Input; +global using Terminal.Gui.ViewBase; +global using Terminal.Gui.Views; +global using Terminal.Gui.Drawing; +global using UnitTests; diff --git a/Tests/PerformanceTests/PerformanceTests.csproj b/Tests/PerformanceTests/PerformanceTests.csproj new file mode 100644 index 0000000000..6513b94358 --- /dev/null +++ b/Tests/PerformanceTests/PerformanceTests.csproj @@ -0,0 +1,55 @@ + + + + PerformanceTests + Exe + true + true + false + enable + enable + true + true + portable + $(DefineConstants);JETBRAINS_ANNOTATIONS;CONTRACTS_FULL + true + $(NoWarn);AD0001 + true + + + + true + $(DefineConstants);DEBUG_IDISPOSABLE + + + + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/PerformanceTests/ScrollingPerformanceTests.cs b/Tests/PerformanceTests/ScrollingPerformanceTests.cs new file mode 100644 index 0000000000..b6c0bc5c5e --- /dev/null +++ b/Tests/PerformanceTests/ScrollingPerformanceTests.cs @@ -0,0 +1,305 @@ +// Copilot + +using System.Collections.ObjectModel; +using System.Data; +using System.Diagnostics; +using System.Text; + +namespace PerformanceTests; + +/// +/// Stopwatch-based smoke tests that catch catastrophic rendering regressions. +/// Thresholds are intentionally generous (~50โ€“100ร— typical) so these never flake on slow CI runners +/// but still catch accidental O(nยฒ) regressions where a single viewport draw inadvertently +/// iterates over the entire document instead of just the visible rows. +/// +/// +/// Each test measures the cost of a SINGLE viewport draw on a large document so that an +/// O(document-size) regression is immediately detectable without needing a full scroll loop. +/// These are Layer 1 of the two-layer CI performance gate. +/// Layer 2 (BenchmarkDotNet baseline comparison) lives in Tests/Benchmarks/. +/// This project runs exclusively on Linux via the perf-gate CI workflow. +/// +[Trait ("Category", "Performance")] +public class ScrollingPerformanceTests : TestDriverBase +{ + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // ListView smoke tests + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// + /// Builds a 100 000-item and renders a single viewport. + /// Asserts layout + one full draw completes under a generous threshold. + /// + [Fact] + public void ListView_LayoutAndDraw_100K_Items_UnderThreshold () + { + const int ITEM_COUNT = 100_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 30; + + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); + + ListView listView = new () + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Driver = driver + }; + listView.SetSource (new ObservableCollection (BuildListItems (ITEM_COUNT))); + listView.BeginInit (); + listView.EndInit (); + listView.Layout (); + + // Warm up. + listView.SetNeedsDraw (); + listView.Draw (); + + var sw = Stopwatch.StartNew (); + listView.SetNeedsDraw (); + listView.Draw (); + sw.Stop (); + + // 300 ms is ~150ร— what a single viewport draw takes on a typical machine. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (300), + $"ListView layout+draw ({ITEM_COUNT} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 300 ms"); + } + + /// + /// Scrolls a 100 000-item to the mid-point and measures the cost of + /// a single redraw. Detects O(total-items) regressions in the per-draw path. + /// + [Fact] + public void ListView_SingleViewportDraw_Mid_100K_Items_UnderThreshold () + { + const int ITEM_COUNT = 100_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 30; + + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); + + ListView listView = new () + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Driver = driver + }; + listView.SetSource (new ObservableCollection (BuildListItems (ITEM_COUNT))); + listView.BeginInit (); + listView.EndInit (); + listView.Layout (); + + // Warm up. + listView.SetNeedsDraw (); + listView.Draw (); + + // Scroll to the mid-point of the list. + listView.Viewport = listView.Viewport with { Y = ITEM_COUNT / 2 }; + + var sw = Stopwatch.StartNew (); + listView.SetNeedsDraw (); + listView.Draw (); + sw.Stop (); + + // 300 ms threshold. A genuine O(100 000) regression would scan all items and take >> 1 s. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (300), + $"ListView mid-doc viewport draw ({ITEM_COUNT} items) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 300 ms"); + } + + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // TableView smoke tests + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// + /// Builds a 10 000-row and renders a single viewport. + /// Asserts layout + one full draw completes under a generous threshold. + /// + [Fact] + public void TableView_LayoutAndDraw_10K_Rows_UnderThreshold () + { + const int ROW_COUNT = 10_000; + const int COL_COUNT = 10; + const int SCREEN_WIDTH = 120; + const int SCREEN_HEIGHT = 30; + + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); + + TableView tableView = new (new DataTableSource (BuildDataTable (ROW_COUNT, COL_COUNT))) + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Driver = driver + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.Layout (); + + // Warm up. + tableView.SetNeedsDraw (); + tableView.Draw (); + + var sw = Stopwatch.StartNew (); + tableView.SetNeedsDraw (); + tableView.Draw (); + sw.Stop (); + + // 200 ms is ~50ร— what a single viewport draw takes on a typical machine. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200), + $"TableView layout+draw ({ROW_COUNT} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); + } + + /// + /// Scrolls a to the mid-point of a 10 000-row table and measures + /// the cost of a single redraw from that offset. + /// Detects O(total-rows) regressions in the per-draw path. + /// + [Fact] + public void TableView_SingleViewportDraw_Mid_10K_Rows_UnderThreshold () + { + const int ROW_COUNT = 10_000; + const int COL_COUNT = 10; + const int SCREEN_WIDTH = 120; + const int SCREEN_HEIGHT = 30; + + IDriver driver = CreateTestDriver (SCREEN_WIDTH, SCREEN_HEIGHT); + + TableView tableView = new (new DataTableSource (BuildDataTable (ROW_COUNT, COL_COUNT))) + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Driver = driver + }; + tableView.BeginInit (); + tableView.EndInit (); + tableView.Layout (); + + // Warm up. + tableView.SetNeedsDraw (); + tableView.Draw (); + + // Scroll to mid-document. + tableView.RowOffset = ROW_COUNT / 2; + + var sw = Stopwatch.StartNew (); + tableView.SetNeedsDraw (); + tableView.Draw (); + sw.Stop (); + + // 200 ms threshold โ€” an O(total-rows) regression would scan 10 000 rows. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (200), + $"TableView single viewport draw at mid ({ROW_COUNT} rows) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < 200 ms"); + } + + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // TextView smoke tests + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// + /// Builds a 1 000-line and measures the cost of a single viewport + /// draw after scrolling to the middle of the document. + /// An O(document-size) regression in the rendering path would exceed the threshold even + /// on a slow CI runner. + /// + [Fact] + public void TextView_SingleViewportDraw_1K_Lines_UnderThreshold () + { + const int THRESHOLD_MS = 1000; + const int LINE_COUNT = 1_000; + const int SCREEN_WIDTH = 80; + const int SCREEN_HEIGHT = 25; + + IDriver driver = CreateTestDriver (); + + TextView tv = new () + { + X = 0, + Y = 0, + Width = SCREEN_WIDTH, + Height = SCREEN_HEIGHT, + Text = BuildTextViewContent (LINE_COUNT), + ReadOnly = true, + WordWrap = false, + Driver = driver + }; + tv.BeginInit (); + tv.EndInit (); + tv.Layout (); + + // Warm up: prime JIT and layout caches. + tv.SetNeedsDraw (); + tv.Draw (); + + // Scroll to the middle of the document. + tv.Viewport = tv.Viewport with { Y = LINE_COUNT / 2 }; + + var sw = Stopwatch.StartNew (); + tv.SetNeedsDraw (); + tv.Draw (); + sw.Stop (); + + // 1000 ms is generous even in debug/slow-CI mode. An O(lineCount) regression + // scanning 1 000 lines in the draw path would take at least 5โ€“10ร— longer. + Assert.True (sw.Elapsed < TimeSpan.FromMilliseconds (THRESHOLD_MS), + $"TextView single viewport draw ({LINE_COUNT} lines, mid-doc) took {sw.Elapsed.TotalMilliseconds:F0} ms, expected < {THRESHOLD_MS}ms"); + } + + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Helpers + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + private static DataTable BuildDataTable (int rows, int cols) + { + DataTable dt = new (); + + for (var colIndex = 0; colIndex < cols; colIndex++) + { + dt.Columns.Add ($"Col{colIndex}", typeof (string)); + } + + for (var rowIndex = 0; rowIndex < rows; rowIndex++) + { + var row = new object [cols]; + + for (var colIndex = 0; colIndex < cols; colIndex++) + { + row [colIndex] = $"R{rowIndex}C{colIndex}"; + } + + dt.Rows.Add (row); + } + + return dt; + } + + private static List BuildListItems (int count) + { + List items = new (count); + + for (var itemIndex = 0; itemIndex < count; itemIndex++) + { + items.Add ($"Item {itemIndex,6}: value = {itemIndex * 17 % 100:D3}"); + } + + return items; + } + + private static string BuildTextViewContent (int lineCount) + { + StringBuilder sb = new (lineCount * 85); + + for (var lineIndex = 0; lineIndex < lineCount; lineIndex++) + { + sb.AppendLine ($"Line {lineIndex,6}: The quick brown fox jumps over the lazy dog. Extra padding {lineIndex % 10}."); + } + + return sb.ToString (); + } +} diff --git a/Tests/PerformanceTests/xunit.runner.json b/Tests/PerformanceTests/xunit.runner.json new file mode 100644 index 0000000000..608c553c4e --- /dev/null +++ b/Tests/PerformanceTests/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "stopOnFail": false +} diff --git a/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs b/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs index 74f0a2fc5d..d84c1fe29f 100644 --- a/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Keyboard/ApplicationDefaultKeyBindingsTests.cs @@ -7,6 +7,7 @@ namespace ApplicationTests.Keyboard; /// /// Tests for static property. /// +[Collection ("Application Tests")] public class ApplicationDefaultKeyBindingsTests { [Fact]