From 513157cee76786875bef9945dc369dbf004fc4b5 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 1 Apr 2026 21:28:55 -0500 Subject: [PATCH 01/12] ci: add test results and coverage reporting to PR comments --- .github/workflows/ci.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88b57e6..f775d9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI Builds permissions: contents: read + pull-requests: write on: pull_request: @@ -36,7 +37,18 @@ jobs: run: dotnet build --no-restore - name: Test - run: dotnet test --no-build + run: dotnet test --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" + + - name: Generate test report + uses: bibipkins/dotnet-test-reporter@v1.6.1 + if: ${{ always() && matrix.os == 'ubuntu-latest' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + comment-title: "Test Results" + results-path: tests/**/*.trx + coverage-path: tests/**/*.cobertura.xml + coverage-type: cobertura + coverage-threshold: 70 - name: Publish run: dotnet publish src/SharpFM/SharpFM.csproj --runtime "${{ matrix.target }}" -c Debug \ No newline at end of file From ee2e43a8d70afcde7fa2b1f9291cbc53cf670a86 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 1 Apr 2026 21:34:39 -0500 Subject: [PATCH 02/12] chore: exclude ClipRepository from code coverage --- src/SharpFM/Models/ClipRepository.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/SharpFM/Models/ClipRepository.cs b/src/SharpFM/Models/ClipRepository.cs index 266bd77..03f2dc0 100644 --- a/src/SharpFM/Models/ClipRepository.cs +++ b/src/SharpFM/Models/ClipRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using NLog; @@ -8,6 +9,7 @@ namespace SharpFM.Models; /// /// Clip File Repository. /// +[ExcludeFromCodeCoverage] public class ClipRepository { private static readonly Logger Log = LogManager.GetCurrentClassLogger(); From 4126c58ed6f1dc7c1e41f17c4e98343653edbcc9 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 1 Apr 2026 21:36:25 -0500 Subject: [PATCH 03/12] test: add BracketMatcher unit tests --- .../Scripting/BracketMatcherTests.cs | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/SharpFM.Tests/Scripting/BracketMatcherTests.cs diff --git a/tests/SharpFM.Tests/Scripting/BracketMatcherTests.cs b/tests/SharpFM.Tests/Scripting/BracketMatcherTests.cs new file mode 100644 index 0000000..7ca798a --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/BracketMatcherTests.cs @@ -0,0 +1,200 @@ +using SharpFM.Scripting.Parsing; +using Xunit; + +namespace SharpFM.Tests.Scripting; + +public class BracketMatcherTests +{ + // --- FindTopLevelOpenBracket --- + + [Fact] + public void FindTopLevelOpenBracket_SimpleBracket_ReturnsIndex() + { + Assert.Equal(4, BracketMatcher.FindTopLevelOpenBracket("test[value]")); + } + + [Fact] + public void FindTopLevelOpenBracket_NoBracket_ReturnsNegativeOne() + { + Assert.Equal(-1, BracketMatcher.FindTopLevelOpenBracket("no brackets here")); + } + + [Fact] + public void FindTopLevelOpenBracket_BracketInsideQuotes_Ignored() + { + Assert.Equal(-1, BracketMatcher.FindTopLevelOpenBracket("\"quoted[text]\"")); + } + + [Fact] + public void FindTopLevelOpenBracket_BracketInsideParens_Ignored() + { + Assert.Equal(-1, BracketMatcher.FindTopLevelOpenBracket("(nested[bracket])")); + } + + [Fact] + public void FindTopLevelOpenBracket_BracketAfterParens_Found() + { + Assert.Equal(3, BracketMatcher.FindTopLevelOpenBracket("(x)[y]")); + } + + [Fact] + public void FindTopLevelOpenBracket_EscapedQuoteDoesNotCloseString() + { + // "abc\" is an escaped quote inside the string, so the [ is still quoted + Assert.Equal(-1, BracketMatcher.FindTopLevelOpenBracket("\"abc\\\"[x]\"")); + } + + // --- FindMatchingClose --- + + [Fact] + public void FindMatchingClose_SimpleCase_ReturnsClosingIndex() + { + Assert.Equal(6, BracketMatcher.FindMatchingClose("test[x]end", 4)); + } + + [Fact] + public void FindMatchingClose_NestedBrackets_FindsOuterClose() + { + Assert.Equal(7, BracketMatcher.FindMatchingClose("a[b[c]d]e", 1)); + } + + [Fact] + public void FindMatchingClose_Unmatched_ReturnsNegativeOne() + { + Assert.Equal(-1, BracketMatcher.FindMatchingClose("a[open", 1)); + } + + [Fact] + public void FindMatchingClose_BracketInQuotes_IgnoredInDepth() + { + // a[\"]\"bc]d — the ] inside quotes is ignored, outer ] is at index 7 + Assert.Equal(7, BracketMatcher.FindMatchingClose("a[\"]\"bc]d", 1)); + } + + // --- FindMatchingOpen --- + + [Fact] + public void FindMatchingOpen_SimpleCase_ReturnsOpenIndex() + { + // For FindMatchingOpen, closePos should point to the char *before* the ']' we're matching + // The method scans backward from closePos + var text = "test[x]end"; + Assert.Equal(4, BracketMatcher.FindMatchingOpen(text, 5)); + } + + [Fact] + public void FindMatchingOpen_NestedBrackets_FindsOuterOpen() + { + Assert.Equal(1, BracketMatcher.FindMatchingOpen("a[b[c]d]e", 6)); + } + + [Fact] + public void FindMatchingOpen_Unmatched_ReturnsNegativeOne() + { + Assert.Equal(-1, BracketMatcher.FindMatchingOpen("close]", 4)); + } + + // --- HasUnbalancedBrackets --- + + [Fact] + public void HasUnbalancedBrackets_Balanced_ReturnsFalse() + { + Assert.False(BracketMatcher.HasUnbalancedBrackets("[a] [b]")); + } + + [Fact] + public void HasUnbalancedBrackets_MoreOpens_ReturnsTrue() + { + Assert.True(BracketMatcher.HasUnbalancedBrackets("[a [b]")); + } + + [Fact] + public void HasUnbalancedBrackets_MoreCloses_ReturnsFalse() + { + // depth goes negative but ends negative, not > 0 + Assert.False(BracketMatcher.HasUnbalancedBrackets("a] [b")); + } + + [Fact] + public void HasUnbalancedBrackets_BracketsInsideQuotes_Ignored() + { + Assert.False(BracketMatcher.HasUnbalancedBrackets("\"[unmatched\"")); + } + + // --- CountBracketDepth --- + + [Fact] + public void CountBracketDepth_Balanced_ReturnsZero() + { + Assert.Equal(0, BracketMatcher.CountBracketDepth("[x]")); + } + + [Fact] + public void CountBracketDepth_OneOpen_ReturnsOne() + { + Assert.Equal(1, BracketMatcher.CountBracketDepth("[x")); + } + + [Fact] + public void CountBracketDepth_OneClose_ReturnsNegativeOne() + { + Assert.Equal(-1, BracketMatcher.CountBracketDepth("x]")); + } + + [Fact] + public void CountBracketDepth_QuotedBrackets_Ignored() + { + Assert.Equal(0, BracketMatcher.CountBracketDepth("\"[\"")); + } + + // --- SplitParams --- + + [Fact] + public void SplitParams_SimpleSemicolon_Splits() + { + var result = BracketMatcher.SplitParams("a ; b ; c"); + Assert.Equal(["a", "b", "c"], result); + } + + [Fact] + public void SplitParams_SemicolonInQuotes_NotSplit() + { + var result = BracketMatcher.SplitParams("\"a;b\" ; c"); + Assert.Equal(["\"a;b\"", "c"], result); + } + + [Fact] + public void SplitParams_SemicolonInParens_NotSplit() + { + var result = BracketMatcher.SplitParams("func(a;b) ; c"); + Assert.Equal(["func(a;b)", "c"], result); + } + + [Fact] + public void SplitParams_SemicolonInBrackets_NotSplit() + { + var result = BracketMatcher.SplitParams("[a;b] ; c"); + Assert.Equal(["[a;b]", "c"], result); + } + + [Fact] + public void SplitParams_EmptyString_ReturnsEmpty() + { + var result = BracketMatcher.SplitParams(""); + Assert.Empty(result); + } + + [Fact] + public void SplitParams_NoSemicolon_ReturnsSingle() + { + var result = BracketMatcher.SplitParams("just one param"); + Assert.Equal(["just one param"], result); + } + + [Fact] + public void SplitParams_EscapedQuote_HandledCorrectly() + { + var result = BracketMatcher.SplitParams("\"a\\\"b\" ; c"); + Assert.Equal(["\"a\\\"b\"", "c"], result); + } +} From c88c9fb0ff73604ae663fdadbc85a5dbd35c4c56 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 1 Apr 2026 21:37:28 -0500 Subject: [PATCH 04/12] test: add FileMakerClip unit tests --- .../SharpFM.Tests/Core/FileMakerClipTests.cs | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 tests/SharpFM.Tests/Core/FileMakerClipTests.cs diff --git a/tests/SharpFM.Tests/Core/FileMakerClipTests.cs b/tests/SharpFM.Tests/Core/FileMakerClipTests.cs new file mode 100644 index 0000000..09f5caf --- /dev/null +++ b/tests/SharpFM.Tests/Core/FileMakerClipTests.cs @@ -0,0 +1,171 @@ +using System.Linq; +using System.Text; +using Xunit; + +namespace SharpFM.Tests.Core; + +public class FileMakerClipTests +{ + private const string SimpleXml = ""; + private const string TableXml = + "" + + "" + + "" + + "" + + "first name field" + + "" + + "" + + "" + + "" + + ""; + + private const string LayoutXml = + "" + + "" + + "People::FirstName" + + "People::LastName" + + "" + + ""; + + // --- String constructor --- + + [Fact] + public void Constructor_String_SetsNameAndFormat() + { + var clip = new FileMakerClip("MyClip", "Mac-XMSS", SimpleXml); + Assert.Equal("MyClip", clip.Name); + Assert.Equal("Mac-XMSS", clip.ClipboardFormat); + } + + [Fact] + public void Constructor_String_PrettifiesXml() + { + var clip = new FileMakerClip("test", "Mac-XMSS", SimpleXml); + Assert.Contains("\n", clip.XmlData); + } + + [Fact] + public void Constructor_String_InvalidXml_StoresRawString() + { + var clip = new FileMakerClip("test", "Mac-XMSS", "not xml at all"); + Assert.Equal("not xml at all", clip.XmlData); + } + + // --- Byte array constructor --- + + [Fact] + public void Constructor_ByteArray_ExtractsNameFromXml() + { + var xmlBytes = Encoding.UTF8.GetBytes(SimpleXml); + var lengthPrefix = System.BitConverter.GetBytes(xmlBytes.Length); + var data = lengthPrefix.Concat(xmlBytes).ToArray(); + + var clip = new FileMakerClip("fallback", "Mac-XMSS", data); + Assert.Contains("fmxmlsnippet", clip.XmlData); + } + + [Fact] + public void Constructor_ByteArray_EmptyPayload_SetsEmptyXml() + { + // 4 bytes length prefix + no actual data + var data = System.BitConverter.GetBytes(0).Concat(Encoding.UTF8.GetBytes("")).ToArray(); + var clip = new FileMakerClip("fallback", "Mac-XMSS", data); + Assert.Equal("fallback", clip.Name); + } + + // --- RawData round-trip --- + + [Fact] + public void RawData_ContainsLengthPrefixAndXml() + { + var clip = new FileMakerClip("test", "Mac-XMSS", SimpleXml); + var rawData = clip.RawData; + + var length = System.BitConverter.ToInt32(rawData, 0); + var xmlPart = Encoding.UTF8.GetString(rawData, 4, length); + + Assert.Equal(clip.XmlData, xmlPart); + } + + [Fact] + public void RawData_InvalidatedWhenXmlChanges() + { + var clip = new FileMakerClip("test", "Mac-XMSS", SimpleXml); + var first = clip.RawData; + + clip.XmlData = ""; + var second = clip.RawData; + + Assert.NotEqual(first, second); + } + + // --- ClipTypes --- + + [Fact] + public void ClipTypes_ContainsExpectedFormats() + { + Assert.Contains(FileMakerClip.ClipTypes, ct => ct.KeyId == "Mac-XMSS" && ct.DisplayName == "ScriptSteps"); + Assert.Contains(FileMakerClip.ClipTypes, ct => ct.KeyId == "Mac-XMTB" && ct.DisplayName == "Table"); + Assert.Contains(FileMakerClip.ClipTypes, ct => ct.KeyId == "Mac-XML2" && ct.DisplayName == "Layout"); + } + + // --- Fields property --- + + [Fact] + public void Fields_TableClip_ReturnsFieldsWithMetadata() + { + var clip = new FileMakerClip("People", "Mac-XMTB", TableXml); + var fields = clip.Fields.ToList(); + + Assert.Equal(2, fields.Count); + Assert.Equal("FirstName", fields[0].Name); + Assert.Equal("Text", fields[0].DataType); + Assert.True(fields[0].NotEmpty); + Assert.Equal("first name field", fields[0].Comment); + Assert.Equal("Age", fields[1].Name); + Assert.Equal("Number", fields[1].DataType); + } + + [Fact] + public void Fields_LayoutClip_ReturnsFieldNames() + { + var clip = new FileMakerClip("Detail", "Mac-XML2", LayoutXml); + var fields = clip.Fields.ToList(); + + Assert.Equal(2, fields.Count); + Assert.Equal("FirstName", fields[0].Name); + Assert.Equal("LastName", fields[1].Name); + } + + [Fact] + public void Fields_ScriptStepsClip_ReturnsEmpty() + { + var clip = new FileMakerClip("test", "Mac-XMSS", SimpleXml); + Assert.Empty(clip.Fields); + } + + [Fact] + public void Fields_UnknownFormat_ReturnsEmpty() + { + var clip = new FileMakerClip("test", "Unknown-Format", SimpleXml); + Assert.Empty(clip.Fields); + } + + // --- ClipBytesToPrettyXml --- + + [Fact] + public void ClipBytesToPrettyXml_ValidXml_ReturnsPrettyVersion() + { + var bytes = Encoding.UTF8.GetBytes(""); + var result = FileMakerClip.ClipBytesToPrettyXml(bytes); + Assert.Contains("\n", result); + Assert.Contains("child", result); + } + + [Fact] + public void ClipBytesToPrettyXml_EmptyInput_ReturnsEmpty() + { + var result = FileMakerClip.ClipBytesToPrettyXml(System.Array.Empty()); + Assert.Equal(string.Empty, result); + } +} From 12d34f6919069a21cffe5df588116063501d8165 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 1 Apr 2026 21:38:36 -0500 Subject: [PATCH 05/12] test: add FileMakerClipExtensions unit tests --- .../Core/FileMakerClipExtensionsTests.cs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/SharpFM.Tests/Core/FileMakerClipExtensionsTests.cs diff --git a/tests/SharpFM.Tests/Core/FileMakerClipExtensionsTests.cs b/tests/SharpFM.Tests/Core/FileMakerClipExtensionsTests.cs new file mode 100644 index 0000000..e913227 --- /dev/null +++ b/tests/SharpFM.Tests/Core/FileMakerClipExtensionsTests.cs @@ -0,0 +1,136 @@ +using Xunit; + +namespace SharpFM.Tests.Core; + +public class FileMakerClipExtensionsTests +{ + private static FileMakerClip MakeTableClip(string tableName, params (string name, string dataType)[] fields) + { + var fieldElements = string.Join("", fields.Select(f => + $"" + + "" + + "")); + + var xml = $"{fieldElements}"; + return new FileMakerClip(tableName, "Mac-XMTB", xml); + } + + [Fact] + public void CreateClass_TextFields_GeneratesStringProperties() + { + var clip = MakeTableClip("People", ("FirstName", "Text"), ("LastName", "Text")); + var code = clip.CreateClass(); + + Assert.Contains("class People", code); + Assert.Contains("string FirstName { get; set; }", code); + Assert.Contains("string LastName { get; set; }", code); + } + + [Fact] + public void CreateClass_NumberField_GeneratesIntProperty() + { + var clip = MakeTableClip("Items", ("Quantity", "Number")); + var code = clip.CreateClass(); + + Assert.Contains("int Quantity { get; set; }", code); + } + + [Fact] + public void CreateClass_DateField_GeneratesDateTimeProperty() + { + var clip = MakeTableClip("Events", ("EventDate", "Date")); + var code = clip.CreateClass(); + + Assert.Contains("DateTime EventDate { get; set; }", code); + } + + [Fact] + public void CreateClass_TimeField_GeneratesTimeSpanProperty() + { + var clip = MakeTableClip("Events", ("StartTime", "Time")); + var code = clip.CreateClass(); + + Assert.Contains("TimeSpan StartTime { get; set; }", code); + } + + [Fact] + public void CreateClass_TimestampField_GeneratesDateTimeProperty() + { + var clip = MakeTableClip("Log", ("CreatedAt", "TimeStamp")); + var code = clip.CreateClass(); + + Assert.Contains("DateTime CreatedAt { get; set; }", code); + } + + [Fact] + public void CreateClass_BinaryField_GeneratesByteArrayProperty() + { + var clip = MakeTableClip("Docs", ("Photo", "Binary")); + var code = clip.CreateClass(); + + Assert.Contains("byte[] Photo { get; set; }", code); + } + + [Fact] + public void CreateClass_UnknownDataType_DefaultsToString() + { + var clip = MakeTableClip("Misc", ("Custom", "SomeOtherType")); + var code = clip.CreateClass(); + + Assert.Contains("string Custom { get; set; }", code); + } + + [Fact] + public void CreateClass_IncludesDataContractAttribute() + { + var clip = MakeTableClip("People", ("Name", "Text")); + var code = clip.CreateClass(); + + Assert.Contains("[DataContract]", code); + Assert.Contains("[DataMember]", code); + } + + [Fact] + public void CreateClass_IncludesNamespaceAndUsings() + { + var clip = MakeTableClip("People", ("Name", "Text")); + var code = clip.CreateClass(); + + Assert.Contains("namespace SharpFM.CodeGen", code); + Assert.Contains("using System;", code); + Assert.Contains("using System.Runtime.Serialization;", code); + } + + [Fact] + public void CreateClass_WithFieldProjectionList_FiltersFields() + { + var clip = MakeTableClip("People", ("FirstName", "Text"), ("LastName", "Text"), ("Age", "Number")); + var code = clip.CreateClass(new[] { "FirstName", "Age" }); + + Assert.Contains("FirstName", code); + Assert.Contains("Age", code); + Assert.DoesNotContain("LastName", code); + } + + [Fact] + public void CreateClass_NullClip_ReturnsEmpty() + { + FileMakerClip? clip = null; + var code = FileMakerClipExtensions.CreateClass(clip!, (FileMakerClip?)null); + Assert.Equal(string.Empty, code); + } + + [Fact] + public void CreateClass_NullableNumberField_GetsQuestionMark() + { + // NotEmpty=false on a non-string type should produce a nullable + var xml = "" + + "" + + "" + + ""; + var clip = new FileMakerClip("T", "Mac-XMTB", xml); + var code = clip.CreateClass(); + + Assert.Contains("int?", code); + } +} From 7d83bb3846fc0fe975b11500634088bb76b7c668 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Wed, 1 Apr 2026 21:40:01 -0500 Subject: [PATCH 06/12] test: add StepParamValue unit tests --- .../Scripting/StepParamValueTests.cs | 458 ++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 tests/SharpFM.Tests/Scripting/StepParamValueTests.cs diff --git a/tests/SharpFM.Tests/Scripting/StepParamValueTests.cs b/tests/SharpFM.Tests/Scripting/StepParamValueTests.cs new file mode 100644 index 0000000..5e8dd45 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/StepParamValueTests.cs @@ -0,0 +1,458 @@ +using System.Xml.Linq; +using SharpFM.Scripting.Catalog; +using SharpFM.Scripting.Model; +using Xunit; + +namespace SharpFM.Tests.Scripting; + +public class StepParamValueTests +{ + private static StepParam MakeParam(string type, string xmlElement, string? hrLabel = null, + string? xmlAttr = null, string? wrapperElement = null, string? parentElement = null, + Dictionary? hrEnumValues = null, string[]? hrValues = null, + bool invertedHr = false, string? defaultValue = null) + { + return new StepParam + { + Type = type, + XmlElement = xmlElement, + HrLabel = hrLabel, + XmlAttr = xmlAttr, + WrapperElement = wrapperElement, + ParentElement = parentElement, + HrEnumValues = hrEnumValues, + HrValues = hrValues, + InvertedHr = invertedHr, + DefaultValue = defaultValue + }; + } + + // --- FromXml extraction --- + + [Fact] + public void FromXml_Calculation_ExtractsValue() + { + var param = MakeParam("calculation", "Calculation"); + var step = XElement.Parse("Get(AccountName)"); + + var result = StepParamValue.FromXml(step, param); + Assert.Equal("Get(AccountName)", result.Value); + } + + [Fact] + public void FromXml_Calculation_Empty_ReturnsNull() + { + var param = MakeParam("calculation", "Calculation"); + var step = XElement.Parse(""); + + var result = StepParamValue.FromXml(step, param); + Assert.Null(result.Value); + } + + [Fact] + public void FromXml_Text_ExtractsValue() + { + var param = MakeParam("text", "Text"); + var step = XElement.Parse("hello world"); + + var result = StepParamValue.FromXml(step, param); + Assert.Equal("hello world", result.Value); + } + + [Fact] + public void FromXml_Boolean_True_ReturnsOn() + { + var param = MakeParam("boolean", "Restore"); + var step = XElement.Parse(""); + + var result = StepParamValue.FromXml(step, param); + Assert.Equal("On", result.Value); + } + + [Fact] + public void FromXml_Boolean_False_ReturnsOff() + { + var param = MakeParam("boolean", "Restore"); + var step = XElement.Parse(""); + + var result = StepParamValue.FromXml(step, param); + Assert.Equal("Off", result.Value); + } + + [Fact] + public void FromXml_Boolean_InvertedHr() + { + var param = MakeParam("boolean", "NoDialog", invertedHr: true); + var step = XElement.Parse(""); + + var result = StepParamValue.FromXml(step, param); + Assert.Equal("Off", result.Value); + } + + [Fact] + public void FromXml_Boolean_WithHrValues() + { + var param = MakeParam("boolean", "Select", hrValues: ["All", "None"]); + var step = XElement.Parse("