Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,6 @@ tsconfig.tsbuildinfo

# codex
.omx/

# Local visual regression runs
.vtable-visual/
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# VTable 开发约定

## 本地视觉测试

- 使用 Node.js 22,在仓库根目录运行视觉测试。开发完成后按改动范围执行相关单例或目录,例如 `node packages/vtable/scripts/visual-test.mjs --case list-basic` 或 `node packages/vtable/scripts/visual-test.mjs --dir pivot`;影响多个模块时扩大测试范围。
- 提交 PR 前运行全量:`node packages/vtable/scripts/visual-test.mjs`。检查报告中的基线、当前和差异图;执行错误或无法运行时如实说明,不把 `--self-compare` 当作官方基线通过,也不通过跳过断言或放宽阈值掩盖差异。
- 新增功能、修复 bug 或发现覆盖空缺时,可以新增有明确目的的确定性用例,或补强现有用例。新用例登记在 `packages/vtable/__tests__/visual/cases/index.mjs`;交互用例须执行真实动作并断言结果。新增或修改用例先运行单例 `--self-compare`,再运行相关目录。
- 开发者或编码 Agent 独立新增的用例**完全省略** `BugServer case IDs` 行,不填写空值、占位或虚构 ID。由现有 BugServer case 迁移的用例保留真实来源 ID,合并来源时列出全部真实 ID。未来同步到 BugServer 成功后,再补写服务返回的真实 ID;本期没有同步脚本。
- 环境准备、报告和清理见 [视觉测试说明](./packages/vtable/__tests__/visual/README.md);目录与用例格式见 [用例指南](./packages/vtable/__tests__/visual/cases/README.md)。本次任务启动的浏览器、服务和其他测试进程在结束时应停止。
41 changes: 41 additions & 0 deletions packages/vtable/__tests__/visual/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# VTable 本地视觉回归

本工具在本机将当前工作区与官方 `VisActor/VTable` 的 `develop` 构建逐例截图比较。用例由 BugServer Photo case 人工筛选、去业务化改写而来,另含少量独立的基础用例;运行不依赖 BugServer。两侧共用冻结的用例副本,只替换 VTable 构建产物。截图差异需要人工检查;自比较通过只说明运行稳定。

## 准备

在仓库根目录使用 Node.js 22,安装 Rush 依赖和锁文件对应的 Chromium:

```sh
node common/scripts/install-run-rush.js install --ignore-hooks
PLAYWRIGHT_SKIP_BROWSER_GC=1 node packages/vtable/node_modules/@playwright/test/cli.js install chromium
```

Linux 还需安装 Chromium 的系统库。`--check` 会实际启动浏览器做预检,但不会安装依赖或构建。

## 运行

```sh
node packages/vtable/scripts/visual-test.mjs --check
node packages/vtable/scripts/visual-test.mjs --list
node packages/vtable/scripts/visual-test.mjs --case list-basic --self-compare
node packages/vtable/scripts/visual-test.mjs --dir pivot
node packages/vtable/scripts/visual-test.mjs
node packages/vtable/scripts/visual-test.mjs --baseline <完整的40位commit-sha>
```

`--case` 选择一个用例,`--dir` 递归选择一个相对目录,两者互斥;不传则运行全部。`--self-compare` 在两个隔离的浏览器上下文中执行相同的本地构建,用于检查用例确定性。默认基线每次从官方仓库获取 `develop` 并固定本轮 SHA;`--baseline` 从官方仓库获取指定 SHA。基线按自己的锁文件独立安装与构建,缓存最近一次成功的基线产物。当前工作区每轮构建包含未提交修改。

按用例需要构建核心 `vtable`,以及 `editors`、`gantt`、`plugins`、`sheet` 的本地 UMD 包;透视图还加载当前依赖锁定的 `vchart` 浏览器包。目录仅影响用例与所需包的选择;不会自动推断源码影响范围。

结果写入 Git 忽略的 `.vtable-visual/runs/<run-id>/`:`index.html` 可离线查看基线、当前和差异三图,`agent-summary.md` 便于快速定位失败,`summary.json` 保留结构化证据。退出码 `0` 表示所选用例均通过,`1` 表示视觉差异,`2` 表示执行、构建、资源或清理错误。报告可以整体复制,单独复制 HTML 会丢失相对图片。

正常结束、失败或 Ctrl+C 后,工具会回收本轮测试子进程、浏览器、HTTP 服务和临时基线 worktree。SIGKILL/断电后,先确认进程已结束,再检查 `git worktree list` 和 `.vtable-visual/running.lock`;不要删除其他任务的 worktree。

## 用例维护

目录说明及来源 ID 规则见 [用例指南](./cases/README.md)。开发完成后运行相关目录;提交 PR 前运行默认全量比较并检查差异。新功能或缺陷修复可以加独立用例。不得用放宽截图阈值、跳过语义断言或接受差异代替修复不稳定用例。工具自检:

```sh
node --test packages/vtable/scripts/visual-test.test.mjs
```
20 changes: 20 additions & 0 deletions packages/vtable/__tests__/visual/cases/COVERAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# VTable 本地视觉覆盖

[`index.mjs`](./index.mjs) 是唯一可执行清单;在仓库根目录运行 `node packages/vtable/scripts/visual-test.mjs --list` 可查看所有用例的 ID、目的和路径。每个用例只验证其固定输入、动作与断言,不能代表相邻配置或 BugServer 全部用例都已覆盖。

| 用例目录 | 已有代表场景 |
| --- | --- |
| `table`、`header`、`cells` | 列表与多级表头、合并单元格、行序号、文本/复选框/开关/按钮/图片/链接等单元格类型 |
| `pivot`、`pivot-chart` | 维度、聚合与汇总、指标隐藏、排序、冻结、图例,以及面积图、玫瑰图、箱线图、热力图、散点图、旭日图等图表和交互 |
| `gantt`、`sheet`、`plugins` | 任务条与基线、公式及行列插入、筛选/填充柄/主从表/Excel 键盘插件 |
| `layout`、`frozen`、`scroll`、`theme`、`style`、`language`、`empty` | 自动尺寸、冻结区与阴影、滚动、主题和样式、多语言及空状态 |
| `interaction`、`keyboard`、`edit`、`sort`、`menu` | 悬停与选择、拖动和尺寸调整、复制粘贴、编辑、排序及菜单 |
| `analysis`、`records`、`tree`、`group`、`transpose`、`api`、`data`、`components`、`custom` | 过滤与聚合、记录更新、树/分组、转置、异步数据、API 更新和自定义布局 |

## 当前边界

- Sheet 公式复制填充目前断言邻格显示值,未验证公式引用随位置平移。
- 远程媒体资源、大规模数据的性能,以及透视树扩展标题路径与懒加载、多级分组的大规模动态更新,不属于当前本地视觉用例的已验证范围。
- 官方基线无法正常执行的来源不登记为通过用例。新增功能或修复缺陷时,优先增加对应触发条件和结果断言;不要以用例数量或配置关键词推断覆盖率。

运行方式、报告和失败含义见[视觉测试说明](../README.md),新增用例与真实 BugServer 来源 ID 规则见[用例指南](./README.md)。
21 changes: 21 additions & 0 deletions packages/vtable/__tests__/visual/cases/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# VTable 视觉用例

`index.mjs` 是唯一可执行清单;一份用例一个模块,按主要验证目的归类。用 `--list` 查看实际 ID、目的和文件路径;已覆盖的条件与缺口见 [覆盖清单](./COVERAGE.md)。下表用于选择相关范围,交互动作仍放在所属功能目录。

| 目录 | 主要覆盖 |
| --- | --- |
| `table`、`header`、`cells` | 列表基础、表头层级、单元格类型与格式 |
| `pivot`、`pivot-chart` | 透视维度、虚拟节点、透视图 |
| `gantt`、`sheet`、`plugins` | 扩展包的图形与 API |
| `layout`、`frozen`、`scroll`、`empty`、`theme` | 尺寸、冻结、滚动、空状态、主题 |
| `interaction`、`keyboard`、`edit`、`sort`、`menu` | 选择、编辑、排序 |
| `analysis`、`records`、`transpose` | 聚合、过滤、记录更新、转置 |
| `components`、`custom`、`language`、`tree`、`data`、`group`、`style`、`api` | 标题、自定义布局、多语言 |

新增用例先查找已有近邻,优先在一个明确场景中补强必要条件。模块默认导出 `mount(container)`、`verify(page)` 和可选 `exercise(page)`;`mount` 返回待测实例。输入必须固定、公开、无外网依赖。`exercise` 需要执行真实动作,`verify` 需要检查目标状态;截图负责外观,不能替代语义断言。模块和核心方法写中文目的注释。登记唯一短 ID、目的、文件及所需 `bundles`;未登记模块不会执行。

只有从 BugServer 现有 case 迁移的模块才在头部写 `BugServer case IDs: <真实ID>`;合并时列出全部真实来源 ID,改写时仍保留原 ID。开发者或编码 Agent 独立新增的本地用例完全省略这一行,不填空值、占位或虚构 ID;未来同步成功后才补写服务返回的真实 ID。该 ID 只用于追溯,不表示与线上内容实时同步。

从 BugServer 迁移来源时,缺少 `interactions` 字段仅表示没有附加录制动作;仍须检查源码中的事件、定时器、异步调用、后续 API 更新及资源。保留目标配置、数据边界、动作顺序和最终状态。不得复制业务原文、内部地址、凭证、原始数据或截图到公开仓库;来源不明时先在仓库外私有目录复核。

新增或修改后先运行单例 `--self-compare --case <id>`,再运行对应目录;提交前运行默认全量比较。若用例在官方基线不支持,报告为执行错误,应如实说明,不能把自比较当作双侧通过。
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* BugServer case IDs: 69d89a775bd8c7005e123da1
* 验证目的:小数数据的 AVG 与顶部 SUM 聚合在同一列表中显示。
* 改写:原例的未声明变量和错误释放调用改为直接返回实例。
*/
export default {
mount(container) {
// 用 0.1 与 0.2 触发浮点聚合,保留顶部和底部聚合位置。
return new window.VTable.ListTable(container, {
columns: [{ field: 'value', title: 'Value', width: 120, aggregation: [
{ aggregationType: window.VTable.TYPES.AggregationType.AVG },
{ aggregationType: window.VTable.TYPES.AggregationType.SUM, showOnTop: true }
] }],
records: [{ value: 0.1 }, { value: 0.2 }],
bottomFrozenRowCount: 3, widthMode: 'autoWidth', heightMode: 'autoHeight', autoWrapText: true
});
},
async verify(page) {
// 小数记录和聚合行均存在,外观及聚合文本交给截图比较。
await page.evaluate(() => {
const table = window.__visualTable;
if (table.rowCount < 5 || table.getCellOriginValue(0, 2) !== 0.1)
throw new Error('聚合行或小数记录缺失');
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* BugServer case IDs: 65ed9e7aa5483e00afa5869c
* 验证目的:同值合并、SUM 聚合和每页两条记录的分页共同显示。
* 改写:商品名称换成通用项,保留来源的 100/1/1/2/2/2 数值关系。
*/
export default {
mount(container) {
// 固定六条记录,第一页包含 100 与 1,聚合总和可见。
return new window.VTable.ListTable(container, {
records: [100, 1, 1, 2, 2, 2].map((price, index) => ({ area: 'A', product: `Item ${index + 1}`, price })),
columns: [
{ field: 'area', title: 'Area', width: 'auto', mergeCell: true,
aggregation: [{ aggregationType: window.VTable.TYPES.AggregationType.NONE, formatFun: () => 'Total' }] },
{ field: 'product', title: 'Product', width: 'auto' },
{ field: 'price', title: 'Price', width: 'auto',
aggregation: [{ aggregationType: window.VTable.TYPES.AggregationType.SUM,
formatFun: value => Math.round(value) }] }
],
widthMode: 'standard', pagination: { perPageCount: 2, currentPage: 0 }
});
},
async verify(page) {
// 检查分页数据与数值聚合在可见表格中出现。
await page.evaluate(() => {
const table = window.__visualTable;
const values = [];
for (let row = 0; row < table.rowCount; row++)
for (let col = 0; col < table.colCount; col++) values.push(table.getCellValue(col, row));
if (!values.includes('Item 1') || !values.includes('Item 2') || values.includes('Item 3'))
throw new Error('分页记录错误');
if (!values.some(value => Number(value) === 101 || Number(value) === 108))
throw new Error('SUM 聚合缺失');
});
}
};
27 changes: 27 additions & 0 deletions packages/vtable/__tests__/visual/cases/analysis/filter-api.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* BugServer case IDs: 65bf7a3197cc3d008de5b474
* 验证目的:字段枚举筛选和函数筛选同时应用于列表。
* 改写:用通用编号和状态替换人员信息,并缩小数据量以便本地稳定运行。
*/
export default {
mount(container) {
// 60 条固定记录的交集是 10 条,便于直接验证筛选结果。
const table = new window.VTable.ListTable(container, {
columns: [{ field: 'id', title: 'ID', width: 100 }, { field: 'state', title: 'State', width: 120 },
{ field: 'name', title: 'Name', width: 150 }],
records: Array.from({ length: 60 }, (_, i) => ({ id: i + 1, state: i % 2 === 0 ? 'A' : 'B', name: `Item ${i + 1}` })),
frozenColCount: 1, widthMode: 'standard'
});
table.updateFilterRules([{ filterKey: 'state', filteredValues: ['A'] },
{ filterFunc: record => record.id % 3 === 0 }]);
return table;
},
async verify(page) {
// 两个过滤条件应同时生效,且首条可见记录符合交集。
await page.evaluate(() => {
const table = window.__visualTable;
if (table.rowCount !== 11 || table.getCellValue(0, 1) !== 3)
throw new Error(`筛选结果错误:${table.rowCount}`);
});
}
};
44 changes: 44 additions & 0 deletions packages/vtable/__tests__/visual/cases/api/change-cell-values.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* 验证目的:匿名二维批量单元格更新保持行列映射,并让相邻单元格不变。
* 来源:独立 API 覆盖用例;含个人信息的 BugServer 来源已排除,不建立来源 ID 关联。
*/
export default {
mount(container) {
// 用固定的通用字段和数值建立可观察的二维更新范围。
return new window.VTable.ListTable(container, {
columns: [
{ field: 'id', title: 'ID', width: 100 },
{ field: 'amount', title: 'Amount', width: 120 },
{ field: 'state', title: 'State', width: 120 }
],
records: [
{ id: 1, amount: 10, state: 'new' },
{ id: 2, amount: 20, state: 'new' },
{ id: 3, amount: 30, state: 'new' }
]
});
},
async exercise(page) {
// 从第二列首条记录开始更新两行两列,并检查 API 的逐格成功结果。
await page.evaluate(async () => {
const changed = await window.__visualTable.changeCellValues(1, 1, [[11, 'ready'], [21, 'hold']]);
if (changed.length !== 2 || changed.some(row => row.length !== 2 || row.some(value => value !== true)))
throw new Error('批量更新结果不完整');
});
},
async verify(page) {
// 断言二维目标、原始记录和未触及的邻格均保持预期值。
await page.evaluate(() => {
const table = window.__visualTable;
const expected = [[11, 'ready'], [21, 'hold']];
for (let row = 0; row < expected.length; row++) {
for (let col = 0; col < expected[row].length; col++) {
if (table.getCellOriginValue(col + 1, row + 1) !== expected[row][col])
throw new Error('批量更新的行列映射错误');
}
}
if (table.getCellOriginValue(0, 1) !== 1 || table.getCellOriginValue(1, 3) !== 30)
throw new Error('批量更新越过目标范围');
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* BugServer case IDs: 65af6e6906085f008cf8f85f
* 验证目的:点击选中单元格的回调内调用 updateColumns 后,选中状态与内容仍有效。
* 改写:匿名固定记录保留点击触发更新的时机,用新标题使更新可观察。
*/
export default {
mount(container) {
// 一次点击触发一次相同结构列的重新配置。
const table = new window.VTable.ListTable(container, {
columns: [{ field: 'progress', title: 'Progress', width: 150 },
{ field: 'id', title: 'ID', width: 110 }, { field: 'name', title: 'Name', width: 150 }],
records: [{ progress: 20, id: 1, name: 'A' }, { progress: 40, id: 2, name: 'B' }]
});
window.__selectedColumnUpdates = 0;
table.on('click_cell', () => {
// 在点击事件同步调用 updateColumns,保留来源的重入路径。
if (window.__selectedColumnUpdates++) return;
table.updateColumns([{ field: 'progress', title: 'Progress', width: 150 },
{ field: 'id', title: 'ID', width: 110 }, { field: 'name', title: 'Name updated', width: 150 }]);
});
return table;
},
async exercise(page) {
// 实际点击第二列数据格,不通过 selectCell API 注入选中状态。
const point = await page.evaluate(() => {
const b = window.__visualTable.getCellRect(1, 1).bounds;
const host = document.getElementById('table').getBoundingClientRect();
return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 };
});
await page.mouse.click(point.x, point.y);
await page.waitForFunction(() => window.__selectedColumnUpdates > 0);
},
async verify(page) {
// 点击时列结构重建完成,选中范围与新增标题同时保持。
await page.evaluate(() => {
const table = window.__visualTable;
const range = table.getSelectedCellRanges()[0];
if (window.__selectedColumnUpdates < 1 || table.colCount !== 3 ||
table.getCellValue(2, 0) !== 'Name updated' || !range || range.start.col !== 1)
throw new Error(`点击更新后的选择状态异常:${JSON.stringify(range)}`);
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* BugServer case IDs: 65d45c7a97cc3d008de5b576
* 验证目的:鼠标离开单元格回调中更新列结构后表格继续正常绘制。
* 改写:使用匿名记录,保留 mouseleave_cell 中 updateColumns 的调用时机。
*/
export default {
mount(container) {
// 多行记录提供真实的单元格离开事件;回调只运行一次。
const table = new window.VTable.ListTable(container, {
columns: [{ field: 'id', title: 'ID', width: 110 },
{ field: 'name', title: 'Name', width: 180 }, { field: 'value', title: 'Value', width: 130 }],
records: [{ id: 1, name: 'A', value: 10 }, { id: 2, name: 'B', value: 20 },
{ id: 3, name: 'C', value: 30 }]
});
window.__mouseleaveUpdates = 0;
table.on('mouseleave_cell', () => {
// 事件回调里重建列是本例要验证的路径。
if (window.__mouseleaveUpdates++) return;
table.updateColumns([{ field: 'name', title: 'Name', width: 180 }]);
});
return table;
},
async exercise(page) {
// 从首个数据格移出到表格外侧,触发真实 mouseleave_cell。
const point = await page.evaluate(() => {
const b = window.__visualTable.getCellRect(0, 1).bounds;
const host = document.getElementById('table').getBoundingClientRect();
return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 };
});
await page.mouse.move(point.x, point.y);
await page.mouse.move(850, 550);
await page.waitForFunction(() => window.__mouseleaveUpdates > 0);
},
async verify(page) {
// 更新后只保留 Name 列,原始记录仍可渲染。
await page.evaluate(() => {
const table = window.__visualTable;
if (window.__mouseleaveUpdates < 1 || table.colCount !== 1 || table.getCellValue(0, 1) !== 'A')
throw new Error('鼠标离开后列更新失败');
});
}
};
Loading
Loading