feat(review): show format rules summary per target journal

- New summary row: shows the journal's rules (even if no diff applies)
- Fixes user confusion when switching journals yields same diff
- When all rules are null: 'no explicit format requirements'
- Vitest: add case for summary rendering
This commit is contained in:
potaser
2026-10-13 11:22:52 +08:00
parent da84dff9bb
commit e036301e1a
2 changed files with 162 additions and 0 deletions
@@ -0,0 +1,123 @@
// tests/frontend/unit/edu/reformat.rules-summary.test.js
// 目标期刊规则摘要行(data-testid="rf-rules-summary"):
// 1) 有规则字段 + 有 diff → 摘要 + "已修改 N 处"
// 2) 规则全 null + diff=0 → "无强制约束" + "样本已满足该刊要求,无需修改"
// 背景:GB/BMC PB/Rice 规则相同、Frontiers 几乎无规则,用户切换期刊看不到 diff 差异,
// 即使 diff=0 也要展示"该刊要求什么",消除"没生效"错觉。
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';
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(),
};
});
import { useEduStore } from '../../../../ui/src/stores/edu.js';
function mountWithResult(eduSeed) {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
Object.assign(edu, eduSeed);
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
return { wrapper, edu };
}
function resultWith(rules, changes) {
return {
ok: true,
job_id: 'abc123def456',
modified_tex: 'x',
changes,
warnings: [],
meta: {
filename: 'paper.tex',
journal_name: 'Molecular Plant',
rules_source: 'official',
rules_note: 'journals.json 期刊专属规则',
rules,
},
};
}
describe('ReformatPanel 目标期刊规则摘要', () => {
beforeEach(() => {
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
});
it('1) 有规则字段 + 有 diff → 摘要列出字段且标注"已修改 N 处"', async () => {
// Molecular Plant 真实规则形态:12pt / 1.5 / author_year(font/margin null 不显示)
const { wrapper } = mountWithResult({
reformatStatus: 'ready',
reformatResult: resultWith(
{
min_font_pt: 12, min_margin_mm: null, min_line_spacing: 1.5,
refs_style: 'author_year', max_pages: null, min_figures: null, max_fonts: null,
},
[
{ line: 1, original: 'a', modified: 'b', reason: 'r', rule_key: 'font_pt', action: 'modified' },
{ line: 3, original: 'a', modified: 'b', reason: 'r', rule_key: 'line_spacing', action: 'modified' },
{ line: 4, original: 'a', modified: 'b', reason: 'r', rule_key: 'bib_style', action: 'modified' },
],
),
});
await wrapper.vm.$nextTick();
const el = wrapper.find('[data-testid="rf-rules-summary"]');
expect(el.exists()).toBe(true);
const text = el.text();
expect(text).toContain('1.5 倍行距');
expect(text).toContain('作者-年份引用');
expect(text).toContain('字号 ≥12pt');
expect(text).not.toContain('null');
expect(text).not.toContain('边距'); // min_margin_mm=null → 不显示
expect(text).toContain('已修改 3 处');
});
it('2) 规则全 null + diff=0 → "无强制约束" + "样本已满足该刊要求,无需修改"', async () => {
const { wrapper } = mountWithResult({
reformatStatus: 'ready',
reformatResult: resultWith(
{
min_font_pt: null, min_margin_mm: null, min_line_spacing: null,
refs_style: null, max_pages: null, min_figures: null, max_fonts: null,
},
[],
),
});
await wrapper.vm.$nextTick();
const el = wrapper.find('[data-testid="rf-rules-summary"]');
expect(el.exists()).toBe(true);
const text = el.text();
expect(text).toContain('该刊官网未规定明确格式要求,无强制约束');
expect(text).toContain('样本已满足该刊要求,无需修改');
expect(text).not.toContain('已修改');
});
it('3) 双倍行距 + Vancouver(GB/BMC/Rice 形态)→ 正确映射中文标签', async () => {
const { wrapper } = mountWithResult({
reformatStatus: 'ready',
reformatResult: resultWith(
{
min_font_pt: null, min_margin_mm: null, min_line_spacing: 2.0,
refs_style: 'vancouver', max_pages: null, min_figures: null, max_fonts: null,
},
[{ line: 3, original: 'a', modified: 'b', reason: 'r', rule_key: 'line_spacing', action: 'modified' }],
),
});
await wrapper.vm.$nextTick();
const text = wrapper.find('[data-testid="rf-rules-summary"]').text();
expect(text).toContain('双倍行距');
expect(text).toContain('Vancouver 引用');
expect(text).toContain('已修改 1 处');
});
});
@@ -118,6 +118,31 @@ const structWarnings = computed(() => result.value?.warnings || []);
const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').length);
const suggestCount = computed(() => changes.value.filter((c) => c.action === 'warning').length);
const sourceLabel = computed(() => SOURCE_LABEL[meta.value?.rules_source] || meta.value?.rules_source || '—');
// 目标期刊规则摘要:只显示有值字段(null 不显示);全 null → 明确说明无强制约束。
// 目的:diff=0 时(如 Frontiers / GB↔BMC 互切)也能看到"该刊要求什么",消除"没生效"错觉。
const _REFS_LABEL = { vancouver: 'Vancouver 引用', apa: 'APA 引用', author_year: '作者-年份引用' };
const rulesSummary = computed(() => {
const r = meta.value?.rules;
if (!r || typeof r !== 'object') return null;
const parts = [];
const ls = r.min_line_spacing;
if (typeof ls === 'number' && ls > 0) {
if (ls <= 1.05) parts.push('单倍行距');
else if (Math.abs(ls - 1.5) < 0.01) parts.push('1.5 倍行距');
else if (ls >= 1.95) parts.push('双倍行距');
else parts.push(`行距 ≥${ls} 倍`);
}
if (r.refs_style && _REFS_LABEL[r.refs_style]) parts.push(_REFS_LABEL[r.refs_style]);
if (typeof r.min_font_pt === 'number') parts.push(`字号 ≥${r.min_font_pt}pt`);
if (typeof r.min_margin_mm === 'number') parts.push(`边距 ≥${r.min_margin_mm}mm`);
if (!parts.length) return '该刊官网未规定明确格式要求,无强制约束';
return parts.join(' · ');
});
// 与 diff 的关系:有修改 → "已修改 N 处";0 修改 → "样本已满足该刊要求,无需修改"
const rulesStatusText = computed(() => {
if (modifiedCount.value > 0) return `已修改 ${modifiedCount.value} 处`;
return '样本已满足该刊要求,无需修改';
});
// ISO 时间戳 → "10-12 15:25"(内部字段不裸露;helper 规则见 UI 注释)
const generatedShort = computed(() => {
const s = meta.value?.generated_at;
@@ -266,6 +291,11 @@ function download() {
复制任务 ID
</button>
</div>
<!-- 目标期刊规则摘要:diff=0 时也能看到"该刊要求什么"(次级文字,不抢 diff 主视觉) -->
<p v-if="rulesSummary" class="rf-rules" data-testid="rf-rules-summary">
📋 该刊规则:{{ rulesSummary }}
<span class="rf-rules-status">({{ rulesStatusText }})</span>
</p>
<p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p>
<!-- 汇总:已修改 / 建议(橙,不计入修改)/ 结构提示 -->
@@ -540,6 +570,15 @@ function download() {
font-size: 12.5px;
color: var(--pa-ink-muted);
}
/* 规则摘要:次级文字(同 rf-meta 色阶),与汇总条的计数视觉区分 */
.rf-rules {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.rf-rules-status {
color: var(--pa-ink-subtle);
}
.rf-copy-id {
margin-left: 8px;
padding: 1px 8px;