外包风险评估系统:领域引擎+前端+服务端持久化与生产部署

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
This commit is contained in:
freedakgmail
2026-06-13 01:06:39 +08:00
commit c670b9e454
404 changed files with 61820 additions and 0 deletions
@@ -0,0 +1,195 @@
/**
* 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');
});
});