const test=require('node:test'); const assert=require('node:assert/strict'); const fs=require('node:fs'); const vm=require('node:vm'); const parser=require('../../static/js/case-commands.js'); const completion=require('../../static/js/case-command-suggestions.js'); class Element { constructor(tag='div') {this.tag=tag;this.children=[];this.dataset={};this.listeners={};this.value='';this.disabled=false;this.selectionStart=0;this.classList={toggle(){},add(){},remove(){}};} append(...children){this.children.push(...children);} after(){} add(option){this.children.push(option);} setAttribute(){} replaceChildren(...children){this.children=children;} addEventListener(type,handler){this.listeners[type]=handler;} focus(){} setRangeText(value,start,end){this.value=this.value.slice(0,start)+value+this.value.slice(end);this.selectionStart=start+value.length;} setSelectionRange(start,end){this.selectionStart=start;this.selectionEnd=end;} querySelectorAll(selector){const list=this.children.flatMap(child=>[child,...child.querySelectorAll(selector)]);return list.filter(child=>selector.split(',').some(tag=>child.tag===tag));} querySelector(selector){if(selector.startsWith('[data-line=')) return this.children.find(child=>child.dataset.line===selector.match(/"(\d+)"/)[1]);return null;} scrollIntoView(){} click(){this.listeners.click?.();} } async function fixture(handler) { const input=new Element('textarea'),button=new Element('button'),composer=new Element(),form=new Element('form'); form.querySelector=selector=>selector.includes('textarea') ? input : selector.includes('submit') ? button : composer; const panelNodes=[],data={textContent:JSON.stringify({caseId:186})},storage=new Map(),calls=[]; const document={createTextNode:text=>{const node=new Element('text');node.textContent=text;return node;},readyState:'complete',getElementById:id=>id==='comment-form' ? form : id==='caseV5Data' ? data : null,createElement:tag=>{const element=new Element(tag);panelNodes.push(element);return element;}}; const context={document,CaseCommands:parser,CaseCommandSuggestions:completion,Option:function(text,value){const option=new Element('option');option.textContent=text;option.value=value;return option;},crypto:{randomUUID:()=>`uuid-${calls.length}`},Date,Map,setTimeout:()=>1,clearTimeout(){},sessionStorage:{getItem:key=>storage.get(key)||null,setItem:(key,value)=>storage.set(key,value),removeItem:key=>storage.delete(key)},fetch:async(url,options)=>{ if(url==='/api/v1/auth/me')return {ok:true,json:async()=>({id:17})}; if(url.includes('billing-options'))return {ok:true,json:async()=>({has_customer:true,hourly_rate:100})}; if(url.includes('command-mail-drafts'))return {ok:true,json:async()=>[]}; const body=JSON.parse(options.body);calls.push({url,body});return handler(url,body,calls); }}; context.window=context;vm.createContext(context); vm.runInContext(fs.readFileSync('static/js/case-command-composer.js','utf8'),context); for(let i=0;i<10;i++)await Promise.resolve(); const type=text=>{input.value=text;input.selectionStart=text.length;input.listeners.input();}; return {controller:context.CaseCommandComposer,input,button,type,calls,nodes:panelNodes,storage}; } const response=(status,data)=>({ok:status<400,status,json:async()=>data}); test('network uncertainty retries the exact command payload and UUID',async()=>{ let attempts=0; const f=await fixture((url,body)=>{ if(url.endsWith('preview'))return response(200,{commands:body.commands}); if(++attempts===1)throw new Error('offline'); return response(200,{results:[{type:'time',case_id:186,id:81}]}); }); f.type('/tid 30m remote "Support"'); await assert.rejects(f.controller.submit(),/offline/); assert.equal(f.input.readOnly,true); const first=f.calls.find(call=>call.url.endsWith('execute')).body; await f.controller.submit(); const executes=f.calls.filter(call=>call.url.endsWith('execute')); assert.deepEqual(executes[1].body,first); assert.equal(f.input.value,'');assert.equal(f.input.readOnly,false); }); test('definite 409 allows editing instead of trapping the draft',async()=>{ const f=await fixture((url,body)=>url.endsWith('preview') ? response(200,{commands:body.commands}) : response(409,{detail:'Timer allerede startet'})); f.type('/timer start "Support"'); await assert.rejects(f.controller.submit(),/Timer allerede/); assert.equal(f.input.readOnly,false);assert.equal(f.input.value,'/timer start "Support"'); }); test('save freezes input before preview responds and preserves separate billing per line',async()=>{ let release; const f=await fixture((url,body)=>url.endsWith('preview') ? new Promise(resolve=>{release=()=>resolve(response(200,{commands:body.commands}));}) : response(200,{results:[]})); f.type('/tid 30m remote "Support"\n/tid 30m remote "Support"'); const choices=f.nodes.filter(node=>node.tag==='select').slice(-2); choices[0].value='operations';choices[0].listeners.change(); const actual=f.controller.getParsed().commands; assert.equal(actual[0].settlement_choice,'operations');assert.equal(actual[1].settlement_choice,'draft'); const promise=f.controller.submit();assert.equal(f.input.readOnly,true); release();await promise; const payload=f.calls.find(call=>call.url.endsWith('execute')).body; assert.equal(payload.commands[0].settlement_choice,'operations');assert.equal(payload.commands[1].settlement_choice,'draft'); }); test('grouped help inserts an example without executing it',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); const sections=f.nodes.filter(node=>node.className==='case-command-help-group'); assert.equal(sections.length,4); const time=f.nodes.find(node=>node.className==='case-command-help-item' && node.children[0].textContent==='/tid'); time.click(); assert.equal(f.controller.getParsed().commands[0].type,'time'); assert.equal(f.controller.getParsed().commands[0].minutes,30); assert.equal(f.calls.length,0); }); test('suggestion menu continues from command to minutes to category',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); f.type('/ti'); f.nodes.find(node=>node.tag==='button' && node.className!=='case-command-help-item' && node.children[0]?.textContent==='/tid').click(); assert.equal(f.input.value,'/tid '); f.nodes.find(node=>node.tag==='button' && node.children[0]?.textContent==='30m').click(); assert.equal(f.input.value,'/tid 30m '); assert.ok(f.nodes.some(node=>node.tag==='button' && node.children[0]?.textContent==='research')); });