'use strict'; /** * AksField 的 group / repeater 行 key 稳定性契约。 * * 背景:inline 展开模式用 vuedraggable 渲染行,vuedraggable 的 computeNodes 会把 * `node.key = getKey(element)`,而行 key 由 AksField 的 repeaterDragKey 提供。 * 服务端历史行没有 _id,key 来自 WeakMap;而 updateRepeaterItem 每次输入都会把行 * 对象展开成新引用,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' ); /** 取一整行声明(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 = []; const props = { modelValue: rows }; const emit = (event, value) => emitted.push({ event, value }); const factory = new Function('props', 'emit', `${prelude}\nreturn { repeaterDragKey, updateRepeaterItem };`); const api = factory(props, emit); return { api, props, emitted }; } test('group 行在连续输入期间保持同一个 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 } = makeField(serverRows); const firstKey = api.repeaterDragKey(serverRows[0]); const secondKey = api.repeaterDragKey(serverRows[1]); assert.notStrictEqual(firstKey, secondKey, '两行必须各有一个 key'); // 模拟逐字输入:每次 emit 后父组件把新数组回填 props.modelValue。 let rows = serverRows; for (const value of ['5', '50', '500']) { api.updateRepeaterItem(0, 'points', value); rows = emitted[emitted.length - 1].value; props.modelValue = rows; } assert.strictEqual(rows[0].points, '500', '输入值应写进行内'); assert.strictEqual( api.repeaterDragKey(rows[0]), firstKey, '行 key 必须在编辑期间保持稳定,否则整行重挂载、输入框失焦' ); assert.strictEqual(api.repeaterDragKey(rows[1]), secondKey, '未编辑的行 key 不应变'); }); test('带 _id 的行仍以 _id 作为 key', () => { const rows = [{ _id: 'rpt_1', points: 100 }, { _id: 'rpt_2', points: 200 }]; const { api } = makeField(rows); assert.strictEqual(api.repeaterDragKey(rows[0]), 'rpt_1'); assert.strictEqual(api.repeaterDragKey(rows[1]), 'rpt_2'); }); test('新增行获得独立 key,不与既有行冲突', () => { const serverRows = [{ points: 500 }, { points: 5000 }]; const { api } = makeField(serverRows); const keys = new Set(serverRows.map((row) => api.repeaterDragKey(row))); assert.strictEqual(keys.size, 2); const added = { points: 0 }; assert.ok(!keys.has(api.repeaterDragKey(added)), '新行必须拿到全新 key'); });