import assert from 'node:assert/strict'; import test from 'node:test'; import { loadPageActionRealm } from './page-action-test-realm.mjs'; // Exercise the public preflight with synthetic ERP lookup responses, native // product linkage and submit interception. No real browser or ERP is used. function fixture({ products = ['单订房 (测试市场)'], query = '单订房(测试市场)', customer = '测试客户', productStatus = 200, incompleteLinkage = false } = {}) { const requests = []; const linkedNames = []; let submittedBody = ''; const fields = new Map(); const window = { location: new URL('https://erp.invalid/System/Business/orders_add.asp'), setTimeout: (...args) => setTimeout(...args).unref(), clearTimeout }; const document = { defaultView: window, querySelector: (selector) => selector === '#ListForm' ? form : null, querySelectorAll: () => [] }; const form = { elements: [], ownerDocument: document, querySelectorAll: () => [updateButton], querySelector: () => null }; const values = { fabudanwei: '测试单位', zutuanshe: '', zutuansheid: '', lianxiren: '', zutuanshegzr: '', bizhong: 'CNY', chufa_ri: '', chanpinming: '', TianShu: '', zhuanxianming: '', tuanxuhao1: '', tuanxuhao2: '', gendanren: '测试员工', xiaoshouren: '测试员工', darenshu: '', xiaorenshu: '', ertrenshu: '', yingrenshu: '', quanrenshu: '', frenshu0: '', frenshu1: '', frenshu2: '', frenshu3: '', frenshu4: '', frenshu5: '', frenshu6: '', danzhuangtai: '', yaobeian: '', ys_danjia0: '', ys_danwei0: '', ys_danweiid0: '', ys_bizhong0: '', ys_shuliang0: '', ys_jine0: '' }; for (let i = 0; i < 800; i++) values[`unused${i}`] = ''; for (const [name, value] of Object.entries(values)) { const field = { name, value, tagName: 'INPUT', type: 'text', dispatchEvent() {} }; fields.set(name, field); form.elements.push(field); form.elements[name] = field; } const serialize = () => new URLSearchParams(form.elements.map((field) => [field.name, field.value])).toString(); const jq = () => ({ serialize }); jq.ajax = (config) => { requests.push({ native: true, url: config.url, data: config.data }); assert.ok(config.url.includes('Act=GetProduct'), 'a real submit must never escape the preflight guard'); config.success?.('synthetic product response', 'success'); config.complete?.(); }; window.jQuery = jq; window.Find_product = () => { const selectedName = fields.get('chanpinming').value; linkedNames.push(selectedName); jq.ajax({ url: `../dat/AjaxPublicFun.asp?Act=GetProduct&cpm=${encodeURIComponent(selectedName)}`, success() { const effects = { TianShu: incompleteLinkage ? '' : '1D', zhuanxianming: '测试线路', tuanxuhao1: 'TEST', tuanxuhao2: 'A', ys_danjia0: '100' }; for (const [name, value] of Object.entries(effects)) fields.get(name).value = value; } }); }; window.SubmitInfoForm = () => { submittedBody = `Act=DoInfoJH&${serialize()}`; jq.ajax({ url: '/System/DAT/orders.asp', type: 'POST', data: submittedBody }); }; const updateButton = { innerText: '更新行程', getAttribute: () => '', click: () => window.Find_product() }; const globals = { window, document, location: window.location, Event, URL, URLSearchParams, TextEncoder, crypto: globalThis.crypto, Blob, performance, setTimeout: window.setTimeout, clearTimeout, async fetch(url) { requests.push({ native: false, url }); const action = new URL(url).searchParams.get('Act'); const responses = { ProGetProductname: products.map((name, i) => `${i + 1}◆${name}`).join('◇'), GetInformation: '1◆测试线路◆unused◆TEST', ProTravel: 'CNY◆123◆测试客户◆测试联系人◆测试负责人◆unused', ProDanwei_Yuangong: '测试员工◆1' }; assert.ok(Object.hasOwn(responses, action), `unexpected lookup ${action}`); const status = action === 'ProGetProductname' ? productStatus : 200; return { ok: status === 200, status, text: async () => responses[action] }; } }; loadPageActionRealm(window, globals); const operation = { action: 'team_order_create', order_nature: 'formal', submit_mode: 'dry_run', data: { customer: { name: customer, keyword: customer }, product: { name: query, keyword: query }, departure_dates: ['2026-09-19'], passenger_counts: { adult: 1 }, room_counts: { DBL: 1 } } }; return { operation, fields, requests, linkedNames, submitted: () => submittedBody, snapshot: serialize, preflight: () => window.LTJTOrderAssistant.preflightRawInstruction(operation, { interceptSubmit: true }) }; } function productCheck(result) { return result.lookup_checks.find((check) => check.name === 'product_initial_selection'); } async function assertUntouched(f) { const before = f.snapshot(); const result = await f.preflight(); assert.equal(result.status, 'raw_instruction_preflight_blocked'); assert.equal(result.no_erp_write, true); assert.equal(result.write_attempted, false); assert.equal(result.set_summary.set_field_count, 0); assert.equal(result.product_side_effect.status, 'skipped'); assert.equal(f.snapshot(), before); assert.equal(f.linkedNames.length, 0); assert.equal(f.submitted(), ''); assert.equal(f.requests.length, 4); assert.ok(f.requests.every((request) => !request.native)); assert.equal(result.submit_intercept, null); assert.equal(result.skipped_stages.length, 4); assert.doesNotMatch(result.blockers.join(), /GetProduct|after_effect|after_restore|Required fields still blank/); return result; } test('single-order preflight normalizes both sides and uses the exact ERP spelling through native linkage and intercepted submission', async () => { for (const [query, erpName] of [ ['单订房(测试市场)', '单订房 (测试市场)'], ['单订房(测试市场)', '单订房 (测试市场)'], ['单订房\u3000(测试市场)', '单订房\u00a0(测试市场)'], ['AB12(测试市场)', 'AB12 (测试市场)'] ]) { const f = fixture({ products: [erpName], query }); const result = await f.preflight(); assert.equal(result.status, 'raw_instruction_preflight_passed', JSON.stringify(result.blockers)); assert.equal(productCheck(result).match_rule, 'exact_normalized'); assert.equal(productCheck(result).exact_match_count, 1); assert.deepEqual(f.linkedNames, [erpName]); assert.equal(f.fields.get('chanpinming').value, erpName); assert.equal(new URLSearchParams(f.submitted()).get('chanpinming'), erpName); assert.equal(new URLSearchParams(f.submitted()).get('frenshu3'), '1'); assert.equal(result.submit_intercept.status, 'submit_intercept_captured'); assert.equal(result.submit_safety.live_submit_attempted, false); assert.equal(f.requests.filter((request) => request.native).length, 1); } }); test('normalized exact match takes precedence over longer containing product names', async () => { const f = fixture({ products: ['单订房 (测试市场)', '单订房(测试市场)升级版'] }); const result = await f.preflight(); assert.equal(result.status, 'raw_instruction_preflight_passed', JSON.stringify(result.blockers)); assert.equal(productCheck(result).exact_match_count, 1); assert.equal(productCheck(result).contains_match_count, 2); assert.deepEqual(f.linkedNames, ['单订房 (测试市场)']); }); test('a unique shortened keyword still selects the ERP product', async () => { const f = fixture({ query: '单订房', products: ['单订房 (测试市场)', '单订车(测试市场)'] }); const result = await f.preflight(); assert.equal(result.status, 'raw_instruction_preflight_passed', JSON.stringify(result.blockers)); assert.equal(productCheck(result).match_rule, 'unique_contains_normalized'); assert.deepEqual(f.linkedNames, ['单订房 (测试市场)']); }); test('normalization collisions block even when one raw spelling matches exactly', async () => { const f = fixture({ products: ['单订房(测试市场)', '单订房 (测试市场)'] }); const result = await assertUntouched(f); assert.equal(productCheck(result).exact_match_count, 2); assert.equal(result.blockers.length, 1); assert.match(result.blockers[0], /匹配到 2 个产品/); }); test('multiple shortened-keyword matches block instead of selecting the first', async () => { const f = fixture({ query: '单订房', products: ['单订房(市场甲)', '单订房(市场乙)'] }); const result = await assertUntouched(f); assert.equal(productCheck(result).exact_match_count, 0); assert.equal(productCheck(result).contains_match_count, 2); assert.equal(result.blockers.length, 1); assert.match(result.blockers[0], /匹配到 2 个产品/); }); test('unmatched product stops with one useful error and preserves punctuation and qualifiers', async () => { for (const products of [[], ['单订房'], ['单订房(市场乙)'], ['单订房-测试市场'], ['单订房测试市场']]) { const result = await assertUntouched(fixture({ products })); assert.equal(productCheck(result).exact_match_count, 0); assert.equal(productCheck(result).contains_match_count, 0); assert.equal(result.blockers.length, 1); assert.match(result.blockers[0], /未找到匹配的产品/); } }); test('failed lookup transport or customer selection never advances into product linkage', async () => { const transport = await assertUntouched(fixture({ productStatus: 503 })); assert.match(transport.blockers.join(), /HTTP 503/); assert.equal(productCheck(transport).exact_match_count, 1); const customer = await assertUntouched(fixture({ customer: '不存在的客户' })); assert.match(customer.blockers.join(), /customer_initial_selection/); }); test('successful name resolution still requires complete native linkage before submission', async () => { const f = fixture({ incompleteLinkage: true }); const result = await f.preflight(); assert.equal(result.status, 'raw_instruction_preflight_blocked'); assert.equal(productCheck(result).exact_match_count, 1); assert.match(result.blockers.join(), /product_template_trip_nonempty/); assert.equal(f.linkedNames.length, 1); assert.equal(f.submitted(), ''); assert.equal(result.submit_safety.live_submit_attempted, false); });