@tbox.cn/app-template-agent 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore +5 -0
- package/.tbox/app.json +9 -1
- package/AGENTS.md +12 -4
- package/ENV.md +102 -17
- package/README.md +36 -34
- package/apps/client/VISUAL_RESTORE.md +11 -11
- package/apps/client/index.html +3 -1
- package/apps/client/package.json +9 -8
- package/apps/client/src/business/App.tsx +4 -4
- package/apps/client/src/business/README.md +5 -11
- package/apps/client/src/cards/registry.ts +23 -8
- package/apps/client/src/main.tsx +36 -2
- package/apps/client/src/modules.ts +7 -4
- package/apps/client/src/pages/index/index.tsx +18 -162
- package/apps/client/src/platform/AgentChatApp.tsx +3 -3
- package/apps/client/src/platform/agent-runtime.ts +5 -0
- package/apps/client/src/store/chat.ts +47 -185
- package/apps/client/src/test/setup.ts +7 -0
- package/apps/client/vite.config.ts +16 -1
- package/apps/server/package.json +0 -1
- package/apps/server/src/app-tools.ts +2 -1
- package/apps/server/src/app.ts +178 -110
- package/apps/server/src/config/integration.ts +2 -6
- package/apps/server/src/dev.ts +0 -1
- package/apps/server/src/intent-routes.ts +3 -3
- package/apps/server/src/modules.ts +4 -4
- package/apps/server/src/pre-tools.ts +4 -3
- package/apps/server/tests/agent-prompt.test.ts +81 -0
- package/apps/server/tests/cards-registry.test.ts +1 -3
- package/apps/server/tests/deployment-loader.test.ts +35 -56
- package/apps/server/tests/tool-runtime.test.ts +25 -61
- package/package.json +5 -4
- package/packages/agent-ui-kit/LICENSE +21 -0
- package/packages/agent-ui-kit/README.md +17 -0
- package/packages/agent-ui-kit/package.json +58 -0
- package/packages/agent-ui-kit/src/ChatPage.less +62 -0
- package/packages/agent-ui-kit/src/ChatPage.tsx +305 -0
- package/packages/agent-ui-kit/src/chat-config.test.ts +82 -0
- package/packages/agent-ui-kit/src/chat-config.ts +62 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/DebugButton.tsx +2 -2
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.less +19 -16
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.tsx +10 -13
- package/packages/agent-ui-kit/src/components/CardRenderer.tsx +36 -0
- package/packages/agent-ui-kit/src/components/ChatHeader/index.less +148 -0
- package/packages/agent-ui-kit/src/components/ChatHeader/index.test.tsx +74 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatHeader/index.tsx +27 -15
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.less +47 -39
- package/packages/agent-ui-kit/src/components/ChatInput/index.test.tsx +156 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.tsx +13 -4
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/useVoicePress.ts +2 -2
- package/packages/agent-ui-kit/src/components/HistoryEntry/index.less +49 -0
- package/packages/agent-ui-kit/src/components/HistoryEntry/index.tsx +42 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.less +3 -3
- package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.tsx +6 -6
- package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.less +25 -20
- package/packages/agent-ui-kit/src/components/MessageBubble/index.test.tsx +112 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.tsx +22 -15
- package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.less +2 -2
- package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.tsx +2 -2
- package/packages/agent-ui-kit/src/components/QuickCommands/index.less +63 -0
- package/packages/agent-ui-kit/src/components/QuickCommands/index.test.tsx +99 -0
- package/packages/agent-ui-kit/src/components/QuickCommands/index.tsx +52 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.less +1 -1
- package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.test.ts +346 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.ts +204 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.less +312 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.test.tsx +261 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.tsx +212 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/titles.ts +102 -0
- package/packages/agent-ui-kit/src/hooks/use-drag-gesture.test.ts +66 -0
- package/packages/agent-ui-kit/src/hooks/use-drag-gesture.ts +129 -0
- package/packages/agent-ui-kit/src/hooks/use-pull-down-history.ts +241 -0
- package/packages/agent-ui-kit/src/hooks/use-pull-down-wheel-native.test.ts +64 -0
- package/{apps/client → packages/agent-ui-kit}/src/hooks/useMediaUpload.ts +3 -3
- package/{apps/client → packages/agent-ui-kit}/src/hooks/useTTSAutoPlay.ts +7 -5
- package/packages/agent-ui-kit/src/index.ts +69 -0
- package/packages/agent-ui-kit/src/store-context.tsx +92 -0
- package/packages/agent-ui-kit/src/test/setup.ts +7 -0
- package/packages/agent-ui-kit/src/theme/contract.json +72 -0
- package/packages/agent-ui-kit/src/theme/global.less +99 -0
- package/packages/agent-ui-kit/src/theme/motion.less +19 -0
- package/packages/agent-ui-kit/src/theme/theme-contract.test.ts +69 -0
- package/packages/agent-ui-kit/src/theme/theme.ts +17 -0
- package/packages/agent-ui-kit/src/theme/tokens.less +79 -0
- package/packages/agent-ui-kit/src/utils/haptics.test.ts +51 -0
- package/packages/agent-ui-kit/src/utils/haptics.ts +44 -0
- package/packages/agent-ui-kit/src/utils/pull-down-machine.test.ts +188 -0
- package/packages/agent-ui-kit/src/utils/pull-down-machine.ts +157 -0
- package/packages/agent-ui-kit/src/utils/tool-call-display.test.ts +240 -0
- package/packages/agent-ui-kit/src/utils/tool-call-display.ts +146 -0
- package/packages/agent-ui-kit/src/vite-env.d.ts +1 -0
- package/packages/agent-ui-kit/tsconfig.json +8 -0
- package/packages/agent-ui-kit/vitest.config.ts +7 -0
- package/pnpm-lock.template.yaml +177 -83
- package/pnpm-workspace.template.yaml +12 -4
- package/profiles/mall/config/agent.jsonc +33 -0
- package/profiles/mall/config/chat.json +21 -0
- package/profiles/mall/config/knowledge.json +5 -0
- package/profiles/mall/config/llm_provider.json +5 -0
- package/profiles/mall/config/plugins.json +3 -0
- package/profiles/mall.json +16 -0
- package/scripts/check-workspace.mjs +3 -2
- package/apps/client/assets/copy@2x.png +0 -0
- package/apps/client/assets/debug.png +0 -0
- package/apps/client/assets/dislike@2x.png +0 -0
- package/apps/client/assets/like@2x.png +0 -0
- package/apps/client/assets/speaker@2x.png +0 -0
- package/apps/client/src/cards/CardRenderer.tsx +0 -20
- package/apps/client/src/components/ChatHeader/index.less +0 -104
- package/apps/client/src/components/ErrorBoundary.tsx +0 -2
- package/apps/client/src/components/QuickCommands/index.less +0 -48
- package/apps/client/src/components/QuickCommands/index.tsx +0 -29
- package/apps/client/src/components/ToolCallBlock/index.less +0 -131
- package/apps/client/src/components/ToolCallBlock/index.tsx +0 -125
- package/apps/client/src/examples/tourism/TourismLanding.tsx +0 -96
- package/apps/client/src/examples/tourism/index.less +0 -192
- package/apps/client/src/pages/index/index.less +0 -43
- package/apps/client/src/store/chat-config.ts +0 -40
- package/apps/client/src/styles/global.less +0 -60
- package/apps/client/src/styles/tokens.less +0 -60
- package/apps/server/src/config/deployment.ts +0 -47
- package/packages/module-weather/package.json +0 -36
- package/packages/module-weather/skills/weather-guide/SKILL.md +0 -22
- package/packages/module-weather/skills/weather-guide/references/dressing-tips.md +0 -5
- package/packages/module-weather/src/client/cards/weather/index.less +0 -22
- package/packages/module-weather/src/client/cards/weather/index.tsx +0 -14
- package/packages/module-weather/src/client/index.ts +0 -19
- package/packages/module-weather/src/index.ts +0 -2
- package/packages/module-weather/src/server/cards/weather/meta.ts +0 -27
- package/packages/module-weather/src/server/index.ts +0 -45
- package/packages/module-weather/src/server/pre-tools.ts +0 -20
- package/packages/module-weather/src/server/tool.ts +0 -29
- package/packages/module-weather/tbox.module.json +0 -27
- package/packages/module-weather/tsconfig.json +0 -14
- /package/{apps/client → packages/agent-ui-kit}/assets/keyboard-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/more-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/new-chat.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/quick-command-star.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/send-gradient.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/stop@2x.png +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/voice-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoicePressField.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoiceRecordingOverlay.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/machine.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.less +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.tsx +0 -0
- /package/{apps/client/src/types/chat.ts → packages/agent-ui-kit/src/types.ts} +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/copyText.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/imagePreview.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/session-error.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/toast.ts +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
4
|
+
import { flushSync } from 'react-dom';
|
|
5
|
+
import {
|
|
6
|
+
buildMemoryToolCallPayload,
|
|
7
|
+
buildPreToolArgsPayload,
|
|
8
|
+
buildPreToolResultPayload,
|
|
9
|
+
buildSkillToolCallPayload,
|
|
10
|
+
MEMORY_TOOL_CALL_NAME,
|
|
11
|
+
PRE_TOOL_CALL_NAME,
|
|
12
|
+
SKILL_TOOL_CALL_NAME,
|
|
13
|
+
} from '@tbox.cn/app-contracts';
|
|
14
|
+
import type { ToolCallInfo } from '../../types';
|
|
15
|
+
import ToolCallBlock from './index';
|
|
16
|
+
|
|
17
|
+
const mocks = vi.hoisted(() => ({
|
|
18
|
+
copyText: vi.fn(async () => true),
|
|
19
|
+
toast: vi.fn(async () => {}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../../utils/copyText', () => ({ copyText: mocks.copyText }));
|
|
23
|
+
vi.mock('../../utils/toast', () => ({ toast: mocks.toast }));
|
|
24
|
+
|
|
25
|
+
/** 夹具:1 preTool(done) + 1 skill(带 description) + 1 normal(calling),载荷经契约 builder 同构 */
|
|
26
|
+
function makeCalls(): ToolCallInfo[] {
|
|
27
|
+
const skill = buildSkillToolCallPayload({ name: 'member-guide', description: '会员导购技能' });
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
id: 'pre-1',
|
|
31
|
+
name: PRE_TOOL_CALL_NAME,
|
|
32
|
+
args: buildPreToolArgsPayload({
|
|
33
|
+
toolName: 'member-balance',
|
|
34
|
+
description: '会员卡余额查询',
|
|
35
|
+
toolArgs: { cardNo: 'C001' },
|
|
36
|
+
}),
|
|
37
|
+
result: buildPreToolResultPayload({
|
|
38
|
+
toolName: 'member-balance',
|
|
39
|
+
status: 'done',
|
|
40
|
+
result: { balance: 1200 },
|
|
41
|
+
}),
|
|
42
|
+
status: 'done',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'skill-1',
|
|
46
|
+
name: SKILL_TOOL_CALL_NAME,
|
|
47
|
+
args: skill.args,
|
|
48
|
+
result: skill.result,
|
|
49
|
+
status: 'done',
|
|
50
|
+
},
|
|
51
|
+
{ id: 'tool-1', name: 'search_stores', status: 'calling' },
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 018:memory 伪工具夹具(hit + degraded),载荷经契约 builder 同构 */
|
|
56
|
+
function makeMemoryCalls(): ToolCallInfo[] {
|
|
57
|
+
const hit = buildMemoryToolCallPayload({
|
|
58
|
+
query: '推荐美食',
|
|
59
|
+
status: 'hit',
|
|
60
|
+
records: [{ id: 'm1', kind: 'preference', content: '用户爱吃辣' }],
|
|
61
|
+
});
|
|
62
|
+
const degraded = buildMemoryToolCallPayload({
|
|
63
|
+
query: '今天活动',
|
|
64
|
+
status: 'degraded',
|
|
65
|
+
error: '记忆召回超时',
|
|
66
|
+
});
|
|
67
|
+
return [
|
|
68
|
+
{ id: 'mem-hit', name: MEMORY_TOOL_CALL_NAME, args: hit.args, result: hit.result, status: 'done' },
|
|
69
|
+
{ id: 'mem-deg', name: MEMORY_TOOL_CALL_NAME, args: degraded.args, result: degraded.result, status: 'done' },
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderBlock(
|
|
74
|
+
calls: ToolCallInfo[],
|
|
75
|
+
opts: { debug?: boolean } = {},
|
|
76
|
+
): {
|
|
77
|
+
container: HTMLElement;
|
|
78
|
+
rerender: (next: ToolCallInfo[]) => void;
|
|
79
|
+
unmount: () => void;
|
|
80
|
+
} {
|
|
81
|
+
const container = document.createElement('div');
|
|
82
|
+
document.body.appendChild(container);
|
|
83
|
+
const root: Root = createRoot(container);
|
|
84
|
+
flushSync(() => root.render(<ToolCallBlock toolCalls={calls} debug={opts.debug} />));
|
|
85
|
+
return {
|
|
86
|
+
container,
|
|
87
|
+
rerender: (next) => flushSync(() => root.render(<ToolCallBlock toolCalls={next} debug={opts.debug} />)),
|
|
88
|
+
unmount: () => flushSync(() => root.unmount()),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expandSummary(container: HTMLElement): void {
|
|
93
|
+
flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__summary')?.click());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
vi.clearAllMocks();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
document.body.innerHTML = '';
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('ToolCallBlock prod 态(018 I4:友好聚合、无出入参展开)', () => {
|
|
105
|
+
it('默认收起为语义按钮,主文案显示当前执行中的中文任务标题', () => {
|
|
106
|
+
const { container, unmount } = renderBlock(makeCalls());
|
|
107
|
+
const summary = container.querySelector<HTMLButtonElement>('.tool-calls__summary');
|
|
108
|
+
expect(summary?.tagName).toBe('BUTTON');
|
|
109
|
+
expect(summary?.type).toBe('button');
|
|
110
|
+
expect(summary?.getAttribute('aria-expanded')).toBe('false');
|
|
111
|
+
expect(summary?.textContent).toContain('正在搜索商场门店');
|
|
112
|
+
expect(summary?.textContent).not.toContain('search_stores');
|
|
113
|
+
expect(container.querySelector('.tool-calls__list')).toBeNull();
|
|
114
|
+
unmount();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('展开后活动行仅状态/中文名/徽标——零详情触发按钮、零技术名(I4)', () => {
|
|
118
|
+
const { container, unmount } = renderBlock(makeCalls());
|
|
119
|
+
expandSummary(container);
|
|
120
|
+
|
|
121
|
+
expect(container.querySelectorAll('.tool-calls__item')).toHaveLength(3);
|
|
122
|
+
expect(container.querySelector('.tool-calls__list')?.textContent).toContain('前置工具');
|
|
123
|
+
expect(container.querySelector('.tool-calls__list')?.textContent).toContain('会员卡余额查询');
|
|
124
|
+
expect(container.querySelector('.tool-calls__list')?.textContent).toContain('搜索商场门店');
|
|
125
|
+
// I4:无任何出入参展开入口(触发按钮/chevron/复制全部不渲染)
|
|
126
|
+
expect(container.querySelectorAll('button.tool-calls__item-trigger')).toHaveLength(0);
|
|
127
|
+
expect(container.querySelector('.tool-calls__item-chevron')).toBeNull();
|
|
128
|
+
expect(container.querySelector('.tool-calls__detail')).toBeNull();
|
|
129
|
+
expect(container.querySelector('.tool-calls__copy')).toBeNull();
|
|
130
|
+
expect(container.querySelector('.tool-calls__item-tech')).toBeNull();
|
|
131
|
+
unmount();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('preTool payload 失败保持失败聚合与失败活动行', () => {
|
|
135
|
+
const failedPreTool: ToolCallInfo = {
|
|
136
|
+
id: 'pre-error',
|
|
137
|
+
name: PRE_TOOL_CALL_NAME,
|
|
138
|
+
args: buildPreToolArgsPayload({ toolName: 'parking-fee', description: '停车费查询' }),
|
|
139
|
+
result: buildPreToolResultPayload({ toolName: 'parking-fee', status: 'error', error: '接口超时' }),
|
|
140
|
+
status: 'done',
|
|
141
|
+
};
|
|
142
|
+
const { container, unmount } = renderBlock([failedPreTool]);
|
|
143
|
+
expect(container.querySelector('.tool-calls__summary')?.textContent).toContain('已调用工具 1 个');
|
|
144
|
+
expect(container.querySelector('.tool-calls__failed')?.textContent).toContain('1 个失败');
|
|
145
|
+
|
|
146
|
+
expandSummary(container);
|
|
147
|
+
expect(container.querySelector('.tool-calls__item--error')).not.toBeNull();
|
|
148
|
+
unmount();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('聚合按钮可由浏览器原生键盘激活', () => {
|
|
152
|
+
const { container, unmount } = renderBlock(makeCalls());
|
|
153
|
+
const summary = container.querySelector<HTMLButtonElement>('.tool-calls__summary');
|
|
154
|
+
summary?.focus();
|
|
155
|
+
expect(document.activeElement).toBe(summary);
|
|
156
|
+
flushSync(() => summary?.click());
|
|
157
|
+
expect(container.querySelector('.tool-calls__list')).not.toBeNull();
|
|
158
|
+
unmount();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('ToolCallBlock dev 态(018 tool-debug:紧凑详情 + 技术后缀)', () => {
|
|
163
|
+
it('展开后行标题附技术名后缀(协议技术名 dev 可见)', () => {
|
|
164
|
+
const { container, unmount } = renderBlock(makeCalls(), { debug: true });
|
|
165
|
+
expandSummary(container);
|
|
166
|
+
|
|
167
|
+
const tech = container.querySelector('.tool-calls__item-tech');
|
|
168
|
+
expect(tech?.textContent).toBe(' · member-balance');
|
|
169
|
+
expect(container.querySelectorAll('button.tool-calls__item-trigger')).toHaveLength(2);
|
|
170
|
+
unmount();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('详情在消息流内展开:屏显走紧凑单行格式(formatCompactToolIo),双栏输入输出', () => {
|
|
174
|
+
const { container, unmount } = renderBlock(makeCalls(), { debug: true });
|
|
175
|
+
expandSummary(container);
|
|
176
|
+
flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger')?.click());
|
|
177
|
+
|
|
178
|
+
const detail = container.querySelector('.tool-calls__detail');
|
|
179
|
+
expect(detail).not.toBeNull();
|
|
180
|
+
const bodies = detail?.querySelectorAll('.tool-calls__detail-body');
|
|
181
|
+
// 紧凑单行(无 pretty 缩进空格)
|
|
182
|
+
expect(bodies?.[0].textContent).toContain('{"cardNo":"C001"}');
|
|
183
|
+
expect(bodies?.[1].textContent).toContain('{"balance":1200}');
|
|
184
|
+
expect(bodies?.[0].textContent).not.toContain('"cardNo": "C001"');
|
|
185
|
+
// 输入/输出双栏标签
|
|
186
|
+
expect(detail?.textContent).toContain('输入');
|
|
187
|
+
expect(detail?.textContent).toContain('输出');
|
|
188
|
+
unmount();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('输出复制仍为美化全文(屏显紧凑、粘贴友好分工)', async () => {
|
|
192
|
+
const { container, unmount } = renderBlock(makeCalls(), { debug: true });
|
|
193
|
+
expandSummary(container);
|
|
194
|
+
flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger')?.click());
|
|
195
|
+
// 第二个复制按钮 = 输出(第一个是输入)
|
|
196
|
+
container.querySelectorAll<HTMLButtonElement>('.tool-calls__copy')[1]?.click();
|
|
197
|
+
|
|
198
|
+
await vi.waitFor(() => expect(mocks.copyText).toHaveBeenCalledTimes(1));
|
|
199
|
+
expect(mocks.copyText).toHaveBeenCalledWith(expect.stringContaining('"balance": 1200'));
|
|
200
|
+
expect(mocks.toast).toHaveBeenCalledWith('输出已复制', 'success');
|
|
201
|
+
unmount();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('聚合与行详情展开状态在流式 rerender 后保持稳定', () => {
|
|
205
|
+
const calls = makeCalls();
|
|
206
|
+
const { container, rerender, unmount } = renderBlock(calls, { debug: true });
|
|
207
|
+
expandSummary(container);
|
|
208
|
+
flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger')?.click());
|
|
209
|
+
expect(container.querySelector('.tool-calls__detail')).not.toBeNull();
|
|
210
|
+
|
|
211
|
+
rerender(calls.map((item) => item.id === 'tool-1'
|
|
212
|
+
? { ...item, status: 'done' as const, args: '{"keyword":"咖啡"}', result: '{"total":3}' }
|
|
213
|
+
: item));
|
|
214
|
+
|
|
215
|
+
expect(container.querySelector('.tool-calls__summary')?.getAttribute('aria-expanded')).toBe('true');
|
|
216
|
+
expect(container.querySelector('.tool-calls__list')).not.toBeNull();
|
|
217
|
+
expect(container.querySelector('.tool-calls__detail')).not.toBeNull();
|
|
218
|
+
expect(container.querySelector('.tool-calls__summary')?.textContent).toContain('已调用工具 2 个');
|
|
219
|
+
unmount();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('详情按钮可由浏览器原生键盘激活', () => {
|
|
223
|
+
const { container, unmount } = renderBlock(makeCalls(), { debug: true });
|
|
224
|
+
expandSummary(container);
|
|
225
|
+
const detailTrigger = container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger');
|
|
226
|
+
detailTrigger?.focus();
|
|
227
|
+
expect(document.activeElement).toBe(detailTrigger);
|
|
228
|
+
expect(detailTrigger?.getAttribute('aria-expanded')).toBe('false');
|
|
229
|
+
flushSync(() => detailTrigger?.click());
|
|
230
|
+
expect(detailTrigger?.getAttribute('aria-expanded')).toBe('true');
|
|
231
|
+
unmount();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('ToolCallBlock memory 伪工具(018)', () => {
|
|
236
|
+
it('hit/degraded 活动行:记忆召回徽标、降级为失败态、dev 详情含记录预览', () => {
|
|
237
|
+
const { container, unmount } = renderBlock(makeMemoryCalls(), { debug: true });
|
|
238
|
+
expandSummary(container);
|
|
239
|
+
|
|
240
|
+
const items = container.querySelectorAll('.tool-calls__item');
|
|
241
|
+
expect(items).toHaveLength(2);
|
|
242
|
+
expect(container.querySelector('.tool-calls__list')?.textContent).toContain('记忆召回 · 1 条命中');
|
|
243
|
+
expect(container.querySelector('.tool-calls__list')?.textContent).toContain('记忆召回降级');
|
|
244
|
+
expect(container.querySelector('.tool-calls__item--error')).not.toBeNull();
|
|
245
|
+
expect(container.querySelector('.tool-calls__list')?.textContent).toContain('记忆召回');
|
|
246
|
+
|
|
247
|
+
flushSync(() => container.querySelector<HTMLButtonElement>('.tool-calls__item-trigger')?.click());
|
|
248
|
+
const bodies = container.querySelectorAll('.tool-calls__detail-body');
|
|
249
|
+
expect(bodies[0].textContent).toBe('推荐美食');
|
|
250
|
+
expect(bodies[1].textContent).toContain('[preference] 用户爱吃辣');
|
|
251
|
+
unmount();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('prod 态 memory 行无展开入口(与其它工具一致)', () => {
|
|
255
|
+
const { container, unmount } = renderBlock(makeMemoryCalls());
|
|
256
|
+
expandSummary(container);
|
|
257
|
+
expect(container.querySelectorAll('button.tool-calls__item-trigger')).toHaveLength(0);
|
|
258
|
+
expect(container.querySelector('.tool-calls__detail')).toBeNull();
|
|
259
|
+
unmount();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useId, useMemo, useState } from 'react';
|
|
2
|
+
import { CaretDown, Check, Copy, X } from '@phosphor-icons/react';
|
|
3
|
+
import type { ToolCallInfo } from '../../types';
|
|
4
|
+
import { formatCompactToolIo } from '../../utils/tool-call-display';
|
|
5
|
+
import { buildSummaryText, deriveSummary, deriveToolCallItem, prettyDetailText } from './detail';
|
|
6
|
+
import type { ToolCallViewItem } from './detail';
|
|
7
|
+
import { copyText } from '../../utils/copyText';
|
|
8
|
+
import { toast } from '../../utils/toast';
|
|
9
|
+
import './index.less';
|
|
10
|
+
|
|
11
|
+
const EMPTY_TEXT = '(无)';
|
|
12
|
+
|
|
13
|
+
const STATUS_TEXT: Record<ToolCallViewItem['status'], string> = {
|
|
14
|
+
done: '成功',
|
|
15
|
+
error: '失败',
|
|
16
|
+
calling: '执行中',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Codex 风格工具活动日志:紧凑聚合行 / 流内活动列表 / 行内完整 IO。
|
|
21
|
+
* 语义判定仍在 SDK describeToolCall + detail.ts 视图模型,本组件只管理展示状态;
|
|
22
|
+
* debug 门控在渲染层(018 tool-debug):prod 恒无出入参展开入口(I4),
|
|
23
|
+
* dev 行标题附技术名、详情走紧凑单行格式(复制仍为美化全文);
|
|
24
|
+
* 图标颜色不传 color prop(phosphor 落 SVG fill 属性,var() 低版本 web-view 不可靠),
|
|
25
|
+
* 统一走 currentColor + less token 类。
|
|
26
|
+
*/
|
|
27
|
+
export default function ToolCallBlock({
|
|
28
|
+
toolCalls,
|
|
29
|
+
debug = false,
|
|
30
|
+
}: {
|
|
31
|
+
toolCalls: ToolCallInfo[];
|
|
32
|
+
/** 018 tool-debug:dev 视图(出入参展开 + 技术名 + 紧凑格式);prod 缺省 false */
|
|
33
|
+
debug?: boolean;
|
|
34
|
+
}) {
|
|
35
|
+
const [expanded, setExpanded] = useState(false);
|
|
36
|
+
const [expandedDetailIds, setExpandedDetailIds] = useState<Set<string>>(() => new Set());
|
|
37
|
+
const listId = useId();
|
|
38
|
+
const items = useMemo(() => toolCalls.map(deriveToolCallItem), [toolCalls]);
|
|
39
|
+
const summary = useMemo(() => deriveSummary(items), [items]);
|
|
40
|
+
|
|
41
|
+
const toggleDetail = (id: string) => {
|
|
42
|
+
setExpandedDetailIds((current) => {
|
|
43
|
+
const next = new Set(current);
|
|
44
|
+
if (next.has(id)) next.delete(id);
|
|
45
|
+
else next.add(id);
|
|
46
|
+
return next;
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="tool-calls">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className={`tool-calls__summary${summary.calling ? ' tool-calls__summary--calling' : ''}`}
|
|
55
|
+
aria-expanded={expanded}
|
|
56
|
+
aria-controls={listId}
|
|
57
|
+
onClick={() => setExpanded((current) => !current)}
|
|
58
|
+
>
|
|
59
|
+
<span className="tool-calls__left">
|
|
60
|
+
{summary.calling ? (
|
|
61
|
+
<span className="tool-calls__spinner" aria-hidden="true" />
|
|
62
|
+
) : summary.failedCount > 0 ? (
|
|
63
|
+
<X className="tool-calls__icon tool-calls__icon--error" size={16} strokeWidth={2.5} />
|
|
64
|
+
) : (
|
|
65
|
+
<Check className="tool-calls__icon tool-calls__icon--success" size={16} strokeWidth={2.5} />
|
|
66
|
+
)}
|
|
67
|
+
<span className="tool-calls__summary-text">
|
|
68
|
+
{buildSummaryText(summary).join(' · ')}
|
|
69
|
+
{summary.failedCount > 0 && (
|
|
70
|
+
<span className="tool-calls__failed"> · {summary.failedCount} 个失败</span>
|
|
71
|
+
)}
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
<CaretDown
|
|
75
|
+
size={14}
|
|
76
|
+
className={`tool-calls__chevron${expanded ? ' tool-calls__chevron--up' : ''}`}
|
|
77
|
+
/>
|
|
78
|
+
</button>
|
|
79
|
+
|
|
80
|
+
{expanded && (
|
|
81
|
+
<div id={listId} className="tool-calls__list">
|
|
82
|
+
{items.map((item) => {
|
|
83
|
+
// 018 I4:prod(debug=false)恒无出入参展开入口;dev 才有详情交互
|
|
84
|
+
const hasDetail = debug && !!item.detail;
|
|
85
|
+
const detailExpanded = hasDetail && expandedDetailIds.has(item.id);
|
|
86
|
+
const detailId = `${listId}-${item.id}`;
|
|
87
|
+
const itemContent = (
|
|
88
|
+
<>
|
|
89
|
+
<span className="tool-calls__item-left">
|
|
90
|
+
{item.status === 'calling' && (
|
|
91
|
+
<span className="tool-calls__spinner tool-calls__spinner--sm" />
|
|
92
|
+
)}
|
|
93
|
+
{item.status === 'done' && (
|
|
94
|
+
<Check className="tool-calls__icon tool-calls__icon--success" size={14} strokeWidth={2.5} />
|
|
95
|
+
)}
|
|
96
|
+
{item.status === 'error' && (
|
|
97
|
+
<X className="tool-calls__icon tool-calls__icon--error" size={14} strokeWidth={2.5} />
|
|
98
|
+
)}
|
|
99
|
+
<span className="tool-calls__item-name">
|
|
100
|
+
{item.label}
|
|
101
|
+
{debug && item.techName && (
|
|
102
|
+
<span className="tool-calls__item-tech"> · {item.techName}</span>
|
|
103
|
+
)}
|
|
104
|
+
</span>
|
|
105
|
+
{item.badge && (
|
|
106
|
+
<span className="tool-calls__badge">· {item.badge}</span>
|
|
107
|
+
)}
|
|
108
|
+
</span>
|
|
109
|
+
{hasDetail && (
|
|
110
|
+
<CaretDown
|
|
111
|
+
size={12}
|
|
112
|
+
className={`tool-calls__item-chevron${detailExpanded ? ' tool-calls__item-chevron--up' : ''}`}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div key={item.id} className={`tool-calls__item tool-calls__item--${item.status}`}>
|
|
120
|
+
{hasDetail ? (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
className="tool-calls__item-trigger"
|
|
124
|
+
aria-expanded={detailExpanded}
|
|
125
|
+
aria-controls={detailId}
|
|
126
|
+
aria-label={`${item.label},${STATUS_TEXT[item.status]},${detailExpanded ? '收起详情' : '查看详情'}`}
|
|
127
|
+
onClick={() => toggleDetail(item.id)}
|
|
128
|
+
>
|
|
129
|
+
{itemContent}
|
|
130
|
+
</button>
|
|
131
|
+
) : (
|
|
132
|
+
<div
|
|
133
|
+
className="tool-calls__item-trigger tool-calls__item-trigger--static"
|
|
134
|
+
role="status"
|
|
135
|
+
aria-label={`${item.label},${STATUS_TEXT[item.status]}`}
|
|
136
|
+
>
|
|
137
|
+
{itemContent}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
{detailExpanded && item.detail && (
|
|
141
|
+
<ToolCallInlineDetail id={detailId} item={item} />
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
})}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 018 dev 详情区:屏显走 formatCompactToolIo(单行紧凑 + 头尾保留);
|
|
154
|
+
* 复制走 prettyDetailText(美化全文)——屏显紧凑、粘贴友好分工。
|
|
155
|
+
*/
|
|
156
|
+
function ToolCallInlineDetail({ id, item }: { id: string; item: ToolCallViewItem }) {
|
|
157
|
+
const formatted = useMemo(
|
|
158
|
+
() => ({
|
|
159
|
+
input: formatCompactToolIo(item.detail?.inputText ?? ''),
|
|
160
|
+
output: formatCompactToolIo(item.detail?.outputText ?? ''),
|
|
161
|
+
inputPretty: prettyDetailText(item.detail?.inputText ?? ''),
|
|
162
|
+
outputPretty: prettyDetailText(item.detail?.outputText ?? ''),
|
|
163
|
+
}),
|
|
164
|
+
[item],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const copyIo = async (text: string, label: string) => {
|
|
168
|
+
const copied = await copyText(text);
|
|
169
|
+
void toast(copied ? `${label}已复制` : '复制失败,请重试', copied ? 'success' : 'error');
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div id={id} className="tool-calls__detail">
|
|
174
|
+
<div className="tool-calls__detail-section">
|
|
175
|
+
<div className="tool-calls__detail-heading">
|
|
176
|
+
<span className="tool-calls__detail-label">输入</span>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className="tool-calls__copy"
|
|
180
|
+
aria-label={`复制${item.label}的输入`}
|
|
181
|
+
disabled={!formatted.inputPretty}
|
|
182
|
+
onClick={() => void copyIo(formatted.inputPretty, '输入')}
|
|
183
|
+
>
|
|
184
|
+
<Copy size={13} />
|
|
185
|
+
<span>复制</span>
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
<pre className="tool-calls__detail-body">{formatted.input || EMPTY_TEXT}</pre>
|
|
189
|
+
</div>
|
|
190
|
+
<div className="tool-calls__detail-section">
|
|
191
|
+
<div className="tool-calls__detail-heading">
|
|
192
|
+
<span className="tool-calls__detail-label">输出</span>
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
className="tool-calls__copy"
|
|
196
|
+
aria-label={`复制${item.label}的输出`}
|
|
197
|
+
disabled={!formatted.outputPretty}
|
|
198
|
+
onClick={() => void copyIo(formatted.outputPretty, '输出')}
|
|
199
|
+
>
|
|
200
|
+
<Copy size={13} />
|
|
201
|
+
<span>复制</span>
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
<pre
|
|
205
|
+
className={`tool-calls__detail-body${item.detail?.isError ? ' tool-calls__detail-body--error' : ''}`}
|
|
206
|
+
>
|
|
207
|
+
{formatted.output || EMPTY_TEXT}
|
|
208
|
+
</pre>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mall 工具活动标题:只改变展示,不改协议中的技术 name。
|
|
3
|
+
* 当前模块工具使用精确中文标题;未知英文工具使用中文通用兜底,避免把技术标识泄漏到用户标题面。
|
|
4
|
+
*/
|
|
5
|
+
const TOOL_TITLE_BY_NAME: Readonly<Record<string, string>> = {
|
|
6
|
+
// 会员
|
|
7
|
+
queryPoints: '查询会员积分',
|
|
8
|
+
queryMemberInfo: '查询会员信息',
|
|
9
|
+
queryMemberCard: '查询会员卡',
|
|
10
|
+
queryEnrollmentForm: '查询入会状态',
|
|
11
|
+
queryEnrollmentCta: '准备入会指引',
|
|
12
|
+
queryLoginCta: '准备登录指引',
|
|
13
|
+
|
|
14
|
+
// 商场导购
|
|
15
|
+
recommend: '推荐商品',
|
|
16
|
+
'search-products': '搜索商品',
|
|
17
|
+
'product-facets': '查询商品筛选项',
|
|
18
|
+
'get-product': '查询商品详情',
|
|
19
|
+
'compare-products': '对比商品',
|
|
20
|
+
'search-shops': '搜索商场门店',
|
|
21
|
+
'get-shop': '查询门店详情',
|
|
22
|
+
'mall-directory': '查询商场楼层',
|
|
23
|
+
'get-location': '查询门店位置',
|
|
24
|
+
'list-human-guides': '查询人工导购',
|
|
25
|
+
'list-browsing-history': '查询浏览记录',
|
|
26
|
+
'list-nearby-malls': '查询附近商场',
|
|
27
|
+
'list-categories': '查询商场业态',
|
|
28
|
+
'search-knowledge': '检索商场知识',
|
|
29
|
+
'customer-service-faq': '查询客服问题',
|
|
30
|
+
'mall-context': '加载商场信息',
|
|
31
|
+
'mall-info': '查询商场信息',
|
|
32
|
+
|
|
33
|
+
// 停车
|
|
34
|
+
queryParkingFee: '查询停车费用',
|
|
35
|
+
listVehicles: '查询已绑车辆',
|
|
36
|
+
listPaymentRecords: '查询停车缴费记录',
|
|
37
|
+
queryParkingInfo: '查询停车场信息',
|
|
38
|
+
queryApplicableBenefits: '查询停车权益',
|
|
39
|
+
locateVehicle: '查找车辆位置',
|
|
40
|
+
listInvoiceableOrders: '查询可开票订单',
|
|
41
|
+
queryParkingPaymentStatus: '查询停车支付状态',
|
|
42
|
+
queryParkingPaymentDetail: '查询停车订单详情',
|
|
43
|
+
|
|
44
|
+
// 优惠活动
|
|
45
|
+
listActivityCategories: '查询活动分类',
|
|
46
|
+
listActivities: '查询商场活动',
|
|
47
|
+
getActivityDetail: '查询活动详情',
|
|
48
|
+
listActivitySessions: '查询活动场次',
|
|
49
|
+
getActivityGuide: '查询活动参与指南',
|
|
50
|
+
searchOffers: '搜索优惠',
|
|
51
|
+
getOfferDetail: '查询优惠详情',
|
|
52
|
+
listEntitlements: '查询我的优惠',
|
|
53
|
+
listPromotions: '查询促销活动',
|
|
54
|
+
getPromotionDetail: '查询促销详情',
|
|
55
|
+
getParticipationGuide: '查询参与步骤',
|
|
56
|
+
listCoupons: '查询优惠券',
|
|
57
|
+
getPackageDetail: '查询礼包详情',
|
|
58
|
+
recommendOffers: '推荐可用优惠',
|
|
59
|
+
calculateBestDeal: '测算最佳优惠',
|
|
60
|
+
|
|
61
|
+
// 碰出惊喜与平台前置工具
|
|
62
|
+
getBumpInActivity: '查询碰出惊喜活动',
|
|
63
|
+
getBumpInActivityBanner: '加载碰出惊喜活动',
|
|
64
|
+
getCurrentTime: '获取当前时间',
|
|
65
|
+
|
|
66
|
+
// 历史命名与确定性 Gallery 别名
|
|
67
|
+
'member-balance': '查询会员余额',
|
|
68
|
+
'parking-fee': '查询停车费用',
|
|
69
|
+
'parking-fee-context': '加载停车费用信息',
|
|
70
|
+
search_stores: '搜索商场门店',
|
|
71
|
+
search_mall_services: '搜索商场服务',
|
|
72
|
+
get_member_points: '查询会员积分',
|
|
73
|
+
load_parking_order: '查询停车订单',
|
|
74
|
+
search_nearby_stores: '搜索附近门店',
|
|
75
|
+
read_store_opening_hours: '查询门店营业时间',
|
|
76
|
+
search_shops_with_member_benefits_and_parking_validation_rules_for_current_mall: '搜索会员权益门店',
|
|
77
|
+
pay_order: '提交支付订单',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const HAN_TEXT = /[\u3400-\u9fff]/u;
|
|
81
|
+
const MAX_TITLE_LENGTH = 28;
|
|
82
|
+
|
|
83
|
+
function compactChineseTitle(value: string | undefined): string | undefined {
|
|
84
|
+
if (!value || !HAN_TEXT.test(value)) return undefined;
|
|
85
|
+
const firstClause = value.trim().split(/[\n。;;]/u, 1)[0]?.trim();
|
|
86
|
+
if (!firstClause) return undefined;
|
|
87
|
+
return firstClause.length > MAX_TITLE_LENGTH
|
|
88
|
+
? `${firstClause.slice(0, MAX_TITLE_LENGTH - 1)}…`
|
|
89
|
+
: firstClause;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 中文描述 > 已登记中文标题 > 原始中文名 > 中文通用兜底。 */
|
|
93
|
+
export function resolveToolTitle(
|
|
94
|
+
technicalName: string,
|
|
95
|
+
preferredText?: string,
|
|
96
|
+
fallback = '处理工具任务',
|
|
97
|
+
): string {
|
|
98
|
+
return compactChineseTitle(preferredText)
|
|
99
|
+
?? TOOL_TITLE_BY_NAME[technicalName]
|
|
100
|
+
?? compactChineseTitle(technicalName)
|
|
101
|
+
?? fallback;
|
|
102
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* 手势通道用例。核心契约:触摸/笔参与拖拽,鼠标不参与——桌面的下拉意图由触控板双指滑动
|
|
4
|
+
* 经 wheel 表达,按住鼠标拖页面在桌面上是选中文本的惯例,不该触发下拉。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { createRoot } from 'react-dom/client';
|
|
8
|
+
import { flushSync } from 'react-dom';
|
|
9
|
+
import { createElement } from 'react';
|
|
10
|
+
import { useDragGesture } from './use-drag-gesture';
|
|
11
|
+
|
|
12
|
+
function pointer(type: string, clientY: number, name: string) {
|
|
13
|
+
const e = new Event(name, { bubbles: true, cancelable: true }) as PointerEvent;
|
|
14
|
+
Object.defineProperties(e, {
|
|
15
|
+
pointerType: { value: type },
|
|
16
|
+
clientY: { value: clientY },
|
|
17
|
+
});
|
|
18
|
+
return e;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 极简 hook 宿主:仓库内既有写法(createRoot + flushSync),不引入额外测试依赖。 */
|
|
22
|
+
function setup() {
|
|
23
|
+
const el = document.createElement('div');
|
|
24
|
+
document.body.appendChild(el);
|
|
25
|
+
const handlers = { onStart: vi.fn(), onMove: vi.fn(() => true), onEnd: vi.fn() };
|
|
26
|
+
const Host = () => {
|
|
27
|
+
useDragGesture({ scrollRef: { current: el }, ...handlers });
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
const host = document.createElement('div');
|
|
31
|
+
document.body.appendChild(host);
|
|
32
|
+
flushSync(() => createRoot(host).render(createElement(Host)));
|
|
33
|
+
return { el, handlers };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('useDragGesture', () => {
|
|
37
|
+
it('D1 触摸指针参与拖拽', () => {
|
|
38
|
+
const { el, handlers } = setup();
|
|
39
|
+
el.dispatchEvent(pointer('touch', 100, 'pointerdown'));
|
|
40
|
+
el.dispatchEvent(pointer('touch', 160, 'pointermove'));
|
|
41
|
+
expect(handlers.onStart).toHaveBeenCalledWith({ clientY: 100 });
|
|
42
|
+
expect(handlers.onMove).toHaveBeenCalledWith({ clientY: 160 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('D2 鼠标拖拽被忽略(桌面单指拖拽不响应,交给 wheel 通道)', () => {
|
|
46
|
+
const { el, handlers } = setup();
|
|
47
|
+
el.dispatchEvent(pointer('mouse', 100, 'pointerdown'));
|
|
48
|
+
el.dispatchEvent(pointer('mouse', 160, 'pointermove'));
|
|
49
|
+
expect(handlers.onStart).not.toHaveBeenCalled();
|
|
50
|
+
expect(handlers.onMove).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('D3 笔同触摸(手写笔在触屏设备上是等价输入)', () => {
|
|
54
|
+
const { el, handlers } = setup();
|
|
55
|
+
el.dispatchEvent(pointer('pen', 50, 'pointerdown'));
|
|
56
|
+
el.dispatchEvent(pointer('pen', 90, 'pointermove'));
|
|
57
|
+
expect(handlers.onStart).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(handlers.onMove).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('D4 未按下时的移动不算拖拽', () => {
|
|
62
|
+
const { el, handlers } = setup();
|
|
63
|
+
el.dispatchEvent(pointer('touch', 160, 'pointermove'));
|
|
64
|
+
expect(handlers.onMove).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
});
|