import assert from 'node:assert/strict' import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' import ts from 'typescript' /** 这些调用最终都会写入 work_order_events,事件类型必须在前端有中文映射。 */ const WORK_ORDER_EVENT_CALLS = new Set([ 'createWorkOrderEvent', 'createWorkOrderEventWithClient', 'parkWorkOrderWaitingFriendAddWithClient', 'startWorkOrderGiftCountdownWithClient', ]) /** * 生产库仍存在、但当前代码已经不再写入的历史事件类型。 * 前端必须继续映射,否则历史工单详情会露英文 key。 */ const PRODUCTION_ONLY_EVENT_TYPES = [ 'auto_publish_succeeded', 'status_repaired', 'problem_cancel_restored', 'consignment_internal_matching_entered', 'consignment_internal_matching_reentered', 'consignment_internal_matching_expired', 'consignment_internal_matching_migrated', ] function collectStringLiterals(expr: ts.Expression, result: Set) { if (ts.isStringLiteralLike(expr)) { result.add(expr.text) return } if (ts.isParenthesizedExpression(expr)) { collectStringLiterals(expr.expression, result) return } if (ts.isConditionalExpression(expr)) { // 只取分支结果,不把条件里的 'queued' / 'failed' 当成事件类型。 collectStringLiterals(expr.whenTrue, result) collectStringLiterals(expr.whenFalse, result) return } if ( ts.isBinaryExpression(expr) && [ ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.PlusToken, ].includes(expr.operatorToken.kind) ) { collectStringLiterals(expr.left, result) collectStringLiterals(expr.right, result) return } if (ts.isTemplateExpression(expr) && expr.templateSpans.length === 0) { result.add(expr.head.text) } } function collectEventTypesFromFile(filePath: string, result: Set) { const source = readFileSync(filePath, 'utf8') const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true) function visit(node: ts.Node) { if (ts.isCallExpression(node)) { const callee = node.expression.getText(sourceFile).split('.').pop() || '' if (WORK_ORDER_EVENT_CALLS.has(callee)) { const objectArg = node.arguments.find((arg): arg is ts.ObjectLiteralExpression => ts.isObjectLiteralExpression(arg), ) if (objectArg) { for (const property of objectArg.properties) { if ( ts.isPropertyAssignment(property) && property.name.getText(sourceFile) === 'eventType' ) { collectStringLiterals(property.initializer, result) } } } } } ts.forEachChild(node, visit) } visit(sourceFile) } function walkBackendSources(dir: string, result: Set) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const filePath = join(dir, entry.name) if (entry.isDirectory()) { walkBackendSources(filePath, result) continue } if (!entry.isFile() || !filePath.endsWith('.ts') || filePath.endsWith('.test.ts')) continue collectEventTypesFromFile(filePath, result) } } test('工单事件时间线:后端写入的每个 eventType 都必须有中文映射', () => { const backendRoot = fileURLToPath(new URL('../../backend/src', import.meta.url)) const timelinePath = fileURLToPath( new URL('../src/components/WorkOrderEventTimeline.tsx', import.meta.url), ) const eventTypes = new Set() for (const type of PRODUCTION_ONLY_EVENT_TYPES) eventTypes.add(type) walkBackendSources(backendRoot, eventTypes) const timelineSource = readFileSync(timelinePath, 'utf8') const mappedTypes = new Set( [...timelineSource.matchAll(/case '([a-z_]+)'/g)].map((match) => match[1]!), ) const missing = [...eventTypes].filter((type) => !mappedTypes.has(type)).sort() assert.deepEqual( missing, [], `以下工单事件缺少中文映射,会在流转记录里直接显示英文 key:${missing.join(', ')}`, ) }) test('工单事件时间线:截图中的内部寄售与凭证事件必须命中中文映射', () => { const timelineSource = readFileSync( fileURLToPath(new URL('../src/components/WorkOrderEventTimeline.tsx', import.meta.url)), 'utf8', ) for (const eventType of [ 'consignment_gift_countdown_started', 'publish_voucher_consume_schedule_skipped', 'consignment_manual_matched', ]) { assert.ok(timelineSource.includes(`case '${eventType}'`), `${eventType} 必须有中文映射`) } })