'use strict'; /** * AksField 的 group / repeater 行身份稳定性契约。 * * 背景:inline 展开模式用 vuedraggable 渲染行,vuedraggable 的 computeNodes 会把 * `node.key = getKey(element)`,而行 key 由 AksField 的 repeaterDragKey 提供。服务端 * 历史行没有 _id,key 来自以「对象身份」为键的 WeakMap。 * * 关键坑:行数据是 reactive 的。updateRepeaterItem 一旦把行展开成一个新对象,渲染时 * 读到的是那个新对象的又一个 reactive 代理——身份再次改变,WeakMap 认不出它便生成 * 新 key,整行随之重挂载、输入框失焦,表现为「打一个字就退出编辑」。所以必须原地写, * 让行身份跨渲染保持不变。本测试直接跑这两个真实函数来锁定该不变量。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const root = path.resolve(__dirname, '..', '..'); const fieldVue = fs.readFileSync( path.join(root, 'assets', 'admin', 'src', 'components', 'form', 'AksField.vue'), 'utf8' ); /** * 模拟 Vue 的 reactive():每个原始对象只包一次代理并缓存,之后读多少次都返回同一个 * 代理。这正是让 WeakMap 身份在渲染与更新之间保持一致(或不一致)的原因。 */ const proxyCache = new WeakMap(); const proxySet = new WeakSet(); function reactiveOf(value) { if (!value || typeof value !== 'object' || proxySet.has(value)) return value; let proxy = proxyCache.get(value); if (!proxy) { proxy = new Proxy(value, { get(target, key, receiver) { return Reflect.get(target, key, receiver); }, set(target, key, next) { target[key] = next; return true; }, }); proxyCache.set(value, proxy); proxySet.add(proxy); } return proxy; } /** 取一整行声明(WeakMap / 种子计数器都没有花括号体)。 */ function declaration(source, marker) { const start = source.indexOf(marker); assert.notStrictEqual(start, -1, `AksField.vue 缺少声明: ${marker}`); const end = source.indexOf('\n', start); return source.slice(start, end === -1 ? undefined : end); } /** 按花括号配平切出一个顶层函数(这些函数体内没有字符串/注释里的孤立花括号)。 */ function functionBlock(source, marker) { const start = source.indexOf(marker); assert.notStrictEqual(start, -1, `AksField.vue 缺少函数: ${marker}`); const open = source.indexOf('{', start); assert.notStrictEqual(open, -1, `函数没有函数体: ${marker}`); let depth = 0; for (let i = open; i < source.length; i++) { if (source[i] === '{') depth++; else if (source[i] === '}') { depth--; if (depth === 0) return source.slice(start, i + 1); } } assert.fail(`花括号不配平: ${marker}`); } const prelude = [ declaration(fieldVue, 'const dragRowKeys = new WeakMap()'), declaration(fieldVue, 'let dragRowKeySeed = 0'), functionBlock(fieldVue, 'function repeaterDragKey(item)'), functionBlock(fieldVue, 'function updateRepeaterItem(index, fieldKey, value)'), ].join('\n'); /** 用真实的 props / emit 形状驱动这两个函数。 */ function makeField(rows) { const emitted = []; // props.modelValue 是 reactive 视图,正如真实组件收到的那样。 const props = { modelValue: rows.map(reactiveOf) }; const emit = (event, value) => emitted.push({ event, value }); const factory = new Function('props', 'emit', `${prelude}\nreturn { repeaterDragKey, updateRepeaterItem };`); const api = factory(props, emit); // 父组件把 emit 出来的数组写回 reactive 树,读出来时行又被包成代理。 const roundTrip = () => { props.modelValue = emitted[emitted.length - 1].value.map(reactiveOf); }; return { api, props, emitted, roundTrip }; } test('连续输入期间行身份不变,drag key 保持稳定', () => { // 服务端历史行:没有 _id,key 只能由 WeakMap 派生。 const serverRows = [ { points: 500, level: 1, time: 30, unit: 'day' }, { points: 5000, level: 1, time: 'Permanent' }, ]; const { api, props, emitted, roundTrip } = makeField(serverRows); const firstKey = api.repeaterDragKey(props.modelValue[0]); const secondKey = api.repeaterDragKey(props.modelValue[1]); assert.notStrictEqual(firstKey, secondKey, '两行必须各有一个 key'); const firstRow = props.modelValue[0]; // 模拟逐字输入:每次 emit 后父组件把新数组回填 props.modelValue。 let rows = serverRows; for (const value of ['5', '50', '500']) { api.updateRepeaterItem(0, 'points', value); rows = emitted[emitted.length - 1].value; roundTrip(); } assert.strictEqual(rows[0].points, '500', '输入值应写进行内'); // 核心不变量:行对象身份必须跨渲染保持不变,否则 WeakMap 会发新 key。 assert.strictEqual(props.modelValue[0], firstRow, '行身份必须在编辑期间保持不变'); assert.strictEqual( api.repeaterDragKey(props.modelValue[0]), firstKey, '行 key 必须在编辑期间保持稳定,否则整行重挂载、输入框失焦' ); assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), secondKey, '未编辑的行 key 不应变'); }); test('emit 出去的是新数组,父组件仍会重渲染', () => { const { api, props, emitted, roundTrip } = makeField([{ points: 500 }]); const before = props.modelValue; api.updateRepeaterItem(0, 'points', '9'); const after = emitted[emitted.length - 1].value; assert.notStrictEqual(after, before, '必须发出新数组,否则父组件不会重渲染'); roundTrip(); assert.strictEqual(props.modelValue[0].points, '9'); }); test('行不是对象时退化为新建,不抛错', () => { const { api, props, roundTrip } = makeField([]); api.updateRepeaterItem(0, 'points', '7'); roundTrip(); assert.strictEqual(props.modelValue.length, 1); assert.strictEqual(props.modelValue[0].points, '7'); }); test('带 _id 的行仍以 _id 作为 key', () => { const rows = [{ _id: 'rpt_1', points: 100 }, { _id: 'rpt_2', points: 200 }]; const { api, props } = makeField(rows); assert.strictEqual(api.repeaterDragKey(props.modelValue[0]), 'rpt_1'); assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), 'rpt_2'); }); test('新增行获得独立 key,不与既有行冲突', () => { const { api, props } = makeField([{ points: 500 }, { points: 5000 }]); const keys = new Set(props.modelValue.map((row) => api.repeaterDragKey(row))); assert.strictEqual(keys.size, 2); assert.ok(!keys.has(api.repeaterDragKey({ points: 0 })), '新行必须拿到全新 key'); });