Browse Source

chore: add Claude command add-test (#1036)

* chore: add claude add test command

* chore: add ut to form core
YuanHeDx 2 weeks ago
parent
commit
d53c8d4ed6

+ 204 - 0
.claude/commands/add-tests.md

@@ -0,0 +1,204 @@
+---
+description: 为代码添加单元测试(支持增量和存量代码测试补齐)
+---
+
+# 单元测试生成
+
+## 用法
+- `/add-tests [dir]` - 为指定模块或文件添加单元测试
+  - `[dir]` 可选参数:包名(以 @ 开头)、文件路径或目录路径
+  - 不指定 `[dir]` 则默认为全代码库(会提示用户确认)
+  - 执行后会询问用户选择:增量代码测试(基于 git diff)或存量代码测试补齐
+
+## 命令说明
+
+此命令用于自动生成和补充单元测试,确保代码质量。FlowGram 使用 **Vitest** 作为测试框架。
+
+### 测试覆盖率目标
+
+根据包的类型和重要性,测试覆盖率要求如下:
+
+- **核心引擎层**(canvas-engine、node-engine、variable-engine、runtime)
+  - 覆盖率目标:≥ 85%
+  - 包括:@flowgram.ai/core、@flowgram.ai/form、@flowgram.ai/variable-core、@flowgram.ai/runtime-js 等
+
+- **插件和客户端层**(plugins、client)
+  - 覆盖率目标:≥ 60%
+  - 包括:@flowgram.ai/editor、各类 plugin 包、@flowgram.ai/fixed-layout-editor 等
+
+- **工具和示例**(common、apps)
+  - 覆盖率目标:尽可能覆盖关键逻辑
+  - 包括:@flowgram.ai/utils、demo 应用等
+
+### 测试文件组织
+
+- 测试文件位置:
+  - `__tests__/` 目录(推荐)
+  - 或与源文件同级的 `*.test.ts`/`*.test.tsx` 文件
+- 命名规范:
+  - 对于 `src/core/utils.ts`,测试文件为 `__tests__/core/utils.test.ts` 或 `src/core/utils.test.ts`
+
+## 测试生成流程
+
+### 0. 命令执行和用户确认
+
+1. **确认范围**:
+   - 如果未指定 `[dir]`,询问用户是否要对全代码库操作,还是指定具体目录
+   - 全代码库操作工作量巨大,需要用户明确确认
+
+2. **选择模式**:
+   - 询问用户选择测试模式:
+     - **增量代码测试**:仅为 git diff 中的新增/修改代码添加测试
+     - **存量代码测试补齐**:扫描所有代码,补齐缺失或覆盖率不足的测试
+
+### 1. 识别待测代码
+
+**增量代码模式**:
+```bash
+# 检查 git diff 获取所有修改的文件
+git diff --name-only
+git diff <file>  # 查看具体变更
+```
+
+**存量代码模式**:
+- 扫描指定目录下所有源文件(排除已有完整测试的文件)
+- 查找缺少测试或覆盖率不足的文件
+- 优先处理核心引擎层的文件
+
+### 2. 确定包信息和覆盖率目标
+
+1. 从最近的 `package.json` 获取包名
+2. 使用包名在 `rush.json` 中查找包的分类(projectFolder)
+3. 根据包所在目录确定覆盖率目标:
+   - `packages/canvas-engine/`、`packages/node-engine/`、`packages/variable-engine/`、`packages/runtime/` → 85%
+   - `packages/plugins/`、`packages/client/` → 60%
+   - `packages/common/`、`apps/` → 尽可能覆盖
+
+### 3. 生成测试代码
+
+**测试重点**:
+- 新增或修改的函数、方法、类
+- 分支逻辑(if/else、switch/case)
+- 边界条件和异常处理
+- 依赖注入容器(inversify)的模拟
+- 响应式状态(ReactiveState)的行为验证
+
+**Vitest 最佳实践**:
+```typescript
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+describe('ModuleName', () => {
+  beforeEach(() => {
+    // 初始化
+  });
+
+  it('should handle specific case', () => {
+    // 测试逻辑
+    expect(result).toBe(expected);
+  });
+});
+```
+
+**React 组件测试**:
+- 使用 `@testing-library/react` 进行组件测试
+- 为关键元素添加 `data-testid` 属性
+- 测试用户交互和状态变化
+
+**依赖注入测试**:
+- 使用 `vi.mock()` 模拟依赖
+- 创建测试容器来验证服务注册
+
+### 4. 执行测试验证
+
+**单包测试**:
+```bash
+cd packages/canvas-engine/core
+rushx test          # 运行测试
+rushx test:cov      # 生成覆盖率报告
+```
+
+**全局测试**:
+```bash
+rush test           # 运行所有包的测试
+rush test:cov       # 生成所有包的覆盖率报告
+```
+
+**每次添加测试后**:
+1. 立即运行测试确保通过
+2. 检查覆盖率是否达到目标
+3. 修复失败的测试或调整测试用例
+4. 继续处理下一个文件
+
+### 5. 输出测试文件
+
+- 将测试文件保存到 `__tests__/` 目录(优先)或源文件同级
+- 保持目录结构与源代码一致
+- 添加必要的导入和类型声明
+
+## 示例命令
+
+```bash
+# 为某个包添加测试(执行后会询问增量或存量)
+/add_tests @flowgram.ai/core
+
+# 为特定目录添加测试
+/add_tests packages/node-engine/form
+
+# 为单个文件添加测试
+/add_tests packages/canvas-engine/core/src/core/utils.ts
+
+# 全代码库测试(会先确认范围,再询问增量或存量)
+/add_tests
+```
+
+### 典型使用场景
+
+**场景 1:为新功能添加测试**
+```bash
+/add_tests packages/plugins/my-new-plugin
+# 选择:增量代码测试
+# 结果:仅为 git diff 中的新代码生成测试
+```
+
+**场景 2:提升现有包的测试覆盖率**
+```bash
+/add_tests @flowgram.ai/variable-core
+# 选择:存量代码测试补齐
+# 结果:扫描所有代码,补齐缺失的测试,目标 85% 覆盖率
+```
+
+**场景 3:全面测试检查**
+```bash
+/add_tests
+# 确认:选择要处理的目录或全代码库
+# 选择:存量代码测试补齐
+# 结果:系统性地补齐整个项目的测试
+```
+
+## 注意事项
+
+1. **优先级**:优先为核心引擎层包编写高质量测试
+2. **隔离性**:每个测试应该独立,不依赖其他测试的执行顺序
+3. **可读性**:测试用例命名应清晰描述测试场景(使用中文或英文皆可)
+4. **Mock 策略**:
+   - 外部依赖(网络请求、文件系统)必须 mock
+   - 内部复杂模块可以考虑 mock
+   - 简单工具函数可以直接使用
+5. **快照测试**:谨慎使用快照测试,仅用于稳定的 UI 或数据结构
+6. **异步测试**:使用 async/await 处理异步操作,确保 Promise 正确解决
+
+## 工作流程总结
+
+1. **接收命令**:用户执行 `/add_tests [dir]`
+2. **确认范围**:如果未指定 dir,询问用户要处理全代码库还是指定目录
+3. **选择模式**:询问用户选择增量代码测试或存量代码测试补齐
+4. **分析代码**:根据选择的模式识别待测代码
+5. **确定目标**:根据包的分类确定覆盖率目标
+6. **生成测试**:逐文件生成测试用例
+7. **运行验证**:每生成一批测试后立即运行验证
+8. **修复问题**:修复失败的测试或调整测试用例
+9. **检查覆盖率**:查看覆盖率报告是否达标
+10. **继续迭代**:直到达到目标覆盖率或所有文件都有测试
+
+开始生成测试吧!

+ 195 - 0
packages/node-engine/form/__tests__/create-form.test.ts

@@ -5,9 +5,19 @@
 
 import { describe, expect, it } from 'vitest';
 
+import { FieldModel } from '@/core/field-model';
+import { FieldArrayModel } from '@/core/field-array-model';
 import { createForm } from '@/core/create-form';
 
 describe('createForm', () => {
+  it('should create form with auto initialization by default', () => {
+    const { form, control } = createForm();
+
+    expect(form).toBeDefined();
+    expect(control).toBeDefined();
+    expect(control._formModel.initialized).toBe(true);
+  });
+
   it('should disableAutoInit work', async () => {
     const { control } = createForm({ disableAutoInit: true });
 
@@ -16,4 +26,189 @@ describe('createForm', () => {
     control.init();
     expect(control._formModel.initialized).toBe(true);
   });
+
+  it('should create form with initial values', () => {
+    const initialValues = {
+      username: 'John',
+      email: 'john@example.com',
+      age: 30,
+    };
+    const { form } = createForm({ initialValues });
+
+    expect(form.initialValues).toEqual(initialValues);
+    expect(form.values).toEqual(initialValues);
+  });
+
+  it('should create form with validation', async () => {
+    const { form } = createForm({
+      initialValues: { username: '' },
+    });
+
+    // Validation should be callable
+    const errors = await form.validate();
+
+    // Without explicit validators, errors should be empty or undefined
+    expect(errors === undefined || Object.keys(errors).length === 0).toBe(true);
+  });
+
+  it('should create form with empty options', () => {
+    const { form, control } = createForm({});
+
+    expect(form).toBeDefined();
+    expect(control).toBeDefined();
+    expect(control._formModel.initialized).toBe(true);
+  });
+
+  it('should create form without options', () => {
+    const { form, control } = createForm();
+
+    expect(form).toBeDefined();
+    expect(control).toBeDefined();
+    expect(control._formModel.initialized).toBe(true);
+  });
+
+  describe('control.getField', () => {
+    it('should get field by name', () => {
+      const { form, control } = createForm({
+        initialValues: {
+          username: 'John',
+        },
+      });
+
+      // Create field first
+      control._formModel.createField('username');
+      const field = control.getField('username');
+
+      expect(field).toBeDefined();
+      expect(field!.name).toBe('username');
+      expect(field!.value).toBe('John');
+    });
+
+    it('should return undefined for non-existent field', () => {
+      const { control } = createForm();
+
+      const field = control.getField('nonexistent');
+
+      expect(field).toBeUndefined();
+    });
+
+    it('should get FieldArray when field is array', () => {
+      const { control } = createForm({
+        initialValues: {
+          users: [{ name: 'Alice' }, { name: 'Bob' }],
+        },
+      });
+
+      // Create field array
+      control._formModel.createFieldArray('users');
+      const fieldArray = control.getField('users');
+
+      expect(fieldArray).toBeDefined();
+      expect(fieldArray!.name).toBe('users');
+      expect(Array.isArray(fieldArray!.value)).toBe(true);
+    });
+
+    it('should return Field for regular field', () => {
+      const { control } = createForm({
+        initialValues: {
+          username: 'John',
+        },
+      });
+
+      control._formModel.createField('username');
+      const field = control.getField('username');
+
+      expect(field).toBeDefined();
+      expect(field!.name).toBe('username');
+      expect((field as any).onChange).toBeDefined();
+    });
+
+    it('should return FieldArray for array field with array methods', () => {
+      const { control } = createForm({
+        initialValues: {
+          users: [{ name: 'Alice' }],
+        },
+      });
+
+      control._formModel.createFieldArray('users');
+      const fieldArrayModel = control._formModel.getField('users');
+      expect(fieldArrayModel).toBeInstanceOf(FieldArrayModel);
+
+      const fieldArray = control.getField('users');
+
+      expect(fieldArray).toBeDefined();
+      expect((fieldArray as any).append).toBeDefined();
+      expect((fieldArray as any).remove).toBeDefined();
+      expect((fieldArray as any).swap).toBeDefined();
+      expect((fieldArray as any).move).toBeDefined();
+    });
+
+    it('should handle nested field names', () => {
+      const { control } = createForm({
+        initialValues: {
+          user: {
+            profile: {
+              name: 'Alice',
+            },
+          },
+        },
+      });
+
+      control._formModel.createField('user.profile.name');
+      const field = control.getField('user.profile.name');
+
+      expect(field).toBeDefined();
+      expect(field!.name).toBe('user.profile.name');
+      expect(field!.value).toBe('Alice');
+    });
+
+    it('should handle array index in field names', () => {
+      const { control } = createForm({
+        initialValues: {
+          users: [{ name: 'Alice' }, { name: 'Bob' }],
+        },
+      });
+
+      control._formModel.createField('users.0.name');
+      const field = control.getField('users.0.name');
+
+      expect(field).toBeDefined();
+      expect(field!.name).toBe('users.0.name');
+      expect(field!.value).toBe('Alice');
+    });
+  });
+
+  describe('control.init', () => {
+    it('should initialize form with new options', () => {
+      const { control } = createForm({ disableAutoInit: true });
+
+      expect(control._formModel.initialized).toBe(false);
+
+      control.init();
+
+      expect(control._formModel.initialized).toBe(true);
+    });
+
+    it('should reinitialize form', () => {
+      const { control } = createForm({
+        initialValues: { username: 'John' },
+      });
+
+      expect(control._formModel.initialized).toBe(true);
+      expect(control._formModel.initialValues).toEqual({ username: 'John' });
+
+      control._formModel.dispose();
+      control.init();
+
+      expect(control._formModel.initialized).toBe(true);
+    });
+  });
+
+  it('should expose _formModel on control', () => {
+    const { control } = createForm();
+
+    expect(control._formModel).toBeDefined();
+    expect(control._formModel.init).toBeDefined();
+    expect(control._formModel.createField).toBeDefined();
+  });
 });

+ 202 - 0
packages/node-engine/form/__tests__/to-field-array.test.ts

@@ -0,0 +1,202 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { toFieldArray } from '@/core/to-field-array';
+import { FormModel } from '@/core/form-model';
+import { FieldArrayModel } from '@/core/field-array-model';
+
+describe('toFieldArray', () => {
+  let formModel: FormModel;
+  let fieldArrayModel: FieldArrayModel;
+
+  beforeEach(() => {
+    formModel = new FormModel();
+    formModel.init({});
+    formModel.createFieldArray('users');
+    fieldArrayModel = formModel.getField<FieldArrayModel>('users')!;
+  });
+
+  it('should convert FieldArrayModel to FieldArray', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    expect(fieldArray).toBeDefined();
+    expect(fieldArray.name).toBe('users');
+    expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
+  });
+
+  it('should expose key property from model id', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    expect(fieldArray.key).toBe(fieldArrayModel.id);
+  });
+
+  it('should expose name property from model path', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    expect(fieldArray.name).toBe('users');
+    expect(fieldArray.name).toBe(fieldArrayModel.path.toString());
+  });
+
+  it('should expose value property from model value', () => {
+    fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }];
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Bob' }]);
+    expect(fieldArray.value).toBe(fieldArrayModel.value);
+  });
+
+  it('should update model value via onChange', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+    const newValue = [{ name: 'Charlie' }];
+
+    fieldArray.onChange(newValue);
+
+    expect(fieldArrayModel.value).toEqual(newValue);
+    expect(fieldArray.value).toEqual(newValue);
+  });
+
+  it('should map over array elements correctly', () => {
+    fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }];
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    const mapped = fieldArray.map((field, index) => {
+      expect(field).toBeDefined();
+      expect(field.name).toBe(`users.${index}`);
+      return field.value;
+    });
+
+    expect(mapped).toHaveLength(2);
+    expect(mapped).toEqual([{ name: 'Alice' }, { name: 'Bob' }]);
+  });
+
+  it('should append new item and return Field', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+    const newItem = { name: 'Charlie' };
+
+    const newField = fieldArray.append(newItem);
+
+    expect(newField).toBeDefined();
+    expect(newField.name).toBe('users.0');
+    expect(newField.value).toEqual(newItem);
+    expect(fieldArrayModel.value).toEqual([newItem]);
+  });
+
+  it('should delete item by index', () => {
+    fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    fieldArray.delete(1);
+
+    expect(fieldArrayModel.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
+    expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
+  });
+
+  it('should remove item by index (same as delete)', () => {
+    fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    fieldArray.remove(1);
+
+    expect(fieldArrayModel.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
+    expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
+  });
+
+  it('should swap items at two indices', () => {
+    fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    fieldArray.swap(0, 2);
+
+    expect(fieldArrayModel.value).toEqual([
+      { name: 'Charlie' },
+      { name: 'Bob' },
+      { name: 'Alice' },
+    ]);
+    expect(fieldArray.value).toEqual([{ name: 'Charlie' }, { name: 'Bob' }, { name: 'Alice' }]);
+  });
+
+  it('should move item from one index to another', () => {
+    fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    fieldArray.move(0, 2);
+
+    expect(fieldArrayModel.value).toEqual([
+      { name: 'Bob' },
+      { name: 'Charlie' },
+      { name: 'Alice' },
+    ]);
+    expect(fieldArray.value).toEqual([{ name: 'Bob' }, { name: 'Charlie' }, { name: 'Alice' }]);
+  });
+
+  it('should hide _fieldModel property (non-enumerable)', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    // _fieldModel should exist but not be enumerable
+    expect((fieldArray as any)._fieldModel).toBe(fieldArrayModel);
+    expect(Object.keys(fieldArray)).not.toContain('_fieldModel');
+  });
+
+  it('should support complex nested operations', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    // Append multiple items
+    fieldArray.append({ name: 'Alice', age: 30 });
+    fieldArray.append({ name: 'Bob', age: 25 });
+    fieldArray.append({ name: 'Charlie', age: 35 });
+
+    expect(fieldArray.value).toHaveLength(3);
+
+    // Map and modify
+    const names = fieldArray.map((field) => field.value.name);
+    expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
+
+    // Swap
+    fieldArray.swap(0, 1);
+    expect(fieldArray.value[0].name).toBe('Bob');
+    expect(fieldArray.value[1].name).toBe('Alice');
+
+    // Remove
+    fieldArray.remove(2);
+    expect(fieldArray.value).toHaveLength(2);
+
+    // Move
+    fieldArray.move(1, 0);
+    expect(fieldArray.value[0].name).toBe('Alice');
+    expect(fieldArray.value[1].name).toBe('Bob');
+  });
+
+  it('should work with empty array', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    // Value might be undefined or empty array initially
+    expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
+
+    const mapped = fieldArray.map((field) => field.value);
+    expect(Array.isArray(mapped)).toBe(true);
+    expect(mapped.length === 0 || mapped.length > 0).toBe(true);
+  });
+
+  it('should preserve reactivity through getters', () => {
+    const fieldArray = toFieldArray(fieldArrayModel);
+
+    // Initial value might be undefined or empty array
+    expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
+
+    // Modify through model
+    fieldArrayModel.value = [{ name: 'Alice' }];
+
+    // Should reflect in fieldArray (getter)
+    expect(fieldArray.value).toEqual([{ name: 'Alice' }]);
+
+    // Modify through fieldArray
+    fieldArray.onChange([{ name: 'Bob' }]);
+
+    // Should reflect in model
+    expect(fieldArrayModel.value).toEqual([{ name: 'Bob' }]);
+  });
+});

+ 331 - 0
packages/node-engine/form/__tests__/to-field.test.ts

@@ -0,0 +1,331 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import * as React from 'react';
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ValidateTrigger } from '@/types';
+import { toField, toFieldState } from '@/core/to-field';
+import { FormModel } from '@/core/form-model';
+import { FieldModel } from '@/core/field-model';
+
+describe('toField', () => {
+  let formModel: FormModel;
+  let fieldModel: FieldModel;
+
+  beforeEach(() => {
+    formModel = new FormModel();
+    formModel.init({});
+    fieldModel = formModel.createField('username') as FieldModel;
+  });
+
+  it('should convert FieldModel to Field', () => {
+    const field = toField(fieldModel);
+
+    expect(field).toBeDefined();
+    expect(field.name).toBe('username');
+    expect(field.value).toBeUndefined();
+  });
+
+  it('should expose name property from model', () => {
+    const field = toField(fieldModel);
+
+    expect(field.name).toBe(fieldModel.name);
+  });
+
+  it('should expose value property from model', () => {
+    fieldModel.value = 'John';
+    const field = toField(fieldModel);
+
+    expect(field.value).toBe('John');
+    expect(field.value).toBe(fieldModel.value);
+  });
+
+  describe('onChange', () => {
+    it('should update model value with plain value', () => {
+      const field = toField(fieldModel);
+
+      field.onChange('Alice');
+
+      expect(fieldModel.value).toBe('Alice');
+      expect(field.value).toBe('Alice');
+    });
+
+    it('should handle React change event for input', () => {
+      const field = toField(fieldModel);
+      const mockEvent = {
+        target: {
+          value: 'Bob',
+        },
+      } as React.ChangeEvent<HTMLInputElement>;
+
+      field.onChange(mockEvent);
+
+      expect(fieldModel.value).toBe('Bob');
+    });
+
+    it('should handle React change event for checkbox (checked)', () => {
+      const field = toField(fieldModel);
+      const mockEvent = {
+        target: {
+          type: 'checkbox',
+          checked: true,
+          value: 'on',
+        },
+      } as React.ChangeEvent<HTMLInputElement>;
+
+      field.onChange(mockEvent);
+
+      expect(fieldModel.value).toBe(true);
+    });
+
+    it('should handle React change event for checkbox (unchecked)', () => {
+      const field = toField(fieldModel);
+      const mockEvent = {
+        target: {
+          type: 'checkbox',
+          checked: false,
+          value: 'on',
+        },
+      } as React.ChangeEvent<HTMLInputElement>;
+
+      field.onChange(mockEvent);
+
+      expect(fieldModel.value).toBe(false);
+    });
+
+    it('should handle numeric value', () => {
+      const field = toField(fieldModel);
+
+      field.onChange(42);
+
+      expect(fieldModel.value).toBe(42);
+    });
+
+    it('should handle object value', () => {
+      const field = toField(fieldModel);
+      const objValue = { name: 'test', value: 123 };
+
+      field.onChange(objValue);
+
+      expect(fieldModel.value).toEqual(objValue);
+    });
+
+    it('should handle array value', () => {
+      const field = toField(fieldModel);
+      const arrValue = ['a', 'b', 'c'];
+
+      field.onChange(arrValue);
+
+      expect(fieldModel.value).toEqual(arrValue);
+    });
+  });
+
+  describe('onBlur', () => {
+    it('should call validate when validateTrigger is onBlur', () => {
+      formModel.dispose();
+      formModel = new FormModel();
+      formModel.init({ validateTrigger: ValidateTrigger.onBlur });
+      fieldModel = formModel.createField('username') as FieldModel;
+
+      const validateSpy = vi.spyOn(fieldModel, 'validate');
+      const field = toField(fieldModel);
+
+      field.onBlur?.();
+
+      expect(validateSpy).toHaveBeenCalled();
+    });
+
+    it('should not trigger validation when validateTrigger is not onBlur', () => {
+      formModel.dispose();
+      formModel = new FormModel();
+      formModel.init({ validateTrigger: ValidateTrigger.onChange });
+      fieldModel = formModel.createField('username') as FieldModel;
+
+      const validateSpy = vi.spyOn(fieldModel, 'validate');
+      const field = toField(fieldModel);
+
+      field.onBlur?.();
+
+      expect(validateSpy).not.toHaveBeenCalled();
+    });
+
+    it('should not trigger validation when validateTrigger is onSubmit', () => {
+      formModel.dispose();
+      formModel = new FormModel();
+      formModel.init({ validateTrigger: ValidateTrigger.onSubmit });
+      fieldModel = formModel.createField('username') as FieldModel;
+
+      const validateSpy = vi.spyOn(fieldModel, 'validate');
+      const field = toField(fieldModel);
+
+      field.onBlur?.();
+
+      expect(validateSpy).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('onFocus', () => {
+    it('should set isTouched to true', () => {
+      const field = toField(fieldModel);
+
+      expect(fieldModel.state.isTouched).toBe(false);
+
+      field.onFocus?.();
+
+      expect(fieldModel.state.isTouched).toBe(true);
+    });
+
+    it('should set isTouched only once', () => {
+      const field = toField(fieldModel);
+
+      field.onFocus?.();
+      expect(fieldModel.state.isTouched).toBe(true);
+
+      field.onFocus?.();
+      expect(fieldModel.state.isTouched).toBe(true);
+    });
+  });
+
+  it('should expose key property (non-enumerable)', () => {
+    const field = toField(fieldModel);
+
+    expect((field as any).key).toBe(fieldModel.id);
+    expect(Object.keys(field)).not.toContain('key');
+  });
+
+  it('should hide _fieldModel property (non-enumerable)', () => {
+    const field = toField(fieldModel);
+
+    expect((field as any)._fieldModel).toBe(fieldModel);
+    expect(Object.keys(field)).not.toContain('_fieldModel');
+  });
+
+  it('should preserve reactivity through getters', () => {
+    const field = toField(fieldModel);
+
+    expect(field.name).toBe('username');
+    expect(field.value).toBeUndefined();
+
+    fieldModel.value = 'NewValue';
+
+    expect(field.value).toBe('NewValue');
+  });
+});
+
+describe('toFieldState', () => {
+  let formModel: FormModel;
+  let fieldModel: FieldModel;
+
+  beforeEach(() => {
+    formModel = new FormModel();
+    formModel.init({});
+    fieldModel = formModel.createField('username') as FieldModel;
+  });
+
+  it('should convert FieldModelState to FieldState', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    expect(fieldState).toBeDefined();
+    expect(fieldState.isTouched).toBe(false);
+    expect(fieldState.isDirty).toBe(false);
+    expect(fieldState.invalid).toBe(false);
+    expect(fieldState.isValidating).toBe(false);
+  });
+
+  it('should reflect isTouched state', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    expect(fieldState.isTouched).toBe(false);
+
+    fieldModel.state.isTouched = true;
+
+    expect(fieldState.isTouched).toBe(true);
+  });
+
+  it('should reflect isDirty state', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    expect(fieldState.isDirty).toBe(false);
+
+    // Manually set dirty state
+    fieldModel.state.isDirty = true;
+
+    expect(fieldState.isDirty).toBe(true);
+  });
+
+  it('should reflect invalid state', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    expect(fieldState.invalid).toBe(false);
+
+    fieldModel.state.invalid = true;
+
+    expect(fieldState.invalid).toBe(true);
+  });
+
+  it('should reflect isValidating state', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    expect(fieldState.isValidating).toBe(false);
+
+    fieldModel.state.isValidating = true;
+
+    expect(fieldState.isValidating).toBe(true);
+  });
+
+  it('should return errors as flat array', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    expect(fieldState.errors).toBeUndefined();
+
+    fieldModel.state.errors = {
+      validate1: ['Error 1', 'Error 2'],
+      validate2: ['Error 3'],
+    };
+
+    expect(fieldState.errors).toEqual(['Error 1', 'Error 2', 'Error 3']);
+  });
+
+  it('should return warnings as flat array', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    expect(fieldState.warnings).toBeUndefined();
+
+    fieldModel.state.warnings = {
+      validate1: ['Warning 1', 'Warning 2'],
+      validate2: ['Warning 3'],
+    };
+
+    expect(fieldState.warnings).toEqual(['Warning 1', 'Warning 2', 'Warning 3']);
+  });
+
+  it('should handle empty errors object', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    fieldModel.state.errors = {};
+
+    expect(fieldState.errors).toEqual([]);
+  });
+
+  it('should handle empty warnings object', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    fieldModel.state.warnings = {};
+
+    expect(fieldState.warnings).toEqual([]);
+  });
+
+  it('should preserve reactivity through getters', () => {
+    const fieldState = toFieldState(fieldModel.state);
+
+    expect(fieldState.isTouched).toBe(false);
+
+    fieldModel.state.isTouched = true;
+
+    expect(fieldState.isTouched).toBe(true);
+  });
+});

+ 316 - 0
packages/node-engine/form/__tests__/to-form.test.ts

@@ -0,0 +1,316 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { toForm, toFormState } from '@/core/to-form';
+import { FormModel } from '@/core/form-model';
+
+describe('toForm', () => {
+  let formModel: FormModel;
+
+  beforeEach(() => {
+    formModel = new FormModel();
+    formModel.init({
+      initialValues: {
+        username: 'John',
+        email: 'john@example.com',
+        age: 30,
+      },
+    });
+  });
+
+  it('should convert FormModel to Form', () => {
+    const form = toForm(formModel);
+
+    expect(form).toBeDefined();
+    expect(form.initialValues).toEqual({
+      username: 'John',
+      email: 'john@example.com',
+      age: 30,
+    });
+  });
+
+  it('should expose initialValues from model', () => {
+    const form = toForm(formModel);
+
+    expect(form.initialValues).toBe(formModel.initialValues);
+  });
+
+  it('should expose values getter from model', () => {
+    const form = toForm(formModel);
+
+    expect(form.values).toEqual({
+      username: 'John',
+      email: 'john@example.com',
+      age: 30,
+    });
+    expect(form.values).toEqual(formModel.values);
+  });
+
+  it('should expose values setter to update model', () => {
+    const form = toForm(formModel);
+    const newValues = {
+      username: 'Alice',
+      email: 'alice@example.com',
+      age: 25,
+    };
+
+    form.values = newValues;
+
+    expect(formModel.values).toEqual(newValues);
+    expect(form.values).toEqual(newValues);
+  });
+
+  it('should expose state as FormState', () => {
+    const form = toForm(formModel);
+
+    expect(form.state).toBeDefined();
+    expect(form.state.isTouched).toBe(false);
+    expect(form.state.isDirty).toBe(false);
+    expect(form.state.invalid).toBe(false);
+    expect(form.state.isValidating).toBe(false);
+  });
+
+  describe('getValueIn', () => {
+    it('should get value by field name', () => {
+      const form = toForm(formModel);
+
+      expect(form.getValueIn('username')).toBe('John');
+      expect(form.getValueIn('email')).toBe('john@example.com');
+      expect(form.getValueIn('age')).toBe(30);
+    });
+
+    it('should get nested value by path', () => {
+      formModel.values = {
+        user: {
+          profile: {
+            name: 'Alice',
+            age: 25,
+          },
+        },
+      };
+      const form = toForm(formModel);
+
+      expect(form.getValueIn('user.profile.name')).toBe('Alice');
+      expect(form.getValueIn('user.profile.age')).toBe(25);
+    });
+
+    it('should get array value by index', () => {
+      formModel.values = {
+        users: [{ name: 'Alice' }, { name: 'Bob' }],
+      };
+      const form = toForm(formModel);
+
+      expect(form.getValueIn('users.0.name')).toBe('Alice');
+      expect(form.getValueIn('users.1.name')).toBe('Bob');
+    });
+
+    it('should return undefined for non-existent path', () => {
+      const form = toForm(formModel);
+
+      expect(form.getValueIn('nonexistent')).toBeUndefined();
+    });
+  });
+
+  describe('setValueIn', () => {
+    it('should set value by field name', () => {
+      const form = toForm(formModel);
+
+      form.setValueIn('username', 'Bob');
+
+      expect(formModel.values.username).toBe('Bob');
+      expect(form.values.username).toBe('Bob');
+    });
+
+    it('should set nested value by path', () => {
+      formModel.values = {
+        user: {
+          profile: {
+            name: 'Alice',
+            age: 25,
+          },
+        },
+      };
+      const form = toForm(formModel);
+
+      form.setValueIn('user.profile.name', 'Charlie');
+
+      expect(formModel.values.user.profile.name).toBe('Charlie');
+    });
+
+    it('should set array value by index', () => {
+      formModel.values = {
+        users: [{ name: 'Alice' }, { name: 'Bob' }],
+      };
+      const form = toForm(formModel);
+
+      form.setValueIn('users.0.name', 'Charlie');
+
+      expect(formModel.values.users[0].name).toBe('Charlie');
+    });
+
+    it('should create nested structure if not exists', () => {
+      formModel.values = {};
+      const form = toForm(formModel);
+
+      form.setValueIn('user.profile.name', 'Alice');
+
+      expect(formModel.values.user.profile.name).toBe('Alice');
+    });
+  });
+
+  describe('validate', () => {
+    it('should bind model validate method', () => {
+      const form = toForm(formModel);
+
+      expect(form.validate).toBeDefined();
+      expect(typeof form.validate).toBe('function');
+    });
+
+    it('should call form validate method', async () => {
+      const form = toForm(formModel);
+
+      // Validate should be callable
+      const result = await form.validate();
+
+      // Without validators, should return empty object or undefined
+      expect(result === undefined || Object.keys(result || {}).length === 0).toBe(true);
+    });
+  });
+
+  it('should hide _formModel property (non-enumerable)', () => {
+    const form = toForm(formModel);
+
+    expect((form as any)._formModel).toBe(formModel);
+    expect(Object.keys(form)).not.toContain('_formModel');
+  });
+
+  it('should preserve reactivity through getters', () => {
+    const form = toForm(formModel);
+
+    expect(form.values.username).toBe('John');
+
+    formModel.values = { username: 'Alice' };
+
+    expect(form.values.username).toBe('Alice');
+  });
+
+  it('should work with empty initialValues', () => {
+    const emptyFormModel = new FormModel();
+    emptyFormModel.init({});
+    const form = toForm(emptyFormModel);
+
+    expect(form.initialValues).toBeUndefined();
+    expect(form.values).toBeUndefined();
+  });
+});
+
+describe('toFormState', () => {
+  let formModel: FormModel;
+
+  beforeEach(() => {
+    formModel = new FormModel();
+    formModel.init({
+      initialValues: {
+        username: 'John',
+        email: 'john@example.com',
+      },
+    });
+  });
+
+  it('should convert FormModelState to FormState', () => {
+    const formState = toFormState(formModel.state);
+
+    expect(formState).toBeDefined();
+    expect(formState.isTouched).toBe(false);
+    expect(formState.isDirty).toBe(false);
+    expect(formState.invalid).toBe(false);
+    expect(formState.isValidating).toBe(false);
+  });
+
+  it('should reflect isTouched state', () => {
+    const formState = toFormState(formModel.state);
+
+    expect(formState.isTouched).toBe(false);
+
+    formModel.state.isTouched = true;
+
+    expect(formState.isTouched).toBe(true);
+  });
+
+  it('should reflect isDirty state', () => {
+    const formState = toFormState(formModel.state);
+
+    expect(formState.isDirty).toBe(false);
+
+    // Manually set dirty state
+    formModel.state.isDirty = true;
+
+    expect(formState.isDirty).toBe(true);
+  });
+
+  it('should reflect invalid state', () => {
+    const formState = toFormState(formModel.state);
+
+    expect(formState.invalid).toBe(false);
+
+    formModel.state.invalid = true;
+
+    expect(formState.invalid).toBe(true);
+  });
+
+  it('should reflect isValidating state', () => {
+    const formState = toFormState(formModel.state);
+
+    expect(formState.isValidating).toBe(false);
+
+    formModel.state.isValidating = true;
+
+    expect(formState.isValidating).toBe(true);
+  });
+
+  it('should expose errors from model state', () => {
+    const formState = toFormState(formModel.state);
+
+    expect(formState.errors).toBeUndefined();
+
+    formModel.state.errors = {
+      username: 'Username is required',
+      email: 'Invalid email format',
+    };
+
+    expect(formState.errors).toEqual({
+      username: 'Username is required',
+      email: 'Invalid email format',
+    });
+  });
+
+  it('should expose warnings from model state', () => {
+    const formState = toFormState(formModel.state);
+
+    expect(formState.warnings).toBeUndefined();
+
+    formModel.state.warnings = {
+      username: 'Username should be longer',
+      email: 'Consider using a different email',
+    };
+
+    expect(formState.warnings).toEqual({
+      username: 'Username should be longer',
+      email: 'Consider using a different email',
+    });
+  });
+
+  it('should preserve reactivity through getters', () => {
+    const formState = toFormState(formModel.state);
+
+    expect(formState.isTouched).toBe(false);
+
+    formModel.state.isTouched = true;
+
+    expect(formState.isTouched).toBe(true);
+  });
+});