指令问题修复

This commit is contained in:
andy
2026-09-17 11:03:19 +08:00
parent a3505f3921
commit 6eeaee07d4
40 changed files with 395 additions and 59 deletions

View File

@@ -0,0 +1,156 @@
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');
});

View File

@@ -1374,7 +1374,7 @@ test('passenger explicit server success is terminal without a post-save row requ
assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/);
assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/);
assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.186'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.187'/);
});
test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => {
@@ -1584,7 +1584,7 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett
assert.match(background, /arrangement_resource_candidate_data_missing/);
});
test('extension 0.5.186 keeps reload dormant unless its durable execution state is fully idle', async () => {
test('extension 0.5.187 keeps reload dormant unless its durable execution state is fully idle', async () => {
const [background, bridge] = await Promise.all([
readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'),
readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8')
@@ -1805,6 +1805,7 @@ test('update actions expose validated writes and preserve unvalidated fields for
{ target: 'twin_room_count', operation: 'set', value: 9 },
{ target: 'rooms.SGL', operation: 'set', value: 3 },
{ target: 'rooms.TWN', operation: 'set', value: 2 },
{ target: 'rooms.DBL', operation: 'set', value: 2 },
{ target: 'pax.adult', operation: 'set', value: 8 },
{ target: 'pax.child_bed', operation: 'set', value: 1 },
{ target: 'pax.child_no_bed', operation: 'set', value: 1 },
@@ -1827,6 +1828,7 @@ test('update actions expose validated writes and preserve unvalidated fields for
const adapter = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8');
assert.match(adapter, /'rooms\.SGL': 'frenshu0'/);
assert.match(adapter, /'rooms\.TWN': 'frenshu1'/);
assert.match(adapter, /'rooms\.DBL': 'frenshu3'/);
assert.match(adapter, /ownerWindow\?\.sum_fang/);
assert.match(adapter, /frenshu6/);
assert.match(adapter, /independent_room_total_mismatch/);
@@ -1835,7 +1837,7 @@ test('update actions expose validated writes and preserve unvalidated fields for
assert.match(adapter, /'pax\.child_no_bed': 'ertrenshu'/);
assert.match(adapter, /'pax\.leader': 'quanrenshu'/);
assert.match(adapter, /independent_room_count_invalid/);
assert.doesNotMatch(plans.validateOperation(independent).warnings.join('\n'), /rooms\.SGL|rooms\.TWN/);
assert.doesNotMatch(plans.validateOperation(independent).warnings.join('\n'), /rooms\.SGL|rooms\.TWN|rooms\.DBL/);
assert.doesNotMatch(plans.validateOperation(independent).warnings.join('\n'), /pax\.adult|pax\.child_bed|pax\.child_no_bed|pax\.leader/);
const negativeRoomCount = structuredClone(independent);
@@ -2035,7 +2037,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro
for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; '));
const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8'));
assert.equal(mapping.current_extension_version, '0.5.186');
assert.equal(mapping.current_extension_version, '0.5.187');
assert.equal(
mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'],
'ertrenshu'
@@ -2419,10 +2421,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov
assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/);
assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/);
assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/);
assert.equal(extensionManifest.version, '0.5.186');
assert.match(inpage, /version: '0\.5\.186'/);
assert.match(teamBatchInpage, /version: '0\.5\.186'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.186'/);
assert.equal(extensionManifest.version, '0.5.187');
assert.match(inpage, /version: '0\.5\.187'/);
assert.match(teamBatchInpage, /version: '0\.5\.187'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.187'/);
assert.match(inpage, /function strictIsoDate\(value\)/);
assert.match(inpage, /const startDate = strictIsoDate\(current\('riqi'\)\)/);
assert.match(inpage, /const endDate = strictIsoDate\(current\('riqis'\)\)/);

View File

@@ -304,7 +304,7 @@ test('single-source and generated-output boundaries remain explicit', async () =
const diagnoseServer = await readFile(path.join(ROOT, 'infra/diagnose-server.sh'), 'utf8');
const gitignore = await readFile(path.join(ROOT, '.gitignore'), 'utf8');
assert.match(template, /^交付版本:0\.5\.135\s*$/mu);
assert.match(template, /^交付版本:0\.5\.136\s*$/mu);
assert.match(template, /^适用范围:当前 23 条业务路由,按 19 项运营场景分组;执行能力以生命周期发布门槛为准。\s*$/mu);
assert.match(template, /^### 5\. 散拼团多个新增子单\s*$/mu);
assert.doesNotMatch(template, /^事实基线:/mu);