task-3(壳层/路由): 定义并锁定 AdminLayout 页面容器职责契约

壳层只做页面容器:唯一 RouterView 挂载业务视图、不静态 import 业务页面、
不直接调后台 API、Element Plus 按需命名导入;页面接线归 router 懒加载。
配套 tests/helpers.ts 提供只读源码分析与 @/ import 可解析校验。
This commit is contained in:
2026-09-05 12:14:17 +08:00
parent 6f0357e41c
commit 7065dbb53e
2 changed files with 130 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
const ROOT = process.cwd()
const SRC = join(ROOT, 'src')
const EXTENSIONS = ['', '.ts', '.tsx', '.vue', '.js', '.json']
const INDEX_CANDIDATES = ['/index.ts', '/index.tsx', '/index.vue', '/index.js']
/** 读取工程内源码文件(相对工程根)。 */
export function readSource(rel: string): string {
return readFileSync(join(ROOT, rel), 'utf8')
}
/** 统计非重叠子串出现次数(幂等、只读)。 */
export function occurrences(text: string, token: string): number {
if (!token) return 0
let count = 0
let index = text.indexOf(token)
while (index !== -1) {
count += 1
index = text.indexOf(token, index + token.length)
}
return count
}
/** 从源码里抓出所有 @/ 别名 import 说明符(含 import type)。 */
export function aliasImports(source: string): string[] {
const specs = new Set<string>()
const re = /(?:import\s+type\s+)?[^'"]*from\s*['"](@\/[^'"]+)['"]/g
let match: RegExpExecArray | null
while ((match = re.exec(source))) specs.add(match[1])
// 处理动态 import('@/...') 与直接裸 @/ 引用
const dyn = /(?:import|import\s*\(\s*)['"](@\/[^'"]+)['"]/g
while ((match = dyn.exec(source))) specs.add(match[1])
return [...specs]
}
/** 把 import 说明符解析为存在的模块文件;解析不到返回 null(含可操作路径)。 */
export function resolveModuleSpec(spec: string, fromRel: string): string | null {
let base: string
if (spec.startsWith('@/')) {
base = join(SRC, spec.slice(2))
} else {
base = resolve(dirname(join(ROOT, fromRel)), spec)
}
for (const ext of EXTENSIONS) {
if (existsSync(base + ext)) return base + ext
}
for (const idx of INDEX_CANDIDATES) {
if (existsSync(base + idx)) return base + idx
}
return null
}
/** 校验一个源文件的所有 @/ import 都能解析;返回缺失清单(空 = 全部可解析)。 */
export function missingImports(rel: string): string[] {
const source = readSource(rel)
const missing: string[] = []
for (const spec of aliasImports(source)) {
if (!resolveModuleSpec(spec, rel)) missing.push(`${rel} 无法解析 import: ${spec}`)
}
return missing
}
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource, occurrences, missingImports } from './helpers.ts'
const LAYOUT = 'src/layout/AdminLayout.vue'
const ROUTER = 'src/router/index.ts'
test('test_task_003_layout_contract_normal_primary_path', () => {
// 正常主路径:壳层作为页面容器,唯一地把业务视图挂到 RouterView。
const layout = readSource(LAYOUT)
assert.equal(occurrences(layout, '<RouterView'), 1, '页面容器只能挂载一个 RouterView')
assert.equal(occurrences(layout, 'admin-content'), 1)
})
test('test_task_003_layout_contract_normal_variant_input', () => {
// 正常变体:页面接线归路由所有;壳层不得静态 import 业务页面。
const layout = readSource(LAYOUT)
const router = readSource(ROUTER)
assert.equal(occurrences(layout, '@/pages/'), 0, 'AdminLayout 不得静态 import 业务页面')
assert.ok(router.includes('component: () => import('), '路由使用异步组件加载页面')
})
test('test_task_003_layout_contract_normal_repeated_operation_is_idempotent', () => {
// 正常重复:只读复查结果稳定,不依赖可变状态。
const layoutA = readSource(LAYOUT)
const layoutB = readSource(LAYOUT)
assert.equal(layoutA.length, layoutB.length)
assert.equal(occurrences(layoutA, 'RouterView'), occurrences(layoutB, 'RouterView'))
})
test('test_task_003_layout_contract_boundary_empty_input', () => {
// 边界空值:菜单为空时应展示无菜单状态,且页面容器本身仍保留。
const layout = readSource(LAYOUT)
assert.ok(layout.includes('暂无可用菜单'), '无菜单需有空状态文案')
assert.ok(layout.includes('!groups.length'), '空菜单由 groups 长度驱动')
assert.equal(occurrences(layout, '<RouterView'), 1)
})
test('test_task_003_layout_contract_boundary_single_item', () => {
// 边界单元素:单一内容区与单一页面挂载点。
const layout = readSource(LAYOUT)
assert.equal(occurrences(layout, 'admin-main'), 1)
assert.equal(occurrences(layout, '<aside'), 1, '侧边栏唯一')
assert.equal(occurrences(layout, '<header'), 1, '顶栏唯一')
})
test('test_task_003_layout_contract_boundary_limit_or_missing_field', () => {
// 边界上限:Element Plus 交互能力按需命名导入,不整包拖入壳层。
const layout = readSource(LAYOUT)
assert.match(layout, /import\s*\{\s*ElMessage\s*\}\s*from\s*['"]element-plus['"]/)
assert.equal(occurrences(layout, 'import ElementPlus'), 0, '壳层不得整包导入 ElementPlus')
})
test('test_task_003_layout_contract_invalid_input_rejected', () => {
// 异常输入:壳层不得自行发起后台数据请求,也不得引用 @/api。
const layout = readSource(LAYOUT)
assert.equal(occurrences(layout, '@/api/'), 0, 'AdminLayout 不得直接调用后台 API')
assert.equal(occurrences(layout, 'http.get'), 0)
})
test('test_task_003_layout_contract_dependency_failure_returns_actionable_message', () => {
// 依赖失败:壳层自身依赖必须全部可解析,缺失时返回可操作清单。
const missing = missingImports(LAYOUT)
assert.deepEqual(missing, [], `存在无法解析的 import: ${missing.join('; ')}`)
const routerMissing = missingImports(ROUTER)
assert.deepEqual(routerMissing, [], `路由存在无法解析的 import: ${routerMissing.join('; ')}`)
})