迭代批量安排酒店
This commit is contained in:
1 parent
4cfbefffe8
commit
c2c71d35af
48 files changed
+5670
-57
No files matched your search
@@ -208,7 +208,7 @@ test('catalog has exactly 19 ordered operations with adjacent template and examp
|
||||
assert.deepEqual(operationSections.map((section) => section.directive), allDirectives);
|
||||
|
||||
for (const pair of catalogPairs) {
|
||||
assert.equal(pair.blocks.length, 2, `${pair.directive}: expected template and example`);
|
||||
assert.equal(pair.blocks.length, pair.directive === '安排酒店' ? 3 : 2, `${pair.directive}: expected template and example (plus hotel batch example)`);
|
||||
assert.ok(pair.template, `${pair.directive}: missing template block`);
|
||||
assert.ok(pair.example, `${pair.directive}: missing example block`);
|
||||
assert.match(pair.template, /(必填):/u);
|
||||
@@ -220,7 +220,7 @@ test('catalog has exactly 19 ordered operations with adjacent template and examp
|
||||
assert.deepEqual(blocks.map((block) => block.split('\n')[0]), arrangementUpdateDirectives.flatMap((directive) => [directive, directive]));
|
||||
continue;
|
||||
}
|
||||
assert.equal(textBlocks(section.body).length, 2);
|
||||
assert.equal(textBlocks(section.body).length, section.directive === '安排酒店' ? 3 : 2);
|
||||
assert.ok(labeledBlock(section, '模板'));
|
||||
assert.ok(labeledBlock(section, '示例'));
|
||||
assert.ok(section.body.indexOf('#### 模板') < section.body.indexOf('#### 示例'));
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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,/未完成/);
|
||||
});
|
||||
@@ -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\.189'/);
|
||||
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.190'/);
|
||||
});
|
||||
|
||||
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.189 keeps reload dormant unless its durable execution state is fully idle', async () => {
|
||||
test('extension 0.5.190 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')
|
||||
@@ -2037,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.189');
|
||||
assert.equal(mapping.current_extension_version, '0.5.190');
|
||||
assert.equal(
|
||||
mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'],
|
||||
'ertrenshu'
|
||||
@@ -2421,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.189');
|
||||
assert.match(inpage, /version: '0\.5\.189'/);
|
||||
assert.match(teamBatchInpage, /version: '0\.5\.189'/);
|
||||
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.189'/);
|
||||
assert.equal(extensionManifest.version, '0.5.190');
|
||||
assert.match(inpage, /version: '0\.5\.190'/);
|
||||
assert.match(teamBatchInpage, /version: '0\.5\.190'/);
|
||||
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.190'/);
|
||||
assert.match(inpage, /function strictIsoDate\(value\)/);
|
||||
assert.match(inpage, /const startDate = strictIsoDate\(current\('riqi'\)\)/);
|
||||
assert.match(inpage, /const endDate = strictIsoDate\(current\('riqis'\)\)/);
|
||||
|
||||
@@ -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\.136\s*$/mu);
|
||||
assert.match(template, /^交付版本:0\.5\.137\s*$/mu);
|
||||
assert.match(template, /^适用范围:当前 23 条业务路由,按 19 项运营场景分组;执行能力以生命周期发布门槛为准。\s*$/mu);
|
||||
assert.match(template, /^### 5\. 散拼团多个新增子单\s*$/mu);
|
||||
assert.doesNotMatch(template, /^事实基线:/mu);
|
||||
|
||||
Reference in new issue
Block a user