diff --git a/src/Tests/Grand.Modules.Tests/Views/ViewComponentTagTests.cs b/src/Tests/Grand.Modules.Tests/Views/ViewComponentTagTests.cs new file mode 100644 index 000000000..61dd46d56 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Views/ViewComponentTagTests.cs @@ -0,0 +1,116 @@ +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Views; + +/// +/// Razor turns <vc:...> into a tag helper only for the view components of an assembly +/// the view registers with @addTagHelper. Any other <vc:...> compiles without a +/// warning and reaches the browser as an empty unknown element, so the component never runs. +/// +[TestClass] +public class ViewComponentTagTests +{ + private static readonly string Src = Path.Combine(Installer.RepositoryPaths.Root, "src"); + private static readonly HashSet SkippedFolders = ["bin", "obj", "node_modules", "wwwroot"]; + + [TestMethod] + public void EveryVcTagResolvesToAComponentItsViewRegisters() + { + var components = ComponentAssemblies(); + var problems = new List(); + var checkedTags = 0; + + foreach (var view in SourceFiles(Src, "*.cshtml")) + { + var tags = Regex.Matches(File.ReadAllText(view), " m.Groups[1].Value).Distinct().ToList(); + if (tags.Count == 0) continue; + + var project = ProjectOf(view); + var registered = RegisteredAssemblies(view, project.Dir); + foreach (var tag in tags) + { + checkedTags++; + if (!components.TryGetValue(tag, out var assemblies)) + problems.Add($"{Path.GetRelativePath(Src, view)}: matches no view component"); + else if (!assemblies.Overlaps(registered)) + problems.Add($"{Path.GetRelativePath(Src, view)}: lives in {string.Join(", ", assemblies)}, " + + $"which {project.Name} does not register with @addTagHelper"); + } + } + + Assert.IsTrue(checkedTags > 0, "No tag found, so the scan itself is broken"); + Assert.AreEqual(0, problems.Count, Environment.NewLine + string.Join(Environment.NewLine, problems)); + } + + [TestMethod] + public void ComponentScan_FindsWidgetComponentsOfEveryPanel() + { + var components = ComponentAssemblies(); + + CollectionAssert.AreEquivalent(new[] { "Grand.Web.Common" }, components["widget"].ToList()); + CollectionAssert.AreEquivalent(new[] { "Grand.Web.Admin" }, components["admin-widget"].ToList()); + CollectionAssert.AreEquivalent(new[] { "Grand.Web.Store" }, components["store-widget"].ToList()); + CollectionAssert.AreEquivalent(new[] { "Grand.Web.Vendor" }, components["vendor-widget"].ToList()); + CollectionAssert.AreEquivalent(new[] { "Grand.Web" }, components["related-products"].ToList()); + } + + //tag name -> assemblies that declare a view component with that name + private static Dictionary> ComponentAssemblies() + { + var declaration = new Regex( + @"(?:\[ViewComponent\(Name\s*=\s*""(?[^""]+)""\)\]\s*)?public\s+(?:sealed\s+)?class\s+(?\w+)(?:\s*:\s*(?\w+))?"); + var components = new Dictionary>(); + foreach (var file in SourceFiles(Src, "*.cs").Where(f => !f.Contains($"{Path.DirectorySeparatorChar}Tests{Path.DirectorySeparatorChar}"))) + foreach (Match m in declaration.Matches(File.ReadAllText(file))) + { + var className = m.Groups["class"].Value; + var isComponent = m.Groups["name"].Success || className.EndsWith("ViewComponent") || + m.Groups["base"].Value.EndsWith("ViewComponent"); + if (!isComponent) continue; + + var name = m.Groups["name"].Success ? m.Groups["name"].Value : className[..^"ViewComponent".Length]; + var tag = Regex.Replace(name, "(?<=[a-z0-9])(?=[A-Z])", "-").ToLowerInvariant(); + if (!components.TryGetValue(tag, out var assemblies)) + components[tag] = assemblies = []; + assemblies.Add(ProjectOf(file).Name); + } + + return components; + } + + private static HashSet RegisteredAssemblies(string view, string projectDir) + { + var assemblies = new HashSet(); + for (var dir = Path.GetDirectoryName(view); dir != null && dir.Length >= projectDir.Length; dir = Path.GetDirectoryName(dir)) + { + var imports = Path.Combine(dir, "_ViewImports.cshtml"); + if (!File.Exists(imports)) continue; + foreach (Match m in Regex.Matches(File.ReadAllText(imports), @"@addTagHelper\s+\*\s*,\s*([\w.]+)")) + assemblies.Add(m.Groups[1].Value); + } + + return assemblies; + } + + private static (string Dir, string Name) ProjectOf(string file) + { + for (var dir = Path.GetDirectoryName(file); dir != null; dir = Path.GetDirectoryName(dir)) + { + var project = Directory.GetFiles(dir, "*.csproj").FirstOrDefault(); + if (project != null) return (dir, Path.GetFileNameWithoutExtension(project)); + } + + throw new InvalidOperationException($"{file} belongs to no project"); + } + + private static IEnumerable SourceFiles(string dir, string pattern) + { + foreach (var file in Directory.GetFiles(dir, pattern)) + yield return file; + foreach (var sub in Directory.GetDirectories(dir).Where(d => !SkippedFolders.Contains(Path.GetFileName(d)))) + foreach (var file in SourceFiles(sub, pattern)) + yield return file; + } +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/List.cshtml index 7aca2ef9a..94cb93ec9 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/List.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/List.cshtml @@ -8,7 +8,7 @@ store's own) and no export or import. The ids the script below looks up are unchanged. *@ - + @Loc["Admin.Common.AddNew"] diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/CreateOrUpdate.TabInfo.cshtml index d81e54a70..94097d26f 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/CreateOrUpdate.TabInfo.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/CreateOrUpdate.TabInfo.cshtml @@ -44,7 +44,7 @@ it is shown. The field order is the one this tab always had, except the picture, last because its upload box is the tallest control. The ids the script above shows and hides - pnlSearchBoxOrder, pnlPageSize, pnlPageSizeOptions - sit on the same fields as before. *@ - +
@@ -110,4 +110,4 @@
- + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Home/AccessDenied.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Home/AccessDenied.cshtml index d9c96057f..511e985f3 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Home/AccessDenied.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Home/AccessDenied.cshtml @@ -1,6 +1,6 @@  - + @* the panel's empty state: nothing here the account may see *@
@@ -8,5 +8,5 @@

@Loc["Admin.AccessDenied.Description"]

- +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/List.cshtml index c0ba12179..1a0008aa5 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/List.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/List.cshtml @@ -10,7 +10,7 @@
- + @Loc["Admin.Common.AddNew"] diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml index 1b454ab35..b6187c6c0 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml @@ -131,7 +131,7 @@
- + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Setting/Partials/Sales.TabLoyaltyPoints.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Setting/Partials/Sales.TabLoyaltyPoints.cshtml index a0998ea2d..62bdd9a67 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Setting/Partials/Sales.TabLoyaltyPoints.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Setting/Partials/Sales.TabLoyaltyPoints.cshtml @@ -1,6 +1,6 @@ @model SalesSettingsModel - + @* the program and what a point is worth, then how points are earned; the rate and the purchase rule read as the sentences they are, with the fields inside them *@ @@ -38,4 +38,4 @@
- + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Setting/Partials/Sales.TabShoppingCart.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Setting/Partials/Sales.TabShoppingCart.cshtml index 646ad0d3c..e4d8b9d49 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Setting/Partials/Sales.TabShoppingCart.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Setting/Partials/Sales.TabShoppingCart.cshtml @@ -1,6 +1,6 @@ @model SalesSettingsModel - + @* what adding to the cart or wishlist does and how many items they hold, what the cart shows, the wishlist e-mail and the mini cart with what they reveal, then the cart's behaviour *@ @@ -50,7 +50,7 @@ - + - \ No newline at end of file + \ No newline at end of file