Files
potaser 3a72d3a861 feat(review): P3b enable compile PDF in reformat panel
- 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)
2026-10-13 10:24:55 +08:00

179 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
});
});