diff --git a/.codefuse/skills/components/SKILL.md b/.codefuse/skills/components/SKILL.md deleted file mode 100644 index cc35a011e5..0000000000 --- a/.codefuse/skills/components/SKILL.md +++ /dev/null @@ -1,572 +0,0 @@ -# Ant Design X 组件开发规范指南 - -基于 antd 组件库最佳实践的完整开发规范,涵盖命名、架构、样式、测试等各个方面。 - -## 1. 项目架构规范 - -### 1.1 目录结构 - -``` -components/[组件名]/ -├── index.tsx # 组件入口文件 -├── [组件名].tsx # 主组件实现 -├── [子组件].tsx # 子组件实现 -├── style/ -│ ├── index.ts # 样式入口 -│ ├── token.ts # Token 定义 -│ └── [其他样式文件].ts -├── demo/ # 示例代码 -│ ├── basic.tsx # 基础示例 -│ └── [其他示例].tsx -├── __tests__/ # 测试文件 -│ └── index.test.tsx # 单元测试 -└── index.zh-CN.md # 中文文档 -``` - -### 1.2 文件命名规范 - -- 组件文件:PascalCase(如 `Button.tsx`) -- 样式文件:camelCase(如 `buttonStyle.ts`) -- 测试文件:`index.test.tsx` 或 `[组件名].test.tsx` -- 示例文件:kebab-case(如 `basic.tsx`、`custom-filter.tsx`) - -## 2. 命名规范与语义化 - -### 2.1 组件命名 - -- **完整名称**:使用完整单词,避免缩写 -- **PascalCase**:组件名使用大驼峰命名 -- **语义化**:名称应准确描述组件功能 - -```typescript -// ✅ 正确 -interface ButtonProps {} -interface TypographyTextProps {} - -// ❌ 错误 -interface BtnProps {} // 缩写 -interface TxtProps {} // 缩写 -interface MyComponentProps {} // 语义不清 -``` - -### 2.2 Props 命名规范 - -#### 基础属性 - -- **类型属性**:`type`(如 `type="primary"`) -- **状态属性**:`disabled`、`loading`、`open` -- **尺寸属性**:`size`(`large` | `middle` | `small`) -- **默认值**:`default` + 属性名(如 `defaultValue`) - -#### 功能属性 - -- **可编辑**:`editable`(布尔或配置对象) -- **可复制**:`copyable`(布尔或配置对象) -- **可展开**:`expandable`(布尔或配置对象) - -#### 事件属性 - -- **触发事件**:`on` + 事件名(如 `onClick`、`onChange`) -- **子组件事件**:`on` + 子组件名 + 事件名(如 `onPanelClick`) -- **前置事件**:`before` + 事件名(如 `beforeUpload`) -- **后置事件**:`after` + 事件名(如 `afterClose`) - -### 2.3 CSS 类名规范 - -```typescript -// 组件前缀 -const prefixCls = getPrefixCls('button', customizePrefixCls); - -// 状态类名 -`${prefixCls}-${type}` // 类型类名 -`${prefixCls}-disabled` // 禁用状态 -`${prefixCls}-loading` // 加载状态 -`${prefixCls}-${sizeCls}` // 尺寸类名 -// 组合类名 -`${prefixCls}-icon-only` // 仅图标按钮 -`${prefixCls}-two-chinese-chars`; // 中文字符间距 -``` - -## 3. TypeScript 类型设计 - -### 3.1 Props 接口定义 - -```typescript -// 基础 Props 接口 -export interface ButtonProps extends React.ButtonHTMLAttributes { - // 类型定义 - type?: 'primary' | 'default' | 'dashed' | 'text' | 'link'; - size?: 'large' | 'middle' | 'small'; - - // 状态控制 - loading?: boolean | { delay?: number }; - disabled?: boolean; - - // 内容相关 - icon?: React.ReactNode; - children?: React.ReactNode; - - // 样式相关 - className?: string; - style?: React.CSSProperties; - - // 事件处理 - onClick?: (event: React.MouseEvent) => void; -} - -// 配置对象类型 -export interface CopyConfig { - text?: string | (() => string | Promise); - onCopy?: (event?: React.MouseEvent) => void; - icon?: React.ReactNode; - tooltips?: React.ReactNode; - format?: 'text/plain' | 'text/html'; -} -``` - -### 3.2 泛型组件设计 - -```typescript -// 泛型组件支持不同元素类型 -export interface BlockProps< - C extends keyof JSX.IntrinsicElements = keyof JSX.IntrinsicElements, -> extends TypographyProps { - component?: C; - // 其他属性... -} - -// 使用示例 -const Base = React.forwardRef((props, ref) => { - const { component = 'div' as C, ...rest } = props; - return React.createElement(component, rest); -}); -``` - -### 3.3 类型安全实践 - -```typescript -// 使用联合类型而非 enum -type ButtonType = 'primary' | 'default' | 'dashed' | 'text' | 'link'; - -// 使用 as const 定义常量 -const BUTTON_TYPES = ['primary', 'default', 'dashed', 'text', 'link'] as const; - -// 精确的类型定义 -interface EllipsisConfig { - rows?: number; - expandable?: boolean | 'collapsible'; - suffix?: string; - symbol?: React.ReactNode | ((expanded: boolean) => React.ReactNode); -} -``` - -## 4. 组件架构模式 - -### 4.1 复合组件模式 - -```typescript -// 主组件 -const Button = React.forwardRef((props, ref) => { - // 实现... -}); - -// 子组件 -Button.Group = ButtonGroup; -Button.__ANT_BUTTON = true; - -// 使用 - - - - -``` - -### 4.2 配置合并模式 - -```typescript -// 使用 useMergedConfig 合并布尔值和配置对象 -const [enableEdit, editConfig] = useMergedConfig(editable); - -// 实现 useMergedConfig -function useMergedConfig(config: boolean | T): [boolean, T] { - const enable = Boolean(config); - const mergedConfig = React.useMemo(() => { - if (config === true) return {} as T; - if (config === false) return {} as T; - return config || ({} as T); - }, [config]); - return [enable, mergedConfig]; -} -``` - -### 4.3 受控与非受控模式 - -```typescript -// 使用 useControlledState 处理受控/非受控状态 -const [editing, setEditing] = useControlledState(false, editConfig.editing); - -// useControlledState 实现 -function useControlledState(defaultValue: T, controlledValue?: T): [T, (value: T) => void] { - const [internalValue, setInternalValue] = React.useState(defaultValue); - const isControlled = controlledValue !== undefined; - const value = isControlled ? controlledValue : internalValue; - - const setValue = React.useCallback( - (newValue: T) => { - if (!isControlled) { - setInternalValue(newValue); - } - }, - [isControlled], - ); - - return [value, setValue]; -} -``` - -## 5. 样式系统规范 - -### 5.1 CSS-in-JS 架构 - -```typescript -// Token 定义 -export interface ComponentToken { - // 颜色相关 - colorPrimary?: string; - colorBgContainer?: string; - - // 尺寸相关 - controlHeight?: number; - controlHeightSM?: number; - controlHeightLG?: number; - - // 间距相关 - padding?: number; - paddingSM?: number; - paddingLG?: number; -} - -// 样式生成函数 -const genButtonStyle = (token: ButtonToken): CSSInterpolation => { - return [ - // 基础样式 - genSharedButtonStyle(token), - // 尺寸样式 - genSizeBaseButtonStyle(token), - genSizeSmallButtonStyle(token), - genSizeLargeButtonStyle(token), - // 变体样式 - genVariantStyle(token), - ]; -}; - -// 样式导出 -export default genStyleHooks('Button', genButtonStyle, prepareComponentToken, { - unitless: { fontWeight: true }, -}); -``` - -### 5.2 响应式设计 - -```typescript -// 使用 CSS 逻辑属性支持 RTL -const styles = { - marginInlineStart: token.marginXS, // 替代 marginLeft - marginInlineEnd: token.marginXS, // 替代 marginRight - paddingBlock: token.paddingSM, // 替代 paddingTop/paddingBottom - paddingInline: token.paddingSM, // 替代 paddingLeft/paddingRight -}; - -// 响应式断点 -const responsiveStyles = { - [token.screenXS]: { - fontSize: token.fontSizeSM, - }, - [token.screenMD]: { - fontSize: token.fontSize, - }, - [token.screenLG]: { - fontSize: token.fontSizeLG, - }, -}; -``` - -### 5.3 主题定制支持 - -```typescript -// 支持 ConfigProvider 主题定制 -const { getPrefixCls, direction } = React.useContext(ConfigContext); -const prefixCls = getPrefixCls('button', customizePrefixCls); - -// 支持语义化 className 和 style -export interface ButtonSemanticClassNames { - root?: string; - icon?: string; - content?: string; -} - -export interface ButtonSemanticStyles { - root?: React.CSSProperties; - icon?: React.CSSProperties; - content?: React.CSSProperties; -} -``` - -## 6. 可访问性规范 - -### 6.1 ARIA 属性 - -```typescript -// 正确的 ARIA 属性使用 - - -// 键盘导航支持 -const handleKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case 'Enter': - case ' ': - event.preventDefault(); - handleClick(); - break; - case 'Escape': - handleCancel(); - break; - } -}; -``` - -### 6.2 焦点管理 - -```typescript -// 焦点状态样式 -const focusStyles = { - '&:focus-visible': { - outline: `${token.lineWidthFocus}px solid ${token.colorPrimaryBorder}`, - outlineOffset: 1, - }, -}; - -// 程序化焦点管理 -const buttonRef = React.useRef(null); -React.useEffect(() => { - if (autoFocus && buttonRef.current) { - buttonRef.current.focus(); - } -}, [autoFocus]); -``` - -## 7. 性能优化规范 - -### 7.1 React 优化 - -```typescript -// 使用 React.memo 避免不必要的重渲染 -const Button = React.memo( - React.forwardRef((props, ref) => { - // 组件实现 - }), -); - -// 使用 useMemo 缓存计算结果 -const classes = React.useMemo(() => { - return clsx(prefixCls, `${prefixCls}-${type}`, `${prefixCls}-${size}`, className); -}, [prefixCls, type, size, className]); - -// 使用 useCallback 缓存函数 -const handleClick = React.useCallback( - (event: React.MouseEvent) => { - if (!disabled && !loading) { - onClick?.(event); - } - }, - [disabled, loading, onClick], -); -``` - -### 7.2 样式优化 - -```typescript -// 避免不必要的样式重计算 -const useStyle = genStyleHooks( - 'Button', - (token) => { - // 样式计算逻辑 - }, - prepareComponentToken, -); - -// 使用 CSS containment -const containerStyles = { - contain: 'layout style paint', - contentVisibility: 'auto', -}; -``` - -## 8. 测试规范 - -### 8.1 测试文件结构 - -```typescript -// __tests__/index.test.tsx -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import Button from '../index'; - -describe('Button', () => { - it('should render correctly', () => { - const { container } = render(); - expect(container.firstChild).toMatchSnapshot(); - }); - - it('should handle click events', () => { - const handleClick = jest.fn(); - render(); - - fireEvent.click(screen.getByText('Click me')); - expect(handleClick).toHaveBeenCalledTimes(1); - }); - - it('should be disabled when disabled prop is true', () => { - render(); - expect(screen.getByText('Disabled')).toBeDisabled(); - }); -}); -``` - -### 8.2 测试覆盖率要求 - -- 单元测试覆盖率:100% -- 集成测试:主要使用场景 -- 可访问性测试:键盘导航、屏幕阅读器 -- 视觉回归测试:UI 变化检测 - -## 9. 文档规范 - -### 9.1 API 文档格式 - -```markdown -| 参数 | 说明 | 类型 | 默认值 | -| -------- | ---------------- | ------------------------------------------------------ | --------- | -| type | 设置按钮类型 | `primary` \| `default` \| `dashed` \| `text` \| `link` | `default` | -| size | 设置按钮大小 | `large` \| `middle` \| `small` | `middle` | -| disabled | 按钮失效状态 | boolean | false | -| loading | 设置按钮载入状态 | boolean \| { delay: number } | false | -| onClick | 点击按钮时的回调 | (event) => void | - | -``` - -### 9.2 示例代码规范 - -```typescript -// demo/basic.tsx -import React from 'react'; -import { Button } from 'antd'; - -const App: React.FC = () => ( - <> - - - - - - -); - -export default App; -``` - -## 10. 国际化规范 - -### 10.1 本地化配置 - -```typescript -// locale/zh_CN.ts -export default { - Text: { - edit: '编辑', - copy: '复制', - copied: '复制成功', - expand: '展开', - collapse: '收起', - }, -}; - -// 使用 useLocale 获取本地化 -const [textLocale] = useLocale('Text', enUS.Text); -``` - -### 10.2 动态文本处理 - -```typescript -// 支持模板变量的本地化 -const messages = { - selected: '已选择 ${count} 项', -}; - -// 使用 -const message = messages.selected.replace('${count}', count.toString()); -``` - -## 11. 版本兼容规范 - -### 11.1 向下兼容 - -- 避免破坏性变更 -- 提供迁移指南 -- 保持 API 稳定性 -- 使用废弃警告 - -```typescript -// 废弃警告 -if (process.env.NODE_ENV !== 'production') { - const warning = devUseWarning('Button'); - warning.deprecated(!iconPosition, 'iconPosition', 'iconPlacement'); -} -``` - -### 11.2 浏览器兼容 - -- 支持 Chrome 80+ -- 支持服务端渲染 -- 支持 TypeScript 4.0+ -- 支持 React 18 ~ 19 - -## 12. 发布规范 - -### 12.1 版本管理 - -- 遵循语义化版本(SemVer) -- 主版本:破坏性变更 -- 次版本:新功能 -- 修订版本:Bug 修复 - -### 12.2 变更日志 - -```markdown -## 5.0.0 - -### 重大变更 - -- 移除废弃的 `icon` 字符串用法 -- 重构样式系统,使用 CSS-in-JS - -### 新功能 - -- 新增 `variant` 属性支持多种按钮变体 -- 新增语义化 className 和 style 支持 - -### Bug 修复 - -- 修复按钮在 disabled 状态下仍可点击的问题 -``` - ---- - -这套规范基于 antd 组件库的最佳实践,涵盖了从项目结构到发布流程的完整开发规范。遵循这些规范可以确保组件的一致性、可维护性和高质量。 diff --git a/README-zh_CN.md b/README-zh_CN.md index d6609af0d4..c9ac3beb7e 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -91,6 +91,10 @@ `@ant-design/x-markdown` 旨在提供流式友好、强拓展性和高性能的 Markdown 渲染器。提供流式渲染公式、代码高亮、mermaid 等能力,详情点击[这里](packages/x-markdown/README-zh_CN.md)。 +## 🎴 动态卡片渲染器 + +`@ant-design/x-card` 是一个基于 A2UI 协议的动态卡片渲染组件,让 AI Agent 能够通过结构化的 JSON 消息流,动态构建和渲染交互式界面。支持流式渲染、数据绑定和响应式更新,详情点击[这里](packages/x-card/README.md)。 + ## 🚀 Skill `@ant-design/x-skill` 是专为 Ant Design X 打造的智能技能库,提供了一系列精心设计的 Agent 技能。这些技能能够显著提升开发效率,帮助您快速构建高质量的 AI 对话应用,并有效解决开发过程中遇到的各种问题,详情点击[这里](packages/x-skill/README-zh_CN.md)。 diff --git a/README.md b/README.md index f8631d5767..e616cc9d10 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ Build excellent AI interfaces and pioneer intelligent new experiences. `@ant-design/x-markdown` aims to provide a streaming-friendly, highly extensible, and high-performance Markdown renderer. It supports streaming rendering of formulas, code highlighting, mermaid, and more. See details [here](packages/x-markdown/README.md). +## 🎴 Dynamic Card Renderer + +`@ant-design/x-card` is a dynamic card rendering component based on the A2UI protocol, enabling AI Agents to dynamically build and render interactive interfaces through structured JSON message streams. It supports streaming rendering, data binding, and reactive updates. See details [here](packages/x-card/README.md). + ## 🚀 Skill `@ant-design/x-skill` is an intelligent skill library specially designed for Ant Design X, providing a series of carefully designed Agent skills. These skills can significantly improve development efficiency, help you quickly build high-quality AI conversation applications, and effectively solve various problems encountered during development. See details [here](packages/x-skill/README.md). diff --git a/packages/x-card/.fatherrc.ts b/packages/x-card/.fatherrc.ts new file mode 100644 index 0000000000..2c9c584715 --- /dev/null +++ b/packages/x-card/.fatherrc.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'father'; + +export default defineConfig({ + plugins: ['@rc-component/father-plugin'], + targets: { + chrome: 80, + }, + esm: { + input: 'src', + ignores: ['**/demo/**', '**/__tests__/**'], + }, + cjs: { + input: 'src/', + ignores: ['**/demo/**', '**/__tests__/**'], + }, + umd: { + entry: 'src/index.ts', + name: 'XCard', + output: { + path: 'dist/', + filename: 'x-card', + }, + sourcemap: true, + generateUnminified: true, + externals: { + react: { + root: 'React', + commonjs: 'react', + commonjs2: 'react', + }, + 'react-dom': { + root: 'ReactDOM', + commonjs: 'react-dom', + commonjs2: 'react-dom', + }, + }, + }, +}); diff --git a/packages/x-card/.jest.js b/packages/x-card/.jest.js new file mode 100644 index 0000000000..7383987ddc --- /dev/null +++ b/packages/x-card/.jest.js @@ -0,0 +1,73 @@ +const compileModules = [ + '@rc-component', + 'react-sticky-box', + 'rc-tween-one', + '@babel', + '@ant-design', + 'countup.js', + '.pnpm', +]; + +const resolve = (p) => require.resolve(`@ant-design/tools/lib/jest/${p}`); + +const ignoreList = []; + +// cnpm use `_` as prefix +['', '_'].forEach((prefix) => { + compileModules.forEach((module) => { + ignoreList.push(`${prefix}${module}`); + }); +}); + +const transformIgnorePatterns = [ + // Ignore modules without es dir. + // Update: @babel/runtime should also be transformed + `[/\\\\]node_modules[/\\\\](?!${ignoreList.join('|')})[^/\\\\]+?[/\\\\](?!(es)[/\\\\])`, +]; + +function getTestRegex(libDir) { + if (['dist', 'lib', 'es', 'dist-min'].includes(libDir)) { + return 'demo\\.test\\.(j|t)sx?$'; + } + return '.*\\.test\\.(j|t)sx?$'; +} + +module.exports = { + verbose: true, + testEnvironment: '@happy-dom/jest-environment', + setupFiles: ['./tests/setup.ts'], + setupFilesAfterEnv: ['@testing-library/jest-dom'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'], + modulePathIgnorePatterns: [], + moduleNameMapper: { + '\\.(css|less)$': 'identity-obj-proxy', + }, + testPathIgnorePatterns: ['/node_modules/', 'dekko', 'node', 'image.test.js', 'image.test.ts'], + transform: { + '\\.tsx?$': resolve('codePreprocessor'), + '\\.(m?)js$': resolve('codePreprocessor'), + '\\.md$': resolve('demoPreprocessor'), + '\\.(jpg|png|gif|svg)$': resolve('imagePreprocessor'), + }, + testRegex: getTestRegex(process.env.LIB_DIR), + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/demo/**', + '!src/**/__tests__/**', + '!src/version.ts', + '!src/index.ts', // 纯重导出文件 + '!src/A2UI/types/**', // 纯类型文件 + ], + transformIgnorePatterns, + globals: { + 'ts-jest': { + tsConfig: './tsconfig.json', + }, + }, + testEnvironmentOptions: { + url: 'http://localhost/x-card', + }, + bail: true, + maxWorkers: '50%', +}; diff --git a/packages/x-card/.lintstagedrc.json b/packages/x-card/.lintstagedrc.json new file mode 100644 index 0000000000..8b50598b6b --- /dev/null +++ b/packages/x-card/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.{ts,tsx}": ["biome lint --fix"], + "*.{ts,tsx,less,md}": ["prettier --write"] +} \ No newline at end of file diff --git a/packages/x-card/README.md b/packages/x-card/README.md new file mode 100644 index 0000000000..135d81d133 --- /dev/null +++ b/packages/x-card/README.md @@ -0,0 +1,184 @@ +# @ant-design/x-card + +React card loader for dynamic content loading and management. + +## Features + +- 🚀 **Dynamic Loading**: Load cards asynchronously with configurable concurrency +- 🔄 **Retry Mechanism**: Automatic retry with exponential backoff +- ⚡ **Performance**: Optimized for large datasets with virtual scrolling support +- 🎨 **Customizable**: Fully customizable card rendering and loading states +- 📱 **Responsive**: Mobile-friendly responsive design +- 🔧 **TypeScript**: Full TypeScript support + +## Installation + +```bash +npm install @ant-design/x-card +# or +yarn add @ant-design/x-card +# or +pnpm add @ant-design/x-card +``` + +## Usage + +### Basic Usage + +```tsx +import React from 'react'; +import { CardLoader } from '@ant-design/x-card'; + +const App = () => { + const cards = [ + { + id: '1', + title: 'Card 1', + content: 'This is card content', + }, + { + id: '2', + title: 'Card 2', + content: 'Another card content', + }, + ]; + + return ; +}; +``` + +### Advanced Usage + +```tsx +import React from 'react'; +import { CardLoader, useCardLoader } from '@ant-design/x-card'; + +const App = () => { + const { state, actions } = useCardLoader({ + config: { + maxConcurrent: 5, + retryCount: 3, + timeout: 10000, + }, + customLoader: async (card) => { + // Custom loading logic + const response = await fetch(`/api/cards/${card.id}`); + const data = await response.json(); + return data.content; + }, + }); + + React.useEffect(() => { + actions.loadCards([ + { id: '1', title: 'Dynamic Card 1' }, + { id: '2', title: 'Dynamic Card 2' }, + ]); + }, []); + + return ( +
Loading {card.title}...
} + renderError={(error, card) =>
Error: {error.message}
} + /> + ); +}; +``` + +### Using Hooks + +```tsx +import React from 'react'; +import { useCardLoader } from '@ant-design/x-card'; + +const App = () => { + const { state, actions } = useCardLoader(); + + const addNewCard = () => { + actions.addCard({ + id: Date.now().toString(), + title: 'New Card', + content: 'Dynamic content', + }); + }; + + return ( +
+ + {state.cards.map((card) => ( +
+

{card.title}

+

{card.content}

+
+ ))} +
+ ); +}; +``` + +## API + +### CardLoader Props + +| Property | Type | Default | Description | +| ---------------- | ------------------ | ------- | ----------------------------- | +| cards | CardLoaderConfig[] | [] | Array of card configurations | +| config | CardLoaderConfig | - | Loader configuration | +| customLoader | function | - | Custom card loading function | +| renderEmpty | function | - | Custom empty state renderer | +| renderLoading | function | - | Custom loading state renderer | +| renderError | function | - | Custom error state renderer | +| onLoadingChange | function | - | Loading state change callback | +| onCardLoad | function | - | Card load success callback | +| onCardError | function | - | Card load error callback | +| onAllCardsLoaded | function | - | All cards loaded callback | + +### CardLoaderConfig + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| id | string | - | Unique card identifier | +| title | string | - | Card title | +| content | ReactNode | - | Card content | +| type | 'default' \| 'info' \| 'success' \| 'warning' \| 'error' | 'default' | Card type | +| loading | boolean | false | Loading state | +| closable | boolean | false | Whether card can be closed | +| size | 'small' \| 'middle' \| 'large' | 'middle' | Card size | +| disabled | boolean | false | Whether card is disabled | +| className | string | - | Custom CSS class | +| style | CSSProperties | - | Custom inline style | +| extra | ReactNode | - | Extra content in card header | + +### useCardLoader Hook + +Returns an object with: + +- `state`: Current loader state +- `actions`: Available actions + - `addCard(card)`: Add a new card + - `removeCard(id)`: Remove a card + - `updateCard(id, updates)`: Update a card + - `reloadCard(id)`: Reload a card + - `clearCards()`: Clear all cards + - `getCardState(id)`: Get card state + - `loadCards(cards)`: Load multiple cards + +## Development + +```bash +# Install dependencies +npm install + +# Start development +npm run start + +# Run tests +npm test + +# Build +npm run compile +``` + +## License + +MIT diff --git a/packages/x-card/package.json b/packages/x-card/package.json new file mode 100644 index 0000000000..3acbeb8f92 --- /dev/null +++ b/packages/x-card/package.json @@ -0,0 +1,66 @@ +{ + "name": "@ant-design/x-card", + "version": "2.3.0-beta.2", + "description": "React card loader for dynamic content loading and management", + "keywords": [ + "A2UI", + "loader", + "react", + "ant-design" + ], + "homepage": "https://x.ant.design/x-card", + "bugs": { + "url": "https://github.com/ant-design/x/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/ant-design/x" + }, + "license": "MIT", + "sideEffects": false, + "main": "lib/index.js", + "module": "es/index.js", + "typings": "es/index.d.ts", + "files": [ + "dist", + "es", + "lib" + ], + "scripts": { + "compile": "father build", + "prepublishOnly": "tsx ../../scripts/pre-publish.ts x-card", + "tsc": "tsc --noEmit", + "lint": "npm run version && npm run tsc && npm run lint:script && npm run lint:md", + "lint:md": "remark . -f -q", + "predist": "npm run prestart", + "prestart": "npm run version", + "pretest": "npm run prestart", + "precompile": "npm run prestart", + "lint:script": "biome lint", + "test": "jest --config .jest.js --no-cache --collect-coverage", + "coverage": "jest --config .jest.js --no-cache --collect-coverage --coverage", + "version": "tsx scripts/generate-version.ts", + "test:dekko": "tsx ./tests/dekko/index.test.ts", + "clean": "rm -rf es lib coverage dist", + "test:package-diff": "antd-tools run package-diff" + }, + "dependencies": { + "@ant-design/icons": "^6.0.0", + "@babel/runtime": "^7.25.6", + "classnames": "^2.5.1", + "rc-util": "^5.43.0" + }, + "devDependencies": { + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/x-card/scripts/generate-version.ts b/packages/x-card/scripts/generate-version.ts new file mode 100644 index 0000000000..7b9fdc08ad --- /dev/null +++ b/packages/x-card/scripts/generate-version.ts @@ -0,0 +1,10 @@ +import fs from 'fs'; +import path from 'path'; + +const packageJson = require('../package.json'); + +const versionFileContent = `// This file is auto generated by npm run version +export default '${packageJson.version}'; +`; + +fs.writeFileSync(path.join(__dirname, '../src/version.ts'), versionFileContent, 'utf8'); diff --git a/packages/x-card/src/A2UI/Box.tsx b/packages/x-card/src/A2UI/Box.tsx new file mode 100644 index 0000000000..e1ea805570 --- /dev/null +++ b/packages/x-card/src/A2UI/Box.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { BoxProps } from './types/box'; +import Context from './Context'; +import { loadCatalog, type Catalog } from './catalog'; +import type { A2UICommand_v0_9 } from './types/command_v0.9'; +import type { A2UICommand_v0_8 } from './types/command_v0.8'; + +const Box: React.FC = ({ children, commands = [], components, onAction }) => { + const [catalogMap, setCatalogMap] = useState>(new Map()); + // Store surfaceId -> catalogId mapping + const [surfaceCatalogMap, setSurfaceCatalogMap] = useState>(new Map()); + // Track the number of already-processed commands to avoid re-processing on every render + const processedCommandsCount = useRef(0); + + /** + * Listen to command queue changes, handle createSurface (load catalog) and deleteSurface (clear mapping). + * The commands array is maintained by external demo, reference changes after each new command is appended, triggering this effect. + * Only new commands (appended since last render) are processed to avoid redundant work as the queue grows. + */ + useEffect(() => { + // commands was cleared or reset — reset the counter and bail out + if (!commands || commands.length === 0) { + processedCommandsCount.current = 0; + return; + } + + // commands array was replaced with a shorter one (e.g. reset by parent) — reset counter and reprocess from scratch + if (commands.length < processedCommandsCount.current) { + processedCommandsCount.current = 0; + } + + // Only process commands added since the last effect run + const newCommands = commands.slice(processedCommandsCount.current); + if (newCommands.length === 0) return; + + for (const cmd of newCommands) { + if ('createSurface' in cmd) { + const { surfaceId, catalogId } = (cmd as A2UICommand_v0_9 & { createSurface: any }) + .createSurface; + + if (catalogId) { + setSurfaceCatalogMap((prev) => { + if (prev.get(surfaceId) === catalogId) return prev; + return new Map(prev).set(surfaceId, catalogId); + }); + + // Load catalog (cached ones will be hit directly, no duplicate requests) + loadCatalog(catalogId) + .then((catalog) => { + setCatalogMap((prev) => { + if (prev.has(catalogId)) return prev; + return new Map(prev).set(catalogId, catalog); + }); + }) + .catch((error) => { + console.error(`Failed to load catalog ${catalogId}:`, error); + }); + } + } + + // Clear mapping in surfaceCatalogMap when deleteSurface + if ('deleteSurface' in cmd) { + const surfaceId = (cmd as { deleteSurface: { surfaceId: string } }).deleteSurface.surfaceId; + setSurfaceCatalogMap((prev) => { + if (!prev.has(surfaceId)) return prev; + const next = new Map(prev); + next.delete(surfaceId); + return next; + }); + } + } + + // Advance the pointer to the end of the current commands array + processedCommandsCount.current = commands.length; + }, [commands]); + + return ( + + {children} + + ); +}; +export default Box; diff --git a/packages/x-card/src/A2UI/Card.tsx b/packages/x-card/src/A2UI/Card.tsx new file mode 100644 index 0000000000..c80842a31f --- /dev/null +++ b/packages/x-card/src/A2UI/Card.tsx @@ -0,0 +1,371 @@ +import React, { useEffect, useRef, useState } from 'react'; +import BoxContext from './Context'; +import { createComponentTransformer } from './format/components'; +import type { ComponentTransformer, ReactComponentTree } from './format/components'; +import type { Catalog } from './catalog'; + +// v0.8 specific logic +import { resolvePropsV08, extractDataUpdatesV08, applyDataModelUpdateV08 } from './Card.v0.8'; + +// v0.9 specific logic +import { resolvePropsV09, extractDataUpdatesV09, applyDataModelUpdateV09 } from './Card.v0.9'; + +// Shared logic +import { setValueByPath, validateComponentAgainstCatalog } from './utils'; + +export interface CardProps { + id: string; +} + +/** Recursively render a single node, child nodes are found via getById */ +function renderNode( + nodeId: string, + transformer: ComponentTransformer, + components: Record>, + dataModel: Record, + onAction?: (name: string, context: Record, actionConfig?: any) => void, + onDataChange?: (path: string, value: any) => void, + catalog?: Catalog, + commandVersion?: 'v0.8' | 'v0.9', +): React.ReactNode { + const node = transformer.getById(nodeId); + + if (!node) return null; + return ( + + ); +} + +interface NodeRendererProps { + node: ReactComponentTree; + transformer: ComponentTransformer; + components: Record>; + dataModel: Record; + onAction?: (name: string, context: Record, actionConfig?: any) => void; + /** Callback when component writes back to dataModel via onChange, path is the binding path */ + onDataChange?: (path: string, value: any) => void; + /** catalog for component validation */ + catalog?: Catalog; + /** command version */ + commandVersion?: 'v0.8' | 'v0.9'; +} + +const NodeRenderer: React.FC = ({ + node, + transformer, + components, + dataModel, + onAction, + onDataChange, + catalog, + commandVersion = 'v0.8', +}) => { + const { type, props, children } = node; + + // Validate if component conforms to catalog definition + const validation = validateComponentAgainstCatalog(catalog, type, props); + if (!validation.valid || validation.errors.length > 0) { + // Output warnings in development environment + if (process.env.NODE_ENV === 'development') { + validation.errors.forEach((error) => { + console.warn(error); + }); + } + } + + // Find corresponding component from registered component mapping + const Component = components[type]; + + if (!Component) { + // Check if defined in catalog + if (catalog?.components && !catalog.components[type]) { + if (process.env.NODE_ENV === 'development') { + console.error( + `Component "${type}" is not registered and not defined in catalog. It will not be rendered.`, + ); + } + return null; + } + // If defined in catalog but not registered, show warning + if (process.env.NODE_ENV === 'development') { + console.warn( + `Component "${type}" is defined in catalog but not registered. Please provide a component implementation.`, + ); + } + return null; + } + + // Use different resolveProps based on version + const resolvedProps = + commandVersion === 'v0.9' + ? resolvePropsV09(props, dataModel) + : resolvePropsV08(props, dataModel); + + // Inject onAction to all custom components, let component decide when and how to trigger + if (typeof Component !== 'string') { + // Wrap onAction to pass action configuration + resolvedProps.onAction = (name: string, context: Record) => { + // Get action configuration from resolvedProps (path binding already resolved) + const actionConfig = resolvedProps.action; + onAction?.(name, context, actionConfig); + }; + + // Inject onDataChange for components to directly update dataModel + resolvedProps.onDataChange = onDataChange; + } + + const childNodes = children?.map((childId) => + renderNode( + childId, + transformer, + components, + dataModel, + onAction, + onDataChange, + catalog, + commandVersion, + ), + ); + + return {childNodes}; +}; + +const Card: React.FC = ({ id }) => { + const { + commandQueue, + components = {}, + onAction, + catalogMap, + surfaceCatalogMap, + } = React.useContext(BoxContext); + + // Each Card instance holds independent transformer, maintaining their own component cache + const transformerRef = useRef(null); + if (transformerRef.current === null) { + transformerRef.current = createComponentTransformer(); + } + + // Get catalog corresponding to current surface + const catalogId = surfaceCatalogMap ? surfaceCatalogMap.get(id) : undefined; + const catalog = catalogId && catalogMap ? catalogMap.get(catalogId) : undefined; + + // Use rootNode to drive re-render + const [rootNode, setRootNode] = useState(null); + + // Data model, storing values written by updateDataModel + const [dataModel, setDataModel] = useState>({}); + + // Track command version of current surface (per-surface, avoid global shared pollution) + const [commandVersion, setCommandVersion] = useState<'v0.8' | 'v0.9'>('v0.8'); + + // Used to track if beginRendering command is received (v0.8), use ref to avoid triggering useEffect re-execution + const pendingRenderRef = useRef<{ surfaceId: string; root: string } | null>(null); + // Store converted component tree, waiting for beginRendering to trigger rendering + const pendingNodeTreeRef = useRef(null); + // Track if already rendered (use ref to avoid dependency cycle) + const hasRenderedRef = useRef(false); + + /** + * Listen to command queue changes, consume all commands related to this Card (surfaceId === id). + * Use for...of to iterate through the entire queue, ensuring all commands in the same render cycle are processed. + */ + useEffect(() => { + if (commandQueue.length === 0) return; + + // Filter out commands belonging to this surface + const myCommands = commandQueue.filter((cmd) => { + if ('createSurface' in cmd) return cmd.createSurface.surfaceId === id; + if ('updateComponents' in cmd) return cmd.updateComponents.surfaceId === id; + if ('updateDataModel' in cmd) return cmd.updateDataModel.surfaceId === id; + if ('deleteSurface' in cmd) return cmd.deleteSurface.surfaceId === id; + if ('surfaceUpdate' in cmd) return cmd.surfaceUpdate.surfaceId === id; + if ('dataModelUpdate' in cmd) return cmd.dataModelUpdate.surfaceId === id; + if ('beginRendering' in cmd) return cmd.beginRendering.surfaceId === id; + return false; + }); + + if (myCommands.length === 0) return; + + // Batch process all commands of this surface, execute in order + let nextDataModel = dataModel; + let nextRootNode = rootNode; + let nextCommandVersion = commandVersion; + let hasDataModelChange = false; + let hasRootNodeChange = false; + + for (const cmd of myCommands) { + // ===== v0.9 command processing ===== + if ('version' in cmd && cmd.version === 'v0.9') { + nextCommandVersion = 'v0.9'; + + if ('createSurface' in cmd) { + // createSurface is only for initialization, catalog loading is handled by Box + // If recreating (previously deleted), reset state + if (!hasRenderedRef.current) { + nextRootNode = null; + nextDataModel = {}; + hasRootNodeChange = true; + hasDataModelChange = true; + } + } + + if ('updateComponents' in cmd) { + const nodeTree = transformerRef.current!.transform( + cmd.updateComponents.components, + 'v0.9', + ); + if (nodeTree) { + nextRootNode = nodeTree; + hasRenderedRef.current = true; + hasRootNodeChange = true; + } + } + + if ('updateDataModel' in cmd) { + const { path, value } = cmd.updateDataModel; + nextDataModel = applyDataModelUpdateV09(nextDataModel, path, value); + hasDataModelChange = true; + } + + if ('deleteSurface' in cmd) { + nextRootNode = null; + nextDataModel = {}; + hasRenderedRef.current = false; + hasRootNodeChange = true; + hasDataModelChange = true; + transformerRef.current!.reset(); + pendingRenderRef.current = null; + pendingNodeTreeRef.current = null; + } + + continue; + } + + // ===== v0.8 command processing ===== + nextCommandVersion = 'v0.8'; + + // surfaceUpdate: define component structure + if ('surfaceUpdate' in cmd) { + const nodeTree = transformerRef.current!.transform(cmd.surfaceUpdate.components, 'v0.8'); + pendingNodeTreeRef.current = nodeTree; + + // If already rendered, update directly + if (hasRenderedRef.current) { + const rootNodeFromCache = transformerRef.current!.getById('root'); + if (rootNodeFromCache) { + nextRootNode = rootNodeFromCache; + hasRootNodeChange = true; + } + } + } + + // dataModelUpdate: update data model (v0.8 format) + if ('dataModelUpdate' in cmd) { + const { contents } = cmd.dataModelUpdate; + nextDataModel = applyDataModelUpdateV08(nextDataModel, contents); + hasDataModelChange = true; + } + + // beginRendering: start rendering + if ('beginRendering' in cmd) { + const { root } = cmd.beginRendering; + const nodeTree = transformerRef.current!.getById(root); + if (nodeTree) { + nextRootNode = nodeTree; + pendingRenderRef.current = null; + hasRenderedRef.current = true; + hasRootNodeChange = true; + } else { + pendingRenderRef.current = { surfaceId: id, root }; + } + } + + // deleteSurface: delete surface + if ('deleteSurface' in cmd) { + nextRootNode = null; + nextDataModel = {}; + hasRenderedRef.current = false; + hasRootNodeChange = true; + hasDataModelChange = true; + transformerRef.current!.reset(); + pendingRenderRef.current = null; + pendingNodeTreeRef.current = null; + } + } + + // Batch submit state changes, reduce re-render count + if (nextCommandVersion !== commandVersion) { + setCommandVersion(nextCommandVersion); + } + if (hasRootNodeChange) { + setRootNode(nextRootNode); + } + if (hasDataModelChange) { + setDataModel(nextDataModel); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [commandQueue, id]); + + if (!rootNode) { + return null; + } + + /** + * Handler when action is triggered + * Use different extractDataUpdates and resolveActionContext based on version + */ + const handleAction = (name: string, context: Record, actionConfig?: any) => { + // Use different extractDataUpdates based on version + const dataUpdates = + commandVersion === 'v0.9' + ? extractDataUpdatesV09(actionConfig, context) + : extractDataUpdatesV08(actionConfig, context); + + // Update dataModel first + let newDataModel = dataModel; + if (dataUpdates.length > 0) { + newDataModel = dataUpdates.reduce((prev, { path, value }) => { + return setValueByPath(prev, path, value); + }, dataModel); + + setDataModel(newDataModel); + } + + // Report event to upper layer + onAction?.({ + name, + surfaceId: id, + context: { ...context }, + }); + }; + + /** Component onChange writes back to dataModel (two-way binding) */ + const handleDataChange = (path: string, value: any) => { + setDataModel((prev) => setValueByPath(prev, path, value)); + }; + + return ( + >} + dataModel={dataModel} + onAction={handleAction} + onDataChange={handleDataChange} + catalog={catalog} + commandVersion={commandVersion} + /> + ); +}; + +export default Card; diff --git a/packages/x-card/src/A2UI/Card.v0.8.ts b/packages/x-card/src/A2UI/Card.v0.8.ts new file mode 100644 index 0000000000..fd15571cfd --- /dev/null +++ b/packages/x-card/src/A2UI/Card.v0.8.ts @@ -0,0 +1,207 @@ +/** + * Card v0.8 版本专用逻辑 + * + * v0.8 命令格式: + * - surfaceUpdate: { surfaceId, components } + * - dataModelUpdate: { surfaceId, contents: [{ key, valueMap: [{ key, valueString }] }] } + * - beginRendering: { surfaceId, root } + * + * v0.8 action 格式: + * - action.name: string + * - action.context: [{ key: string, value: { path: string } }] + * + * v0.8 特殊处理: + * - props 中的 { literalString: string } 需要解析为字符串 + * - action.context 中的 { path } 是写入目标,不应被 resolveProps 解析 + */ + +import { + getValueByPath, + setValueByPath, + isPathValue, + isPathObject, + validateComponentAgainstCatalog, +} from './utils'; + +/** 判断一个值是否为 { literalString: string } 形式的字面字符串对象 */ +export function isLiteralStringObject(val: any): val is { literalString: string } { + return val !== null && typeof val === 'object' && typeof val.literalString === 'string'; +} + +/** 将 props 中的路径值替换为 dataModel 中的真实值(v0.8 版本) */ +export function resolvePropsV08( + props: Record, + dataModel: Record, +): Record { + const resolved: Record = {}; + for (const [key, val] of Object.entries(props)) { + resolved[key] = resolveValueV08(val, dataModel); + } + return resolved; +} + +/** + * 递归解析值中的路径引用(v0.8 版本) + * + * 特殊处理: + * - { literalString: string } → 解析为字符串 + * - { path: string } → 从 dataModel 读取值 + * - action.context 中的 { key, value: { path } } → 保留 { path } 结构(写入目标) + */ +function resolveValueV08(val: any, dataModel: Record, isActionContext = false): any { + // 处理 { literalString: string } 形式的字面字符串 + if (isLiteralStringObject(val)) { + return val.literalString; + } + // 处理 { path: string } 形式的路径对象 + // 但在 action.context 中,value 字段的 { path } 是写入目标,需要保留 + if (isPathObject(val)) { + if (isActionContext) { + // 在 action.context 中,保留 { path } 结构 + return val; + } + return getValueByPath(dataModel, val.path); + } + // 处理字符串路径(向后兼容) + if (isPathValue(val)) { + return getValueByPath(dataModel, val); + } + // 数组递归处理 + if (Array.isArray(val)) { + return val.map((item) => resolveValueV08(item, dataModel, false)); + } + // 对象递归处理 + if (val && typeof val === 'object') { + const result: Record = {}; + for (const [k, v] of Object.entries(val)) { + // 特殊处理:如果当前在 action.context 的 item 中(有 key 和 value 字段), + // 则 value 字段应该保留 { path } 结构 + if (k === 'value' && 'key' in val) { + result[k] = resolveValueV08(v, dataModel, true); + } else { + result[k] = resolveValueV08(v, dataModel, false); + } + } + return result; + } + // 字面值直接使用 + return val; +} + +/** + * 解析 action.context 中的路径绑定,从 dataModel 中提取实际值 + * v0.8 格式: action.context 是数组 [{ key, value: { path: string } | literal }] + */ +export function resolveActionContextV08( + action: any, + dataModel: Record, +): Record | undefined { + const context = action?.context; + if (!Array.isArray(context)) { + return undefined; + } + + const resolved: Record = {}; + for (const item of context) { + if (item && typeof item === 'object' && 'key' in item && 'value' in item) { + const pathObj = item.value; + // 处理 { path: string } 形式的路径绑定 + if (isPathObject(pathObj)) { + resolved[item.key] = getValueByPath(dataModel, pathObj.path); + } + // 处理 { literalString: string } 形式的字面字符串 + else if (isLiteralStringObject(pathObj)) { + resolved[item.key] = pathObj.literalString; + } + // 字面值直接使用 + else { + resolved[item.key] = item.value; + } + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** + * 根据 action.context 中的路径配置,将组件传递的值写入 dataModel + * v0.8 格式: action.context 是数组 [{ key, value: { path: string } }] + * + * 注意:v0.8 的 action.context 中的 { path } 是写入目标路径 + * + * @param action action 配置对象 + * @param componentContext 组件传递的上下文数据 + * @returns 需要更新的数据路径和值的数组 + */ +export function extractDataUpdatesV08( + action: any, + componentContext: Record, +): Array<{ path: string; value: any }> { + const context = action?.context; + if (!Array.isArray(context)) { + return []; + } + + const updates: Array<{ path: string; value: any }> = []; + for (const item of context) { + if (item && typeof item === 'object' && 'key' in item && 'value' in item) { + const pathObj = item.value; + // 只处理 { path: string } 形式的路径绑定 + if (isPathObject(pathObj)) { + // 从组件传递的 context 中查找对应 key 的值 + const componentValue = componentContext[item.key]; + if (componentValue !== undefined) { + updates.push({ path: pathObj.path, value: componentValue }); + } + } + } + } + return updates; +} + +/** + * 处理 v0.8 的 dataModelUpdate 命令 + * v0.8 格式: contents 支持: + * - [{ key, valueString }] - 直接存储字符串值 + * - [{ key, valueMap: [{ key, valueString }] }] - 转换为对象 + * + * 示例输入: + * contents: [ + * { key: 'products', valueString: '[...]' }, + * { key: 'res', valueMap: [{ key: 'time', valueString: '...' }] } + * ] + * + * 输出 dataModel: + * { products: '[...]', res: { time: '...' } } + */ +export function applyDataModelUpdateV08( + prevDataModel: Record, + contents: Array<{ + key: string; + valueString?: string; + valueMap?: Array<{ key: string; valueString: string }>; + }>, +): Record { + const next = { ...prevDataModel }; + for (const item of contents) { + if ('valueString' in item && item.valueString !== undefined) { + // 直接存储字符串值 + next[item.key] = item.valueString; + } else if (Array.isArray(item.valueMap)) { + // valueMap 转换为对象 + const valueObj: Record = {}; + for (const { key, valueString } of item.valueMap) { + valueObj[key] = valueString; + } + next[item.key] = valueObj; + } + } + return next; +} + +export { + getValueByPath, + setValueByPath, + isPathValue, + isPathObject, + validateComponentAgainstCatalog, +}; diff --git a/packages/x-card/src/A2UI/Card.v0.9.ts b/packages/x-card/src/A2UI/Card.v0.9.ts new file mode 100644 index 0000000000..c81e532ae1 --- /dev/null +++ b/packages/x-card/src/A2UI/Card.v0.9.ts @@ -0,0 +1,190 @@ +/** + * Card v0.9 版本专用逻辑 + * + * v0.9 命令格式: + * - version: 'v0.9' + * - updateComponents: { surfaceId, components } + * - updateDataModel: { surfaceId, path, value } + * - deleteSurface: { surfaceId } + * + * v0.9 action 格式: + * - action.event.name: string + * - action.event.context: { [key]: { path: string } | literal } + */ + +import { + getValueByPath, + setValueByPath, + isPathValue, + isPathObject, + validateComponentAgainstCatalog, +} from './utils'; + +/** 将 props 中的路径值替换为 dataModel 中的真实值 */ +export function resolvePropsV09( + props: Record, + dataModel: Record, +): Record { + const resolved: Record = {}; + for (const [key, val] of Object.entries(props)) { + if (key === 'action') { + // action.event.context 中的 { path } 是写入目标,需要精确处理,不能走通用路径解析 + resolved[key] = resolveActionPropV09(val, dataModel); + } else { + resolved[key] = resolveValueV09(val, dataModel); + } + } + return resolved; +} + +/** + * 精确处理 action prop: + * - action.event.name / 其他字段 → 正常解析路径引用 + * - action.event.context → 保留所有 { path } 结构(写入目标,不做读取替换) + */ +function resolveActionPropV09(action: any, dataModel: Record): any { + if (!action || typeof action !== 'object') return action; + + const result: Record = {}; + for (const [k, v] of Object.entries(action)) { + if (k === 'event') { + result[k] = resolveActionEventV09(v, dataModel); + } else { + result[k] = resolveValueV09(v, dataModel); + } + } + return result; +} + +/** + * 精确处理 action.event: + * - event.context → 原样保留({ path } 是写入目标) + * - event 其他字段(如 name)→ 正常解析 + */ +function resolveActionEventV09(event: any, dataModel: Record): any { + if (!event || typeof event !== 'object') return event; + + const result: Record = {}; + for (const [k, v] of Object.entries(event)) { + if (k === 'context') { + // context 中的 { path } 是写入目标,原样保留,不做路径读取 + result[k] = v; + } else { + result[k] = resolveValueV09(v, dataModel); + } + } + return result; +} + +/** + * 递归解析值中的路径引用(v0.9 版本) + * + * - { path: string } → 从 dataModel 读取值 + * - 字符串路径(/xxx)→ 从 dataModel 读取值(向后兼容) + * - 数组 / 对象 → 递归处理 + * - 字面值 → 原样返回 + * + * 注意:action.event.context 的特殊处理已在 resolvePropsV09 入口处分支, + * 此函数无需感知 action 上下文,保持纯粹的递归解析逻辑。 + */ +function resolveValueV09(val: any, dataModel: Record): any { + // 处理 { path: string } 形式的路径对象 + if (isPathObject(val)) { + return getValueByPath(dataModel, val.path); + } + // 处理字符串路径(向后兼容) + if (isPathValue(val)) { + return getValueByPath(dataModel, val); + } + // 数组递归处理 + if (Array.isArray(val)) { + return val.map((item) => resolveValueV09(item, dataModel)); + } + // 对象递归处理 + if (val && typeof val === 'object') { + const result: Record = {}; + for (const [k, v] of Object.entries(val)) { + result[k] = resolveValueV09(v, dataModel); + } + return result; + } + // 字面值直接使用 + return val; +} + +/** + * 解析 action.event.context 中的路径绑定,从 dataModel 中提取实际值 + * v0.9 格式: action.event.context 是对象 { key: { path: string } | literal } + */ +export function resolveActionContextV09( + action: any, + dataModel: Record, +): Record | undefined { + const context = action?.event?.context; + if (!context || typeof context !== 'object' || Array.isArray(context)) { + return undefined; + } + + const resolved: Record = {}; + for (const [key, val] of Object.entries(context)) { + // 处理 { path: string } 形式的路径绑定 + if (isPathObject(val)) { + resolved[key] = getValueByPath(dataModel, (val as { path: string }).path); + } + // 字面值直接使用 + else { + resolved[key] = val; + } + } + return resolved; +} + +/** + * 根据 action.event.context 中的路径配置,将组件传递的值写入 dataModel + * v0.9 格式: action.event.context 是对象 { key: { path: string } } + * @param action action 配置对象 + * @param componentContext 组件传递的上下文数据 + * @returns 需要更新的数据路径和值的数组 + */ +export function extractDataUpdatesV09( + action: any, + componentContext: Record, +): Array<{ path: string; value: any }> { + const context = action?.event?.context; + if (!context || typeof context !== 'object' || Array.isArray(context)) { + return []; + } + + const updates: Array<{ path: string; value: any }> = []; + for (const [key, val] of Object.entries(context)) { + // 只处理 { path: string } 形式的路径绑定 + if (isPathObject(val)) { + // 从组件传递的 context 中查找对应 key 的值 + const componentValue = componentContext[key]; + if (componentValue !== undefined) { + updates.push({ path: (val as { path: string }).path, value: componentValue }); + } + } + } + return updates; +} + +/** + * 处理 v0.9 的 updateDataModel 命令 + * 将路径值写入 dataModel + */ +export function applyDataModelUpdateV09( + prevDataModel: Record, + path: string, + value: any, +): Record { + return setValueByPath(prevDataModel, path, value); +} + +export { + getValueByPath, + setValueByPath, + isPathValue, + isPathObject, + validateComponentAgainstCatalog, +}; diff --git a/packages/x-card/src/A2UI/Context.tsx b/packages/x-card/src/A2UI/Context.tsx new file mode 100644 index 0000000000..0be4613548 --- /dev/null +++ b/packages/x-card/src/A2UI/Context.tsx @@ -0,0 +1,26 @@ +import { createContext } from 'react'; +import { BoxProps, ActionPayload } from './types/box'; +import type { Catalog } from './catalog'; +import type { A2UICommand_v0_9 } from './types/command_v0.9'; +import type { A2UICommand_v0_8 } from './types/command_v0.8'; + +interface IBoxContext { + components: BoxProps['components']; + /** + * Command queue: maintained by external demo, entire array reference changes after each new command is appended. + * Card listens to this queue, filters commands belonging to its surfaceId and processes them in batch. + */ + commandQueue: (A2UICommand_v0_9 | A2UICommand_v0_8)[]; + onAction?: (payload: ActionPayload) => void; + /** catalogId -> Catalog mapping */ + catalogMap?: Map; + /** surfaceId -> catalogId mapping */ + surfaceCatalogMap?: Map; +} + +const BoxContext = createContext({ + components: {}, + commandQueue: [], +}); + +export default BoxContext; diff --git a/packages/x-card/src/A2UI/__tests__/Box.coverage.test.tsx b/packages/x-card/src/A2UI/__tests__/Box.coverage.test.tsx new file mode 100644 index 0000000000..86cda19ef1 --- /dev/null +++ b/packages/x-card/src/A2UI/__tests__/Box.coverage.test.tsx @@ -0,0 +1,419 @@ +/** + * Box.tsx 和 components.ts 覆盖率补充测试用例 + * 覆盖:Box loadCatalog 错误处理、components transform 空数组分支、explicitList 分支 + */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Box from '../Box'; +import Card from '../Card'; +import { registerCatalog, clearCatalogCache } from '../catalog'; +import { createComponentTransformer } from '../format/components'; +import type { ComponentWrapper_v0_8 } from '../types/command_v0.8'; + +// Mock fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +// Mock console +const originalConsole = { ...console }; +beforeEach(() => { + console.log = jest.fn(); + console.warn = jest.fn(); + console.error = jest.fn(); + mockFetch.mockClear(); +}); + +afterEach(() => { + console.log = originalConsole.log; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + clearCatalogCache(); +}); + +describe('Box.tsx coverage', () => { + describe('createSurface without catalogId', () => { + it('should handle createSurface without catalogId', async () => { + // 测试覆盖 Box.tsx 行 34: catalogId 为 falsy 的分支 + render( + + + , + ); + + // 不应该打印 catalog loaded 日志(因为 catalogId 为空) + expect(console.log).not.toHaveBeenCalledWith( + 'Box: catalog loaded', + expect.anything(), + expect.anything(), + ); + }); + }); + + describe('loadCatalog error handling', () => { + it('should handle catalog load failure', async () => { + // 测试覆盖 Box.tsx 行 53: loadCatalog 失败时的错误处理 + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + render( + + + , + ); + + await waitFor(() => { + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to load catalog'), + expect.any(Error), + ); + }); + }); + + it('should skip setting catalog if already cached', async () => { + // 测试覆盖 Box.tsx 行 47: catalog 已缓存时跳过 setCatalogMap + const catalogUrl = 'https://example.com/cached-catalog.json'; + + // 先注册 catalog 到缓存 + registerCatalog({ + $id: catalogUrl, + components: { + TestComponent: { type: 'object' }, + }, + }); + + const TestComponent: React.FC = () =>
Test
; + + // 第一次渲染,catalog 已在缓存中 + const { rerender } = render( + + + , + ); + + // 等待 useEffect 执行完成(catalog 从缓存加载) + await waitFor(() => { + // 由于 catalog 已注册,loadCatalog 会直接返回缓存 + expect(mockFetch).not.toHaveBeenCalled(); + }); + + // 清除 console.log mock + (console.log as jest.Mock).mockClear(); + + // 重新渲染相同的命令,catalog 已缓存 + rerender( + + + , + ); + + // 由于 catalog 已缓存,不应该再调用 fetch + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should return prev map when catalogId already exists', async () => { + // 专门测试 Box.tsx 行 46-48: prev.has(catalogId) 返回 true 的分支 + const catalogUrl = 'https://example.com/duplicate-catalog.json'; + + registerCatalog({ + $id: catalogUrl, + components: { + TestComponent: { type: 'object' }, + }, + }); + + const TestComponent: React.FC = () =>
Test
; + + // 使用 act 来确保状态更新完成 + const { rerender } = render( + + + , + ); + + // 等待第一次加载完成 + await waitFor(() => { + // catalog 从缓存加载,不需要 fetch + expect(mockFetch).not.toHaveBeenCalled(); + }); + + // 清空 mock + (console.log as jest.Mock).mockClear(); + + // 再次触发相同的 createSurface 命令(新数组引用) + rerender( + + + , + ); + + // 等待 useEffect 执行 + await waitFor(() => { + // 由于 catalog 已缓存,fetch 不应该被调用 + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + }); +}); + +describe('components.ts coverage', () => { + describe('transform with empty components', () => { + it('should return null when components array is empty', () => { + // 测试覆盖行 112-113: 空数组时返回缓存的 root 或 null + const transformer = createComponentTransformer(); + + // 初始空数组应返回 null + const result1 = transformer.transform([], 'v0.9'); + expect(result1).toBeNull(); + + // 先添加一些组件 + transformer.transform( + [ + { + id: 'root', + component: 'Container', + }, + ], + 'v0.9', + ); + + // 再用空数组调用,应该返回缓存的 root + const result2 = transformer.transform([], 'v0.9'); + expect(result2).not.toBeNull(); + expect(result2!.type).toBe('Container'); + }); + + it('should return null for undefined/null components', () => { + const transformer = createComponentTransformer(); + + // @ts-ignore - 测试边界情况 + const result = transformer.transform(null, 'v0.9'); + expect(result).toBeNull(); + }); + + it('should return cached root for non-array input', () => { + const transformer = createComponentTransformer(); + + // 先添加组件 + transformer.transform( + [ + { + id: 'root', + component: 'Test', + }, + ], + 'v0.9', + ); + + // @ts-ignore - 测试边界情况 + const result = transformer.transform('not-an-array', 'v0.9'); + expect(result).not.toBeNull(); + }); + }); + + describe('explicitList children parsing', () => { + it('should parse explicitList children in v0.8', () => { + // 测试覆盖行 50: isExplicitList 分支 + const transformer = createComponentTransformer(); + + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + children: { + explicitList: ['child1', 'child2'], + }, + }, + }, + }, + { + id: 'child1', + component: { + Text: { text: 'Child 1' }, + }, + }, + { + id: 'child2', + component: { + Text: { text: 'Child 2' }, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + expect(result!.children).toEqual(['child1', 'child2']); + + // 验证子节点 + const child1 = transformer.getById('child1'); + expect(child1).toBeDefined(); + expect(child1!.type).toBe('Text'); + }); + + it('should parse array children in v0.8 (not explicitList)', () => { + // 测试覆盖行 51-52: children 是数组但不是 explicitList 的分支 + const transformer = createComponentTransformer(); + + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + // 普通数组形式,不是 explicitList + children: ['child1'], + }, + }, + }, + { + id: 'child1', + component: { + Text: { text: 'Child 1' }, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + expect(result!.children).toEqual(['child1']); + }); + + it('should handle non-array, non-explicitList children in v0.8', () => { + // 测试覆盖行 51 的 false 分支: children 存在但既不是 explicitList 也不是数组 + const transformer = createComponentTransformer(); + + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + // children 是一个对象但不是 explicitList,也不是数组 + // 这种情况下 children 会被跳过 + children: { someOtherProperty: 'value' } as any, + }, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + // children 应该是 undefined,因为不是 explicitList 也不是数组 + expect(result!.children).toBeUndefined(); + }); + + it('should handle child property instead of children', () => { + // 测试覆盖行 54-55: 使用 child 而不是 children + const transformer = createComponentTransformer(); + + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + child: 'singleChild', + }, + }, + }, + { + id: 'singleChild', + component: { + Text: { text: 'Single Child' }, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + expect(result!.children).toEqual(['singleChild']); + }); + }); + + describe('transform with default version', () => { + it('should default to v0.8 when version not specified', () => { + const transformer = createComponentTransformer(); + + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Test: { value: 'test' }, + }, + }, + ]; + + // @ts-ignore - 测试默认版本 + const result = transformer.transform(components); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Test'); + }); + }); +}); diff --git a/packages/x-card/src/A2UI/__tests__/Card.coverage.test.tsx b/packages/x-card/src/A2UI/__tests__/Card.coverage.test.tsx new file mode 100644 index 0000000000..de30125431 --- /dev/null +++ b/packages/x-card/src/A2UI/__tests__/Card.coverage.test.tsx @@ -0,0 +1,729 @@ +/** + * Card.tsx 覆盖率补充测试用例 + * 覆盖:v0.8 hasRenderedRef 分支、dataModelUpdate、handleAction dataUpdates reduce、handleDataChange + */ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Box from '../Box'; +import Card from '../Card'; +import { registerCatalog, clearCatalogCache } from '../catalog'; + +// Mock console +const originalConsole = { ...console }; +const originalEnv = process.env; + +beforeEach(() => { + console.log = jest.fn(); + console.warn = jest.fn(); + console.error = jest.fn(); +}); + +afterEach(() => { + console.log = originalConsole.log; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + clearCatalogCache(); + process.env = originalEnv; +}); + +describe('Card.tsx coverage', () => { + describe('development environment branches', () => { + it('should warn in development mode when component not registered but in catalog', async () => { + // 测试覆盖行 79-83, 92-100: process.env.NODE_ENV === 'development' 分支 + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development' as any; + + // 注册 catalog,但不注册组件 + registerCatalog({ + $id: 'test-catalog', + components: { + TestComponent: { type: 'object' }, + }, + }); + + const { unmount } = render( + + + , + ); + + await waitFor(() => { + // 在开发模式下,组件在 catalog 中定义但未注册时应该有警告 + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('is defined in catalog but not registered'), + ); + }); + + process.env.NODE_ENV = originalEnv; + unmount(); + }); + + it('should error in development mode when component not in catalog', async () => { + // 测试覆盖行 92-97: 组件不在 catalog 中时应该有错误 + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development' as any; + + // 注册 catalog,但不包含目标组件 + registerCatalog({ + $id: 'test-catalog-2', + components: { + OtherComponent: { type: 'object' }, + }, + }); + + const { unmount } = render( + + + , + ); + + await waitFor(() => { + // 在开发模式下,组件不在 catalog 中且未注册时应该有错误 + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('is not registered and not defined in catalog'), + ); + }); + + process.env.NODE_ENV = originalEnv; + unmount(); + }); + }); + + describe('NodeRenderer branches', () => { + it('should not inject onAction when Component is a string (HTML element)', async () => { + // 测试覆盖行 115: typeof Component !== 'string' 的 false 分支 + // 当 Component 是字符串(如 'div')时,不应该注入 onAction + + // 使用 'div' 作为组件 + render( + + + , + ); + + // 应该渲染成功 + await waitFor(() => { + expect(screen.queryByText('div')).toBeNull(); // div 不会渲染文本 + }); + }); + + it('should return null when node not found in renderNode', async () => { + // 测试覆盖行 33: renderNode 中 node 不存在时返回 null + // 这发生在 children 引用的节点不存在时 + + const ParentComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( +
{children}
+ ); + + render( + + + , + ); + + // 应该渲染父组件,但子节点为空(因为不存在) + await waitFor(() => { + expect(screen.getByTestId('parent')).toBeInTheDocument(); + expect(screen.getByTestId('parent').children.length).toBe(0); + }); + }); + }); + + describe('v0.8 hasRenderedRef branch', () => { + it('should update from cache when hasRenderedRef is true after rerender', async () => { + // 测试覆盖行 214-216: v0.8 中已经渲染过后,收到新的 surfaceUpdate 时直接从缓存更新 + const TestComponent: React.FC<{ text?: string }> = ({ text }) => ( +
{text || 'empty'}
+ ); + + // 先进行初始渲染并触发 beginRendering + const { rerender } = render( + + + , + ); + + expect(screen.getByTestId('test-component').textContent).toBe('initial'); + + // 发送新的 surfaceUpdate 命令,此时 hasRenderedRef 为 true + rerender( + + + , + ); + + // 由于 hasRenderedRef 为 true,应该从缓存更新 root 节点 + await waitFor(() => { + expect(screen.getByTestId('test-component').textContent).toBe('updated'); + }); + }); + }); + + describe('v0.8 dataModelUpdate command', () => { + it('should apply dataModelUpdate in v0.8 mode', async () => { + // 测试覆盖行 223-224: v0.8 dataModelUpdate 命令 + const TestComponent: React.FC<{ value?: string }> = ({ value }) => ( +
{value || 'no value'}
+ ); + + // 先设置组件结构和数据绑定 + const { rerender } = render( + + + , + ); + + // 初始应该显示 no value + expect(screen.getByTestId('bound-value').textContent).toBe('no value'); + + // 发送 dataModelUpdate 命令 - 使用正确的 v0.8 格式 + // v0.8 dataModelUpdate 格式: contents: [{ key: string, valueMap: [{ key: string, valueString: string }] }] + rerender( + + + , + ); + + // 等待数据更新 + await waitFor(() => { + expect(screen.getByTestId('bound-value').textContent).toBe('data from update'); + }); + }); + }); + + describe('handleAction dataUpdates reduce', () => { + it('should apply dataUpdates in handleAction with reduce', async () => { + // 测试覆盖行 281-286: handleAction 中 dataUpdates.length > 0 时的 reduce 逻辑 + const onAction = jest.fn(); + + // 创建一个带有 action 配置的组件 + const ClickableComponent: React.FC<{ + onAction?: (name: string, ctx: any) => void; + action?: any; + }> = ({ onAction: componentOnAction }) => ( + + ); + + render( + + + , + ); + + // 点击按钮触发 action + fireEvent.click(screen.getByTestId('click-btn')); + + await waitFor(() => { + expect(onAction).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'click', + surfaceId: 'card1', + context: expect.objectContaining({ + resultValue: 'clicked-value', + }), + }), + ); + }); + }); + + it('should apply dataUpdates in v0.8 mode', async () => { + // 测试 v0.8 的 extractDataUpdatesV08 分支 + const onAction = jest.fn(); + + const ClickableComponent: React.FC<{ + onAction?: (name: string, ctx: any) => void; + action?: any; + }> = ({ onAction: componentOnAction }) => ( + + ); + + render( + + + , + ); + + // 点击按钮触发 action + fireEvent.click(screen.getByTestId('click-btn')); + + await waitFor(() => { + expect(onAction).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'click', + surfaceId: 'card1', + }), + ); + }); + }); + }); + + describe('handleDataChange', () => { + it('should handle data change via onDataChange', async () => { + // 测试覆盖行 305: handleDataChange 函数 + // onDataChange 会被注入到组件中,组件可以调用它来更新 dataModel + + const InputComponent: React.FC<{ + value?: string; + onDataChange?: (path: string, value: any) => void; + onAction?: (name: string, ctx: any) => void; + }> = ({ value, onDataChange, onAction }) => ( + { + // 调用 onDataChange 更新 dataModel + onDataChange?.('/form/input', e.target.value); + // 同时触发 action 通知外部 + onAction?.('change', { value: e.target.value }); + }} + /> + ); + + const onAction = jest.fn(); + + render( + + + , + ); + + const input = screen.getByTestId('test-input'); + + // 输入新值,触发 onDataChange + fireEvent.change(input, { target: { value: 'new value' } }); + + await waitFor(() => { + expect(onAction).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'change', + surfaceId: 'card1', + }), + ); + }); + }); + }); + + describe('additional branches', () => { + it('should handle surfaceCatalogMap not existing', async () => { + // 测试覆盖行 161: surfaceCatalogMap 不存在时返回 undefined + const TestComponent: React.FC = () =>
Test
; + + // 不使用 createSurface,直接使用 updateComponents + // 此时 surfaceCatalogMap 应该为空,catalogId 应该为 undefined + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('test')).toBeInTheDocument(); + }); + }); + + it('should use default commandVersion v0.8 when version not specified', async () => { + // 测试覆盖行 71, 151: commandVersion 默认值 'v0.8' + // 当 commands 对象没有 version 字段时,默认使用 v0.8 + const TestComponent: React.FC = () =>
Test
; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('test')).toBeInTheDocument(); + }); + }); + + it('should handle nodeTree not existing in v0.9 updateComponents', async () => { + // 测试覆盖行 188: nodeTree 不存在时不更新渲染 + const TestComponent: React.FC = () =>
Test
; + + // 使用 updateComponents 但不提供有效的组件 + render( + + + , + ); + + // 由于没有组件,不应该渲染任何内容 + await waitFor(() => { + expect(screen.queryByTestId('test')).not.toBeInTheDocument(); + }); + }); + + it('should handle rootNodeFromCache not existing in v0.8', async () => { + // 测试覆盖行 218: rootNodeFromCache 不存在时不更新 + const TestComponent: React.FC<{ text?: string }> = ({ text }) => ( +
{text || 'default'}
+ ); + + // 先渲染一次建立 hasRenderedRef + const { rerender } = render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('test').textContent).toBe('initial'); + }); + + // 更新但提供空的组件列表,此时 rootNodeFromCache 会返回 undefined + rerender( + + + , + ); + + // 应该保持原有内容 + await waitFor(() => { + expect(screen.getByTestId('test').textContent).toBe('initial'); + }); + }); + }); +}); diff --git a/packages/x-card/src/A2UI/__tests__/Card.v0.8.test.ts b/packages/x-card/src/A2UI/__tests__/Card.v0.8.test.ts new file mode 100644 index 0000000000..0b153a916d --- /dev/null +++ b/packages/x-card/src/A2UI/__tests__/Card.v0.8.test.ts @@ -0,0 +1,328 @@ +/** + * Card.v0.8.ts 测试用例 + * 覆盖 isLiteralStringObject, resolvePropsV08, resolveActionContextV08, extractDataUpdatesV08, applyDataModelUpdateV08 + */ +import { + isLiteralStringObject, + resolvePropsV08, + resolveActionContextV08, + extractDataUpdatesV08, + applyDataModelUpdateV08, +} from '../Card.v0.8'; + +describe('isLiteralStringObject', () => { + it('should return true for { literalString: string }', () => { + expect(isLiteralStringObject({ literalString: 'hello' })).toBe(true); + }); + + it('should return false for null', () => { + expect(isLiteralStringObject(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isLiteralStringObject(undefined)).toBe(false); + }); + + it('should return false for string', () => { + expect(isLiteralStringObject('hello')).toBe(false); + }); + + it('should return false for object without literalString', () => { + expect(isLiteralStringObject({ path: '/name' })).toBe(false); + }); + + it('should return false for object with non-string literalString', () => { + expect(isLiteralStringObject({ literalString: 123 })).toBe(false); + }); + + it('should return true for object with extra fields', () => { + expect(isLiteralStringObject({ literalString: 'hello', extra: 'value' })).toBe(true); + }); +}); + +describe('resolvePropsV08', () => { + it('should resolve literal string values', () => { + const props = { + text: { literalString: 'Hello World' }, + }; + const result = resolvePropsV08(props, {}); + expect(result.text).toBe('Hello World'); + }); + + it('should resolve path values from dataModel', () => { + const props = { + name: { path: '/user/name' }, + }; + const dataModel = { user: { name: 'Alice' } }; + const result = resolvePropsV08(props, dataModel); + expect(result.name).toBe('Alice'); + }); + + it('should resolve string path values', () => { + const props = { + name: '/user/name', + }; + const dataModel = { user: { name: 'Bob' } }; + const result = resolvePropsV08(props, dataModel); + expect(result.name).toBe('Bob'); + }); + + it('should keep literal values unchanged', () => { + const props = { + count: 42, + enabled: true, + text: 'hello', + }; + const result = resolvePropsV08(props, {}); + expect(result.count).toBe(42); + expect(result.enabled).toBe(true); + expect(result.text).toBe('hello'); + }); + + it('should resolve array values recursively', () => { + const props = { + items: [{ path: '/a' }, { literalString: 'literal' }], + }; + const dataModel = { a: 'value' }; + const result = resolvePropsV08(props, dataModel); + expect(result.items[0]).toBe('value'); + expect(result.items[1]).toBe('literal'); + }); + + it('should resolve nested object values recursively', () => { + const props = { + config: { + nested: { path: '/user/name' }, + value: 'static', + }, + }; + const dataModel = { user: { name: 'Alice' } }; + const result = resolvePropsV08(props, dataModel); + expect(result.config.nested).toBe('Alice'); + expect(result.config.value).toBe('static'); + }); + + it('should preserve { path } in action.context items', () => { + const props = { + action: { + context: [{ key: 'userName', value: { path: '/user/name' } }], + }, + }; + const dataModel = { user: { name: 'Alice' } }; + const result = resolvePropsV08(props, dataModel); + // context 中的 { path } 应该被保留(是写入目标) + expect(result.action.context[0].value).toEqual({ path: '/user/name' }); + }); + + it('should resolve undefined path values', () => { + const props = { + missing: { path: '/not/exist' }, + }; + const result = resolvePropsV08(props, {}); + expect(result.missing).toBeUndefined(); + }); +}); + +describe('resolveActionContextV08', () => { + it('should return undefined for non-array context', () => { + expect(resolveActionContextV08({ context: {} }, {})).toBeUndefined(); + expect(resolveActionContextV08({ context: null }, {})).toBeUndefined(); + expect(resolveActionContextV08({ context: 'string' }, {})).toBeUndefined(); + expect(resolveActionContextV08({}, {})).toBeUndefined(); + }); + + it('should resolve path values from dataModel', () => { + const action = { + context: [{ key: 'userName', value: { path: '/user/name' } }], + }; + const dataModel = { user: { name: 'Alice' } }; + const result = resolveActionContextV08(action, dataModel); + expect(result).toEqual({ userName: 'Alice' }); + }); + + it('should resolve literalString values', () => { + const action = { + context: [{ key: 'greeting', value: { literalString: 'Hello' } }], + }; + const result = resolveActionContextV08(action, {}); + expect(result).toEqual({ greeting: 'Hello' }); + }); + + it('should use literal values directly', () => { + const action = { + context: [{ key: 'count', value: 42 }], + }; + const result = resolveActionContextV08(action, {}); + expect(result).toEqual({ count: 42 }); + }); + + it('should skip invalid context items', () => { + const action = { + context: [{ key: 'valid', value: 'test' }, { invalid: 'item' }, null, 'string'], + }; + const result = resolveActionContextV08(action, {}); + expect(result).toEqual({ valid: 'test' }); + }); + + it('should return undefined for empty resolved object', () => { + const action = { + context: [{ invalid: 'item' }], + }; + const result = resolveActionContextV08(action, {}); + expect(result).toBeUndefined(); + }); + + it('should handle multiple context items', () => { + const action = { + context: [ + { key: 'name', value: { path: '/user/name' } }, + { key: 'age', value: { literalString: '25' } }, + { key: 'active', value: true }, + ], + }; + const dataModel = { user: { name: 'Bob' } }; + const result = resolveActionContextV08(action, dataModel); + expect(result).toEqual({ + name: 'Bob', + age: '25', + active: true, + }); + }); +}); + +describe('extractDataUpdatesV08', () => { + it('should return empty array for non-array context', () => { + expect(extractDataUpdatesV08({ context: {} }, {})).toEqual([]); + expect(extractDataUpdatesV08({ context: null }, {})).toEqual([]); + expect(extractDataUpdatesV08({}, {})).toEqual([]); + }); + + it('should extract updates from path bindings', () => { + const action = { + context: [{ key: 'userName', value: { path: '/user/name' } }], + }; + const componentContext = { userName: 'Alice' }; + const result = extractDataUpdatesV08(action, componentContext); + expect(result).toEqual([{ path: '/user/name', value: 'Alice' }]); + }); + + it('should skip non-path values', () => { + const action = { + context: [ + { key: 'name', value: 'literal' }, + { key: 'age', value: { path: '/user/age' } }, + ], + }; + const componentContext = { name: 'test', age: 25 }; + const result = extractDataUpdatesV08(action, componentContext); + expect(result).toEqual([{ path: '/user/age', value: 25 }]); + }); + + it('should skip when componentContext does not have key', () => { + const action = { + context: [{ key: 'userName', value: { path: '/user/name' } }], + }; + const componentContext = { otherKey: 'value' }; + const result = extractDataUpdatesV08(action, componentContext); + expect(result).toEqual([]); + }); + + it('should skip when value is undefined', () => { + const action = { + context: [{ key: 'userName', value: { path: '/user/name' } }], + }; + const componentContext = { userName: undefined }; + const result = extractDataUpdatesV08(action, componentContext); + expect(result).toEqual([]); + }); + + it('should handle multiple updates', () => { + const action = { + context: [ + { key: 'name', value: { path: '/user/name' } }, + { key: 'email', value: { path: '/user/email' } }, + ], + }; + const componentContext = { name: 'Alice', email: 'alice@example.com' }; + const result = extractDataUpdatesV08(action, componentContext); + expect(result).toEqual([ + { path: '/user/name', value: 'Alice' }, + { path: '/user/email', value: 'alice@example.com' }, + ]); + }); + + it('should skip invalid context items', () => { + const action = { + context: [{ key: 'valid', value: { path: '/valid' } }, { invalid: 'item' }, null], + }; + const componentContext = { valid: 'test' }; + const result = extractDataUpdatesV08(action, componentContext); + expect(result).toEqual([{ path: '/valid', value: 'test' }]); + }); +}); + +describe('applyDataModelUpdateV08', () => { + it('should apply single content item', () => { + const prev = { existing: 'value' }; + const contents = [ + { + key: 'res', + valueMap: [{ key: 'time', valueString: '2024-01-01' }], + }, + ]; + const result = applyDataModelUpdateV08(prev, contents); + expect(result.res).toEqual({ time: '2024-01-01' }); + expect(result.existing).toBe('value'); + }); + + it('should apply multiple content items', () => { + const prev = {}; + const contents = [ + { + key: 'user', + valueMap: [ + { key: 'name', valueString: 'Alice' }, + { key: 'age', valueString: '25' }, + ], + }, + { + key: 'settings', + valueMap: [{ key: 'theme', valueString: 'dark' }], + }, + ]; + const result = applyDataModelUpdateV08(prev, contents); + expect(result.user).toEqual({ name: 'Alice', age: '25' }); + expect(result.settings).toEqual({ theme: 'dark' }); + }); + + it('should override existing keys', () => { + const prev = { user: { oldKey: 'oldValue' } }; + const contents = [ + { + key: 'user', + valueMap: [{ key: 'newKey', valueString: 'newValue' }], + }, + ]; + const result = applyDataModelUpdateV08(prev, contents); + expect(result.user).toEqual({ newKey: 'newValue' }); + }); + + it('should handle empty contents', () => { + const prev = { existing: 'value' }; + const result = applyDataModelUpdateV08(prev, []); + expect(result).toEqual({ existing: 'value' }); + }); + + it('should be immutable', () => { + const prev = { existing: 'value' }; + const contents = [ + { + key: 'new', + valueMap: [{ key: 'data', valueString: 'test' }], + }, + ]; + const result = applyDataModelUpdateV08(prev, contents); + expect(prev).toEqual({ existing: 'value' }); + expect(result.new).toBeDefined(); + }); +}); diff --git a/packages/x-card/src/A2UI/__tests__/Card.v0.9.test.ts b/packages/x-card/src/A2UI/__tests__/Card.v0.9.test.ts new file mode 100644 index 0000000000..6153a30b65 --- /dev/null +++ b/packages/x-card/src/A2UI/__tests__/Card.v0.9.test.ts @@ -0,0 +1,319 @@ +/** + * Card.v0.9.ts 测试用例 + * 覆盖 resolvePropsV09, resolveActionContextV09, extractDataUpdatesV09, applyDataModelUpdateV09 + */ +import { + resolvePropsV09, + resolveActionContextV09, + extractDataUpdatesV09, + applyDataModelUpdateV09, +} from '../Card.v0.9'; + +describe('resolvePropsV09', () => { + it('should resolve path values from dataModel', () => { + const props = { + name: { path: '/user/name' }, + }; + const dataModel = { user: { name: 'Alice' } }; + const result = resolvePropsV09(props, dataModel); + expect(result.name).toBe('Alice'); + }); + + it('should resolve string path values', () => { + const props = { + name: '/user/name', + }; + const dataModel = { user: { name: 'Bob' } }; + const result = resolvePropsV09(props, dataModel); + expect(result.name).toBe('Bob'); + }); + + it('should keep literal values unchanged', () => { + const props = { + count: 42, + enabled: true, + text: 'hello', + }; + const result = resolvePropsV09(props, {}); + expect(result.count).toBe(42); + expect(result.enabled).toBe(true); + expect(result.text).toBe('hello'); + }); + + it('should resolve array values recursively', () => { + const props = { + items: [{ path: '/a' }, 'literal'], + }; + const dataModel = { a: 'value' }; + const result = resolvePropsV09(props, dataModel); + expect(result.items[0]).toBe('value'); + expect(result.items[1]).toBe('literal'); + }); + + it('should resolve nested object values recursively', () => { + const props = { + config: { + nested: { path: '/user/name' }, + value: 'static', + }, + }; + const dataModel = { user: { name: 'Alice' } }; + const result = resolvePropsV09(props, dataModel); + expect(result.config.nested).toBe('Alice'); + expect(result.config.value).toBe('static'); + }); + + it('should preserve { path } in action.event.context', () => { + const props = { + action: { + event: { + name: 'submit', + context: { + userName: { path: '/user/name' }, + }, + }, + }, + }; + const dataModel = { user: { name: 'Alice' } }; + const result = resolvePropsV09(props, dataModel); + // context 中的 { path } 应该被保留(是写入目标) + expect(result.action.event.context.userName).toEqual({ path: '/user/name' }); + }); + + it('should resolve undefined path values', () => { + const props = { + missing: { path: '/not/exist' }, + }; + const result = resolvePropsV09(props, {}); + expect(result.missing).toBeUndefined(); + }); + + it('should handle null values', () => { + const props = { + value: null, + }; + const result = resolvePropsV09(props, {}); + expect(result.value).toBeNull(); + }); + + it('should preserve { path } when inside action.event', () => { + const props = { + action: { + event: { + name: 'click', + context: { + userId: { path: '/user/id' }, + timestamp: 12345, + }, + }, + }, + }; + const dataModel = { user: { id: 'user123' } }; + const result = resolvePropsV09(props, dataModel); + // path 应该被保留 + expect(result.action.event.context.userId).toEqual({ path: '/user/id' }); + // 字面值保持不变 + expect(result.action.event.context.timestamp).toBe(12345); + }); +}); + +describe('resolveActionContextV09', () => { + it('should return undefined for non-object context', () => { + expect(resolveActionContextV09({ event: { context: [] } }, {})).toBeUndefined(); + expect(resolveActionContextV09({ event: { context: 'string' } }, {})).toBeUndefined(); + expect(resolveActionContextV09({ event: { context: null } }, {})).toBeUndefined(); + expect(resolveActionContextV09({ event: {} }, {})).toBeUndefined(); + expect(resolveActionContextV09({}, {})).toBeUndefined(); + }); + + it('should resolve path values from dataModel', () => { + const action = { + event: { + context: { + userName: { path: '/user/name' }, + }, + }, + }; + const dataModel = { user: { name: 'Alice' } }; + const result = resolveActionContextV09(action, dataModel); + expect(result).toEqual({ userName: 'Alice' }); + }); + + it('should use literal values directly', () => { + const action = { + event: { + context: { + count: 42, + text: 'hello', + enabled: true, + }, + }, + }; + const result = resolveActionContextV09(action, {}); + expect(result).toEqual({ + count: 42, + text: 'hello', + enabled: true, + }); + }); + + it('should handle multiple context keys', () => { + const action = { + event: { + context: { + name: { path: '/user/name' }, + age: 25, + active: true, + }, + }, + }; + const dataModel = { user: { name: 'Bob' } }; + const result = resolveActionContextV09(action, dataModel); + expect(result).toEqual({ + name: 'Bob', + age: 25, + active: true, + }); + }); + + it('should handle empty context object', () => { + const action = { + event: { + context: {}, + }, + }; + const result = resolveActionContextV09(action, {}); + expect(result).toEqual({}); + }); +}); + +describe('extractDataUpdatesV09', () => { + it('should return empty array for non-object context', () => { + expect(extractDataUpdatesV09({ event: { context: [] } }, {})).toEqual([]); + expect(extractDataUpdatesV09({ event: { context: null } }, {})).toEqual([]); + expect(extractDataUpdatesV09({ event: {} }, {})).toEqual([]); + expect(extractDataUpdatesV09({}, {})).toEqual([]); + }); + + it('should extract updates from path bindings', () => { + const action = { + event: { + context: { + userName: { path: '/user/name' }, + }, + }, + }; + const componentContext = { userName: 'Alice' }; + const result = extractDataUpdatesV09(action, componentContext); + expect(result).toEqual([{ path: '/user/name', value: 'Alice' }]); + }); + + it('should skip non-path values', () => { + const action = { + event: { + context: { + name: 'literal', + age: { path: '/user/age' }, + }, + }, + }; + const componentContext = { name: 'test', age: 25 }; + const result = extractDataUpdatesV09(action, componentContext); + expect(result).toEqual([{ path: '/user/age', value: 25 }]); + }); + + it('should skip when componentContext does not have key', () => { + const action = { + event: { + context: { + userName: { path: '/user/name' }, + }, + }, + }; + const componentContext = { otherKey: 'value' }; + const result = extractDataUpdatesV09(action, componentContext); + expect(result).toEqual([]); + }); + + it('should skip when value is undefined', () => { + const action = { + event: { + context: { + userName: { path: '/user/name' }, + }, + }, + }; + const componentContext = { userName: undefined }; + const result = extractDataUpdatesV09(action, componentContext); + expect(result).toEqual([]); + }); + + it('should handle multiple updates', () => { + const action = { + event: { + context: { + name: { path: '/user/name' }, + email: { path: '/user/email' }, + literal: 'ignored', + }, + }, + }; + const componentContext = { name: 'Alice', email: 'alice@example.com', literal: 'value' }; + const result = extractDataUpdatesV09(action, componentContext); + expect(result).toEqual([ + { path: '/user/name', value: 'Alice' }, + { path: '/user/email', value: 'alice@example.com' }, + ]); + }); +}); + +describe('applyDataModelUpdateV09', () => { + it('should set value at path', () => { + const prev = {}; + const result = applyDataModelUpdateV09(prev, '/user/name', 'Alice'); + expect((result as any).user.name).toBe('Alice'); + }); + + it('should preserve existing data', () => { + const prev = { existing: 'value' }; + const result = applyDataModelUpdateV09(prev, '/new/key', 'test'); + expect(result.existing).toBe('value'); + expect((result as any).new.key).toBe('test'); + }); + + it('should override existing path', () => { + const prev = { user: { name: 'Bob' } }; + const result = applyDataModelUpdateV09(prev, '/user/name', 'Alice'); + expect((result as any).user.name).toBe('Alice'); + }); + + it('should be immutable', () => { + const prev = { user: { name: 'Bob' } }; + applyDataModelUpdateV09(prev, '/user/name', 'Alice'); + expect((prev as any).user.name).toBe('Bob'); + }); + + it('should handle nested paths', () => { + const prev = {}; + const result = applyDataModelUpdateV09(prev, '/a/b/c/d', 'deep'); + expect((result as any).a.b.c.d).toBe('deep'); + }); + + it('should handle null value', () => { + const prev = {}; + const result = applyDataModelUpdateV09(prev, '/key', null); + expect(result.key).toBeNull(); + }); + + it('should handle object value', () => { + const prev = {}; + const result = applyDataModelUpdateV09(prev, '/data', { nested: 'value' }); + expect((result as any).data.nested).toBe('value'); + }); + + it('should handle array value', () => { + const prev = {}; + const result = applyDataModelUpdateV09(prev, '/items', [1, 2, 3]); + expect((result as any).items).toEqual([1, 2, 3]); + }); +}); diff --git a/packages/x-card/src/A2UI/__tests__/catalog.test.ts b/packages/x-card/src/A2UI/__tests__/catalog.test.ts new file mode 100644 index 0000000000..740e8d34ae --- /dev/null +++ b/packages/x-card/src/A2UI/__tests__/catalog.test.ts @@ -0,0 +1,261 @@ +/** + * catalog.ts 测试用例 + * 覆盖 registerCatalog, loadCatalog, validateComponent, getComponentPropsSchema, clearCatalogCache + */ +import { + registerCatalog, + loadCatalog, + validateComponent, + getComponentPropsSchema, + clearCatalogCache, + type Catalog, +} from '../catalog'; + +// Mock fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +beforeEach(() => { + clearCatalogCache(); + mockFetch.mockReset(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('registerCatalog', () => { + it('should register catalog with $id', () => { + const catalog: Catalog = { + $id: 'test-catalog', + components: { Button: { type: 'object' } }, + }; + registerCatalog(catalog); + // 验证可以通过 loadCatalog 获取 + return loadCatalog('test-catalog').then((result) => { + expect(result).toBe(catalog); + }); + }); + + it('should register catalog with catalogId', () => { + const catalog: Catalog = { + catalogId: 'my-catalog', + components: { Input: { type: 'object' } }, + }; + registerCatalog(catalog); + return loadCatalog('my-catalog').then((result) => { + expect(result).toBe(catalog); + }); + }); + + it('should not register catalog without id', () => { + const catalog: Catalog = { + components: { Button: { type: 'object' } }, + }; + registerCatalog(catalog); + // 没有 id,不应该被注册,fetch 会被调用 + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }); + return loadCatalog('undefined-catalog').catch(() => { + // 预期会失败 + }); + }); +}); + +describe('loadCatalog', () => { + it('should return cached catalog', async () => { + const catalog: Catalog = { + $id: 'cached-catalog', + components: { Button: { type: 'object' } }, + }; + registerCatalog(catalog); + const result = await loadCatalog('cached-catalog'); + expect(result).toBe(catalog); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should return empty catalog for unregistered local:// schema', async () => { + const result = await loadCatalog('local://my-catalog'); + expect(result.$id).toBe('local://my-catalog'); + expect(result.components).toEqual({}); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should fetch remote catalog', async () => { + const remoteCatalog: Catalog = { + $id: 'https://example.com/catalog.json', + components: { Button: { type: 'object' } }, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(remoteCatalog), + }); + + const result = await loadCatalog('https://example.com/catalog.json'); + expect(result).toEqual(remoteCatalog); + expect(mockFetch).toHaveBeenCalledWith('https://example.com/catalog.json'); + }); + + it('should cache fetched remote catalog', async () => { + const remoteCatalog: Catalog = { + $id: 'https://example.com/catalog2.json', + components: {}, + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(remoteCatalog), + }); + + await loadCatalog('https://example.com/catalog2.json'); + // 第二次调用不应该再 fetch + const result2 = await loadCatalog('https://example.com/catalog2.json'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result2).toEqual(remoteCatalog); + }); + + it('should throw error when fetch fails with non-ok response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }); + + await expect(loadCatalog('https://example.com/bad-catalog.json')).rejects.toThrow(); + expect(console.error).toHaveBeenCalled(); + }); + + it('should throw error when fetch throws', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + await expect(loadCatalog('https://example.com/error-catalog.json')).rejects.toThrow( + 'Network error', + ); + expect(console.error).toHaveBeenCalled(); + }); +}); + +describe('validateComponent', () => { + it('should return false when component not in catalog', () => { + const catalog: Catalog = { + components: { Input: { type: 'object' } }, + }; + const result = validateComponent(catalog, 'Button', {}); + expect(result).toBe(false); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should return true when component has no required fields', () => { + const catalog: Catalog = { + components: { + Button: { + type: 'object', + properties: { label: { type: 'string' } }, + }, + }, + }; + const result = validateComponent(catalog, 'Button', { label: 'Click' }); + expect(result).toBe(true); + }); + + it('should return false when required field is missing', () => { + const catalog: Catalog = { + components: { + Button: { + type: 'object', + required: ['label'], + properties: { label: { type: 'string' } }, + }, + }, + }; + const result = validateComponent(catalog, 'Button', {}); + expect(result).toBe(false); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should return true when all required fields present', () => { + const catalog: Catalog = { + components: { + Button: { + type: 'object', + required: ['label'], + properties: { label: { type: 'string' } }, + }, + }, + }; + const result = validateComponent(catalog, 'Button', { label: 'Click' }); + expect(result).toBe(true); + }); + + it('should handle catalog with no components', () => { + const catalog: Catalog = {}; + const result = validateComponent(catalog, 'Button', {}); + expect(result).toBe(false); + }); +}); + +describe('getComponentPropsSchema', () => { + it('should return properties for existing component', () => { + const catalog: Catalog = { + components: { + Button: { + type: 'object', + properties: { + label: { type: 'string' }, + disabled: { type: 'boolean' }, + }, + }, + }, + }; + const schema = getComponentPropsSchema(catalog, 'Button'); + expect(schema).toEqual({ + label: { type: 'string' }, + disabled: { type: 'boolean' }, + }); + }); + + it('should return undefined for non-existing component', () => { + const catalog: Catalog = { + components: { Input: { type: 'object' } }, + }; + const schema = getComponentPropsSchema(catalog, 'Button'); + expect(schema).toBeUndefined(); + }); + + it('should return undefined when catalog has no components', () => { + const catalog: Catalog = {}; + const schema = getComponentPropsSchema(catalog, 'Button'); + expect(schema).toBeUndefined(); + }); + + it('should return undefined when component has no properties', () => { + const catalog: Catalog = { + components: { Button: { type: 'object' } }, + }; + const schema = getComponentPropsSchema(catalog, 'Button'); + expect(schema).toBeUndefined(); + }); +}); + +describe('clearCatalogCache', () => { + it('should clear all cached catalogs', async () => { + const catalog: Catalog = { + $id: 'clear-test-catalog', + components: {}, + }; + registerCatalog(catalog); + + clearCatalogCache(); + + // 清除后,再次加载应该走 fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ $id: 'clear-test-catalog', components: {} }), + }); + + await loadCatalog('clear-test-catalog'); + expect(mockFetch).toHaveBeenCalledWith('clear-test-catalog'); + }); +}); diff --git a/packages/x-card/src/A2UI/__tests__/components.test.ts b/packages/x-card/src/A2UI/__tests__/components.test.ts new file mode 100644 index 0000000000..554994f590 --- /dev/null +++ b/packages/x-card/src/A2UI/__tests__/components.test.ts @@ -0,0 +1,451 @@ +/** + * format/components.ts 测试用例 + * 覆盖 createComponentTransformer, parseV08Node, parseV09Node, transformToReactTree + */ +import { createComponentTransformer } from '../format/components'; +import type { ComponentWrapper_v0_8 } from '../types/command_v0.8'; +import type { BaseComponent_v0_9 } from '../types/command_v0.9'; + +describe('createComponentTransformer', () => { + describe('v0.8', () => { + it('should transform simple v0.8 component', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + children: ['child1'], + }, + }, + }, + { + id: 'child1', + component: { + Text: { + text: 'Hello', + }, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + expect(result!.children).toContain('child1'); + + const child = transformer.getById('child1'); + expect(child).toBeDefined(); + expect(child!.type).toBe('Text'); + expect(child!.props.text).toBe('Hello'); + }); + + it('should parse { path: string } binding in v0.8', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Text: { + text: { path: '/user/name' }, + }, + }, + }, + ]; + + transformer.transform(components, 'v0.8'); + const node = transformer.getById('root'); + expect(node!.props.text).toBe('/user/name'); + }); + + it('should parse { literalString: string } in v0.8', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Text: { + text: { literalString: 'Hello World' }, + }, + }, + }, + ]; + + transformer.transform(components, 'v0.8'); + const node = transformer.getById('root'); + expect(node!.props.text).toBe('Hello World'); + }); + + it('should parse children array in v0.8', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + children: ['child1', 'child2'], + }, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result!.children).toEqual(['child1', 'child2']); + }); + + it('should parse { explicitList: string[] } children in v0.8', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + children: { explicitList: ['child1', 'child2'] }, + }, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result!.children).toEqual(['child1', 'child2']); + }); + + it('should parse child (singular) in v0.8', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + child: 'singleChild', + }, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result!.children).toEqual(['singleChild']); + }); + + it('should skip child/children in props', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: { + child: 'singleChild', + children: ['other'], + }, + }, + }, + ]; + + transformer.transform(components, 'v0.8'); + const node = transformer.getById('root'); + expect(node!.props.child).toBeUndefined(); + expect(node!.props.children).toBeUndefined(); + }); + + it('should return null when no root component', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'notRoot', + component: { + Text: {}, + }, + }, + ]; + + const result = transformer.transform(components, 'v0.8'); + expect(result).toBeNull(); + }); + + it('should return cached root when called with empty array', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: {}, + }, + }, + ]; + + transformer.transform(components, 'v0.8'); + const result = transformer.transform([], 'v0.8'); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + }); + + it('should handle mixed literal and binding values', () => { + const transformer = createComponentTransformer(); + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Input: { + placeholder: { literalString: 'Enter name' }, + value: { path: '/form/name' }, + disabled: false, + }, + }, + }, + ]; + + transformer.transform(components, 'v0.8'); + const node = transformer.getById('root'); + expect(node!.props.placeholder).toBe('Enter name'); + expect(node!.props.value).toBe('/form/name'); + expect(node!.props.disabled).toBe(false); + }); + }); + + describe('v0.9', () => { + it('should transform simple v0.9 component', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Container', + children: ['child1'], + }, + { + id: 'child1', + component: 'Text', + text: 'Hello', + }, + ]; + + const result = transformer.transform(components, 'v0.9'); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + expect(result!.children).toContain('child1'); + + const child = transformer.getById('child1'); + expect(child).toBeDefined(); + expect(child!.type).toBe('Text'); + expect(child!.props.text).toBe('Hello'); + }); + + it('should parse { path: string } binding in v0.9', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Text', + text: { path: '/user/name' }, + }, + ]; + + transformer.transform(components, 'v0.9'); + const node = transformer.getById('root'); + expect(node!.props.text).toBe('/user/name'); + }); + + it('should parse children array in v0.9', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Container', + children: ['child1', 'child2'], + }, + ]; + + const result = transformer.transform(components, 'v0.9'); + expect(result!.children).toEqual(['child1', 'child2']); + }); + + it('should parse child (singular) in v0.9', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Container', + child: 'singleChild', + }, + ]; + + const result = transformer.transform(components, 'v0.9'); + expect(result!.children).toEqual(['singleChild']); + }); + + it('should skip id, component, child, children in props', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Container', + child: 'singleChild', + extraProp: 'value', + }, + ]; + + transformer.transform(components, 'v0.9'); + const node = transformer.getById('root'); + expect(node!.props.id).toBeUndefined(); + expect(node!.props.component).toBeUndefined(); + expect(node!.props.child).toBeUndefined(); + expect(node!.props.extraProp).toBe('value'); + }); + + it('should return null when no root component in v0.9', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'notRoot', + component: 'Text', + }, + ]; + + const result = transformer.transform(components, 'v0.9'); + expect(result).toBeNull(); + }); + + it('should not have children when no children or child defined', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Text', + text: 'Hello', + }, + ]; + + const result = transformer.transform(components, 'v0.9'); + expect(result!.children).toBeUndefined(); + }); + }); + + describe('getById', () => { + it('should return undefined for non-existent id', () => { + const transformer = createComponentTransformer(); + const result = transformer.getById('nonexistent'); + expect(result).toBeUndefined(); + }); + + it('should return correct node after transform', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'myNode', + component: 'Text', + text: 'Hello', + }, + ]; + + transformer.transform(components, 'v0.9'); + const node = transformer.getById('myNode'); + expect(node).toBeDefined(); + expect(node!.type).toBe('Text'); + }); + }); + + describe('reset', () => { + it('should clear all cached components', () => { + const transformer = createComponentTransformer(); + const components: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Container', + }, + ]; + + transformer.transform(components, 'v0.9'); + expect(transformer.getById('root')).toBeDefined(); + + transformer.reset(); + expect(transformer.getById('root')).toBeUndefined(); + }); + }); + + describe('transformToReactTree (deprecated)', () => { + it('should work as factory function result', () => { + const components: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Container', + }, + ]; + + // transformToReactTree 是默认导出 + const transformerModule = require('../format/components'); + const result = transformerModule.default(components, 'v0.9'); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + }); + + it('should default to v0.8 version', () => { + const components: ComponentWrapper_v0_8[] = [ + { + id: 'root', + component: { + Container: {}, + }, + }, + ]; + + const transformerModule = require('../format/components'); + const result = transformerModule.default(components); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Container'); + }); + }); + + describe('incremental transform', () => { + it('should merge new components with existing', () => { + const transformer = createComponentTransformer(); + const components1: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Container', + children: ['child1'], + }, + ]; + + transformer.transform(components1, 'v0.9'); + expect(transformer.getById('root')).toBeDefined(); + + const components2: BaseComponent_v0_9[] = [ + { + id: 'child1', + component: 'Text', + text: 'Hello', + }, + ]; + + transformer.transform(components2, 'v0.9'); + expect(transformer.getById('child1')).toBeDefined(); + expect(transformer.getById('root')).toBeDefined(); + }); + + it('should update existing component', () => { + const transformer = createComponentTransformer(); + const components1: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Text', + text: 'Hello', + }, + ]; + + transformer.transform(components1, 'v0.9'); + const node1 = transformer.getById('root'); + expect(node1!.props.text).toBe('Hello'); + + const components2: BaseComponent_v0_9[] = [ + { + id: 'root', + component: 'Text', + text: 'Updated', + }, + ]; + + transformer.transform(components2, 'v0.9'); + const node2 = transformer.getById('root'); + expect(node2!.props.text).toBe('Updated'); + }); + }); +}); diff --git a/packages/x-card/src/A2UI/__tests__/integration.test.tsx b/packages/x-card/src/A2UI/__tests__/integration.test.tsx new file mode 100644 index 0000000000..9cf87eb09e --- /dev/null +++ b/packages/x-card/src/A2UI/__tests__/integration.test.tsx @@ -0,0 +1,890 @@ +/** + * Box.tsx 和 Card.tsx 集成测试用例 + * 覆盖 Box, Card, NodeRenderer 组件的各种场景 + */ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import Box from '../Box'; +import Card from '../Card'; +import type { A2UICommand_v0_9 } from '../types/command_v0.9'; +import type { XAgentCommand_v0_8 } from '../types/command_v0.8'; +import { registerCatalog, clearCatalogCache } from '../catalog'; + +// Mock console +const originalConsole = { ...console }; +beforeEach(() => { + console.log = jest.fn(); + console.warn = jest.fn(); + console.error = jest.fn(); +}); + +afterEach(() => { + console.log = originalConsole.log; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + clearCatalogCache(); + mockFetch.mockClear(); +}); + +// Mock fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +// 简单测试组件 +const TestText: React.FC<{ + text?: string; + onAction?: (name: string, context: Record) => void; +}> = ({ text, onAction }) => ( +
onAction?.('click', { value: 'clicked' })}> + {text} +
+); + +const TestContainer: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( +
{children}
+); + +describe('Box Component', () => { + describe('v0.8', () => { + it('should render children without commands', () => { + render( + +
Child
+
, + ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('should render Card children', () => { + render( + + + , + ); + // Card 应该被渲染,但内容为空(没有 commands) + }); + + it('should provide context with v0.8 default version', () => { + const TestComponent = () => { + const context = React.useContext(require('../Context').default) as { + commandQueue: any[]; + }; + // v0.8 是默认版本,当没有命令或命令中没有 version 字段时 + return
{context.commandQueue.length === 0 ? 'v0.8' : ''}
; + }; + + render( + + + , + ); + expect(screen.getByTestId('version').textContent).toBe('v0.8'); + }); + + it('should handle surfaceUpdate command', async () => { + const commands: XAgentCommand_v0_8[] = [ + { + surfaceUpdate: { + surfaceId: 'card1', + components: [ + { + id: 'root', + component: { + TestContainer: { + children: ['text1'], + }, + }, + }, + { + id: 'text1', + component: { + TestText: { + text: 'Hello World', + }, + }, + }, + ], + }, + }, + ]; + + const components = { + TestContainer, + TestText, + }; + + render( + + + , + ); + + // v0.8 需要 beginRendering 才会渲染 + // 没有 beginRendering,不应该渲染内容 + }); + + it('should handle beginRendering command', async () => { + const commands: XAgentCommand_v0_8[] = [ + { + beginRendering: { + surfaceId: 'card1', + root: 'root', + }, + }, + ]; + + render( + + + , + ); + + // 没有 surfaceUpdate,root 不存在 + }); + + it('should handle dataModelUpdate command', async () => { + const TestComponent: React.FC<{ value?: string }> = ({ value }) => ( +
{value || 'empty'}
+ ); + + // 先通过 surfaceUpdate 设置组件 + const { rerender } = render( + + + , + ); + + // 更新 dataModel + rerender( + + + , + ); + }); + + it('should handle deleteSurface command', async () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByTestId('test-container')).toBeInTheDocument(); + + // 删除 surface + rerender( + + + , + ); + + expect(screen.queryByTestId('test-container')).not.toBeInTheDocument(); + }); + }); + + describe('v0.9', () => { + it('should detect v0.9 version from commands', () => { + const TestComponent = () => { + const context = React.useContext(require('../Context').default) as { + commandQueue: any[]; + }; + // 检查命令队列中是否有 v0.9 版本的命令 + const hasV09 = context.commandQueue.some( + (cmd: any) => 'version' in cmd && cmd.version === 'v0.9', + ); + return
{hasV09 ? 'v0.9' : ''}
; + }; + + const commands: A2UICommand_v0_9[] = [ + { + version: 'v0.9', + updateComponents: { + surfaceId: 'card1', + components: [], + }, + }, + ]; + + render( + + + , + ); + expect(screen.getByTestId('version').textContent).toBe('v0.9'); + }); + + it('should handle updateComponents command', async () => { + const commands: A2UICommand_v0_9[] = [ + { + version: 'v0.9', + updateComponents: { + surfaceId: 'card1', + components: [ + { + id: 'root', + component: 'TestContainer', + children: ['text1'], + }, + { + id: 'text1', + component: 'TestText', + text: 'Hello v0.9', + }, + ], + }, + }, + ]; + + render( + + + , + ); + + expect(screen.getByTestId('test-container')).toBeInTheDocument(); + expect(screen.getByText('Hello v0.9')).toBeInTheDocument(); + }); + + it('should handle updateDataModel command', async () => { + const TestComponent: React.FC<{ value?: string }> = ({ value }) => ( +
{value || 'empty'}
+ ); + + // 先渲染组件 + const { rerender } = render( + + + , + ); + + // 初始应该显示 empty(因为 dataModel 中没有值) + expect(screen.getByTestId('data-value').textContent).toBe('empty'); + + // 更新 dataModel + rerender( + + + , + ); + + // 注意:由于 React 的状态更新机制,这里可能需要 waitFor + }); + + it('should handle deleteSurface command in v0.9', async () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByTestId('test-container')).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.queryByTestId('test-container')).not.toBeInTheDocument(); + }); + + it('should handle createSurface command', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + $id: 'test-catalog', + components: { + TestContainer: { type: 'object' }, + }, + }), + }); + + render( + + + , + ); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('https://example.com/catalog.json'); + }); + }); + + it('should handle createSurface with local catalog', async () => { + // 在测试前注册 catalog + const localCatalogId = 'local://my-catalog-2'; + registerCatalog({ + $id: localCatalogId, + components: { + TestContainer: { type: 'object' }, + }, + }); + + // 先创建一个 Box,触发 createSurface + render( + + + , + ); + + // 等待 catalog 加载 + await waitFor(() => { + // 由于已经在缓存中,fetch 不应该被调用 + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('onAction callback', () => { + it('should call onAction when component triggers action', async () => { + const onAction = jest.fn(); + + const ClickableComponent: React.FC<{ onAction?: (name: string, ctx: any) => void }> = ({ + onAction: componentOnAction, + }) => ( + + ); + + render( + + + , + ); + + const btn = screen.getByTestId('click-btn'); + btn.click(); + + await waitFor(() => { + expect(onAction).toHaveBeenCalled(); + }); + }); + + it('should pass action name and surfaceId in callback', async () => { + const onAction = jest.fn(); + + const ActionComponent: React.FC<{ onAction?: (name: string, ctx: any) => void }> = ({ + onAction: componentOnAction, + }) => ( + + ); + + render( + + + , + ); + + screen.getByTestId('action-btn').click(); + + await waitFor(() => { + expect(onAction).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'submit', + surfaceId: 'test-surface', + }), + ); + }); + }); + }); + + describe('catalog validation', () => { + it('should validate component against catalog in development', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + registerCatalog({ + $id: 'validation-catalog', + components: { + ValidComponent: { + type: 'object', + required: ['requiredField'], + properties: { + requiredField: { type: 'string' }, + }, + }, + }, + }); + + const ValidComponent: React.FC<{ requiredField?: string }> = ({ requiredField }) => ( +
{requiredField}
+ ); + + render( + + + , + ); + + // 应该有警告输出 + await waitFor(() => { + expect(console.warn).toHaveBeenCalled(); + }); + + process.env.NODE_ENV = originalEnv; + }); + + it('should warn when component not registered but in catalog', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + registerCatalog({ + $id: 'missing-component-catalog', + components: { + MissingComponent: { type: 'object' }, + }, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(console.warn).toHaveBeenCalled(); + }); + + process.env.NODE_ENV = originalEnv; + }); + + it('should error when component not in catalog and not registered', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + // 首先创建一个带有 catalog 的 surface + registerCatalog({ + $id: 'error-test-catalog', + components: { + // 空的 catalog,不包含 UnknownComponent + }, + }); + + const { rerender } = render( + + + , + ); + + // 等待 catalog 加载完成(使用 setTimeout 让 useEffect 执行完成) + await waitFor(() => { + // 由于 catalog 已通过 registerCatalog 注册,loadCatalog 会直接返回缓存的 catalog + // 不需要检查 console.log,只需等待足够时间让 effect 执行 + expect(true).toBe(true); + }); + + // 然后发送 updateComponents 命令 + rerender( + + + , + ); + + await waitFor(() => { + expect(console.error).toHaveBeenCalled(); + }); + + process.env.NODE_ENV = originalEnv; + }); + }); + + describe('multiple cards', () => { + it('should render multiple cards independently', async () => { + render( + + + + , + ); + + // card1 应该渲染内容 + expect(screen.getByText('Card 1')).toBeInTheDocument(); + // card2 没有收到命令,不应该渲染 + }); + + it('should handle different cards with different commands', async () => { + const { rerender } = render( + + + + , + ); + + expect(screen.getByText('Card 1')).toBeInTheDocument(); + + // 更新 card2 + rerender( + + + + , + ); + + // card1 内容应该保留(被缓存在 transformer 中) + // card2 应该渲染新内容 + }); + }); + + describe('data binding', () => { + it('should resolve data binding in v0.9', async () => { + const DataComponent: React.FC<{ value?: string }> = ({ value }) => ( +
{value || 'no value'}
+ ); + + const { rerender } = render( + + + , + ); + + // 初始没有值 + expect(screen.getByTestId('bound-value').textContent).toBe('no value'); + + // 更新 dataModel + rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('bound-value').textContent).toBe('Alice'); + }); + }); + }); +}); diff --git a/packages/x-card/src/A2UI/__tests__/utils.test.ts b/packages/x-card/src/A2UI/__tests__/utils.test.ts new file mode 100644 index 0000000000..5c2567d6e4 --- /dev/null +++ b/packages/x-card/src/A2UI/__tests__/utils.test.ts @@ -0,0 +1,312 @@ +/** + * utils.ts 测试用例 + * 覆盖 getValueByPath, setValueByPath, isPathValue, isPathObject, validateComponentAgainstCatalog + */ +import { + getValueByPath, + setValueByPath, + isPathValue, + isPathObject, + validateComponentAgainstCatalog, +} from '../utils'; + +describe('getValueByPath', () => { + it('should get top-level value', () => { + const obj = { name: 'Alice' }; + expect(getValueByPath(obj, '/name')).toBe('Alice'); + }); + + it('should get nested value', () => { + const obj = { booking: { date: '2024-01-01' } }; + expect(getValueByPath(obj, '/booking/date')).toBe('2024-01-01'); + }); + + it('should get deeply nested value', () => { + const obj = { a: { b: { c: 42 } } }; + expect(getValueByPath(obj, '/a/b/c')).toBe(42); + }); + + it('should return undefined for missing path', () => { + const obj = { name: 'Alice' }; + expect(getValueByPath(obj, '/missing')).toBeUndefined(); + }); + + it('should return undefined for nested missing path', () => { + const obj = { a: {} }; + expect(getValueByPath(obj, '/a/b/c')).toBeUndefined(); + }); + + it('should handle path without leading slash', () => { + const obj = { name: 'Alice' }; + expect(getValueByPath(obj, 'name')).toBe('Alice'); + }); + + it('should return null value correctly', () => { + const obj = { value: null }; + expect(getValueByPath(obj, '/value')).toBeNull(); + }); + + it('should return false value correctly', () => { + const obj = { flag: false }; + expect(getValueByPath(obj, '/flag')).toBe(false); + }); + + it('should return 0 value correctly', () => { + const obj = { count: 0 }; + expect(getValueByPath(obj, '/count')).toBe(0); + }); +}); + +describe('setValueByPath', () => { + it('should set top-level value', () => { + const obj = { name: 'Alice' }; + const result = setValueByPath(obj, '/name', 'Bob'); + expect(result.name).toBe('Bob'); + }); + + it('should set nested value', () => { + const obj = { booking: { date: '2024-01-01' } }; + const result = setValueByPath(obj, '/booking/date', '2024-12-31'); + expect(result.booking.date).toBe('2024-12-31'); + }); + + it('should create nested path if not exists', () => { + const obj = {}; + const result = setValueByPath(obj, '/a/b/c', 'value'); + expect((result as any).a.b.c).toBe('value'); + }); + + it('should be immutable (not mutate original)', () => { + const obj = { name: 'Alice' }; + const result = setValueByPath(obj, '/name', 'Bob'); + expect(obj.name).toBe('Alice'); + expect(result.name).toBe('Bob'); + }); + + it('should handle path without leading slash', () => { + const obj = { name: 'Alice' }; + const result = setValueByPath(obj, 'name', 'Bob'); + expect(result.name).toBe('Bob'); + }); + + it('should preserve other keys', () => { + const obj = { name: 'Alice', age: 30 }; + const result = setValueByPath(obj, '/name', 'Bob'); + expect(result.age).toBe(30); + }); + + it('should set null value', () => { + const obj = { name: 'Alice' }; + const result = setValueByPath(obj, '/name', null); + expect(result.name).toBeNull(); + }); + + it('should handle deeply nested existing path', () => { + const obj = { a: { b: { c: 1, d: 2 } } }; + const result = setValueByPath(obj, '/a/b/c', 99); + expect((result as any).a.b.c).toBe(99); + expect((result as any).a.b.d).toBe(2); + }); +}); + +describe('isPathValue', () => { + it('should return true for string starting with /', () => { + expect(isPathValue('/user/name')).toBe(true); + }); + + it('should return true for simple path', () => { + expect(isPathValue('/name')).toBe(true); + }); + + it('should return false for string not starting with /', () => { + expect(isPathValue('name')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isPathValue('')).toBe(false); + }); + + it('should return false for number', () => { + expect(isPathValue(42)).toBe(false); + }); + + it('should return false for null', () => { + expect(isPathValue(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isPathValue(undefined)).toBe(false); + }); + + it('should return false for object', () => { + expect(isPathValue({ path: '/name' })).toBe(false); + }); + + it('should return false for boolean', () => { + expect(isPathValue(true)).toBe(false); + }); +}); + +describe('isPathObject', () => { + it('should return true for { path: string }', () => { + expect(isPathObject({ path: '/user/name' })).toBe(true); + }); + + it('should return true for path object with other fields', () => { + expect(isPathObject({ path: '/name', extra: 'value' })).toBe(true); + }); + + it('should return false for null', () => { + expect(isPathObject(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isPathObject(undefined)).toBe(false); + }); + + it('should return false for string', () => { + expect(isPathObject('/name')).toBe(false); + }); + + it('should return false for object without path', () => { + expect(isPathObject({ name: 'Alice' })).toBe(false); + }); + + it('should return false for object with non-string path', () => { + expect(isPathObject({ path: 42 })).toBe(false); + }); + + it('should return false for array', () => { + expect(isPathObject(['/name'])).toBe(false); + }); + + it('should return false for number', () => { + expect(isPathObject(42)).toBe(false); + }); +}); + +describe('validateComponentAgainstCatalog', () => { + it('should return valid when no catalog', () => { + const result = validateComponentAgainstCatalog(undefined, 'Button', { label: 'Click' }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return valid when catalog has no components', () => { + const result = validateComponentAgainstCatalog({}, 'Button', { label: 'Click' }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return invalid when component not in catalog', () => { + const catalog = { components: { Input: { type: 'object' as const } } }; + const result = validateComponentAgainstCatalog(catalog, 'Button', {}); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Button'); + }); + + it('should return valid when component in catalog with no required fields', () => { + const catalog = { + components: { + Button: { type: 'object' as const, properties: { label: { type: 'string' } } }, + }, + }; + const result = validateComponentAgainstCatalog(catalog, 'Button', { label: 'Click' }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return errors for missing required fields', () => { + const catalog = { + components: { + Button: { + type: 'object' as const, + required: ['label'], + properties: { label: { type: 'string' } }, + }, + }, + }; + const result = validateComponentAgainstCatalog(catalog, 'Button', {}); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('label'))).toBe(true); + }); + + it('should return errors for undefined props not in schema', () => { + const catalog = { + components: { + Button: { + type: 'object' as const, + properties: { label: { type: 'string' } }, + }, + }, + }; + const result = validateComponentAgainstCatalog(catalog, 'Button', { + label: 'Click', + unknownProp: 'value', + }); + expect(result.errors.some((e) => e.includes('unknownProp'))).toBe(true); + }); + + it('should ignore id, children, component fields in prop validation', () => { + const catalog = { + components: { + Button: { + type: 'object' as const, + properties: { label: { type: 'string' } }, + }, + }, + }; + const result = validateComponentAgainstCatalog(catalog, 'Button', { + label: 'Click', + id: 'btn1', + children: [], + component: 'Button', + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should handle multiple required fields', () => { + const catalog = { + components: { + Form: { + type: 'object' as const, + required: ['title', 'onSubmit'], + properties: { + title: { type: 'string' }, + onSubmit: { type: 'object' }, + }, + }, + }, + }; + const result = validateComponentAgainstCatalog(catalog, 'Form', {}); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(2); + }); + + it('should return valid when all required fields present', () => { + const catalog = { + components: { + Button: { + type: 'object' as const, + required: ['label'], + properties: { label: { type: 'string' } }, + }, + }, + }; + const result = validateComponentAgainstCatalog(catalog, 'Button', { label: 'Click' }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should handle component with no properties defined', () => { + const catalog = { + components: { + Button: { type: 'object' as const }, + }, + }; + const result = validateComponentAgainstCatalog(catalog, 'Button', { label: 'Click' }); + expect(result.valid).toBe(true); + }); +}); diff --git a/packages/x-card/src/A2UI/catalog.ts b/packages/x-card/src/A2UI/catalog.ts new file mode 100644 index 0000000000..e7853c358c --- /dev/null +++ b/packages/x-card/src/A2UI/catalog.ts @@ -0,0 +1,129 @@ +/** + * Catalog 管理模块 + * 用于加载和管理远程/本地组件定义 + */ + +/** Catalog 组件定义 */ +export interface CatalogComponent { + type: 'object'; + allOf?: any[]; + properties?: Record; + required?: string[]; + [key: string]: any; +} + +/** Catalog 定义 */ +export interface Catalog { + $schema?: string; + $id?: string; + title?: string; + description?: string; + catalogId?: string; + components?: Record; + functions?: Record; + $defs?: Record; +} + +/** Catalog 缓存 */ +const catalogCache = new Map(); + +/** + * 注册本地 catalog + * @param catalog catalog 定义 + */ +export function registerCatalog(catalog: Catalog): void { + const catalogId = catalog.$id || catalog.catalogId; + if (catalogId) { + catalogCache.set(catalogId, catalog); + } +} + +/** + * 加载 catalog(支持远程 URL 或本地注册的 schema) + * @param catalogId catalog 的 URL 或本地 ID + * @returns catalog 定义 + */ +export async function loadCatalog(catalogId: string): Promise { + // 检查缓存 + if (catalogCache.has(catalogId)) { + return catalogCache.get(catalogId)!; + } + + // 检查是否是本地 schema + if (catalogId.startsWith('local://')) { + console.warn( + `Local catalog "${catalogId}" not registered. Use registerCatalog() to register it.`, + ); + // 返回空 catalog + return { + $id: catalogId, + components: {}, + }; + } + + // 从远程加载 + try { + const response = await fetch(catalogId); + if (!response.ok) { + throw new Error(`Failed to load catalog from ${catalogId}: ${response.statusText}`); + } + + const catalog: Catalog = await response.json(); + catalogCache.set(catalogId, catalog); + return catalog; + } catch (error) { + console.error('Error loading catalog:', error); + throw error; + } +} + +/** + * 验证组件是否符合 catalog 定义 + * @param catalog catalog 定义 + * @param componentName 组件名称 + * @param componentProps 组件属性 + * @returns 是否有效 + */ +export function validateComponent( + catalog: Catalog, + componentName: string, + componentProps: Record, +): boolean { + const componentDef = catalog.components?.[componentName]; + if (!componentDef) { + console.warn(`Component "${componentName}" not found in catalog`); + return false; + } + + // 简单的必填字段验证 + if (componentDef.required) { + for (const field of componentDef.required) { + if (!(field in componentProps)) { + console.warn(`Missing required field "${field}" for component "${componentName}"`); + return false; + } + } + } + + return true; +} + +/** + * 获取组件的属性定义 + * @param catalog catalog 定义 + * @param componentName 组件名称 + * @returns 属性定义 + */ +export function getComponentPropsSchema( + catalog: Catalog, + componentName: string, +): Record | undefined { + return catalog.components?.[componentName]?.properties; +} + +/** + * 清除 catalog 缓存 + */ +export function clearCatalogCache(): void { + catalogCache.clear(); +} diff --git a/packages/x-card/src/A2UI/format/components.ts b/packages/x-card/src/A2UI/format/components.ts new file mode 100644 index 0000000000..f60b1b3074 --- /dev/null +++ b/packages/x-card/src/A2UI/format/components.ts @@ -0,0 +1,149 @@ +import type { ComponentWrapper_v0_8 } from '../types/command_v0.8'; +import type { BaseComponent_v0_9 } from '../types/command_v0.9'; + +export interface ReactComponentTree { + type: string; + props: Record; // 不含 id、component、children、child 等结构性字段;{ path } 绑定已提取为路径字符串 + children?: string[]; // 子节点 id 列表,由调用方去 componentMap 中查找对应节点 +} + +// ─── 内部解析辅助 ──────────────────────────────────────────────────────────── + +/** 判断一个值是否为 { path: string } 形式的路径对象 */ +function isPathObject(val: any): val is { path: string } { + return val !== null && typeof val === 'object' && typeof val.path === 'string'; +} + +/** 判断一个值是否为 { literalString: string } 形式的字面字符串对象(v0.8 特有) */ +function isLiteralStringValue(val: any): val is { literalString: string } { + return val !== null && typeof val === 'object' && typeof val.literalString === 'string'; +} + +/** 判断一个值是否为 { explicitList: string[] } 形式(v0.8 children 格式) */ +function isExplicitList(val: any): val is { explicitList: string[] } { + return val !== null && typeof val === 'object' && Array.isArray(val.explicitList); +} + +function parseV08Node(comp: ComponentWrapper_v0_8): ReactComponentTree { + const [type, config] = Object.entries(comp.component)[0]; + + const props: Record = {}; + for (const [key, val] of Object.entries(config)) { + if (['child', 'children'].includes(key)) continue; + // v0.8 中支持三种格式: + // 1. { path: string } - 数据绑定路径 + // 2. { literalString: string } - 字面字符串 + // 3. 字面值直接使用 + if (isPathObject(val)) { + props[key] = val.path; + } else if (isLiteralStringValue(val)) { + props[key] = val.literalString; + } else { + props[key] = val; + } + } + + // 解析 children 字段,支持 string[] 或 { explicitList: string[] } + let childIds: string[] = []; + if (config.children) { + if (isExplicitList(config.children)) { + childIds = config.children.explicitList; + } else if (Array.isArray(config.children)) { + childIds = config.children; + } + } else if (config.child) { + childIds = [config.child]; + } + + return { + type, + props, + ...(childIds.length > 0 && { children: childIds }), + }; +} + +function parseV09Node(comp: BaseComponent_v0_9): ReactComponentTree { + const type = comp.component; + + const props: Record = {}; + for (const [key, val] of Object.entries(comp)) { + // 跳过结构性内部字段,其余字段(包括 value)统一处理 + if (['id', 'component', 'child', 'children'].includes(key)) continue; + // 字面值直接使用,{ path } 形式提取路径字符串供 resolveProps 解析 + props[key] = isPathObject(val) ? val.path : val; + } + + const childIds: string[] = comp.children ?? (comp.child ? [comp.child] : []); + + return { + type, + props, + ...(childIds.length > 0 && { children: childIds }), + }; +} + +// ─── 工厂函数 ──────────────────────────────────────────────────────────────── + +export interface ComponentTransformer { + /** + * 将新增/更新的 components 合并进内部缓存,并返回以 root 节点为根的树。 + * 若缓存中尚无 id === 'root' 的节点则返回 null。 + */ + transform( + components: ComponentWrapper_v0_8[] | BaseComponent_v0_9[], + version?: 'v0.8' | 'v0.9', + ): ReactComponentTree | null; + + /** 按 id 查找已缓存的节点,不存在时返回 undefined */ + getById(id: string): ReactComponentTree | undefined; + + /** 清空内部缓存 */ + reset(): void; +} + +export function createComponentTransformer(): ComponentTransformer { + // id → 解析后的节点(children 为子节点 id 列表) + const componentMap = new Map(); + + function transform( + components: ComponentWrapper_v0_8[] | BaseComponent_v0_9[], + version: 'v0.8' | 'v0.9' = 'v0.8', + ): ReactComponentTree | null { + if (!Array.isArray(components) || components.length === 0) { + return componentMap.get('root') ?? null; + } + + if (version === 'v0.8') { + for (const comp of components as ComponentWrapper_v0_8[]) { + componentMap.set(comp.id, parseV08Node(comp)); + } + } else { + for (const comp of components as BaseComponent_v0_9[]) { + componentMap.set(comp.id, parseV09Node(comp)); + } + } + + return componentMap.get('root') ?? null; + } + + function getById(id: string): ReactComponentTree | undefined { + return componentMap.get(id); + } + + function reset(): void { + componentMap.clear(); + } + + return { transform, getById, reset }; +} + +// ─── 向后兼容的默认导出 ─────────────────────────────────────────────────────── + +/** @deprecated 请使用 createComponentTransformer() 工厂函数 */ +export default function transformToReactTree( + componentsCommand: ComponentWrapper_v0_8[] | BaseComponent_v0_9[], + version: 'v0.8' | 'v0.9' = 'v0.8', +): ReactComponentTree | null { + const transformer = createComponentTransformer(); + return transformer.transform(componentsCommand, version); +} diff --git a/packages/x-card/src/A2UI/index.ts b/packages/x-card/src/A2UI/index.ts new file mode 100644 index 0000000000..6444eda5ff --- /dev/null +++ b/packages/x-card/src/A2UI/index.ts @@ -0,0 +1,11 @@ +import Card from './Card'; +import Box from './Box'; +export type { BoxProps, ActionPayload } from './types/box'; +export type { XAgentCommand_v0_9 } from './types/command_v0.9'; +export type { XAgentCommand_v0_8 } from './types/command_v0.8'; +export type { Catalog, CatalogComponent } from './catalog'; +export { loadCatalog, registerCatalog, validateComponent, clearCatalogCache } from './catalog'; + +const XCard = { Card, Box }; + +export default XCard; diff --git a/packages/x-card/src/A2UI/types/box.ts b/packages/x-card/src/A2UI/types/box.ts new file mode 100644 index 0000000000..be61e261bd --- /dev/null +++ b/packages/x-card/src/A2UI/types/box.ts @@ -0,0 +1,31 @@ +import type { XAgentCommand_v0_8 } from './command_v0.8'; +import type { A2UICommand_v0_9 } from './command_v0.9'; + +type ComponentName = `${Uppercase}${string}`; + +/** action 事件载荷,由 Button 等组件触发 */ +export interface ActionPayload { + /** 事件名称,对应 action.event.name */ + name: string; + /** 触发该 action 的 surfaceId */ + surfaceId: string; + /** 当前 surface 的完整 dataModel 快照,作为 context */ + context: Record; +} + +export interface BoxProps { + children?: React.ReactNode; + /** + * @description 配置组件需要遵循 React 组件规范, 组件名称必须以大写字母开头 + */ + components?: Record>; + /** + * @description 命令队列,每次追加新命令到数组末尾,Box 按顺序处理所有命令。 + * 使用数组而非单值,避免 React 批量更新时同一渲染周期内多条命令被合并丢失。 + */ + commands?: (A2UICommand_v0_9 | XAgentCommand_v0_8)[]; + /** + * @description 当 Card 内部组件触发 action 时回调,携带事件名称、surfaceId 及当前数据快照 + */ + onAction?: (payload: ActionPayload) => void; +} diff --git a/packages/x-card/src/A2UI/types/command_v0.8.ts b/packages/x-card/src/A2UI/types/command_v0.8.ts new file mode 100644 index 0000000000..341cdb58de --- /dev/null +++ b/packages/x-card/src/A2UI/types/command_v0.8.ts @@ -0,0 +1,80 @@ +// A2UI Component System v0.8 Type Definitions +// Flexible component system supporting dynamic component types + +/** 数据绑定路径对象 */ +export interface PathValue { + path: string; +} + +/** 字面字符串值对象(v0.8 特有) */ +export interface LiteralStringValue { + literalString: string; +} + +/** v0.8 children 字段格式,支持数组或 explicitList 对象 */ +export interface ExplicitList { + explicitList: string[]; +} + +// Component wrapper structure with standard fields and custom properties +export interface ComponentWrapper_v0_8 { + id: string; + component: { + [componentType: string]: { + // Standard fields for component relationships + child?: string; + children?: string[] | ExplicitList; + // 任何字段均支持字面值或 PathValue / LiteralStringValue 数据绑定形式 + // 例:{ "text": "Hello" } 或 { "text": { "path": "/user/name" } } 或 { "text": { "literalString": "Hello" } } + [key: string]: any; + }; + }; +} + +// Command to update surface components +interface SurfaceUpdateCommand { + surfaceUpdate: { + surfaceId: string; + components: ComponentWrapper_v0_8[]; + }; +} + +// Command to update data model +interface DataModelUpdateCommand { + dataModelUpdate: { + surfaceId: string; + contents: Array<{ + key: string; + valueString?: string; + valueMap?: Array<{ + key: string; + valueString: string; + }>; + }>; + }; +} + +// Command to begin rendering +interface BeginRenderingCommand { + beginRendering: { + surfaceId: string; + root: string; // Root component ID + }; +} + +// Command to delete a surface +interface DeleteSurfaceCommand { + deleteSurface: { + surfaceId: string; + }; +} + +// Union type for all possible commands +export type A2UICommand_v0_8 = + | SurfaceUpdateCommand + | DataModelUpdateCommand + | BeginRenderingCommand + | DeleteSurfaceCommand; + +// Backward compatible type alias +export type XAgentCommand_v0_8 = A2UICommand_v0_8; diff --git a/packages/x-card/src/A2UI/types/command_v0.9.ts b/packages/x-card/src/A2UI/types/command_v0.9.ts new file mode 100644 index 0000000000..43eec9878f --- /dev/null +++ b/packages/x-card/src/A2UI/types/command_v0.9.ts @@ -0,0 +1,87 @@ +// A2UI Command System v0.9 Type Definitions +// Structured command system with explicit versioning and strict typing + +/** 数据绑定路径对象,任何组件字段均可使用此形式实现响应式绑定 */ +export interface PathValue { + path: string; +} + +/** Action 事件的 context 字段结构,支持路径绑定 */ +export interface ActionContext { + /** context 中的每个字段都可以是路径绑定或字面值 */ + [key: string]: PathValue | any; +} + +/** Action 事件定义 */ +export interface ActionEvent { + /** 事件名称 */ + name: string; + /** + * 事件上下文,包含需要上报的数据路径绑定 + * 组件触发 action 时,会自动将组件传递的值写入这些路径 + */ + context?: ActionContext; +} + +/** Action 配置 */ +export interface ActionConfig { + /** 事件配置 */ + event?: ActionEvent; +} + +// Base component structure for v0.9 +export interface BaseComponent_v0_9 { + id: string; + component: string; // Component type identifier + child?: string; + children?: string[]; // Reference to children component ID + // 任何字段均支持字面值或 PathValue({ path: string })数据绑定形式 + // 例:{ "text": "Hello" } 或 { "text": { "path": "/user/name" } } + [key: string]: any; +} + +// Command to create a new surface +interface CreateSurfaceCommand { + version: 'v0.9'; + createSurface: { + surfaceId: string; + catalogId: string; // 必需,组件目录 URL 或本地标识 + }; +} + +// Command to update components on a surface +interface UpdateComponentsCommand { + version: 'v0.9'; + updateComponents: { + surfaceId: string; + components: BaseComponent_v0_9[]; + }; +} + +// Command to update data model +interface UpdateDataModelCommand { + version: 'v0.9'; + updateDataModel: { + surfaceId: string; + path: string; + value: any; + }; +} + +// Command to delete a surface +interface DeleteSurfaceCommand { + version: 'v0.9'; + deleteSurface: { + surfaceId: string; + }; +} + +// Union type for all possible commands +export type A2UICommand_v0_9 = + | CreateSurfaceCommand + | UpdateComponentsCommand + | UpdateDataModelCommand + | DeleteSurfaceCommand; + +// Backward compatible type alias +export type XAgentCommand_v0_9 = A2UICommand_v0_9; diff --git a/packages/x-card/src/A2UI/utils.ts b/packages/x-card/src/A2UI/utils.ts new file mode 100644 index 0000000000..d091a5a84a --- /dev/null +++ b/packages/x-card/src/A2UI/utils.ts @@ -0,0 +1,90 @@ +/** + * Card shared utility functions + * Utility functions shared by v0.8 and v0.9 versions + */ + +/** Get value from nested object by path, path format like /booking/date */ +export function getValueByPath(obj: Record, path: string): any { + const parts = path.replace(/^\//, '').split('/'); + return parts.reduce((cur, key) => (cur != null ? cur[key] : undefined), obj as any); +} + +/** Write value to nested object by path (immutable), path format like /booking/selectedCoffee */ +export function setValueByPath( + obj: Record, + path: string, + value: any, +): Record { + const parts = path.replace(/^\//, '').split('/'); + const next = { ...obj }; + let cur: Record = next; + for (let i = 0; i < parts.length - 1; i++) { + cur[parts[i]] = cur[parts[i]] ? { ...cur[parts[i]] } : {}; + cur = cur[parts[i]]; + } + cur[parts[parts.length - 1]] = value; + return next; +} + +/** Check if string is a data binding path (starts with /) */ +export function isPathValue(val: any): val is string { + return typeof val === 'string' && val.startsWith('/'); +} + +/** Check if a value is a path object in { path: string } format */ +export function isPathObject(val: any): val is { path: string } { + return val !== null && typeof val === 'object' && typeof val.path === 'string'; +} + +/** + * Validate if component conforms to catalog definition + * @param catalog catalog definition + * @param componentName component name + * @param componentProps component properties + * @returns { valid: boolean, errors: string[] } + */ +export function validateComponentAgainstCatalog( + catalog: any, + componentName: string, + componentProps: Record, +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // If no catalog, pass by default + if (!catalog || !catalog.components) { + return { valid: true, errors: [] }; + } + + // Check if component is defined in catalog + const componentDef = catalog.components[componentName]; + if (!componentDef) { + errors.push(`Component "${componentName}" is not defined in catalog`); + return { valid: false, errors }; + } + + // Check required fields + const requiredFields = componentDef.required || []; + for (const field of requiredFields) { + if (!(field in componentProps)) { + errors.push(`Missing required field "${field}" for component "${componentName}"`); + } + } + + // Check if properties are defined in schema (warning level, does not block rendering) + if (componentDef.properties) { + const definedProps = Object.keys(componentDef.properties); + const actualProps = Object.keys(componentProps).filter( + (key) => !['id', 'children', 'component'].includes(key), + ); + + for (const prop of actualProps) { + if (!definedProps.includes(prop)) { + errors.push( + `Warning: Property "${prop}" is not defined in catalog for component "${componentName}"`, + ); + } + } + } + + return { valid: errors.length === 0, errors }; +} diff --git a/packages/x-card/src/index.ts b/packages/x-card/src/index.ts new file mode 100644 index 0000000000..2f6dfdda9c --- /dev/null +++ b/packages/x-card/src/index.ts @@ -0,0 +1,15 @@ +export { default as version } from './version'; +export { + default as XCard, + type XAgentCommand_v0_9, + type XAgentCommand_v0_8, + type ActionPayload, + type Catalog, + type CatalogComponent, + registerCatalog, + loadCatalog, + validateComponent, + clearCatalogCache, +} from './A2UI'; +export { default as Card } from './A2UI/Card'; +export { default as Box } from './A2UI/Box'; diff --git a/packages/x-card/src/version.ts b/packages/x-card/src/version.ts new file mode 100644 index 0000000000..e0291fa0ee --- /dev/null +++ b/packages/x-card/src/version.ts @@ -0,0 +1,2 @@ +// This file is auto generated by npm run version +export default '2.3.0-beta.2'; diff --git a/packages/x-card/tests/dekko/index.test.ts b/packages/x-card/tests/dekko/index.test.ts new file mode 100644 index 0000000000..ad1326e3c1 --- /dev/null +++ b/packages/x-card/tests/dekko/index.test.ts @@ -0,0 +1,7 @@ +import $ from 'dekko'; + +$('lib').isDirectory().hasFile('index.js').hasFile('index.d.ts'); + +$('es').isDirectory().hasFile('index.js').hasFile('index.d.ts'); + +$('dist').isDirectory().hasFile('x-card.js').hasFile('x-card.min.js'); diff --git a/packages/x-card/tests/setup.ts b/packages/x-card/tests/setup.ts new file mode 100644 index 0000000000..9424ab49d1 --- /dev/null +++ b/packages/x-card/tests/setup.ts @@ -0,0 +1,47 @@ +/* eslint-disable no-console */ +import util from 'util'; + +type Writeable = { -readonly [P in keyof T]: T[P] }; + +// This function can not move to external file since jest setup not support +export function fillWindowEnv(window: Window) { + const win = window as Writeable & typeof globalThis; + win.resizeTo = (width, height) => { + win.innerWidth = width || win.innerWidth; + win.innerHeight = height || win.innerHeight; + win.dispatchEvent(new Event('resize')); + }; + win.scrollTo = () => {}; + // ref: https://github.com/ant-design/ant-design/issues/18774 + if (!win.matchMedia) { + Object.defineProperty(win, 'matchMedia', { + writable: true, + configurable: true, + value: jest.fn((query) => ({ + matches: query.includes('max-width'), + addListener: jest.fn(), + removeListener: jest.fn(), + })), + }); + } + // Fix css-animation or @rc-component/motion deps on these + win.AnimationEvent = win.AnimationEvent || win.Event; + win.TransitionEvent = win.TransitionEvent || win.Event; + // ref: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + // ref: https://github.com/jsdom/jsdom/issues/2524 + Object.defineProperty(win, 'TextEncoder', { + writable: true, + value: util.TextEncoder, + }); + Object.defineProperty(win, 'TextDecoder', { + writable: true, + value: util.TextDecoder, + }); +} + +if (typeof window !== 'undefined') { + fillWindowEnv(window); +} + +global.requestAnimationFrame = global.requestAnimationFrame || global.setTimeout; +global.cancelAnimationFrame = global.cancelAnimationFrame || global.clearTimeout; diff --git a/packages/x-card/tsconfig.json b/packages/x-card/tsconfig.json new file mode 100644 index 0000000000..23ca6649d0 --- /dev/null +++ b/packages/x-card/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*", "typings/**/*"], + "exclude": ["node_modules", "lib", "es", "dist"] +} \ No newline at end of file diff --git a/packages/x-markdown/src/plugins/Latex/__tests__/__snapshots__/index.test.tsx.snap b/packages/x-markdown/src/plugins/Latex/__tests__/__snapshots__/index.test.tsx.snap index 946775fc19..2c194a7153 100644 --- a/packages/x-markdown/src/plugins/Latex/__tests__/__snapshots__/index.test.tsx.snap +++ b/packages/x-markdown/src/plugins/Latex/__tests__/__snapshots__/index.test.tsx.snap @@ -29,7 +29,7 @@ exports[`LaTeX Plugin should handle LaTeX with surrounding text 1`] = ` /> E @@ -137,7 +137,7 @@ exports[`LaTeX Plugin should handle align* syntax replacement 1`] = ` > @@ -205,10 +205,10 @@ exports[`LaTeX Plugin should handle align* syntax replacement 1`] = ` > y @@ -245,7 +245,7 @@ exports[`LaTeX Plugin should handle align* syntax replacement 1`] = ` y @@ -263,7 +263,7 @@ exports[`LaTeX Plugin should handle align* syntax replacement 1`] = ` > @@ -285,10 +285,10 @@ exports[`LaTeX Plugin should handle align* syntax replacement 1`] = ` > z @@ -326,7 +326,7 @@ exports[`LaTeX Plugin should handle align* syntax replacement 1`] = ` > @@ -581,7 +581,7 @@ exports[`LaTeX Plugin should handle complex LaTeX expressions 1`] = ` > j @@ -662,7 +662,7 @@ exports[`LaTeX Plugin should handle complex LaTeX expressions 1`] = ` > y @@ -691,7 +691,7 @@ exports[`LaTeX Plugin should handle complex LaTeX expressions 1`] = ` > j @@ -842,7 +842,7 @@ exports[`LaTeX Plugin should handle mixed LaTeX syntaxes 1`] = ` > @@ -919,7 +919,7 @@ exports[`LaTeX Plugin should handle mixed LaTeX syntaxes 1`] = ` /> f @@ -1198,7 +1198,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > v @@ -1230,7 +1230,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > P @@ -1293,7 +1293,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > v @@ -1362,7 +1362,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( /> β @@ -1392,7 +1392,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > v @@ -1421,7 +1421,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > P @@ -1473,7 +1473,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > v @@ -1571,7 +1571,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > v @@ -1603,7 +1603,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > P @@ -1666,7 +1666,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > v @@ -1735,7 +1735,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( /> β @@ -1765,7 +1765,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > v @@ -1794,7 +1794,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > P @@ -1846,7 +1846,7 @@ exports[`LaTeX Plugin should parse consecutive block formulas with indentation ( > v @@ -2181,7 +2181,7 @@ exports[`LaTeX Plugin should render inline LaTeX with $$\\n..\\n$$ syntax 1`] = /> f @@ -2264,7 +2264,7 @@ exports[`LaTeX Plugin should render inline LaTeX with $$\\n..\\n$$ syntax 1`] = y @@ -2301,7 +2301,7 @@ exports[`LaTeX Plugin should render inline LaTeX with $$\\n..\\n$$ syntax 1`] = f @@ -2384,7 +2384,7 @@ exports[`LaTeX Plugin should render inline LaTeX with $$\\n..\\n$$ syntax 1`] = f @@ -2395,7 +2395,7 @@ exports[`LaTeX Plugin should render inline LaTeX with $$\\n..\\n$$ syntax 1`] = y @@ -2442,7 +2442,7 @@ exports[`LaTeX Plugin should render inline LaTeX with $..$ syntax 1`] = ` /> E @@ -2588,7 +2588,7 @@ exports[`LaTeX Plugin should render inline LaTeX with [\\n..\\n] syntax 1`] = ` > C @@ -2599,13 +2599,13 @@ exports[`LaTeX Plugin should render inline LaTeX with [\\n..\\n] syntax 1`] = ` I P @@ -2624,7 +2624,7 @@ exports[`LaTeX Plugin should render inline LaTeX with [\\n..\\n] syntax 1`] = ` θ @@ -2756,7 +2756,7 @@ exports[`LaTeX Plugin should render inline LaTeX with [\\n..\\n] syntax 1`] = ` > r @@ -2817,7 +2817,7 @@ exports[`LaTeX Plugin should render inline LaTeX with [\\n..\\n] syntax 1`] = ` θ @@ -2955,7 +2955,7 @@ exports[`LaTeX Plugin should render inline LaTeX with [\\n..\\n] syntax 1`] = ` > r @@ -3016,7 +3016,7 @@ exports[`LaTeX Plugin should render inline LaTeX with [\\n..\\n] syntax 1`] = ` θ diff --git a/packages/x-skill/scripts/generate-meta.ts b/packages/x-skill/scripts/generate-meta.ts index 060aedefd2..7f13d6db19 100644 --- a/packages/x-skill/scripts/generate-meta.ts +++ b/packages/x-skill/scripts/generate-meta.ts @@ -268,4 +268,4 @@ if (require.main === module) { generateSkillMeta(); } -export { generateSkillMeta, scanSkills, extractSkillMetadata }; +export { extractSkillMetadata, generateSkillMeta, scanSkills }; diff --git a/packages/x/.dumi/theme/builtins/Sandpack/index.tsx b/packages/x/.dumi/theme/builtins/Sandpack/index.tsx index e84c9f2f26..f60ca75bc9 100644 --- a/packages/x/.dumi/theme/builtins/Sandpack/index.tsx +++ b/packages/x/.dumi/theme/builtins/Sandpack/index.tsx @@ -62,6 +62,7 @@ const Sandpack: React.FC> = ({ antd: '^6.1.1', '@ant-design/x': '^2.0.0', '@ant-design/x-markdown': '^2.0.0', + '@ant-design/x-card': '^2.0.0', '@ant-design/x-sdk': '^2.0.0', ...dependencies, }, diff --git a/packages/x/.dumi/theme/slots/Header/Navigation.tsx b/packages/x/.dumi/theme/slots/Header/Navigation.tsx index 7117a61b26..e4c29d5e1b 100644 --- a/packages/x/.dumi/theme/slots/Header/Navigation.tsx +++ b/packages/x/.dumi/theme/slots/Header/Navigation.tsx @@ -21,6 +21,7 @@ const locales = { blog: '博客', sdk: 'X SDK', skill: 'X Skill', + card: 'X Card', markdown: 'X Markdown', resources: '资源', }, @@ -33,6 +34,7 @@ const locales = { blog: 'Blog', sdk: 'X SDK', skill: 'X Skill', + card: 'X Card', markdown: 'X Markdown', resources: 'Resources', }, @@ -64,6 +66,11 @@ const defaultItems = [ basePath: '/x-sdk', key: 'sdk', }, + { + path: '/x-cards/introduce', + basePath: '/x-card', + key: 'card', + }, { path: '/x-skills/introduce', basePath: '/x-skill', diff --git a/packages/x/.dumirc.ts b/packages/x/.dumirc.ts index 8463059293..75adece52e 100644 --- a/packages/x/.dumirc.ts +++ b/packages/x/.dumirc.ts @@ -39,6 +39,7 @@ export default defineConfig({ { type: 'doc', dir: 'docs' }, { type: 'x-sdk', dir: 'docs/x-sdk' }, { type: 'x-markdown', dir: 'docs/x-markdown' }, + { type: 'x-card', dir: 'docs/x-card' }, { type: 'x-skill', dir: 'docs/x-skill' }, ], atomDirs: [{ type: 'component', dir: 'components' }], @@ -60,6 +61,7 @@ export default defineConfig({ '@ant-design/x/lib': path.join(__dirname, 'components'), '@ant-design/x/es': path.join(__dirname, 'components'), '@ant-design/x': path.join(__dirname, 'components'), + '@ant-design/x-card': path.join(__dirname, '../x-card/src'), '@ant-design/x-markdown': path.join(__dirname, '../x-markdown/src'), '@ant-design/x-sdk': path.join(__dirname, '../x-sdk/src'), '@ant-design/x-skill': path.join(__dirname, '../x-skill/src'), diff --git a/packages/x/components/attachments/__tests__/__snapshots__/demo-extend.test.ts.snap b/packages/x/components/attachments/__tests__/__snapshots__/demo-extend.test.ts.snap index f69c98f448..7fb68a4411 100644 --- a/packages/x/components/attachments/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/packages/x/components/attachments/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -2,13 +2,13 @@ exports[`renders components/attachments/demo/basic.tsx extend context correctly 1`] = `