This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
using Bunit;
using Shouldly;
namespace Prefab.Tests.Web.Client;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class BunitSmokeTests : BunitContext
{
[Fact]
public void RenderSimpleMarkup()
{
var cut = Render(builder =>
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "bunit-smoke");
builder.CloseElement();
});
cut.Find(".bunit-smoke").ShouldNotBeNull();
}
}

View File

@@ -0,0 +1,81 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Prefab.Web.Client.Components.Catalog;
using Prefab.Web.Client.ViewModels.Catalog;
using Shouldly;
namespace Prefab.Tests.Web.Client.Components.Catalog;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class CategoryCardShould : BunitContext
{
[Fact]
public void RenderImageAndTitle()
{
var model = new CategoryCardModel
{
Title = "Ceiling Supports",
Url = "/catalog/products?category-slug=ceiling-supports",
ImageUrl = "/images/categories/ceiling-supports.png",
SecondaryText = "2 products"
};
var cut = Render<CategoryCard>(parameters => parameters.Add(p => p.Category, model));
var root = cut.Find($".{TemplateCss.CardRoot}");
root.ClassList.ShouldContain(TemplateCss.CategoryCardRoot);
var image = cut.Find($".{TemplateCss.CategoryCardImage} img");
image.GetAttribute("src").ShouldBe(model.ImageUrl);
image.GetAttribute("alt").ShouldBe(model.Title);
var title = cut.Find($".{TemplateCss.CategoryCardName}");
title.TextContent.Trim().ShouldBe(model.Title);
var secondary = cut.Find($".{TemplateCss.CategoryCardProducts}");
secondary.TextContent.Trim().ShouldBe("2 products");
}
[Fact]
public void RenderPlaceholderWhenNoImage()
{
const string placeholder = "No image available";
var model = new CategoryCardModel
{
Title = "Prefabricated Assemblies",
Url = "/catalog/products?category-slug=prefab-assemblies"
};
var cut = Render<CategoryCard>(parameters =>
{
parameters.Add(p => p.Category, model);
parameters.Add(p => p.ImagePlaceholderText, placeholder);
});
var placeholderNode = cut.Find($".{TemplateCss.CategoryCardImage} .{TemplateCss.CategoryCardImagePlaceholder}");
placeholderNode.TextContent.Trim().ShouldBe(placeholder);
}
[Fact]
public void InvokeSelectionCallback()
{
var model = new CategoryCardModel
{
Title = "Rod & Strut Hardware",
Url = "/catalog/products?category-slug=rod-strut-hardware"
};
CategoryCardModel? selected = null;
var cut = Render<CategoryCard>(parameters =>
{
parameters.Add(p => p.Category, model);
parameters.Add(p => p.OnCategorySelected, EventCallback.Factory.Create<CategoryCardModel>(this, value => selected = value));
});
cut.Find("a").Click();
selected.ShouldNotBeNull();
ReferenceEquals(selected, model).ShouldBeTrue();
}
}

View File

@@ -0,0 +1,128 @@
using System.Globalization;
using Bunit;
using Prefab.Web.Client.Components.Catalog;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.ViewModels.Catalog;
using Shouldly;
namespace Prefab.Tests.Web.Client.Components.Catalog;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class ProductCardShould : BunitContext
{
[Fact]
public void RenderSaleBadgeOldPriceAndRating()
{
var model = CreateModel(m =>
{
m.IsOnSale = true;
m.OldPrice = 419m;
m.Rating = 4;
m.ReviewCount = 15;
m.Badges.Add("New");
});
var cut = Render<ProductCard>(parameters =>
{
parameters.Add(p => p.Product, model);
parameters.Add(p => p.ShowPrice, true);
});
var root = cut.Find($".{TemplateCss.ProductCardRoot}");
root.ClassList.ShouldContain(TemplateCss.ProductCardGridModifier);
cut.FindAll($".{TemplateCss.ProductCardBadgeSale}").Count.ShouldBe(1);
cut.FindAll($".{TemplateCss.ProductCardBadge}").Count.ShouldBe(2);
var newPrice = cut.Find($".{TemplateCss.ProductCardPriceNew}");
var expectedNewPrice = model.FromPrice?.Amount ?? throw new InvalidOperationException("Expected from price to be set.");
newPrice.TextContent.Trim().ShouldBe(expectedNewPrice.ToString("C", CultureInfo.CurrentCulture));
var oldPrice = cut.Find($".{TemplateCss.ProductCardPriceOld}");
var expectedOldPrice = model.OldPrice ?? throw new InvalidOperationException("Expected old price to be set for sale items.");
oldPrice.TextContent.Trim().ShouldBe(expectedOldPrice.ToString("C", CultureInfo.CurrentCulture));
var rating = cut.Find($".{TemplateCss.ProductCardRating}");
var ratingLabel = rating.QuerySelector($".{TemplateCss.Rating}")!;
ratingLabel.GetAttribute("aria-label").ShouldBe("4 out of 5");
}
[Fact]
public void NotRenderSaleBadgeOrOldPriceWhenAbsent()
{
var model = CreateModel(m =>
{
m.IsOnSale = false;
m.OldPrice = null;
m.Rating = 0;
m.ReviewCount = 0;
});
var cut = Render<ProductCard>(parameters =>
{
parameters.Add(p => p.Product, model);
parameters.Add(p => p.ShowPrice, true);
});
cut.FindAll($".{TemplateCss.ProductCardBadgeSale}").ShouldBeEmpty();
cut.Markup.ShouldNotContain(TemplateCss.ProductCardPriceOld);
cut.Markup.ShouldNotContain(TemplateCss.ProductCardRating);
}
[Fact]
public void NotRenderPricesByDefault()
{
var model = CreateModel();
var cut = Render<ProductCard>(parameters =>
{
parameters.Add(p => p.Product, model);
// ShowPrice is false by default
});
cut.Markup.ShouldNotContain(TemplateCss.ProductCardPrice);
}
[Fact]
public void NotRenderActionButtons()
{
var model = CreateModel();
var cut = Render<ProductCard>(parameters =>
{
parameters.Add(p => p.Product, model);
});
cut.Markup.ShouldNotContain("product-card__actions");
cut.Markup.ShouldNotContain("product-card__buttons");
}
private static ProductCardModel CreateModel(Action<ProductCardModel>? configure = null)
{
var model = new ProductCardModel
{
Id = Guid.NewGuid(),
Title = "Aluminum Chandelier",
Url = "/catalog/product/aluminum-chandelier",
Slug = "aluminum-chandelier",
CategoryName = "Chandeliers",
CategoryUrl = "/catalog/chandeliers",
PrimaryImageUrl = "/images/products/product1.jpg",
FromPrice = new MoneyModel
{
Amount = 321.54m,
Currency = "USD"
},
IsPriced = true,
Rating = 3,
ReviewCount = 2,
Sku = "SKU-1001",
IsOnSale = true,
OldPrice = 399.99m,
LastModifiedOn = DateTimeOffset.UtcNow
};
configure?.Invoke(model);
return model;
}
}

View File

@@ -0,0 +1,71 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Prefab.Web.Client.Components.Shared;
using Shouldly;
namespace Prefab.Tests.Web.Client.Components.Shared;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class CardGridShould : BunitContext
{
[Fact]
public void RenderGridLayoutWithItems()
{
var cut = Render<CardGrid>(parameters =>
{
parameters.Add(p => p.View, "grid");
parameters.Add(p => p.ChildContent, builder =>
{
builder.OpenComponent<CardGridItem>(0);
builder.AddAttribute(1, "ChildContent", (RenderFragment)(child =>
{
child.OpenElement(0, "div");
child.AddAttribute(1, "class", "fake-card-1");
child.CloseElement();
}));
builder.CloseComponent();
builder.OpenComponent<CardGridItem>(2);
builder.AddAttribute(3, "ChildContent", (RenderFragment)(child =>
{
child.OpenElement(0, "div");
child.AddAttribute(1, "class", "fake-card-2");
child.CloseElement();
}));
builder.CloseComponent();
});
});
var container = cut.Find($".{TemplateCss.ProductsViewList}");
container.ClassList.ShouldContain(TemplateCss.ProductsList);
container.ClassList.ShouldContain(TemplateCss.ProductsListLayoutGrid);
var items = cut.FindAll($".{TemplateCss.ProductsListItem}");
items.Count.ShouldBe(2);
items[0].QuerySelector(".fake-card-1").ShouldNotBeNull();
items[1].QuerySelector(".fake-card-2").ShouldNotBeNull();
}
[Fact]
public void RenderListLayoutModifier()
{
var cut = Render<CardGrid>(parameters =>
{
parameters.Add(p => p.View, "list");
parameters.Add(p => p.ChildContent, builder =>
{
builder.OpenComponent<CardGridItem>(0);
builder.AddAttribute(1, "ChildContent", (RenderFragment)(child =>
{
child.OpenElement(0, "div");
child.AddAttribute(1, "class", "fake-card");
child.CloseElement();
}));
builder.CloseComponent();
});
});
var container = cut.Find($".{TemplateCss.ProductsViewList}");
container.ClassList.ShouldContain(TemplateCss.ProductsListLayoutList);
}
}

View File

@@ -0,0 +1,118 @@
using System.Linq;
using Prefab.Web.Client.Models.Home;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.Pages;
using Prefab.Web.Client.Services;
using Prefab.Web.Client.ViewModels.Catalog;
using Shouldly;
using Telerik.Blazor.Components;
namespace Prefab.Tests.Web.Client.Pages;
[Trait(TraitName.Category, TraitCategory.Unit)]
public sealed class HomeComponentShould
{
[Fact]
public async Task LoadHomeContentPopulatesProductsAndCategories()
{
var payload = new HomePageModel
{
FeaturedProducts = Enumerable.Range(0, 3)
.Select(index => new ProductCardModel
{
Title = $"Product {index}",
Url = $"/product/{index}",
FromPrice = new MoneyModel
{
Amount = index,
Currency = "USD"
},
IsPriced = true,
Sku = $"SKU-{index}",
LastModifiedOn = DateTimeOffset.UtcNow
})
.ToList(),
LatestCategories = new List<CategoryCardModel>
{
new()
{
Title = "Leaf A",
Url = "/catalog/category?category-slug=leaf-a",
SecondaryText = "2 Products"
}
}
};
var service = new FakeHomePageService(Result<HomePageModel>.Success(payload));
var component = new TestableHomeComponent();
component.ConfigureServices(service, new NoopNotificationService());
await component.InitializeAsync();
component.IsProductsLoading.ShouldBeFalse();
component.IsCategoriesLoading.ShouldBeFalse();
component.FeaturedProductsView.Count.ShouldBe(3);
component.CategoriesView.Count.ShouldBe(1);
}
[Fact]
public async Task LeaveCollectionsEmptyWhenServiceReturnsNoData()
{
var service = new FakeHomePageService(Result<HomePageModel>.Success(new HomePageModel()));
var component = new TestableHomeComponent();
component.ConfigureServices(service, new NoopNotificationService());
await component.InitializeAsync();
component.FeaturedProductsView.ShouldBeEmpty();
component.CategoriesView.ShouldBeEmpty();
component.HasFeaturedProducts.ShouldBeFalse();
component.HasCategories.ShouldBeFalse();
}
private sealed class FakeHomePageService(Result<HomePageModel> result) : IHomePageService
{
private Result<HomePageModel> Result { get; set; } = result;
public Task<Result<HomePageModel>> Get(CancellationToken cancellationToken) => Task.FromResult(Result);
}
private sealed class NoopNotificationService : INotificationService
{
public void Attach(TelerikNotification notification)
{
}
public void ShowError(string message)
{
}
public void ShowSuccess(string message)
{
}
public void ShowWarning(string message)
{
}
}
private sealed class TestableHomeComponent : HomeComponent
{
public void ConfigureServices(IHomePageService homePageService, INotificationService notificationService)
{
HomePageService = homePageService;
NotificationService = notificationService;
}
public Task InitializeAsync() => OnInitializedAsync();
public IReadOnlyList<ProductCardModel> FeaturedProductsView => base.FeaturedProducts;
public IReadOnlyList<CategoryCardModel> CategoriesView => base.LatestCategories;
public bool IsProductsLoading => IsFeaturedProductsLoading;
public bool IsCategoriesLoading => base.AreCategoriesLoading;
public bool HasFeaturedProducts => FeaturedProductsHaveItemsToShow;
public bool HasCategories => CategoriesHaveItemsToShow;
}
}

View File

@@ -0,0 +1,55 @@
namespace Prefab.Tests.Web.Client;
/// <summary>
/// Mirrors the class hooks used in the Prefab.Web template assets.
/// Sources:
/// HTML (product listing/grid & product card): template/html/classic/shop-left-sidebar.html
/// HTML (product list view): template/html/classic/shop-list.html
/// HTML (category tiles): template/html/classic/index.html
/// CSS: template/html/classic/css/style.css
/// </summary>
public static class TemplateCss
{
// Product card
public const string ProductCardRoot = "product-card";
public const string ProductCardGridModifier = "product-card--layout--grid";
public const string ProductCardListModifier = "product-card--layout--list";
public const string ProductCardActions = "product-card__actions";
public const string ProductCardActionsList = "product-card__actions-list";
public const string ProductCardImage = "product-card__image";
public const string ProductCardBadges = "product-card__badges-list";
public const string ProductCardBadge = "product-card__badge";
public const string ProductCardBadgeSale = "product-card__badge--style--sale";
public const string ProductCardInfo = "product-card__info";
public const string ProductCardCategory = "product-card__category";
public const string ProductCardName = "product-card__name";
public const string ProductCardRating = "product-card__rating";
public const string ProductCardRatingTitle = "product-card__rating-title";
public const string ProductCardRatingStars = "product-card__rating-stars";
public const string Rating = "rating";
public const string RatingStar = "rating__star";
public const string ProductCardPricesList = "product-card__prices-list";
public const string ProductCardPrice = "product-card__price";
public const string ProductCardPriceNew = "product-card__price-new";
public const string ProductCardPriceOld = "product-card__price-old";
public const string ProductCardButtons = "product-card__buttons";
public const string ProductCardButtonsList = "product-card__buttons-list";
public const string ProductCardAddToCart = "product-card__addtocart";
public const string ProductCardWishlist = "product-card__wishlist";
public const string ProductCardCompare = "product-card__compare";
// Category card
public const string CardRoot = "card";
public const string CategoryCardRoot = "category-card";
public const string CategoryCardImage = "category-card__image";
public const string CategoryCardImagePlaceholder = "category-card__image-placeholder";
public const string CategoryCardName = "category-card__name";
public const string CategoryCardProducts = "category-card__products";
// Listing container
public const string ProductsViewList = "products-view__list";
public const string ProductsList = "products-list";
public const string ProductsListItem = "products-list__item";
public const string ProductsListLayoutGrid = "products-list--layout--grid-3";
public const string ProductsListLayoutList = "products-list--layout--list";
}