Files
LWLT-AIBOT/tools/native-list-readiness.test.mjs
2026-09-15 11:34:59 +08:00

165 lines
8.7 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
const testSource = source.replace(' resolveNativeListSearchValues,', ' testSearch: searchMainPage,\n resolveNativeListSearchValues,');
function fixture({ framed = true, tick = () => {} } = {}) {
let now = 0;
const calls = [];
const state = { current: null, framePresent: framed, inaccessible: false };
function list({ path = '/System/Business/orders.asp', complete = true, loader = true, button = true, disabled = false } = {}) {
const listeners = new Map();
const fields = new Map();
const search = { isConnected: true, disabled };
for (const name of ['S_chufariqi', 'S_chufarizhi', 'S_tuanxuhao', 'S_kehuming', 'S_chanpinming', 'S_youkexinxi', 'S_lingdui', 'S_zhuangtai']) fields.set(name, { value: '' });
const doc = {
location: { pathname: path, href: `https://erp.invalid${path}` }, readyState: complete ? 'complete' : 'loading',
querySelector(selector) {
if (selector.startsWith('#SearchButton')) return button ? search : null;
return fields.get(selector.match(/^#([^,]+)/)?.[1]) || null;
},
querySelectorAll: () => [],
fields, search
};
const events = { on: (name, callback) => listeners.set(name.split('.')[0], callback), off: () => listeners.clear() };
const jq = () => events;
jq.ajax = () => {};
jq.active = 0;
doc.defaultView = { document: doc, jQuery: jq };
if (loader) doc.defaultView.AjaxLoadData = () => {
calls.push({ document: doc, identifier: fields.get('S_tuanxuhao').value });
const xhr = { status: 200 };
const data = new URLSearchParams({ Act: 'PrintGridList', ...Object.fromEntries([...fields].map(([name, field]) => [name, field.value])) });
listeners.get('ajaxSend')?.({}, xhr, { url: '/System/Dat/order.asp', data: data.toString() });
listeners.get('ajaxComplete')?.({}, xhr);
};
return doc;
}
const initial = list();
state.current = initial;
const frame = { get contentDocument() { if (state.inaccessible) throw new Error('fixture access denied'); return state.current; } };
const root = framed ? {
location: { pathname: '/System/Mainlt.asp' },
getElementById: () => state.framePresent ? frame : null,
querySelector: () => null
} : initial;
if (!framed) { root.getElementById = () => null; }
const window = { document: root, location: root.location };
vm.runInNewContext(testSource, {
window, document: root, location: root.location, URL, URLSearchParams,
Date: class extends Date { static now() { return now; } },
performance: { now: () => now },
setTimeout(callback, delay) { now += delay; tick({ now, state, initial }); callback(); }
});
return { api: window.LTJTOrderAssistant, list, initial, root, state, calls, elapsed: () => now,
run: () => window.LTJTOrderAssistant.testSearch(initial, { S_tuanxuhao: 'TEST-LIST-001', S_zhuangtai: '' }) };
}
test('ready native list searches once and retains exact request evidence', async () => {
for (const framed of [true, false]) {
const f = fixture({ framed });
const result = await f.run();
assert.equal(result, f.initial);
assert.equal(f.calls.length, 1);
assert.equal(f.calls[0].identifier, 'TEST-LIST-001');
assert.equal(result.__ltjtNativeListSearchEvidence.completed, true);
}
});
test('a loading same-path Document replacement is reacquired before searching', async () => {
let replacement;
const f = fixture({ tick({ now, state }) { if (now >= 500) state.current = replacement; } });
f.initial.readyState = 'loading';
replacement = f.list();
const result = await f.run();
assert.equal(result, replacement);
assert.equal(f.calls.length, 1);
assert.equal(f.calls[0].document, replacement);
assert.equal(f.initial.fields.get('S_tuanxuhao').value, '', 'detached document must not be filled');
assert.equal(result.__ltjtNativeListSearchEvidence.readiness.document_replacements, 1);
});
test('replaced same-path document can finish loading later without using its predecessor', async () => {
let replacement;
const f = fixture({ tick({ now, state }) {
if (now >= 100) state.current = replacement;
if (now >= 1200) replacement.readyState = 'complete';
} });
f.initial.readyState = 'loading';
replacement = f.list({ complete: false });
assert.equal(await f.run(), replacement);
assert.equal(f.elapsed(), 1200);
});
for (const [name, change, missing] of [
['unfinished page', f => { f.initial.readyState = 'loading'; }, 'document_complete'],
['missing loader', f => { delete f.initial.defaultView.AjaxLoadData; }, 'loader_ready'],
['disabled button', f => { f.initial.search.disabled = true; }, 'search_button_ready'],
['detached button', f => { f.initial.search.isConnected = false; }, 'search_button_ready'],
['wrong page', f => { f.state.current = f.list({ path: '/System/login.asp' }); }, 'path_matched'],
['missing document', f => { f.state.current = null; }, 'document_present'],
['removed frame', f => { f.state.framePresent = false; }, 'document_present'],
['inaccessible frame', f => { f.state.inaccessible = true; }, 'document_accessible']
]) test(`native readiness stops without searching: ${name}`, async () => {
const f = fixture(); change(f);
await assert.rejects(f.run(), error => {
assert.match(error.message, /native_list_search_not_ready/);
assert.ok(error.message.includes(missing));
assert.equal(error.nativeListReadiness[missing], false);
assert.ok(!JSON.stringify(error.nativeListReadiness).includes('TEST-LIST'));
return true;
});
assert.equal(f.calls.length, 0);
assert.equal(f.elapsed(), 15000);
});
test('document replacement during prior Ajax wait fails closed without searching either page', async () => {
const f = fixture({ tick({ state, initial }) { state.current = f.list(); initial.defaultView.jQuery.active = 0; } });
f.initial.defaultView.jQuery.active = 1;
await assert.rejects(f.run(), /native_list_search_document_changed/);
assert.equal(f.calls.length, 0);
});
test('missing button is diagnosed separately from a ready document and loader', async () => {
const f = fixture(); f.state.current = f.list({ button: false });
await assert.rejects(f.run(), error => error.nativeListReadiness.search_button_ready === false
&& error.nativeListReadiness.document_complete === true && error.nativeListReadiness.loader_ready === true);
assert.equal(f.calls.length, 0);
});
test('a page change during the native search is rejected instead of using stale result rows', async () => {
const f = fixture(); const load = f.initial.defaultView.AjaxLoadData;
f.initial.defaultView.AjaxLoadData = () => { load(); f.state.current = f.list(); };
await assert.rejects(f.run(), /native_list_search_document_changed/);
assert.equal(f.calls.length, 1, 'only the original read-only search is issued; no retry');
assert.equal(f.initial.__ltjtNativeListSearchEvidence, undefined);
});
test('the real resolution failure carries explicit no-write proof and value-free readiness details', async () => {
const f = fixture(); delete f.initial.defaultView.AjaxLoadData;
const report = await f.api.resolveLifecycleOperation({ action: 'arrangement_hotel', data: { existing_refs: { identifier: 'TEST-LIST-001' } } });
assert.equal(report.status, 'erp_resolution_blocked');
assert.equal(report.no_erp_write, true);
assert.equal(report.write_attempted, false);
assert.equal(report.native_request, null);
assert.equal(report.preflight.stage, 'erp_readonly_resolution');
assert.equal(report.preflight.native_list_readiness.loader_ready, false);
assert.equal(f.calls.length, 0);
});
test('the worker describes readiness failures accurately while preserving not-found errors', () => {
const worker = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
const fn = worker.slice(worker.indexOf('function erpResolutionFailure('), worker.indexOf('function lifecycleRouteFailure('));
const context = vm.createContext({}); vm.runInContext(fn, context);
for (const cause of ['native_list_search_not_ready', 'native_list_search_not_ready:loader_ready', 'native_list_search_document_changed']) {
const failure = context.erpResolutionFailure({}, { blockers: [`erp_resolution_navigation_failed:${cause}`] });
assert.equal(failure.errorCode, 'erp_list_search_not_ready');
assert.match(failure.message, /未写入 ERP/);
assert.doesNotMatch(failure.message, /确认 ERP 已登录/);
}
assert.equal(context.erpResolutionFailure({}, { blockers: ['erp_resolution_candidate_count:0'] }).errorCode, 'erp_target_not_found');
});