Files
RiskAgent/web/src/__tests__/uiStateTransition.property67.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

196 lines
7.9 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 67: UI 状态转换保留已录入数据 的属性化测试
* (跨表现层 Design_System / Wizard / 响应式布局,Req 19.7 / 21.2 / 22.4)。
*
* 属性陈述:在任意 UI 状态转换序列下——
* 1. 切换 ThemeLight ↔ Darkdesign-system:仅替换 Color_Token 取值,Req 19.7);
* 2. 切换录入方式(对话式 ↔ 表单,wizard`switchInputMode`Req 21.2);
* 3. 跨断点变更布局(viewport`onViewportChange`Req 22.4)——
* 页面已录入数据(`enteredData`)既不丢失也不被篡改:转换前后逐键一致、
* 键集合不变、取值字节级相等。
*
* 本测试以智能生成器构造:
* - 任意已录入数据(字符串键 → 字符串/数值/布尔/null/嵌套结构的不透明值);
* - 任意长度的三类转换混合序列(每步随机选定目标主题/录入方式/视口宽度);
* 然后将序列归约施加于统一 UI 状态,断言末态 `enteredData` 与初始快照
* 深度相等(toStrictEqual),且实现以引用保留(=== 恒等)未发生任何拷贝/篡改。
*
* 转换 1/2/3 分别复用真实实现 `buildThemeCssVariables` / `switchInputMode`
* / `onViewportChange`,不对其行为做任何模拟。
*
* Feature: outsourcing-risk-assessment, Property 67: UI 状态转换保留已录入数据
* Validates: Requirements 19.7, 21.2, 22.4
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import type { Theme } from '../design-system/index.js';
import { buildThemeCssVariables } from '../design-system/index.js';
import {
createSession,
switchInputMode,
type InputMode,
type WizardSession,
} from '../wizard/index.js';
import { createWizard } from '../wizard/index.js';
import {
onViewportChange,
selectLayout,
type Layout,
type UIState,
} from '../viewport/index.js';
/** 统一 UI 状态:承载当前主题、录入会话(含录入方式与已录入数据)与布局。 */
interface CombinedUIState {
readonly theme: Theme;
readonly session: WizardSession;
readonly layout: Layout;
}
/** 三类 UI 状态转换命令(与三条受验需求一一对应)。 */
type Transition =
| { readonly kind: 'theme'; readonly theme: Theme }
| { readonly kind: 'inputMode'; readonly mode: InputMode }
| { readonly kind: 'viewport'; readonly width: number };
/**
* 施加单步转换(纯归约)。三类转换均**不触碰** `enteredData`
* - theme:复用 `buildThemeCssVariables`(仅依赖主题、与录入数据无关),仅换主题字段;
* - inputMode:复用 `switchInputMode`,按引用原样保留 `enteredData`
* - viewport:复用 `onViewportChange`,按引用原样保留 `enteredData`。
*/
function applyTransition(state: CombinedUIState, t: Transition): CombinedUIState {
switch (t.kind) {
case 'theme': {
// 模拟 Theme Provider 行为:切换主题仅重算 CSS 变量取值,不接触录入数据。
void buildThemeCssVariables(t.theme);
return { ...state, theme: t.theme };
}
case 'inputMode': {
return { ...state, session: switchInputMode(state.session, t.mode) };
}
case 'viewport': {
const ui: UIState = { layout: state.layout, enteredData: state.session.enteredData };
const next = onViewportChange(ui, t.width);
// 将布局变更回写,并把(应为同引用的)录入数据回填到会话。
return {
...state,
layout: next.layout,
session: { ...state.session, enteredData: next.enteredData },
};
}
}
}
/** 已录入数据值:覆盖常见标量、null 与浅层嵌套结构。 */
const enteredValueArb: fc.Arbitrary<unknown> = fc.oneof(
fc.string(),
fc.integer(),
fc.double({ noNaN: true, noDefaultInfinity: true }),
fc.boolean(),
fc.constant(null),
fc.array(fc.string(), { maxLength: 4 }),
fc.dictionary(fc.string(), fc.string(), { maxKeys: 4 }),
);
/** 任意已录入数据:字符串键 → 不透明值的映射。 */
const enteredDataArb: fc.Arbitrary<Readonly<Record<string, unknown>>> = fc.dictionary(
fc.string(),
enteredValueArb,
{ maxKeys: 8 },
);
const themeArb: fc.Arbitrary<Theme> = fc.constantFrom('Light', 'Dark');
const inputModeArb: fc.Arbitrary<InputMode> = fc.constantFrom('对话式', '表单');
/** 视口宽度:跨三段断点并显式纳入边界,确保布局确实发生跨断点变更。 */
const widthArb: fc.Arbitrary<number> = fc.oneof(
fc.integer({ min: -100, max: 3000 }),
fc.constantFrom(320, 767, 768, 1024, 1279, 1280, 1920),
);
/** 单步转换生成器:三类等概率。 */
const transitionArb: fc.Arbitrary<Transition> = fc.oneof(
themeArb.map((theme) => ({ kind: 'theme', theme }) as const),
inputModeArb.map((mode) => ({ kind: 'inputMode', mode }) as const),
widthArb.map((width) => ({ kind: 'viewport', width }) as const),
);
/** 初始统一状态构造器(步骤序列对本属性无影响,置最小占位)。 */
function makeInitialState(
enteredData: Readonly<Record<string, unknown>>,
theme: Theme,
mode: InputMode,
width: number,
): CombinedUIState {
const wizard = createWizard([{ id: 'step-1', title: '步骤一' }]);
return {
theme,
session: createSession(wizard, mode, enteredData),
layout: selectLayout(width),
};
}
describe('Property 67: UI 状态转换保留已录入数据 (Req 19.7, 21.2, 22.4)', () => {
it('任意主题/录入方式/布局转换序列后,已录入数据逐键一致、不丢失不篡改', () => {
fc.assert(
fc.property(
enteredDataArb,
themeArb,
inputModeArb,
widthArb,
fc.array(transitionArb, { maxLength: 30 }),
(enteredData, theme0, mode0, width0, transitions) => {
const initial = makeInitialState(enteredData, theme0, mode0, width0);
// 初始已录入数据的独立深拷贝快照,作为「不丢失不篡改」的判定基准。
const snapshot = structuredClone(initial.session.enteredData);
const finalState = transitions.reduce(applyTransition, initial);
// 末态录入数据与初始快照逐键深度相等(既未丢键,也未篡改任何取值)。
expect(finalState.session.enteredData).toStrictEqual(snapshot);
// 键集合不变。
expect(Object.keys(finalState.session.enteredData).sort()).toStrictEqual(
Object.keys(snapshot).sort(),
);
},
),
{ numRuns: 100 },
);
});
it('转换序列以引用保留已录入数据(不复制、不重建)', () => {
fc.assert(
fc.property(
enteredDataArb,
fc.array(transitionArb, { maxLength: 30 }),
(enteredData, transitions) => {
const initial = makeInitialState(enteredData, 'Light', '对话式', 1280);
const originalRef = initial.session.enteredData;
const finalState = transitions.reduce(applyTransition, initial);
// 三类转换均不触碰 enteredData,引用恒等可证明零拷贝/零篡改。
expect(finalState.session.enteredData).toBe(originalRef);
},
),
{ numRuns: 100 },
);
});
it('单独施加任一类转换均保留已录入数据(逐类回归)', () => {
const enteredData = { : '外包项目A', 金额: 120000, 跨境: true, 备注: null };
const initial = makeInitialState(enteredData, 'Light', '对话式', 1280);
const snapshot = structuredClone(enteredData);
const afterTheme = applyTransition(initial, { kind: 'theme', theme: 'Dark' });
expect(afterTheme.session.enteredData).toStrictEqual(snapshot);
const afterMode = applyTransition(initial, { kind: 'inputMode', mode: '表单' });
expect(afterMode.session.enteredData).toStrictEqual(snapshot);
const afterViewport = applyTransition(initial, { kind: 'viewport', width: 375 });
expect(afterViewport.session.enteredData).toStrictEqual(snapshot);
expect(afterViewport.layout).toBe('MobileLayout');
});
});