diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
new file mode 100644
index 0000000..7aee387
--- /dev/null
+++ b/.github/workflows/build-and-test.yml
@@ -0,0 +1,33 @@
+name: Build and test
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ runs-on: windows-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@v6
+
+ - name: Set up .NET 8
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore WorkNotes.sln
+
+ - name: Build release configuration
+ run: dotnet build WorkNotes.sln --configuration Release --no-restore
+
+ - name: Run tests
+ run: dotnet test WorkNotes.sln --configuration Release --no-build --no-restore --verbosity normal
diff --git a/WorkNotes.Tests/LinkTextParserTests.cs b/WorkNotes.Tests/LinkTextParserTests.cs
new file mode 100644
index 0000000..ce45eee
--- /dev/null
+++ b/WorkNotes.Tests/LinkTextParserTests.cs
@@ -0,0 +1,89 @@
+using WorkNotes.Services;
+
+namespace WorkNotes.Tests;
+
+public sealed class LinkTextParserTests
+{
+ [Fact]
+ public void FindLinks_ReturnsNoLinksForEmptyText()
+ {
+ Assert.Empty(LinkTextParser.FindLinks(string.Empty));
+ }
+
+ [Fact]
+ public void FindLinks_DetectsEmailWithoutOverlappingDomain()
+ {
+ const string text = "Contact dev@example.com for help.";
+
+ var link = Assert.Single(LinkTextParser.FindLinks(text));
+
+ Assert.Equal("dev@example.com", link.DisplayText);
+ Assert.Equal("mailto:dev@example.com", link.Url);
+ Assert.Equal(text.IndexOf("dev@example.com", StringComparison.Ordinal), link.StartOffset);
+ Assert.Equal(link.StartOffset + link.DisplayText.Length, link.EndOffset);
+ }
+
+ [Theory]
+ [InlineData("https://example.com/docs", "https://example.com/docs")]
+ [InlineData("http://example.com", "http://example.com")]
+ [InlineData("www.example.com/path", "https://www.example.com/path")]
+ [InlineData("example.com/path?q=1#top", "https://example.com/path?q=1#top")]
+ public void FindLinks_NormalizesSupportedUrlForms(string text, string expectedUrl)
+ {
+ var link = Assert.Single(LinkTextParser.FindLinks(text));
+
+ Assert.Equal(text, link.DisplayText);
+ Assert.Equal(expectedUrl, link.Url);
+ }
+
+ [Theory]
+ [InlineData("Visit example.com.", "example.com")]
+ [InlineData("Visit example.com, then continue", "example.com")]
+ [InlineData("Visit (example.com)", "example.com")]
+ [InlineData("Visit [example.com]", "example.com")]
+ public void FindLinks_TrimsSentencePunctuation(string text, string expectedDisplayText)
+ {
+ var link = Assert.Single(LinkTextParser.FindLinks(text));
+
+ Assert.Equal(expectedDisplayText, link.DisplayText);
+ Assert.Equal("https://" + expectedDisplayText, link.Url);
+ }
+
+ [Fact]
+ public void FindLinks_PreservesBalancedParenthesesInUrl()
+ {
+ const string text = "https://example.com/wiki/Function_(mathematics)";
+
+ var link = Assert.Single(LinkTextParser.FindLinks(text));
+
+ Assert.Equal(text, link.DisplayText);
+ Assert.Equal(text.Length, link.EndOffset);
+ }
+
+ [Fact]
+ public void FindLinks_AppliesBaseOffsetToExclusiveRange()
+ {
+ const string text = "example.com";
+
+ var link = Assert.Single(LinkTextParser.FindLinks(text, baseOffset: 17));
+
+ Assert.Equal(17, link.StartOffset);
+ Assert.Equal(17 + text.Length, link.EndOffset);
+ }
+
+ [Fact]
+ public void FindLinks_ReturnsMixedLinksInDocumentOrder()
+ {
+ const string text = "See example.com or email team@example.org, then visit https://docs.example.net/start.";
+
+ var links = LinkTextParser.FindLinks(text);
+
+ Assert.Collection(
+ links,
+ link => Assert.Equal("example.com", link.DisplayText),
+ link => Assert.Equal("team@example.org", link.DisplayText),
+ link => Assert.Equal("https://docs.example.net/start", link.DisplayText));
+ Assert.True(links[0].EndOffset <= links[1].StartOffset);
+ Assert.True(links[1].EndOffset <= links[2].StartOffset);
+ }
+}
diff --git a/WorkNotes.Tests/WorkNotes.Tests.csproj b/WorkNotes.Tests/WorkNotes.Tests.csproj
new file mode 100644
index 0000000..f09b6d0
--- /dev/null
+++ b/WorkNotes.Tests/WorkNotes.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0-windows
+ Exe
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WorkNotes.sln b/WorkNotes.sln
index 68e4aee..9284638 100644
--- a/WorkNotes.sln
+++ b/WorkNotes.sln
@@ -1,19 +1,25 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31903.59
-MinimumVisualStudioVersion = 10.0.40219.1
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkNotes", "WorkNotes\WorkNotes.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkNotes.Tests", "WorkNotes.Tests\WorkNotes.Tests.csproj", "{B0DA7728-F09E-4076-96F1-D37A0C653DBA}"
+EndProject
Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B0DA7728-F09E-4076-96F1-D37A0C653DBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B0DA7728-F09E-4076-96F1-D37A0C653DBA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B0DA7728-F09E-4076-96F1-D37A0C653DBA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B0DA7728-F09E-4076-96F1-D37A0C653DBA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/WorkNotes/WorkNotes.csproj b/WorkNotes/WorkNotes.csproj
index d28bab0..4c06999 100644
--- a/WorkNotes/WorkNotes.csproj
+++ b/WorkNotes/WorkNotes.csproj
@@ -11,12 +11,16 @@
1.5.0.0
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+