130 lines
9.6 KiB
JavaScript
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,/未完成/);
|
|
});
|