- 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
189 lines
8.4 KiB
JavaScript
189 lines
8.4 KiB
JavaScript
// 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);
|
||
});
|
||
});
|