Skip to content

Commit 4c0bbbd

Browse files
committed
test: isolate repository tab drag feedback fixtures
1 parent c7b47d4 commit 4c0bbbd

4 files changed

Lines changed: 234 additions & 0 deletions

File tree

‎tests/TabDragFeedback/Program.cs‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System.Reflection;
2+
using Avalonia;
3+
using Avalonia.Controls;
4+
using Avalonia.Controls.Primitives;
5+
using Avalonia.Headless;
6+
using Avalonia.Input;
7+
using Avalonia.Media;
8+
using Avalonia.Media.Imaging;
9+
using Avalonia.Styling;
10+
using Avalonia.Threading;
11+
using Avalonia.VisualTree;
12+
using SourceGit.Views;
13+
14+
// Linux.GetXdgDirectory only honors XDG roots that already exist.
15+
// Create both app directories before setup to prevent fallback or legacy migration.
16+
var outputRoot = Path.GetFullPath(Environment.GetEnvironmentVariable("TEST_OUTPUT") ?? throw new Exception("Run this fixture using tests/TabDragFeedback/run.py"));
17+
var configRoot = Path.Combine(outputRoot, "config");
18+
var cacheRoot = Path.Combine(outputRoot, "cache");
19+
Directory.CreateDirectory(Path.Combine(configRoot, "SourceGit"));
20+
Directory.CreateDirectory(Path.Combine(cacheRoot, "SourceGit"));
21+
Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configRoot);
22+
Environment.SetEnvironmentVariable("XDG_CACHE_HOME", cacheRoot);
23+
Environment.SetEnvironmentVariable("APPIMAGE", null);
24+
25+
Console.WriteLine("stage: directories");
26+
SourceGit.Native.OS.SetupBasicDirectories();
27+
if (SourceGit.Native.OS.BasicDirectories.ConfigDir != Path.Combine(configRoot, "SourceGit") || SourceGit.Native.OS.BasicDirectories.CacheDir != Path.Combine(cacheRoot, "SourceGit")) throw new Exception("Test directories are not isolated");
28+
Console.WriteLine("stage: setup");
29+
SourceGit.App.BuildAvaloniaApp().UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).SetupWithoutStarting();
30+
Console.WriteLine("stage: launcher");
31+
var vm = new SourceGit.ViewModels.Launcher(null);
32+
vm.AddNewTab();
33+
vm.AddNewTab();
34+
if (vm.Pages.Count != 3) throw new Exception("Expected exactly three isolated test pages");
35+
var testCase = args.FirstOrDefault() ?? "long";
36+
Console.WriteLine("case: " + testCase);
37+
SourceGit.ViewModels.Preferences.Instance.UseFixedTabWidth = testCase != "variable";
38+
if (testCase != "welcome")
39+
{
40+
vm.Pages[0].Node.IsRepository = true;
41+
vm.Pages[0].Node.Name = testCase == "short"
42+
? "SourceGit"
43+
: "sourcegit-repository-with-a-very-long-name-for-preview-validation";
44+
}
45+
var bar = new LauncherTabBar { DataContext = vm };
46+
var window = new Window { Width = 850, Height = 180, Content = bar };
47+
Console.WriteLine("stage: show");
48+
window.Show();
49+
Flush();
50+
var list = bar.FindControl<ListBox>("LauncherTabsList");
51+
var tabs = Enumerable.Range(0, vm.Pages.Count).Select(i => list.ContainerFromIndex(i).GetVisualDescendants().OfType<LauncherTabSizeBox>().First()).ToArray();
52+
var layer = AdornerLayer.GetAdornerLayer(bar);
53+
var initialAdorners = layer.Children.Count;
54+
var first = vm.Pages[0];
55+
var order = vm.Pages.Select(p => p.Node.Id).ToArray();
56+
var format = Field<DataFormat<string>>("_dndMainTabFormat");
57+
var data = new DataTransfer();
58+
data.Add(DataTransferItem.Create(format, first.Node.Id));
59+
60+
Console.WriteLine("stage: begin feedback");
61+
Call("BeginDragFeedback", tabs[0], first);
62+
Flush();
63+
Check(tabs[0].Classes.Contains("tab_drag_source"), "source is dimmed");
64+
Check(Math.Abs(tabs[0].Opacity - 0.45) < 0.01, "source dimming is rendered");
65+
Check(layer.Children.Count == initialAdorners + 1, "preview adorner attached");
66+
var over = Drag(DragDrop.DragOverEvent, tabs[1]);
67+
Call("DragOverTabs", bar, over);
68+
Flush();
69+
var previewForReview = Field<Border>("_dragPreview");
70+
var titleForReview = previewForReview.GetVisualDescendants().OfType<TextBlock>().Single();
71+
Console.WriteLine($"REVIEW preview width={previewForReview.Bounds.Width}, title width={titleForReview.Bounds.Width}, title desired width={titleForReview.DesiredSize.Width}, preview clips={previewForReview.ClipToBounds}");
72+
var titlePosition = titleForReview.TranslatePoint(new Point(0, 0), previewForReview)!.Value;
73+
Check(titlePosition.X >= 0 && titlePosition.X + titleForReview.Bounds.Width <= previewForReview.Bounds.Width + 0.01, "title stays within preview width");
74+
Check(titleForReview.TextLayout.TextLines.Any(line => line.HasCollapsed) == (testCase == "long"), "long fixed-width title has ellipsis and fitting titles stay complete");
75+
var bookmark = previewForReview.GetVisualDescendants().OfType<Bookmark>().FirstOrDefault();
76+
var welcomeIcon = previewForReview.GetVisualDescendants().OfType<Avalonia.Controls.Shapes.Path>().FirstOrDefault();
77+
Check(testCase == "welcome" ? welcomeIcon != null : bookmark != null, "preview retains the correct repository or welcome icon");
78+
Check(over.DragEffects == DragDropEffects.Move, "valid target accepts move");
79+
Check(tabs[1].Classes.Contains("tab_drop_target"), "target highlighted");
80+
Check(tabs[1].Background is ISolidColorBrush targetBrush &&
81+
bar.FindResource("Brush.AccentHovered") is ISolidColorBrush expectedBrush &&
82+
targetBrush.Color == expectedBrush.Color, "destination highlight is rendered");
83+
Check(Field<Border>("_dragPreview").IsVisible && Field<Border>("_dropIndicator").IsVisible, "preview and insertion marker visible");
84+
var expectedEdge = tabs[1].TranslatePoint(new Point(tabs[1].Bounds.Width, 2), bar)!.Value;
85+
Check(Math.Abs(Canvas.GetLeft(Field<Border>("_dropIndicator")) - expectedEdge.X + 1.5) < 0.01, "rightward move marker matches insertion edge");
86+
var screenshot = new RenderTargetBitmap(new PixelSize(850, 180));
87+
screenshot.Render(window);
88+
screenshot.Save(Path.Combine(Environment.GetEnvironmentVariable("TEST_OUTPUT")!, "feedback.png"));
89+
screenshot.Dispose();
90+
91+
Call("DragLeaveTabs", bar, new DragEventArgs(DragDrop.DragLeaveEvent, data, bar, new Point(0, 0), KeyModifiers.None));
92+
Check(!Field<Border>("_dragPreview").IsVisible && !tabs[1].Classes.Contains("tab_drop_target"), "leave clears feedback");
93+
Call("DragOverTabs", bar, Drag(DragDrop.DragOverEvent, tabs[2]));
94+
Check(tabs[2].Classes.Contains("tab_drop_target"), "enter another target restores feedback");
95+
Call("ClearDragFeedback");
96+
Check(order.SequenceEqual(vm.Pages.Select(p => p.Node.Id)), "cancel leaves order unchanged");
97+
Check(layer.Children.Count == initialAdorners && Field<Border>("_dragPreview") == null, "cancel removes preview");
98+
Check(Math.Abs(tabs[0].Opacity - 1) < 0.01 &&
99+
tabs[2].Background is ISolidColorBrush clearedTargetBrush &&
100+
clearedTargetBrush.Color == Colors.Transparent, "cancel restores rendered tab styles");
101+
102+
Console.WriteLine("stage: begin feedback");
103+
Call("BeginDragFeedback", tabs[0], first);
104+
Call("DragOverTabs", bar, Drag(DragDrop.DragOverEvent, tabs[1]));
105+
Call("DropTab", tabs[1], Drag(DragDrop.DropEvent, tabs[1]));
106+
Check(vm.Pages[1] == first && vm.Pages[0].Node.Id == order[1], "drop preserves existing reorder behavior");
107+
Check(layer.Children.Count == initialAdorners && !tabs[0].Classes.Contains("tab_drag_source") && !tabs[1].Classes.Contains("tab_drop_target"), "drop clears all feedback");
108+
Flush();
109+
var source = list.ContainerFromIndex(1).GetVisualDescendants().OfType<LauncherTabSizeBox>().First();
110+
var target = list.ContainerFromIndex(0).GetVisualDescendants().OfType<LauncherTabSizeBox>().First();
111+
Call("BeginDragFeedback", source, first);
112+
Call("DragOverTabs", bar, Drag(DragDrop.DragOverEvent, target));
113+
expectedEdge = target.TranslatePoint(new Point(0, 2), bar)!.Value;
114+
Check(Math.Abs(Canvas.GetLeft(Field<Border>("_dropIndicator")) - expectedEdge.X + 1.5) < 0.01, "leftward move marker matches insertion edge");
115+
window.Close();
116+
Check(Field<Border>("_dragPreview") == null, "detach clears preview");
117+
Console.WriteLine("PASS all feedback lifecycle and reorder checks");
118+
119+
void Flush() { Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); Dispatcher.UIThread.RunJobs(); }
120+
T Field<T>(string name) => (T)typeof(LauncherTabBar).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(bar)!;
121+
void Call(string name, params object[] args) => typeof(LauncherTabBar).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(bar, args);
122+
void Check(bool condition, string message) { if (!condition) throw new Exception(message); Console.WriteLine("PASS " + message); }
123+
DragEventArgs Drag(Avalonia.Interactivity.RoutedEvent<DragEventArgs> type, LauncherTabSizeBox target) => new(type, data, target, new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), KeyModifiers.None);

‎tests/TabDragFeedback/README.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Repository tab drag feedback fixture
2+
3+
Run from an initialized SourceGit checkout with .NET SDK 10 and Python 3:
4+
5+
```sh
6+
python3 tests/TabDragFeedback/run.py
7+
```
8+
9+
The runner builds the real SourceGit views and executes four headless rendering
10+
cases: a long fixed-width title, a short title, a welcome tab, and a variable-width
11+
tab. It checks effective source opacity and destination background, preview
12+
clipping and ellipsis, icon selection, both insertion directions, leave/re-entry,
13+
cancellation, drop ordering, and detach cleanup. Logs and PNG previews are saved
14+
in a new temporary directory; `--output DIRECTORY` selects its parent.
15+
16+
Both XDG roots and their SourceGit subdirectories are created before application
17+
initialization. The fixture verifies the resolved application directories and
18+
requires exactly three initial pages. This prevents Linux's fallback to personal
19+
configuration when an XDG root does not exist, including unintended workspace
20+
restoration and scrolling. Each case receives fresh configuration and a private
21+
copy of the user's existing fontconfig cache. A native font initialization crash
22+
fails the test and is recorded in its log; the runner does not preload libraries
23+
or retry failed cases.
24+
25+
To test the same fixture against an Avalonia 12 Wayland checkout:
26+
27+
```sh
28+
python3 tests/TabDragFeedback/run.py \
29+
--source-project /path/to/wayland-checkout/src/SourceGit.csproj \
30+
--avalonia-version 12.1.4
31+
```
32+
33+
`--dotnet /path/to/dotnet` selects an SDK outside `PATH`. The headless package
34+
version must match that checkout's Avalonia version. The fixture invokes feedback
35+
handlers directly; compositor input, native drag sessions, and backend selection
36+
need separate native Wayland/X11 interaction checks.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<AvaloniaVersion Condition="'$(AvaloniaVersion)' == ''">11.3.22</AvaloniaVersion>
7+
<SourceProject Condition="'$(SourceProject)' == ''">$(MSBuildThisFileDirectory)../../src/SourceGit.csproj</SourceProject>
8+
</PropertyGroup>
9+
<ItemGroup>
10+
<ProjectReference Include="$(SourceProject)" />
11+
<PackageReference Include="Avalonia.Headless" Version="$(AvaloniaVersion)" />
12+
</ItemGroup>
13+
</Project>

‎tests/TabDragFeedback/run.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env python3
2+
"""Build and run the tab drag feedback fixture with isolated application state."""
3+
4+
import argparse
5+
import os
6+
from pathlib import Path
7+
import shutil
8+
import subprocess
9+
import tempfile
10+
11+
12+
def main():
13+
parser = argparse.ArgumentParser(description=__doc__)
14+
parser.add_argument("--dotnet", default="dotnet")
15+
parser.add_argument("--source-project", type=Path)
16+
parser.add_argument("--avalonia-version", default="11.3.22")
17+
parser.add_argument("--output", type=Path)
18+
args = parser.parse_args()
19+
20+
fixture = Path(__file__).resolve().parent
21+
output = (args.output or Path(tempfile.mkdtemp(prefix="sourcegit-tab-drag-"))).resolve()
22+
output.mkdir(parents=True, exist_ok=True)
23+
run = Path(tempfile.mkdtemp(prefix="run-", dir=output))
24+
env = os.environ.copy()
25+
# Native backend selection and portable mode must not select personal state.
26+
env.pop("WAYLAND_DISPLAY", None)
27+
env.pop("APPIMAGE", None)
28+
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
29+
project = fixture / "TabDragFeedback.csproj"
30+
properties = [f"-p:AvaloniaVersion={args.avalonia_version}", "-p:DisableAOT=true"]
31+
if args.source_project:
32+
properties.append(f"-p:SourceProject={args.source_project.resolve()}")
33+
build = [args.dotnet, "build", str(project), "-c", "Release",
34+
"-p:UseSharedCompilation=false", "-maxCpuCount:4", *properties]
35+
with (run / "build.log").open("w") as log:
36+
subprocess.run(build, cwd=fixture, env=env, stdout=log,
37+
stderr=subprocess.STDOUT, check=True)
38+
39+
# Preserve normal font discovery without writing to the user's font cache.
40+
# Cold font-cache/native HarfBuzz failures remain visible in the case logs.
41+
font_cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "fontconfig"
42+
for case in ("long", "short", "welcome", "variable"):
43+
case_dir = run / case
44+
for relative in ("config/SourceGit", "cache/SourceGit"):
45+
(case_dir / relative).mkdir(parents=True)
46+
if font_cache.is_dir():
47+
shutil.copytree(font_cache, case_dir / "cache/fontconfig")
48+
case_env = env | {"TEST_OUTPUT": str(case_dir),
49+
"XDG_CONFIG_HOME": str(case_dir / "config"),
50+
"XDG_CACHE_HOME": str(case_dir / "cache")}
51+
command = [args.dotnet, str(fixture / "bin/Release/net10.0/TabDragFeedback.dll"), case]
52+
with (case_dir / "test.log").open("w") as log:
53+
result = subprocess.run(command, cwd=fixture, env=case_env,
54+
stdout=log, stderr=subprocess.STDOUT)
55+
if result.returncode:
56+
raise SystemExit(f"FAIL {case}: see {case_dir / 'test.log'}")
57+
print(f"PASS {case}: {case_dir}", flush=True)
58+
print(f"All four cases passed. Logs and rendered previews: {run}")
59+
60+
61+
if __name__ == "__main__":
62+
main()

0 commit comments

Comments
 (0)