Files
ARR-2.0-0918/tests/javascript/arr_data_review.cjs
T

352 lines
19 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} = 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));
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('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,/&lt;img/);
assert.match(markup,/&lt;\/textarea&gt;/);
});
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);
});