task-141(Java 菜单树与 Flyway): 冻结 Java 菜单树响应 DTO 契约
新增纯 Java AdminMenuTreeBuilder:Node(key/name/route/children) 树组装与 route 规整,column_key 与 route 分离,可离线单测。 TDD: Task141Test 8 用例先 RED 后 GREEN。
This commit is contained in:
+112
@@ -0,0 +1,112 @@
|
||||
package com.nanri.aiimage.modules.permission.service.support;
|
||||
|
||||
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 后台菜单树纯组装器(module 08):column_key→key、route_path→route、parent 树、排序等均为纯逻辑,
|
||||
* 便于离线单测;树形结果不含 sort 冗余字段。不与当前请求/UA/查询参数联动。
|
||||
*/
|
||||
public final class AdminMenuTreeBuilder {
|
||||
|
||||
/** 冻结的树形节点 DTO:key=column_key、route=Vue path、children 有序。 */
|
||||
public record Node(Long id, String key, String name, Integer sort, String route, List<Node> children) {
|
||||
public Node {
|
||||
children = children == null ? List.of() : List.copyOf(children);
|
||||
}
|
||||
}
|
||||
|
||||
private AdminMenuTreeBuilder() {
|
||||
}
|
||||
|
||||
/** 由有效菜单集合组装树(仅 menu_type=admin)。空/无效输入回空列表。 */
|
||||
public static List<Node> build(List<PermissionMenuItemVo> menus) {
|
||||
if (menus == null || menus.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<Long, Node> nodes = new LinkedHashMap<>();
|
||||
for (PermissionMenuItemVo menu : menus) {
|
||||
Node node = toNode(menu);
|
||||
if (node != null) {
|
||||
nodes.put(node.id(), node);
|
||||
}
|
||||
}
|
||||
List<Node> roots = new ArrayList<>();
|
||||
for (PermissionMenuItemVo menu : menus) {
|
||||
Node node = nodes.get(menu.getId());
|
||||
if (node == null) {
|
||||
continue;
|
||||
}
|
||||
if (menu.getParentId() != null && nodes.containsKey(menu.getParentId())) {
|
||||
Node parent = nodes.get(menu.getParentId());
|
||||
Node child = node;
|
||||
List<Node> children = new ArrayList<>(parent.children());
|
||||
children.add(child);
|
||||
nodes.put(parent.id(), new Node(parent.id(), parent.key(), parent.name(), parent.sort(), parent.route(), children));
|
||||
} else {
|
||||
roots.add(node);
|
||||
}
|
||||
}
|
||||
Comparator<Node> order = Comparator
|
||||
.comparingInt((Node n) -> n.sort() == null ? 0 : n.sort())
|
||||
.thenComparing(Node::key);
|
||||
return sortTree(roots, order);
|
||||
}
|
||||
|
||||
private static List<Node> sortTree(List<Node> roots, Comparator<Node> order) {
|
||||
List<Node> sorted = new ArrayList<>(roots);
|
||||
sorted.sort(order);
|
||||
List<Node> result = new ArrayList<>(sorted.size());
|
||||
for (Node node : sorted) {
|
||||
Node withChildren = node.children().isEmpty()
|
||||
? node
|
||||
: new Node(node.id(), node.key(), node.name(), node.sort(), node.route(), sortTree(node.children(), order));
|
||||
result.add(withChildren);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 单节点映射;缺 id 返回 null;分组/页面节点 route 由 stored route 规整。 */
|
||||
public static Node toNode(PermissionMenuItemVo menu) {
|
||||
if (menu == null || menu.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
String key = menu.getColumnKey() == null ? String.valueOf(menu.getId()) : menu.getColumnKey();
|
||||
String name = menu.getName() == null || menu.getName().isBlank() ? "未命名菜单" : menu.getName();
|
||||
Integer sort = menu.getSortOrder() == null ? 0 : menu.getSortOrder();
|
||||
String route = normalizeRoute(menu.getRoutePath());
|
||||
return new Node(menu.getId(), key, name, sort, route, new ArrayList<>());
|
||||
}
|
||||
|
||||
/** route 规整:去空白、非 "/" 前缀补 "/",分组占位(group-*)保留原值不当作页面。 */
|
||||
public static String normalizeRoute(String storedRoute) {
|
||||
if (storedRoute == null) {
|
||||
return null;
|
||||
}
|
||||
String route = storedRoute.trim();
|
||||
if (route.isEmpty() || route.startsWith("group-")) {
|
||||
return route.isEmpty() ? null : route;
|
||||
}
|
||||
return route.startsWith("/") ? route : "/" + route;
|
||||
}
|
||||
|
||||
/** 只保留指定菜单域(menu_type=admin)的菜单项;null/空回空列表。 */
|
||||
public static List<PermissionMenuItemVo> filterByMenuType(List<PermissionMenuItemVo> menus, String menuType) {
|
||||
if (menus == null || menuType == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<PermissionMenuItemVo> out = new ArrayList<>();
|
||||
for (PermissionMenuItemVo menu : menus) {
|
||||
if (menu != null && menuType.equals(menu.getMenuType())) {
|
||||
out.add(menu);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.nanri.aiimage.modules.permission.service.support;
|
||||
|
||||
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** task-141:冻结 Java 菜单树响应 DTO 契约(key/name/route/children)。 */
|
||||
class Task141Test {
|
||||
|
||||
private PermissionMenuItemVo menu(Long id, Long parentId, String key, String name,
|
||||
Integer sort, String route, String menuType) {
|
||||
PermissionMenuItemVo vo = new PermissionMenuItemVo();
|
||||
vo.setId(id);
|
||||
vo.setParentId(parentId);
|
||||
vo.setColumnKey(key);
|
||||
vo.setName(name);
|
||||
vo.setSortOrder(sort);
|
||||
vo.setRoutePath(route);
|
||||
vo.setMenuType(menuType);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_141_freeze_java_dto_normal_primary_path() {
|
||||
List<AdminMenuTreeBuilder.Node> tree = AdminMenuTreeBuilder.build(List.of(
|
||||
menu(1L, null, "admin_users", "账号管理", 1, "account/users", "admin"),
|
||||
menu(2L, null, "admin_columns", "菜单管理", 2, "account/menus", "admin")));
|
||||
assertEquals(2, tree.size());
|
||||
assertEquals("admin_users", tree.get(0).key());
|
||||
assertEquals("/account/users", tree.get(0).route());
|
||||
assertEquals("账号管理", tree.get(0).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_141_freeze_java_dto_normal_variant_input() {
|
||||
List<AdminMenuTreeBuilder.Node> tree = AdminMenuTreeBuilder.build(List.of(
|
||||
menu(10L, 1L, "child", "子页", 1, "child/page", "admin"),
|
||||
menu(1L, null, "parent", "父级", 0, null, "admin")));
|
||||
assertEquals(1, tree.size());
|
||||
assertEquals(1, tree.get(0).children().size());
|
||||
assertEquals("child", tree.get(0).children().get(0).key());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_141_freeze_java_dto_normal_repeated_is_idempotent() {
|
||||
List<PermissionMenuItemVo> menus = List.of(menu(1L, null, "a", "A", 0, "a/p", "admin"));
|
||||
assertEquals(AdminMenuTreeBuilder.build(menus), AdminMenuTreeBuilder.build(menus));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_141_freeze_java_dto_boundary_empty_input() {
|
||||
assertTrue(AdminMenuTreeBuilder.build(null).isEmpty());
|
||||
assertTrue(AdminMenuTreeBuilder.build(List.of()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_141_freeze_java_dto_boundary_single_item() {
|
||||
List<AdminMenuTreeBuilder.Node> tree = AdminMenuTreeBuilder.build(
|
||||
List.of(menu(1L, null, "only", "单页", 0, "page/x", "admin")));
|
||||
assertEquals(1, tree.size());
|
||||
assertEquals("/page/x", tree.get(0).route());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_141_freeze_java_dto_boundary_limit_or_missing_field() {
|
||||
assertTrue(AdminMenuTreeBuilder.build(List.of(menu(null, null, "no-id", "X", 0, "p", "admin"))).isEmpty());
|
||||
assertTrue(AdminMenuTreeBuilder.build(List.of(menu(null, null, null, "X", 0, "p", "admin"))).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_141_freeze_java_dto_invalid_input_rejected() {
|
||||
// 分组占位 route 不作为页面路由
|
||||
AdminMenuTreeBuilder.Node node = AdminMenuTreeBuilder.toNode(menu(5L, null, "g", "分组", 0, "group-1", "admin"));
|
||||
assertNotNull(node);
|
||||
assertEquals("group-1", node.route());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_141_freeze_java_dto_dependency_failure_returns_actionable_message() {
|
||||
// 纯逻辑:不引用 http/当前会话;key/route 分离(column_key 保持,route 独立)。
|
||||
assertEquals("/account/users", AdminMenuTreeBuilder.normalizeRoute("account/users"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user