外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* PortfolioCompareChart 单元测试(task 19.5,Req 20.1)。
|
||||
*
|
||||
* 验证:
|
||||
* - 就绪态并呈各项目标签、Risk_Score 与 Risk_Grade(可见 DOM 文本,Property 69)。
|
||||
* - 项目数 ≥2 时呈现图例,且图例标签与数据元素(项目)标签一致(Property 68)。
|
||||
* - 每个项目以其 Risk_Grade 的稳定 Color_Token 着色(Req 19.6 / Property 64)。
|
||||
* - 空集 → Empty_State + 非空提示(Req 20.4);loading → Loading_State(Req 20.5)。
|
||||
*
|
||||
* 注:跨全输入空间的属性测试属 task 19.6–19.11,本文件仅覆盖代表性样例与边界。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { riskGradeColorToken } from '../../design-system/index.js';
|
||||
import {
|
||||
PortfolioCompareChart,
|
||||
buildPortfolioCompareSpec,
|
||||
type PortfolioCompareRow,
|
||||
} from '../PortfolioCompareChart.js';
|
||||
import { legendMatchesData, labelsComplete } from '../helpers.js';
|
||||
|
||||
const ROWS: readonly PortfolioCompareRow[] = [
|
||||
{ assessmentId: 'a-1', label: '项目甲', riskScore: 20, riskGrade: '低' },
|
||||
{ assessmentId: 'a-2', label: '项目乙', riskScore: 60, riskGrade: '高' },
|
||||
{
|
||||
assessmentId: 'a-3',
|
||||
label: '项目丙',
|
||||
riskScore: 88,
|
||||
riskGrade: '极高',
|
||||
keyRisks: [{ dimensionId: '财务', indicatorId: '现金流', score: 4 }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('buildPortfolioCompareSpec(纯派生)', () => {
|
||||
it('每个项目为一个数据类别,类型为 PortfolioCompare', () => {
|
||||
const spec = buildPortfolioCompareSpec(ROWS);
|
||||
expect(spec.type).toBe('PortfolioCompare');
|
||||
expect(spec.status).toBe('ready');
|
||||
expect(spec.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('≥2 项目时图例标签集合与数据元素标签集合一致(Property 68)', () => {
|
||||
const spec = buildPortfolioCompareSpec(ROWS);
|
||||
expect(spec.legend).toBeDefined();
|
||||
expect(legendMatchesData(spec)).toBe(true);
|
||||
});
|
||||
|
||||
it('全部数据元素文本标签非空(Property 69)', () => {
|
||||
expect(labelsComplete(buildPortfolioCompareSpec(ROWS))).toBe(true);
|
||||
});
|
||||
|
||||
it('每个项目以其 Risk_Grade 的稳定 Color_Token 着色', () => {
|
||||
const spec = buildPortfolioCompareSpec(ROWS);
|
||||
for (const [index, row] of ROWS.entries()) {
|
||||
expect(spec.series[index]?.encoding.colorToken).toBe(
|
||||
riskGradeColorToken(row.riskGrade),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('空集 → empty 状态并附非空提示', () => {
|
||||
const spec = buildPortfolioCompareSpec([]);
|
||||
expect(spec.status).toBe('empty');
|
||||
expect(spec.emptyMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loading 优先于 empty', () => {
|
||||
expect(buildPortfolioCompareSpec([], { loading: true }).status).toBe('loading');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PortfolioCompareChart(跨项目组合对比图)', () => {
|
||||
it('并呈各项目标签、Risk_Score 与 Risk_Grade 文本', () => {
|
||||
render(<PortfolioCompareChart rows={ROWS} />);
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
for (const row of ROWS) {
|
||||
const tr = within(table).getByText(row.label).closest('tr');
|
||||
expect(tr).not.toBeNull();
|
||||
expect(within(tr as HTMLElement).getByText(String(row.riskScore))).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('项目数 ≥2 时呈现图例', () => {
|
||||
const { container } = render(<PortfolioCompareChart rows={ROWS} />);
|
||||
const legend = container.querySelector('[data-chart-legend="true"]');
|
||||
expect(legend).not.toBeNull();
|
||||
for (const row of ROWS) {
|
||||
expect(
|
||||
container.querySelector(`[data-legend-item="${row.label}"]`),
|
||||
).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('空集呈现 Empty_State(Req 20.4)', () => {
|
||||
render(<PortfolioCompareChart rows={[]} />);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveAttribute('data-chart-state', 'empty');
|
||||
expect(
|
||||
within(status).getByText('暂无可对比的项目(至少需选择 2 个评估)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loading 态呈现 Loading_State(Req 20.5)', () => {
|
||||
render(<PortfolioCompareChart rows={ROWS} loading={true} />);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveAttribute('data-chart-state', 'loading');
|
||||
});
|
||||
|
||||
it('无明显可访问性违规', async () => {
|
||||
const { container } = render(<PortfolioCompareChart rows={ROWS} />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ScoreGauge + 本地 classifyGrade 单元测试(task 19.3,Req 20.1 / 20.6)。
|
||||
*
|
||||
* 验证:
|
||||
* - 本地 classifyGrade 边界归属与领域分级规则逐字一致
|
||||
* ([0,25]→低、(25,50]→中、(50,75]→高、(75,100]→极高)。
|
||||
* - 仪表盘同时呈现 Risk_Score 数值与对应 Risk_Grade 文本(Req 20.6),
|
||||
* 且所呈现等级恒等于 classifyGrade(score)。
|
||||
* - loading 态由容器呈现 Loading_State。
|
||||
*
|
||||
* 注:跨全取值域的属性测试(Property 71)属 task 19.9,本文件不实现。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { ScoreGauge } from '../ScoreGauge.js';
|
||||
import { classifyGrade, RISK_GRADE_VALUES } from '../riskGrade.js';
|
||||
|
||||
describe('classifyGrade(本地镜像领域分级规则)', () => {
|
||||
it('边界值精确归属:右闭左开衔接,首区间左闭', () => {
|
||||
expect(classifyGrade(0)).toBe('低');
|
||||
expect(classifyGrade(25)).toBe('低');
|
||||
expect(classifyGrade(26)).toBe('中');
|
||||
expect(classifyGrade(50)).toBe('中');
|
||||
expect(classifyGrade(51)).toBe('高');
|
||||
expect(classifyGrade(75)).toBe('高');
|
||||
expect(classifyGrade(76)).toBe('极高');
|
||||
expect(classifyGrade(100)).toBe('极高');
|
||||
});
|
||||
|
||||
it('输出恒为四级合法 Risk_Grade 之一', () => {
|
||||
for (let score = 0; score <= 100; score += 1) {
|
||||
expect(RISK_GRADE_VALUES).toContain(classifyGrade(score));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScoreGauge(风险总分仪表盘)', () => {
|
||||
it('同时呈现 Risk_Score 数值与对应 Risk_Grade(Req 20.6)', () => {
|
||||
render(<ScoreGauge score={60} />);
|
||||
|
||||
const scoreNode = screen.getByLabelText('风险总分 60');
|
||||
expect(scoreNode).toBeInTheDocument();
|
||||
expect(scoreNode).toHaveTextContent('60');
|
||||
|
||||
// 60 → (50,75] → 高
|
||||
const gradeNode = screen.getByLabelText('风险分级 高');
|
||||
expect(gradeNode).toBeInTheDocument();
|
||||
expect(gradeNode).toHaveTextContent('高');
|
||||
});
|
||||
|
||||
it('所呈现等级恒等于 classifyGrade(score)(边界样例)', () => {
|
||||
for (const score of [0, 25, 26, 50, 51, 75, 76, 100]) {
|
||||
const expectedGrade = classifyGrade(score);
|
||||
const { unmount } = render(<ScoreGauge score={score} />);
|
||||
expect(screen.getByLabelText(`风险分级 ${expectedGrade}`)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(`风险总分 ${score}`)).toHaveTextContent(
|
||||
String(score),
|
||||
);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('越界分值被夹取到 [0,100]', () => {
|
||||
render(<ScoreGauge score={150} />);
|
||||
expect(screen.getByLabelText('风险总分 100')).toHaveTextContent('100');
|
||||
expect(screen.getByLabelText('风险分级 极高')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loading 态呈现 Loading_State(Req 20.5)', () => {
|
||||
render(<ScoreGauge score={42} loading={true} />);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveAttribute('data-chart-state', 'loading');
|
||||
expect(within(status).getByText('正在计算风险总分…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无明显可访问性违规', async () => {
|
||||
const { container } = render(<ScoreGauge score={30} />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 全套图表组件渲染单元测试(task 19.12,Req 20.1)。
|
||||
*
|
||||
* Req 20.1 规定系统应提供全套图表组件。本文件以代表性非空数据逐一渲染七类图表,
|
||||
* 断言「可渲染且关键内容呈现于 DOM」:
|
||||
* 1. RiskHeatmap —— 热力图:表格 + 数值 Risk_Level 单元格
|
||||
* 2. ScoreGauge —— 仪表盘:Risk_Score 数值 + Risk_Grade 文本
|
||||
* 3. RiskBadge —— 徽章:Risk_Grade 文字标签
|
||||
* 4. TopNRiskChart —— Top N:关键风险得分标签
|
||||
* 5. CostBreakdownChart —— 费用拆解:各拆解项名称 + 金额
|
||||
* 6. QuoteCompareChart —— 报价对比:基准 / 风险调整后 / 差额 三值
|
||||
* 7. PortfolioCompareChart —— 组合对比:各项目 Risk_Score 与 Risk_Grade
|
||||
*
|
||||
* 关注「渲染 + 关键内容存在」,不重复验证标签/状态等属性级不变量(task 19.6–19.11
|
||||
* 已覆盖)。组件以 Color_Token 经 CSS 变量取色,无需 ThemeProvider 即可渲染。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
RiskHeatmap,
|
||||
ScoreGauge,
|
||||
RiskBadge,
|
||||
TopNRiskChart,
|
||||
CostBreakdownChart,
|
||||
QuoteCompareChart,
|
||||
PortfolioCompareChart,
|
||||
type HeatmapCellInput,
|
||||
type RiskItemInput,
|
||||
type CostBreakdownItemInput,
|
||||
type PortfolioCompareRow,
|
||||
} from '../index.js';
|
||||
|
||||
const HEATMAP_CELLS: readonly HeatmapCellInput[] = [
|
||||
{ dimensionId: '财务', indicatorId: '现金流', riskLevel: 4 },
|
||||
{ dimensionId: '财务', indicatorId: '负债率', riskLevel: 2 },
|
||||
{ dimensionId: '合规', indicatorId: '资质', riskLevel: 5 },
|
||||
];
|
||||
|
||||
const TOP_N_ITEMS: readonly RiskItemInput[] = [
|
||||
{ dimensionId: '财务', indicatorId: '现金流', score: 80, rationale: '现金流紧张' },
|
||||
{ dimensionId: '合规', indicatorId: '资质', score: 65, rationale: '资质不全' },
|
||||
];
|
||||
|
||||
const COST_ITEMS: readonly CostBreakdownItemInput[] = [
|
||||
{ name: '垫资利息', amount: 100000 },
|
||||
{ name: '保函费用', amount: 25000 },
|
||||
];
|
||||
|
||||
const PORTFOLIO_ROWS: readonly PortfolioCompareRow[] = [
|
||||
{ assessmentId: 'a-1', label: '项目甲', riskScore: 20, riskGrade: '低' },
|
||||
{ assessmentId: 'a-2', label: '项目乙', riskScore: 88, riskGrade: '极高' },
|
||||
];
|
||||
|
||||
describe('全套图表组件渲染(task 19.12,Req 20.1)', () => {
|
||||
it('1. RiskHeatmap:渲染表格与数值 Risk_Level 单元格', () => {
|
||||
const { container } = render(<RiskHeatmap cells={HEATMAP_CELLS} />);
|
||||
|
||||
// 容器就绪态 + 类型标识。
|
||||
expect(container.querySelector('[data-chart-type="Heatmap"]')).not.toBeNull();
|
||||
// 网格表格存在。
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
// 有数据单元格附数值等级标签。
|
||||
const filled = screen.getByLabelText('财务 / 现金流:风险等级 4');
|
||||
expect(filled).toHaveTextContent('4');
|
||||
expect(screen.getByLabelText('合规 / 资质:风险等级 5')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
it('2. ScoreGauge:同时呈现 Risk_Score 数值与 Risk_Grade 文本', () => {
|
||||
render(<ScoreGauge score={60} />);
|
||||
|
||||
expect(screen.getByLabelText('风险总分 60')).toHaveTextContent('60');
|
||||
// 60 → (50,75] → 高
|
||||
expect(screen.getByLabelText('风险分级 高')).toHaveTextContent('高');
|
||||
});
|
||||
|
||||
it('3. RiskBadge:始终呈现 Risk_Grade 文字标签', () => {
|
||||
render(<RiskBadge grade="高" prefix="风险等级" />);
|
||||
|
||||
const badge = screen.getByRole('status');
|
||||
expect(badge).toHaveAttribute('data-risk-grade', '高');
|
||||
expect(within(badge).getByText('高')).toBeInTheDocument();
|
||||
expect(within(badge).getByText('风险等级')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('4. TopNRiskChart:渲染关键风险得分标签', () => {
|
||||
const { container } = render(<TopNRiskChart items={TOP_N_ITEMS} />);
|
||||
|
||||
expect(container.querySelector('[data-chart-type="TopNRiskChart"]')).not.toBeNull();
|
||||
// 容器标签区以可见 DOM 文本呈现各关键风险(含得分)。
|
||||
expect(screen.getByText(/财务 \/ 现金流(得分 80)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/合规 \/ 资质(得分 65)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('5. CostBreakdownChart:渲染各费用拆解项名称与金额', () => {
|
||||
const { container } = render(<CostBreakdownChart items={COST_ITEMS} />);
|
||||
|
||||
expect(container.querySelector('[data-chart-type="CostBreakdown"]')).not.toBeNull();
|
||||
const interest = container.querySelector('[data-cost-item="垫资利息"]');
|
||||
expect(interest).not.toBeNull();
|
||||
expect(interest).toHaveTextContent('垫资利息');
|
||||
expect(interest).toHaveTextContent('100,000.00 元');
|
||||
const guarantee = container.querySelector('[data-cost-item="保函费用"]');
|
||||
expect(guarantee).toHaveTextContent('保函费用');
|
||||
expect(guarantee).toHaveTextContent('25,000.00 元');
|
||||
});
|
||||
|
||||
it('6. QuoteCompareChart:呈现基准 / 风险调整后 / 差额 三个数值', () => {
|
||||
const { container } = render(
|
||||
<QuoteCompareChart quote={{ baselineQuote: 1000000, riskAdjustedQuote: 1200000 }} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-chart-type="QuoteCompare"]')).not.toBeNull();
|
||||
|
||||
const baseline = container.querySelector('[data-quote-baseline]');
|
||||
const riskAdjusted = container.querySelector('[data-quote-risk-adjusted]');
|
||||
const difference = container.querySelector('[data-quote-difference]');
|
||||
|
||||
expect(baseline).toHaveAttribute('data-quote-baseline', '1000000');
|
||||
expect(riskAdjusted).toHaveAttribute('data-quote-risk-adjusted', '1200000');
|
||||
// 差额 = 风险调整后 − 基准 = 200000
|
||||
expect(difference).toHaveAttribute('data-quote-difference', '200000');
|
||||
expect(difference).toHaveTextContent('200,000.00 元');
|
||||
});
|
||||
|
||||
it('7. PortfolioCompareChart:并呈各项目 Risk_Score 与 Risk_Grade', () => {
|
||||
render(<PortfolioCompareChart rows={PORTFOLIO_ROWS} />);
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
for (const row of PORTFOLIO_ROWS) {
|
||||
const tr = within(table).getByText(row.label).closest('tr');
|
||||
expect(tr).not.toBeNull();
|
||||
expect(
|
||||
within(tr as HTMLElement).getByText(String(row.riskScore)),
|
||||
).toBeInTheDocument();
|
||||
// Risk_Grade 徽章呈现该等级文字。
|
||||
expect(
|
||||
within(tr as HTMLElement).getByText(row.riskGrade),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Property 79: 图表非纯颜色编码 的属性化测试(通用 Chart 契约,Req 23.6)。
|
||||
*
|
||||
* 属性陈述:对任意 Chart 中的数据类别,必存在颜色之外的区分编码(文本标签或图案),
|
||||
* 使该类别在不依赖颜色的情况下仍可识别。
|
||||
*
|
||||
* 实现语义(见 helpers.ts `isDistinctlyEncoded`):当某类别 `encoding.textLabel`
|
||||
* 去除首尾空白后非空,即视为「颜色之外可区分」(文本标签本身不依赖颜色)。
|
||||
* 因此本测试以「文本标签是否非空」为可区分性的判据:
|
||||
* - 任意含非空 textLabel 的 encoding → isDistinctlyEncoded === true。
|
||||
* - 系列数组中每个 encoding 的 textLabel 均非空 → allCategoriesDistinct === true。
|
||||
* - 反例:textLabel 为空/纯空白 → isDistinctlyEncoded === false
|
||||
* (判据确实要求非颜色线索,而非恒真)。
|
||||
* - 由 buildHeatmapSpec / buildTopNSpec 自非空输入构造的 spec,其全部类别均
|
||||
* 可区分,且每个 encoding 同时具备非空 textLabel 与取自 PATTERN_SEQUENCE 的图案。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 79: 图表非纯颜色编码
|
||||
* Validates: Requirements 23.6
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
allCategoriesDistinct,
|
||||
buildHeatmapSpec,
|
||||
buildTopNSpec,
|
||||
categoryEncodings,
|
||||
isDistinctlyEncoded,
|
||||
PATTERN_SEQUENCE,
|
||||
} from '../index.js';
|
||||
import type {
|
||||
CategoryEncoding,
|
||||
ChartPattern,
|
||||
HeatmapCellInput,
|
||||
RiskItemInput,
|
||||
RiskLevel,
|
||||
Series,
|
||||
} from '../index.js';
|
||||
import type { ColorToken } from '../../design-system/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法的配色令牌名(数据编码色子集,足以覆盖 colorToken 字段)。 */
|
||||
const colorTokenArb: fc.Arbitrary<ColorToken> = fc.constantFrom<ColorToken>(
|
||||
'color.risk.low',
|
||||
'color.risk.medium',
|
||||
'color.risk.high',
|
||||
'color.risk.critical',
|
||||
'color.heat.1',
|
||||
'color.heat.2',
|
||||
'color.heat.3',
|
||||
'color.heat.4',
|
||||
'color.heat.5',
|
||||
);
|
||||
|
||||
/** 合法的图案枚举值(取自实现的 PATTERN_SEQUENCE)。 */
|
||||
const patternArb: fc.Arbitrary<ChartPattern> = fc.constantFrom<ChartPattern>(
|
||||
...PATTERN_SEQUENCE,
|
||||
);
|
||||
|
||||
/**
|
||||
* 非空文本标签生成器:至少含一个非空白字符。
|
||||
* 在任意字符串前拼接一个固定的非空白字符,确保 trim() 后长度 ≥1。
|
||||
*/
|
||||
const nonEmptyTextLabelArb: fc.Arbitrary<string> = fc
|
||||
.string({ maxLength: 12 })
|
||||
.map((s) => `类别${s}`);
|
||||
|
||||
/** 含非空 textLabel 的类别编码。 */
|
||||
const distinctEncodingArb: fc.Arbitrary<CategoryEncoding> = fc.record({
|
||||
colorToken: colorTokenArb,
|
||||
textLabel: nonEmptyTextLabelArb,
|
||||
pattern: patternArb,
|
||||
});
|
||||
|
||||
/** 单个数据点:非空标签 + 数值。 */
|
||||
const dataPointArb = fc.record({
|
||||
label: fc.string({ minLength: 1, maxLength: 10 }),
|
||||
value: fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
});
|
||||
|
||||
/** 含非空 textLabel 编码的数据系列。 */
|
||||
const distinctSeriesArb: fc.Arbitrary<Series> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
label: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
encoding: distinctEncodingArb,
|
||||
points: fc.array(dataPointArb, { maxLength: 4 }),
|
||||
});
|
||||
|
||||
/** 系列数组:长度 0..6。 */
|
||||
const distinctSeriesArrayArb: fc.Arbitrary<Series[]> = fc.array(distinctSeriesArb, {
|
||||
minLength: 0,
|
||||
maxLength: 6,
|
||||
});
|
||||
|
||||
/** 空/纯空白文本(用于反例)。 */
|
||||
const blankTextArb: fc.Arbitrary<string> = fc.constantFrom('', ' ', ' ', '\t', '\n', ' \t\n ');
|
||||
|
||||
/** 风险等级(1–5)。 */
|
||||
const riskLevelArb: fc.Arbitrary<RiskLevel> = fc.constantFrom<RiskLevel>(1, 2, 3, 4, 5);
|
||||
|
||||
/** 热力图单元格输入生成器。 */
|
||||
const heatmapCellArb: fc.Arbitrary<HeatmapCellInput> = fc.record({
|
||||
dimensionId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
riskLevel: riskLevelArb,
|
||||
});
|
||||
|
||||
/** 关键风险项输入生成器。 */
|
||||
const riskItemArb: fc.Arbitrary<RiskItemInput> = fc.record({
|
||||
dimensionId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
score: fc.double({ noNaN: true, noDefaultInfinity: true, min: 0, max: 100 }),
|
||||
rationale: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
});
|
||||
|
||||
describe('Property 79: 图表非纯颜色编码', () => {
|
||||
it('含非空 textLabel 的 encoding → isDistinctlyEncoded 为真', () => {
|
||||
fc.assert(
|
||||
fc.property(distinctEncodingArb, (encoding) => {
|
||||
expect(isDistinctlyEncoded(encoding)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('每个 encoding 的 textLabel 均非空 → allCategoriesDistinct 为真', () => {
|
||||
fc.assert(
|
||||
fc.property(distinctSeriesArrayArb, (series) => {
|
||||
expect(allCategoriesDistinct(series)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('反例:textLabel 为空/纯空白 → isDistinctlyEncoded 为假(判据确需非颜色线索)', () => {
|
||||
fc.assert(
|
||||
fc.property(colorTokenArb, patternArb, blankTextArb, (colorToken, pattern, textLabel) => {
|
||||
const encoding: CategoryEncoding = { colorToken, textLabel, pattern };
|
||||
expect(isDistinctlyEncoded(encoding)).toBe(false);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('buildHeatmapSpec:非空输入构造的全部类别可区分,且具备非空 textLabel + 合法图案', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(heatmapCellArb, { minLength: 1, maxLength: 8 }), (cells) => {
|
||||
const spec = buildHeatmapSpec(cells);
|
||||
expect(allCategoriesDistinct(spec.series)).toBe(true);
|
||||
for (const encoding of categoryEncodings(spec.series)) {
|
||||
expect(encoding.textLabel.trim().length).toBeGreaterThan(0);
|
||||
expect(PATTERN_SEQUENCE).toContain(encoding.pattern);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('buildTopNSpec:非空输入构造的全部类别可区分,且具备非空 textLabel + 合法图案', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(riskItemArb, { minLength: 1, maxLength: 8 }), (items) => {
|
||||
const spec = buildTopNSpec(items);
|
||||
expect(allCategoriesDistinct(spec.series)).toBe(true);
|
||||
for (const encoding of categoryEncodings(spec.series)) {
|
||||
expect(encoding.textLabel.trim().length).toBeGreaterThan(0);
|
||||
expect(PATTERN_SEQUENCE).toContain(encoding.pattern);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Property 69: 图表文本标签齐备 的属性化测试(Charts,Req 20.3)。
|
||||
*
|
||||
* 属性陈述:对任意 Chart,其每个坐标轴、数据点或分区必具有非空的文本标签。
|
||||
*
|
||||
* 本测试从两个层面验证:
|
||||
* 1. 经 `buildHeatmapSpec` / `buildTopNSpec` 由任意「非空领域输入」构造的 ChartSpec,
|
||||
* `labelsComplete(spec)` 恒为 true —— 即全部 axis/point/partition 标签以及
|
||||
* 每个数据点的标签文本均非空。
|
||||
* 2. 纯谓词 `allLabelsNonEmpty` 本身可信:对任意「文本非空」的 Label 数组返回 true;
|
||||
* 而一旦数组中混入空/空白文本的 Label,则返回 false —— 证明谓词确能检出缺失标签。
|
||||
*
|
||||
* 领域不变量:Dimension/Indicator 标识恒为非空字符串,故生成器对 dimensionId /
|
||||
* indicatorId 约束为非空白字符串,以保持「标签齐备」属性的语义意义。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 69: 图表文本标签齐备
|
||||
* Validates: Requirements 20.3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
allLabelsNonEmpty,
|
||||
buildHeatmapSpec,
|
||||
buildTopNSpec,
|
||||
collectDataElementLabels,
|
||||
labelsComplete,
|
||||
} from '../index.js';
|
||||
import type {
|
||||
HeatmapCellInput,
|
||||
Label,
|
||||
LabelKind,
|
||||
RiskItemInput,
|
||||
RiskLevel,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 非空白字符串(trim 后长度 ≥1)——镜像「领域标识恒非空」不变量。 */
|
||||
const nonBlankStringArb: fc.Arbitrary<string> = fc
|
||||
.string({ minLength: 1, maxLength: 16 })
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
/** 风险等级(1–5)。 */
|
||||
const riskLevelArb: fc.Arbitrary<RiskLevel> = fc.constantFrom<RiskLevel>(1, 2, 3, 4, 5);
|
||||
|
||||
/** 任意热力图单元格(标识非空白、等级合法)。 */
|
||||
const heatmapCellArb: fc.Arbitrary<HeatmapCellInput> = fc.record({
|
||||
dimensionId: nonBlankStringArb,
|
||||
indicatorId: nonBlankStringArb,
|
||||
riskLevel: riskLevelArb,
|
||||
});
|
||||
|
||||
/** 任意关键风险项(标识非空白、得分有限、判定依据任意)。 */
|
||||
const riskItemArb: fc.Arbitrary<RiskItemInput> = fc.record({
|
||||
dimensionId: nonBlankStringArb,
|
||||
indicatorId: nonBlankStringArb,
|
||||
score: fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
rationale: fc.string({ maxLength: 32 }),
|
||||
});
|
||||
|
||||
/** 标签种类。 */
|
||||
const labelKindArb: fc.Arbitrary<LabelKind> = fc.constantFrom<LabelKind>(
|
||||
'axis',
|
||||
'point',
|
||||
'partition',
|
||||
);
|
||||
|
||||
/** 文本非空白的标签。 */
|
||||
const nonEmptyLabelArb: fc.Arbitrary<Label> = fc.record({
|
||||
kind: labelKindArb,
|
||||
text: nonBlankStringArb,
|
||||
});
|
||||
|
||||
/** 文本为空/纯空白的标签(用于构造反例)。 */
|
||||
const blankLabelArb: fc.Arbitrary<Label> = fc.record({
|
||||
kind: labelKindArb,
|
||||
text: fc.constantFrom('', ' ', ' ', '\t', '\n', ' \t\n '),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 69: 图表文本标签齐备 (Req 20.3)', () => {
|
||||
it('buildHeatmapSpec 由任意非空单元格构造的图表标签恒齐备', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(heatmapCellArb, { minLength: 1, maxLength: 20 }),
|
||||
fc.boolean(),
|
||||
(cells, loading) => {
|
||||
const spec = buildHeatmapSpec(cells, { loading });
|
||||
expect(labelsComplete(spec)).toBe(true);
|
||||
// 逐条断言收集到的数据元素文本均非空。
|
||||
for (const text of collectDataElementLabels(spec)) {
|
||||
expect(text.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('buildTopNSpec 由任意非空风险项构造的图表标签恒齐备', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(riskItemArb, { minLength: 1, maxLength: 20 }),
|
||||
fc.boolean(),
|
||||
(items, loading) => {
|
||||
const spec = buildTopNSpec(items, { loading });
|
||||
expect(labelsComplete(spec)).toBe(true);
|
||||
for (const text of collectDataElementLabels(spec)) {
|
||||
expect(text.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('allLabelsNonEmpty 对全部文本非空的标签数组恒为 true', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(nonEmptyLabelArb, { minLength: 0, maxLength: 20 }),
|
||||
(labels) => {
|
||||
expect(allLabelsNonEmpty(labels)).toBe(true);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('allLabelsNonEmpty 检出任意含空/空白文本标签的数组(返回 false)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(nonEmptyLabelArb, { minLength: 0, maxLength: 10 }),
|
||||
blankLabelArb,
|
||||
fc.array(nonEmptyLabelArb, { minLength: 0, maxLength: 10 }),
|
||||
(before, blank, after) => {
|
||||
// 构造反例:在任意非空标签之间插入一条空白文本标签。
|
||||
const labels: Label[] = [...before, blank, ...after];
|
||||
expect(allLabelsNonEmpty(labels)).toBe(false);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Property 68: 图表图例与数据系列一致 的属性化测试(通用 Chart 契约,Req 20.2)。
|
||||
*
|
||||
* 属性陈述:对任意含两个及以上数据系列或类别的 Chart,其图例必存在,且图例标签
|
||||
* 集合恒与该 Chart 中对应数据元素的标签集合相等。系列/类别 <2 时无需图例。
|
||||
*
|
||||
* 本测试以智能生成器构造任意 Series 数组(系列数跨越 0..6,覆盖 <2 与 ≥2):
|
||||
* - 标签可重复亦可唯一,以检验「集合相等(忽略顺序、去重)」语义;
|
||||
* - encoding 的 colorToken 取自合法 ColorToken 名集合,pattern 取自合法 ChartPattern;
|
||||
* - points 各附非空标签,使 ChartSpec 在 status='ready' 下贴近真实形态。
|
||||
*
|
||||
* 断言:
|
||||
* - 系列 ≥2:deriveLegend(series) 的标签集合 == seriesLabels(series) 的集合
|
||||
* (labelSetsEqual);且以派生图例构造的 spec 满足 legendMatchesData === true。
|
||||
* - 系列 ≥2 且无 legend:legendMatchesData === false(图例必需)。
|
||||
* - 系列 <2:无论是否提供 legend,legendMatchesData === true(无需图例)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 68: 图表图例与数据系列一致
|
||||
* Validates: Requirements 20.2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
deriveLegend,
|
||||
labelSetsEqual,
|
||||
legendLabels,
|
||||
legendMatchesData,
|
||||
seriesLabels,
|
||||
shouldShowLegend,
|
||||
} from '../index.js';
|
||||
import type {
|
||||
ChartPattern,
|
||||
ChartSpec,
|
||||
Label,
|
||||
Series,
|
||||
} from '../index.js';
|
||||
import type { ColorToken } from '../../design-system/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造任意数据系列 Series。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法的配色令牌名(语义/数据编码色子集,足以覆盖编码字段)。 */
|
||||
const colorTokenArb: fc.Arbitrary<ColorToken> = fc.constantFrom<ColorToken>(
|
||||
'color.risk.low',
|
||||
'color.risk.medium',
|
||||
'color.risk.high',
|
||||
'color.risk.critical',
|
||||
'color.heat.1',
|
||||
'color.heat.2',
|
||||
'color.heat.3',
|
||||
'color.heat.4',
|
||||
'color.heat.5',
|
||||
);
|
||||
|
||||
/** 合法的图案枚举值。 */
|
||||
const patternArb: fc.Arbitrary<ChartPattern> = fc.constantFrom<ChartPattern>(
|
||||
'solid',
|
||||
'diagonal',
|
||||
'horizontal',
|
||||
'vertical',
|
||||
'grid',
|
||||
'dots',
|
||||
'crosshatch',
|
||||
);
|
||||
|
||||
/**
|
||||
* 标签生成器:从一个较小的取值池中抽取,以提升「标签重复」的概率,
|
||||
* 从而检验集合(去重)相等语义;同时偶尔产出唯一长标签覆盖唯一情形。
|
||||
*/
|
||||
const labelArb: fc.Arbitrary<string> = fc.oneof(
|
||||
fc.constantFrom('系列甲', '系列乙', '系列丙', '系列甲'),
|
||||
fc.string({ minLength: 1, maxLength: 12 }),
|
||||
);
|
||||
|
||||
/** 单个数据点:非空标签 + 数值。 */
|
||||
const dataPointArb = fc.record({
|
||||
label: fc.string({ minLength: 1, maxLength: 10 }),
|
||||
value: fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
});
|
||||
|
||||
/** 单个数据系列。 */
|
||||
const seriesArb: fc.Arbitrary<Series> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
label: labelArb,
|
||||
encoding: fc.record({
|
||||
colorToken: colorTokenArb,
|
||||
textLabel: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
pattern: patternArb,
|
||||
}),
|
||||
points: fc.array(dataPointArb, { maxLength: 4 }),
|
||||
});
|
||||
|
||||
/** 系列数组:长度跨越 0..6,覆盖 <2 与 ≥2 两类。 */
|
||||
const seriesArrayArb: fc.Arbitrary<Series[]> = fc.array(seriesArb, {
|
||||
minLength: 0,
|
||||
maxLength: 6,
|
||||
});
|
||||
|
||||
/** 由系列数组构造 status='ready' 的 ChartSpec(legend 可选传入)。 */
|
||||
function buildSpec(
|
||||
series: readonly Series[],
|
||||
legend?: readonly { label: string; colorToken: ColorToken; pattern: ChartPattern }[],
|
||||
): ChartSpec {
|
||||
const labels: Label[] = [{ kind: 'axis', text: '轴' }];
|
||||
return {
|
||||
type: 'Heatmap',
|
||||
status: 'ready',
|
||||
series,
|
||||
...(legend !== undefined ? { legend } : {}),
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Property 68: 图表图例与数据系列一致', () => {
|
||||
it('系列 ≥2:派生图例的标签集合 == 数据元素标签集合,且 legendMatchesData 为真', () => {
|
||||
fc.assert(
|
||||
fc.property(seriesArrayArb, (series) => {
|
||||
fc.pre(shouldShowLegend(series)); // 仅检验 ≥2 情形
|
||||
const legend = deriveLegend(series);
|
||||
// 图例必存在且标签集合与数据系列标签集合相等。
|
||||
expect(labelSetsEqual(legendLabels(legend), seriesLabels(series))).toBe(true);
|
||||
const spec = buildSpec(series, legend);
|
||||
expect(legendMatchesData(spec)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('系列 ≥2 且缺失图例:legendMatchesData 为假(图例必需)', () => {
|
||||
fc.assert(
|
||||
fc.property(seriesArrayArb, (series) => {
|
||||
fc.pre(shouldShowLegend(series));
|
||||
const spec = buildSpec(series); // 不提供 legend
|
||||
expect(legendMatchesData(spec)).toBe(false);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('系列 <2:无论是否提供图例,legendMatchesData 恒为真(无需图例)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(seriesArb, { minLength: 0, maxLength: 1 }),
|
||||
fc.boolean(),
|
||||
(series, withLegend) => {
|
||||
const spec = withLegend
|
||||
? buildSpec(series, deriveLegend(series))
|
||||
: buildSpec(series);
|
||||
expect(legendMatchesData(spec)).toBe(true);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Property 72: 费用对比图三值并呈且差额一致(Req 20.7)。
|
||||
*
|
||||
* 属性陈述:对任意基准报价与风险调整后报价对,费用对比图必同时呈现基准报价金额、
|
||||
* 风险调整后报价金额与二者差额,且所呈现差额恒等于风险调整后报价减基准报价。
|
||||
*
|
||||
* 本测试以智能生成器构造任意有限的 (baseline, riskAdjusted) 报价对(含正负、含
|
||||
* 大小数量级),并从两个层面验证:
|
||||
* - 纯函数层:`quoteDifference(baseline, riskAdjusted)` 恒等于 `riskAdjusted - baseline`。
|
||||
* - 渲染层:`<QuoteCompareChart quote={{...}} />` 同时渲染三个 `data-quote-*` 文本节点
|
||||
* (baseline / risk-adjusted / difference),三者皆出现于 DOM;其中 difference 节点
|
||||
* 承载的原始数值(取自 `data-quote-difference` 属性,非格式化文本)恒等于
|
||||
* `riskAdjusted - baseline`。
|
||||
*
|
||||
* 断言基于 `data-quote-*` 属性的原始数值而非 toLocaleString 后的展示文本,以避开
|
||||
* 千分位/小数位格式化对相等比较的干扰;属性数值经 React 渲染为最短可往返字符串,
|
||||
* `Number(attr)` 可精确还原。每次随机渲染后显式 cleanup,避免 DOM 残留串扰。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 72: 费用对比图三值并呈且差额一致
|
||||
* Validates: Requirements 20.7
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
import { QuoteCompareChart, quoteDifference } from '../QuoteCompareChart.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:任意有限报价金额(覆盖正负与多数量级,排除 NaN/±Infinity)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const quoteAmountArb: fc.Arbitrary<number> = fc
|
||||
.double({
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
min: -1e9,
|
||||
max: 1e9,
|
||||
})
|
||||
// 归一化负零:货币报价无 -0 语义,且 React 将 -0 序列化为属性字符串 "0",
|
||||
// 经 Number(attr) 往返还原为 +0,会与 Object.is(+0, -0)===false 冲突。
|
||||
// 这是测试数据产物而非实现缺陷,故将 -0 折叠为 +0,其余有限数值范围保持不变。
|
||||
.map((n) => (Object.is(n, -0) ? 0 : n));
|
||||
|
||||
describe('Property 72: 费用对比图三值并呈且差额一致', () => {
|
||||
it('纯函数:quoteDifference(baseline, riskAdjusted) 恒等于 riskAdjusted - baseline', () => {
|
||||
fc.assert(
|
||||
fc.property(quoteAmountArb, quoteAmountArb, (baseline, riskAdjusted) => {
|
||||
expect(quoteDifference(baseline, riskAdjusted)).toBe(riskAdjusted - baseline);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('渲染:三值并呈,且 difference 节点的原始数值恒等于 riskAdjusted - baseline', () => {
|
||||
fc.assert(
|
||||
fc.property(quoteAmountArb, quoteAmountArb, (baseline, riskAdjusted) => {
|
||||
try {
|
||||
const { container } = render(
|
||||
<QuoteCompareChart
|
||||
quote={{ baselineQuote: baseline, riskAdjustedQuote: riskAdjusted }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const baselineNode = container.querySelector('[data-quote-baseline]');
|
||||
const riskAdjustedNode = container.querySelector('[data-quote-risk-adjusted]');
|
||||
const differenceNode = container.querySelector('[data-quote-difference]');
|
||||
|
||||
// 三个数值节点必须同时出现于 DOM。
|
||||
expect(baselineNode).not.toBeNull();
|
||||
expect(riskAdjustedNode).not.toBeNull();
|
||||
expect(differenceNode).not.toBeNull();
|
||||
|
||||
// 基准与风险调整后节点承载的原始数值与输入一致。
|
||||
expect(Number(baselineNode?.getAttribute('data-quote-baseline'))).toBe(baseline);
|
||||
expect(
|
||||
Number(riskAdjustedNode?.getAttribute('data-quote-risk-adjusted')),
|
||||
).toBe(riskAdjusted);
|
||||
|
||||
// 所呈现差额恒等于 风险调整后报价 − 基准报价。
|
||||
expect(
|
||||
Number(differenceNode?.getAttribute('data-quote-difference')),
|
||||
).toBe(riskAdjusted - baseline);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* ScoreGauge — Property 71(task 19.9,Req 20.6)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 71: 仪表盘同时呈现总分与分级
|
||||
*
|
||||
* 对任意 Risk_Score([0,100] 整数),风险总分仪表盘必同时呈现该 Risk_Score
|
||||
* 数值与其按分级规则对应的 Risk_Grade,且所呈现 Risk_Grade 恒与分级函数
|
||||
* `classifyGrade` 的输出一致。
|
||||
*
|
||||
* 说明:RTL 仅在 vitest `afterEach` 自动 cleanup(即每个 `it` 之后一次),而本
|
||||
* 属性在单个 `it` 内跑 ≥100 次 render,故每次迭代后显式调用 `cleanup()` 清空
|
||||
* DOM,避免重复节点导致 `getByLabelText` 命中多个元素。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
import { ScoreGauge } from '../ScoreGauge.js';
|
||||
import { classifyGrade, RISK_GRADE_VALUES } from '../riskGrade.js';
|
||||
|
||||
describe('ScoreGauge — Property 71(仪表盘同时呈现总分与分级,Req 20.6)', () => {
|
||||
it('对任意 Risk_Score 同时呈现分值与等级,且等级恒等于 classifyGrade(score)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 0, max: 100 }), (score) => {
|
||||
try {
|
||||
const expectedGrade = classifyGrade(score);
|
||||
|
||||
render(<ScoreGauge score={score} />);
|
||||
|
||||
// 分值数值节点存在,且文本为该分值。
|
||||
const scoreNode = screen.getByLabelText(`风险总分 ${score}`);
|
||||
expect(scoreNode).toBeInTheDocument();
|
||||
expect(scoreNode).toHaveTextContent(String(score));
|
||||
|
||||
// 等级文本节点存在,且等于 classifyGrade(score)。
|
||||
const gradeNode = screen.getByLabelText(`风险分级 ${expectedGrade}`);
|
||||
expect(gradeNode).toBeInTheDocument();
|
||||
expect(gradeNode).toHaveTextContent(expectedGrade);
|
||||
|
||||
// 所呈现等级恒为合法 Risk_Grade 之一。
|
||||
expect(RISK_GRADE_VALUES).toContain(expectedGrade);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Property 70: 图表空态与加载态呈现(通用 Chart 容器,Req 20.4 / 20.5)。
|
||||
*
|
||||
* 属性陈述:对任意 Chart——
|
||||
* - 当其对应数据为空时,必呈现 Empty_State 并提示无可展示数据(非空文案);
|
||||
* - 当其对应数据正在请求或计算中时,必呈现 Loading_State。
|
||||
*
|
||||
* 本测试分两层覆盖:
|
||||
* 1. 纯派生层(`chartStatus`):用 fast-check(≥100 次)覆盖状态优先级——
|
||||
* 加载中恒为 `loading`(无论数据如何);非加载且数据为空恒为 `empty`;
|
||||
* 非加载且数据非空恒为 `ready`。这是 Empty_State / Loading_State 的判定依据。
|
||||
* 2. 渲染层(`ChartContainer` / `renderChart`):对任意「空数据」ChartSpec,
|
||||
* 渲染结果必含 `data-chart-state="empty"` 且其可见文案非空;对任意「加载中」
|
||||
* ChartSpec,渲染结果必含 `data-chart-state="loading"` 且其可见文案非空。
|
||||
* 文案在缺省(emptyMessage/loadingMessage 未提供)时由容器回退为默认非空提示。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 70: 图表空态与加载态呈现
|
||||
* Validates: Requirements 20.4, 20.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { ChartContainer, chartStatus } from '../index.js';
|
||||
import type {
|
||||
ChartPattern,
|
||||
ChartSpec,
|
||||
ChartType,
|
||||
Label,
|
||||
Series,
|
||||
} from '../index.js';
|
||||
import type { ColorToken } from '../../design-system/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 任意图表类型。 */
|
||||
const chartTypeArb: fc.Arbitrary<ChartType> = fc.constantFrom(
|
||||
'Heatmap',
|
||||
'ScoreGauge',
|
||||
'RiskBadge',
|
||||
'TopNRiskChart',
|
||||
'CostBreakdown',
|
||||
'QuoteCompare',
|
||||
'PortfolioCompare',
|
||||
);
|
||||
|
||||
/** 任意图案。 */
|
||||
const patternArb: fc.Arbitrary<ChartPattern> = fc.constantFrom(
|
||||
'solid',
|
||||
'diagonal',
|
||||
'horizontal',
|
||||
'vertical',
|
||||
'grid',
|
||||
'dots',
|
||||
'crosshatch',
|
||||
);
|
||||
|
||||
/** 任意配色令牌(取若干合法令牌即可,本属性不依赖具体取值)。 */
|
||||
const colorTokenArb: fc.Arbitrary<ColorToken> = fc.constantFrom(
|
||||
'color.risk.high',
|
||||
'color.risk.medium',
|
||||
'color.risk.low',
|
||||
'color.brand.primary',
|
||||
);
|
||||
|
||||
/** 任意非空、非纯空白的文本(保证 trim 后仍非空)。 */
|
||||
const nonEmptyTextArb: fc.Arbitrary<string> = fc
|
||||
.string({ minLength: 1 })
|
||||
.map((s) => `文本${s}`);
|
||||
|
||||
/** 任意数据系列(可含 0..n 个数据点)。 */
|
||||
const seriesArb: fc.Arbitrary<Series> = fc.record({
|
||||
id: nonEmptyTextArb,
|
||||
label: nonEmptyTextArb,
|
||||
encoding: fc.record({
|
||||
colorToken: colorTokenArb,
|
||||
textLabel: nonEmptyTextArb,
|
||||
pattern: patternArb,
|
||||
}),
|
||||
points: fc.array(
|
||||
fc.record({ label: nonEmptyTextArb, value: fc.double({ noNaN: true }) }),
|
||||
{ maxLength: 4 },
|
||||
),
|
||||
});
|
||||
|
||||
/** 任意标签集合。 */
|
||||
const labelsArb: fc.Arbitrary<Label[]> = fc.array(
|
||||
fc.record({
|
||||
kind: fc.constantFrom('axis', 'point', 'partition'),
|
||||
text: nonEmptyTextArb,
|
||||
}),
|
||||
{ maxLength: 4 },
|
||||
);
|
||||
|
||||
/** 可选的非空文案(present 且非空,或 absent)。 */
|
||||
const optionalMessageArb: fc.Arbitrary<string | undefined> = fc.option(
|
||||
nonEmptyTextArb,
|
||||
{ nil: undefined },
|
||||
);
|
||||
|
||||
/** 可选标题。 */
|
||||
const optionalTitleArb: fc.Arbitrary<string | undefined> = fc.option(
|
||||
nonEmptyTextArb,
|
||||
{ nil: undefined },
|
||||
);
|
||||
|
||||
/**
|
||||
* 构造 ChartSpec,按 exactOptionalPropertyTypes 要求条件性附加可选字段。
|
||||
*/
|
||||
function makeSpec(parts: {
|
||||
type: ChartType;
|
||||
status: ChartSpec['status'];
|
||||
series: readonly Series[];
|
||||
labels: readonly Label[];
|
||||
title?: string | undefined;
|
||||
emptyMessage?: string | undefined;
|
||||
loadingMessage?: string | undefined;
|
||||
}): ChartSpec {
|
||||
return {
|
||||
type: parts.type,
|
||||
status: parts.status,
|
||||
series: parts.series,
|
||||
labels: parts.labels,
|
||||
...(parts.title !== undefined ? { title: parts.title } : {}),
|
||||
...(parts.emptyMessage !== undefined ? { emptyMessage: parts.emptyMessage } : {}),
|
||||
...(parts.loadingMessage !== undefined
|
||||
? { loadingMessage: parts.loadingMessage }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意「空数据」ChartSpec:
|
||||
* - 变体 A:status === 'empty'(series 任意,含可非空系列)。
|
||||
* - 变体 B:status === 'ready' 但所有系列均无数据点(空数据)。
|
||||
* 二者均应被容器判定为 Empty_State。emptyMessage 取「缺省」或「非空」。
|
||||
*/
|
||||
const emptySpecArb: fc.Arbitrary<ChartSpec> = fc.oneof(
|
||||
fc.record({
|
||||
type: chartTypeArb,
|
||||
series: fc.array(seriesArb, { maxLength: 3 }),
|
||||
labels: labelsArb,
|
||||
title: optionalTitleArb,
|
||||
emptyMessage: optionalMessageArb,
|
||||
}).map((r) =>
|
||||
makeSpec({
|
||||
type: r.type,
|
||||
status: 'empty',
|
||||
series: r.series,
|
||||
labels: r.labels,
|
||||
title: r.title,
|
||||
emptyMessage: r.emptyMessage,
|
||||
}),
|
||||
),
|
||||
fc.record({
|
||||
type: chartTypeArb,
|
||||
// status 'ready' 但每个系列 points 为空 → 无任何数据 → Empty_State。
|
||||
series: fc.array(
|
||||
fc.record({
|
||||
id: nonEmptyTextArb,
|
||||
label: nonEmptyTextArb,
|
||||
encoding: fc.record({
|
||||
colorToken: colorTokenArb,
|
||||
textLabel: nonEmptyTextArb,
|
||||
pattern: patternArb,
|
||||
}),
|
||||
points: fc.constant([] as Series['points']),
|
||||
}),
|
||||
{ maxLength: 3 },
|
||||
),
|
||||
labels: labelsArb,
|
||||
title: optionalTitleArb,
|
||||
emptyMessage: optionalMessageArb,
|
||||
}).map((r) =>
|
||||
makeSpec({
|
||||
type: r.type,
|
||||
status: 'ready',
|
||||
series: r.series,
|
||||
labels: r.labels,
|
||||
title: r.title,
|
||||
emptyMessage: r.emptyMessage,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* 任意「加载中」ChartSpec:status === 'loading',series/labels 任意,
|
||||
* loadingMessage 取「缺省」或「非空」。
|
||||
*/
|
||||
const loadingSpecArb: fc.Arbitrary<ChartSpec> = fc
|
||||
.record({
|
||||
type: chartTypeArb,
|
||||
series: fc.array(seriesArb, { maxLength: 3 }),
|
||||
labels: labelsArb,
|
||||
title: optionalTitleArb,
|
||||
loadingMessage: optionalMessageArb,
|
||||
})
|
||||
.map((r) =>
|
||||
makeSpec({
|
||||
type: r.type,
|
||||
status: 'loading',
|
||||
series: r.series,
|
||||
labels: r.labels,
|
||||
title: r.title,
|
||||
loadingMessage: r.loadingMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 1. 纯派生层:chartStatus 状态优先级(Empty_State / Loading_State 的判定依据)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 70: 图表空态与加载态呈现 — chartStatus 派生 (Req 20.4/20.5)', () => {
|
||||
it('加载中恒为 loading(无论数据为空或非空)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(fc.anything()), (data) => {
|
||||
expect(chartStatus({ loading: true, data })).toBe('loading');
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('非加载且数据为空恒为 empty', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.boolean(), (provideLoadingFalse) => {
|
||||
const status = provideLoadingFalse
|
||||
? chartStatus({ loading: false, data: [] })
|
||||
: chartStatus({ data: [] });
|
||||
expect(status).toBe('empty');
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('非加载且数据非空恒为 ready', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.anything(), { minLength: 1 }),
|
||||
fc.boolean(),
|
||||
(data, provideLoadingFalse) => {
|
||||
const status = provideLoadingFalse
|
||||
? chartStatus({ loading: false, data })
|
||||
: chartStatus({ data });
|
||||
expect(status).toBe('ready');
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 2. 渲染层:ChartContainer 呈现 Empty_State / Loading_State。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 70: 图表空态与加载态呈现 — 渲染 (Req 20.4/20.5)', () => {
|
||||
it('任意空数据 Chart 必呈现 Empty_State 且文案非空,且不呈现 Loading_State', () => {
|
||||
fc.assert(
|
||||
fc.property(emptySpecArb, (spec) => {
|
||||
const { container } = render(<ChartContainer spec={spec} />);
|
||||
try {
|
||||
const empty = container.querySelector('[data-chart-state="empty"]');
|
||||
expect(empty).not.toBeNull();
|
||||
// Empty_State 必为可访问状态区域。
|
||||
expect(empty?.getAttribute('role')).toBe('status');
|
||||
// 必提示「无可展示数据」——文案非空。
|
||||
expect((empty?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
// 提供了 emptyMessage 时,所呈现文案必包含该文案。
|
||||
if (spec.emptyMessage !== undefined) {
|
||||
expect(empty?.textContent ?? '').toContain(spec.emptyMessage);
|
||||
}
|
||||
// 空态不得同时呈现 Loading_State。
|
||||
expect(container.querySelector('[data-chart-state="loading"]')).toBeNull();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('任意加载中 Chart 必呈现 Loading_State 且文案非空,且不呈现 Empty_State', () => {
|
||||
fc.assert(
|
||||
fc.property(loadingSpecArb, (spec) => {
|
||||
const { container } = render(<ChartContainer spec={spec} />);
|
||||
try {
|
||||
const loading = container.querySelector('[data-chart-state="loading"]');
|
||||
expect(loading).not.toBeNull();
|
||||
expect(loading?.getAttribute('role')).toBe('status');
|
||||
expect((loading?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
if (spec.loadingMessage !== undefined) {
|
||||
expect(loading?.textContent ?? '').toContain(spec.loadingMessage);
|
||||
}
|
||||
// 加载态不得同时呈现 Empty_State。
|
||||
expect(container.querySelector('[data-chart-state="empty"]')).toBeNull();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user