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'); });