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.setSelectionRange(text.length,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 uses automatic billing for every 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"'); assert.equal(f.nodes.filter(node=>node.tag==='select').length,0); const actual=f.controller.getParsed().commands; assert.equal(actual[0].settlement_choice,undefined);assert.equal(actual[1].settlement_choice,undefined); 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.billing_mode,'automatic'); assert.equal(payload.commands[0].settlement_choice,undefined);assert.equal(payload.commands[1].settlement_choice,undefined); }); 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')); }); test('required quotes appear automatically after typing the preceding arguments',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); for(const text of ['/todo ', '/note ', '/tildel ct ', '/tid 30m research ', '/timer start ', '//gruppe Support ']) { f.type(text); assert.equal(f.input.value,text+'""'); assert.equal(f.input.selectionStart,text.length+1); } }); test('selecting a category inserts description quotes immediately',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); f.type('/tid 30m re'); f.nodes.find(node=>node.tag==='button' && node.children[0]?.textContent==='research').click(); assert.equal(f.input.value,'/tid 30m research ""'); assert.equal(f.input.selectionStart,f.input.value.length-1); }); test('Enter inside command quotes keeps the closing quote on the command line',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); for(const text of ['/todo ""','/note "Tekst"','/tid 30m research "Arbejde"','//todo "Opgave" @ct fredag']) { f.type(text); const caret=text.indexOf('"')+1; f.input.setSelectionRange(caret,caret); let prevented=false; f.input.listeners.keydown({key:'Enter',preventDefault(){prevented=true;}}); assert.equal(prevented,true); assert.equal(f.input.value,text+'\n'); assert.equal(f.input.selectionStart,text.length+1); } }); test('Enter leaves ordinary prose and selections to native editing',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); f.type('Tekst "citat"');f.input.setSelectionRange(8,8); f.input.listeners.keydown({key:'Enter',preventDefault(){assert.fail('ordinary prose intercepted');}}); f.type('/note "tekst"');f.input.setSelectionRange(8,10); f.input.listeners.keydown({key:'Enter',preventDefault(){assert.fail('selection intercepted');}}); }); test('Enter chooses the first suggestion without arrow navigation',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); f.type('/ti'); let prevented=false; f.input.listeners.keydown({key:'Enter',preventDefault(){prevented=true;}}); assert.equal(prevented,true); assert.equal(f.input.value,'/tid '); assert.equal(f.calls.length,0); }); test('Enter honours an explicitly selected suggestion',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); f.type('/ti'); const press=key=>f.input.listeners.keydown({key,preventDefault(){}}); press('ArrowDown');press('ArrowDown');press('Enter'); assert.equal(f.input.value,'/tildel '); }); test('Shift Enter does not accept a suggestion',async()=>{ const f=await fixture((url,body)=>response(200,{commands:body.commands})); f.type('/ti'); f.input.listeners.keydown({key:'Enter',shiftKey:true,preventDefault(){assert.fail('Shift Enter intercepted');}}); assert.equal(f.input.value,'/ti'); });