| 1 | #nullable enable |
| 2 | |
| 3 | using CascadeIDE.Models.Intercom; |
| 4 | using CascadeIDE.Services; |
| 5 | using CascadeIDE.Services.Intercom; |
| 6 | using CascadeIDE.Views.Chat; |
| 7 | using CascadeIDE.Views.Chat.Skia; |
| 8 | using SkiaSharp; |
| 9 | using Xunit; |
| 10 | |
| 11 | namespace CascadeIDE.Tests; |
| 12 | |
| 13 | public sealed class SkiaChatFeedAttachHitTests |
| 14 | { |
| 15 | [Fact] |
| 16 | public void ComputeFeedLinkHitRect_is_narrower_than_full_segment() |
| 17 | { |
| 18 | var segment = new SKRect(12, 80, 400, 104); |
| 19 | var metrics = new SkiaChatBubbleMetrics( |
| 20 | [new SkiaMarkdownLine([new SkiaMarkdownRun("[TryResolveFile]", SkiaMarkdownStyle.Link)])], |
| 21 | Footer: null, |
| 22 | TitleHeight: 0, |
| 23 | FooterHeight: 0, |
| 24 | LineHeight: 15); |
| 25 | |
| 26 | var feed = SkiaChatFeedLayout.For(forwardHost: false); |
| 27 | var hit = SkiaChatBubbleRenderer.ComputeFeedLinkHitRect(segment, "[TryResolveFile]", metrics, feed); |
| 28 | |
| 29 | Assert.True(hit.Width < segment.Width - 40f); |
| 30 | Assert.True(hit.Left > segment.Left); |
| 31 | Assert.InRange(hit.Top, segment.Top, segment.Bottom); |
| 32 | } |
| 33 | |
| 34 | [Fact] |
| 35 | public void HitRegistry_find_prefers_attach_reveal_over_later_row_hit() |
| 36 | { |
| 37 | var registry = new SkiaChatHitRegistry(); |
| 38 | registry.RegisterControlRect( |
| 39 | new Avalonia.Rect(0, 0, 400, 80), |
| 40 | new SkiaChatHit(0, null, ResetDetailMode: false)); |
| 41 | registry.RegisterControlRect( |
| 42 | new Avalonia.Rect(12, 20, 200, 24), |
| 43 | new SkiaChatHit(0, null, ResetDetailMode: false, RevealAttachment: new AttachmentAnchor { File = "a.cs" })); |
| 44 | |
| 45 | var index = registry.FindIndex(new Avalonia.Point(20, 28)); |
| 46 | |
| 47 | Assert.Equal(1, index); |
| 48 | Assert.True(registry.TryGetHit(index, out var hit)); |
| 49 | Assert.NotNull(hit.RevealAttachment); |
| 50 | Assert.True(SkiaChatHitRegistry.WantsHandCursor(hit)); |
| 51 | } |
| 52 | |
| 53 | [Fact] |
| 54 | public void HitRegistry_content_rect_scroll_maps_to_control_viewport() |
| 55 | { |
| 56 | const float chromeTop = 48f; |
| 57 | const float scrollOffset = 120f; |
| 58 | var scrollInContext = scrollOffset - chromeTop; |
| 59 | var registry = new SkiaChatHitRegistry(); |
| 60 | registry.RegisterContentRect( |
| 61 | new SKRect(12, 200, 372, 220), |
| 62 | scrollInContext, |
| 63 | new SkiaChatHit(0, null, ResetDetailMode: false, RevealAttachment: new AttachmentAnchor { File = "a.cs" })); |
| 64 | |
| 65 | var controlY = chromeTop - scrollOffset + 200f; |
| 66 | var controlPoint = new Avalonia.Point(20, controlY + 5); |
| 67 | Assert.Equal(0, registry.FindIndex(controlPoint)); |
| 68 | Assert.True(registry.TryGetHit(0, out var hit)); |
| 69 | Assert.NotNull(hit.RevealAttachment); |
| 70 | } |
| 71 | |
| 72 | [Fact] |
| 73 | public void Attach_chip_classify_resolved_degraded_failed_pending() |
| 74 | { |
| 75 | var resolved = new AttachmentAnchor |
| 76 | { |
| 77 | File = "src/Foo.cs", |
| 78 | ResolveOutcome = IntercomAttachmentRevealPlan.OutcomeResolved, |
| 79 | }; |
| 80 | Assert.Equal( |
| 81 | IntercomAttachLinkVisualStatus.Resolved, |
| 82 | SkiaIntercomAttachLinkChip.Classify(resolved, messagePending: false)); |
| 83 | |
| 84 | var degraded = resolved with { ResolveOutcome = IntercomAttachmentRevealPlan.OutcomeMemberNotFound }; |
| 85 | Assert.Equal( |
| 86 | IntercomAttachLinkVisualStatus.Degraded, |
| 87 | SkiaIntercomAttachLinkChip.Classify(degraded, messagePending: false)); |
| 88 | |
| 89 | var failed = resolved with |
| 90 | { |
| 91 | File = "missing.cs", |
| 92 | ResolveOutcome = IntercomAttachmentRevealPlan.OutcomeFileMissing, |
| 93 | }; |
| 94 | Assert.Equal( |
| 95 | IntercomAttachLinkVisualStatus.Failed, |
| 96 | SkiaIntercomAttachLinkChip.Classify(failed, messagePending: false)); |
| 97 | |
| 98 | Assert.Equal( |
| 99 | IntercomAttachLinkVisualStatus.Pending, |
| 100 | SkiaIntercomAttachLinkChip.Classify(resolved, messagePending: true)); |
| 101 | } |
| 102 | |
| 103 | [Fact] |
| 104 | public void RegisterFeedMarkdownLinkHits_registers_narrow_link_rects() |
| 105 | { |
| 106 | var registry = new SkiaChatHitRegistry(); |
| 107 | var drawContext = new SkiaChatDrawContext |
| 108 | { |
| 109 | Canvas = null!, |
| 110 | Theme = SkiaChatTheme.DarkFallback, |
| 111 | ContentLeft = 12, |
| 112 | ContentWidth = 360, |
| 113 | ScrollOffset = 0, |
| 114 | ItemIndex = 0, |
| 115 | HoveredItemIndex = -1, |
| 116 | SelectedMessageIndex = -1, |
| 117 | HitRegistry = registry, |
| 118 | }; |
| 119 | |
| 120 | var segment = new SKRect(12, 40, 372, 80); |
| 121 | var metrics = new SkiaChatBubbleMetrics( |
| 122 | [ |
| 123 | new SkiaMarkdownLine( |
| 124 | [ |
| 125 | new SkiaMarkdownRun("see ", SkiaMarkdownStyle.Plain), |
| 126 | new SkiaMarkdownRun("Run", SkiaMarkdownStyle.Link), |
| 127 | new SkiaMarkdownRun(" ok", SkiaMarkdownStyle.Plain), |
| 128 | ]), |
| 129 | ], |
| 130 | Footer: null, |
| 131 | TitleHeight: 0, |
| 132 | FooterHeight: 0, |
| 133 | LineHeight: 15); |
| 134 | |
| 135 | var anchor = new AttachmentAnchor { File = "Foo.cs", MemberKey = "Run", DisplayLabel = "Run" }; |
| 136 | var feed = SkiaChatFeedLayout.For(forwardHost: false); |
| 137 | SkiaChatBubbleRenderer.RegisterFeedMarkdownLinkHits( |
| 138 | drawContext, |
| 139 | segment, |
| 140 | metrics, |
| 141 | feed, |
| 142 | messageIndex: 3, |
| 143 | linkText => string.Equals(linkText, "Run", StringComparison.Ordinal) ? anchor : null); |
| 144 | |
| 145 | Assert.Equal(1, registry.Count); |
| 146 | var linkBaseline = feed.FirstLineBaselineY(segment.Top, metrics.TitleHeight); |
| 147 | Assert.Equal(0, registry.FindIndex(new Avalonia.Point(50, linkBaseline))); |
| 148 | Assert.Equal(-1, registry.FindIndex(new Avalonia.Point(12, segment.Top))); |
| 149 | Assert.True(registry.TryGetHit(0, out var hit)); |
| 150 | Assert.Equal(3, hit.MessageIndex); |
| 151 | Assert.Equal("Run", hit.RevealAttachment?.MemberKey); |
| 152 | } |
| 153 | |
| 154 | [Fact] |
| 155 | public void HitRegistry_find_prefers_chrome_action_over_row_hit() |
| 156 | { |
| 157 | var registry = new SkiaChatHitRegistry(); |
| 158 | registry.RegisterControlRect( |
| 159 | new Avalonia.Rect(0, 0, 400, 80), |
| 160 | new SkiaChatHit(0, null, ResetDetailMode: false)); |
| 161 | registry.RegisterControlRect( |
| 162 | new Avalonia.Rect(20, 20, 80, 24), |
| 163 | new SkiaChatHit(null, null, ResetDetailMode: false, PointerAction: SkiaChatPointerAction.OverviewToggle)); |
| 164 | |
| 165 | Assert.Equal(1, registry.FindIndex(new Avalonia.Point(40, 30))); |
| 166 | } |
| 167 | |
| 168 | [Fact] |
| 169 | public void HitRegistry_contains_pointer_action_for_wheel_routing() |
| 170 | { |
| 171 | var registry = new SkiaChatHitRegistry(); |
| 172 | registry.RegisterControlRect( |
| 173 | new Avalonia.Rect(0, 500, 800, 120), |
| 174 | new SkiaChatHit(null, null, ResetDetailMode: false, PointerAction: SkiaChatPointerAction.ComposerFocus)); |
| 175 | |
| 176 | Assert.True(registry.ContainsPointerAction(new Avalonia.Point(100, 550), SkiaChatPointerAction.ComposerFocus)); |
| 177 | Assert.False(registry.ContainsPointerAction(new Avalonia.Point(100, 200), SkiaChatPointerAction.ComposerFocus)); |
| 178 | } |
| 179 | |
| 180 | [Fact] |
| 181 | public void Link_body_measure_uses_single_link_run_for_bracket_label() |
| 182 | { |
| 183 | var ctx = new SkiaChatMeasureContext(48, 360); |
| 184 | var spec = new SkiaChatBubbleSpec( |
| 185 | "", |
| 186 | "[TryResolveFile]", |
| 187 | null, |
| 188 | SkiaChatBubbleKind.Feed, |
| 189 | SkiaBubbleFillRole.MessageAssistant, |
| 190 | SkiaChatBodyTone.Link, |
| 191 | false, |
| 192 | false, |
| 193 | false, |
| 194 | 0); |
| 195 | |
| 196 | var metrics = SkiaChatBubbleRenderer.Measure(ctx, spec); |
| 197 | Assert.Single(metrics.ContentLines); |
| 198 | Assert.Single(metrics.ContentLines[0].Runs); |
| 199 | Assert.Equal(SkiaMarkdownStyle.Link, metrics.ContentLines[0].Runs[0].Style); |
| 200 | } |
| 201 | } |
| 202 | |