bmc_hub/tests/js/case-command-composer.test.cjs

159 lines
9.6 KiB
JavaScript
Raw Permalink Normal View History

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');
});