Files
RiskAgent/src/report/__tests__/report.property36.test.ts
T
freedakgmail c670b9e454 外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
2026-06-13 01:06:39 +08:00

301 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Property 36: 报告章节完备 的属性化测试(Report_GeneratorReq 10.1)。
*
* 属性陈述:对任意风险评分、红线校验、费用测算与可接受性结论均已完成的评估,
* `generate` 产出的报告*必*包含全部规定章节(项目概要与业务类型判定、风险总分与分级、
* 风险热力图、各维度风险明细、Top 关键风险与红线校验结果、可接受性结论、应对方案、
* 假设与信息缺口说明);且未命中任一红线时,红线校验结果*必*明确标注为"无红线命中"。
*
* 本测试在生成器中构造"完整 Assessment"(评分/红线/费用/可接受性结论均已产生),
* 覆盖启用/停用维度与指标、混合数据来源(含"智能体假设")、有/无红线命中等形态,
* 对 `generate` 施加上述章节完备性与"无红线命中"标注约束。
*
* Feature: outsourcing-risk-assessment, Property 36: 报告章节完备
* Validates: Requirements 10.1
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
ACCEPTABILITY_VALUES,
REDLINE_STATUS_VALUES,
type Acceptability,
type Assessment,
type RedlineResult,
type RedlineStatus,
type ScoringItem,
} from '../../domain/assessment.js';
import {
DATA_PROVENANCE_VALUES,
RISK_GRADE_VALUES,
RISK_LEVEL_VALUES,
type DataProvenance,
type RiskGrade,
type RiskLevel,
} from '../../domain/common.js';
import type { CostEstimate } from '../../domain/cost.js';
import type { Dimension, Indicator, Redline, RiskModel, ScoringRule } from '../../domain/model.js';
import { REGION_CN } from '../../domain/region.js';
import { generate, NO_REDLINE_HIT_LABEL } from '../report.js';
// ----------------------------------------------------------------------------
// 生成器:构造"评分/红线/费用/可接受性结论均已完成"的 Assessment
// ----------------------------------------------------------------------------
/** 合法 Indicator 须覆盖 Risk_Level 1-5Req 11.3);报告不读取规则文本。 */
const scoringRules: ScoringRule[] = (RISK_LEVEL_VALUES as readonly RiskLevel[]).map(
(level) => ({ level, label: `L${level}`, description: `规则${level}` }),
);
const riskLevelArb = fc.constantFrom<RiskLevel>(...(RISK_LEVEL_VALUES as readonly RiskLevel[]));
const provenanceArb = fc.constantFrom<DataProvenance>(
...(DATA_PROVENANCE_VALUES as readonly DataProvenance[]),
);
const riskGradeArb = fc.constantFrom<RiskGrade>(...(RISK_GRADE_VALUES as readonly RiskGrade[]));
const acceptabilityArb = fc.constantFrom<Acceptability>(
...(ACCEPTABILITY_VALUES as readonly Acceptability[]),
);
const redlineStatusArb = fc.constantFrom<RedlineStatus>(
...(REDLINE_STATUS_VALUES as readonly RedlineStatus[]),
);
/** 置信度:[0,1] 两位小数(Req 4.6, 18.4)。 */
const confidenceArb = fc.integer({ min: 0, max: 100 }).map((n) => n / 100);
const weightArb = fc.double({ min: 0, max: 100, noNaN: true });
const moneyArb = fc.double({ min: 0, max: 1_000_000, noNaN: true });
interface RawIndicator {
enabled: boolean;
weight: number;
riskLevel: RiskLevel;
provenance: DataProvenance;
confidence: number;
}
interface RawDimension {
enabled: boolean;
weight: number;
indicators: RawIndicator[];
}
interface RawRedline {
enabled: boolean;
status: RedlineStatus;
}
const rawIndicatorArb: fc.Arbitrary<RawIndicator> = fc.record({
enabled: fc.boolean(),
weight: weightArb,
riskLevel: riskLevelArb,
provenance: provenanceArb,
confidence: confidenceArb,
});
const rawDimensionArb: fc.Arbitrary<RawDimension> = fc.record({
enabled: fc.boolean(),
weight: weightArb,
indicators: fc.array(rawIndicatorArb, { minLength: 0, maxLength: 4 }),
});
const rawRedlineArb: fc.Arbitrary<RawRedline> = fc.record({
enabled: fc.boolean(),
status: redlineStatusArb,
});
const costEstimateArb: fc.Arbitrary<CostEstimate> = fc
.record({
baseline: moneyArb,
premiumLower: fc.double({ min: 0, max: 50, noNaN: true }),
premiumDelta: fc.double({ min: 0, max: 50, noNaN: true }),
})
.map(({ baseline, premiumLower, premiumDelta }) => ({
riskPremiumRange: { lower: premiumLower, upper: premiumLower + premiumDelta, unit: '百分比' },
advanceInterest: 0,
insuranceCost: 0,
compensationReserve: 0,
badDebtReserve: 0,
baselineQuote: baseline,
riskAdjustedQuote: baseline,
breakdown: [],
}));
interface AssessmentSpec {
dimensions: RawDimension[];
redlines: RawRedline[];
riskScore: number;
riskGrade: RiskGrade;
acceptability: Acceptability;
costEstimate: CostEstimate;
/** 强制构造"无红线命中"情形,确保该分支被充分覆盖。 */
forceNoHit: boolean;
}
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
dimensions: fc.array(rawDimensionArb, { minLength: 0, maxLength: 4 }),
redlines: fc.array(rawRedlineArb, { minLength: 0, maxLength: 5 }),
riskScore: fc.integer({ min: 0, max: 100 }),
riskGrade: riskGradeArb,
acceptability: acceptabilityArb,
costEstimate: costEstimateArb,
forceNoHit: fc.boolean(),
});
/**
* 由随机规格构造一个"已完成"的 Assessment
* - 风险模型保留全部随机形态(启用/停用维度与指标、空指标维度、权重为 0 等);
* - 每个 Indicator 均有对应 ScoringItem(含来源/置信/三要素);
* - 每个**启用**红线均有对应的红线校验结果(满足红线校验完成条件);
* - riskScore/riskGrade/costEstimate/acceptability 均已产生。
*/
function buildAssessment(spec: AssessmentSpec): Assessment {
const dimensions: Dimension[] = spec.dimensions.map((d, i) => ({
id: `d${i}`,
name: `维度${i}`,
weight: d.weight,
enabled: d.enabled,
indicators: d.indicators.map(
(ind, j): Indicator => ({
id: `i${j}`,
name: `指标${i}-${j}`,
weight: ind.weight,
enabled: ind.enabled,
scoringRules,
evidenceRequired: '',
askPrompt: '',
}),
),
}));
const redlines: Redline[] = spec.redlines.map((r, k) => ({
id: `r${k}`,
triggerCondition: `触发条件${k}`,
consequence: `后果${k}`,
enabled: r.enabled,
}));
const riskModel: RiskModel = {
id: 'm1',
name: '风险模型',
businessType: '岗位外包',
dimensions,
redlines,
};
// 为每个指标构造评分项(含停用项;报告以对象引用映射,停用项不进入热力图/TopN)。
const scoringItems: ScoringItem[] = spec.dimensions.flatMap((d, i) =>
d.indicators.map((ind, j): ScoringItem => ({
dimensionId: `d${i}`,
indicatorId: `i${j}`,
riskLevel: ind.riskLevel,
score: ind.riskLevel * ind.weight,
provenance: ind.provenance,
confidence: ind.confidence,
rationale: `判定依据${i}-${j}`,
riskImpact: `风险影响${i}-${j}`,
recommendation: `建议${i}-${j}`,
})),
);
// 每个启用红线产生一个红线校验结果(满足红线校验完成条件)。
const redlineResults: RedlineResult[] = spec.redlines
.map((r, k): { enabled: boolean; result: RedlineResult } => {
const status: RedlineStatus =
spec.forceNoHit && r.status === '命中' ? '未命中' : r.status;
const base: RedlineResult = { redlineId: `r${k}`, status };
const result: RedlineResult =
status === '命中'
? { ...base, triggeredCondition: `触发条件${k}`, evidenceData: `判定依据数据${k}` }
: base;
return { enabled: r.enabled, result };
})
.filter((entry) => entry.enabled)
.map((entry) => entry.result);
return {
id: 'a1',
projectDescription: '某外包项目风险评估',
businessType: '岗位外包',
industry: '制造业',
region: REGION_CN,
riskModel,
scoringItems,
riskScore: spec.riskScore,
riskGrade: spec.riskGrade,
redlineResults,
costEstimate: spec.costEstimate,
acceptability: spec.acceptability,
metadata: {
businessType: '岗位外包',
industry: '制造业',
region: REGION_CN,
riskScore: spec.riskScore,
riskGrade: spec.riskGrade,
createdAt: '2024-01-01T00:00:00.000Z',
assessorId: 'assessor-1',
},
createdAt: '2024-01-01T00:00:00.000Z',
assessorId: 'assessor-1',
};
}
// ----------------------------------------------------------------------------
// 属性测试
// ----------------------------------------------------------------------------
describe('Property 36: 报告章节完备 (Req 10.1)', () => {
it('对任意已完成评估,报告含全部规定章节;未命中任一红线时明确标注"无红线命中"', () => {
fc.assert(
fc.property(assessmentSpecArb, (spec) => {
const assessment = buildAssessment(spec);
const report = generate(assessment);
// 1) 全部八个规定章节均存在,且各章节标题非空。
expect(report.projectOverview).toBeDefined();
expect(report.projectOverview.title.length).toBeGreaterThan(0);
expect(report.riskScoreGrade).toBeDefined();
expect(report.riskScoreGrade.title.length).toBeGreaterThan(0);
expect(report.heatmap).toBeDefined();
expect(report.heatmap.title.length).toBeGreaterThan(0);
expect(report.dimensionDetails).toBeDefined();
expect(report.dimensionDetails.title.length).toBeGreaterThan(0);
expect(report.keyRisksAndRedlines).toBeDefined();
expect(report.keyRisksAndRedlines.title.length).toBeGreaterThan(0);
expect(report.acceptabilityConclusion).toBeDefined();
expect(report.acceptabilityConclusion.title.length).toBeGreaterThan(0);
expect(report.responsePlan).toBeDefined();
expect(report.responsePlan.title.length).toBeGreaterThan(0);
expect(report.assumptionsAndGaps).toBeDefined();
expect(report.assumptionsAndGaps.title.length).toBeGreaterThan(0);
// 2) 章节内容与已完成评估一致的基本结构。
expect(report.riskScoreGrade.riskScore).toBe(assessment.riskScore);
expect(report.riskScoreGrade.riskGrade).toBe(assessment.riskGrade);
expect(report.acceptabilityConclusion.acceptability).toBe(assessment.acceptability);
expect(Array.isArray(report.heatmap.cells)).toBe(true);
expect(report.dimensionDetails.dimensions.length).toBe(
assessment.riskModel.dimensions.length,
);
expect(report.keyRisksAndRedlines.redlineResults.length).toBe(
assessment.redlineResults.length,
);
expect(Array.isArray(report.assumptionsAndGaps.items)).toBe(true);
// 3) 红线命中与"无红线命中"标注。
const anyHit = assessment.redlineResults.some((r) => r.status === '命中');
if (anyHit) {
expect(report.keyRisksAndRedlines.hasRedlineHit).toBe(true);
expect(report.keyRisksAndRedlines.hitRedlines.length).toBe(
assessment.redlineResults.filter((r) => r.status === '命中').length,
);
expect(report.keyRisksAndRedlines.redlineSummary).not.toBe(NO_REDLINE_HIT_LABEL);
} else {
expect(report.keyRisksAndRedlines.hasRedlineHit).toBe(false);
expect(report.keyRisksAndRedlines.hitRedlines.length).toBe(0);
expect(report.keyRisksAndRedlines.redlineSummary).toBe(NO_REDLINE_HIT_LABEL);
}
}),
{ numRuns: 100 },
);
});
});