|
| 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); |
0 commit comments