Files

183 lines
11 KiB
JavaScript

import {createServer} from 'vite'
import {_electron as electron,expect} from '@playwright/test'
import electronPath from 'electron'
import ExcelJS from 'exceljs'
import fs from 'node:fs/promises'
import path from 'node:path'
// All data and dialog results belong to this isolated run.
const root=process.cwd(),qa=path.join(root,'.qa','loading')
await fs.mkdir(qa,{recursive:true})
const run=await fs.mkdtemp(path.join(qa,'run-')),profile=path.join(run,'profile'),seals=path.join(run,'seals')
await fs.mkdir(seals)
const file=path.join(run,'动画验证.xlsx'),template=path.join(run,'模板.xlsx')
const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
sheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额'])
sheet.addRow(['2026-10-16','测试学校','高2028届','测试姓名','服务费',3500])
await book.xlsx.writeFile(file)
const server=await createServer({server:{host:'127.0.0.1',port:5176,strictPort:true}});await server.listen()
const env={...process.env,ELECTRON_RENDERER_URL:'http://127.0.0.1:5176'};delete env.ELECTRON_RUN_AS_NODE
const checks=[],frames=[],errors=[]
let desktop
try{
desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode','--user-data-dir='+profile],cwd:root,env})
const page=await desktop.firstWindow();page.setDefaultTimeout(15000);page.on('pageerror',e=>errors.push(e.message))
await page.getByText('把表格交给收据工坊').waitFor()
// Hidden Chromium windows do not advance CSS timelines, so inspect a visible, unfocused test window.
await desktop.evaluate(({BrowserWindow,dialog,shell})=>{
BrowserWindow.getAllWindows()[0].showInactive()
const picker=()=>new Promise(resolve=>{globalThis.finishPicker=resolve})
dialog.showOpenDialog=picker;dialog.showSaveDialog=picker
shell.openPath=async()=>new Promise(resolve=>{globalThis.finishOpen=()=>resolve('')})
})
async function arm(scope){
await page.evaluate(scope=>{
window.loadingTrace=new Promise((resolve,reject)=>{
let start
const transforms=[],deadline=performance.now()+10000
function sample(now){
const region=document.querySelector('[data-region="'+scope+'"]'),spinner=region?.querySelector('.region-spinner')
if(spinner&&region.getAttribute('aria-busy')==='true'){
start??=now;transforms.push(getComputedStyle(spinner).transform)
}else if(start!==undefined){resolve({duration:now-start,frames:transforms.length,uniqueTransforms:new Set(transforms).size});return}
if(now>deadline){reject(Error('No loading cycle in '+scope));return}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
})
},scope)
}
async function checkTrace(name){
const result=await page.evaluate(()=>window.loadingTrace)
expect(result.duration).toBeGreaterThanOrEqual(490)
expect(result.duration).toBeLessThan(1800)
expect(result.frames).toBeGreaterThan(8);expect(result.uniqueTransforms).toBeGreaterThan(5)
frames.push({name,...result})
await expect(page.getByRole('status')).toHaveCount(0)
}
async function noLoading(scope){
const region=page.locator('[data-region="'+scope+'"]')
await expect(region).not.toHaveAttribute('aria-busy','true')
await expect(region.locator('.region-loading')).toHaveCount(0)
expect(await region.locator('.region-content').evaluate(el=>el.inert)).toBe(false)
}
async function resolvePicker(value){
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
await desktop.evaluate((_,value)=>{globalThis.finishPicker(value);delete globalThis.finishPicker},value)
}
async function watchNoMasks(){
await page.evaluate(()=>{
window.maskCount=0
window.maskObserver=new MutationObserver(records=>{
for(const r of records)for(const n of r.addedNodes)if(n instanceof Element&&(n.matches('.region-loading')||n.querySelector('.region-loading')))window.maskCount++
})
window.maskObserver.observe(document.body,{childList:true,subtree:true})
})
}
async function stopWatching(){
const count=await page.evaluate(()=>{window.maskObserver.disconnect();return window.maskCount})
expect(count).toBe(0)
}
await page.getByRole('button',{name:'偏好设置',exact:true}).click()
for(const reducedMotion of ['no-preference','reduce']){
await page.emulateMedia({reducedMotion})
await page.getByLabel('默认出纳',{exact:true}).fill('动画验证 '+reducedMotion)
await arm('settings');await page.locator('button[data-task="settings"]').click()
await checkTrace('save / '+reducedMotion)
}
checks.push('Real fast saves rotate continuously for at least 500 ms with both motion preferences')
// A pending native folder picker must leave the form clear and avoid a delayed mask on cancellation.
await watchNoMasks()
await page.locator('[data-task="settings-folder"][data-task-target="output"]').click()
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
await noLoading('settings')
expect(await page.getByLabel('默认出纳',{exact:true}).isEditable()).toBe(true)
await page.evaluate(()=>{window.pickerCancelledAt=performance.now()})
await resolvePicker({canceled:true,filePaths:[]})
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('settings-folder'))).toBe(true)
const cancelMs=await page.evaluate(()=>performance.now()-window.pickerCancelledAt)
expect(cancelMs).toBeLessThan(450);await stopWatching()
checks.push('Folder selection has no mask; cancellation adds no 500 ms wait')
// Opening a folder should also leave the form interactive.
await watchNoMasks()
await page.evaluate(async()=>{
const {actions,state}=await import('/state.ts')
void actions.open(state.dataDirectory)
})
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishOpen)).toBe('function')
await noLoading('settings')
await desktop.evaluate(()=>{globalThis.finishOpen()})
await desktop.evaluate(({shell})=>{shell.openPath=async()=>''})
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('open'))).toBe(true)
await stopWatching()
checks.push('Opening local folders has no mask')
await page.getByRole('button',{name:'印章管理',exact:true}).click()
await arm('seals');await page.locator('[data-task="refresh-seals"]').click();await checkTrace('refresh seals')
// Binding a folder includes saving its path and reloading images, without a synthetic loading mask.
await watchNoMasks()
await page.locator('.panel-heading [data-task="folder"]').click()
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
await noLoading('seals')
await resolvePicker({canceled:false,filePaths:[seals]})
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('folder'))).toBe(true)
await stopWatching()
checks.push('Choosing and binding a stamp folder has no mask')
await page.getByRole('button',{name:/^生成记录/}).click()
await arm('history');await page.locator('[data-task="refresh-records"]').click();await checkTrace('refresh history')
checks.push('Both manual refresh actions show half a second of real rotation')
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
await arm('preview');await page.locator('[data-region="preview"] [data-task="refresh-seals"]').click();await checkTrace('refresh seals from workbench')
await page.locator('.panel-heading [data-task="import"]').click()
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
await noLoading('data')
await resolvePicker({canceled:true,filePaths:[]})
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('import'))).toBe(true)
await noLoading('data')
await page.locator('.panel-heading [data-task="import"]').click()
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
await noLoading('data');await arm('data')
await resolvePicker({canceled:false,filePaths:[file]})
await checkTrace('import after file chosen')
await expect(page.getByText('测试学校 高2028届 测试姓名',{exact:true})).toBeVisible()
checks.push('Import rotates during processing only, after accepting the file picker')
await page.locator('[data-task="template"]').click()
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
await noLoading('data');await arm('data')
await resolvePicker({canceled:false,filePath:template})
await checkTrace('template after destination chosen')
expect((await fs.stat(template)).size).toBeGreaterThan(0)
checks.push('Template starts loading after accepting its picker')
// Generate one isolated batch to verify that deleting waits for confirmation before animating.
await page.evaluate(async output=>{
const {actions,state}=await import('/state.ts')
await window.receipts.saveSettings({...state.settings,outputDirectory:output,exportPdf:true,exportPng:false,exportPrint:false})
await actions.reload()
},path.join(run,'output'))
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
await page.locator('button.export-button').click()
await expect.poll(()=>page.evaluate(async()=>(await window.receipts.listBatches())[0]?.status),{timeout:30000}).toBe('completed')
await page.getByRole('button',{name:/^生成记录/}).click()
await page.locator('[data-task="delete-batch"]').click()
await expect(page.getByRole('alertdialog')).toBeVisible();await noLoading('history')
await page.getByRole('alertdialog').getByRole('button',{name:'取消',exact:true}).click()
await expect(page.getByRole('alertdialog')).toHaveCount(0)
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('delete-batch'))).toBe(true)
await noLoading('history')
expect((await page.evaluate(()=>window.receipts.listBatches())).length).toBe(1)
await page.locator('[data-task="delete-batch"]').click()
await expect(page.getByRole('alertdialog')).toBeVisible();await noLoading('history')
await arm('history')
await page.getByRole('alertdialog').getByRole('button',{name:'删除记录',exact:true}).click()
await checkTrace('delete after confirmation')
expect((await page.evaluate(()=>window.receipts.listBatches())).length).toBe(0)
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('SJ00000002')
checks.push('Delete cancellation is immediate; confirmed deletion rotates and preserves reserved numbers')
await expect(page.locator('.main-content > .feedback')).toHaveCount(0)
expect(errors).toEqual([])
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,checks,frames,folderCancellationMs:cancelMs,rendererErrors:errors},null,2))
console.log(JSON.stringify({passed:true,checks,frames,folderCancellationMs:cancelMs},null,2))
}finally{
if(desktop){await desktop.evaluate(({app})=>app.exit(0)).catch(()=>{});await desktop.close().catch(()=>{})}
await server.close()
}