Files
potaser 2797145104 feat(review): P4 two-phase zip compile + XDV→PDF diagnostics + EPS fallback
- inspect/upload zip then compile project (main-file detection, zip safety,
  single-slot queue, failed-envelope carries compile_id + log_url)
- keep failed project/ scene 24h (failed_project_ttl_s; KEEP_FAILED_PROJECTS=1
  forever), success deletes immediately; two-tier compile_jobs sweep
- add tectonic --print and persist full engine chatter to output.log so
  XDV→PDF (xdvipdfmx) errors are visible (MDPI: logo EPS inclusion failure)
- EPS fallback: swap X.eps refs for *-eps-converted-to.pdf when present and
  materialize X.pdf for extension-less graphicx lookup; friendly error when
  no converted PDF exists
- frontend: ReformatPanel zip upload/inspect/run flow; one-off
  scripts/debug_compile.py (gitignored)
- tests: backend 169 passed (22 zip_safety + 34 compile_zip + P3a regression),
  frontend edu vitest 43 passed
2026-10-13 18:42:04 +08:00

189 lines
8.4 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.zip.test.js —— ReformatPanel 项目 zip 两段式(P4)
// 1) 默认 .tex 模式;切"项目 .zip"→ zip 上传区 + 打包建议文案 + inspect 按钮
// 2) 选 .zip → inspect 成功 → 摘要(main/统计/跳过)+ 编译按钮
// 3) inspect 失败(ZIP_UNSAFE 后端原话)→ 错误展示
// 4) 点编译 → loading → 成功:iframe 预览 + 耗时
// 5) 编译失败:错误摘要 + log 折叠区 + 查看完整 log(失败信封带 log_url)
// 6) 模式互斥:切回 .tex 清 zip 状态
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 inspectZipMock = vi.fn();
const runZipCompileMock = vi.fn();
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
getDirectory: vi.fn(),
reformatTex: vi.fn(),
compileTex: vi.fn(),
inspectZip: (...args) => inspectZipMock(...args),
runZipCompile: (...args) => runZipCompileMock(...args),
};
});
import { useEduStore } from '../../../../ui/src/stores/edu.js';
import { ApiError } from '../../../../ui/src/api/http.js';
const INSPECT_OK = {
ok: true,
meta: {
job_id: 'abc123def456',
main: 'main.tex',
stats: { files: 5, tex: 1, images: 1, cls: 1, bib: 1, other: 1, bytes: 4096 },
skipped: [{ name: '__MACOSX/._main.tex', reason: 'skip_dir' }],
skipped_count: 1,
created_at: '2026-10-13T00:00:00+00:00',
},
};
function zipFile(name = 'project.zip') {
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
type: 'application/zip',
});
}
async function mountZipPanel(edu) {
const pinia = createPinia();
setActivePinia(pinia);
edu = useEduStore();
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.find('[data-testid="rf-mode-zip"]').trigger('click');
return { wrapper, edu };
}
async function pickZip(wrapper, edu, file = zipFile()) {
const input = wrapper.find('input[type="file"]');
Object.defineProperty(input.element, 'files', { value: [file], configurable: true });
await input.trigger('change');
await wrapper.vm.$nextTick();
}
describe('ReformatPanel 项目 zip 两段式(P4)', () => {
beforeEach(() => {
inspectZipMock.mockReset();
runZipCompileMock.mockReset();
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) 默认 .tex 模式;切 zip → zip 上传区 + 打包建议 + inspect 按钮', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
expect(wrapper.find('[data-testid="rf-mode-tex"]').classes()).toContain('active');
expect(wrapper.find('[data-testid="rf-zip-inspect-btn"]').exists()).toBe(false);
// tex 提交按钮仍在
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(true);
await wrapper.find('[data-testid="rf-mode-zip"]').trigger('click');
const zipBtn = wrapper.find('[data-testid="rf-zip-inspect-btn"]');
expect(zipBtn.exists()).toBe(true);
expect(zipBtn.attributes('disabled')).toBeDefined(); // 未选文件不可点
expect(wrapper.text()).toContain('打包建议:main.tex 放 zip 根目录');
// tex 提交按钮消失(互斥)
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(false);
});
it('2) 选 .zip → inspect 成功 → 摘要 + 编译按钮', async () => {
inspectZipMock.mockResolvedValue(INSPECT_OK);
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(true);
});
expect(inspectZipMock).toHaveBeenCalledTimes(1);
const meta = wrapper.find('[data-testid="rf-zip-meta"]');
expect(meta.text()).toContain('main.tex');
expect(meta.text()).toContain('文件 5 个');
expect(meta.text()).toContain('1 个文件已跳过');
expect(wrapper.find('[data-testid="rf-zip-skipped"]').text()).toContain('__MACOSX');
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
});
it('3) inspect 失败(ZIP_UNSAFE)→ 错误展示', async () => {
inspectZipMock.mockRejectedValue(
new ApiError('zip 含危险条目:evil.sh', { code: 'ZIP_UNSAFE', status: 200 }),
);
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-error"]').exists()).toBe(true);
});
expect(wrapper.find('[data-testid="rf-zip-error"]').text()).toContain('evil.sh');
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(false);
});
it('4) 编译 → loading → 成功:iframe 预览 + 耗时', async () => {
inspectZipMock.mockResolvedValue(INSPECT_OK);
let resolveRun;
runZipCompileMock.mockReturnValue(new Promise((r) => { resolveRun = r; }));
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
});
await wrapper.find('[data-testid="rf-zip-compile-btn"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
resolveRun({
ok: true,
compile_id: 'aabbccddeeff',
pdf_url: '/api/review/compile/zip/aabbccddeeff/pdf',
log_url: '/api/review/compile/zip/aabbccddeeff/log',
meta: { seconds: 3.2, pdf_bytes: 2048, project: true },
});
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(true);
});
expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('3.2 秒');
expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
});
it('5) 编译失败:错误摘要 + log 折叠区 + 查看完整 log', async () => {
inspectZipMock.mockResolvedValue(INSPECT_OK);
const compileErr = new ApiError('LaTeX 源码有语法错误', {
code: 'COMPILE_FAILED', status: 200,
});
compileErr.detail = 'l.12 \\badcmd'; // unwrap 从 error.detail 挂载(log 尾)
runZipCompileMock.mockRejectedValue(compileErr);
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
});
await wrapper.find('[data-testid="rf-zip-compile-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-compile-error"]').exists()).toBe(true);
});
expect(wrapper.find('[data-testid="rf-compile-error"]').text()).toContain('语法错误');
expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('l.12');
expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
});
it('6) 模式互斥:切回 .tex 清 zip 状态(inspect 摘要消失)', async () => {
inspectZipMock.mockResolvedValue(INSPECT_OK);
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(true);
});
await wrapper.find('[data-testid="rf-mode-tex"]').trigger('click');
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(false);
expect(edu.zipInspect).toBeNull();
expect(edu.zipFile).toBeNull();
// tex 上传区回来了
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(true);
});
});