- ReformatPanel: compile tab state machine (idle/loading/success/error) - Blob URL for PDF preview; backend path hidden - First-compile 1-3min hint banner (dismissible) - QUEUE_FULL 429 with fixed message - Store: compileStatus/compileResult/compileError + actions - prewarm_tectonic.sh: minimal .tex compilation to pre-cache TeX bundles - Vitest: 6 new cases (guide / button / loading / success / error / queue-full)
179 lines
8.3 KiB
JavaScript
179 lines
8.3 KiB
JavaScript
// tests/frontend/unit/edu/reformat.compile.test.js —— ReformatPanel 编译 PDF Tab(P3b)
|
||
// 1) 无 job_id → "请先完成重排版再编译" 引导
|
||
// 2) 有 job_id → "编译 PDF" 按钮可点
|
||
// 3) 点击后按钮变 "编译中…"(loading 期间禁用;store 状态驱动,切面板不中断)
|
||
// 4) 成功状态:iframe 预览 + 下载按钮 + 耗时
|
||
// 5) 失败状态:错误摘要 + log 尾折叠 <pre> + 查看完整 log
|
||
// 6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { mount } from '@vue/test-utils';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
|
||
|
||
const compileTexMock = vi.fn();
|
||
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
|
||
const actual = await importOriginal(); // 保留真实 reviewErrorMessage(QUEUE_FULL 文案走 CODE_HINTS)
|
||
return {
|
||
...actual,
|
||
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
|
||
getDirectory: vi.fn(),
|
||
reformatTex: vi.fn(),
|
||
compileTex: (...args) => compileTexMock(...args),
|
||
};
|
||
});
|
||
|
||
import { useEduStore } from '../../../../ui/src/stores/edu.js';
|
||
import { ApiError } from '../../../../ui/src/api/http.js';
|
||
|
||
function seedResult(edu, job_id = 'abc123def456') {
|
||
edu.reformatResult = {
|
||
ok: true,
|
||
job_id,
|
||
modified_tex: '\\documentclass{article}\\begin{document}Hi\\end{document}',
|
||
changes: [],
|
||
warnings: [],
|
||
meta: { filename: 'paper.tex', journal_name: 'Plant Cell', rules_source: 'official' },
|
||
};
|
||
edu.reformatStatus = 'ready';
|
||
}
|
||
|
||
async function mountPdfTab(edu) {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
edu = useEduStore();
|
||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||
// 切到"编译 PDF" Tab
|
||
const tabs = wrapper.findAll('.rf-tab');
|
||
const pdfTab = tabs.find((t) => t.text() === '编译 PDF');
|
||
await pdfTab.trigger('click');
|
||
return { wrapper, edu };
|
||
}
|
||
|
||
describe('ReformatPanel 编译 PDF Tab(P3b)', () => {
|
||
beforeEach(() => {
|
||
compileTexMock.mockReset();
|
||
// jsdom 无 URL.createObjectURL:store blob 加载走 catch 回退,不致挂
|
||
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
|
||
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
|
||
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
|
||
});
|
||
|
||
it('1) 无 job_id → "请先完成重排版再编译" 引导', async () => {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
const edu = useEduStore();
|
||
edu.reformatResult = { ok: true, job_id: null, changes: [], meta: {} };
|
||
edu.reformatStatus = 'ready';
|
||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||
const guide = wrapper.find('[data-testid="rf-compile-guide"]');
|
||
expect(guide.exists()).toBe(true);
|
||
expect(guide.text()).toContain('请先完成重排版再编译');
|
||
expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false);
|
||
});
|
||
|
||
it('2) 有 job_id → "编译 PDF" 按钮可点', async () => {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
const edu = useEduStore();
|
||
seedResult(edu);
|
||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||
const btn = wrapper.find('[data-testid="rf-compile-btn"]');
|
||
expect(btn.exists()).toBe(true);
|
||
expect(btn.text()).toBe('编译 PDF');
|
||
expect(btn.attributes('disabled')).toBeUndefined();
|
||
});
|
||
|
||
it('3) 点击后按钮变"编译中…"并禁用;首次编译黄条出现', async () => {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
const edu = useEduStore();
|
||
seedResult(edu);
|
||
compileTexMock.mockReturnValue(new Promise(() => {})); // 挂起,模拟编译中
|
||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
|
||
await wrapper.vm.$nextTick();
|
||
const btn = wrapper.find('[data-testid="rf-compile-btn"]');
|
||
expect(btn.text()).toContain('编译中');
|
||
expect(btn.attributes('disabled')).toBeDefined();
|
||
expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="rf-first-hint"]').exists()).toBe(true);
|
||
expect(compileTexMock).toHaveBeenCalledTimes(1);
|
||
// loading 期间再点不重复提交
|
||
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
|
||
expect(compileTexMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('4) 成功状态:iframe 预览 + 下载按钮 + 耗时', async () => {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
const edu = useEduStore();
|
||
seedResult(edu);
|
||
edu.compileResult = {
|
||
ok: true, compile_id: 'abc123def456',
|
||
pdf_url: '/api/review/compile/abc123def456/pdf',
|
||
log_url: '/api/review/compile/abc123def456/log',
|
||
meta: { seconds: 12.34, pdf_bytes: 1048576, engine: 'tectonic' },
|
||
};
|
||
edu.compileStatus = 'ready';
|
||
edu.compilePdfUrl = ''; // blob 失败回退直链
|
||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||
await wrapper.vm.$nextTick();
|
||
const frame = wrapper.find('[data-testid="rf-pdf-frame"]');
|
||
expect(frame.exists()).toBe(true);
|
||
expect(frame.attributes('src')).toContain('/pdf');
|
||
expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="rf-pdf-size"]').text()).toContain('MB');
|
||
expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('12.3 秒');
|
||
expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false); // 操作行换成下载/重编
|
||
});
|
||
|
||
it('5) 失败状态:错误摘要 + log 尾折叠区 + 完整 log 按钮', async () => {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
const edu = useEduStore();
|
||
seedResult(edu);
|
||
edu.compileStatus = 'error';
|
||
edu.compileError = 'LaTeX 编译失败:缺少宏包';
|
||
edu.compileLogTail = 'This is tectonic\n! LaTeX Error: File `foo.sty\' not found.\n68 lines omitted...';
|
||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||
await wrapper.vm.$nextTick();
|
||
const err = wrapper.find('[data-testid="rf-compile-error"]');
|
||
expect(err.exists()).toBe(true);
|
||
expect(err.text()).toContain('缺少宏包');
|
||
const details = wrapper.find('[data-testid="rf-log-details"]');
|
||
expect(details.exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('foo.sty');
|
||
expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
|
||
expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(false);
|
||
});
|
||
|
||
it('6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"', async () => {
|
||
const pinia = createPinia();
|
||
setActivePinia(pinia);
|
||
const edu = useEduStore();
|
||
seedResult(edu);
|
||
compileTexMock.mockRejectedValue(new ApiError('编译队列已满(7 个任务排队),请稍后重试', {
|
||
code: 'QUEUE_FULL', status: 429,
|
||
}));
|
||
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
|
||
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
|
||
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
|
||
await wrapper.vm.$nextTick();
|
||
await wrapper.vm.$nextTick();
|
||
const err = wrapper.find('[data-testid="rf-compile-error"]');
|
||
expect(err.exists()).toBe(true);
|
||
expect(err.text()).toContain('编译队列已满(最多 5 个任务),请稍后重试');
|
||
// 报错后按钮立即恢复可点(重新编译)
|
||
const retry = wrapper.find('[data-testid="rf-recompile"]');
|
||
expect(retry.exists()).toBe(true);
|
||
expect(retry.attributes('disabled')).toBeUndefined();
|
||
});
|
||
});
|