From 5603aea680ec7dbc1a5454ed9d12c9631b317c41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Tue, 7 Jul 2026 14:14:13 +0800 Subject: [PATCH] feat: support module diagram outline layouts --- .../easycode-web/src/utils/appModuleGraph.js | 514 +++++++++++++----- .../src/utils/appModuleGraph.test.mjs | 149 ++++- 2 files changed, 504 insertions(+), 159 deletions(-) diff --git a/RuoYi-Vue/easycode-web/src/utils/appModuleGraph.js b/RuoYi-Vue/easycode-web/src/utils/appModuleGraph.js index d9f60b4..1d01f68 100644 --- a/RuoYi-Vue/easycode-web/src/utils/appModuleGraph.js +++ b/RuoYi-Vue/easycode-web/src/utils/appModuleGraph.js @@ -1,20 +1,71 @@ -const SOURCE_X = 32 -const FRONTEND_X = 360 -const ADMIN_X = 688 -const START_Y = 72 -const NODE_WIDTH = 252 -const NODE_HEIGHT = 108 -const NODE_GAP_Y = 24 +import { blueprintToModuleOutline, normalizeModuleOutline } from './appModuleOutline.js' + +const CANVAS_MIN_WIDTH = 640 +const DEFAULT_ROOT_TITLE = '系统模块设计' +const CAPTION = '系统功能结构图' + +const DENSITY_CONFIGS = { + compact: { + canvasHeight: 390, + rootWidth: 230, + rootHeight: 40, + groupWidth: 112, + groupHeight: 38, + moduleWidth: 44, + moduleHeight: 124, + moduleGap: 10, + groupGap: 72, + rootY: 16, + rootBranchY: 78, + groupY: 98, + moduleBranchY: 160, + moduleY: 180 + }, + standard: { + canvasHeight: 420, + rootWidth: 260, + rootHeight: 42, + groupWidth: 128, + groupHeight: 40, + moduleWidth: 48, + moduleHeight: 138, + moduleGap: 14, + groupGap: 96, + rootY: 18, + rootBranchY: 88, + groupY: 110, + moduleBranchY: 178, + moduleY: 198 + }, + relaxed: { + canvasHeight: 460, + rootWidth: 280, + rootHeight: 46, + groupWidth: 140, + groupHeight: 44, + moduleWidth: 54, + moduleHeight: 154, + moduleGap: 18, + groupGap: 124, + rootY: 20, + rootBranchY: 100, + groupY: 126, + moduleBranchY: 202, + moduleY: 224 + } +} + +const FONT_SIZE_CONFIGS = { + small: { root: 16, group: 15, module: 15, caption: 13 }, + standard: { root: 17, group: 16, module: 16, caption: 14 }, + large: { root: 19, group: 18, module: 18, caption: 15 } +} function cleanText(value, fallback = '') { const text = String(value ?? '').trim() return text || fallback } -function flagOn(value) { - return value === true || value === 1 || value === '1' || value === 'true' -} - function normalizeKey(value, fallback) { const text = cleanText(value, fallback) .toLowerCase() @@ -36,165 +87,342 @@ function uniqueKey(value, fallbackPrefix, index, used) { return candidate } -function normalizeRoles(roles) { - const used = new Set() - return (Array.isArray(roles) ? roles : []).filter(Boolean).map((role, index) => { - const code = uniqueKey(role.code, 'role', index, used) - return { - code, - id: `role:${code}`, - title: cleanText(role.name, code), - subtitle: cleanText(role.description, '角色'), - kind: 'role', - lane: 'role', - badges: ['角色'] - } - }) +function normalizedOptions(options = {}) { + const density = DENSITY_CONFIGS[options.density] ? options.density : 'standard' + const direction = options.direction === 'vertical' ? 'vertical' : 'horizontal' + const fontSize = FONT_SIZE_CONFIGS[options.fontSize] ? options.fontSize : 'standard' + return { density, direction, fontSize } } -function normalizeMenus(menus, lane, fallbackPrefix) { - const used = new Set() - return (Array.isArray(menus) ? menus : []).filter(Boolean).map((menu, index) => { - const code = uniqueKey(menu.code, fallbackPrefix, index, used) - const requiresLogin = flagOn(menu.requiresLogin) - const dataScope = cleanText(menu.dataScope, requiresLogin ? 'CURRENT_USER' : 'PUBLIC').toUpperCase() - const visibleRoles = Array.isArray(menu.visibleRoles) - ? menu.visibleRoles.map((role) => normalizeKey(role, '')).filter(Boolean) - : [] +function layoutConfig(options) { + return DENSITY_CONFIGS[options.density] +} +function fontConfig(options) { + return FONT_SIZE_CONFIGS[options.fontSize] +} + +function sourceToOutline(source, options = {}) { + const input = source && typeof source === 'object' ? source : {} + if (Array.isArray(input.groups)) { + return normalizeModuleOutline(input) + } + return blueprintToModuleOutline(input, options) +} + +function menuTitle(menu, fallback) { + return cleanText(menu?.name ?? menu?.title ?? menu?.code, fallback) +} + +function normalizeBlueprintMenus(menus, lane, fallbackPrefix) { + const used = new Set() + return (Array.isArray(menus) ? menus : []).filter((menu) => { + return menu && typeof menu === 'object' && cleanText(menu.name ?? menu.title ?? menu.code) + }).map((menu, index) => { + const code = uniqueKey(menu.code ?? menu.name ?? menu.title, fallbackPrefix, index, used) return { code, id: `${lane}:${code}`, - title: cleanText(menu.name, code), - subtitle: cleanText(menu.path, '/'), - kind: 'menu', + title: menuTitle(menu, code), + kind: 'module', lane, - requiresLogin, - visibleRoles, - dataScope, - badges: [ - lane === 'frontend' ? '前台' : '后台', - requiresLogin ? '需登录' : '公开', - dataScope - ] + vertical: true } }) } -function shouldUsePublic(menu) { - return !menu.requiresLogin || menu.dataScope === 'PUBLIC' -} - -function needsPublicAccess(menus) { - return menus.some(shouldUsePublic) -} - -function needsLoggedInAccess(menus, roleIds) { - return menus.some((menu) => { - if (!menu.requiresLogin) return false - return !menu.visibleRoles.some((roleCode) => roleIds.has(`role:${roleCode}`)) - }) -} - -function buildAccessNodes(menus, roleIds) { - const nodes = [] - if (needsPublicAccess(menus)) { - nodes.push({ - code: 'public', - id: 'access:public', - title: '公开访问', - subtitle: '无需登录', - kind: 'access', - lane: 'access', - badges: ['PUBLIC'] - }) - } - if (needsLoggedInAccess(menus, roleIds)) { - nodes.push({ - code: 'logged_in', - id: 'access:logged_in', - title: '登录用户', - subtitle: '未限定角色', - kind: 'access', - lane: 'access', - badges: ['LOGIN'] - }) - } - return nodes -} - -function applyLayout(nodes) { - const laneIndexes = new Map() - return nodes.map((node) => { - const layoutLane = node.lane === 'admin' ? 'admin' : node.lane === 'frontend' ? 'frontend' : 'source' - const index = laneIndexes.get(layoutLane) || 0 - laneIndexes.set(layoutLane, index + 1) - const x = layoutLane === 'admin' ? ADMIN_X : layoutLane === 'frontend' ? FRONTEND_X : SOURCE_X - +function normalizeOutlineItems(items, lane, fallbackPrefix) { + return (Array.isArray(items) ? items : []).filter(Boolean).map((item, index) => { + const code = `${fallbackPrefix}_${index + 1}` return { - ...node, - x, - y: START_Y + index * (NODE_HEIGHT + NODE_GAP_Y), - width: NODE_WIDTH, - height: NODE_HEIGHT + code, + id: `${lane}:${code}`, + title: cleanText(item.title ?? item.name ?? item.code, code), + kind: 'module', + lane, + vertical: true } }) } -function edgeLabel(menu) { - return menu.dataScope || (menu.requiresLogin ? 'LOGIN' : 'PUBLIC') +function groupWidth(itemCount, config) { + const moduleWidth = itemCount * config.moduleWidth + Math.max(itemCount - 1, 0) * config.moduleGap + return Math.max(config.groupWidth, moduleWidth) } -function edgeId(source, target) { - return `${source}->${target}` +function centerX(node) { + return node.x + node.width / 2 } -function buildEdges(menus, roleIds) { - const edges = [] - menus.forEach((menu) => { - const validRoleIds = menu.visibleRoles - .map((roleCode) => `role:${roleCode}`) - .filter((roleId) => roleIds.has(roleId)) +function bottomY(node) { + return node.y + node.height +} - if (validRoleIds.length) { - validRoleIds.forEach((roleId) => { - edges.push({ - id: edgeId(roleId, menu.id), - source: roleId, - target: menu.id, - label: edgeLabel(menu) - }) - }) - return - } +function treePath(source, target, branchY) { + const sourceX = centerX(source) + const targetX = centerX(target) + const sourceY = bottomY(source) + const targetY = target.y - const source = shouldUsePublic(menu) ? 'access:public' : 'access:logged_in' - edges.push({ - id: edgeId(source, menu.id), - source, - target: menu.id, - label: edgeLabel(menu) + if (sourceX === targetX) { + return `M ${sourceX} ${sourceY} V ${targetY}` + } + return `M ${sourceX} ${sourceY} V ${branchY} H ${targetX} V ${targetY}` +} + +function horizontalTreePath(source, target) { + const sourceX = source.x + source.width + const sourceY = source.y + source.height / 2 + const targetX = target.x + const targetY = target.y + target.height / 2 + const branchX = sourceX + (targetX - sourceX) / 2 + return `M ${sourceX} ${sourceY} H ${branchX} V ${targetY} H ${targetX}` +} + +function buildSections(outline, source = {}) { + const input = source && typeof source === 'object' ? source : {} + if (!Array.isArray(input.groups)) { + return [ + { + key: 'admin', + title: '管理员端', + lane: 'admin', + items: normalizeBlueprintMenus(input.adminMenus, 'admin', 'admin_menu') + }, + { + key: 'frontend', + title: '普通用户端', + lane: 'frontend', + items: normalizeBlueprintMenus(input.frontendMenus, 'frontend', 'frontend_menu') + } + ].filter((section) => section.items.length) + } + + return (Array.isArray(outline.groups) ? outline.groups : []) + .map((group, index) => { + const lane = `group_${index + 1}` + return { + key: lane, + title: cleanText(group.title, `模块${index + 1}`), + lane, + items: normalizeOutlineItems(group.items, lane, 'module') + } }) - }) - return edges + .filter((section) => section.items.length) } -export function buildAppModuleGraph(blueprint = {}) { - const roles = normalizeRoles(blueprint.roles) - const frontendMenus = normalizeMenus(blueprint.frontendMenus, 'frontend', 'frontend_menu') - const adminMenus = normalizeMenus(blueprint.adminMenus, 'admin', 'admin_menu') - const menus = [...frontendMenus, ...adminMenus] - const roleIds = new Set(roles.map((role) => role.id)) - const accessNodes = buildAccessNodes(menus, roleIds) - const nodes = applyLayout([...accessNodes, ...roles, ...frontendMenus, ...adminMenus]) +function buildHorizontalGraph(rootTitle, sections, graphOptions, config, fonts) { + const sectionWidths = sections.map((section) => groupWidth(section.items.length, config)) + const sectionsWidth = sectionWidths.reduce((sum, width) => sum + width, 0) + config.groupGap * Math.max(sections.length - 1, 0) + const canvasWidth = Math.max(CANVAS_MIN_WIDTH, sectionsWidth + 96 + config.groupGap) + const root = { + id: 'root', + title: rootTitle, + kind: 'root', + lane: 'root', + x: (canvasWidth - config.rootWidth) / 2, + y: config.rootY, + width: config.rootWidth, + height: config.rootHeight, + fontSize: fonts.root + } + + const nodes = [root] + const edges = [] + const groups = [] + let sectionX = (canvasWidth - sectionsWidth) / 2 + + sections.forEach((section, sectionIndex) => { + const width = sectionWidths[sectionIndex] + const groupNode = { + id: `group:${section.key}`, + title: section.title, + kind: 'group', + lane: section.lane, + x: sectionX + (width - config.groupWidth) / 2, + y: config.groupY, + width: config.groupWidth, + height: config.groupHeight, + fontSize: fonts.group + } + const moduleRowWidth = section.items.length * config.moduleWidth + Math.max(section.items.length - 1, 0) * config.moduleGap + const moduleStartX = sectionX + (width - moduleRowWidth) / 2 + const childIds = section.items.map((item) => item.id) + + groups.push({ + key: section.key, + title: section.title, + nodeId: groupNode.id, + childIds, + x: sectionX, + y: config.groupY, + width, + height: config.moduleY + config.moduleHeight - config.groupY + }) + nodes.push(groupNode) + edges.push({ + id: `root->${groupNode.id}`, + source: root.id, + target: groupNode.id, + path: treePath(root, groupNode, config.rootBranchY) + }) + + section.items.forEach((item, itemIndex) => { + const moduleNode = { + ...item, + x: moduleStartX + itemIndex * (config.moduleWidth + config.moduleGap), + y: config.moduleY, + width: config.moduleWidth, + height: config.moduleHeight, + fontSize: fonts.module + } + nodes.push(moduleNode) + edges.push({ + id: `${groupNode.id}->${moduleNode.id}`, + source: groupNode.id, + target: moduleNode.id, + path: treePath(groupNode, moduleNode, config.moduleBranchY) + }) + }) + + sectionX += width + config.groupGap + }) return { + title: root.title, + caption: CAPTION, nodes, - edges: buildEdges(menus, roleIds), - lanes: [ - { key: 'source', title: '访问角色', x: SOURCE_X, width: NODE_WIDTH }, - { key: 'frontend', title: '前台功能模块', x: FRONTEND_X, width: NODE_WIDTH }, - { key: 'admin', title: '后台功能模块', x: ADMIN_X, width: NODE_WIDTH } - ] + edges, + groups, + options: graphOptions, + canvas: { + width: canvasWidth, + height: config.canvasHeight + } } } + +function buildVerticalGraph(rootTitle, sections, graphOptions, config, fonts) { + const groupBlockHeight = Math.max(config.groupHeight, config.moduleHeight / 2) + const groupGap = config.groupGap / 2 + const moduleGap = config.moduleGap + const leftX = 48 + const groupX = leftX + config.rootWidth + 80 + const moduleX = groupX + config.groupWidth + 80 + const startY = 36 + const root = { + id: 'root', + title: rootTitle, + kind: 'root', + lane: 'root', + x: leftX, + y: startY, + width: config.rootWidth, + height: config.rootHeight, + fontSize: fonts.root + } + + const nodes = [root] + const edges = [] + const groups = [] + let y = startY + + sections.forEach((section) => { + const moduleColumnHeight = section.items.length * config.groupHeight + Math.max(section.items.length - 1, 0) * moduleGap + const blockHeight = Math.max(groupBlockHeight, moduleColumnHeight) + const groupNode = { + id: `group:${section.key}`, + title: section.title, + kind: 'group', + lane: section.lane, + x: groupX, + y, + width: config.groupWidth, + height: config.groupHeight, + fontSize: fonts.group + } + nodes.push(groupNode) + edges.push({ + id: `root->${groupNode.id}`, + source: root.id, + target: groupNode.id, + path: horizontalTreePath(root, groupNode) + }) + + const childIds = [] + section.items.forEach((item, itemIndex) => { + const moduleNode = { + ...item, + x: moduleX, + y: y + itemIndex * (config.groupHeight + moduleGap), + width: config.groupWidth, + height: config.groupHeight, + vertical: false, + fontSize: fonts.module + } + childIds.push(moduleNode.id) + nodes.push(moduleNode) + edges.push({ + id: `${groupNode.id}->${moduleNode.id}`, + source: groupNode.id, + target: moduleNode.id, + path: horizontalTreePath(groupNode, moduleNode) + }) + }) + + groups.push({ + key: section.key, + title: section.title, + nodeId: groupNode.id, + childIds, + x: groupX, + y, + width: moduleX + config.groupWidth - groupX, + height: blockHeight + }) + y += blockHeight + groupGap + }) + + return { + title: root.title, + caption: CAPTION, + nodes, + edges, + groups, + options: graphOptions, + canvas: { + width: moduleX + config.groupWidth + 80, + height: Math.max(config.canvasHeight + 80, y + 48) + } + } +} + +export function buildAppModuleGraph(source = {}, options = {}) { + const graphOptions = normalizedOptions(options) + const config = layoutConfig(graphOptions) + const fonts = fontConfig(graphOptions) + const outline = sourceToOutline(source, options) + const rootTitle = cleanText(outline.title, DEFAULT_ROOT_TITLE) + const sections = buildSections(outline, source) + + if (!sections.length) { + return { + title: rootTitle, + caption: CAPTION, + nodes: [], + edges: [], + groups: [], + options: graphOptions, + canvas: { + width: CANVAS_MIN_WIDTH, + height: config.canvasHeight + } + } + } + + if (graphOptions.direction === 'vertical') { + return buildVerticalGraph(rootTitle, sections, graphOptions, config, fonts) + } + + return buildHorizontalGraph(rootTitle, sections, graphOptions, config, fonts) +} diff --git a/RuoYi-Vue/easycode-web/src/utils/appModuleGraph.test.mjs b/RuoYi-Vue/easycode-web/src/utils/appModuleGraph.test.mjs index 50d9c7a..79a51fd 100644 --- a/RuoYi-Vue/easycode-web/src/utils/appModuleGraph.test.mjs +++ b/RuoYi-Vue/easycode-web/src/utils/appModuleGraph.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { buildAppModuleGraph } from './appModuleGraph.js' -test('buildAppModuleGraph creates role and menu module nodes with access edges', () => { +test('buildAppModuleGraph creates a thesis-style module hierarchy from menus', () => { const graph = buildAppModuleGraph({ roles: [ { code: 'reader', name: '读者', description: '前台用户' }, @@ -38,29 +38,40 @@ test('buildAppModuleGraph creates role and menu module nodes with access edges', ] }) + assert.equal(graph.title, '系统模块设计') assert.deepEqual( - graph.nodes.map((node) => ({ id: node.id, title: node.title, kind: node.kind, lane: node.lane })), + graph.groups.map((group) => ({ key: group.key, title: group.title, childIds: group.childIds })), [ - { id: 'access:public', title: '公开访问', kind: 'access', lane: 'access' }, - { id: 'role:reader', title: '读者', kind: 'role', lane: 'role' }, - { id: 'role:admin', title: '管理员', kind: 'role', lane: 'role' }, - { id: 'frontend:catalog', title: '图书目录', kind: 'menu', lane: 'frontend' }, - { id: 'frontend:my_borrows', title: '我的借阅', kind: 'menu', lane: 'frontend' }, - { id: 'admin:book_admin', title: '图书管理', kind: 'menu', lane: 'admin' } + { key: 'admin', title: '管理员端', childIds: ['admin:book_admin'] }, + { key: 'frontend', title: '普通用户端', childIds: ['frontend:catalog', 'frontend:my_borrows'] } ] ) assert.deepEqual( - graph.edges.map((edge) => ({ source: edge.source, target: edge.target, label: edge.label })), + graph.nodes.map((node) => ({ id: node.id, title: node.title, kind: node.kind, lane: node.lane })), [ - { source: 'access:public', target: 'frontend:catalog', label: 'PUBLIC' }, - { source: 'role:reader', target: 'frontend:my_borrows', label: 'CURRENT_USER' }, - { source: 'role:admin', target: 'admin:book_admin', label: 'ROLE' } + { id: 'root', title: '系统模块设计', kind: 'root', lane: 'root' }, + { id: 'group:admin', title: '管理员端', kind: 'group', lane: 'admin' }, + { id: 'admin:book_admin', title: '图书管理', kind: 'module', lane: 'admin' }, + { id: 'group:frontend', title: '普通用户端', kind: 'group', lane: 'frontend' }, + { id: 'frontend:catalog', title: '图书目录', kind: 'module', lane: 'frontend' }, + { id: 'frontend:my_borrows', title: '我的借阅', kind: 'module', lane: 'frontend' } + ] + ) + + assert.deepEqual( + graph.edges.map((edge) => ({ source: edge.source, target: edge.target })), + [ + { source: 'root', target: 'group:admin' }, + { source: 'group:admin', target: 'admin:book_admin' }, + { source: 'root', target: 'group:frontend' }, + { source: 'group:frontend', target: 'frontend:catalog' }, + { source: 'group:frontend', target: 'frontend:my_borrows' } ] ) }) -test('buildAppModuleGraph creates a logged-in access node for menus without explicit visible roles', () => { +test('buildAppModuleGraph keeps frontend-only projects centered and exportable', () => { const graph = buildAppModuleGraph({ roles: [], frontendMenus: [ @@ -76,7 +87,113 @@ test('buildAppModuleGraph creates a logged-in access node for menus without expl adminMenus: [] }) - assert.equal(graph.nodes[0].id, 'access:logged_in') - assert.equal(graph.edges[0].source, 'access:logged_in') - assert.equal(graph.edges[0].target, 'frontend:profile') + assert.deepEqual(graph.groups.map((group) => group.key), ['frontend']) + assert.equal(graph.nodes[0].id, 'root') + assert.equal(graph.nodes[1].id, 'group:frontend') + assert.equal(graph.nodes[2].id, 'frontend:profile') + assert.equal(graph.nodes[2].vertical, true) + assert.equal(graph.edges[0].source, 'root') + assert.equal(graph.edges[0].target, 'group:frontend') + assert.equal(graph.edges[1].source, 'group:frontend') + assert.equal(graph.edges[1].target, 'frontend:profile') +}) + +test('buildAppModuleGraph uses the project name as the root title', () => { + const graph = buildAppModuleGraph({ + frontendMenus: [ + { + code: 'catalog', + name: '图书目录' + } + ], + adminMenus: [] + }, { + projectName: '图书借阅系统' + }) + + assert.equal(graph.title, '图书借阅系统') + assert.equal(graph.nodes[0].title, '图书借阅系统') +}) + +test('buildAppModuleGraph skips invalid legacy menus while preserving code-only menus', () => { + const graph = buildAppModuleGraph({ + adminMenus: [ + null, + {}, + { name: ' ' }, + { code: 'settings' } + ], + frontendMenus: [] + }) + + assert.deepEqual(graph.groups.map((group) => group.childIds), [['admin:settings']]) + assert.equal(graph.nodes.find((node) => node.id === 'admin:settings').title, 'settings') +}) + +test('buildAppModuleGraph accepts a normalized outline source', () => { + const graph = buildAppModuleGraph({ + title: '学生社团管理系统', + groups: [ + { title: '管理员端', items: [{ title: '用户管理' }, { title: '社团管理' }] }, + { title: '普通用户端', items: [{ title: '社团浏览' }] } + ] + }) + + assert.equal(graph.title, '学生社团管理系统') + assert.deepEqual( + graph.groups.map((group) => ({ key: group.key, title: group.title, childIds: group.childIds })), + [ + { key: 'group_1', title: '管理员端', childIds: ['group_1:module_1', 'group_1:module_2'] }, + { key: 'group_2', title: '普通用户端', childIds: ['group_2:module_1'] } + ] + ) + assert.equal(graph.nodes.find((node) => node.id === 'group_1:module_1').title, '用户管理') +}) + +test('buildAppModuleGraph density options change spacing predictably', () => { + const outline = { + title: '学生社团管理系统', + groups: [ + { title: '管理员端', items: [{ title: '用户管理' }, { title: '社团管理' }, { title: '活动管理' }] }, + { title: '普通用户端', items: [{ title: '社团浏览' }, { title: '活动报名' }, { title: '个人中心' }] } + ] + } + + const compact = buildAppModuleGraph(outline, { density: 'compact' }) + const relaxed = buildAppModuleGraph(outline, { density: 'relaxed' }) + + assert.equal(compact.options.density, 'compact') + assert.equal(relaxed.options.density, 'relaxed') + assert.ok(relaxed.canvas.width > compact.canvas.width) + assert.ok(relaxed.nodes.find((node) => node.kind === 'module').height > compact.nodes.find((node) => node.kind === 'module').height) +}) + +test('buildAppModuleGraph font size option is stored on graph and nodes', () => { + const graph = buildAppModuleGraph({ + title: '学生社团管理系统', + groups: [{ title: '管理员端', items: [{ title: '用户管理' }] }] + }, { + fontSize: 'large' + }) + + assert.equal(graph.options.fontSize, 'large') + assert.equal(graph.nodes.find((node) => node.kind === 'module').fontSize, 18) + assert.equal(graph.nodes.find((node) => node.kind === 'root').fontSize, 19) +}) + +test('buildAppModuleGraph vertical direction produces a valid graph', () => { + const graph = buildAppModuleGraph({ + title: '学生社团管理系统', + groups: [ + { title: '管理员端', items: [{ title: '用户管理' }] }, + { title: '普通用户端', items: [{ title: '社团浏览' }] } + ] + }, { + direction: 'vertical' + }) + + assert.equal(graph.options.direction, 'vertical') + assert.equal(graph.nodes[0].id, 'root') + assert.equal(graph.edges.length, 4) + assert.ok(graph.canvas.height > 420) })