/** * 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 = 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>> = fc.dictionary( fc.string(), enteredValueArb, { maxKeys: 8 }, ); const themeArb: fc.Arbitrary = fc.constantFrom('Light', 'Dark'); const inputModeArb: fc.Arbitrary = fc.constantFrom('对话式', '表单'); /** 视口宽度:跨三段断点并显式纳入边界,确保布局确实发生跨断点变更。 */ const widthArb: fc.Arbitrary = fc.oneof( fc.integer({ min: -100, max: 3000 }), fc.constantFrom(320, 767, 768, 1024, 1279, 1280, 1920), ); /** 单步转换生成器:三类等概率。 */ const transitionArb: fc.Arbitrary = 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>, 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'); }); });