Files
LWLT-AIBOT/tools/hotel-batch.test.mjs
2026-09-24 14:43:08 +08:00

130 lines
9.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import Ajv from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { validateProgramOperation } from '../LianSyn-platform/external-agent-client.mjs';
const require = createRequire(import.meta.url);
const plans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js');
const batch = require('../chrome-extension/ltjt-order-assistant/hotel-batch.js');
const guard = require('../chrome-extension/ltjt-order-assistant/execution-guard.js');
const timing = require('../chrome-extension/ltjt-order-assistant/operation-timing.js');
const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
const slice = (a,b) => source.slice(source.indexOf(a),source.indexOf(b,source.indexOf(a)));
const operation = { action:'arrangement_hotel', order_nature:'formal', submit_mode:'dry_run', data:{ existing_refs:{ identifier:'LW-SAMPLE' },
arrangements:[26,27,28].map(day => ({ mode:'create', resource:{ name:`酒店${day}TWN`, keyword:`酒店${day}TWN` }, start_date:`2026-09-${day}`, end_date:`2026-09-${day + 1}`, room_count:1 })) } };
function result(item, index, status = 'completed') {
if (status === 'failed') return { status:'blocked', no_erp_write:true, write_attempted:false, message:'没有匹配候选' };
if (status === 'uncertain') return { status:'execution_uncertain', no_erp_write:false, write_attempted:true };
return { status:'completed', no_erp_write:false, write_attempted:true, report:{ status:'lifecycle_completed', resolved_refs:{ kind:'independent_order', identifier:'LW-SAMPLE' },
server_response:{ completed:true }, requery:{ matched:true, subpage:{ arrangement_target:{ row_id:String(index + 1), quantity_value:'1' }, checks:[
{field:'riqi0', actual:item.start_date, matched:true}, {field:'riqis0', actual:item.end_date, matched:true} ] } } } };
}
function harness(failure = '', failureIndex = 1) {
const saved = { businessTaskExecutions:{ TASK:{ state:'running', execution_id:'EXEC' } }, businessTaskResults:{}, businessTaskTimingStates:{} };
const calls = [], rootResults = [];
const context = { console, performance, Date, Map, Set, Promise, hotelBatch:batch, executionGuard:guard, operationTiming:timing,
operationPlans:plans, operationTimingDetailBuffers:new Map(), runningTasks:new Set(),
isTaskCancelled:()=>false, throwIfTaskCancelled:()=>{}, nowIso:()=>new Date().toISOString(),
withStorageMutation:fn=>fn(), queueTimedDetail:()=>{}, takeOperationTimingDetails:()=>[],
chrome:{ storage:{ local:{ get:async()=>structuredClone(saved), set:async patch=>{
Object.assign(saved,structuredClone(patch));
const root=saved.businessTaskResults.TASK; if(root)rootResults.push(structuredClone(root));
} } } },
executeLifecycleOperation:async(id, op) => {
calls.push(id); const index=calls.length-1;
const status=index===failureIndex && failure ? failure : 'completed';
saved.businessTaskExecutions.TASK.state=status==='failed'?'submitted':'write_started';
if(failure==='throw' && index===failureIndex) throw new Error('lost connection');
await context.setResult(id,result(op.data.arrangement,index,status));
if(status==='completed')saved.businessTaskExecutions.TASK.state='submitted';
}
};
vm.createContext(context);
vm.runInContext(slice('async function setResult(', 'async function publish(')+'\n'+slice('async function publishHotelBatch(', 'async function runAutoTask(')+'\n'+slice('async function reconcileHotelBatch(', 'async function reconcileLifecycleTask('),context);
return { context, saved, calls, rootResults };
}
test('dispatch and schema accept only create batches with all ordinary per-row fields valid',()=>{
const ajv=new Ajv({strict:false});addFormats(ajv);
const validate=ajv.compile(JSON.parse(readFileSync(new URL('../schemas/standard_system_operation.schema.json',import.meta.url))));
assert.deepEqual(validateProgramOperation(operation),[]);
assert.equal(plans.validateDispatchOperation(operation).execution,'browser_hotel_batch');
assert.equal(validate(operation),true,JSON.stringify(validate.errors));
for(const mutate of [op=>{op.data.arrangements[2].room_count=0;},op=>{op.data.arrangements[1].mode='update';},op=>{op.data.arrangement=op.data.arrangements[0];},op=>{op.action='arrangement_vehicle';}]){
const bad=structuredClone(operation);mutate(bad);assert.equal(plans.validateDispatchOperation(bad).ok,false);assert.equal(validate(bad),false);
}
});
test('successful rows execute serially without prematurely finalizing the root task',async()=>{
const h=harness();await h.context.executeHotelBatch('TASK',operation);
assert.deepEqual(h.calls,['TASK#hotel-1','TASK#hotel-2','TASK#hotel-3']);
assert.equal(h.saved.businessTaskExecutions.TASK.state,'completed');
assert.equal(h.saved.businessTaskResults.TASK.status,'completed');
assert.ok(h.rootResults.filter(row=>row.hotel_batch?.items.some(item=>item.status==='running')).every(row=>row.write_attempted===true && row.no_erp_write===false), 'a parent lease expiry must never infer no write from a still-running child');
assert.ok(h.rootResults.filter(row=>row.status==='completed').every(row=>row.hotel_batch.items.every(item=>item.status==='completed')));
});
test('prewrite failure stops subsequent rows, retains saved prefix and stays known partial failure',async()=>{
const h=harness('failed');await h.context.executeHotelBatch('TASK',operation);
assert.equal(h.calls.length,2);const root=h.saved.businessTaskResults.TASK;
assert.equal(root.status,'blocked');assert.equal(root.no_erp_write,false);assert.equal(root.write_attempted,true);
assert.deepEqual(root.hotel_batch.items.map(item=>item.status),['completed','failed','not_started']);
assert.equal(h.saved.businessTaskExecutions.TASK.state,'blocked');assert.match(root.message,/未执行 1 条/);
});
test('uncertain or missing response stops without retry and preserves both prefix and pending tail',async()=>{
for(const failure of ['uncertain','throw']){
const h=harness(failure);await h.context.executeHotelBatch('TASK',operation);
assert.equal(h.calls.length,2);assert.equal(h.saved.businessTaskResults.TASK.status,'execution_uncertain');
assert.equal(h.saved.businessTaskResults.TASK.hotel_batch.items[2].status,'not_started');
}
});
test('read-only reconciliation resolves only the uncertain row and never starts the pending tail',async()=>{
const h=harness('uncertain');await h.context.executeHotelBatch('TASK',operation);
const root=h.saved.businessTaskResults.TASK;
const item=root.hotel_batch.items[1];
const proof=result(item.arrangement,1);
item.result.report={...proof.report,status:'saved_unverified',requery:{matched:false}};
h.saved.businessTasks={'TASK#hotel-2':{operation:{...operation,data:{existing_refs:{identifier:'LW-SAMPLE'},arrangement:item.arrangement}}}};
h.context.operationPlans={...plans,validateOperation:()=>({ok:true,execution:'browser_lifecycle'})};
h.context.findOrOpenErpTab=async()=>({id:1});
let reads=0;
h.context.runLifecycleReadOnlyVerification=async()=>{reads++;return {resolved:true,verification:{requery:proof.report.requery}};};
const reply=await h.context.reconcileHotelBatch('TASK',operation,root,h.saved);
assert.equal(reply.status,'blocked');assert.equal(reply.reconciliation_resolved,true);
assert.equal(reads,1);assert.equal(h.calls.length,2);
assert.deepEqual(h.saved.businessTaskResults.TASK.hotel_batch.items.map(row=>row.status),['completed','completed','not_started']);
assert.equal(h.saved.businessTaskExecutions.TASK.state,'blocked');
});
test('timeout preserves an already-saved child and classifies a missing child as uncertain without replay',async()=>{
for(const savedChild of [true,false]){
const h=harness();
const pending={version:1,group_number:'LW-SAMPLE',items:operation.data.arrangements.map((arrangement,index)=>({index,arrangement,status:index===0?'completed':index===1?'running':'not_started',...(index===0?{result:result(arrangement,0)}:{})}))};
await h.context.publishHotelBatch('TASK',operation,pending);
if(savedChild)h.saved.businessTaskResults['TASK#hotel-2']=result(operation.data.arrangements[1],1);
assert.equal(await h.context.stopHotelBatch('TASK',operation,'timeout'),true);
const root=h.saved.businessTaskResults.TASK;
assert.equal(root.status,savedChild?'blocked':'execution_uncertain');
assert.equal(root.hotel_batch.items[0].status,'completed');
assert.equal(root.hotel_batch.items[1].status,savedChild?'completed':'uncertain');
assert.equal(root.hotel_batch.items[2].status,'not_started');assert.equal(h.calls.length,0);
}
});
test('production Docker runtime includes the shared batch evidence validator',()=>{
const dockerfile=readFileSync(new URL('../Dockerfile',import.meta.url),'utf8');
const runtime=dockerfile.split('AS runtime')[1];
assert.match(runtime,/COPY[^\n]*chrome-extension\/ltjt-order-assistant\/hotel-batch\.js[^\n]*\.\/chrome-extension\/ltjt-order-assistant\//);
});
test('a proven prewrite failure on the first row reports zero writes and leaves the rest untouched',async()=>{
const h=harness('failed',0);await h.context.executeHotelBatch('TASK',operation);
const root=h.saved.businessTaskResults.TASK;
assert.equal(h.calls.length,1);assert.equal(root.status,'blocked');
assert.equal(root.no_erp_write,true);assert.equal(root.write_attempted,false);
assert.deepEqual(root.hotel_batch.items.map(item=>item.status),['failed','not_started','not_started']);
assert.match(root.message,/未完成/);
});