c670b9e454
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
/**
|
||
* Property 36: 报告章节完备 的属性化测试(Report_Generator,Req 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-5(Req 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 },
|
||
);
|
||
});
|
||
});
|