671 lines
38 KiB
JavaScript
671 lines
38 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const {harness,response,scopedKey} = require('./helpers/arr_ui_harness.cjs');
|
|
const requestId='a'.repeat(32);
|
|
const task={request_id:requestId,report_date:'2026-10-07',status:'needs_data_review',job_id:null,can_retry:false};
|
|
const item=(extra={})=>({item_id:'1:BLOCK_CODE',source_sequence:1,confirmation_no:'SYNTHETIC-001',room_no:'SYNTHETIC-ROOM',company_name:'Synthetic Company',rate_code:'SYNTHETIC',field:'BLOCK_CODE',field_label:'团队代码',reason_code:'MISSING',can_be_empty:true,value:null,confirmed:false,...extra});
|
|
const review=(items,revision=1)=>({request_id:requestId,report_date:task.report_date,status:'editing',revision,items,pending_count:items.filter(x=>!x.confirmed).length,total_count:items.length,can_finalize:items.every(x=>x.confirmed)});
|
|
const plain=value=>JSON.parse(JSON.stringify(value));
|
|
const activeTaskPoll=h=>h.timers.filter(timer=>!timer.cancelled&&timer.callback.name==='loadARRDownloadTask').at(-1);
|
|
const priceTask={request_id:'c'.repeat(32),report_date:'2026-09-16',status:'needs_review',
|
|
job_id:'fixture-price-september',can_retry:false};
|
|
const priceReview=(price=null)=>({case_id:'fixture-price-case',case_status:'open',business_date:priceTask.report_date,revision:1,
|
|
completed_items:price===null?0:1,total_items:1,
|
|
items:[{item_id:1,company_key:'Fixture Company',rate_code:'FIXTURE',effective_rate_amount:100,
|
|
real_price:price,affected_records:1,affected_rooms:1,affected_room_nights:2,candidate_prices:[]}],
|
|
pagination:{offset:0,limit:50,total:1}});
|
|
function pendingAPI(url,currentPrice=priceTask) {
|
|
if(url===`/api/arr-downloads/${requestId}`) return response(200,task);
|
|
if(url===`/api/arr-downloads/${priceTask.request_id}`) return response(200,currentPrice);
|
|
if(url.endsWith('/data-review')) return response(200,review([item()]));
|
|
if(url.includes('/review?')) return response(200,priceReview());
|
|
if(url.endsWith('/trace')) return response(200,{job:{job_id:priceTask.job_id,
|
|
business_date:priceTask.report_date,status:currentPrice.status,active:currentPrice.status==='needs_review'},logs:[]});
|
|
if(url.startsWith('/api/jobs?')) return response(200,[]);
|
|
throw new Error('unexpected request '+url);
|
|
}
|
|
|
|
test('an empty validated field review polls the same task and follows processing then price review by GET only',async()=>{
|
|
let nextStatus='processing';
|
|
const jobId='fixture-recovered-empty-review';
|
|
const h=harness(url=>{
|
|
if(url.endsWith('/data-review')) return response(200,review([]));
|
|
if(url===`/api/arr-downloads/${requestId}`) return response(200,{...task,status:nextStatus,job_id:nextStatus==='processing'?null:jobId});
|
|
if(url.startsWith('/api/jobs?')) return response(200,[]);
|
|
if(url.endsWith('/trace')) return response(200,{job:{job_id:jobId,business_date:task.report_date,status:nextStatus,active:true},logs:[]});
|
|
if(url.includes('/review?')) return response(200,{...priceReview(),business_date:task.report_date});
|
|
throw new Error('unexpected request '+url);
|
|
});
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
const emptyPoll=activeTaskPoll(h);
|
|
assert.equal(emptyPoll?.delay,4000);
|
|
await emptyPoll.callback();
|
|
assert.equal(h.state.arrDownloadTask.status,'processing');
|
|
assert.equal(h.state.arrDataReview,null);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
|
nextStatus='needs_review';
|
|
await activeTaskPoll(h).callback();
|
|
assert.equal(h.state.arrDownloadTask.status,'needs_review');
|
|
assert.equal(h.element('#arr-download-review').hidden,false);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
|
assert.equal(h.state.arrDownloadPendingReviews[0].status,'needs_review');
|
|
await h.openDailyPriceReview(jobId,false);
|
|
assert.equal(h.element('#daily-price-review-panel').hidden,false);
|
|
assert.equal(h.element('#manual-review-workspace').hidden,false);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
assert.equal(h.calls.some(call=>call.url.includes('/finalize')),false);
|
|
});
|
|
|
|
for(const status of ['succeeded','failed']) {
|
|
test(`empty-review recovery follows the real ${status} task status without submitting a decision`,async()=>{
|
|
const h=harness(url=>{
|
|
if(url.endsWith('/data-review')) return response(200,review([]));
|
|
assert.equal(url,`/api/arr-downloads/${requestId}`);
|
|
return response(200,{...task,status,error_code:status==='failed'?'ARR_DATA_EXECUTION_FAILED':null});
|
|
});
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
await activeTaskPoll(h).callback();
|
|
assert.equal(h.state.arrDownloadTask.status,status);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
|
assert.equal(h.element('#manual-review-workspace').hidden,true);
|
|
assert.equal(h.element('#arr-download-pending').hidden,true);
|
|
assert.match(h.element('#arr-download-status').textContent,new RegExp(`arr_download\\.${status}`));
|
|
assert.equal(activeTaskPoll(h),undefined);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
}
|
|
|
|
test('filled real review items, inconsistent zero counts and unsaved drafts never trigger zero-item recovery polling',async()=>{
|
|
for(const snapshot of [review([item({confirmed:true,value:'SAVED'})]),
|
|
{...review([]),total_count:1}, {...review([]),pending_count:1}, {...review([]),can_finalize:false},
|
|
{...review([]),items:[item()]}, {...review([]),request_id:'f'.repeat(32)},
|
|
{...review([]),report_date:'2026-09-17'}]) {
|
|
const h=harness(()=>response(200,snapshot));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
assert.equal(activeTaskPoll(h),undefined);
|
|
assert.equal(h.calls.length,1);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
}
|
|
const h=harness(()=>response(200,review([item({confirmed:true,value:'SAVED'})])));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','UNSAVED').input);
|
|
h.state.arrDataReview=review([]);
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
assert.equal(activeTaskPoll(h),undefined);
|
|
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'UNSAVED');
|
|
assert.equal(h.calls.some(call=>call.url.includes('/finalize')),false);
|
|
});
|
|
|
|
test('cross-month field and price dates restore, stay clickable after polling, and open their own pages by GET',async()=>{
|
|
const h=harness(url=>url==='/api/arr-downloads'
|
|
? response(200,{context_id:'production',ready:true,default_date:priceTask.report_date,
|
|
pending_reviews:[task,priceTask],pending_data_reviews:[task],latest_task:priceTask})
|
|
: pendingAPI(url));
|
|
h.storage.set(scopedKey(),JSON.stringify({request_id:priceTask.request_id,report_date:priceTask.report_date}));
|
|
await h.initARRDownload();
|
|
assert.equal(h.state.arrDownloadPendingReviews.length,2);
|
|
assert.equal(h.element('#arr-download-pending-title').textContent,'arr_download.pending_dates');
|
|
assert.match(h.element('#arr-download-pending-list').innerHTML,/2026-10-07.*arr_download.pending_date/);
|
|
assert.match(h.element('#arr-download-pending-list').innerHTML,/2026-09-16.*arr_download.pending_price/);
|
|
assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/);
|
|
await h.selectARRPendingReview(requestId);
|
|
h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','OCTOBER-DRAFT').input);
|
|
await h.selectARRPendingReview(priceTask.request_id);
|
|
assert.equal(h.state.jobsMonth,'2026-09');
|
|
assert.equal(h.element('#jobs-history-month').value,'2026-09');
|
|
assert.equal(h.state.selectedJobId,priceTask.job_id);
|
|
assert.equal(h.state.dailyPriceReviewJobId,priceTask.job_id);
|
|
assert.equal(h.element('#daily-price-review-panel').hidden,false);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
|
assert.equal(h.element('#manual-review-workspace').hidden,false);
|
|
assert.equal(h.element('#manual-review-context').textContent,'2026-09-16 · manual_review.prices');
|
|
assert.equal(h.element('#arr-download-pending').hidden,false);
|
|
assert.equal(h.element('#arr-download-date').value,'2026-09-16');
|
|
assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/);
|
|
await h.loadARRDownloadTask();
|
|
assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/,
|
|
'a finished status poll must unlock the pending buttons again');
|
|
await h.selectARRPendingReview(requestId);
|
|
assert.equal(h.element('#daily-price-review-panel').hidden,true);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,false);
|
|
assert.equal(h.element('#manual-review-workspace').hidden,false);
|
|
assert.equal(h.element('#manual-review-context').textContent,'2026-10-07 · manual_review.fields');
|
|
assert.equal(h.element('#arr-download-pending').hidden,false);
|
|
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'OCTOBER-DRAFT');
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true,'restoring reviews never downloads or changes prices');
|
|
});
|
|
|
|
test('history price entry uses the same workspace and keeps field drafts while switching bodies',async()=>{
|
|
const h=harness(url=>pendingAPI(url));
|
|
h.state.arrDownloadPendingReviews=[task,priceTask];
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','FIELD-DRAFT').input);
|
|
await h.openDailyPriceReview(priceTask.job_id,false);
|
|
assert.equal(h.element('#manual-review-workspace').hidden,false);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
|
assert.equal(h.element('#daily-price-review-panel').hidden,false);
|
|
assert.equal(h.element('#manual-review-context').textContent,'2026-09-16 · manual_review.prices');
|
|
assert.equal(h.element('#arr-download-pending').hidden,false);
|
|
h.renderARRDataReview();
|
|
h.renderDailyPriceReview();
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true,'routine renders cannot switch the selected body');
|
|
await h.selectARRPendingReview(requestId);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,false);
|
|
assert.equal(h.element('#daily-price-review-panel').hidden,true);
|
|
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'FIELD-DRAFT');
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
|
|
test('terminal reviews hide the workspace without hiding other dates requiring attention',async()=>{
|
|
const h=harness(url=>pendingAPI(url));
|
|
h.state.arrDownloadPendingReviews=[task,priceTask];
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
await h.openDailyPriceReview(priceTask.job_id,false);
|
|
for(const case_status of ['completed','cancelled','failed']) {
|
|
h.state.dailyPriceReview={...priceReview(),case_status};
|
|
h.renderDailyPriceReview();
|
|
assert.equal(h.element('#manual-review-workspace').hidden,true);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
|
assert.equal(h.element('#daily-price-review-panel').hidden,true);
|
|
assert.equal(h.element('#arr-download-pending').hidden,false);
|
|
}
|
|
h.state.arrDownloadTask={...task,status:'succeeded'};
|
|
h.state.manualReviewMode='fields';
|
|
h.renderARRDataReview();
|
|
assert.equal(h.element('#manual-review-workspace').hidden,true);
|
|
});
|
|
|
|
test('opening fields directly cannot hide an unsaved price edit',async()=>{
|
|
const h=harness(url=>pendingAPI(url));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
await h.openDailyPriceReview(priceTask.job_id,false);
|
|
const input={value:'0',closest:()=>({dataset:{dailyPriceReviewItemId:'1'}})};
|
|
h.element('#daily-price-review-body').querySelectorAll=()=>[input];
|
|
const count=h.calls.length;
|
|
await h.loadARRDataReview();
|
|
assert.equal(h.calls.length,count);
|
|
assert.equal(h.element('#daily-price-review-panel').hidden,false);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
|
assert.equal(input.value,'0');
|
|
});
|
|
|
|
test('a source context change clears the workspace and ignores an older price response',async()=>{
|
|
let finishPrice;
|
|
const h=harness(url=>{
|
|
if(url.includes('/review?')) return new Promise(resolve=>{finishPrice=()=>resolve(response(200,priceReview()));});
|
|
if(url==='/api/arr-downloads') return response(200,{context_id:'new-context',ready:true,
|
|
default_date:'2026-09-17',pending_reviews:[],latest_task:null});
|
|
throw new Error('unexpected request '+url);
|
|
});
|
|
h.state.arrDownloadContextId='old-context';
|
|
h.state.arrDownloadStorageKey=scopedKey('old-context');
|
|
h.state.dailyPriceReview=priceReview();
|
|
h.state.dailyPriceReviewJobId=priceTask.job_id;
|
|
h.state.dailyPriceReviewOffset=50;
|
|
const loading=h.loadDailyPriceReview();
|
|
await h.initARRDownload();
|
|
assert.equal(h.state.manualReviewMode,'');
|
|
assert.equal(h.state.dailyPriceReview,null);
|
|
assert.equal(h.state.dailyPriceReviewJobId,'');
|
|
assert.equal(h.state.dailyPriceReviewOffset,0);
|
|
assert.equal(h.element('#manual-review-workspace').hidden,true);
|
|
finishPrice();
|
|
await loading;
|
|
assert.equal(h.state.dailyPriceReview,null);
|
|
assert.equal(h.element('#manual-review-workspace').hidden,true);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
|
|
test('an authoritative empty pending list overrides legacy and stale current review state',async()=>{
|
|
let cleared=false;
|
|
const h=harness(url=>url==='/api/arr-downloads'
|
|
? response(200,{context_id:'production',ready:true,default_date:priceTask.report_date,
|
|
pending_reviews:cleared?[]:[priceTask],pending_data_reviews:[task],latest_task:priceTask})
|
|
: pendingAPI(url));
|
|
h.storage.set(scopedKey(),JSON.stringify({request_id:priceTask.request_id,report_date:priceTask.report_date}));
|
|
await h.initARRDownload();
|
|
assert.deepEqual(plain(h.state.arrDownloadPendingReviews).map(x=>x.request_id),[priceTask.request_id]);
|
|
cleared=true;
|
|
await h.initARRDownload();
|
|
assert.equal(h.state.arrDownloadTask.status,'needs_review','the fixture deliberately returns an older task snapshot');
|
|
assert.equal(h.state.arrDownloadPendingReviews.length,0);
|
|
assert.equal(h.element('#arr-download-pending').hidden,true);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
|
|
test('a source request can return as a price review after processing with the same request id',async()=>{
|
|
const h=harness(()=>response(200,review([item()])));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
await h.acceptARRDownloadTask({...task,status:'processing'},{sync:false});
|
|
assert.equal(h.state.arrDownloadPendingReviews.length,0);
|
|
await h.acceptARRDownloadTask({...task,status:'needs_review',job_id:'same-request-price-job'},{sync:false});
|
|
assert.equal(h.state.arrDownloadPendingReviews.length,1);
|
|
assert.equal(h.state.arrDownloadPendingReviews[0].status,'needs_review');
|
|
assert.match(h.element('#arr-download-pending-list').innerHTML,/arr_download.pending_price/);
|
|
});
|
|
|
|
for(const status of ['succeeded','failed']) {
|
|
test(`a stale price date whose authoritative task is ${status} leaves the pending list without reopening prices`,async()=>{
|
|
const h=harness(url=>pendingAPI(url,{...priceTask,status}));
|
|
h.state.arrDownloadPendingReviews=[priceTask];
|
|
await h.selectARRPendingReview(priceTask.request_id);
|
|
assert.equal(h.state.arrDownloadTask.status,status);
|
|
assert.equal(h.state.arrDownloadPendingReviews.length,0);
|
|
assert.equal(h.element('#arr-download-pending').hidden,true);
|
|
assert.equal(h.element('#manual-review-workspace').hidden,true);
|
|
assert.equal(h.element('#daily-price-review-panel').hidden,true);
|
|
assert.equal(h.calls.some(call=>call.url.includes('/review?')),false);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
}
|
|
|
|
test('unsaved prices prevent a pending date switch without clearing the current price input',async()=>{
|
|
const h=harness(()=>{throw new Error('switch must not send a request');});
|
|
h.state.arrDownloadPendingReviews=[task,priceTask];
|
|
h.state.arrDownloadTask=priceTask;
|
|
h.state.dailyPriceReviewJobId=priceTask.job_id;
|
|
h.state.dailyPriceReview=priceReview();
|
|
const input={value:'0',closest:()=>({dataset:{dailyPriceReviewItemId:'1'}})};
|
|
h.element('#daily-price-review-body').querySelectorAll=()=>[input];
|
|
assert.equal(h.dailyPriceReviewHasUnsavedChanges(),true);
|
|
await h.selectARRPendingReview(requestId);
|
|
assert.equal(h.calls.length,0);
|
|
assert.equal(h.state.arrDownloadTask.request_id,priceTask.request_id);
|
|
assert.equal(input.value,'0');
|
|
assert.equal(h.element('#daily-price-review-message').textContent,'daily_review.save_before_switch');
|
|
h.state.dailyPriceReview=priceReview('0');
|
|
assert.equal(h.dailyPriceReviewHasUnsavedChanges(),false,'a saved zero is not an unsaved draft');
|
|
});
|
|
|
|
for(const operation of ['finalize','cancel']) {
|
|
test(`price ${operation} refreshes the same request and removes its terminal pending date`,async()=>{
|
|
const h=harness((url,options)=>{
|
|
if(options.method==='POST') {
|
|
assert.equal(url,`/api/jobs/${priceTask.job_id}/review/${operation}`);
|
|
return response(200,{status:operation==='finalize'?'succeeded':'cancelled'});
|
|
}
|
|
return pendingAPI(url,{...priceTask,status:operation==='finalize'?'succeeded':'failed'});
|
|
});
|
|
h.state.arrDownloadPendingReviews=[priceTask];
|
|
h.state.arrDownloadTask=priceTask;
|
|
h.state.dailyPriceReviewJobId=priceTask.job_id;
|
|
h.state.dailyPriceReview=priceReview('0');
|
|
if(operation==='finalize') await h.finalizeDailyPriceReview();
|
|
else await h.confirmDailyPriceReviewCancel();
|
|
assert.equal(h.state.arrDownloadPendingReviews.length,0);
|
|
assert.equal(h.element('#arr-download-pending').hidden,true);
|
|
assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
|
|
assert.equal(h.calls.some(call=>call.url===`/api/arr-downloads/${priceTask.request_id}`),true);
|
|
assert.equal(h.calls.some(call=>call.url==='/api/arr-downloads'),false,'no replacement download is created');
|
|
assert.equal(h.state.arrDownloadTask.status,operation==='finalize'?'succeeded':'failed');
|
|
});
|
|
}
|
|
|
|
test('pending date labels have Chinese, English and Thai translations',()=>{
|
|
const context=vm.createContext({window:{},document:{readyState:'loading',addEventListener(){}}});
|
|
vm.runInContext(fs.readFileSync(path.resolve(__dirname,'../../arr_web/static/i18n.js'),'utf8'),context);
|
|
const catalog=context.window.ARRI18n.CATALOG;
|
|
for(const key of ['arr_download.pending_dates','arr_download.pending_date','arr_download.pending_price','daily_review.save_before_switch',
|
|
'manual_review.title','manual_review.fields','manual_review.prices','manual_review.context']) {
|
|
assert.equal(catalog[key].length,3);
|
|
catalog[key].forEach(text=>assert.equal(typeof text==='string' && text.length>0,true));
|
|
}
|
|
assert.equal(catalog['arr_download.pending_dates'][0],'需处理日期');
|
|
assert.equal(catalog['manual_review.title'][0],'人工核对');
|
|
assert.equal(catalog['arr_download.pending_price'][0],'待复核价格');
|
|
});
|
|
|
|
test('cancelled source rows need no fields and the exclusion stays visible before continuation',async()=>{
|
|
const h=harness(()=>response(200,{...review([]),excluded_cancelled_count:6}));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
assert.equal(h.element('#arr-data-review-body').innerHTML,'');
|
|
assert.match(h.element('#arr-data-review-progress').textContent,/data_review\.excluded_cancelled/);
|
|
assert.equal(h.element('#arr-data-review-finalize').disabled,false);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
|
|
test('PM exclusions stay visible without requiring field completion',async()=>{
|
|
const h=harness(()=>response(200,{...review([]),excluded_cancelled_count:6,excluded_pm_count:19}));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
const progress=h.element('#arr-data-review-progress').textContent;
|
|
assert.match(progress,/data_review\.excluded_cancelled/);
|
|
assert.match(progress,/data_review\.excluded_pm/);
|
|
assert.equal(h.element('#arr-data-review-body').innerHTML,'');
|
|
assert.equal(h.element('#arr-data-review-finalize').disabled,false);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
|
|
test('removed cancelled items clear obsolete drafts but retain edits still in the review',async()=>{
|
|
let current=review([item(),item({item_id:'2:DISP_ROOM_NO',source_sequence:2,field:'DISP_ROOM_NO',can_be_empty:false})]);
|
|
const h=harness(()=>response(200,current));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
h.state.arrDataReviewDrafts['1:BLOCK_CODE']='CANCELLED-DRAFT';
|
|
h.state.arrDataReviewDrafts['2:DISP_ROOM_NO']='ROOM-DRAFT';
|
|
current=review([current.items[1]],2);
|
|
await h.loadARRDataReview(requestId);
|
|
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],undefined);
|
|
assert.equal(h.state.arrDataReviewDrafts['2:DISP_ROOM_NO'],'ROOM-DRAFT');
|
|
current={...review([],3),excluded_cancelled_count:2};
|
|
await h.loadARRDataReview(requestId);
|
|
assert.equal(Object.keys(h.state.arrDataReviewDrafts).length,0);
|
|
assert.equal(h.element('#arr-data-review-finalize').disabled,false);
|
|
});
|
|
|
|
for (const fixture of [
|
|
{name:'missing room retains the original required rule', field:'DISP_ROOM_NO', can_be_empty:false,
|
|
source_state:'missing', reason_code:'calendar_missing_rooms', expected:'room_missing_in_scope'},
|
|
{name:'failed room lookup is not described as confirmed absence', field:'DISP_ROOM_NO', can_be_empty:false,
|
|
source_state:'failed', reason_code:'calendar_missing_rooms', expected:'fetch_failed'},
|
|
{name:'conflicting room evidence is not described as confirmed absence', field:'DISP_ROOM_NO', can_be_empty:false,
|
|
source_state:'ambiguous', reason_code:'calendar_missing_rooms', expected:'conflict_value'},
|
|
{name:'missing block is optional information awaiting confirmation', field:'BLOCK_CODE', can_be_empty:true,
|
|
source_state:'missing', reason_code:'missing_reservation_block', expected:'source_unconfirmed'},
|
|
{name:'missing packages are optional information awaiting confirmation', field:'PRODUCTS', can_be_empty:true,
|
|
source_state:'missing', reason_code:'missing_reservation_packages', expected:'source_unconfirmed'},
|
|
{name:'failed source state takes precedence over a missing reason', source_state:'failed',
|
|
reason_code:'missing_reservation_block', expected:'fetch_failed'},
|
|
{name:'ambiguous source state takes precedence over an invalid reason', source_state:'ambiguous',
|
|
reason_code:'invalid_package_code', expected:'conflict_value'},
|
|
{name:'older HTTP failure snapshots remain failures', reason_code:'http_failure', expected:'fetch_failed'},
|
|
{name:'invalid response JSON is a read failure rather than a field format error',
|
|
reason_code:'invalid_json', expected:'fetch_failed'},
|
|
{name:'a block disagreement is shown as conflicting information',
|
|
reason_code:'block_code_disagreement', expected:'conflict_value'},
|
|
{name:'a changed block association is shown as conflicting information',
|
|
reason_code:'block_association_changed', expected:'conflict_value'},
|
|
{name:'malformed package code has a separate format explanation',
|
|
reason_code:'invalid_package_code', expected:'invalid_value'},
|
|
{name:'invalid nights retain a separate format explanation',
|
|
field:'DEPARTURE', can_be_empty:false, reason_code:'XML_NEGATIVE_NIGHTS', expected:'invalid_value'},
|
|
{name:'confirmed decisions show the saved confirmation instead of the original problem',
|
|
source_state:'failed', reason_code:'http_failure', confirmed:true, value:'VERIFIED', expected:'confirmed_value'},
|
|
]) {
|
|
test(`field review explanation: ${fixture.name}`,async()=>{
|
|
const source=item(fixture);
|
|
const original=plain(source);
|
|
const h=harness(()=>response(200,review([source])));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
const markup=h.element('#arr-data-review-body').innerHTML;
|
|
assert.match(markup,new RegExp(`data_review\\.${fixture.expected}\\b`));
|
|
assert.match(markup,new RegExp(`data_review\\.${source.can_be_empty?'optional':'required'}_field_rule\\b`));
|
|
assert.equal(markup.includes('data_review.missing_value'),false);
|
|
assert.equal(markup.includes('data-arr-data-review-empty'),source.can_be_empty);
|
|
assert.deepEqual(plain(source),original,'rendering explanations never changes a source or confirmation');
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
}
|
|
|
|
test('review explanations have distinct Chinese, English and Thai copy for required, optional and failed reads',()=>{
|
|
const context=vm.createContext({window:{},document:{readyState:'loading',addEventListener(){}}});
|
|
vm.runInContext(fs.readFileSync(path.resolve(__dirname,'../../arr_web/static/i18n.js'),'utf8'),context);
|
|
const catalog=context.window.ARRI18n.CATALOG;
|
|
const keys=['required_field_rule','optional_field_rule','required_value_missing','room_missing_in_scope','source_unconfirmed',
|
|
'fetch_failed','conflict_value','invalid_value'];
|
|
for(const key of keys) {
|
|
const translations=catalog[`data_review.${key}`];
|
|
assert.equal(translations.length,3);
|
|
translations.forEach(text=>assert.equal(typeof text==='string' && text.length>0,true));
|
|
}
|
|
assert.match(catalog['data_review.required_field_rule'][0],/原处理规则必填/);
|
|
assert.match(catalog['data_review.optional_field_rule'][0],/允许为空/);
|
|
assert.match(catalog['data_review.room_missing_in_scope'][0],/费率在处理范围内/);
|
|
assert.match(catalog['data_review.source_unconfirmed'][0],/确认无此信息/);
|
|
assert.match(catalog['data_review.fetch_failed'][0],/不能直接认定没有此信息/);
|
|
assert.equal(catalog['data_review.fetch_failed'][0].includes('刷新'),false,
|
|
'refreshing the review must not imply a retry of Oracle acquisition');
|
|
});
|
|
|
|
test('source review opens before a job exists and another date can start its own download',async()=>{
|
|
const h=harness((url,options)=>options.method==='POST'
|
|
? response(202,{...JSON.parse(options.body),status:'queued',job_id:null,can_retry:false})
|
|
: response(200,review([item()])));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
assert.equal(h.element('#arr-download-data-review').hidden,false);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,false);
|
|
assert.equal(h.element('#arr-download-review').hidden,true);
|
|
assert.equal(h.element('#arr-data-review-finalize').disabled,true);
|
|
h.element('#arr-download-date').value='2026-09-17';
|
|
h.handleARRDownloadDateChange();
|
|
assert.equal(h.state.arrDownloadIntent.request_id,requestId,'choosing a date alone keeps the earlier task');
|
|
assert.equal(h.element('#arr-download-button').textContent,'arr_download.start');
|
|
assert.equal(h.element('#arr-download-button').disabled,false);
|
|
await h.submit();
|
|
assert.notEqual(h.state.arrDownloadIntent.request_id,requestId);
|
|
assert.equal(h.state.arrDownloadTask.report_date,'2026-09-17');
|
|
assert.equal(h.element('#arr-download-date').value,'2026-09-17');
|
|
const post=h.calls.find(call=>call.method==='POST');
|
|
assert.equal(post.url,'/api/arr-downloads');
|
|
assert.equal(JSON.parse(post.body).report_date,'2026-09-17');
|
|
assert.equal(h.state.arrDownloadPendingReviews[0].request_id,requestId);
|
|
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
|
});
|
|
|
|
test('submitting the pending review date only opens that review without another download',async()=>{
|
|
const h=harness(()=>response(200,review([item()])));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
assert.equal(h.element('#arr-download-button').textContent,'arr_download.complete_data');
|
|
await h.submit();
|
|
assert.equal(h.state.arrDownloadIntent.request_id,requestId);
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
|
});
|
|
|
|
test('pending dates and unsaved drafts remain separate when moving between two field reviews',async()=>{
|
|
const otherId='b'.repeat(32);
|
|
const otherTask={...task,request_id:otherId,report_date:'2026-09-17'};
|
|
const h=harness(url=>url.endsWith('/data-review')
|
|
? response(200,{...review([item()]),request_id:url.includes(otherId)?otherId:requestId})
|
|
: response(200,url.endsWith(otherId)?otherTask:task));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','OCTOBER-DRAFT').input);
|
|
await h.acceptARRDownloadTask(otherTask,{sync:false});
|
|
assert.equal(h.state.arrDownloadPendingReviews.length,2);
|
|
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],undefined);
|
|
h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','SEPTEMBER-DRAFT').input);
|
|
await h.selectARRPendingReview(requestId);
|
|
assert.equal(h.state.arrDownloadTask.request_id,requestId);
|
|
assert.equal(h.element('#arr-download-date').value,'2026-10-07');
|
|
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'OCTOBER-DRAFT');
|
|
await h.selectARRPendingReview(otherId);
|
|
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'SEPTEMBER-DRAFT');
|
|
assert.equal(h.calls.every(call=>call.method==='GET'),true,'returning to a review never re-fetches Oracle');
|
|
});
|
|
|
|
test('pending dates restore from the server even when the latest task is no longer a review',async()=>{
|
|
const h=harness(url=>url==='/api/arr-downloads'
|
|
? response(200,{context_id:'production',ready:true,default_date:'2026-10-07',
|
|
pending_data_reviews:[task],latest_task:{...task,request_id:'b'.repeat(32),report_date:'2026-09-17',status:'failed'}})
|
|
: response(200,url.endsWith('/data-review')?review([item()]):task));
|
|
await h.initARRDownload();
|
|
assert.equal(h.state.arrDownloadTask,null);
|
|
assert.equal(h.element('#arr-download-pending').hidden,false);
|
|
assert.match(h.element('#arr-download-pending-list').innerHTML,/2026-10-07/);
|
|
await h.selectARRPendingReview(requestId);
|
|
assert.equal(h.state.arrDownloadTask.request_id,requestId);
|
|
assert.equal(h.state.arrDataReview.pending_count,1);
|
|
});
|
|
|
|
test('an uncertain new date cannot be abandoned by opening an earlier field review',async()=>{
|
|
const h=harness(()=>{throw new Error('unexpected request');});
|
|
h.state.arrDownloadPendingReviews=[task];
|
|
h.rememberARRIntent({request_id:'b'.repeat(32),report_date:'2026-09-17'});
|
|
h.state.arrDownloadDisconnected=true;
|
|
await h.selectARRPendingReview(requestId);
|
|
assert.equal(h.calls.length,0);
|
|
assert.equal(h.state.arrDownloadIntent.report_date,'2026-09-17');
|
|
});
|
|
|
|
test('a completed pending review can still be finalized after reopening it',async()=>{
|
|
const h=harness(url=>response(200,url.endsWith('/data-review')
|
|
? review([item({confirmed:true,value:'VERIFIED'})]) : task));
|
|
h.state.arrDownloadPendingReviews=[task];
|
|
await h.selectARRPendingReview(requestId);
|
|
assert.equal(h.state.arrDownloadBusy,false);
|
|
assert.equal(h.arrDataReviewCanFinalize(),true);
|
|
assert.equal(h.element('#arr-data-review-finalize').disabled,false);
|
|
});
|
|
|
|
test('lost submission for another date retains both its uncertain intent and the earlier pending review',async()=>{
|
|
const h=harness((url,options)=>{
|
|
if(options.method==='POST') throw new TypeError('lost response');
|
|
if(url.endsWith('/data-review')) return response(200,review([item()]));
|
|
return response(404,null,'ARR_DOWNLOAD_NOT_FOUND');
|
|
});
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
h.element('#arr-download-date').value='2026-09-17';
|
|
h.handleARRDownloadDateChange();
|
|
await h.submit();
|
|
const intent=plain(h.state.arrDownloadIntent);
|
|
assert.equal(intent.report_date,'2026-09-17');
|
|
assert.notEqual(intent.request_id,requestId);
|
|
assert.equal(h.state.arrDownloadTask.status,'not_received');
|
|
assert.equal(h.state.arrDownloadPendingReviews[0].request_id,requestId);
|
|
await h.selectARRPendingReview(requestId);
|
|
assert.deepEqual(plain(h.state.arrDownloadIntent),intent);
|
|
});
|
|
|
|
test('incomplete review cannot finalize even when server flag says it can',async()=>{
|
|
const h=harness(()=>response(200,{...review([item()]),can_finalize:true,pending_count:0}));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
await h.finalizeARRDataReview();
|
|
assert.equal(h.arrDataReviewCanFinalize(),false);
|
|
assert.equal(h.calls.length,1);
|
|
assert.equal(h.element('#arr-data-review-message').textContent,'data_review.complete_first');
|
|
});
|
|
|
|
test('blank text is not silently confirmed; explicit empty saves its revision and reloads the review',async()=>{
|
|
let current=review([item()]);
|
|
const h=harness((url,options)=>{
|
|
if(options.method==='POST') {
|
|
assert.equal(url,`/api/arr-downloads/${requestId}/data-review/items/1%3ABLOCK_CODE`);
|
|
assert.deepEqual(JSON.parse(options.body),{revision:1,value:''});
|
|
current=review([item({confirmed:true,value:''})],2);
|
|
return response(200,{revision:2});
|
|
}
|
|
return response(200,current);
|
|
});
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
const row=h.row('1:BLOCK_CODE','');
|
|
await h.saveARRDataReviewItem(row.button);
|
|
assert.equal(h.calls.length,1);
|
|
assert.equal(h.element('#arr-data-review-message').textContent,'data_review.use_empty');
|
|
await h.saveARRDataReviewItem(row.button,{empty:true});
|
|
assert.equal(h.calls.length,3);
|
|
assert.equal(h.state.arrDataReview.revision,2);
|
|
assert.equal(h.arrDataReviewCanFinalize(),true);
|
|
assert.equal(h.element('#arr-data-review-finalize').disabled,false);
|
|
});
|
|
|
|
test('products are an ordered code array and explicit empty products are an empty array',()=>{
|
|
const h=harness(()=>{throw new Error('no request');});
|
|
const product=item({field:'PRODUCTS'});
|
|
assert.deepEqual(plain(h.parseARRDataReviewValue(product,'BREAKFAST\nDINNER\nBREAKFAST',false)),['BREAKFAST','DINNER','BREAKFAST']);
|
|
assert.deepEqual(plain(h.parseARRDataReviewValue(product,'ignored',true)),[]);
|
|
assert.equal(h.parseARRDataReviewValue(item({field:'EFFECTIVE_RATE_AMOUNT'}),'900.000000000001',false),'900.000000000001');
|
|
assert.equal(h.parseARRDataReviewValue(item({field:'ADULTS'}),'0',false),'0');
|
|
assert.throws(()=>h.parseARRDataReviewValue(item({field:'ADULTS'}),'1.5',false));
|
|
assert.throws(()=>h.parseARRDataReviewValue(item({field:'ARRIVAL'}),'2026-02-30',false));
|
|
});
|
|
|
|
test('revision conflict reloads the current revision before any next save',async()=>{
|
|
let reads=0;
|
|
const h=harness((url,options)=>options.method==='POST'
|
|
? response(409,null,'ARR_DATA_REVIEW_CONFLICT')
|
|
: response(200,review([item()],++reads===1 ? 1 : 5)));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
const row=h.row('1:BLOCK_CODE','VERIFIED');
|
|
h.trackARRDataReviewDraft(row.input);
|
|
await h.saveARRDataReviewItem(row.button);
|
|
assert.equal(h.state.arrDataReview.revision,5);
|
|
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'VERIFIED');
|
|
assert.equal(h.element('#arr-data-review-message').textContent,'data_review.conflict');
|
|
assert.equal(h.state.arrDataReviewMutating,false);
|
|
});
|
|
|
|
test('unconfirmed source values are never prefilled and all labels and stored values are escaped',async()=>{
|
|
const h=harness(()=>response(200,review([
|
|
item({company_name:'<img src=x onerror=alert(1)>',value:'GUESSED-BLOCK'}),
|
|
item({item_id:'2:RES_COMMENT',field:'RES_COMMENT',confirmed:true,value:'</textarea><script>bad()</script>'}),
|
|
])));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
const markup=h.element('#arr-data-review-body').innerHTML;
|
|
assert.equal(markup.includes('GUESSED-BLOCK'),false);
|
|
assert.equal(markup.includes('<img'),false);
|
|
assert.equal(markup.includes('<script>'),false);
|
|
assert.match(markup,/<img/);
|
|
assert.match(markup,/<\/textarea>/);
|
|
});
|
|
|
|
test('unsaved edits of confirmed fields block finalization until explicitly saved',async()=>{
|
|
const h=harness(()=>response(200,review([item({confirmed:true,value:'FIRST'})])));
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
assert.equal(h.arrDataReviewCanFinalize(),true);
|
|
const row=h.row('1:BLOCK_CODE','SECOND');
|
|
h.trackARRDataReviewDraft(row.input);
|
|
await h.finalizeARRDataReview();
|
|
assert.equal(h.arrDataReviewCanFinalize(),false);
|
|
assert.equal(h.calls.length,1);
|
|
row.input.value='FIRST';
|
|
h.trackARRDataReviewDraft(row.input);
|
|
assert.equal(h.arrDataReviewCanFinalize(),true);
|
|
});
|
|
|
|
test('lost save response re-reads durable confirmation rather than resubmitting the item',async()=>{
|
|
let current=review([item()]);
|
|
const h=harness((url,options)=>{
|
|
if(options.method==='POST') {
|
|
current=review([item({value:'VERIFIED',confirmed:true})],2);
|
|
throw new TypeError('lost response');
|
|
}
|
|
return response(200,current);
|
|
});
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
const row=h.row('1:BLOCK_CODE','VERIFIED');
|
|
h.trackARRDataReviewDraft(row.input);
|
|
await h.saveARRDataReviewItem(row.button);
|
|
assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
|
|
assert.equal(h.state.arrDataReview.revision,2);
|
|
assert.equal(h.arrDataReviewCanFinalize(),true);
|
|
});
|
|
|
|
test('finalize uses the same request and a lost response reconciles processing without a new download',async()=>{
|
|
let finalized=false;
|
|
const h=harness((url,options)=>{
|
|
if(options.method==='POST') {
|
|
assert.equal(url,`/api/arr-downloads/${requestId}/data-review/finalize`);
|
|
assert.deepEqual(JSON.parse(options.body),{revision:7});
|
|
finalized=true;
|
|
throw new TypeError('lost finalize response');
|
|
}
|
|
if(url===`/api/arr-downloads/${requestId}`) return response(200,{...task,status:'processing'});
|
|
return response(200,review([item({confirmed:true,value:''})],7));
|
|
});
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
await h.finalizeARRDataReview();
|
|
assert.equal(finalized,true);
|
|
assert.equal(h.state.arrDownloadTask.status,'processing');
|
|
assert.equal(h.state.arrDownloadIntent.request_id,requestId);
|
|
assert.equal(h.state.arrDataReview,null);
|
|
assert.equal(h.state.arrDataReviewFinalizing,false);
|
|
assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
|
|
assert.equal(h.calls.some(call=>call.url==='/api/arr-downloads'),false);
|
|
});
|
|
|
|
test('an uncertain frozen finalize stays locked when the task and review cannot be reconciled',async()=>{
|
|
let finalizing=false;
|
|
const h=harness((url,options)=>{
|
|
if(options.method==='POST') {finalizing=true;throw new TypeError('offline');}
|
|
if(finalizing) throw new TypeError('offline');
|
|
return response(200,review([item({confirmed:true,value:''})]));
|
|
});
|
|
await h.acceptARRDownloadTask(task,{sync:false});
|
|
await h.finalizeARRDataReview();
|
|
assert.equal(h.state.arrDataReviewFinalizing,true);
|
|
assert.equal(h.state.arrDataReviewDisconnected,true);
|
|
assert.equal(h.element('#arr-data-review-finalize').disabled,true);
|
|
await h.submit();
|
|
assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
|
|
});
|