Files
wo_chovy/tests/test_frontend.cjs
2026-09-21 19:49:26 +08:00

103 lines
9.8 KiB
JavaScript

// DOM 单元测试:不驱动浏览器,不联网,不写业务数据库。
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const {JSDOM, VirtualConsole} = require('jsdom');
const root = path.resolve(__dirname, '..');
const failures=[];const calls=[];
const consoleSink=new VirtualConsole();consoleSink.on('jsdomError',e=>failures.push(e));
const dom=new JSDOM(fs.readFileSync(path.join(root,'frontend/index.html'),'utf8'),{url:'http://localhost:8004/',runScripts:'outside-only',pretendToBeVisual:true,virtualConsole:consoleSink});
const w=dom.window,d=w.document;
w.HTMLDialogElement.prototype.showModal=function(){this.open=true;};
w.HTMLDialogElement.prototype.close=function(){this.open=false;};
w.HTMLElement.prototype.setPointerCapture=function(){};
w.HTMLElement.prototype.hasPointerCapture=function(){return false;};
Object.defineProperty(d.documentElement,'clientWidth',{value:1280});
const records={
students:[{sid:1,student_no:'S001',student_name:'测试学生',class_id:1,advisor_id:1,class_name:'测试班级',advisor_name:'测试顾问',age:20,gender:'女',state:'在读',enrollment_time:'2026-09-01'}],
classes:[{cid:1,class_name:'测试班级',head_teacher:'甲老师',teacher:'乙老师',start_time:'2026-09-01T08:00:00'}],
teachers:[{tid:1,t_name:'测试教师',phone:'13800000001',subject:'Python',entry_time:'2026-09-01'}],
advisors:Array.from({length:17},(_,i)=>({id:i+1,advisor_name:'测试顾问'+(i+1),phone:'138****0002',gender:'女'})),
scores:[{id:1,student_id:1,student_name:'测试学生',student_no:'S001',class_id:1,class_name:'测试班级',exam_id:1,score:85}],
employment:[{id:1,student_id:1,student_name:'测试学生',student_no:'S001',class_name:'测试班级',company_name:'测试公司',salary:12345,employment_start_time:'2026-09-10T00:00:30',offer_time:'2026-09-01T00:00:00',duration_days:null}],
teacher_classes:[],teacher_students:[]
};
w.fetch=async(url,options={})=>{
const payload=options.body?JSON.parse(options.body):null;calls.push({url,options,payload});let data={};
if(url.endsWith('/workspace/overview'))data={counts:{students:1,classes:1,teachers:1,advisors:17,scores:1,employment:1},offers:1,average_salary:12345,salary_samples:1};
else if(url.endsWith('/workspace/query')){
let rows=records[payload.module]||[];
if(payload.aggregate)rows=[{class_name:'测试班级',gender:'女',student_name:'测试学生',student_id:1,exam_id:1,value:85}];
const page=payload.page||1,size=payload.page_size||10;
data={items:rows.slice((page-1)*size,page*size),total:rows.length,pages:Math.max(1,Math.ceil(rows.length/size)),page,page_size:size,truncated:rows.length>size};
}else if(url.endsWith('/ai/status'))data={configured:true};
else if(url.endsWith('/ai/chat'))data={answer:'共 **1** 条记录。<script>window.injected=true</script>',sources:['业务数据库'],data:{queries:[{total:1}]}};
return {ok:true,status:200,json:async()=>data};
};
for(const name of ['app.js','ai.js','floating-assistant.js'])vm.runInContext(fs.readFileSync(path.join(root,'frontend',name),'utf8'),dom.getInternalVMContext(),{filename:name});
const wait=ms=>new Promise(r=>setTimeout(r,ms));
async function settle(){await wait(30);if(failures.length)throw failures[0];}
function click(id){d.getElementById(id).click();}
function submit(id){d.getElementById(id).dispatchEvent(new w.Event('submit',{bubbles:true,cancelable:true}));}
async function route(module){w.location.hash=module;await settle();}
const lastQuery=()=>calls.filter(c=>c.url.endsWith('/workspace/query')).at(-1).payload;
(async()=>{
await settle();assert.match(d.getElementById('table-body').textContent,/测试公司/);
for(const module of ['students','classes','teachers','advisors','scores','employment']){
await route(module);assert.equal(d.getElementById('module-view').hidden,false);assert.ok(d.querySelector('#table-body [data-action="edit"]'),module+' rows');
click('add-button');await settle();assert.equal(d.getElementById('edit-dialog').open,true);assert.ok(d.querySelectorAll('#edit-fields input, #edit-fields select').length>0,module+' form');
for(const input of d.querySelectorAll('#edit-fields input, #edit-fields select')){
const key=input.name;input.value=key==='phone'?'13800000008':key==='student_no'?'NEW001':input.tagName==='SELECT'?input.options[1]?.value||'':input.type==='date'?'2026-09-01':input.type==='datetime-local'?'2026-09-01T08:00':input.type==='number'?'20':'测试内容';
}
submit('edit-form');await settle();assert.equal(d.getElementById('edit-dialog').open,false,module+' save');
const write=calls.filter(c=>c.options.method==='POST'&&!c.url.endsWith('/query')).at(-1);assert.ok(write,module+' create API');
d.querySelector('#table-body [data-action="edit"]').click();await settle();
if(module==='advisors')assert.equal(d.getElementById('field-phone').value,'','masked phone must not be sent back');
submit('edit-form');await settle();assert.equal(d.getElementById('edit-dialog').open,false,module+' edit');
d.querySelector('#table-body [data-action="detail"]').click();await settle();assert.match(d.getElementById('detail-content').textContent,/测试/);d.getElementById('detail-dialog').close();
d.querySelector('#table-body [data-action="delete"]').click();click('confirm-delete');await settle();assert.equal(d.getElementById('delete-dialog').open,false,module+' delete');
}
await route('advisors');click('next-page');await settle();assert.equal(lastQuery().page,2);assert.match(d.getElementById('table-body').textContent,/测试顾问9/);
await route('employment');d.querySelector('[name=min]').value='20000';d.querySelector('[name=max]').value='10000';submit('filter-form');assert.equal(d.getElementById('page-error').hidden,false);
d.querySelector('[name=min]').value='10000';d.querySelector('[name=max]').value='20000';submit('filter-form');await settle();assert.equal(lastQuery().filters[0].field,'salary');
// 就业时间规则:失败时不能发送新增/修改请求,允许相同时间和日期未确定。
const jobWrites=()=>calls.filter(c=>/\/(addEmployment|modifyEmployment\/)/.test(c.url));
async function employmentDates(edit,start,offer,blocked){
if(edit)d.querySelector('#table-body [data-action="edit"]').click();else click('add-button');
await settle();
if(!edit)d.getElementById('field-student_id').value='1';
d.getElementById('field-employment_start_time').value=start;
d.getElementById('field-offer_time').value=offer;
const before=jobWrites().length;submit('edit-form');await settle();
assert.equal(jobWrites().length,before+(blocked?0:1),`date validation: edit=${edit}, start=${start}, offer=${offer}`);
assert.equal(d.getElementById('edit-dialog').open,blocked);
if(blocked){assert.match(d.getElementById('edit-error').textContent,/就业开放时间不能早于 Offer 下发时间/);assert.equal(d.getElementById('save-button').disabled,false);d.getElementById('edit-dialog').close();}
}
await employmentDates(false,'2026-09-01T08:00','2026-09-02T08:00',true);
await employmentDates(false,'2026-09-02T07:59:59','2026-09-02T08:00:00',true);
await employmentDates(false,'2026-09-02T08:00','2026-09-02T08:00',false);
await employmentDates(false,'2026-09-03T08:00','2026-09-02T08:00',false);
await employmentDates(false,'','2026-09-02T08:00',false);
await employmentDates(false,'2026-09-02T08:00','',false);
await employmentDates(true,'2026-09-01T08:00','2026-09-02T08:00',true);
await employmentDates(true,'','2026-09-10T00:00:31',true); // 开放时间留空,沿用原有30秒
await employmentDates(true,'2026-08-31T23:59:59','',true); // Offer留空,沿用原日期
await employmentDates(true,'','2026-09-10T00:00:30',false);
assert.equal(jobWrites().at(-1).payload.employment_start_time,undefined);
d.querySelector('#table-body [data-action="edit"]').click();await settle();
assert.equal(new Date(d.getElementById('field-employment_start_time').value).getTime(),new Date('2026-09-10T00:00:30').getTime(),'editing preserves seconds');
d.getElementById('edit-dialog').close();
console.log('PASS: employment date order; blocked requests, equality, optional dates, partial edits and second precision');
await route('statistics');for(const option of d.getElementById('stats-kind').options){d.getElementById('stats-kind').value=option.value;d.getElementById('stats-kind').dispatchEvent(new w.Event('change'));await settle();assert.ok(d.getElementById('stats-body').textContent.trim());}
await route('ai');d.getElementById('ai-question').value='查询学生总数';submit('ai-form');await settle();assert.match(d.getElementById('ai-messages').textContent,/共 1 条记录/);assert.equal(w.injected,undefined);assert.equal(d.querySelector('#ai-messages script'),null);
const trigger=d.getElementById('floating-ai-trigger');trigger.getBoundingClientRect=()=>({left:parseFloat(trigger.style.left)||1100,top:parseFloat(trigger.style.top)||600,width:100,height:120,right:1200,bottom:720});
function pointer(name,x,y){const event=new w.Event(name,{bubbles:true});Object.assign(event,{pointerId:1,isPrimary:true,button:0,clientX:x,clientY:y});trigger.dispatchEvent(event);}
pointer('pointerdown',1150,650);pointer('pointermove',850,450);pointer('pointerup',850,450);assert.equal(trigger.style.left,'800px');assert.equal(trigger.style.top,'400px');
trigger.click();assert.equal(d.getElementById('floating-ai-panel').hidden,false);
d.getElementById('floating-ai-question').value='查询班级信息';submit('floating-ai-form');await settle();assert.match(d.getElementById('floating-ai-messages').textContent,/共 1 条记录/);click('floating-ai-close');assert.equal(d.getElementById('floating-ai-panel').hidden,true);
assert.equal(failures.length,0);console.log('PASS: six-module forms, CRUD requests, details, pagination, filters, 8 statistics, full/floating AI, escaped answers, avatar drag events');
dom.window.close();
})().catch(error=>{console.error(error);dom.window.close();process.exitCode=1;});