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

599 lines
34 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 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('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,/&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);
});