// 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, '..'); async function runSuite(pageUrl){ 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:pageUrl,runScripts:'outside-only',pretendToBeVisual:true,virtualConsole:consoleSink}); const w=dom.window,d=w.document; try { const base=new URL('.',pageUrl),apiBase=new URL('api/studentsManagement',base).href; const assets=d.querySelectorAll('link[rel="stylesheet"], script[src], img[src]'); assert.equal(assets.length,9,'all styles, scripts and avatars are covered'); for(const asset of assets)assert.ok((asset.href||asset.src).startsWith(new URL('static/',base).href),'asset stays under the application path'); assert.equal(d.querySelector('a[target="_blank"]').href,new URL('docs',base).href,'API docs stay under the application path'); 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-11T00:00:00',duration_days:0.9996527778}], teacher_classes:[],teacher_students:[] }; w.fetch=async(url,options={})=>{ assert.ok(url.startsWith(apiBase+'/'),'API request stays under the application path: '+url); 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** 条记录。',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; 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',false); await employmentDates(false,'2026-09-02T08:00:00','2026-09-02T07:59:59',true); await employmentDates(false,'2026-09-02T08:00','2026-09-02T08:00',false); await employmentDates(false,'2026-09-03T08:00','2026-09-02T08:00',true); await employmentDates(false,'','2026-09-02T08:00',false); await employmentDates(false,'2026-09-02T08:00','',false); await employmentDates(true,'2026-09-03T08:00','2026-09-02T08:00',true); await employmentDates(true,'','2026-09-10T00:00:29',true); // 开放时间留空,沿用原有30秒 await employmentDates(true,'2026-09-11T00:00:01','',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'); assert.equal(calls.filter(c=>c.url===apiBase+'/ai/chat').length,2,'full and floating AI use the shared API base'); console.log('PASS: assets, docs and all CRUD/AI requests for '+pageUrl); } finally {dom.window.close();} } (async()=>{ for(const pageUrl of ['http://localhost:8004/','https://example.test/wolin/?from=portal#employment'])await runSuite(pageUrl); })().catch(error=>{console.error(error);process.exitCode=1;});