外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Property 67: UI 状态转换保留已录入数据 的属性化测试
|
||||
* (跨表现层 Design_System / Wizard / 响应式布局,Req 19.7 / 21.2 / 22.4)。
|
||||
*
|
||||
* 属性陈述:在任意 UI 状态转换序列下——
|
||||
* 1. 切换 Theme(Light ↔ Dark,design-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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user