The inline group/repeater renderer feeds vuedraggable an item key from a WeakMap, because rows loaded from the server carry no _id. updateRepeaterItem rebuilt every row with a fresh object spread on each keystroke, so the WeakMap missed and minted a brand new key, which remounted the whole row and dropped the input focus after a single character. Inherit the existing drag key onto the replacement row object so the key survives for the whole life of the row. Values that match no option still fall through to the previous coercion, so invalid input keeps reporting a precise error, and rows that already carry an _id continue to key off it. Covered by a new contract test that extracts the real repeaterDragKey and updateRepeaterItem from AksField.vue and drives them through a simulated typing session. Full suite: 661 tests passing.
109 lines
4.3 KiB
JavaScript
109 lines
4.3 KiB
JavaScript
'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');
|
||
});
|