Files
LWLT-AIBOT/tools/independent-room-update.test.mjs
2026-09-17 11:03:19 +08:00

157 lines
8.5 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { loadPageActionRealm } from './page-action-test-realm.mjs';
const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8');
// Isolate room editing from separately covered lookup/readiness logic. The
// real field application, validation, serialization, POST, fresh GET and
// result classification run against a synthetic ERP form and transport.
const adapter = source.replace('assistant.preflightLifecycleOperation = preflightLifecycleOperation;', `
preflightLifecycleOperation = async (operation) => {
const blockers = operationBlockers(operation);
return { status: blockers.length ? 'lifecycle_preflight_blocked' : 'lifecycle_preflight_ready',
blockers, no_erp_write: true, write_attempted: false, preflight: {} };
};
ownershipListProof = async () => ({ matched: true });
assistant.preflightLifecycleOperation = preflightLifecycleOperation;`);
function fixture({ initial = {}, missing = [], persisted = {}, wrongTotal = false, fallbackTotal = false } = {}) {
const requests = [];
let saved = null;
let sumCalls = 0;
const fields = new Map();
const window = { location: new URL('https://erp.invalid/System/Business/orders_add.asp?ddid=123&tdid=456'), setTimeout, clearTimeout };
const document = { body: { innerText: 'LW-SYNTHETIC' }, querySelector: () => form, querySelectorAll: () => [] };
const form = { id: 'ListForm', elements: [], ownerDocument: document };
document.defaultView = window;
for (const [name, value] of Object.entries({
tdid: 456, ddid: 123, frenshu0: 1, frenshu1: 8, frenshu2: 1, frenshu3: 1,
frenshu4: 0, frenshu5: 1, frenshu6: 12, darenshu: 20, xiadanbeizhu: '保留备注', ...initial
})) {
if (missing.includes(name)) continue;
const control = { name, id: name, value: String(value), type: 'text', tagName: 'INPUT',
getAttribute(key) { return key === 'value' ? this.value : null; }, dispatchEvent() {} };
fields.set(name, control); form.elements.push(control);
}
const freshDocument = () => {
const values = { ...saved, ...persisted };
const freshForm = { elements: Object.entries(values).map(([name, value]) => ({ name, id: name,
tagName: 'INPUT', type: 'text', value: String(value), getAttribute: (key) => key === 'value' ? String(value) : null })) };
return { body: { innerText: 'LW-SYNTHETIC' }, querySelector(selector) {
if (selector.includes('#ListForm') || selector.includes('#InfoForm1')) return freshForm;
return freshForm.elements.find((control) => selector.includes(`[name="${control.name}"]`)) || null;
}, querySelectorAll: () => [] };
};
window.Vidy = { CheckMastInput: () => true };
if (!fallbackTotal) window.sum_fang = () => {
sumCalls++;
fields.get('frenshu6').value = wrongTotal ? '999' : String([0, 1, 2, 3, 4, 5].reduce((sum, i) => sum + Number(fields.get(`frenshu${i}`).value || 0), 0));
};
const globals = {
window, document, location: window.location, Event, URL, URLSearchParams, TextEncoder,
crypto: globalThis.crypto, AbortController, Blob, setTimeout, clearTimeout,
DOMParser: class { parseFromString() { return freshDocument(); } },
async fetch(url, options) {
requests.push({ url, ...options });
if (options.method === 'POST') saved = Object.fromEntries(new URLSearchParams(options.body));
return { ok: true, status: 200, text: async () => options.method === 'POST' ? '修改成功' : 'LW-SYNTHETIC' };
}
};
loadPageActionRealm(window, globals, { 'lifecycle-adapters.js': adapter });
const operation = { action: 'order_update_independent', order_nature: 'formal', submit_mode: 'dry_run', data: {
existing_refs: { kind: 'independent_order', identifier: 'LW-SYNTHETIC', tid: '456', ddid: '123', resolved: true, resolution_source: 'erp_unique_match' },
updates: { actions: [
{ target: 'rooms.TWN', operation: 'set', value: 12 },
{ target: 'rooms.DBL', operation: 'set', value: 2 }
] }
} };
return { api: window.LTJTOrderAssistant, plans: window.LTJTOperationPlans, operation, fields,
requests, sumCalls: () => sumCalls, saved: () => saved };
}
test('mixed TWN/DBL updates serialize both fields, preserve other rooms and verify a fresh GET', async () => {
for (const fallbackTotal of [false, true]) {
const f = fixture({ fallbackTotal });
const plan = f.plans.validateOperation(f.operation);
assert.equal(plan.ok, true, JSON.stringify(plan));
assert.doesNotMatch(plan.warnings.join(), /rooms.DBL/);
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_completed', JSON.stringify(result));
assert.equal(f.saved().Act, 'DoInfoJH');
assert.equal(f.saved().frenshu1, '12');
assert.equal(f.saved().frenshu3, '2');
assert.equal(f.saved().frenshu6, '17');
assert.equal(f.saved().frenshu0, '1');
assert.equal(f.saved().frenshu2, '1');
assert.equal(f.saved().frenshu4, '0');
assert.equal(f.saved().frenshu5, '1');
assert.equal(f.saved().xiadanbeizhu, '保留备注');
assert.equal(f.saved().darenshu, '20');
assert.equal(f.requests.filter((r) => r.method === 'POST').length, 1);
assert.equal(f.requests.at(-1).cache, 'no-store');
assert.equal(f.sumCalls(), fallbackTotal ? 0 : 1);
assert.ok(result.requery.checks.some((check) => check.field === 'frenshu3' && check.matched));
}
});
test('DBL zero is an explicit reset and identical room targets never resubmit', async () => {
const f = fixture();
f.operation.data.updates.actions = [{ target: 'rooms.DBL', operation: 'set', value: 0 }];
assert.equal((await f.api.liveSubmitLifecycleOperation(f.operation)).status, 'lifecycle_completed');
assert.equal(f.saved().frenshu3, '0');
assert.equal(f.saved().frenshu1, '8');
assert.equal(f.saved().frenshu6, '11');
const same = fixture({ initial: { frenshu1: 12, frenshu3: 2, frenshu6: 17 } });
assert.match((await same.api.liveSubmitLifecycleOperation(same.operation)).blockers.join(), /lifecycle_update_no_effect/);
assert.equal(same.requests.length, 0);
});
test('a successful POST cannot hide a missing or stale DBL value or total in fresh ERP data', async () => {
for (const persisted of [{ frenshu3: '1' }, { frenshu3: '' }, { frenshu6: '16' }]) {
const f = fixture({ persisted });
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_execution_uncertain');
assert.equal(result.write_attempted, true);
assert.equal(result.requery.matched, false);
assert.match(result.blockers.join(), /write_after_requery_mismatch/);
assert.equal(f.requests.filter((r) => r.method === 'POST').length, 1);
}
});
test('missing controls, invalid existing rooms and incorrect native totals stop before saving', async () => {
for (const options of [{ missing: ['frenshu3'] }, { missing: ['frenshu6'] }, { initial: { frenshu2: '-1' } }, { wrongTotal: true }]) {
const f = fixture(options);
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_apply_blocked', JSON.stringify(result));
assert.equal(result.no_erp_write, true);
assert.equal(f.requests.length, 0);
}
});
test('invalid DBL targets and unimplemented fields block the whole mixed operation', async () => {
for (const item of [
{ target: 'rooms.DBL', operation: 'set', value: -1 },
{ target: 'rooms.DBL', operation: 'set', value: 1.5 },
{ target: 'rooms.DBL', operation: 'delta', value: 1 },
{ target: 'rooms.TRP', operation: 'set', value: 2 }
]) {
const f = fixture();
f.operation.data.updates.actions[1] = item;
const result = await f.api.liveSubmitLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_preflight_blocked', JSON.stringify(result));
assert.equal(f.fields.get('frenshu1').value, '8');
assert.equal(f.requests.length, 0);
}
});
test('read-only reconciliation verifies the requested DBL field and never posts', async () => {
const f = fixture({ persisted: { frenshu1: '12', frenshu3: '2' } });
const result = await f.api.verifyLifecycleOperation(f.operation);
assert.equal(result.status, 'lifecycle_requery_matched', JSON.stringify(result));
assert.ok(result.requery.checks.some((check) => check.field === 'frenshu3' && check.matched));
assert.equal(f.requests.length, 1);
assert.equal(f.requests[0].method, undefined);
assert.equal(f.requests[0].cache, 'no-store');
});