diff --git a/BuildMonitor.Test/ColumnFilterRowTests.cs b/BuildMonitor.Test/ColumnFilterRowTests.cs new file mode 100644 index 0000000..1a1b177 --- /dev/null +++ b/BuildMonitor.Test/ColumnFilterRowTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor.Test; + +using ktsu.Semantics.Strings; +using ktsu.TextFilter; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the column filters as the table applies them: each column's filter, type and match +/// options come from the app data, with the defaults a new install starts with +/// (ktsu-dev/BuildMonitor#319). +/// +/// +/// The filters live on the process-wide , so these run outside the +/// parallel phase and put a fresh instance back afterwards. +/// +[TestClass] +[DoNotParallelize] +public sealed class ColumnFilterRowTests +{ + private static AppData Filters => BuildMonitor.AppData; + + [TestInitialize] + public void SetUp() => BuildMonitor.AppData = new(); + + [TestCleanup] + public void TearDown() => BuildMonitor.AppData = new(); + + private static Build MakeBuild(string owner, string repository, string name, RunStatus status, string branch, out BranchName branchName) + { + Owner buildOwner = new() { Name = owner.As() }; + Repository buildRepository = new() { Name = repository.As(), Owner = buildOwner }; + Build build = new() { Name = name.As(), Owner = buildOwner, Repository = buildRepository }; + branchName = branch.As(); + Run run = new() { Id = "1".As(), Build = build, Branch = branchName, Status = status }; + build.Runs[run.Id] = run; + return build; + } + + private static bool IsBranchShown(string branch) + { + Build build = MakeBuild("ktsu-dev", "BuildMonitor", "dotnet.yml", RunStatus.Success, branch, out BranchName branchName); + return BuildMonitor.ShouldShowBuildBranch(build, branchName); + } + + [TestMethod] + public void ARegexBranchFilterMatchesOnlyThatBranch() + { + Filters.FilterBranch = "^main$"; + Filters.FilterBranchType = TextFilterType.Regex; + + Assert.IsTrue(IsBranchShown("main")); + Assert.IsFalse(IsBranchShown("maintenance")); + } + + [TestMethod] + public void AFuzzyBranchFilterMatchesCharactersInOrder() + { + Filters.FilterBranch = "mn"; + Filters.FilterBranchType = TextFilterType.Fuzzy; + + Assert.IsTrue(IsBranchShown("main")); + Assert.IsFalse(IsBranchShown("develop")); + } + + [TestMethod] + public void AGlobBranchExclusionHidesThatBranch() + { + Filters.FilterBranch = "-main"; + + Assert.IsFalse(IsBranchShown("main")); + Assert.IsTrue(IsBranchShown("develop")); + } + + [TestMethod] + public void EachBuildColumnFiltersOnItsOwnValue() + { + Build build = MakeBuild("ktsu-dev", "BuildMonitor", "dotnet.yml", RunStatus.Failure, "main", out BranchName branch); + + Filters.FilterOwner = "KTSU"; + Filters.FilterRepository = "^build"; + Filters.FilterRepositoryType = TextFilterType.Regex; + Filters.FilterBuildName = "dtnt"; + Filters.FilterBuildNameType = TextFilterType.Fuzzy; + Filters.FilterStatus = "fail"; + Assert.IsTrue(BuildMonitor.ShouldShowBuildBranch(build, branch)); + + Filters.FilterStatus = "-failure"; + Assert.IsFalse(BuildMonitor.ShouldShowBuildBranch(build, branch)); + } + + [TestMethod] + public void AnEmptyRepositoryIsFilteredByOwnerAndRepository() + { + Owner owner = new() { Name = "ktsu-dev".As() }; + Repository repository = new() { Name = "BuildMonitor".As(), Owner = owner }; + + Filters.FilterOwner = "kdv"; + Filters.FilterOwnerType = TextFilterType.Fuzzy; + Filters.FilterRepository = "^build"; + Filters.FilterRepositoryType = TextFilterType.Regex; + Assert.IsTrue(BuildMonitor.ShouldShowEmptyRepository(repository)); + + Filters.FilterOwner = "-ktsu-dev"; + Filters.FilterOwnerType = TextFilterType.Glob; + Assert.IsFalse(BuildMonitor.ShouldShowEmptyRepository(repository)); + } +} diff --git a/BuildMonitor.Test/ColumnFilterTests.cs b/BuildMonitor.Test/ColumnFilterTests.cs new file mode 100644 index 0000000..ab7a5bc --- /dev/null +++ b/BuildMonitor.Test/ColumnFilterTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor.Test; + +using ktsu.TextFilter; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the column filters for each filter type the search box offers. Every column used to wrap +/// the filter in *…* and uppercase it, which only suited a plain Glob word: Regex matched +/// everything, Fuzzy matched nothing, and Glob prefixes were ignored (ktsu-dev/BuildMonitor#319). +/// +[TestClass] +public sealed class ColumnFilterTests +{ + private const TextFilterMatchOptions WholeString = TextFilterMatchOptions.ByWholeString; + + [TestMethod] + public void AnEmptyFilterShowsEverything() => + Assert.IsTrue(ColumnFilter.IsMatch("main", string.Empty, TextFilterType.Glob, WholeString)); + + [TestMethod] + public void APlainGlobWordMatchesAnywhereIgnoringCase() + { + Assert.IsTrue(ColumnFilter.IsMatch("main", "AI", TextFilterType.Glob, WholeString)); + Assert.IsTrue(ColumnFilter.IsMatch("feature/Login", "login", TextFilterType.Glob, WholeString)); + Assert.IsFalse(ColumnFilter.IsMatch("develop", "main", TextFilterType.Glob, WholeString)); + } + + [TestMethod] + public void AnAnchoredRegexMatchesOnlyThatBranch() + { + Assert.IsTrue(ColumnFilter.IsMatch("main", "^main$", TextFilterType.Regex, WholeString)); + Assert.IsFalse(ColumnFilter.IsMatch("maintenance", "^main$", TextFilterType.Regex, WholeString)); + Assert.IsFalse(ColumnFilter.IsMatch("develop", "^main$", TextFilterType.Regex, WholeString)); + } + + [TestMethod] + public void ARegexCharacterClassKeepsItsMeaning() + { + Assert.IsTrue(ColumnFilter.IsMatch("1234", @"^\d+$", TextFilterType.Regex, WholeString)); + Assert.IsFalse(ColumnFilter.IsMatch("main", @"^\d+$", TextFilterType.Regex, WholeString)); + } + + [TestMethod] + public void ARegexIgnoresCase() => + Assert.IsTrue(ColumnFilter.IsMatch("Main", "^main$", TextFilterType.Regex, WholeString)); + + [TestMethod] + public void AFuzzyFilterMatchesCharactersInOrder() + { + Assert.IsTrue(ColumnFilter.IsMatch("main", "mn", TextFilterType.Fuzzy, WholeString)); + Assert.IsFalse(ColumnFilter.IsMatch("develop", "mn", TextFilterType.Fuzzy, WholeString)); + } + + [TestMethod] + public void AGlobExclusionHidesTheBranchAndShowsTheRest() + { + Assert.IsFalse(ColumnFilter.IsMatch("main", "-main", TextFilterType.Glob, WholeString)); + Assert.IsTrue(ColumnFilter.IsMatch("develop", "-main", TextFilterType.Glob, WholeString)); + } + + [TestMethod] + public void OnlyUnprefixedGlobWordsAreWrapped() => + Assert.AreEqual("*a* -b !c ^d +e *f*", ColumnFilter.MakeGlobMatchAnywhere("a -b !c ^d +e f")); +} diff --git a/BuildMonitor/BuildMonitor.cs b/BuildMonitor/BuildMonitor.cs index 34b2fd7..778bdb5 100644 --- a/BuildMonitor/BuildMonitor.cs +++ b/BuildMonitor/BuildMonitor.cs @@ -662,29 +662,29 @@ private static bool IsBuildVisible(Build build) return branches.Any(branch => ShouldShowBuildBranch(build, branch)); } - private static bool ShouldShowBuildBranch(Build build, BranchName branch) + internal static bool ShouldShowBuildBranch(Build build, BranchName branch) { bool shouldShow = true; if (!string.IsNullOrEmpty(AppData.FilterOwner)) { - shouldShow &= TextFilter.IsMatch(build.Owner.Name.ToString().ToUpperInvariant(), "*" + AppData.FilterOwner.ToUpperInvariant() + "*", AppData.FilterOwnerType, AppData.FilterOwnerMatchOptions); + shouldShow &= ColumnFilter.IsMatch(build.Owner.Name.ToString(), AppData.FilterOwner, AppData.FilterOwnerType, AppData.FilterOwnerMatchOptions); } if (!string.IsNullOrEmpty(AppData.FilterRepository)) { string displayRepository = MakeRepositoryDisplayName(build); - shouldShow &= TextFilter.IsMatch(displayRepository.ToUpperInvariant(), "*" + AppData.FilterRepository.ToUpperInvariant() + "*", AppData.FilterRepositoryType, AppData.FilterRepositoryMatchOptions); + shouldShow &= ColumnFilter.IsMatch(displayRepository, AppData.FilterRepository, AppData.FilterRepositoryType, AppData.FilterRepositoryMatchOptions); } if (!string.IsNullOrEmpty(AppData.FilterBuildName)) { string displayName = MakeBuildDisplayName(build); - shouldShow &= TextFilter.IsMatch(displayName.ToUpperInvariant(), "*" + AppData.FilterBuildName.ToUpperInvariant() + "*", AppData.FilterBuildNameType, AppData.FilterBuildNameMatchOptions); + shouldShow &= ColumnFilter.IsMatch(displayName, AppData.FilterBuildName, AppData.FilterBuildNameType, AppData.FilterBuildNameMatchOptions); } if (!string.IsNullOrEmpty(AppData.FilterBranch)) { - shouldShow &= TextFilter.IsMatch(branch.ToString().ToUpperInvariant(), "*" + AppData.FilterBranch.ToUpperInvariant() + "*", AppData.FilterBranchType, AppData.FilterBranchMatchOptions); + shouldShow &= ColumnFilter.IsMatch(branch.ToString(), AppData.FilterBranch, AppData.FilterBranchType, AppData.FilterBranchMatchOptions); } if (!string.IsNullOrEmpty(AppData.FilterStatus)) @@ -695,7 +695,7 @@ private static bool ShouldShowBuildBranch(Build build, BranchName branch) .FirstOrDefault(); if (latestRun is not null) { - shouldShow &= TextFilter.IsMatch(latestRun.Status.ToString().ToUpperInvariant(), "*" + AppData.FilterStatus.ToUpperInvariant() + "*", AppData.FilterStatusType, AppData.FilterStatusMatchOptions); + shouldShow &= ColumnFilter.IsMatch(latestRun.Status.ToString(), AppData.FilterStatus, AppData.FilterStatusType, AppData.FilterStatusMatchOptions); } } @@ -776,19 +776,19 @@ private static void RenderEmptyRepositoryRow(Repository repository) } } - private static bool ShouldShowEmptyRepository(Repository repository) + internal static bool ShouldShowEmptyRepository(Repository repository) { bool shouldShow = true; if (!string.IsNullOrEmpty(AppData.FilterOwner)) { - shouldShow &= TextFilter.IsMatch(repository.Owner.Name.ToString().ToUpperInvariant(), "*" + AppData.FilterOwner.ToUpperInvariant() + "*", AppData.FilterOwnerType, AppData.FilterOwnerMatchOptions); + shouldShow &= ColumnFilter.IsMatch(repository.Owner.Name.ToString(), AppData.FilterOwner, AppData.FilterOwnerType, AppData.FilterOwnerMatchOptions); } if (!string.IsNullOrEmpty(AppData.FilterRepository)) { string displayRepository = MakeRepositoryDisplayName(repository); - shouldShow &= TextFilter.IsMatch(displayRepository.ToUpperInvariant(), "*" + AppData.FilterRepository.ToUpperInvariant() + "*", AppData.FilterRepositoryType, AppData.FilterRepositoryMatchOptions); + shouldShow &= ColumnFilter.IsMatch(displayRepository, AppData.FilterRepository, AppData.FilterRepositoryType, AppData.FilterRepositoryMatchOptions); } // If there's a build name, branch, or status filter, hide empty repositories diff --git a/BuildMonitor/ColumnFilter.cs b/BuildMonitor/ColumnFilter.cs new file mode 100644 index 0000000..fb020ce --- /dev/null +++ b/BuildMonitor/ColumnFilter.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.BuildMonitor; + +using ktsu.TextFilter; + +/// +/// Matches a table cell against the filter typed into its column header. +/// +/// +/// Every column used to wrap the filter in *…* and uppercase both sides, whatever filter type +/// the user had picked. That is only right for a plain Glob word. A Regex such as *MAIN* does +/// not parse and matched everything, and uppercasing turned \d into \D. A Fuzzy filter +/// had to find the two literal asterisks and matched nothing. A Glob token such as -main +/// lost its prefix to the leading * (ktsu-dev/BuildMonitor#319). +/// +internal static class ColumnFilter +{ + private static readonly char[] GlobPrefixes = ['-', '!', '^', '+']; + + /// + /// Determines whether passes , ignoring case. + /// + /// The cell text. + /// The filter the user typed. An empty filter passes everything. + /// The filter type picked for the column. + /// The match options picked for the column. + /// if the cell should be shown. + internal static bool IsMatch(string value, string filter, TextFilterType filterType, TextFilterMatchOptions matchOptions) + { + if (string.IsNullOrEmpty(filter)) + { + return true; + } + + string pattern = filterType == TextFilterType.Glob ? MakeGlobMatchAnywhere(filter) : filter; + return TextFilter.IsMatch(value, pattern, filterType, matchOptions, TextFilterCaseSensitivity.CaseInsensitive); + } + + /// + /// Lets each plain Glob word match anywhere in the cell, so typing mai finds main. + /// A word that starts with a prefix character is left alone so the prefix keeps its meaning. + /// + /// The Glob filter as typed. + /// The filter with each unprefixed word wrapped in *…*. + internal static string MakeGlobMatchAnywhere(string filter) + { + IEnumerable tokens = filter + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(token => GlobPrefixes.Contains(token[0]) ? token : $"*{token}*"); + return string.Join(' ', tokens); + } +}