const {test}=require('node:test'); const assert=require('node:assert/strict'); const vm=require('node:vm'); const fs=require('node:fs'); const html=fs.readFileSync('app/economy/frontend/time_queue.html','utf8'); function fixture(rows=[]){ const controls={};for(const id of ['approveBtn','stickyApproveBtn','settlementMethod','prepaidCard','agreementId','workflowLineMode','workflowLineModeHost','setMethodBtn','previewBtn','stickyPreviewBtn','selectionText','previewPanel','settleBtn','filterMethod','filterCustomer','filterStatus','filterCaseState','filterBillable','filterQuery'])controls[id]={value:'',disabled:false,classList:{add(){},toggle(){}}}; controls.workflowLineMode.value='separate'; const state={scope:'active',items:rows,selected:new Set(rows.map(x=>x.id)),triage:'all',preview:{valid:true},previewPayload:{ids:[1]},previewFingerprint:'old'}; const ctx={state,$:id=>controls[id],norm:v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[v]||v||'draft',ids:()=>[...state.selected],selectedRows:()=>rows,confirm:()=>true,Number,JSON}; vm.createContext(ctx);vm.runInContext(html.slice(html.indexOf(' function actionable('),html.indexOf(' function status(')),ctx); return {ctx,controls,state}; } test('workflow stale, absent or manual review never inherits invoice fallback',()=>{ const {ctx}=fixture(); assert.equal(ctx.effectiveMethod({workflow_active:true,billing_method:'invoice'}),'draft'); assert.equal(ctx.effectiveMethod({workflow_active:true,workflow_stale:true,workflow_decision:{method:'invoice',status:'decided'}}),'draft'); assert.equal(ctx.effectiveMethod({workflow_active:true,workflow_decision:{method:'invoice',status:'manual_review'}}),'manual_review'); assert.match(ctx.reviewIssue({workflow_active:true,workflow_stale:true}),/Genberegn/); }); test('readiness flags missing card/agreement and accepts concrete workflow selection',()=>{ const {ctx}=fixture();const base={original_hours:1,customer_id:9,status:'pending'}; assert.match(ctx.reviewIssue({...base,billing_method:'prepaid'}),/klippekort/); assert.match(ctx.reviewIssue({...base,billing_method:'fixed_price'}),/aftale/); assert.equal(ctx.reviewIssue({...base,workflow_active:true,workflow_decision:{method:'subscription',status:'decided'},fixed_price_agreement_id:2}),''); assert.match(ctx.reviewIssue({...base,billing_method:'invoice',billable:false}),/ikke-fakturerbar/); }); test('mixed selection disables bulk overrides and preview instead of leaking configuration',()=>{ const {ctx,controls}=fixture([{id:1,workflow_active:true},{id:2,workflow_active:false}]); controls.settlementMethod.value='invoice';controls.prepaidCard.value='7';ctx.syncWorkflowControls(); assert.equal(controls.setMethodBtn.disabled,true);assert.equal(controls.previewBtn.disabled,true);assert.equal(controls.stickyPreviewBtn.disabled,true);assert.equal(controls.settlementMethod.value,'');assert.equal(controls.prepaidCard.value,'');assert.match(controls.selectionText.textContent,/hver for sig/); }); test('preview fingerprint tracks billing inputs, filters, selection and line grouping',()=>{ const {ctx,controls,state}=fixture([{id:1,workflow_active:true}]);const initial=ctx.fingerprint(); controls.workflowLineMode.value='combined';assert.notEqual(ctx.fingerprint(),initial); const grouped=ctx.fingerprint();controls.filterQuery.value='ABA';assert.notEqual(ctx.fingerprint(),grouped); const filtered=ctx.fingerprint();state.selected.add(2);assert.notEqual(ctx.fingerprint(),filtered); const selected=ctx.fingerprint();controls.agreementId.value='42';assert.notEqual(ctx.fingerprint(),selected); ctx.invalidatePreview();assert.equal(state.preview,null);assert.equal(state.previewPayload,null);assert.equal(controls.settleBtn.disabled,true); }); test('settlement sends frozen preview request, never a mutated billing configuration',async()=>{ const {ctx,controls,state}=fixture([{id:1}]);let sent=null,sentUrl=null;ctx.message=()=>{};ctx.api=async(url,options)=>{sentUrl=url;sent=JSON.parse(options.body);return {}};ctx.loadCustomers=async()=>{};ctx.load=async()=>{}; state.preview={valid:true};state.previewPayload={ids:[1],line_mode:'separate',snapshots:[{id:1,updated_at:'reviewed'}]};state.previewFingerprint=ctx.fingerprint(); vm.runInContext(html.slice(html.indexOf(' async function settle(){'),html.indexOf(' function selectionChanged()')),ctx); await ctx.settle();assert.match(sentUrl,/send-reviewed$/);assert.equal(sent.snapshots[0].updated_at,'reviewed');assert.equal(sent.line_mode,'separate');assert.deepEqual(sent.ids,[1]); state.preview={valid:true};state.previewPayload={ids:[1],billing_method:'invoice'};state.previewFingerprint=ctx.fingerprint();controls.agreementId.value='99';sent=null; await ctx.settle();assert.equal(sent,null);assert.equal(state.preview,null); }); test('triage and select-ready respect the visible billing filter',()=>{ const rows=[{id:1,can_approve:true,billing_method:'invoice',original_hours:1,customer_id:9},{id:2,can_approve:true,billing_method:'draft',original_hours:1},{id:3,can_approve:true,billing_method:'prepaid',original_hours:1,prepaid_card_id:8}]; const {ctx,controls,state}=fixture(rows);vm.runInContext(html.slice(html.indexOf(' function methodFilteredItems('),html.indexOf(' function render(){')),ctx); state.triage='action';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[2]);state.triage='ready';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[1,3]); controls.filterMethod.value='prepaid';ctx.selectionChanged=()=>{};vm.runInContext(html.slice(html.indexOf("$('selectReadyBtn').onclick="),html.indexOf(";['workflowLineMode'",html.indexOf("$('selectReadyBtn').onclick="))),Object.assign(ctx,{ $:id=>id==='selectReadyBtn'?(controls.selectReadyBtn??={}):controls[id]}));controls.selectReadyBtn.onclick();assert.deepEqual([...state.selected],[3]); }); test('case tags escape names without inserting customer-controlled styles',()=>{ const {ctx}=fixture();ctx.esc=v=>String(v).replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); const out=ctx.caseTags({case_tags:[{name:'ABA