Files
LWLT-AIBOT/tools/arrangement-linkage-stability.test.mjs
2026-09-14 18:24:25 +08:00

113 lines
6.1 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8')
.replace('assistant.preflightLifecycleOperation = preflightLifecycleOperation;',
'assistant.testWaitForStable = waitForStable; assistant.preflightLifecycleOperation = preflightLifecycleOperation;');
const background = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
function fixture({ delays = [], onTick = () => {} } = {}) {
let now = 0;
let ticks = 0;
const control = { name: 'jine1', id: 'jine1', type: 'text', value: 'private-value', isConnected: true };
const document = { querySelectorAll: () => [], readyState: 'complete' };
const window = { document, LTJTOrderAssistant: {}, jQuery: { active: 0 }, performance: { now: () => now },
setTimeout(callback, ms) { now += delays[ticks] ?? ms; onTick({ now, ticks: ++ticks, control, form, window }); callback(); } };
window.parent = window; document.defaultView = window;
const form = { elements: [control], isConnected: true, ownerDocument: document };
vm.runInNewContext(source, { window, Date: class extends Date { static now() { return now; } }, URL, URLSearchParams });
const diagnostic = {};
return { control, form, window, diagnostic, now: () => now,
run: () => window.LTJTOrderAssistant.testWaitForStable(form, ['jine1', 'optional1'], 6000, diagnostic) };
}
test('ordinary linkage requires multiple observations over 1.5 seconds and redacts values', async () => {
const f = fixture();
assert.equal(await f.run(), true);
assert.equal(f.now(), 1500);
assert.ok(f.diagnostic.sample_count >= 3);
assert.equal(f.diagnostic.ready, true);
assert.equal(f.diagnostic.reason, 'stable');
assert.equal(JSON.stringify(f.diagnostic).includes('private-value'), false);
});
test('one delayed callback restarts observation instead of failing or counting the blind interval', async () => {
const f = fixture({ delays: [6000] });
assert.equal(await f.run(), true);
assert.equal(f.now(), 7500);
assert.equal(f.diagnostic.delayed_sample_count, 1);
assert.equal(f.diagnostic.max_sample_gap_ms, 6000);
assert.equal(f.diagnostic.stable_ms, 1500);
});
test('one-second sampling still proves stability; persistent long gaps fail boundedly', async () => {
const slower = fixture({ delays: [1000, 1000] });
assert.equal(await slower.run(), true);
assert.equal(slower.now(), 2000);
const paused = fixture({ delays: Array(6).fill(6000) });
assert.equal(await paused.run(), false);
assert.equal(paused.now(), 30000);
assert.equal(paused.diagnostic.reason, 'sampling_delayed');
assert.equal(paused.diagnostic.stable_ms, 0);
});
test('continuous value changes fail and report field names rather than their values', async () => {
const f = fixture({ onTick: ({ control, ticks }) => { control.value = `secret-${ticks}`; } });
assert.equal(await f.run(), false);
assert.equal(f.now(), 6000);
assert.equal(f.diagnostic.reason, 'fields_changing');
assert.deepEqual([...f.diagnostic.changed_fields], ['jine1']);
assert.equal(JSON.stringify(f.diagnostic).includes('secret-'), false);
});
test('the last allowed sample is checked; a late change restarts stability', async () => {
const f = fixture({ onTick: ({ now, control }) => { if (now <= 4500) control.value = String(now); } });
assert.equal(await f.run(), true);
assert.equal(f.now(), 6000);
const late = fixture({ delays: [6000], onTick: ({ now, control }) => { if (now === 7250) control.value = 'late'; } });
assert.equal(await late.run(), true);
assert.equal(late.now(), 8750);
});
test('detached form, missing/replaced controls and pending native Ajax cannot pass', async () => {
for (const mutate of [
({ form }) => { form.isConnected = false; },
({ form }) => { form.elements = []; },
({ form, control }) => { form.elements = [{ ...control }]; },
({ control }) => { control.isConnected = false; }
]) {
const f = fixture({ onTick: mutate });
assert.equal(await f.run(), false);
assert.match(f.diagnostic.reason, /form_detached|control_unavailable/);
}
const busy = fixture(); busy.window.jQuery.active = 1;
assert.equal(await busy.run(), false);
assert.equal(busy.diagnostic.reason, 'native_busy');
const finishes = fixture({ onTick: ({ now, window }) => { if (now >= 1000) window.jQuery.active = 0; } });
finishes.window.jQuery.active = 1;
assert.equal(await finishes.run(), true);
assert.equal(finishes.now(), 2500);
const late = fixture({ onTick: ({ now, window }) => { if (now >= 5500) window.jQuery.active = 0; } });
late.window.jQuery.active = 1;
assert.equal(await late.run(), false);
assert.equal(late.diagnostic.reason, 'native_busy', 'late Ajax completion is not evidence of oscillating field values');
assert.equal(late.diagnostic.native_busy, false);
assert.ok(late.diagnostic.native_busy_sample_count > 0);
});
test('background reports the observed failure reason without classifying all failures as changing fields', () => {
const start = background.indexOf('function arrangementLinkageFailureMessage(');
const end = background.indexOf('\nfunction lifecycleLiveBusinessFailure(', start);
const message = vm.runInNewContext(`${background.slice(start, end)}; arrangementLinkageFailureMessage;`);
for (const [reason, expected] of [['sampling_delayed', '检测持续延迟'], ['fields_changing', '联动字段在观察期间未稳定'],
['native_busy', '联动请求'], ['form_detached', '关闭或刷新'], ['control_unavailable', '控件']]) {
const result = message({ blockers: ['arrangement_create_linked_fields_not_stable'], preflight: { arrangement_linkage: { reason } } });
assert.ok(result.includes(expected)); assert.ok(result.includes('未提交 ERP 保存'));
}
assert.equal(message({ blockers: ['other_error'] }), '');
assert.equal(message({ blockers: ['arrangement_create_linked_fields_not_stable'] }), '');
assert.match(background, /failure_message: linkageFailureMessage/);
});