# FormPlugin 测试完整示例

> 以 `KeyPartCollectPluginTest` 为例，展示 mmc-sfc-formplugin 工程中继承 `BasePluginTest<T>` 的表单插件测试写法。
> 来源: `kd.mmc.sfc.formplugin.keypart.KeyPartCollectPluginTest`

---

## 1. 类声明 & 泛型

```java
package kd.mmc.sfc.formplugin.keypart;

// 核心 import（省略其余）
import kd.mmc.sfc.formplugin.BasePluginTest;
import kd.bos.test.ext.annotaions.UnittestCaseInfo;
import kd.bos.unittest.mock.DynamicObjectMocker;
import kd.bos.unittest.mock.ReflectHelper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;

import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

/**
 * 关键件采集插件测试
 * 继承 BasePluginTest 并传入待测试插件类型作为泛型参数
 */
public class KeyPartCollectPluginTest extends BasePluginTest<KeyPartCollectPlugin> {

    // ========== 1. 声明多个方法共用的 MockedStatic 变量 ==========
    protected MockedStatic<KeypartCollectHelper> keyPartCollectHelper;
    private MockedStatic<Uuid16> uuid16;
    private MockedStatic<FormMetadataCache> formMetadataCache;
    private MockedStatic<ObjectConverter> objectConverter;

    // ========== 2. @Before 初始化所有 MockedStatic ==========
    @Before
    public void setUp() {
        keyPartCollectHelper = Mockito.mockStatic(KeypartCollectHelper.class);

        Uuid16 mockUuid = mock(Uuid16.class);
        when(mockUuid.toString()).thenReturn("testUuid");
        uuid16 = Mockito.mockStatic(Uuid16.class);
        uuid16.when(Uuid16::create).thenReturn(mockUuid);

        formMetadataCache = Mockito.mockStatic(FormMetadataCache.class);
        FormConfig formConfig = mock(FormConfig.class);
        when(formConfig.getListFormId()).thenReturn("bos_list");
        when(formConfig.getF7ListFormId()).thenReturn("bos_listf7");
        formMetadataCache.when(() -> FormMetadataCache.getListFormConfig(anyString()))
                .thenReturn(formConfig);

        objectConverter = Mockito.mockStatic(ObjectConverter.class, Mockito.CALLS_REAL_METHODS);
    }

    // ========== 3. reset 方法（可选，用于同一用例中多次调用需要重置 mock 的场景） ==========
    public void reset() {
        keyPartCollectHelper.reset();
        uuid16.reset();
        formMetadataCache.reset();
        objectConverter.reset();
    }

    // ========== 4. @After 释放所有 MockedStatic ==========
    @After
    public void tearDown() {
        keyPartCollectHelper.close();
        uuid16.close();
        formMetadataCache.close();
        objectConverter.close();
    }

    // ========== 5. 推荐实现：createPlugin 工厂方法 ==========
    @Override
    protected KeyPartCollectPlugin createPlugin() {
        return new KeyPartCollectPlugin();
    }

    // ... 测试方法及辅助方法（下方分节展示）
}
```

**要点**:
- 继承 `BasePluginTest<T>` 并指定被测插件类型
- 推荐实现 `createPlugin()` 返回插件实例
- BasePluginTest 已提供：`testView`、`testModel`、`plugin`、`pageCache` 等常用 mock
- 多方法共用的 MockedStatic 在 `@Before` 初始化，`@After` 关闭

---

## 2. 辅助 Mock 方法（构造测试数据）

```java
/**
 * 构造"待采集产品序列号"分录数据
 * 展示：DynamicObjectMocker 构造 + when(testModel.getEntryEntity(...)) 绑定
 */
private DynamicObjectCollection mockModelGetProdSnEntry(int size) {
    DynamicObjectCollection mockProdSn = new DynamicObjectCollection();
    when(testModel.getEntryEntity(eq(ENTRYENTITY_PRODSN.ENTRYENTITY))).thenReturn(mockProdSn);
    if (size <= 0) {
        return mockProdSn;
    }
    for (int i = 0; i < size; i++) {
        long id = i + 1;
        DynamicObject e = new DynamicObjectMocker()
                .add(ENTRYENTITY_PRODSN.UUID, "testuuid")
                .add(ENTRYENTITY_PRODSN.PROPLANENTRY_ID, id)
                .add(ENTRYENTITY_PRODSN.WORKROW_ID, id)
                .add(ENTRYENTITY_PRODSN.PROCESSDEPART_ID, id)
                .getObject();
        mockProdSn.add(e);
    }
    return mockProdSn;
}

/**
 * 构造含子单据体的"关键件分录"数据
 * 展示：嵌套 DynamicObjectCollection 的构造方式
 */
private DynamicObjectCollection mockModelGetKeyPartEntry(int entrySize, int subEntrySize) {
    DynamicObjectCollection keyPartColl = new DynamicObjectCollection();
    when(testModel.getEntryEntity(eq(ENTRYENTITY_KEYPART.ENTRYENTITY))).thenReturn(keyPartColl);
    if (entrySize <= 0) {
        return keyPartColl;
    }
    DynamicObjectCollection subEntry = new DynamicObjectCollection();
    for (int i = 0; i < entrySize; i++) {
        long id = i + 1;
        DynamicObject e = new DynamicObjectMocker()
                .add(ENTRYENTITY_KEYPART.MATERIALMASTER_ID, id)
                .add(ENTRYENTITY_KEYPART.MFTSTOCKENTRYID, id)
                .add(ENTRYENTITY_KEYPART.MFTSTOCKENTRYID_ID, id)
                .add(ENTRYENTITY_KEYPART.CHILDMATUNIT,
                        new DynamicObjectMocker().add(Consts.ID, 1L).getObject())
                .add(SUBENTRYENTITY_COLL.ENTITY, subEntry)
                .getObject();
        keyPartColl.add(e);
        for (int j = 0; j < subEntrySize; j++) {
            subEntry.add(new DynamicObjectMocker()
                    .add(SUBENTRYENTITY_COLL.COLLECTQTY, BigDecimal.ZERO)
                    .add(SUBENTRYENTITY_COLL.CHILDMATUNIT_COLLDET_ID, 1L)
                    .add(SUBENTRYENTITY_COLL.KEYSN, "testsn")
                    .add(SUBENTRYENTITY_COLL.KEYBATCHNO, "testbn")
                    .add(SUBENTRYENTITY_COLL.KEYSUPPLIER_ID, 1L)
                    .getObject());
        }
    }
    return keyPartColl;
}
```

**要点**:
- 使用 `DynamicObjectMocker().add(key, value).getObject()` 构造 DynamicObject
- 常量引用来自业务常量类（如 `ENTRYENTITY_PRODSN.UUID`），推荐避免使用字面值字符串
- 嵌套 DynamicObject 通过再次 `new DynamicObjectMocker()` 构建
- 辅助方法抽取共用的 mock 逻辑，减少各测试方法的重复代码

---

## 3. Switch 分支测试（beforeDoOperation）

每个 switch 操作码对应独立测试方法：

```java
@UnittestCaseInfo(
        author = "zhang_san <zhang_san@kingdee.com>",
        title = "Test before do operation open record",
        targetClass = "kd.mmc.sfc.formplugin.keypart.KeyPartCollectPlugin",
        targetMethod = "testBeforeDoOperation",
        lastUpdateTime = "2025-12-01 16:17:26",
        lastUpdateAuthor = "zhang_san <zhang_san@kingdee.com>",
        methodSignature = "public void beforeDoOperation(BeforeDoOperationEventArgs args)",
        testPoints = {"Functionality"},
        description = "Test before do operation open record"
)
@Test
public void testBeforeDoOperation_openRecord() {
    //step 点击"采集记录"
    BeforeDoOperationEventArgs recordsetting =
            mockBeforeDoOperationEventArgs(OP.OPENRECORD);
    plugin.beforeDoOperation(recordsetting);
    //assert 打开采集记录列表
    verify(testView).showForm(any());
}

@UnittestCaseInfo(/* ... */)
@Test
public void testBeforeDoOperation_openSnSel() {
    //step 采集主体=工序产品序列号
    this.mockModelGetCollectSubject(CollectSubjectEnum.PROPLAN_SN);
    BeforeDoOperationEventArgs recordsetting =
            mockBeforeDoOperationEventArgs(OP.SELPROCESSSN);
    plugin.beforeDoOperation(recordsetting);
    //assert 执行选择弹窗
    verify(testView).showForm(any());
}

@UnittestCaseInfo(/* ... */)
@Test
public void testBeforeDoOperation_openProPlanSel() {
    //step 采集主体=工序计划，点击选择弹窗，待采集产品序列号分录为空
    this.mockModelGetCollectSubject(CollectSubjectEnum.PROPLAN);
    this.mockModelGetProdSnEntry(0);
    //step
    BeforeDoOperationEventArgs recordsetting =
            mockBeforeDoOperationEventArgs(OP.SELPROPLAN);
    plugin.beforeDoOperation(recordsetting);
    //assert 执行选择弹窗，未排除当前已存在工序计划分录
    verify(testView).showForm(any());
}

@UnittestCaseInfo(/* ... */)
@Test
public void testBeforeDoOperation_openProPlanSel_dataExists() {
    //step 采集主体=工序计划，点击选择弹窗，待采集产品序列号分录存在数据
    this.mockModelGetCollectSubject(CollectSubjectEnum.PROPLAN);
    DynamicObjectCollection prodSnEntry = this.mockModelGetProdSnEntry(1);
    prodSnEntry.add(new DynamicObjectMocker()
            .add(ENTRYENTITY_PRODSN.PROPLANENTRY_ID, 0L).getObject());
    keyPartCollectHelper.when(KeypartCollectHelper::buildProcessPlanSubjectFilter)
            .thenReturn(QFilter.of("1=1"));
    BeforeDoOperationEventArgs recordsetting =
            mockBeforeDoOperationEventArgs(OP.SELPROPLAN);
    plugin.beforeDoOperation(recordsetting);
    //assert 执行选择弹窗，覆盖排除当前已存在工序计划分录的过滤条件
    verify(testView).showForm(any());
}
```

**要点**:
- 方法名格式：`test<方法名>_<操作代码>`（如 `testBeforeDoOperation_openRecord`）
- 使用 BasePluginTest 提供的 `mockBeforeDoOperationEventArgs(opKey)` 构造入参
- 每个操作代码独立为一个测试方法
- 同一操作代码的不同数据场景可合并在同一方法中，方法名追加场景后缀（如 `_dataExists`）

---

## 4. If/Else 分支覆盖（条件笛卡尔积合并在同一方法）

```java
@UnittestCaseInfo(/* ... */)
@Test
public void testRefreshProdSnEntry_kpcCacheExists() {
    //step 缓存中添加已存在1条数据
    List<KeyPartCollectDetail> cacheData = Stream.of(
            new KeyPartCollectDetail("testuuid", 1L, 1L, 1L, "sn001",
                    "bn001", 1L, BigDecimal.ONE))
            .collect(Collectors.toList());
    mockPageCacheGet("key_part_collect_detail_cache",
            SerializationUtils.toJsonString(cacheData));
    this.mockKpcEntryDisplayName("testname");
    this.mockModelGetKeyPartEntry(0, 0);

    //step 操作编码不存在
    BeforeDoOperationEventArgs beforeDoOperationEventArgs =
            mockBeforeDoOperationEventArgs("testOp");
    plugin.beforeDoOperation(beforeDoOperationEventArgs);

    //step 设置操作编码=刷新
    beforeDoOperationEventArgs = mockBeforeDoOperationEventArgs(OP.REFRESH);
    plugin.beforeDoOperation(beforeDoOperationEventArgs);
    //assert 采集信息已存在，弹出确认提示
    verify(testView).showConfirm(anyString(), anyString(),
            eq(MessageBoxOptions.OKCancel), eq(ConfirmTypes.Save),
            any(ConfirmCallBackListener.class), any());

    //step 覆盖提示语判断分支（displayName=null）
    this.mockKpcEntryDisplayName(null);
    plugin.beforeDoOperation(beforeDoOperationEventArgs);
    //assert 采集信息已存在，弹出确认提示（共2次）
    verify(testView, times(2)).showConfirm(anyString(), anyString(),
            eq(MessageBoxOptions.OKCancel), eq(ConfirmTypes.Save),
            any(ConfirmCallBackListener.class), any());
}
```

**要点**:
- 多个 if/else 分支条件在同一方法中依次设置不同数据并调用
- 使用 `verify(mock, times(N))` 精确验证调用次数
- 使用 `//step` 和 `//assert` 注释区分测试步骤与断言

---

## 5. PropertyChanged（值改变事件测试）

```java
@UnittestCaseInfo(/* ... */)
@Test
public void testPropertyChanged_collectSubject() {
    CollectSubjectEnum targetSubject = CollectSubjectEnum.PROPLAN;
    keyPartCollectHelper.when(() -> KeypartCollectHelper
            .saveCollectSubjectToUserOption(anyString()))
            .thenAnswer(i -> null);
    PropertyChangedArgs args = mockPropertyChangedArgs(
            KeyPartCollectConst.COLLECTSUBJECT, null, targetSubject, -1);

    //step 获取到的采集主体为空
    this.mockModelGetCollectSubject(null);
    plugin.propertyChanged(args);
    //assert 执行清理操作，未更新标题
    verify(testModel).deleteEntryData(eq(ENTRYENTITY_PRODSN.ENTRYENTITY));
    verify(testModel).deleteEntryData(eq(ENTRYENTITY_KEYPART.ENTRYENTITY));
    verify(pageCache).remove(eq("key_part_collect_detail_cache"));
    verify(testView, Mockito.never())
            .updateControlMetadata(eq(CONTROL.ADVCONAP_TOCOLLECT), anyMap());

    //step 获取到的采集主体不为空
    this.mockModelGetCollectSubject(targetSubject);
    plugin.propertyChanged(args);
    //assert 设置待采集信息标题
    verify(testView).updateControlMetadata(eq(CONTROL.ADVCONAP_TOCOLLECT), anyMap());
}

@UnittestCaseInfo(/* ... */)
@Test
public void testPropertyChanged_keySn_oldValueEmpty() {
    //step 序列号由空值到非空，子项单位与物料基本单位一致
    DynamicObjectCollection keyPartEntry = this.mockModelGetKeyPartEntry(1, 2);
    when(testModel.getEntryRowEntity(eq(ENTRYENTITY_KEYPART.ENTRYENTITY), anyInt()))
            .thenReturn(keyPartEntry.get(0));
    when(testModel.getEntryCurrentRowIndex(eq(ENTRYENTITY_KEYPART.ENTRYENTITY)))
            .thenReturn(0);
    when(testModel.getValue(eq(SUBENTRYENTITY_COLL.KEYSN), anyInt(), anyInt()))
            .thenReturn("testKeySn");
    when(testModel.getValue(eq(ENTRYENTITY_KEYPART.CHILDMATUNIT_ID), anyInt()))
            .thenReturn(1L);
    this.mockModelGetMatMaster(1L);

    PropertyChangedArgs args = mockPropertyChangedArgs(
            SUBENTRYENTITY_COLL.KEYSN, null, "testsn", 0);
    plugin.propertyChanged(args);
    //assert 此次采集数量不可编辑，默认值=1
    verify(testView).setEnable(eq(false), eq(0), eq(SUBENTRYENTITY_COLL.COLLECTQTY));
    verify(testModel).setValue(eq(SUBENTRYENTITY_COLL.COLLECTQTY),
            eq(BigDecimal.ONE), eq(0), eq(0));

    //step 子项单位与物料基本单位不一致，执行单位换算
    keyPartCollectHelper.when(() -> KeypartCollectHelper
            .calcNewQty(eq(BigDecimal.ONE), anyLong(), anyLong(), anyLong()))
            .thenReturn(BigDecimal.TEN);
    when(testModel.getValue(eq(ENTRYENTITY_KEYPART.CHILDMATUNIT_ID), anyInt()))
            .thenReturn(2L);
    plugin.propertyChanged(args);
    //assert 采集数量按换算后的值设置
    verify(testModel).setValue(eq(SUBENTRYENTITY_COLL.COLLECTQTY),
            eq(BigDecimal.TEN), eq(0), eq(0));
}
```

**要点**:
- 使用 BasePluginTest 提供的 `mockPropertyChangedArgs(fieldName, oldValue, newValue, rowIndex)` 构造入参
- 子单据体属性值变化额外传 parentEntryName: `mockPropertyChangedArgs(field, old, new, row, parentEntry)`
- 同一属性不同值变化场景拆分为独立方法（如 `_oldValueEmpty`、`_oldValueNotEmpty`、`_newValueEmpty`）

---

## 6. ClosedCallBack（弹窗回调测试）

```java
@UnittestCaseInfo(/* ... */)
@Test
public void testProdSnSelCallback() {
    //step 构造工序计划F7已选数据
    ProsnReturnData d1 = new ProsnReturnData();
    d1.setProplanEntryId(1L);
    // ... 其余测试数据省略
    List<ProsnReturnData> returnData = Stream.of(d1).collect(Collectors.toList());
    //step 采集主体=工序产品序列号
    this.mockModelGetCollectSubject(CollectSubjectEnum.PROPLAN_SN);
    this.mockModelGetProdSnEntry(0);
    mockBatchCreateNewEntryRow(ENTRYENTITY_PRODSN.ENTRYENTITY, 0);

    ClosedCallBackEvent evt = mockClosedCallBackEvent(OP.SELPRODSN, returnData);
    plugin.closedCallBack(evt);
    //assert 合法数据仅1条，并对待采集产品序列号行执行赋值
    verify(testModel).setValue(eq(ENTRYENTITY_PRODSN.SN), anyString(), anyInt());
}
```

**要点**:
- 使用 BasePluginTest 提供的 `mockClosedCallBackEvent(actionId, returnData)` 构造回调事件
- 不同 callbackActionId 的回调拆分为独立方法
- 空数据 / 非法数据场景单独测试（如 `_invalidReturn`、`_emptySelection`）

---

## 7. ConfirmCallBack（消息确认回调测试）

```java
@UnittestCaseInfo(/* ... */)
@Test
public void testConfirmCallBack_refresh_includeCurrentRow() {
    //step 确认刷新，且待采集产品明细当前行在要刷新的行中
    MessageBoxClosedEvent e = super.mockMessageBoxClosedEvent(
            OP.REFRESH, MessageBoxResult.Yes);
    this.mockModelGetProdSnEntry(1);
    when(testModel.getEntryCurrentRowIndex(eq(ENTRYENTITY_PRODSN.ENTRYENTITY)))
            .thenReturn(0);
    when(testModel.getValue(eq(ENTRYENTITY_PRODSN.UUID), anyInt()))
            .thenReturn("testuuid");
    this.mockModelGetCollectSubject(null);
    super.mockGetControl(ENTRYENTITY_PRODSN.ENTRYENTITY, EntryGrid.class);
    plugin.confirmCallBack(e);
    //assert 当前行的采集明细缓存清除；清空当前关键件分录
    verify(pageCache).remove(eq("key_part_collect_detail_cache"));
    verify(testModel).deleteEntryData(eq(ENTRYENTITY_KEYPART.ENTRYENTITY));
}
```

**要点**:
- 使用 BasePluginTest 的 `mockMessageBoxClosedEvent(callbackId, result)` 构造事件
- `MessageBoxResult.Yes` / `MessageBoxResult.Cancel` 分别测试确认/取消路径
- 同一回调的不同场景用方法名后缀区分（如 `_includeCurrentRow`、`_notCurrentRow`）

---

## 8. RegisterListener（监听注册测试）

```java
@UnittestCaseInfo(/* ... */)
@Test
public void testRegisterListener() {
    //step 对待采集产品序列号分录、关键件分录执行监听
    EventObject e = mock(EventObject.class);
    EntryGrid prodEg = mockGetControl(ENTRYENTITY_PRODSN.ENTRYENTITY, EntryGrid.class);
    EntryGrid kpEg = mockGetControl(ENTRYENTITY_KEYPART.ENTRYENTITY, EntryGrid.class);
    plugin.registerListener(e);
    //assert 添加当前插件作为监听器
    verify(prodEg).addRowClickListener(any(KeyPartCollectPlugin.class));
    verify(kpEg).addRowClickListener(any(KeyPartCollectPlugin.class));
}
```

**要点**:
- 使用 BasePluginTest 的 `mockGetControl(key, Class)` 获取控件 mock
- 验证插件实例被正确注册为监听器

---

## 9. 私有方法测试（ReflectHelper）

```java
@UnittestCaseInfo(/* ... */)
@Test
public void testGetFailedRowIndexes() {
    //step 参数successRow为空
    int[] totalRows = {0, 1, 2, 3, 4, 5};
    Object result = ReflectHelper.invokeProtectedMethod(
            KeyPartCollectPlugin.class, plugin,
            "getFailedRowIndexes", totalRows, null);
    //assert 返回totalRows
    assertEquals(totalRows, result);

    //step 参数successRow不为空，元素与totalRows相同
    int[] successRows = {0, 1, 2, 3, 4, 5};
    result = ReflectHelper.invokeProtectedMethod(
            KeyPartCollectPlugin.class, plugin,
            "getFailedRowIndexes", totalRows, successRows);
    //assert 返回结果为空
    assertNotNull(result);
    assertTrue(ObjectUtils.isEmpty(result));
}
```

**要点**:
- 使用 `ReflectHelper.invokeProtectedMethod(Class, instance, methodName, args...)` 调用私有/受保护方法
- 适用于无法通过公共方法间接覆盖的私有逻辑

---

## 10. AfterDoOperation（操作后事件）

```java
@UnittestCaseInfo(/* ... */)
@Test
public void testAfterDoOperation_confirmCollect_finalSuccess() {
    //step 返回保存成功、最终操作成功
    AfterDoOperationEventArgs args = mockAfterDoOperationEventArgs(OP.CONFIRM);
    when(pageCache.get(eq("key_part_collect_detail_cache"))).thenReturn(null);
    DynamicObjectCollection keyPartEntries = this.mockModelGetKeyPartEntry(1, 2);
    this.mockModelGetCollectSubject(CollectSubjectEnum.PROPLAN);
    this.mockModelGetProdSnEntry(1);
    when(testModel.getEntryEntity(eq(SUBENTRYENTITY_COLL.ENTITY)))
            .thenReturn(keyPartEntries.get(0)
                    .getDynamicObjectCollection(SUBENTRYENTITY_COLL.ENTITY));
    List<DynamicObject> mockBills = this.mockRecordBills();
    keyPartCollectHelper.when(() -> KeypartCollectHelper
            .buildRecordBillWhenConfirmCollect(
                    any(CollectSubjectEnum.class),
                    any(DynamicObjectCollection.class), anyList()))
            .thenReturn(mockBills);
    OperationResult opResult = new OperationResult();
    KeyPartConfirmResult mockResult = new KeyPartConfirmResult(opResult, opResult);
    keyPartCollectHelper.when(() -> KeypartCollectHelper
            .saveRecordsToAudit(anyList())).thenReturn(mockResult);
    plugin.afterDoOperation(args);
    //assert 最终操作成功，弹出成功提示
    verify(testView).showSuccessNotification(anyString());
}
```

---

## BasePluginTest 提供的常用工具一览

| 工具方法/变量 | 说明 |
|---|---|
| `plugin` | 被测插件实例（由 `createPlugin()` 返回） |
| `testView` | IFormView mock |
| `testModel` | IDataModel mock |
| `pageCache` | PageCache mock |
| `mockBeforeDoOperationEventArgs(opKey)` | 构造 BeforeDoOperationEventArgs |
| `mockAfterDoOperationEventArgs(opKey)` | 构造 AfterDoOperationEventArgs |
| `mockPropertyChangedArgs(field, old, new, row)` | 构造 PropertyChangedArgs |
| `mockPropertyChangedArgs(field, old, new, row, parentEntry)` | 构造子单据体 PropertyChangedArgs |
| `mockClosedCallBackEvent(actionId, returnData)` | 构造 ClosedCallBackEvent |
| `mockMessageBoxClosedEvent(callbackId, result)` | 构造 MessageBoxClosedEvent |
| `mockClientCallBackEvent(key, data)` | 构造 ClientCallBackEvent |
| `mockGetControl(key, Class)` | 获取控件 mock |
| `mockRowClickEvent(entryKey, row, isEmpty)` | 构造 RowClickEvent |
| `mockAfterAddRowEventArgs(entryKey, rows, data)` | 构造 AfterAddRowEventArgs |
| `mockBeforeDeleteRowEventArgs(entryKey, rows)` | 构造 BeforeDeleteRowEventArgs |
| `mockAfterDeleteRowEventArgs(entryKey)` | 构造 AfterDeleteRowEventArgs |
| `mockBeforeFieldPostBackEvent(field, row, ctrlClass, value)` | 构造 BeforeFieldPostBackEvent |
| `mockBatchCreateNewEntryRow(entryKey, startIndex)` | mock 批量增行 |
| `mockPageCacheGet(key, value)` | mock pageCache.get |
| `mockEventObject(data)` | 构造 EventObject |

---

## 要点总结

### 类结构
- 继承 `BasePluginTest<被测插件类>`，实现 `createPlugin()` 工厂方法
- MockedStatic 变量在 `@Before` 初始化、`@After` 释放
- 公共辅助 mock 方法抽取到类级别复用

### Switch 分支
- **每个操作码对应独立测试方法**，方法名包含操作标识
- 同一操作码的不同数据场景追加后缀（如 `_dataExists`、`_emptyRow`）

### If/Else 分支
- 条件组合（笛卡尔积）可合并在同一方法中依次测试
- 每个分支用 `//step` 注释说明条件设置，`//assert` 验证结果
- 使用 `verify(mock, times(N))` 精确断言调用次数

### DynamicObject 断言
- `getString()` 不为 null，空值用 `assertEquals("", ...)`
- `getLong()` 不为 null，空值用 `assertEquals(0L, ...)`
- `getBigDecimal()` 不为 null，空值用 `assertEquals(BigDecimal.ZERO, ...)`
- `getDate()` 可能为 null，需 `assertNull` 或 `assertNotNull`

### 常量 & 枚举
- 所有字段标识、操作码、回调 ID 均引用常量类（如 `OP.OPENRECORD`、`ENTRYENTITY_PRODSN.UUID`）
- 推荐避免使用字面值字符串替代常量引用
- 找不到常量/枚举时建议告知用户，由用户补充
