Итак, в одном из проектов мне понадобилось реализовать функциональность отображения иерархических данных на веб-странице. Данные представленны в виде класса:
public class Product { public int Id { get; set; } public string Name { get; set; } public ProductType Type { get; set; } public decimal Price { get; set; } public Product Parent { get; set; } }
В бд это хранится в таком же виде. Задача - отображать на сайте иерархическую структуру продуктов с url'ами вида
/catalog/some/product/in/other/product/
т.е. юзер, зайдя по ссылке /catalog/some получит продукт с именем "some" самого верхнего уровня (у этого продукта нет родителя), зайдя по ссыке /catalog/some/product получит продукт с именем "product" и родителем у которого будет является "some" , ну и т.д...
Вообщем ничего сложного, в коде это реализуется примерно так:
Маршрутизация:
// маршрут /Catalog/* routes.MapRoute( null, "Catalog/{*path}", new { controller = "Catalog", action = "Index", path = string.Empty} );
объявляется маршрут с путевой частью "catalog/" и параметром path , который принимает любую строку, а по в случае отсутствия этой строки - является пустой строкой
Код тестов проверяющих заданное поведение будет выглядеть так (я использую NUnit и NMoq):
[Test] // проверяем поведение при пустом значении path public void CatalogIndexWithEmptyPathTest() { // arrange var p = new Product(){Id = 1, Parent = null }; var p1 = new Product(){Id = 2, Parent = p }; var p2 = new Product(){Id = 3, Parent = p }; var p3 = new Product(){Id = 4, Parent = p }; var pChilds = new[]{p1,p2,p3}; var mockProductService = new Mock<IProductService>(); mockProductService.Setup(x => x.GetByParent(null)).Returns(new[]{p}); mockProductService.Setup(x => x.GetByParent(p)).Returns(pChilds); var catalogController = new CatalogController(mockProductService.Object, null); // act var result = (ViewResult)catalogController.Index(string.Empty); // assert Assert.IsNotNull(result); var exceptedProduct = (Product)result.ViewData["Product"]; Assert.AreEqual(p, exceptedProduct); var exceptedChilds = (IEnumerable<Product>) result.ViewData["Childs"]; Assert.AreEqual(pChilds, exceptedChilds); } [Test] // проверяем поведение при не правильном (не существующем) значении path public void CatalogIndexWithNotExistsPathTest() { // arrange var mockProductService = new Mock<IProductService>(); mockProductService.Setup(x => x.GetByName(It.IsAny<string>())).Returns((Product) null); var catalogController = new CatalogController(mockProductService.Object, null); // act var result = (RedirectToRouteResult)catalogController.Index("not/exists/product"); // assert Assert.IsNotNull(result); Assert.AreEqual("Index", result.RouteValues["action"]); } и наконец самый большой тест проверяющий поведение при правильных данных [Test] public void CatalogIndexWithSomePathTest() { // arrange var p = new Product(){Id = 1, Parent = null, Name = "item" }; var p1 = new Product(){Id = 2, Parent = p, Name = "in" }; var p2 = new Product(){Id = 3, Parent = p1, Name = "catalog" }; var p21 = new Product(){Id = 4, Parent = p2 }; var p22 = new Product(){Id = 5, Parent = p2 }; var p23 = new Product(){Id = 6, Parent = p2 }; var p2Childs = new[]{p21,p22,p23}; var mockProductService = new Mock<IProductService>(); mockProductService.Setup(x => x.GetByName(p.Name)).Returns(p); mockProductService.Setup(x => x.GetByName(p1.Name)).Returns(p1); mockProductService.Setup(x => x.GetByName(p2.Name)).Returns(p2); mockProductService.Setup(x => x.GetByParent(p2)).Returns(p2Childs); // act var result = (ViewResult)catalogController.Index("item/in/catalog"); // assert Assert.IsNotNull(result); var exceptedProduct = (Product) result.ViewData["Product"]; Assert.AreEqual(p2, exceptedProduct); var exceptedChilds = (IEnumerable<Product>) result.ViewData["Childs"]; Assert.AreEqual(p2Childs, exceptedChilds); }
Итак, три теста покрывают три основных сценария работы действия Index контроллера Catalog
1) Пустое значение path
2) Не существующее значение path
3) Существующее значение path
В идеале нужно тестировать еще и входящие и исходящие маршруты .. но об этом в другой раз
Сам код метода Index контроллера Catalog выглядит примерно так:
public class CatalogController : Controller { private readonly IProductService _productService; private readonly IProductTypeService _productTypeService; public CatalogController(IProductService productService, IProductTypeService productTypeService) { _productService = productService; _productTypeService = productTypeService; } public ActionResult Index(string path) { Product product = null; if(!string.IsNullOrWhiteSpace(path)) { var pathParts = path.Split(new[] {'/'}, StringSplitOptions.RemoveEmptyEntries); foreach (var pathPart in pathParts) { var parent = product; product = _productService.GetByName(pathPart); // если продукт с именем pathPart не найден или для найденного продукта parent не является родительским if ((product == null) || (parent != null && parent != product.Parent)) return RedirectToAction("Index", new { path = string.Empty }); // делаем редирект на корневую точку каталога } } if (product == null) product = _productService.GetByParent(null).FirstOrDefault(); // получаем список продуктов для который product является родительским var childs = _productService.GetByParent(product); ViewData["Product"] = product; ViewData["Childs"] = childs; } }
Код представления /Views/Catalog/Index.aspx будет выглядеть примерно так:
<% var product = (Product) ViewData["Product"]; var childs = (IEnumerable<Product>)ViewData["Childs"]; %> <p><%: product.Name %></p> <ul> <% foreach(var child in childs) { %> <li><%: child.Name %></li> <% } %> </ul>
// на самом деле на месте child.Name нужно, с помощью метода Html.ActionLink, генерировать ссылку на дочерний объект
Об этом я напишу в следующем посте.
В целом у нас получилось одно действие Index контроллера Catalog. Это хорошо, потому что это просто и понятно как работает. В моем проекте понадобилось для некоторых продуктов, расположенных близко к корню (с уровнем вложенности 1-2 относительно корня) кастомизировать представление - т.е. отображать не просто иерархию со ссылками, а выводить некоторый дополнительный html-контент в представлении Index. При этом не хотелось в коде представления писать условия вида:
<% if(product.Name == "product123") %>
<%= "Дополнительный конент" %>
<% if(product.Name == "product456") %>
<%= "Другой дополнительный конент" %>
<% else %>
<%= "Представление по умолчанию" %>
Как я избежал такого кода - об этом также в следующем посте, который, собственно и будет являеться целью - т.к. хочется написать именно об этом, а не о простом иерархическом представлении данных.
Спасибо, что дочитали до конца, первый пост получился достаточно длинный, видимо у меня стиль такой.. многословный
Комментариев нет:
Отправить комментарий