Initial commit: Receipt Studio desktop application
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import { build } from 'esbuild'
|
||||
import './make-icon.mjs'
|
||||
await build({ entryPoints: ['src/main/index.ts'], outfile: 'dist/main/index.cjs', bundle: true, platform: 'node', target: 'node22', format: 'cjs', external: ['electron'], sourcemap: true })
|
||||
await build({ entryPoints: ['src/main/preload.ts'], outfile: 'dist/main/preload.cjs', bundle: true, platform: 'node', target: 'node22', format: 'cjs', external: ['electron'] })
|
||||
@@ -0,0 +1,165 @@
|
||||
import { _electron as electron,expect } from '@playwright/test'
|
||||
import electronPath from 'electron'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { PNG } from 'pngjs'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
const root=process.cwd(),qa=path.join(root,'.qa'),profile=path.join(qa,'profile'),fixtures=path.join(qa,'fixtures'),output=path.join(qa,'output'),seals=path.join(fixtures,'seals')
|
||||
if(!profile.startsWith(root+path.sep))throw Error('Invalid QA path')
|
||||
await fs.rm(profile,{recursive:true,force:true})
|
||||
await fs.mkdir(profile,{recursive:true})
|
||||
await fs.writeFile(path.join(profile,'settings.json'),JSON.stringify({createBatchFolder:false,openAfterExport:false}))
|
||||
await fs.mkdir(seals,{recursive:true});await fs.mkdir(output,{recursive:true})
|
||||
const image=new PNG({width:240,height:240})
|
||||
for(let y=0;y<240;y++)for(let x=0;x<240;x++){const i=(y*240+x)*4,d=Math.hypot(x-120,y-120),red=(d>99&&d<106)||(d>88&&d<91)||(x>65&&x<175&&y>105&&y<133);image.data[i]=195;image.data[i+1]=30;image.data[i+2]=35;image.data[i+3]=red?255:0}
|
||||
await fs.writeFile(path.join(seals,'测试印章.png'),PNG.sync.write(image))
|
||||
const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
|
||||
sheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额','出纳','复核'])
|
||||
sheet.addRow(['2026-10-16','某某学校','初二年级','张三','秋季学期服务费',3500])
|
||||
sheet.addRow(['2026-10-16','某某学校','高2028届','<script>alert(1)</script>','资料费',12.34])
|
||||
sheet.addRow(['2026-10-17','较长名称测试:某某学校课程服务中心','高三年级','李四','课程服务项目及相关资料费用',10001.05])
|
||||
sheet.addRow(['2026-02-30','某某学校','高2028届','无效行','服务费','12.345'])
|
||||
const excelFile=path.join(fixtures,'收据测试数据.xlsx');await book.xlsx.writeFile(excelFile)
|
||||
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE
|
||||
const errors=[]
|
||||
let desktop
|
||||
async function launch(){
|
||||
desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode'],cwd:root,env,timeout:60000})
|
||||
desktop.process().stderr.on('data',data=>{const text=data.toString();if(!text.includes('Autofill'))process.stderr.write(text)})
|
||||
const page=await desktop.firstWindow();page.setDefaultTimeout(20000)
|
||||
page.on('pageerror',e=>errors.push(e.message))
|
||||
await expect(page.getByRole('heading',{name:'收据工作台',exact:true})).toBeVisible({timeout:30000})
|
||||
await expect(page.getByText('把表格交给收据工坊')).toBeVisible({timeout:30000})
|
||||
return page
|
||||
}
|
||||
try{
|
||||
const page=await launch()
|
||||
await desktop.evaluate(({dialog,shell},p)=>{
|
||||
globalThis.openedDirectories=[]
|
||||
shell.openPath=async folder=>{globalThis.openedDirectories.push(folder);return ''}
|
||||
dialog.showOpenDialog=async(_window,options)=>({canceled:false,filePaths:[options.title==='选择印章文件夹'?p.seals:options.title==='选择导出文件夹'?p.output:p.excelFile]})
|
||||
dialog.showSaveDialog=async()=>({canceled:false,filePath:p.template})
|
||||
dialog.showMessageBox=async()=>({response:1,checkboxChecked:false})
|
||||
},{seals,output,excelFile,template:path.join(fixtures,'模板.xlsx')})
|
||||
await page.screenshot({path:path.join(qa,'01-workspace.png')})
|
||||
await page.getByRole('button',{name:'下载模板',exact:true}).click()
|
||||
await expect.poll(async()=>!!(await fs.stat(path.join(fixtures,'模板.xlsx')).catch(()=>null))).toBe(true)
|
||||
await page.getByRole('button',{name:'偏好设置',exact:true}).click()
|
||||
await expect(page.getByRole('button',{name:'更多偏好与数据备份'})).toHaveCount(0)
|
||||
await expect(page.getByRole('button',{name:'备份数据'})).toHaveCount(0)
|
||||
await expect(page.getByRole('button',{name:'恢复备份'})).toHaveCount(0)
|
||||
await expect(page.getByRole('switch',{name:'每次导出建立独立批次文件夹'})).toHaveCount(0)
|
||||
await expect(page.getByRole('switch',{name:'生成完成后自动打开输出目录'})).toHaveCount(0)
|
||||
await expect(page.getByLabel('导出的文件叫什么名字')).toBeVisible()
|
||||
expect(await page.evaluate(()=>({backup:typeof window.receipts.backup,restore:typeof window.receipts.restore}))).toEqual({backup:'undefined',restore:'undefined'})
|
||||
expect((await page.evaluate(()=>window.receipts.bootstrap())).settings).toMatchObject({createBatchFolder:true,openAfterExport:true})
|
||||
await page.locator('[data-task="settings-folder"][data-task-target="output"]').click()
|
||||
await page.locator('[data-task="settings-folder"][data-task-target="seal"]').click()
|
||||
await expect(page.locator('[data-region="settings"]')).not.toHaveAttribute('aria-busy','true')
|
||||
await page.getByLabel('默认出纳',{exact:true}).fill('李出纳')
|
||||
await page.getByLabel('默认复核人',{exact:true}).fill('王复核')
|
||||
await page.getByLabel('编号前缀',{exact:true}).fill('QA')
|
||||
await page.getByLabel('导出的文件叫什么名字').selectOption('number')
|
||||
await expect(page.locator('.filename-example')).toContainText('QA00000001.pdf')
|
||||
await page.getByLabel('导出的文件叫什么名字').selectOption('customer_number')
|
||||
await expect(page.locator('.filename-example')).toContainText('巴南中学 高2028届 张三_QA00000001.pdf')
|
||||
await page.getByLabel('导出的文件叫什么名字').selectOption('number_customer')
|
||||
await expect(page.locator('.filename-example')).toContainText('QA00000001_巴南中学 高2028届 张三.pdf')
|
||||
await page.getByRole('button',{name:'保存偏好设置',exact:true}).click()
|
||||
await expect(page.locator('[data-region="settings"]')).not.toHaveAttribute('aria-busy','true')
|
||||
await expect(page.locator('.main-content > .feedback')).toHaveCount(0)
|
||||
await page.screenshot({path:path.join(qa,'02-settings.png'),fullPage:true})
|
||||
await page.getByRole('button',{name:'印章管理',exact:true}).click()
|
||||
await page.getByRole('button',{name:/测试印章/}).click()
|
||||
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
|
||||
await page.getByRole('button',{name:'导入 Excel',exact:true}).click()
|
||||
await expect(page.getByText('已选择 3 条',{exact:true})).toBeVisible()
|
||||
await expect(page.getByText('待修改',{exact:true})).toBeVisible()
|
||||
const group=page.getByRole('checkbox',{name:'选择分组:某某学校 / 初二年级 / 秋季学期服务费',exact:true})
|
||||
await group.uncheck();await expect(page.getByText('已选择 2 条',{exact:true})).toBeVisible()
|
||||
await expect(page.getByRole('checkbox',{name:'选择分组:某某学校',exact:true})).toHaveJSProperty('indeterminate',true)
|
||||
await group.check();await expect(page.getByText('已选择 3 条',{exact:true})).toBeVisible()
|
||||
await expect(page.getByRole('checkbox',{name:'选择分组:某某学校 / 高2028届 / 服务费',exact:true})).toBeDisabled()
|
||||
await page.getByLabel('单张 PNG',{exact:true}).check()
|
||||
await page.frameLocator('iframe').getByText('李出纳',{exact:false}).waitFor()
|
||||
await expect(page.frameLocator('iframe').getByText('某某学校 初二年级 张三',{exact:true})).toBeVisible()
|
||||
await page.getByRole('button',{name:'A4 打印排版',exact:true}).click()
|
||||
await expect(page.frameLocator('iframe').getByText('某某学校 初二年级 张三',{exact:true})).toBeVisible()
|
||||
await expect(page.frameLocator('iframe').getByText('某某学校 高2028届 <script>alert(1)</script>',{exact:true})).toHaveCount(0)
|
||||
await page.getByRole('button',{name:'单张收据',exact:true}).click()
|
||||
await page.screenshot({path:path.join(qa,'03-import-preview.png'),fullPage:true})
|
||||
await page.getByRole('button',{name:/生成 3 张收据/}).click()
|
||||
await expect.poll(async()=>page.evaluate(async()=> (await window.receipts.listBatches())[0]?.status),{timeout:120000,intervals:[500,1000]}).toBe('completed')
|
||||
const batches=await page.evaluate(()=>window.receipts.listBatches()),batch=batches[0]
|
||||
await expect.poll(()=>desktop.evaluate(()=>globalThis.openedDirectories)).toContain(batch.outputDirectory)
|
||||
expect(batch.count).toBe(3);expect(batch.firstNumber).toBe('QA00000001');expect(batch.lastNumber).toBe('QA00000003')
|
||||
const files=await fs.readdir(batch.outputDirectory,{recursive:true})
|
||||
expect(files.filter(f=>f.endsWith('.pdf'))).toHaveLength(6);expect(files.filter(f=>f.endsWith('.png'))).toHaveLength(3)
|
||||
const printFiles=files.filter(f=>path.basename(f).startsWith('A4打印版'))
|
||||
expect(printFiles).toHaveLength(3)
|
||||
const printFile=printFiles.find(f=>f.includes('秋季学期服务费'))
|
||||
const pdf=await PDFDocument.load(await fs.readFile(path.join(batch.outputDirectory,printFile)))
|
||||
expect(pdf.getPageCount()).toBe(1)
|
||||
expect(Math.abs(pdf.getPage(0).getWidth()-595.28)).toBeLessThan(1)
|
||||
const singleFile=files.find(f=>path.basename(f).startsWith('QA00000001')&&f.endsWith('.pdf'))
|
||||
expect(path.dirname(singleFile)).toBe(path.join('某某学校','初二年级','秋季学期服务费'))
|
||||
expect((await PDFDocument.load(await fs.readFile(path.join(batch.outputDirectory,singleFile)))).getPageCount()).toBe(1)
|
||||
const pngFile=files.find(f=>path.basename(f).startsWith('QA00000001')&&f.endsWith('.png'))
|
||||
const raster=PNG.sync.read(await fs.readFile(path.join(batch.outputDirectory,pngFile)))
|
||||
expect(raster.width).toBeGreaterThan(2200);expect(raster.height).toBeGreaterThan(1600)
|
||||
await fs.copyFile(path.join(batch.outputDirectory,pngFile),path.join(qa,'04-receipt-300dpi.png'))
|
||||
await page.getByLabel('单张 PNG',{exact:true}).uncheck();await page.getByLabel('A4 打印版',{exact:true}).uncheck()
|
||||
await page.getByRole('button',{name:/生成记录/}).click()
|
||||
await expect(page.getByRole('heading',{name:'最近生成的批次',exact:false})).toBeVisible()
|
||||
await expect(page.locator('.history-category')).toContainText('某某学校')
|
||||
await page.getByLabel('按学校查找记录').selectOption('某某学校')
|
||||
await page.getByLabel('按年级查找记录').selectOption('初二年级')
|
||||
await page.getByLabel('按收费项目查找记录').selectOption('资料费')
|
||||
await expect(page.locator('.history-table tbody tr')).toHaveCount(0)
|
||||
await page.getByRole('button',{name:'清空筛选',exact:true}).click()
|
||||
await page.getByLabel('搜索生成记录').fill('QA00000002')
|
||||
await expect(page.locator('.history-match')).toContainText('QA00000002')
|
||||
await page.getByRole('button',{name:'查看原收据',exact:true}).click()
|
||||
await expect(page.locator('.modal-card iframe')).toBeVisible()
|
||||
await expect(page.frameLocator('.modal-card iframe').getByText('QA00000002',{exact:false})).toBeVisible()
|
||||
await page.getByRole('button',{name:'关闭原收据预览',exact:true}).click()
|
||||
await expect(page.locator('[data-region="history"]')).not.toHaveAttribute('aria-busy','true')
|
||||
await page.getByLabel('搜索生成记录').fill('李四')
|
||||
await expect(page.locator('.history-match')).toContainText('QA00000003')
|
||||
await page.screenshot({path:path.join(qa,'05-history-filter.png'),fullPage:true})
|
||||
await page.getByRole('button',{name:'清空筛选',exact:true}).click()
|
||||
await expect(page.locator('.history-table tbody tr')).toHaveCount(1)
|
||||
const originalDirectory=batch.outputDirectory
|
||||
await page.getByRole('button',{name:'重新导出',exact:true}).first().click()
|
||||
await expect.poll(async()=>page.evaluate(async old => {const b=(await window.receipts.listBatches())[0];return b?.outputDirectory!==old&&b?.status==='completed'},originalDirectory),{timeout:120000}).toBe(true)
|
||||
const boot=await page.evaluate(()=>window.receipts.bootstrap())
|
||||
expect(boot.nextNumber).toBe('QA00000004')
|
||||
expect(boot.batches).toHaveLength(1)
|
||||
const retryFiles=await fs.readdir(boot.batches[0].outputDirectory,{recursive:true})
|
||||
expect(retryFiles.filter(f=>f.endsWith('.pdf'))).toHaveLength(3)
|
||||
expect(retryFiles).toContain(path.join('某某学校','初二年级','秋季学期服务费',path.basename(singleFile)))
|
||||
await expect.poll(()=>desktop.evaluate(()=>globalThis.openedDirectories)).toContain(boot.batches[0].outputDirectory)
|
||||
// A large number of schools should not mount every nested checkbox at once.
|
||||
const largeBook=new ExcelJS.Workbook(),largeSheet=largeBook.addWorksheet('收据数据')
|
||||
largeSheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额'])
|
||||
for(let i=1;i<=60;i++)largeSheet.addRow(['2026-10-19','分组测试学校'+i,'高一','测试姓名','服务费',100])
|
||||
const largeFile=path.join(fixtures,'多学校分组.xlsx');await largeBook.xlsx.writeFile(largeFile)
|
||||
await desktop.evaluate(({dialog},file)=>{dialog.showOpenDialog=async()=>({canceled:false,filePaths:[file]})},largeFile)
|
||||
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
|
||||
await page.locator('.panel-heading [data-task="import"]').click()
|
||||
await expect(page.getByText('已选择 60 条',{exact:true})).toBeVisible()
|
||||
await expect(page.locator('.group-tree-root > .receipt-group-node')).toHaveCount(50)
|
||||
await page.getByRole('button',{name:'显示更多分组(还有 10 项)',exact:true}).click()
|
||||
await expect(page.locator('.group-tree-root > .receipt-group-node')).toHaveCount(60)
|
||||
await page.getByRole('checkbox',{name:'选择分组:分组测试学校60',exact:true}).uncheck()
|
||||
await expect(page.getByText('已选择 59 条',{exact:true})).toBeVisible()
|
||||
await desktop.close()
|
||||
const restarted=await launch()
|
||||
const boot2=await restarted.evaluate(()=>window.receipts.bootstrap())
|
||||
expect(boot2.nextNumber).toBe('QA00000004');expect(boot2.settings.sealDirectory).toBe(seals);expect(boot2.batches).toHaveLength(1)
|
||||
expect(boot2.settings).toMatchObject({createBatchFolder:true,openAfterExport:true})
|
||||
expect(errors).toEqual([])
|
||||
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,checks:['Excel 模板','错误行定位','印章文件夹读取','设置持久化','文件命名实时示例','学校/年级/项目分组勾选与错误行禁用','大批分组按需展开与加载','分组 A4 预览','单张 PDF','300 DPI PNG','学校/年级/收费项目分层','分组 A4 打印版','历史组合条件同收据匹配','中间编号及姓名查找','匹配原收据预览','原编号分层重试','备份及更多偏好区域已移除','旧关闭配置自动启用','成功后自动打开批次目录','重启编号连续'],output:batch.outputDirectory,printGroups:printFiles.length,printPages:pdf.getPageCount(),png:{width:raster.width,height:raster.height},rendererErrors:errors},null,2))
|
||||
console.log('Desktop verification passed. Artifacts: '+qa)
|
||||
}finally{if(desktop)await desktop.close().catch(()=>{})}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createServer } from 'vite'
|
||||
import { build } from 'esbuild'
|
||||
import { _electron as electron,expect } from '@playwright/test'
|
||||
import electronPath from 'electron'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
for(const entry of ['index','preload'])await build({entryPoints:['src/main/'+entry+'.ts'],outfile:'dist/main/'+entry+'.cjs',bundle:true,platform:'node',format:'cjs',external:['electron'],sourcemap:true})
|
||||
const server=await createServer({server:{host:'127.0.0.1',port:5174,strictPort:true}})
|
||||
await server.listen()
|
||||
const profile=path.join(process.cwd(),'.qa','dev-window-profile')
|
||||
await fs.mkdir(profile,{recursive:true})
|
||||
const env={...process.env,ELECTRON_RENDERER_URL:'http://127.0.0.1:5174'}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
let desktop
|
||||
try{
|
||||
desktop=await electron.launch({executablePath:electronPath,args:['.','--user-data-dir='+profile],cwd:process.cwd(),env,timeout:30000})
|
||||
const page=await desktop.firstWindow();page.setDefaultTimeout(15000)
|
||||
await expect(page.getByText('把表格交给收据工坊')).toBeVisible({timeout:30000})
|
||||
expect(await page.evaluate(()=>typeof window.receipts?.importExcel)).toBe('function')
|
||||
expect(await desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].isVisible())).toBe(true)
|
||||
await desktop.evaluate(({BrowserWindow})=>{const w=BrowserWindow.getAllWindows().find(w=>w.webContents.getURL().includes('/index.html'));w.focus();w.webContents.sendInputEvent({type:'keyDown',keyCode:'F12'});w.webContents.sendInputEvent({type:'keyUp',keyCode:'F12'})})
|
||||
await expect.poll(()=>desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows().find(w=>w.webContents.getURL().includes('/index.html'))?.webContents.isDevToolsOpened())).toBe(true)
|
||||
await desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows().find(w=>w.webContents.getURL().includes('/index.html'))?.webContents.closeDevTools())
|
||||
await fs.writeFile('.qa/dev-window-result.json',JSON.stringify({passed:true,visible:true,rendererReady:true,desktopApi:true,f12:true},null,2))
|
||||
console.log('Development window verified: visible, ready, desktop bridge and F12.')
|
||||
}finally{if(desktop)await desktop.close();await server.close()}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'vite'
|
||||
import { build } from 'esbuild'
|
||||
import electron from 'electron'
|
||||
const args=process.argv.slice(2),smoke=args.includes('--smoke-test')
|
||||
console.log('正在准备收据工坊的本地开发窗口…')
|
||||
for (const entry of ['index', 'preload']) await build({ entryPoints: ['src/main/'+entry+'.ts'], outfile: 'dist/main/'+entry+'.cjs', bundle: true, platform: 'node', format: 'cjs', external: ['electron'], sourcemap:true })
|
||||
const server = await createServer(); await server.listen()
|
||||
const env={ ...process.env, ELECTRON_RENDERER_URL: 'http://127.0.0.1:5173' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(electron, ['.',...args], { stdio: 'inherit', windowsHide: smoke, env })
|
||||
let closing=false
|
||||
async function close(code=0){
|
||||
if(closing)return;closing=true
|
||||
if(child.exitCode===null)child.kill()
|
||||
await server.close()
|
||||
process.exit(code)
|
||||
}
|
||||
child.on('error',error=>{console.error('无法启动桌面程序:',error.message);void close(1)})
|
||||
child.on('exit',code=>void close(code??0))
|
||||
process.on('SIGINT',()=>void close())
|
||||
process.on('SIGTERM',()=>void close())
|
||||
console.log('请在自动打开的“收据工坊”桌面窗口中操作,按 F12 打开调试工具。')
|
||||
console.log('界面修改会自动更新;主程序修改后请关闭并重新启动。')
|
||||
if(smoke)setTimeout(()=>void close(),8000).unref()
|
||||
@@ -0,0 +1,98 @@
|
||||
import {_electron as electron,expect} from '@playwright/test'
|
||||
import electronPath from 'electron'
|
||||
import {build} from 'esbuild'
|
||||
import {createRequire} from 'node:module'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const root=process.cwd(),qa=path.join(root,'.qa','history-pagination')
|
||||
await fs.mkdir(qa,{recursive:true})
|
||||
const run=await fs.mkdtemp(path.join(qa,'run-')),profile=path.join(run,'profile'),output=path.join(run,'output'),runtime=path.join(run,'fixture.cjs')
|
||||
await fs.mkdir(profile);await fs.mkdir(output)
|
||||
await build({stdin:{contents:"export {Ledger} from './src/main/ledger';export {defaultSettings} from './src/shared/settings'",resolveDir:root},outfile:runtime,bundle:true,platform:'node',format:'cjs',logLevel:'silent'})
|
||||
const {Ledger,defaultSettings}=createRequire(import.meta.url)(runtime)
|
||||
const settings={...defaultSettings(output),numberPrefix:'PAGE'}
|
||||
await fs.writeFile(path.join(profile,'settings.json'),JSON.stringify(settings))
|
||||
const ledger=new Ledger(path.join(profile,'receipts.sqlite'));await ledger.open()
|
||||
for(let i=1;i<=13;i++){
|
||||
const school=i%2?'分页甲校':'分页乙校',studentName='学生'+String(i).padStart(2,'0')
|
||||
const batch=await ledger.reserve({name:'分页测试'+String(i).padStart(2,'0')+'.xlsx',hash:String(i)},[{date:'2026-10-19',school,grade:'高一',studentName,customer:school+' 高一 '+studentName,item:'服务费',amountCents:10000,cashier:'',reviewer:''}],settings,null)
|
||||
batch.status='completed';batch.outputDirectory=output;await ledger.update(batch)
|
||||
}
|
||||
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE;delete env.ELECTRON_RENDERER_URL
|
||||
let desktop
|
||||
const errors=[],checks=[]
|
||||
async function launch(){
|
||||
desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode','--user-data-dir='+profile],cwd:root,env,timeout:60000})
|
||||
const page=await desktop.firstWindow();page.setDefaultTimeout(20000);page.on('pageerror',error=>errors.push(error.message))
|
||||
await page.getByText('把表格交给收据工坊').waitFor()
|
||||
await page.getByRole('button',{name:/^生成记录/}).click()
|
||||
return page
|
||||
}
|
||||
try{
|
||||
let page=await launch()
|
||||
const rows=()=>page.locator('.history-table tbody tr')
|
||||
const position=()=>page.locator('.history-page-position')
|
||||
async function idle(){
|
||||
await expect(page.locator('[data-region="history"]')).not.toHaveAttribute('aria-busy','true')
|
||||
await expect(page.locator('.history-search>span')).not.toContainText('正在')
|
||||
}
|
||||
await expect(rows()).toHaveCount(5);await idle()
|
||||
await expect(page.getByLabel('生成记录每页条数')).toHaveValue('5')
|
||||
await expect(position()).toHaveText('第 1 / 3 页')
|
||||
await expect(page.locator('.history-range')).toContainText('共 13 条')
|
||||
await expect(page.getByRole('button',{name:'生成记录上一页',exact:true})).toBeDisabled()
|
||||
await expect(rows().first()).toContainText('分页测试13.xlsx')
|
||||
await page.screenshot({path:path.join(qa,'pagination.png'),fullPage:true})
|
||||
await page.getByRole('button',{name:'生成记录下一页',exact:true}).click();await idle()
|
||||
await expect(position()).toHaveText('第 2 / 3 页');await expect(rows()).toHaveCount(5)
|
||||
await expect(rows().first()).toContainText('分页测试08.xlsx')
|
||||
await expect(page.locator('.history-range')).toContainText('第 6–10 条')
|
||||
await page.getByRole('button',{name:'生成记录末页',exact:true}).click();await idle()
|
||||
await expect(rows()).toHaveCount(3);await expect(position()).toHaveText('第 3 / 3 页')
|
||||
await expect(page.getByRole('button',{name:'生成记录下一页',exact:true})).toBeDisabled()
|
||||
checks.push('Default five rows, accurate total/range, next/last page and disabled boundaries')
|
||||
await page.getByLabel('生成记录每页条数').selectOption('10');await idle()
|
||||
await expect(rows()).toHaveCount(10);await expect(position()).toHaveText('第 1 / 2 页')
|
||||
await page.getByLabel('生成记录每页条数').selectOption('20');await idle()
|
||||
await expect(rows()).toHaveCount(13);await expect(position()).toHaveText('第 1 / 1 页')
|
||||
await page.getByLabel('生成记录每页条数').selectOption('5');await idle()
|
||||
await page.getByLabel('按学校查找记录').selectOption('分页甲校');await idle()
|
||||
await expect(page.locator('.history-range')).toContainText('共 7 条')
|
||||
await page.getByRole('button',{name:'生成记录下一页',exact:true}).click();await idle()
|
||||
await expect(rows()).toHaveCount(2);await expect(position()).toHaveText('第 2 / 2 页')
|
||||
await page.getByLabel('搜索生成记录').fill('学生01');await idle()
|
||||
await expect(rows()).toHaveCount(1);await expect(position()).toHaveText('第 1 / 1 页')
|
||||
await expect(page.locator('.history-match')).toContainText('学生01')
|
||||
await page.getByRole('button',{name:'清空筛选',exact:true}).click();await idle()
|
||||
await expect(rows()).toHaveCount(5);await expect(page.locator('.history-range')).toContainText('共 13 条')
|
||||
checks.push('Page-size changes and filters reset to page one; matching totals span all pages')
|
||||
await page.getByLabel('生成记录每页条数').selectOption('10');await idle()
|
||||
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
|
||||
await page.getByRole('button',{name:/^生成记录/}).click();await idle()
|
||||
await expect(rows()).toHaveCount(10);await expect(page.getByLabel('生成记录每页条数')).toHaveValue('10')
|
||||
await desktop.close();desktop=undefined
|
||||
page=await launch();await idle()
|
||||
await expect(rows()).toHaveCount(10);await expect(page.getByLabel('生成记录每页条数')).toHaveValue('10')
|
||||
expect((await page.evaluate(()=>window.receipts.bootstrap())).settings.historyPageSize).toBe(10)
|
||||
checks.push('Rows per page remembered across navigation and application restart')
|
||||
await page.getByLabel('生成记录每页条数').selectOption('5');await idle()
|
||||
await page.getByRole('button',{name:'生成记录末页',exact:true}).click();await idle()
|
||||
for(let remaining=12;remaining>=10;remaining--){
|
||||
await rows().last().getByRole('button',{name:'删除',exact:true}).click()
|
||||
await page.getByRole('alertdialog').getByRole('button',{name:'删除记录',exact:true}).click();await idle()
|
||||
await expect(page.locator('.history-range')).toContainText('共 '+remaining+' 条')
|
||||
}
|
||||
await expect(position()).toHaveText('第 2 / 2 页');await expect(rows()).toHaveCount(5)
|
||||
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('PAGE00000014')
|
||||
await page.getByLabel('搜索生成记录').fill('没有对应的学生');await idle()
|
||||
await expect(rows()).toHaveCount(0);await expect(position()).toHaveText('第 1 / 1 页')
|
||||
await expect(page.locator('.history-range')).toContainText('共 0 条')
|
||||
await expect(page.getByRole('button',{name:'生成记录下一页',exact:true})).toBeDisabled()
|
||||
checks.push('Deleting the last page clamps to a valid page; no-result filters and issued numbers remain correct')
|
||||
expect(errors).toEqual([])
|
||||
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,checks,rendererErrors:errors},null,2))
|
||||
console.log('History pagination verified: '+checks.join('; '))
|
||||
}finally{
|
||||
if(desktop){await desktop.evaluate(({app})=>app.exit(0)).catch(()=>{});await desktop.close().catch(()=>{})}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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&®ion.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()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {PNG} from 'pngjs'
|
||||
import fs from 'node:fs/promises'
|
||||
const png=new PNG({width:256,height:256})
|
||||
for(let y=0;y<256;y++)for(let x=0;x<256;x++){
|
||||
const i=(y*256+x)*4,dx=Math.max(38-x,0,x-217),dy=Math.max(38-y,0,y-217)
|
||||
let color=[22,133,118,dx*dx+dy*dy<=38*38?255:0]
|
||||
if(x>=64&&x<=192&&y>=39&&y<=216)color=[255,255,255,255]
|
||||
if(x>=163&&y<=68&&y<39+(x-163))color=[22,133,118,255]
|
||||
if((y>=89&&y<=95||y>=112&&y<=118||y>=135&&y<=141)&&x>=83&&x<=171)color=[144,199,185,255]
|
||||
const r=Math.hypot(x-158,y-183)
|
||||
if(r>=18&&r<=23)color=[22,133,118,255]
|
||||
for(let c=0;c<4;c++)png.data[i+c]=color[c]
|
||||
}
|
||||
const data=PNG.sync.write(png),head=Buffer.alloc(22)
|
||||
head.writeUInt16LE(1,2);head.writeUInt16LE(1,4);head[6]=0;head[7]=0;head.writeUInt16LE(1,10);head.writeUInt16LE(32,12);head.writeUInt32LE(data.length,14);head.writeUInt32LE(22,18)
|
||||
await fs.mkdir('build',{recursive:true});await fs.mkdir('dist',{recursive:true})
|
||||
await fs.writeFile('build/icon.png',data);await fs.writeFile('build/icon.ico',Buffer.concat([head,data]));await fs.writeFile('dist/icon.png',data)
|
||||
@@ -0,0 +1,39 @@
|
||||
import { _electron as electron,expect } from '@playwright/test'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {PDFDocument} from 'pdf-lib'
|
||||
const profile=path.join(process.cwd(),'.qa','packaged-profile-1.0.1')
|
||||
await fs.mkdir(profile,{recursive:true})
|
||||
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE
|
||||
let app
|
||||
try{
|
||||
app=await electron.launch({executablePath:path.join(process.cwd(),'release','win-unpacked','收据工坊.exe'),args:['--smoke-test','--user-data-dir='+profile],env,timeout:60000})
|
||||
const page=await app.firstWindow();page.setDefaultTimeout(20000)
|
||||
await expect(page.getByRole('heading',{name:'收据工作台',exact:true})).toBeVisible({timeout:30000})
|
||||
await expect(page.getByText('把表格交给收据工坊')).toBeVisible({timeout:30000})
|
||||
const boot=await page.evaluate(()=>window.receipts.bootstrap())
|
||||
expect(boot.dataDirectory).toBe(profile)
|
||||
expect(boot.nextNumber).toBe('SJ00000001')
|
||||
const sealFolder=path.join(process.cwd(),'.qa','fixtures','seals'),output=path.join(process.cwd(),'.qa','packaged-output')
|
||||
await fs.mkdir(output,{recursive:true})
|
||||
await app.evaluate(({shell})=>{shell.openPath=async()=>''})
|
||||
await page.evaluate(async({output,sealFolder})=>{const b=await window.receipts.bootstrap();await window.receipts.saveSettings({...b.settings,outputDirectory:output,sealDirectory:sealFolder,numberPrefix:'PACK'})},{output,sealFolder})
|
||||
const seals=await page.evaluate(()=>window.receipts.listSeals());expect(seals.images.length).toBeGreaterThan(0)
|
||||
await app.evaluate(({dialog},file)=>{dialog.showOpenDialog=async()=>({canceled:false,filePaths:[file]})},path.join(process.cwd(),'.qa','fixtures','收据测试数据.xlsx'))
|
||||
const draft=await page.evaluate(()=>window.receipts.importExcel())
|
||||
expect(draft.rows[0].receipt.customer).toBe('某某学校 初二年级 张三')
|
||||
expect(draft.rows[0].receipt.school).toBe('某某学校')
|
||||
expect(draft.rows[0].receipt.grade).toBe('初二年级')
|
||||
expect(draft.rows[0].receipt.studentName).toBe('张三')
|
||||
await page.evaluate(async({id,sealId})=>window.receipts.exportReceipts({draftId:id,rowIndices:[0],sealId,options:{pdf:true,png:true,print:true,receiptsPerPage:2,cutLine:true}}),{id:draft.id,sealId:seals.images[0].id})
|
||||
await expect.poll(async()=>page.evaluate(async()=> (await window.receipts.listBatches())[0]?.status),{timeout:90000}).toBe('completed')
|
||||
const batch=(await page.evaluate(()=>window.receipts.listBatches()))[0]
|
||||
const files=await fs.readdir(batch.outputDirectory,{recursive:true})
|
||||
expect(files.filter(n=>n.endsWith('.png'))).toHaveLength(1)
|
||||
const png=await fs.readFile(path.join(batch.outputDirectory,files.find(n=>n.endsWith('.png'))))
|
||||
expect(png.readUInt32BE(png.indexOf('pHYs')+4)).toBe(11811)
|
||||
const pdf=await PDFDocument.load(await fs.readFile(path.join(batch.outputDirectory,files.find(n=>path.basename(n).startsWith('A4')))))
|
||||
expect(pdf.getPageCount()).toBe(1)
|
||||
await fs.writeFile('.qa/package-result.json',JSON.stringify({passed:true,platform:'Windows 10 x64',packaged:true,renderedPdf:true,renderedPng:true,dpi:300,profile},null,2))
|
||||
console.log('Packaged application verified: standalone startup, Excel, seals, PDF and PNG.')
|
||||
}finally{if(app)await app.close().catch(()=>{})}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { _electron as electron,expect } from '@playwright/test'
|
||||
import electronPath from 'electron'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { PNG } from 'pngjs'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
const root=process.cwd(),qa=path.join(root,'.qa','print-regression'),profile=path.join(qa,'profile'),output=path.join(qa,'output'),seals=path.join(qa,'seals')
|
||||
if(!qa.startsWith(root+path.sep))throw Error('Invalid QA path')
|
||||
await fs.rm(qa,{recursive:true,force:true})
|
||||
await fs.mkdir(seals,{recursive:true});await fs.mkdir(output,{recursive:true})
|
||||
const image=new PNG({width:256,height:256})
|
||||
let seed=731
|
||||
for(let i=0;i<image.data.length;i+=4){
|
||||
seed=(Math.imul(seed,1664525)+1013904223)>>>0
|
||||
image.data[i]=130+(seed&127);image.data[i+1]=(seed>>>8)&63;image.data[i+2]=(seed>>>16)&63;image.data[i+3]=100+(seed>>>24)%156
|
||||
}
|
||||
await fs.writeFile(path.join(seals,'批量打印测试章.png'),PNG.sync.write(image))
|
||||
const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
|
||||
sheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额','出纳','复核'])
|
||||
for(let i=1;i<=41;i++)sheet.addRow(['2026-10-16','测试学校','高2028届','测试学生'+i,'测试收费项目',3500,'测试出纳','测试复核'])
|
||||
const excelFile=path.join(qa,'批量测试.xlsx');await book.xlsx.writeFile(excelFile)
|
||||
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE;delete env.ELECTRON_RENDERER_URL
|
||||
let desktop
|
||||
try{
|
||||
desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode','--user-data-dir='+profile],cwd:root,env,timeout:60000})
|
||||
const page=await desktop.firstWindow();page.setDefaultTimeout(30000)
|
||||
await expect(page.getByRole('heading',{name:'收据工作台',exact:true})).toBeVisible()
|
||||
await expect.poll(()=>page.evaluate(async()=>!!(await window.receipts.bootstrap()).settings)).toBe(true)
|
||||
await desktop.evaluate(({dialog,shell},p)=>{dialog.showOpenDialog=async()=>({canceled:false,filePaths:[p.excelFile]});shell.openPath=async()=>''},{excelFile})
|
||||
await page.evaluate(async p=>{
|
||||
const boot=await window.receipts.bootstrap()
|
||||
await window.receipts.saveSettings({...boot.settings,outputDirectory:p.output,sealDirectory:p.seals,numberPrefix:'REG',exportPdf:false,exportPng:false,exportPrint:true})
|
||||
},{output,seals})
|
||||
const sealCharacters=await page.evaluate(async()=> (await window.receipts.listSeals()).images[0].dataUrl.length)
|
||||
expect(sealCharacters*40).toBeGreaterThan(2*1024*1024)
|
||||
const batchId=await page.evaluate(async()=>{
|
||||
const draft=await window.receipts.importExcel(),seal=(await window.receipts.listSeals()).images[0]
|
||||
window.printTestProgress=[]
|
||||
window.receipts.onProgress(p=>window.printTestProgress.push({finished:p.finished,status:p.status,errorLength:p.errors.join('').length}))
|
||||
return window.receipts.exportReceipts({draftId:draft.id,rowIndices:draft.rows.map(r=>r.index),sealId:seal.id,options:{pdf:false,png:false,print:true,receiptsPerPage:2,cutLine:true}})
|
||||
})
|
||||
await expect.poll(()=>page.evaluate(async()=> (await window.receipts.listBatches())[0]),{timeout:120000,intervals:[500,1000]}).toMatchObject({status:process.argv.includes('--expect-failure')?'failed':'completed'})
|
||||
const summary=await page.evaluate(async()=> (await window.receipts.listBatches())[0])
|
||||
if(process.argv.includes('--expect-failure')){
|
||||
expect(summary.message).toContain('ERR_INVALID_URL')
|
||||
console.log(JSON.stringify({reproduced:true,receiptCount:summary.count,messageCharacters:summary.message.length,imageCharactersInFirstChunk:sealCharacters*40}))
|
||||
}else{
|
||||
const files=await fs.readdir(summary.outputDirectory,{recursive:true})
|
||||
const pdf=await PDFDocument.load(await fs.readFile(path.join(summary.outputDirectory,files.find(f=>f.endsWith('.pdf')))))
|
||||
expect(pdf.getPageCount()).toBe(21);expect(Math.abs(pdf.getPage(0).getWidth()-595.28)).toBeLessThan(1)
|
||||
const manifest=JSON.parse(await fs.readFile(path.join(summary.outputDirectory,'生成清单.json'),'utf8'))
|
||||
expect(manifest.errors).toEqual([]);expect(manifest.receipts).toHaveLength(41)
|
||||
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('REG00000042')
|
||||
const firstDirectory=summary.outputDirectory
|
||||
await desktop.evaluate(({BrowserWindow})=>{
|
||||
const original=BrowserWindow.prototype.loadURL
|
||||
BrowserWindow.prototype.receiptTestLoadURL=original
|
||||
BrowserWindow.prototype.loadURL=function(url,...args){
|
||||
if(url.startsWith('receipt://print/'))return Promise.reject(new Error("ERR_INVALID_URL (-300) loading 'data:text/html;charset=utf-8,"+'sensitive'.repeat(300000)+"'"))
|
||||
return original.call(this,url,...args)
|
||||
}
|
||||
})
|
||||
await page.evaluate(async id=>window.receipts.exportReceipts({batchId:id,options:{pdf:false,png:false,print:true,receiptsPerPage:2,cutLine:true}}),batchId)
|
||||
await expect.poll(()=>page.evaluate(async()=> (await window.receipts.listBatches())[0].status),{timeout:30000}).toBe('failed')
|
||||
await expect(page.getByRole('alert')).toContainText('ERR_INVALID_URL')
|
||||
expect((await page.getByRole('alert').innerText()).length).toBeLessThan(450)
|
||||
await expect(page.getByRole('status')).toHaveCount(0)
|
||||
const failed=(await page.evaluate(()=>window.receipts.listBatches()))[0]
|
||||
expect(failed.message.length).toBeLessThan(450);expect(failed.message).not.toContain('sensitive')
|
||||
await desktop.evaluate(({BrowserWindow})=>{BrowserWindow.prototype.loadURL=BrowserWindow.prototype.receiptTestLoadURL;delete BrowserWindow.prototype.receiptTestLoadURL})
|
||||
await page.evaluate(async id=>window.receipts.exportReceipts({batchId:id,options:{pdf:true,png:true,print:true,receiptsPerPage:2,cutLine:true}}),batchId)
|
||||
await expect.poll(()=>page.evaluate(async old=>{const b=(await window.receipts.listBatches())[0];return b.outputDirectory!==old&&b.status==='completed'},firstDirectory),{timeout:180000,intervals:[1000]}).toBe(true)
|
||||
const retry=(await page.evaluate(()=>window.receipts.listBatches()))[0],retryFiles=await fs.readdir(retry.outputDirectory,{recursive:true})
|
||||
expect(retryFiles.filter(f=>f.endsWith('.pdf'))).toHaveLength(42);expect(retryFiles.filter(f=>f.endsWith('.png'))).toHaveLength(41)
|
||||
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('REG00000042')
|
||||
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,receiptCount:41,printPages:pdf.getPageCount(),imageCharactersInFirstChunk:sealCharacters*40,retryPdfCount:42,retryPngCount:41,nextNumber:'REG00000042',shortErrorVerified:true,failureNoticeVerified:true,output:retry.outputDirectory},null,2))
|
||||
console.log('Large stamp batch printing and original-number retry passed.')
|
||||
}
|
||||
}finally{if(desktop)await desktop.close().catch(()=>{})}
|
||||
Reference in New Issue
Block a user