#!/usr/bin/env node /** * 模块注册表前端逻辑的最小自检(仓库无单测基建,用 Node + typescript.transpileModule 跑纯函数)。 * * 覆盖 ticket #65 与 spec #58 修复后的可见行为: * 1. 「可新建审批链」下拉只含 enabled=1 的模块,按 sortNumber 升序,标题取注册表 title; * 2. 审核列表的模块筛选用全量清单(含停用模块),停用项标题带「(已停用)」, * 且只有审批链配置下拉用启用清单; * 3. module → detailKey 的映射收在 resolveDetailKey 一处,注册表里没有该模块时回退 module 本身; * 4. 标题解析:注册表命中取 title,未命中回退 module 本身; * 5. 编辑历史配置时把停用的模块补进下拉(带「已停用」标记),不重复添加; * 6. 审批链保存体:新建与编辑两条分支都必须带上每级的 reviewerRule(spec #58 A5)。 * * 运行:npm run selfcheck:module-registry */ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); const ts = require('typescript'); const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const loadTs = (relative) => { const file = resolve(root, relative); const source = readFileSync(file, 'utf8'); const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 }, fileName: file }); const mod = { exports: {} }; new Function('exports', 'require', 'module', outputText)( mod.exports, require, mod ); return mod.exports; }; const readSource = (relative) => readFileSync(resolve(root, relative), 'utf8'); let failed = 0; let groupCount = 0; const assert = (name, condition, detail) => { if (condition) { console.log(' ok ' + name); return; } failed += 1; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')); }; /** 开始一组行为断言,结尾按实际组数统计,避免硬编码组数 */ const group = (name) => { groupCount += 1; console.log(name); }; const core = loadTs('src/utils/module-registry-core.ts'); const payloadBuilder = loadTs( 'src/views/gxmu/reviewFlow/components/reviewFlowPayload.ts' ); // 与后端种子一致:sql/20261110_spec58_approval_chain.sql 里 10 个模块, // 其中 5 个 enabled=1(含稿件 cms_manuscript,它有在跑的审核链)。 const REGISTRY = [ { module: 'gxmu_sjqn', detailKey: 'gxmu_sjqn', title: '十佳青年岗位能手', enabled: 1, sortNumber: 1 }, { module: 'gxmu_sjtbzbsj', detailKey: 'gxmu_sjtbzbsj', title: '十佳团支部书记', enabled: 0, sortNumber: 2 }, { module: 'gxmu_wshqtw', detailKey: 'gxmu_wshqtw', title: '五四红旗团委', enabled: 0, sortNumber: 3 }, { module: 'gxmu_wshqtzb', detailKey: 'gxmu_wshqtzb', title: '五四红旗团支部', enabled: 0, sortNumber: 4 }, { module: 'gxmu_yxgqtdgb', detailKey: 'gxmu_yxgqtdgb', title: '优秀共青团干部', enabled: 0, sortNumber: 5 }, { module: 'gxmu_yxgqty', detailKey: 'gxmu_yxgqty', title: '优秀共青团员', enabled: 0, sortNumber: 6 }, { module: 'gxmu_tzbcy_form', detailKey: 'gxmu_tzbcy_form', title: '挑战杯', enabled: 1, sortNumber: 7 }, { module: 'gxmu_qmgc_form', detailKey: 'gxmu_qmgc_form', title: '青马工程', enabled: 1, sortNumber: 8 }, { module: 'gxmu_wxxzx_project', detailKey: 'gxmu_wxxzx_project', title: '未来学术之星', enabled: 1, sortNumber: 9 }, { module: 'cms_manuscript', detailKey: 'cms_manuscript', title: '稿件', enabled: 1, sortNumber: 10 } ]; group('新建审批链的模块下拉(enabled=1,按 sortNumber)'); const options = core.toModuleOptions(REGISTRY); assert( '只含已启用模块', options.map((o) => o.value).join(',') === 'gxmu_sjqn,gxmu_tzbcy_form,gxmu_qmgc_form,gxmu_wxxzx_project,cms_manuscript', JSON.stringify(options) ); assert('label 取注册表标题', options[0].label === '十佳青年岗位能手'); assert('停用模块不出现', !options.some((o) => o.value === 'gxmu_sjtbzbsj')); group('审核列表的模块筛选(全量清单,停用项带标记)'); const filterOptions = core.toAllModuleOptions(REGISTRY); assert('含停用模块', filterOptions.length === REGISTRY.length, String(filterOptions.length)); assert( '按 sortNumber 升序', filterOptions.map((o) => o.value).join(',') === REGISTRY.map((r) => r.module).join(','), JSON.stringify(filterOptions.map((o) => o.value)) ); const disabledOption = filterOptions.find((o) => o.value === 'gxmu_sjtbzbsj'); assert('停用项标题带「(已停用)」', disabledOption?.label === '十佳团支部书记(已停用)', JSON.stringify(disabledOption)); assert('启用项标题不加标记', filterOptions.find((o) => o.value === 'gxmu_sjqn')?.label === '十佳青年岗位能手'); assert( '停用模块仍能被筛出来(ADR 0006 历史数据仍可查)', ['gxmu_sjtbzbsj', 'gxmu_wshqtw', 'gxmu_wshqtzb', 'gxmu_yxgqtdgb', 'gxmu_yxgqty'].every((m) => filterOptions.some((o) => o.value === m) ) ); const searchSource = readSource('src/views/gxmu/reviewList/components/search.vue'); assert('审核列表筛选入口用 allModuleOptions', /allModuleOptions\(/.test(searchSource)); const configEditSource = readSource( 'src/views/gxmu/reviewFlowConfig/components/reviewFlowConfigEdit.vue' ); assert( '审批链配置下拉仍只用启用清单', /enabledModuleOptions\(/.test(configEditSource) && !/allModuleOptions\(/.test(configEditSource) ); group('module → detailKey 映射(收在一处)'); assert('走注册表 detailKey', core.resolveDetailKey(REGISTRY, 'gxmu_sjqn') === 'gxmu_sjqn'); assert( '注册表缺该模块时回退 module(停用模块的详情仍可解析)', core.resolveDetailKey(REGISTRY, 'legacy_module') === 'legacy_module' ); assert('空模块返回空', core.resolveDetailKey(REGISTRY, undefined) === undefined); group('标题解析(历史配置回显)'); assert('注册表命中', core.resolveModuleTitle(REGISTRY, 'gxmu_qmgc_form') === '青马工程'); assert('注册表未命中回退 module 本身', core.resolveModuleTitle(REGISTRY, 'legacy_module') === 'legacy_module'); group('编辑历史配置:把停用模块补进下拉'); const merged = core.ensureModuleOption(options, 'gxmu_sjtbzbsj', '十佳团支部书记'); assert('补进来的项标记已停用', merged[0]?.label === '十佳团支部书记(已停用)', JSON.stringify(merged[0])); assert('补进来的项在最前面且可回显', merged[0]?.value === 'gxmu_sjtbzbsj'); assert('原有选项保留', merged.length === options.length + 1); const duplicated = core.ensureModuleOption(options, 'gxmu_sjqn'); assert('已在选项里则不重复添加', duplicated.length === options.length); assert( '无 moduleName 时用 module 当标题', core.ensureModuleOption(options, 'legacy_module')[0]?.label === 'legacy_module(已停用)' ); group('审批链保存体:新建与编辑都带 reviewerRule(spec #58 A5)'); const newChainForm = { title: '新链', organizationIds: [1, 2], flowItems: [{ organizationId: 1 }, { organizationId: 2 }], id: undefined }; const created = payloadBuilder.toReviewFlowPayload( newChainForm, { organizationId: 1, reviewerRule: ' COLLEGE_ROLE:YouthLeagueCommitteeOfSecondaryColleges ' }, 1 ); assert( '新建分支带 reviewerRule(已 trim)', created.reviewerRule === 'COLLEGE_ROLE:YouthLeagueCommitteeOfSecondaryColleges', JSON.stringify(created.reviewerRule) ); assert('新建分支带 level', created.level === 1); assert( '提交体不带前端专用字段', !('organizationIds' in created) && !('flowItems' in created) && !('id' in created) ); assert( '未填规则时写 undefined 而不是空串', payloadBuilder.toReviewFlowPayload(newChainForm, { organizationId: 2 }, 2).reviewerRule === undefined ); const edited = payloadBuilder.toReviewFlowPayload( newChainForm, { organizationId: 2, reviewUserId: 7, reviewerRule: 'USER:12' }, 2 ); assert('编辑分支同样带 reviewerRule', edited.reviewerRule === 'USER:12'); assert('编辑分支保留指定人', edited.reviewUserId === 7); const flowEditSource = readSource('src/views/gxmu/reviewFlow/components/reviewFlowEdit.vue'); assert( '新建/编辑两条分支共用同一份构造逻辑(不再逐字段手拼)', (flowEditSource.match(/toReviewFlowPayload\(/g) || []).length === 2 && !/const formData: ReviewFlow = \{/.test(flowEditSource) ); if (failed) { console.error('\n自检失败:' + failed + ' 项'); process.exit(1); } console.log('\n自检通过:' + groupCount + ' 组行为');