From 298b4ab6222643b6cb95aebb9d95dbc10f8615a3 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 7 Jan 2026 11:18:00 +0800 Subject: [PATCH 01/11] scrollview --- AIDevGallery/Pages/Models/ModelPage.xaml | 47 ++++++++++-------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/AIDevGallery/Pages/Models/ModelPage.xaml b/AIDevGallery/Pages/Models/ModelPage.xaml index 60ed7c2a..d5818a73 100644 --- a/AIDevGallery/Pages/Models/ModelPage.xaml +++ b/AIDevGallery/Pages/Models/ModelPage.xaml @@ -73,33 +73,28 @@ - - - - - - - - - - - - + Margin="0,8,0,0" + ColumnSpacing="16" + RowSpacing="16"> + + + + + + + + + + - @@ -228,7 +222,6 @@ - From a04fce0f7145c9a87d5c2a787c7f8719641c7352 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 7 Jan 2026 12:32:42 +0800 Subject: [PATCH 02/11] Add UT --- .../Markdown/TextElements/MyHyperlink.cs | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs b/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs index f1ecd7c8..0e2b1903 100644 --- a/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs +++ b/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. - using HtmlAgilityPack; using Markdig.Syntax.Inlines; using Microsoft.UI.Xaml.Documents; +using System; using Windows.Foundation; namespace CommunityToolkit.Labs.WinUI.MarkdownTextBlock.TextElements; @@ -40,6 +40,7 @@ public MyHyperlink(LinkInline linkInline, string? baseUrl) var url = linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl() ?? linkInline.Url : linkInline.Url; _linkInline = linkInline; _hyperlink = new Hyperlink(); + SetNavigateUri(url); } public MyHyperlink(HtmlNode htmlNode, string? baseUrl) @@ -48,19 +49,47 @@ public MyHyperlink(HtmlNode htmlNode, string? baseUrl) var url = htmlNode.GetAttribute("href", "#"); _htmlNode = htmlNode; _hyperlink = new Hyperlink(); + SetNavigateUri(url); } - public void AddChild(IAddChild child) + private void SetNavigateUri(string? url) { - if (child.TextElement is Microsoft.UI.Xaml.Documents.Inline inlineChild) + if (string.IsNullOrEmpty(url)) + { + return; + } + + if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out Uri? uri)) { - try + if (uri.IsAbsoluteUri) { - _hyperlink.Inlines.Add(inlineChild); + _hyperlink.NavigateUri = uri; } - catch + else if (!string.IsNullOrEmpty(_baseUrl) && Uri.TryCreate(_baseUrl, UriKind.Absolute, out Uri? baseUri)) { + if (Uri.TryCreate(baseUri, uri, out Uri? absoluteUri)) + { + _hyperlink.NavigateUri = absoluteUri; + } } } } + + public void AddChild(IAddChild child) + { + // Hyperlink cannot contain InlineUIContainer - this is a WinUI limitation + if (child.TextElement is not Microsoft.UI.Xaml.Documents.Inline inlineChild || inlineChild is InlineUIContainer) + { + return; + } + + try + { + _hyperlink.Inlines.Add(inlineChild); + } + catch (ArgumentException ex) + { + System.Diagnostics.Debug.WriteLine($"[MyHyperlink] Failed to add {inlineChild.GetType().Name}: {ex.Message}"); + } + } } \ No newline at end of file From ed863d5cc90df4aeba69ea4b09a4cdee1fc350d7 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 7 Jan 2026 12:32:55 +0800 Subject: [PATCH 03/11] Add UT --- .../Controls/Markdown/MyHyperlinkTests.cs | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs diff --git a/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs b/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs new file mode 100644 index 00000000..563e5d90 --- /dev/null +++ b/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using CommunityToolkit.Labs.WinUI.MarkdownTextBlock.TextElements; +using HtmlAgilityPack; +using Markdig.Syntax.Inlines; +using Microsoft.UI.Xaml.Documents; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace AIDevGallery.Tests.UnitTests.Controls.Markdown; + +[TestClass] +public class MyHyperlinkTests +{ + private static MyHyperlink CreateTestHyperlink() + { + var linkInline = new LinkInline { Url = "https://test.com" }; + return new MyHyperlink(linkInline, null); + } + + private static HtmlNode CreateTestHtmlNode(string href = "https://test.com") + { + var html = $"link"; + var doc = new HtmlDocument(); + doc.LoadHtml(html); + return doc.DocumentNode.SelectSingleNode("//a"); + } + + [DataTestMethod] + [DataRow("Run", DisplayName = "AddChild with Run")] + [DataRow("Span", DisplayName = "AddChild with Span")] + [DataRow("Bold", DisplayName = "AddChild with Bold")] + public void AddChildWithInlineElementShouldAddSuccessfully(string elementType) + { + // Arrange + var hyperlink = CreateTestHyperlink(); + Microsoft.UI.Xaml.Documents.Inline inlineElement = elementType switch + { + "Run" => new Run { Text = "Test Text" }, + "Span" => new Span { Inlines = { new Run { Text = "Span Content" } } }, + "Bold" => new Bold { Inlines = { new Run { Text = "Bold Text" } } }, + _ => throw new ArgumentException($"Unknown element type: {elementType}") + }; + var addChild = new TestAddChild(inlineElement); + + // Act + hyperlink.AddChild(addChild); + + // Assert + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.AreEqual(1, hyperlinkElement.Inlines.Count); + Assert.AreEqual(inlineElement, hyperlinkElement.Inlines[0]); + } + + [TestMethod] + public void AddChildWithInlineUIContainerShouldBeSkipped() + { + // Arrange + var hyperlink = CreateTestHyperlink(); + var inlineUIContainer = new InlineUIContainer(); + var addChild = new TestAddChild(inlineUIContainer); + + // Act + hyperlink.AddChild(addChild); + + // Assert - InlineUIContainer should not be added due to WinUI limitation + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.AreEqual(0, hyperlinkElement.Inlines.Count); + } + + [TestMethod] + public void ConstructorWithLinkInlineShouldInitializeCorrectly() + { + // Arrange & Act + var linkInline = new LinkInline { Url = "https://example.com" }; + var hyperlink = new MyHyperlink(linkInline, "https://base.com"); + + // Assert + Assert.IsNotNull(hyperlink.TextElement); + Assert.IsInstanceOfType(hyperlink.TextElement); + Assert.IsFalse( + hyperlink.IsHtml, + "LinkInline constructor should set IsHtml to false"); + + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.AreEqual(new Uri("https://example.com"), hyperlinkElement.NavigateUri); + } + + [TestMethod] + public void ConstructorWithHtmlNodeShouldInitializeCorrectly() + { + // Arrange & Act + var htmlNode = CreateTestHtmlNode("https://example.com"); + var hyperlink = new MyHyperlink(htmlNode, "https://base.com"); + + // Assert + Assert.IsNotNull(hyperlink.TextElement); + Assert.IsInstanceOfType(hyperlink.TextElement); + Assert.IsTrue( + hyperlink.IsHtml, + "HtmlNode constructor should set IsHtml to true"); + + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.AreEqual(new Uri("https://example.com"), hyperlinkElement.NavigateUri); + } + + [TestMethod] + public void AddChildShouldHandleNestedHyperlinkGracefully() + { + // Arrange + var hyperlink = CreateTestHyperlink(); + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + var initialCount = hyperlinkElement.Inlines.Count; + + // Add a nested hyperlink which WinUI typically rejects + var nestedHyperlink = new Hyperlink(); + nestedHyperlink.Inlines.Add(new Run { Text = "Nested" }); + var addChild = new TestAddChild(nestedHyperlink); + + // Act - Should not throw exception + hyperlink.AddChild(addChild); + + // Assert - Either caught by exception handler or rejected by WinUI, but no crash + // The count should be the same or potentially increased (implementation dependent) + Assert.IsTrue( + hyperlinkElement.Inlines.Count >= initialCount, + "Should handle nested hyperlink without throwing exception"); + } + + [DataTestMethod] + [DataRow("https://example.com/test", null, "https://example.com/test", DisplayName = "Absolute URL without base")] + [DataRow("test/page", "https://example.com/", "https://example.com/test/page", DisplayName = "Relative URL with base")] + [DataRow("/absolute/path", "https://example.com/other/", "https://example.com/absolute/path", DisplayName = "Absolute path with base")] + [DataRow("https://example.com/html", "https://other.com/", "https://example.com/html", DisplayName = "Absolute URL ignores base")] + public void NavigateUriShouldResolveUrlsCorrectly(string url, string baseUrl, string expectedUrl) + { + // Arrange + var linkInline = new LinkInline { Url = url }; + + // Act + var hyperlink = new MyHyperlink(linkInline, baseUrl); + + // Assert + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.IsNotNull(hyperlinkElement.NavigateUri); + Assert.AreEqual(new Uri(expectedUrl), hyperlinkElement.NavigateUri); + } + + [DataTestMethod] + [DataRow("not_a_valid_url", null, DisplayName = "Invalid URL without base")] + [DataRow("relative/path", null, DisplayName = "Relative path without base")] + [DataRow("/absolute/path", null, DisplayName = "Absolute path without base")] + [DataRow("test.html", "invalid-base-url", DisplayName = "Relative URL with invalid base")] + public void NavigateUriShouldBeNullWhenUrlCannotBeResolved(string url, string baseUrl) + { + // Arrange + var linkInline = new LinkInline { Url = url }; + + // Act + var hyperlink = new MyHyperlink(linkInline, baseUrl); + + // Assert + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.IsNull( + hyperlinkElement.NavigateUri, + $"NavigateUri should be null when URL '{url}' cannot be resolved with base '{baseUrl}'"); + } + + [TestMethod] + public void HtmlNodeWithMissingHrefShouldUseDefaultValue() + { + // Arrange + var html = "link without href"; + var doc = new HtmlDocument(); + doc.LoadHtml(html); + var htmlNode = doc.DocumentNode.SelectSingleNode("//a"); + + // Act + var hyperlink = new MyHyperlink(htmlNode, null); + + // Assert - Should not throw and should use default "#" value + Assert.IsNotNull(hyperlink); + Assert.IsTrue(hyperlink.IsHtml); + + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + + // HtmlAgilityPack returns "#" as default, which is not a valid absolute URI + Assert.IsNull( + hyperlinkElement.NavigateUri, + "NavigateUri should be null for default '#' href"); + } + + [TestMethod] + public void LinkInlineWithEmptyOrNullUrlShouldNotThrow() + { + // Arrange & Act & Assert - Should not throw + var linkInlineEmpty = new LinkInline { Url = string.Empty }; + var hyperlinkEmpty = new MyHyperlink(linkInlineEmpty, null); + Assert.IsNotNull(hyperlinkEmpty); + Assert.IsFalse(hyperlinkEmpty.IsHtml); + + var linkInlineNull = new LinkInline { Url = null }; + var hyperlinkNull = new MyHyperlink(linkInlineNull, null); + Assert.IsNotNull(hyperlinkNull); + Assert.IsFalse(hyperlinkNull.IsHtml); + } + + [TestMethod] + public void ConstructorShouldUseGetDynamicUrlWhenAvailable() + { + // Arrange + var staticUrl = "https://static.com"; + var dynamicUrl = "https://dynamic.com"; + var linkInline = new LinkInline + { + Url = staticUrl, + GetDynamicUrl = () => dynamicUrl + }; + + // Act + var hyperlink = new MyHyperlink(linkInline, null); + + // Assert + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.IsNotNull(hyperlinkElement.NavigateUri); + Assert.AreEqual( + new Uri(dynamicUrl), + hyperlinkElement.NavigateUri, + "Should use GetDynamicUrl result instead of static Url"); + } + + [TestMethod] + public void ConstructorShouldHandleNullGetDynamicUrlResult() + { + // Arrange + var staticUrl = "https://static.com"; + var linkInline = new LinkInline + { + Url = staticUrl, + GetDynamicUrl = () => null! // Returns null + }; + + // Act + var hyperlink = new MyHyperlink(linkInline, null); + + // Assert + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.IsNotNull(hyperlinkElement.NavigateUri); + Assert.AreEqual( + new Uri(staticUrl), + hyperlinkElement.NavigateUri, + "Should fallback to static Url when GetDynamicUrl returns null"); + } + + [DataTestMethod] + [DataRow("mailto:test@example.com", "mailto:test@example.com", DisplayName = "Mailto URL")] + [DataRow("tel:+1234567890", "tel:+1234567890", DisplayName = "Tel URL")] + [DataRow("ftp://ftp.example.com", "ftp://ftp.example.com/", DisplayName = "FTP URL")] + public void NavigateUriShouldHandleSpecialUrlSchemes(string url, string expectedUrl) + { + // Arrange + var linkInline = new LinkInline { Url = url }; + + // Act + var hyperlink = new MyHyperlink(linkInline, null); + + // Assert + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.IsNotNull( + hyperlinkElement.NavigateUri, + $"Should handle {url} scheme"); + Assert.AreEqual(new Uri(expectedUrl), hyperlinkElement.NavigateUri); + } + + // Test helper class to wrap TextElement for IAddChild interface + private class TestAddChild : IAddChild + { + public TestAddChild(TextElement textElement) + { + TextElement = textElement; + } + + public TextElement TextElement { get; } + + public void AddChild(IAddChild child) + { + // Not needed for these tests + throw new NotImplementedException(); + } + } +} \ No newline at end of file From dc692bfe7bf2c01393f4ad048c631ee4c06905fa Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 7 Jan 2026 12:48:15 +0800 Subject: [PATCH 04/11] fix --- .../Controls/Markdown/MyHyperlinkTests.cs | 39 +++++++++++++++++++ .../Markdown/TextElements/MyHyperlink.cs | 13 ++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs b/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs index 563e5d90..4f47c4d5 100644 --- a/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs +++ b/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs @@ -152,6 +152,8 @@ public void NavigateUriShouldResolveUrlsCorrectly(string url, string baseUrl, st [DataRow("relative/path", null, DisplayName = "Relative path without base")] [DataRow("/absolute/path", null, DisplayName = "Absolute path without base")] [DataRow("test.html", "invalid-base-url", DisplayName = "Relative URL with invalid base")] + [DataRow("page.html", "relative/base/path", DisplayName = "Relative URL with relative base")] + [DataRow("../other/page.html", "some/relative/path", DisplayName = "Relative URL with dot-dot and relative base")] public void NavigateUriShouldBeNullWhenUrlCannotBeResolved(string url, string baseUrl) { // Arrange @@ -206,6 +208,43 @@ public void LinkInlineWithEmptyOrNullUrlShouldNotThrow() Assert.IsFalse(hyperlinkNull.IsHtml); } + [TestMethod] + public void RelativeBaseUrlShouldNotResolveRelativeUrls() + { + // Arrange + var relativeUrl = "page.html"; + var relativeBaseUrl = "relative/base/path"; + var linkInline = new LinkInline { Url = relativeUrl }; + + // Act + var hyperlink = new MyHyperlink(linkInline, relativeBaseUrl); + + // Assert + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.IsNull( + hyperlinkElement.NavigateUri, + "NavigateUri should be null when base URL is relative (not absolute)"); + } + + [TestMethod] + public void RelativeBaseUrlWithAbsoluteUrlShouldUseAbsoluteUrl() + { + // Arrange - Even with a relative base, absolute URLs should work + var absoluteUrl = "https://example.com/page"; + var relativeBaseUrl = "relative/base/path"; + var linkInline = new LinkInline { Url = absoluteUrl }; + + // Act + var hyperlink = new MyHyperlink(linkInline, relativeBaseUrl); + + // Assert + var hyperlinkElement = (Hyperlink)hyperlink.TextElement; + Assert.IsNotNull( + hyperlinkElement.NavigateUri, + "Absolute URL should be resolved even when base URL is relative"); + Assert.AreEqual(new Uri(absoluteUrl), hyperlinkElement.NavigateUri); + } + [TestMethod] public void ConstructorShouldUseGetDynamicUrlWhenAvailable() { diff --git a/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs b/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs index 0e2b1903..dbd67ad7 100644 --- a/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs +++ b/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs @@ -65,11 +65,14 @@ private void SetNavigateUri(string? url) { _hyperlink.NavigateUri = uri; } - else if (!string.IsNullOrEmpty(_baseUrl) && Uri.TryCreate(_baseUrl, UriKind.Absolute, out Uri? baseUri)) + else if (!string.IsNullOrEmpty(_baseUrl)) { - if (Uri.TryCreate(baseUri, uri, out Uri? absoluteUri)) + if (Uri.TryCreate(_baseUrl, UriKind.Absolute, out Uri? baseUri)) { - _hyperlink.NavigateUri = absoluteUri; + if (Uri.TryCreate(baseUri, uri, out Uri? absoluteUri)) + { + _hyperlink.NavigateUri = absoluteUri; + } } } } @@ -87,9 +90,9 @@ public void AddChild(IAddChild child) { _hyperlink.Inlines.Add(inlineChild); } - catch (ArgumentException ex) + catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"[MyHyperlink] Failed to add {inlineChild.GetType().Name}: {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Exception when adding {inlineChild.GetType().Name}: {ex.GetType().Name} - {ex.Message}"); } } } \ No newline at end of file From 96384bb70cc6b84927a57e41c80af8e03fd01126 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 7 Jan 2026 15:35:27 +0800 Subject: [PATCH 05/11] fix --- AIDevGallery/Pages/Models/ModelPage.xaml.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/AIDevGallery/Pages/Models/ModelPage.xaml.cs b/AIDevGallery/Pages/Models/ModelPage.xaml.cs index 18d340cd..37ae9b2a 100644 --- a/AIDevGallery/Pages/Models/ModelPage.xaml.cs +++ b/AIDevGallery/Pages/Models/ModelPage.xaml.cs @@ -252,19 +252,19 @@ private void ToolkitActionFlyoutItem_Click(object sender, RoutedEventArgs e) bool wasDeeplinkSuccesful = true; try { - Process.Start(new ProcessStartInfo() + _ = Task.Run(() => Process.Start(new ProcessStartInfo() { FileName = toolkitDeeplink, UseShellExecute = true - }); + })); } catch { - Process.Start(new ProcessStartInfo() + _ = Task.Run(() => Process.Start(new ProcessStartInfo() { FileName = "https://learn.microsoft.com/en-us/windows/ai/toolkit/", UseShellExecute = true - }); + })); wasDeeplinkSuccesful = false; } finally @@ -319,7 +319,7 @@ private void MarkdownTextBlock_OnLinkClicked(object sender, CommunityToolkit.Lab FileName = uri.AbsoluteUri, UseShellExecute = true }; - Process.Start(psi); + _ = Task.Run(() => Process.Start(psi)); } catch (Exception ex) when (ex is Win32Exception || ex is InvalidOperationException From 861ec7b33f92ac8cc1cf6c77df213c3202fed4a8 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 7 Jan 2026 15:45:54 +0800 Subject: [PATCH 06/11] fix --- .../Controls/Markdown/MyHyperlinkTests.cs | 190 ------------------ .../Markdown/TextElements/MyHyperlink.cs | 28 --- 2 files changed, 218 deletions(-) diff --git a/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs b/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs index 4f47c4d5..b928edd6 100644 --- a/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs +++ b/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs @@ -82,9 +82,6 @@ public void ConstructorWithLinkInlineShouldInitializeCorrectly() Assert.IsFalse( hyperlink.IsHtml, "LinkInline constructor should set IsHtml to false"); - - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.AreEqual(new Uri("https://example.com"), hyperlinkElement.NavigateUri); } [TestMethod] @@ -100,9 +97,6 @@ public void ConstructorWithHtmlNodeShouldInitializeCorrectly() Assert.IsTrue( hyperlink.IsHtml, "HtmlNode constructor should set IsHtml to true"); - - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.AreEqual(new Uri("https://example.com"), hyperlinkElement.NavigateUri); } [TestMethod] @@ -128,190 +122,6 @@ public void AddChildShouldHandleNestedHyperlinkGracefully() "Should handle nested hyperlink without throwing exception"); } - [DataTestMethod] - [DataRow("https://example.com/test", null, "https://example.com/test", DisplayName = "Absolute URL without base")] - [DataRow("test/page", "https://example.com/", "https://example.com/test/page", DisplayName = "Relative URL with base")] - [DataRow("/absolute/path", "https://example.com/other/", "https://example.com/absolute/path", DisplayName = "Absolute path with base")] - [DataRow("https://example.com/html", "https://other.com/", "https://example.com/html", DisplayName = "Absolute URL ignores base")] - public void NavigateUriShouldResolveUrlsCorrectly(string url, string baseUrl, string expectedUrl) - { - // Arrange - var linkInline = new LinkInline { Url = url }; - - // Act - var hyperlink = new MyHyperlink(linkInline, baseUrl); - - // Assert - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.IsNotNull(hyperlinkElement.NavigateUri); - Assert.AreEqual(new Uri(expectedUrl), hyperlinkElement.NavigateUri); - } - - [DataTestMethod] - [DataRow("not_a_valid_url", null, DisplayName = "Invalid URL without base")] - [DataRow("relative/path", null, DisplayName = "Relative path without base")] - [DataRow("/absolute/path", null, DisplayName = "Absolute path without base")] - [DataRow("test.html", "invalid-base-url", DisplayName = "Relative URL with invalid base")] - [DataRow("page.html", "relative/base/path", DisplayName = "Relative URL with relative base")] - [DataRow("../other/page.html", "some/relative/path", DisplayName = "Relative URL with dot-dot and relative base")] - public void NavigateUriShouldBeNullWhenUrlCannotBeResolved(string url, string baseUrl) - { - // Arrange - var linkInline = new LinkInline { Url = url }; - - // Act - var hyperlink = new MyHyperlink(linkInline, baseUrl); - - // Assert - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.IsNull( - hyperlinkElement.NavigateUri, - $"NavigateUri should be null when URL '{url}' cannot be resolved with base '{baseUrl}'"); - } - - [TestMethod] - public void HtmlNodeWithMissingHrefShouldUseDefaultValue() - { - // Arrange - var html = "link without href"; - var doc = new HtmlDocument(); - doc.LoadHtml(html); - var htmlNode = doc.DocumentNode.SelectSingleNode("//a"); - - // Act - var hyperlink = new MyHyperlink(htmlNode, null); - - // Assert - Should not throw and should use default "#" value - Assert.IsNotNull(hyperlink); - Assert.IsTrue(hyperlink.IsHtml); - - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - - // HtmlAgilityPack returns "#" as default, which is not a valid absolute URI - Assert.IsNull( - hyperlinkElement.NavigateUri, - "NavigateUri should be null for default '#' href"); - } - - [TestMethod] - public void LinkInlineWithEmptyOrNullUrlShouldNotThrow() - { - // Arrange & Act & Assert - Should not throw - var linkInlineEmpty = new LinkInline { Url = string.Empty }; - var hyperlinkEmpty = new MyHyperlink(linkInlineEmpty, null); - Assert.IsNotNull(hyperlinkEmpty); - Assert.IsFalse(hyperlinkEmpty.IsHtml); - - var linkInlineNull = new LinkInline { Url = null }; - var hyperlinkNull = new MyHyperlink(linkInlineNull, null); - Assert.IsNotNull(hyperlinkNull); - Assert.IsFalse(hyperlinkNull.IsHtml); - } - - [TestMethod] - public void RelativeBaseUrlShouldNotResolveRelativeUrls() - { - // Arrange - var relativeUrl = "page.html"; - var relativeBaseUrl = "relative/base/path"; - var linkInline = new LinkInline { Url = relativeUrl }; - - // Act - var hyperlink = new MyHyperlink(linkInline, relativeBaseUrl); - - // Assert - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.IsNull( - hyperlinkElement.NavigateUri, - "NavigateUri should be null when base URL is relative (not absolute)"); - } - - [TestMethod] - public void RelativeBaseUrlWithAbsoluteUrlShouldUseAbsoluteUrl() - { - // Arrange - Even with a relative base, absolute URLs should work - var absoluteUrl = "https://example.com/page"; - var relativeBaseUrl = "relative/base/path"; - var linkInline = new LinkInline { Url = absoluteUrl }; - - // Act - var hyperlink = new MyHyperlink(linkInline, relativeBaseUrl); - - // Assert - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.IsNotNull( - hyperlinkElement.NavigateUri, - "Absolute URL should be resolved even when base URL is relative"); - Assert.AreEqual(new Uri(absoluteUrl), hyperlinkElement.NavigateUri); - } - - [TestMethod] - public void ConstructorShouldUseGetDynamicUrlWhenAvailable() - { - // Arrange - var staticUrl = "https://static.com"; - var dynamicUrl = "https://dynamic.com"; - var linkInline = new LinkInline - { - Url = staticUrl, - GetDynamicUrl = () => dynamicUrl - }; - - // Act - var hyperlink = new MyHyperlink(linkInline, null); - - // Assert - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.IsNotNull(hyperlinkElement.NavigateUri); - Assert.AreEqual( - new Uri(dynamicUrl), - hyperlinkElement.NavigateUri, - "Should use GetDynamicUrl result instead of static Url"); - } - - [TestMethod] - public void ConstructorShouldHandleNullGetDynamicUrlResult() - { - // Arrange - var staticUrl = "https://static.com"; - var linkInline = new LinkInline - { - Url = staticUrl, - GetDynamicUrl = () => null! // Returns null - }; - - // Act - var hyperlink = new MyHyperlink(linkInline, null); - - // Assert - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.IsNotNull(hyperlinkElement.NavigateUri); - Assert.AreEqual( - new Uri(staticUrl), - hyperlinkElement.NavigateUri, - "Should fallback to static Url when GetDynamicUrl returns null"); - } - - [DataTestMethod] - [DataRow("mailto:test@example.com", "mailto:test@example.com", DisplayName = "Mailto URL")] - [DataRow("tel:+1234567890", "tel:+1234567890", DisplayName = "Tel URL")] - [DataRow("ftp://ftp.example.com", "ftp://ftp.example.com/", DisplayName = "FTP URL")] - public void NavigateUriShouldHandleSpecialUrlSchemes(string url, string expectedUrl) - { - // Arrange - var linkInline = new LinkInline { Url = url }; - - // Act - var hyperlink = new MyHyperlink(linkInline, null); - - // Assert - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.IsNotNull( - hyperlinkElement.NavigateUri, - $"Should handle {url} scheme"); - Assert.AreEqual(new Uri(expectedUrl), hyperlinkElement.NavigateUri); - } - // Test helper class to wrap TextElement for IAddChild interface private class TestAddChild : IAddChild { diff --git a/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs b/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs index dbd67ad7..4888d9d3 100644 --- a/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs +++ b/AIDevGallery/Controls/Markdown/TextElements/MyHyperlink.cs @@ -40,7 +40,6 @@ public MyHyperlink(LinkInline linkInline, string? baseUrl) var url = linkInline.GetDynamicUrl != null ? linkInline.GetDynamicUrl() ?? linkInline.Url : linkInline.Url; _linkInline = linkInline; _hyperlink = new Hyperlink(); - SetNavigateUri(url); } public MyHyperlink(HtmlNode htmlNode, string? baseUrl) @@ -49,33 +48,6 @@ public MyHyperlink(HtmlNode htmlNode, string? baseUrl) var url = htmlNode.GetAttribute("href", "#"); _htmlNode = htmlNode; _hyperlink = new Hyperlink(); - SetNavigateUri(url); - } - - private void SetNavigateUri(string? url) - { - if (string.IsNullOrEmpty(url)) - { - return; - } - - if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out Uri? uri)) - { - if (uri.IsAbsoluteUri) - { - _hyperlink.NavigateUri = uri; - } - else if (!string.IsNullOrEmpty(_baseUrl)) - { - if (Uri.TryCreate(_baseUrl, UriKind.Absolute, out Uri? baseUri)) - { - if (Uri.TryCreate(baseUri, uri, out Uri? absoluteUri)) - { - _hyperlink.NavigateUri = absoluteUri; - } - } - } - } } public void AddChild(IAddChild child) From 23f3eb8c0436ca1d6652e3dcd0cdbb44dfd84bf4 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Wed, 7 Jan 2026 17:20:23 +0800 Subject: [PATCH 07/11] remove ut --- .../Controls/Markdown/MyHyperlinkTests.cs | 141 ------------------ 1 file changed, 141 deletions(-) delete mode 100644 AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs diff --git a/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs b/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs deleted file mode 100644 index b928edd6..00000000 --- a/AIDevGallery.Tests/UnitTests/Controls/Markdown/MyHyperlinkTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using CommunityToolkit.Labs.WinUI.MarkdownTextBlock.TextElements; -using HtmlAgilityPack; -using Markdig.Syntax.Inlines; -using Microsoft.UI.Xaml.Documents; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; - -namespace AIDevGallery.Tests.UnitTests.Controls.Markdown; - -[TestClass] -public class MyHyperlinkTests -{ - private static MyHyperlink CreateTestHyperlink() - { - var linkInline = new LinkInline { Url = "https://test.com" }; - return new MyHyperlink(linkInline, null); - } - - private static HtmlNode CreateTestHtmlNode(string href = "https://test.com") - { - var html = $"link"; - var doc = new HtmlDocument(); - doc.LoadHtml(html); - return doc.DocumentNode.SelectSingleNode("//a"); - } - - [DataTestMethod] - [DataRow("Run", DisplayName = "AddChild with Run")] - [DataRow("Span", DisplayName = "AddChild with Span")] - [DataRow("Bold", DisplayName = "AddChild with Bold")] - public void AddChildWithInlineElementShouldAddSuccessfully(string elementType) - { - // Arrange - var hyperlink = CreateTestHyperlink(); - Microsoft.UI.Xaml.Documents.Inline inlineElement = elementType switch - { - "Run" => new Run { Text = "Test Text" }, - "Span" => new Span { Inlines = { new Run { Text = "Span Content" } } }, - "Bold" => new Bold { Inlines = { new Run { Text = "Bold Text" } } }, - _ => throw new ArgumentException($"Unknown element type: {elementType}") - }; - var addChild = new TestAddChild(inlineElement); - - // Act - hyperlink.AddChild(addChild); - - // Assert - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.AreEqual(1, hyperlinkElement.Inlines.Count); - Assert.AreEqual(inlineElement, hyperlinkElement.Inlines[0]); - } - - [TestMethod] - public void AddChildWithInlineUIContainerShouldBeSkipped() - { - // Arrange - var hyperlink = CreateTestHyperlink(); - var inlineUIContainer = new InlineUIContainer(); - var addChild = new TestAddChild(inlineUIContainer); - - // Act - hyperlink.AddChild(addChild); - - // Assert - InlineUIContainer should not be added due to WinUI limitation - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - Assert.AreEqual(0, hyperlinkElement.Inlines.Count); - } - - [TestMethod] - public void ConstructorWithLinkInlineShouldInitializeCorrectly() - { - // Arrange & Act - var linkInline = new LinkInline { Url = "https://example.com" }; - var hyperlink = new MyHyperlink(linkInline, "https://base.com"); - - // Assert - Assert.IsNotNull(hyperlink.TextElement); - Assert.IsInstanceOfType(hyperlink.TextElement); - Assert.IsFalse( - hyperlink.IsHtml, - "LinkInline constructor should set IsHtml to false"); - } - - [TestMethod] - public void ConstructorWithHtmlNodeShouldInitializeCorrectly() - { - // Arrange & Act - var htmlNode = CreateTestHtmlNode("https://example.com"); - var hyperlink = new MyHyperlink(htmlNode, "https://base.com"); - - // Assert - Assert.IsNotNull(hyperlink.TextElement); - Assert.IsInstanceOfType(hyperlink.TextElement); - Assert.IsTrue( - hyperlink.IsHtml, - "HtmlNode constructor should set IsHtml to true"); - } - - [TestMethod] - public void AddChildShouldHandleNestedHyperlinkGracefully() - { - // Arrange - var hyperlink = CreateTestHyperlink(); - var hyperlinkElement = (Hyperlink)hyperlink.TextElement; - var initialCount = hyperlinkElement.Inlines.Count; - - // Add a nested hyperlink which WinUI typically rejects - var nestedHyperlink = new Hyperlink(); - nestedHyperlink.Inlines.Add(new Run { Text = "Nested" }); - var addChild = new TestAddChild(nestedHyperlink); - - // Act - Should not throw exception - hyperlink.AddChild(addChild); - - // Assert - Either caught by exception handler or rejected by WinUI, but no crash - // The count should be the same or potentially increased (implementation dependent) - Assert.IsTrue( - hyperlinkElement.Inlines.Count >= initialCount, - "Should handle nested hyperlink without throwing exception"); - } - - // Test helper class to wrap TextElement for IAddChild interface - private class TestAddChild : IAddChild - { - public TestAddChild(TextElement textElement) - { - TextElement = textElement; - } - - public TextElement TextElement { get; } - - public void AddChild(IAddChild child) - { - // Not needed for these tests - throw new NotImplementedException(); - } - } -} \ No newline at end of file From 66e3ea7862f1b1429181de0476c9240a86e20d49 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 8 Jan 2026 18:24:12 +0800 Subject: [PATCH 08/11] Fix exception handling in Task.Run and correct Grid.Row index - Move try-catch blocks inside Task.Run to properly capture exceptions from Process.Start() - Fix Grid.Row from 2 to 1 in ModelPage.xaml (parent Grid only has 2 rows) - Ensure telemetry logging happens within Task context for accurate failure tracking --- AIDevGallery/Pages/Models/ModelPage.xaml | 2 +- AIDevGallery/Pages/Models/ModelPage.xaml.cs | 78 ++++++++++++--------- 2 files changed, 46 insertions(+), 34 deletions(-) diff --git a/AIDevGallery/Pages/Models/ModelPage.xaml b/AIDevGallery/Pages/Models/ModelPage.xaml index d5818a73..4cb8a80b 100644 --- a/AIDevGallery/Pages/Models/ModelPage.xaml +++ b/AIDevGallery/Pages/Models/ModelPage.xaml @@ -75,7 +75,7 @@ { - _ = Task.Run(() => Process.Start(new ProcessStartInfo() + bool wasDeeplinkSuccesful = true; + try { - FileName = toolkitDeeplink, - UseShellExecute = true - })); - } - catch - { - _ = Task.Run(() => Process.Start(new ProcessStartInfo() + Process.Start(new ProcessStartInfo() + { + FileName = toolkitDeeplink, + UseShellExecute = true + }); + } + catch { - FileName = "https://learn.microsoft.com/en-us/windows/ai/toolkit/", - UseShellExecute = true - })); - wasDeeplinkSuccesful = false; - } - finally - { - AIToolkitActionClickedEvent.Log(AIToolkitHelper.AIToolkitActionInfos[action].QueryName, modelDetails.Name, wasDeeplinkSuccesful); - } + try + { + Process.Start(new ProcessStartInfo() + { + FileName = "https://learn.microsoft.com/en-us/windows/ai/toolkit/", + UseShellExecute = true + }); + } + catch + { + // Silently fail if fallback also fails + } + wasDeeplinkSuccesful = false; + } + finally + { + AIToolkitActionClickedEvent.Log(AIToolkitHelper.AIToolkitActionInfos[action].QueryName, modelDetails.Name, wasDeeplinkSuccesful); + } + }); } } @@ -312,22 +322,24 @@ private void MarkdownTextBlock_OnLinkClicked(object sender, CommunityToolkit.Lab return; } - try + _ = Task.Run(() => { - var psi = new ProcessStartInfo + try { - FileName = uri.AbsoluteUri, - UseShellExecute = true - }; - _ = Task.Run(() => Process.Start(psi)); - } - catch (Exception ex) when (ex is Win32Exception - || ex is InvalidOperationException - || ex is PlatformNotSupportedException) - { - ModelDetailsLinkClickedEvent.Log($"OpenFailed: {uri} | {ex.GetType().Name}: {ex.Message}"); - ShowDialog(message: errorMessage); - } + var psi = new ProcessStartInfo + { + FileName = uri.AbsoluteUri, + UseShellExecute = true + }; + Process.Start(psi); + } + catch (Exception ex) when (ex is Win32Exception + || ex is InvalidOperationException + || ex is PlatformNotSupportedException) + { + ModelDetailsLinkClickedEvent.Log($"OpenFailed: {uri} | {ex.GetType().Name}: {ex.Message}"); + } + }); } private async void ShowDialog(string? message) From d3904a39eccdf5b30a6672e23ab2aaa871cba01d Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 8 Jan 2026 18:37:00 +0800 Subject: [PATCH 09/11] Fix UI thread issue when showing error dialog in Task.Run - Use DispatcherQueue.TryEnqueue to show error dialog on UI thread - Prevents crash when link opening fails in background task --- AIDevGallery/Pages/Models/ModelPage.xaml.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AIDevGallery/Pages/Models/ModelPage.xaml.cs b/AIDevGallery/Pages/Models/ModelPage.xaml.cs index 44508c12..e504b3c5 100644 --- a/AIDevGallery/Pages/Models/ModelPage.xaml.cs +++ b/AIDevGallery/Pages/Models/ModelPage.xaml.cs @@ -338,6 +338,10 @@ private void MarkdownTextBlock_OnLinkClicked(object sender, CommunityToolkit.Lab || ex is PlatformNotSupportedException) { ModelDetailsLinkClickedEvent.Log($"OpenFailed: {uri} | {ex.GetType().Name}: {ex.Message}"); + DispatcherQueue.TryEnqueue(() => + { + ShowDialog(message: errorMessage); + }); } }); } From e339a478abc9ba6405dafb941ed16f8178c57962 Mon Sep 17 00:00:00 2001 From: "Milly Wei (from Dev Box)" Date: Thu, 8 Jan 2026 18:51:07 +0800 Subject: [PATCH 10/11] Fix StyleCop SA1513 - Add blank line after closing brace --- AIDevGallery/Pages/Models/ModelPage.xaml.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/AIDevGallery/Pages/Models/ModelPage.xaml.cs b/AIDevGallery/Pages/Models/ModelPage.xaml.cs index e504b3c5..92cb838a 100644 --- a/AIDevGallery/Pages/Models/ModelPage.xaml.cs +++ b/AIDevGallery/Pages/Models/ModelPage.xaml.cs @@ -274,6 +274,7 @@ private void ToolkitActionFlyoutItem_Click(object sender, RoutedEventArgs e) { // Silently fail if fallback also fails } + wasDeeplinkSuccesful = false; } finally From c765aec2d9b984e19362090e6aa8982610c33d67 Mon Sep 17 00:00:00 2001 From: Milly Way <181923419@qq.com> Date: Thu, 8 Jan 2026 18:57:44 +0800 Subject: [PATCH 11/11] Update AIDevGallery/Pages/Models/ModelPage.xaml.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- AIDevGallery/Pages/Models/ModelPage.xaml.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AIDevGallery/Pages/Models/ModelPage.xaml.cs b/AIDevGallery/Pages/Models/ModelPage.xaml.cs index 92cb838a..d368f340 100644 --- a/AIDevGallery/Pages/Models/ModelPage.xaml.cs +++ b/AIDevGallery/Pages/Models/ModelPage.xaml.cs @@ -270,9 +270,10 @@ private void ToolkitActionFlyoutItem_Click(object sender, RoutedEventArgs e) UseShellExecute = true }); } - catch + catch (Exception ex) { - // Silently fail if fallback also fails + // Log the failure to open the fallback URL for diagnostics, but do not surface it to the user. + Debug.WriteLine($"Failed to open AI Toolkit fallback URL: {ex}"); } wasDeeplinkSuccesful = false;