Files
PlantAI-rag-new/tests/frontend/unit/b2/components.test.js
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
  历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
  单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
  tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
  生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:56:47 +08:00

258 lines
9.9 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.
// @vitest-environment jsdom
// components.test.js —— Composer 快捷键与禁用逻辑 + AssistantMessage 块联合渲染
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import Composer from '../../../../ui/src/components/chat/Composer.vue';
import AssistantMessage from '../../../../ui/src/components/chat/AssistantMessage.vue';
import SessionItem from '../../../../ui/src/components/session/SessionItem.vue';
describe('Composer(FR-CHAT-005/007/011)', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
const mountComposer = (props = {}) =>
mount(Composer, {
props: { mode: 'ask', busy: false, disabled: false, ...props },
});
it('空输入禁用发送;⌘/Ctrl+Enter 发送;Enter 换行', async () => {
const w = mountComposer();
const btn = w.find('.send-btn');
expect(btn.attributes('disabled')).toBeDefined();
const ta = w.find('textarea');
await ta.setValue('水稻SPL基因家族?');
// Enter 单独按下:不触发发送(换行)
await ta.trigger('keydown', { key: 'Enter' });
expect(w.emitted('send')).toBeUndefined();
// Ctrl+Enter:发送
await ta.trigger('keydown', { key: 'Enter', ctrlKey: true });
expect(w.emitted('send')).toBeTruthy();
expect(w.emitted('send')[0][0]).toBe('水稻SPL基因家族?');
// 发送后清空输入
expect(ta.element.value).toBe('');
});
it('⌘+Enter 同样发送(mac)', async () => {
const w = mountComposer();
const ta = w.find('textarea');
await ta.setValue('问题');
await ta.trigger('keydown', { key: 'Enter', metaKey: true });
expect(w.emitted('send')).toBeTruthy();
});
it('busy 时按钮变「停止」;点击 stop 触发事件(FR-CHAT-011)', async () => {
const w = mountComposer({ busy: true });
const btn = w.find('.send-btn');
expect(btn.text()).toContain('停止');
await btn.trigger('click');
expect(w.emitted('stop')).toBeTruthy();
});
it('disabled(健康降级)时输入与发送均不可用', async () => {
const w = mountComposer({ disabled: true });
expect(w.find('textarea').attributes('disabled')).toBeDefined();
const ta = w.find('textarea');
await ta.setValue('问题');
expect(w.find('.send-btn').attributes('disabled')).toBeDefined();
});
it('模式 pill 三态切换(labels.MODES)', async () => {
const w = mountComposer({ mode: 'ask' });
const pills = w.findAll('.mode-pill');
expect(pills).toHaveLength(3);
expect(pills[0].classes()).toContain('on');
await pills[1].trigger('click');
expect(w.emitted('change-mode')[0][0]).toBe('plan');
});
it('输入法组合键 Enter 不触发发送', async () => {
const w = mountComposer();
const ta = w.find('textarea');
await ta.setValue('问题');
await ta.trigger('keydown', { key: 'Enter', ctrlKey: true, isComposing: true });
expect(w.emitted('send')).toBeUndefined();
});
});
describe('AssistantMessage 块联合渲染', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
const mountMsg = (blocks, extra = {}) =>
mount(AssistantMessage, {
props: { message: { role: 'assistant', status: 'done', blocks, timeLabel: '10:00' }, ...extra },
});
it('text 块:markdown 渲染 + 脚本注入被消毒(FR-CHAT-004)', () => {
const w = mountMsg([{ type: 'text', html: '<p>加粗 <strong>SPL</strong></p><img src=x onerror="alert(1)"><script>alert(1)</script>' }]);
const html = w.find('.msg-body').html();
expect(html).toContain('<strong>SPL</strong>');
expect(html).not.toContain('onerror');
expect(html).not.toContain('<script');
});
it('table 块:md-table + 表头「说明」列非 mono,其余 mono(UI_SPEC §4.3)', () => {
const w = mountMsg([{
type: 'table',
headers: ['成员', '说明'],
rows: [['OsSPL14', '穗分枝']],
}]);
const tds = w.findAll('tbody td');
expect(tds).toHaveLength(2);
expect(tds[0].classes()).toContain('mono');
expect(tds[1].classes()).not.toContain('mono');
});
it('code 块:code-block 类', () => {
const w = mountMsg([{ type: 'code', text: 'ATGC' }]);
expect(w.find('.code-block').text()).toBe('ATGC');
});
it('stream 块:stream-cursor + shimmer-line + 跳过按钮', () => {
const w = mountMsg([{ type: 'stream', text: '部分文字' }], { reveal: { text: '全文' } });
expect(w.find('.stream-cursor').exists()).toBe(true);
expect(w.find('.shimmer-line').exists()).toBe(true);
expect(w.find('.skip-btn').exists()).toBe(true);
awaitFlush(w);
w.find('.skip-btn').trigger('click');
expect(w.emitted('skip-reveal')).toBeTruthy();
});
it('pipeline 块:五相位步骤 + 状态点字形(✓/●/◌)', () => {
const steps = [
{ name: '规划', state: 'done', desc: '意图理解', phase: 'planning' },
{ name: '检索', state: 'running', desc: '多路检索', phase: 'retrieval' },
{ name: '证据', state: 'waiting', desc: '证据筛选', phase: 'evidence' },
{ name: '生成', state: 'waiting', desc: '答案生成', phase: 'generation' },
{ name: '完成', state: 'waiting', desc: '检索完成', phase: 'done' },
];
const w = mountMsg([{ type: 'pipeline', status: 'running', steps }]);
const dots = w.findAll('.p-dot');
expect(dots).toHaveLength(5);
expect(dots[0].text()).toBe('✓');
expect(dots[1].text()).toBe('●');
expect(dots[2].text()).toBe('◌');
expect(w.find('.pipeline-card .pc-title').text()).toContain('检索流水线');
// 整体语义徽章
expect(w.find('.pipeline-card .badge.running').exists()).toBe(true);
});
it('tool 块:折叠头 + 展开体 + 日志类', () => {
const block = {
type: 'tool', icon: '🔬', name: '证据筛选', status: 'done', dur: '3.2s', open: true, phase: 'evidence',
params: 'accepted_count: 4', result: '4 条证据通过审核',
logs: [{ text: '> ok', cls: 'log-ok' }],
};
const w = mountMsg([block]);
expect(w.find('.tool-card.open').exists()).toBe(true);
expect(w.find('.t-name').text()).toBe('证据筛选');
expect(w.find('.t-dur').text()).toBe('3.2s');
expect(w.find('.tb-params').text()).toContain('accepted_count: 4');
expect(w.find('.log-ok').exists()).toBe(true);
});
it('sources 块:内联引用 chip 编号从 1 起(FR-CHAT-015)', () => {
const w = mountMsg([{
type: 'sources',
items: [
{ id: 's1', title: 'A' },
{ id: 's2', title: 'B' },
],
}]);
const chips = w.findAll('.src-chip');
expect(chips).toHaveLength(2);
expect(chips[0].text()).toBe('[1]');
expect(chips[1].text()).toBe('[2]');
chips[0].trigger('click');
expect(w.emitted('jump-source')[0][0]).toMatchObject({ index: 1 });
});
it('graph 块:轻量摘要(节点/边数),完整图在 bench(kg 跳转事件)', () => {
const w = mountMsg([{
type: 'graph',
nodes: [{ id: 'n1' }, { id: 'n2' }, { id: 'n3' }],
edges: [{ source: 'n1', target: 'n2' }],
chains: [],
}]);
expect(w.find('.graph-summary').exists()).toBe(true);
expect(w.find('.graph-summary').text()).toContain('3');
expect(w.find('.graph-summary').text()).toContain('1');
w.find('.gs-link').trigger('click');
expect(w.emitted('jump-graph')).toBeTruthy();
});
it('error 标注 + 重试按钮(FR-CHAT-012)', () => {
const w = mountMsg([{ type: 'pipeline', status: 'error', steps: [] }], {
message: {
role: 'assistant', status: 'error', streamErr: 'timeout',
blocks: [{ type: 'pipeline', status: 'error', steps: [] }], timeLabel: '10:00',
},
onRetry: () => {},
});
expect(w.find('.err-note').text()).toContain('检索超时');
expect(w.find('.retry-btn').exists()).toBe(true);
});
it('BUG-R1-05:errText(后端 message)优先于 streamErr 标注展示', () => {
const w = mountMsg([{ type: 'pipeline', status: 'error', steps: [] }], {
message: {
role: 'assistant', status: 'error', streamErr: 'message',
errText: '上游问答服务返回错误',
blocks: [{ type: 'pipeline', status: 'error', steps: [] }], timeLabel: '10:00',
},
onRetry: () => {},
});
expect(w.find('.err-note .err-text').text()).toBe('上游问答服务返回错误');
expect(w.find('.retry-btn').exists()).toBe(true);
});
it('BUG-R1-06:text 块 HTML 直接 v-html 输出(渲染路径不再 sanitize 一次即吞掉格式)', () => {
const w = mountMsg([{ type: 'text', html: '<p>加粗 <strong>SPL</strong></p><ul><li>成员</li></ul>' }]);
const html = w.find('.md-text').html();
expect(html).toContain('<strong>SPL</strong>');
expect(html).toContain('<li>成员</li>');
});
});
describe('SessionItem 删除入口(BUG-R1-03)', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
const mountItem = () =>
mount(SessionItem, {
props: { session: { id: 's1', title: '测试会话', status: 'done', meta: {} }, active: false, metaText: '2 · 10:00' },
});
it('右键唤出删除事件(不触发选择)', async () => {
const w = mountItem();
await w.find('.session-item').trigger('contextmenu');
expect(w.emitted('delete')).toBeTruthy();
expect(w.emitted('delete')[0][0]).toMatchObject({ id: 's1' });
expect(w.emitted('select')).toBeUndefined();
});
it('删除按钮点击触发 delete 且不冒泡选择', async () => {
const w = mountItem();
await w.find('.item-del').trigger('click');
expect(w.emitted('delete')).toBeTruthy();
expect(w.emitted('select')).toBeUndefined();
});
it('双击仍进入重命名(delete 入口不破坏既有交互)', async () => {
const w = mountItem();
await w.find('.session-item').trigger('dblclick');
expect(w.find('.rename-input').exists()).toBe(true);
});
});
function awaitFlush(w) {
return w.vm.$nextTick();
}