Init
This commit is contained in:
21
Prefab.Tests/Web.Client/BunitSmokeTests.cs
Normal file
21
Prefab.Tests/Web.Client/BunitSmokeTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
128
Prefab.Tests/Web.Client/Components/Catalog/ProductCardShould.cs
Normal file
128
Prefab.Tests/Web.Client/Components/Catalog/ProductCardShould.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
71
Prefab.Tests/Web.Client/Components/Shared/CardGridShould.cs
Normal file
71
Prefab.Tests/Web.Client/Components/Shared/CardGridShould.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
Prefab.Tests/Web.Client/Pages/HomeComponentShould.cs
Normal file
118
Prefab.Tests/Web.Client/Pages/HomeComponentShould.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
55
Prefab.Tests/Web.Client/TemplateCss.cs
Normal file
55
Prefab.Tests/Web.Client/TemplateCss.cs
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user