Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/vchart/__tests__/visual/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Rush 安装还需要项目现有的 node-canvas 编译依赖;按仓库贡献
```sh
node packages/vchart/scripts/visual-test.mjs
node packages/vchart/scripts/visual-test.mjs --baseline <完整的40位commit-sha>
node packages/vchart/scripts/visual-test.mjs --baseline <完整的40位commit-sha> --offline
node packages/vchart/scripts/visual-test.mjs --dir components
node packages/vchart/scripts/visual-test.mjs --dir components/label
node packages/vchart/scripts/visual-test.mjs --case pie-label
Expand All @@ -49,6 +50,7 @@ node packages/vchart/scripts/visual-test.mjs --check

- 默认每次从官方仓库获取 develop,并固定本次 SHA;fork 的 `origin` 不影响基线选择。断网或拉取失败会报错,缓存不会冒充最新版本。
- 指定 SHA 仍从官方仓库获取该提交;用于复现已知基线。首期不支持任意仓库或共同祖先自动选择。
- `--offline` 仅配合完整 `--baseline` 使用:宿主先准备官方提交和同配置依赖,执行时复用本地提交与依赖存储。两侧完整 Rush 配置及根目录、各项目的 `package.json` 必须相同;锁文件相同或缓存命中也不能绕过清单检查。项目链接各自解析到基线或当前源码,不执行联网 fetch/install,无法满足时直接失败。
- 当前工作区包含未提交修改,每次重新构建;构建过程中源码发生变化会要求重跑。
- 基线按自己的锁文件独立安装和构建。只缓存最近一次成功的基线构建,每次重新截图;缓存失效由 SHA、锁文件、Node、系统架构和构建配方决定;报告样式修改不使缓存失效,内容损坏会重建。
- 自比较只构建本地一次,在两套隔离 context 中执行;它验证测试确定性,不能证明图表结果正确。
Expand Down Expand Up @@ -97,7 +99,7 @@ node packages/vchart/node_modules/@playwright/test/cli.js show-report .vchart-vi

## 用例约定

当前 262 个本地用例在 `cases/index.mjs` 显式注册,一用例一文件,按主要验证目的归入 `charts/`、`components/`、`data/`、`layout/`、`theme/`、`interaction/` 和 `api/`。详见 [目录导航、用例说明和待补场景](./cases/README.md)。元数据仅在清单中维护,每项包含 `id`、`purpose`、`file`,`sourceExample` 为可选的真实公开示例来源。
当前 264 个本地用例在 `cases/index.mjs` 显式注册,一用例一文件,按主要验证目的归入 `charts/`、`components/`、`data/`、`layout/`、`theme/`、`interaction/` 和 `api/`。详见 [目录导航、用例说明和待补场景](./cases/README.md)。元数据仅在清单中维护,每项包含 `id`、`purpose`、`file`,`sourceExample` 为可选的真实公开示例来源。

ID 必须唯一且符合 `^[a-z][a-z0-9-]*$`;file 使用 cases 内的 `./<目录>/<name>.mjs`,目录层级和文件名使用小写字母、数字与连字符。缺文件、非法导出、越界、符号链接和空选择集在构建前失败,未登记的模块不会自动执行。

Expand Down
2 changes: 1 addition & 1 deletion packages/vchart/__tests__/visual/cases/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 本地视觉测试用例

一份 case 一个文件,按主要验证目的归类,`index.mjs` 是唯一可执行清单。开发者或编码 Agent 根据改动选择测试范围;工具不推断源码影响范围。当前已登记 262 个用例,具体 ID、目的和文件用 `--list` 查询,配置分支与限制见 [覆盖清单](./COVERAGE.md)。
一份 case 一个文件,按主要验证目的归类,`index.mjs` 是唯一可执行清单。开发者或编码 Agent 根据改动选择测试范围;工具不推断源码影响范围。当前已登记 264 个用例,具体 ID、目的和文件用 `--list` 查询,配置分支与限制见 [覆盖清单](./COVERAGE.md)。

## 目录与选择

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { verifySourceSpec, verifyRendered } from '../../../helpers.mjs';
import { interactionFrame } from '../../../interaction-helpers.mjs';
import { interactionFrame, clippedAxisLabelTarget } from '../../../interaction-helpers.mjs';
/**
* BugServer case IDs: 64b7beba14220f89e4720c80
* 迁移边界:来源固定 350×200;执行时缩窄到 180×200,形成截断后悬停,保留 flush/autoLimit 配置和原始数据。
Expand Down Expand Up @@ -43,22 +43,7 @@ export default {
// 执行来源配置或回调要求的动作并保存实际结果。

await page.evaluate(() => window.__visualChart.resize(180, 200));
const p = await page.evaluate(() => {
const g = window.__visualChart
.getStage()
.find(
g =>
g.name === 'axis-label' &&
g.type === 'text' &&
g.cliped &&
g.globalAABBBounds.y1 > 30 &&
g.globalAABBBounds.y2 < 500,
true
);
if (!g) throw Error('来源未产生截断轴标签');
const b = g.globalAABBBounds;
return { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2, text: String(g.attribute.text) };
});
const p = await clippedAxisLabelTarget(page);
await page.mouse.move(p.x, p.y);
await interactionFrame(page);
await page.evaluate(p => (window.__poptipTarget = p), p);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { verifySourceSpec, verifyRendered } from '../../../helpers.mjs';
import { interactionFrame } from '../../../interaction-helpers.mjs';
import { interactionFrame, clippedAxisLabelTarget } from '../../../interaction-helpers.mjs';
/**
* BugServer case IDs: 64b7bf3f14220f89e4720c82
* 迁移边界:定位实际截断的中间轴标签,避免轴交点的遮挡;记录的旧坐标未被当作新宿主的目标。
Expand Down Expand Up @@ -86,22 +86,7 @@ export default {
async exercise(page) {
// 执行来源配置或回调要求的动作并保存实际结果。

const p = await page.evaluate(() => {
const g = window.__visualChart
.getStage()
.find(
g =>
g.name === 'axis-label' &&
g.type === 'text' &&
g.cliped &&
g.globalAABBBounds.y1 > 30 &&
g.globalAABBBounds.y2 < 500,
true
);
if (!g) throw Error('来源未产生截断轴标签');
const b = g.globalAABBBounds;
return { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2, text: String(g.attribute.text) };
});
const p = await clippedAxisLabelTarget(page);
await page.mouse.move(p.x, p.y);
await interactionFrame(page);
await page.evaluate(p => (window.__poptipTarget = p), p);
Expand Down
23 changes: 23 additions & 0 deletions packages/vchart/__tests__/visual/interaction-helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,35 @@ export async function interactionTarget(page, name, index = 0, seriesIndex) {
y: matrix.b * local.x + matrix.d * local.y + matrix.f
};
const b = g.globalAABBBounds;
// 混合系列可能遮住柱体中点;优先找可命中内点,刷选覆盖层仍保留原中心坐标。
if (name === 'bar') {
for (const fraction of [0.5, 0.25, 0.75]) {
const x = b.x1 + (b.x2 - b.x1) * fraction,
y = (b.y1 + b.y2) / 2;
if (window.__visualChart.getStage().pick(x, y)?.graphic === g) return { x, y };
}
}
return { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2 };
},
{ name, index, seriesIndex }
);
}

/** 选实际可命中的截断轴标签,排除被轴交点遮挡的包围盒中心。 */
export async function clippedAxisLabelTarget(page) {
return page.evaluate(() => {
const stage = window.__visualChart.getStage();
const graphic = stage.find(g => {
if (g.name !== 'axis-label' || g.type !== 'text' || !g.cliped) return false;
const b = g.globalAABBBounds;
return b.y1 > 30 && b.y2 < 500 && stage.pick((b.x1 + b.x2) / 2, (b.y1 + b.y2) / 2)?.graphic === g;
}, true);
if (!graphic) throw new Error('来源未产生可命中的截断轴标签');
const b = graphic.globalAABBBounds;
return { x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2, text: String(graphic.attribute.text) };
});
}

/** 让输入事件后的绘制队列完成;是否生效仍由独立 verify 检查真实状态。 */
export async function interactionFrame(page) {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
Expand Down
111 changes: 111 additions & 0 deletions packages/vchart/scripts/visual-offline.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { parseOptions } from './visual-test.mjs';
import { resolveBaseline, offlineDependencies, baselineBuild } from './visual/build.mjs';

/** 离线模式既不联网,也不能让基线链接借用候选源码或旧配置缓存。 */
test('离线基线固定提交、依赖来源和缓存配置', async () => {
assert.throws(() => parseOptions(['--offline']), /需要/);
assert.equal(parseOptions(['--offline', '--baseline', 'a'.repeat(40)]).offline, true);
const root = await fs.mkdtemp(path.join(tmpdir(), 'vchart-offline-'));
const worktree = await fs.mkdtemp(path.join(tmpdir(), 'vchart-offline-base-'));
const git = args =>
execFileSync('git', args, {
cwd: root,
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@example.invalid'
}
}).trim();
try {
await fs.mkdir(path.join(root, 'common/config/rush'), { recursive: true });
await fs.mkdir(path.join(root, 'common/temp'), { recursive: true });
for (const [file, content] of [
['common/config/rush/pnpm-lock.yaml', 'lockfileVersion: 5.4\n'],
['common/config/rush/pnpm-config.json', '{"useWorkspaces":true}\n'],
['rush.json', '{}\n'],
['package.json', '{}\n'],
['.gitignore', 'node_modules/\n.vchart-visual/\n']
])
await fs.writeFile(path.join(root, file), content);
for (const name of ['vchart', 'vutils-extension']) {
await fs.mkdir(path.join(root, 'packages', name), { recursive: true });
await fs.writeFile(path.join(root, 'packages', name, 'package.json'), '{}\n');
}
await fs.writeFile(path.join(root, 'packages/vutils-extension/version.txt'), 'baseline');
await fs.mkdir(path.join(root, 'packages/vchart/node_modules/@visactor'), { recursive: true });
await fs.symlink(
'../../../vutils-extension',
path.join(root, 'packages/vchart/node_modules/@visactor/vutils-extension'),
'dir'
);
git(['init', '--quiet']);
git(['add', '.']);
git(['-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'fixture']);
const sha = git(['rev-parse', 'HEAD']);
await fs.writeFile(path.join(root, 'packages/vutils-extension/version.txt'), 'current');
await fs.mkdir(path.join(worktree, 'packages/vutils-extension'), { recursive: true });
await fs.writeFile(path.join(worktree, 'packages/vutils-extension/version.txt'), 'baseline');
const runtime = { offline: true, run: () => assert.fail('离线模式不能联网 fetch') };
assert.equal(await resolveBaseline(root, sha, runtime, ''), sha);
await assert.rejects(() => resolveBaseline(root, undefined, runtime, ''), /基线/);
await offlineDependencies(root, worktree, sha);
assert.equal(
await fs.realpath(path.join(worktree, 'common/temp')),
await fs.realpath(path.join(root, 'common/temp'))
);
assert.equal(
await fs.readFile(
path.join(worktree, 'packages/vchart/node_modules/@visactor/vutils-extension/version.txt'),
'utf8'
),
'baseline'
);
const runDir = path.join(root, '.vchart-visual/test-run');
await fs.mkdir(runDir, { recursive: true });
const report = { baseline: {} },
cacheRuntime = {
offline: true,
defer() {},
async run(command, args) {
assert.equal(command, 'git');
assert.equal(args[0], 'worktree');
await fs.mkdir(args[3], { recursive: true });
}
};
const buildOptions = { buildBundle: async (_root, destination) => fs.writeFile(destination, 'cache fixture') };
await baselineBuild(root, runDir, sha, cacheRuntime, report, (_name, action) => action(), buildOptions);
await baselineBuild(root, runDir, sha, cacheRuntime, report, (_name, action) => action(), buildOptions);
assert.equal(report.baseline.cacheHit, true);
// 锁文件不变也不能共享不同的项目声明,已有基线缓存不能绕过检查。
await fs.writeFile(path.join(root, 'packages/vchart/package.json'), '{"dependencies":{"beta":"1.0.0"}}\n');
await assert.rejects(
() => offlineDependencies(root, path.join(worktree, 'manifest-mismatch'), sha),
/依赖配置不同/
);
await assert.rejects(
() => baselineBuild(root, runDir, sha, cacheRuntime, report, (_name, action) => action(), buildOptions),
/依赖配置不同/
);
await fs.writeFile(path.join(root, 'packages/vchart/package.json'), '{}\n');
await fs.writeFile(path.join(root, 'common/config/rush/pnpm-config.json'), '{"useWorkspaces":false}\n');
await assert.rejects(
() => baselineBuild(root, runDir, sha, cacheRuntime, report, (_name, action) => action(), buildOptions),
/依赖配置不同/
);
await fs.writeFile(path.join(root, 'common/config/rush/pnpm-config.json'), '{"useWorkspaces":true}\n');
await fs.writeFile(path.join(root, 'common/config/rush/pnpm-lock.yaml'), 'changed\n');
await assert.rejects(() => offlineDependencies(root, worktree, sha), /依赖配置不同/);
} finally {
await fs.rm(root, { recursive: true, force: true });
await fs.rm(worktree, { recursive: true, force: true });
}
});
49 changes: 49 additions & 0 deletions packages/vchart/scripts/visual-source-snapshot.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { snapshotSource } from './visual/runner.mjs';
import { workingTree } from './visual/build.mjs';

test('结束快照记录真实源码,截图后编辑或快照读取失败均阻止通过', async () => {
const root = await fs.mkdtemp(path.join(tmpdir(), 'vchart-source-snapshot-'));
const git = args =>
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], {
cwd: root,
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@example.invalid'
}
});
try {
git(['init', '--quiet']);
await fs.writeFile(path.join(root, 'source.js'), 'before');
git(['add', '.']);
git(['commit', '--quiet', '-m', 'fixture']);
const local = await workingTree(root),
stable = { local, issues: [] };
await snapshotSource(root, stable);
assert.deepEqual(stable.localAfter, local);
assert.deepEqual(stable.issues, []);
await fs.writeFile(path.join(root, 'source.js'), 'edited-after-screenshot');
const changed = { local, issues: [] };
await snapshotSource(root, changed);
assert.equal(changed.localAfter.head, local.head);
assert.notEqual(changed.localAfter.digest, local.digest);
assert.equal(changed.issues[0].code, 'WORKSPACE_CHANGED');
const missing = { local, issues: [] };
await snapshotSource(path.join(root, 'missing'), missing);
assert.equal(missing.issues[0].code, 'SOURCE_SNAPSHOT_FAILED');
const unprepared = { issues: [] };
await snapshotSource(root, unprepared);
assert.equal(unprepared.localAfter, undefined);
assert.deepEqual(unprepared.issues, []);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
49 changes: 49 additions & 0 deletions packages/vchart/scripts/visual-targets.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runInNewContext } from 'node:vm';
import { interactionTarget, clippedAxisLabelTarget } from '../__tests__/visual/interaction-helpers.mjs';

/** 将定位函数放入独立页面上下文,用覆盖层和轴交点验证真实命中规则。 */
function pageFor(chart) {
return {
evaluate: (fn, input) => runInNewContext(`(${fn.toString()})(input)`, { input, window: { __visualChart: chart } })
};
}

test('混合系列定位避开被面积覆盖的柱心,刷选覆盖层仍使用几何中心', async () => {
const graphic = { attribute: {}, globalAABBBounds: { x1: 0, y1: 0, x2: 100, y2: 100, width: () => 100 } };
let covered = false;
const page = pageFor({
getChart: () => ({ getAllSeries: () => [{ getMarks: () => [{ name: 'bar', getGraphics: () => [graphic] }] }] }),
getStage: () => ({ pick: x => ({ graphic: !covered && x < 50 ? graphic : {} }) })
});
const visible = await interactionTarget(page, 'bar');
assert.equal(visible.x, 25);
assert.equal(visible.y, 50);
covered = true;
const center = await interactionTarget(page, 'bar');
assert.equal(center.x, 50);
assert.equal(center.y, 50);
await assert.rejects(() => interactionTarget(page, 'missing'), /缺少交互图元/);
});

test('截断轴标签定位拒绝交点遮挡,无可命中标签时明确失败', async () => {
const labels = [470, 370].map(y => ({
name: 'axis-label',
type: 'text',
cliped: true,
attribute: { text: 'full-' + y },
globalAABBBounds: { x1: 20, x2: 90, y1: y - 5, y2: y + 5 }
}));
let blocked = false;
const stage = {
find: predicate => labels.find(predicate),
pick: (_x, y) => ({ graphic: !blocked && y < 400 ? labels[1] : {} })
};
const page = pageFor({ getStage: () => stage });
const target = await clippedAxisLabelTarget(page);
assert.equal(target.text, 'full-370');
assert.equal(target.y, 370);
blocked = true;
await assert.rejects(() => clippedAxisLabelTarget(page), /可命中的截断轴标签/);
});
4 changes: 3 additions & 1 deletion packages/vchart/scripts/visual-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function parseOptions(args) {
tokens: true,
options: {
baseline: { type: 'string' },
offline: { type: 'boolean' },
case: { type: 'string' },
dir: { type: 'string' },
'self-compare': { type: 'boolean' },
Expand Down Expand Up @@ -43,6 +44,7 @@ export function parseOptions(args) {
if (values.baseline !== undefined && !/^[a-f0-9]{40}$/i.test(values.baseline))
throw fault('INVALID_ARGUMENT', '--baseline 需要完整 40 位 SHA');
if (values.baseline && values['self-compare']) throw fault('INVALID_ARGUMENT', '--baseline 与 --self-compare 互斥');
if (values.offline && !values.baseline) throw fault('INVALID_ARGUMENT', '--offline 需要已准备的 --baseline 完整 SHA');
if (values.case !== undefined && !/^[a-z][a-z0-9-]*$/.test(values.case))
throw fault('INVALID_ARGUMENT', '--case 需要有效用例 ID');
return values;
Expand All @@ -56,7 +58,7 @@ export async function main(configuration = {}) {
if (options.help) {
console.log(
command +
' [--baseline <40位SHA> | --self-compare] [--dir <目录> | --case <id>]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 components/label)。\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VChart develop。退出码:0 通过,1 视觉差异,2 执行错误。'
' [--baseline <40位SHA> [--offline] | --self-compare] [--dir <目录> | --case <id>]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 components/label)。\n--offline 使用宿主已准备的提交与同锁依赖,不访问网络。\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VChart develop。退出码:0 通过,1 视觉差异,2 执行错误。'
);
return;
}
Expand Down
Loading
Loading