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
32 changes: 32 additions & 0 deletions .github/workflows/skill-generation-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Skill generation contracts

on:
pull_request:
branches: ['develop', 'main']
paths:
- 'skills/vchart-development-assistant/**'
- 'packages/vchart/skill-maintenance/**'
- '.github/workflows/skill-generation-test.yml'
push:
branches: ['develop', 'main']
paths:
- 'skills/vchart-development-assistant/**'
- 'packages/vchart/skill-maintenance/**'
- '.github/workflows/skill-generation-test.yml'

permissions:
contents: read

jobs:
contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: astral-sh/setup-uv@v6
with:
python-version: '3.11'
- run: node --test packages/vchart/skill-maintenance/tests/spec-contract.test.cjs
- run: uv run --no-project --with pytest python -m pytest packages/vchart/skill-maintenance/tests -q
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,5 @@ packages/vchart/__tests__/runtime/node/**.png
# Local caches for the data science skill and its maintenance tests
/skills/data-science-visualization/**/__pycache__/
/packages/vchart-statplots/skill-maintenance/**/__pycache__/
/packages/vchart/skill-maintenance/**/__pycache__/
/skills/vchart-development-assistant/**/__pycache__/
22 changes: 22 additions & 0 deletions docs/skills/vchart-development-assistant-maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,25 @@ node --test packages/vchart/scripts/skill-metadata/distribution.test.mjs package
`audit/check` 只读,差异使退出码非零。`audit --report` 的 `provenance` 记录核心/扩展版本、TypeScript 版本、输入内容指纹和相关源码提交,报告保存在指定位置,不复制到技能目录。指纹不包含当前时间或无关文档提交。

生成只更新 type-meta 和补充声明;topkey 与人工详情按源码依据维护,audit 检查字段、作用域及导航。这些检查不要求用户对生成配置执行 schema 或编译验证。

## 生成器与配置契约回归

维护测试位于 `packages/vchart/skill-maintenance/tests`,不随运行 Skill 分发。
从仓库根执行,无需安装 VChart 仓库的 npm 依赖:

```sh
node --test packages/vchart/skill-maintenance/tests/spec-contract.test.cjs
uv run --no-project --with pytest python -m pytest packages/vchart/skill-maintenance/tests -q
```

对应 CI 为 `skill-generation-test.yml`。覆盖完整源码与辅助函数闭包、初始/编辑执行一致性、
内联源码安全序列化、任意工作目录输出,以及图/树数据、回调和空状态的有限配置检查。
这些测试不证明浏览器渲染、交互或模型行为正确,实际产物仍按 HTML/spec 工作流验收。

## 按变更范围选择模型回归

- 层级示例或 tooltip 取值变更:简单 Treemap 验证默认展示;明确要求自定义内容时,用不同字段和层级核对叶子记录、名称、数值及指定配色。
- 空状态变更:串行验证有数据、清空、恢复、再次清空和容器 resize;无数据时提示可见且不溢出,恢复后无残留提示。
- 图数据说明变更:按业务口径验证边的权重和聚合粒度。

这些是按实际变更选择的维护场景,不要求每次用户生成任务执行整套矩阵。空状态及其清空/恢复生命周期属于可选边缘回归,不作为本次知识库收敛的必过项;未通过的模型用例仍须如实记录。
212 changes: 212 additions & 0 deletions packages/vchart/skill-maintenance/tests/spec-contract.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {validateSpec} = require('../../../../skills/vchart-development-assistant/scripts/spec-contract.cjs');
const {validateSource} = require('../../../../skills/vchart-development-assistant/scripts/validate_spec.cjs');
const edges = [{source:'Channel',target:'Product',value:37}, {source:'Product',target:'Order',value:12}];
const graph = {nodes:[{name:'Channel'},{name:'Product'},{name:'Order'}],links:edges};
const codes = spec => validateSpec(spec).errors.map(e => e.code);
for (const [name, spec] of [
['root array', {type:'sankey',data:[{values:edges}]}],
['series', {type:'sankey',series:[{type:'sankey',data:{values:edges}}]}],
['root object', {type:'sankey',data:{values:edges}}],
['dataset reference', {type:'common',data:[{id:'flow',values:edges}],series:[{type:'sankey',dataId:'flow'}]}]
]) test(`rejects flat edges: ${name}`,()=>assert.deepEqual(codes(spec),['SANKEY_GRAPH_REQUIRED']));
for (const [name,data] of [
['graph',{values:[graph]}], ['empty',{values:[]}],['empty graph',{values:[{nodes:[],links:[]}]}],
['hierarchy',{values:[{nodes:[{name:'Root',children:[{name:'Leaf',value:9}]}]}]}],
['named records',{values:[{id:'nodes',values:graph.nodes},{id:'links',values:edges}]}],
['deferred reference',{fromDataId:'upstream'}]
]) test(`accepts supported sankey input: ${name}`,()=>assert.equal(validateSpec({type:'sankey',data}).ok,true));
test('does not validate an unrelated combined-chart dataset as sankey',()=>{
assert.equal(validateSpec({type:'common',data:[{id:'bars',values:[{x:'a',y:3}]},{id:'flow',values:[graph]}],series:[{type:'bar',dataId:'bars'},{type:'sankey',dataId:'flow'}]}).ok,true);
});
for (const series of [undefined, null, []]) {
test(`ordinary sankey ignores auxiliary dataset shapes with series ${String(series)}`, () => {
assert.equal(validateSpec({type:'sankey',series,data:[
{id:'flow',values:[graph]},
{id:'notes',values:[{note:'total',amount:49}]},
{id:'metadata',values:{description:'Flow context'}}
]}).ok,true);
});
}
test('ordinary sankey still rejects malformed selected data with valid auxiliary graph', () => {
assert.deepEqual(validateSpec({type:'sankey',data:[{values:edges},{values:[graph]}]}).errors.map(({code,path})=>({code,path})), [
{code:'SANKEY_GRAPH_REQUIRED',path:'spec.data[0].values'}
]);
});
test('source validation accepts ordinary sankey with auxiliary records', () => {
assert.equal(validateSource(`const spec = ${JSON.stringify({type:'sankey',data:[{values:[graph]},{values:[{note:'total'}]}]})};`).ok,true);
});
for (const [name, config] of [
['default first dataset', {}],
['explicit index zero', {dataIndex:0}],
['id takes precedence over index', {dataId:'flow',dataIndex:1}],
['inline data takes precedence', {data:{values:[graph]},dataId:'extra'}],
['unresolved id', {dataId:'missing'}],
['unresolved index', {dataIndex:9}],
]) test(`only checks selected sankey data: ${name}`,()=>{
assert.equal(validateSpec({type:'common',data:[{id:'flow',values:[graph]},{id:'extra',values:[{x:'a',y:3}]}],series:[{type:'sankey',...config}]}).ok,true);
});
test('checks explicit nonzero dataset index',()=>assert.equal(validateSpec({type:'common',data:[{values:edges},{values:[graph]}],series:[{type:'sankey',dataIndex:1}]}).ok,true));
test('still rejects malformed default first dataset',()=>assert.deepEqual(codes({type:'common',data:[{values:edges},{values:[graph]}],series:[{type:'sankey'}]}),['SANKEY_GRAPH_REQUIRED']));
test('rejects singular data.value',()=>assert.deepEqual(codes({type:'treemap',data:{value:{children:[]}}}),['DATA_VALUES_REQUIRED']));
test('rejects object instead of tree roots array',()=>assert.deepEqual(codes({type:'treemap',data:{values:{children:[]}}}),['HIERARCHY_ROOT_ARRAY']));
test('permits arbitrary hierarchy field names and non-100 totals',()=>assert.equal(validateSpec({type:'treemap',categoryField:'item',valueField:'revenue',data:{values:[{item:'Group',children:[{item:'A',revenue:7},{item:'B',revenue:16}]}]}}).ok,true));
test('rejects function label formatter but retains tooltip callbacks',()=>{
assert.deepEqual(codes({type:'treemap',data:{values:[]},label:{formatter:()=>''},nonLeafLabel:{formatMethod:()=>''},tooltip:{mark:{title:{value:()=>''}}}}),['FUNCTION_FORMATTER']);
});
test('evaluates complete source and helper closures',()=>assert.equal(validateSource('const rows = [{x:"P",y:31}]; function make() { return {type:"bar",data:{values:rows}}; } const spec = make();').ok,true));
test('rejects syntax errors',()=>assert.equal(validateSource('const spec = {').errors[0].code,'SOURCE_SYNTAX'));
test('reports deferred runtime globals without claiming spec was checked',()=>{
const r=validateSource('const spec = window.buildChart();');assert.equal(r.specChecked,false);assert.equal(r.warnings[0].code,'RUNTIME_VALIDATION_DEFERRED');
});
test('limits source evaluation',()=>assert.equal(validateSource('while(true) {}').ok,false));

test('treemap example preserves records and optional tooltip reads layout nodes',()=>{
const fs=require('node:fs'),vm=require('node:vm'),ctx={};
vm.runInNewContext(fs.readFileSync(require.resolve('../../../../skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js'),'utf8'),ctx);
const rows=[{region:'East',sku:'Blueberry',units:11},{region:'East',sku:'Cherry',units:32},{region:'West',sku:'Apple',units:7}];
const spec=ctx.createTreemapSpec(rows,{hierarchyFields:['region','sku'],valueField:'units'});
assert.deepEqual(JSON.parse(JSON.stringify(spec.data.values[0].children.flatMap(p=>p.children))),rows);
const tooltip=ctx.createTreemapTooltip('sku','units');
for (const node of [rows[0],{datum:rows[0]},{datum:[{sku:'Total'},rows[0]]}]) {
assert.equal(tooltip.mark.title.value(node),'Blueberry');
assert.equal(tooltip.mark.content[0].value(node),'11');
}
});

test('treemap example preserves empty input without synthetic leaves',()=>{
const fs=require('node:fs'),vm=require('node:vm'),ctx={};
vm.runInNewContext(fs.readFileSync(require.resolve('../../../../skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js'),'utf8'),ctx);
const spec=ctx.createTreemapSpec([],{hierarchyFields:['category','item'],valueField:'quantity'});
assert.equal(spec.data.values.length,0);
});

for (const axis of ['x','y']) test(`rejects CSS percentage custom-mark ${axis} regardless of chart and text`,()=>{
const result=validateSpec({type:'line',data:{values:[]},customMark:[{type:'text',style:{[axis]:' 50% ',text:'No matching measurements'}}]});
assert.deepEqual(result.errors.map(({code,path})=>({code,path})),[{code:'CUSTOM_MARK_PIXEL_COORDINATE',path:`spec.customMark[0].style.${axis}`}]);
});
test('validates graphic coordinates nested inside custom groups',()=>assert.deepEqual(codes({type:'bar',customMark:[{type:'group',children:[{type:'text',style:{x:'25%'}}]}]}),['CUSTOM_MARK_PIXEL_COORDINATE']));
test('accepts numeric coordinates and layout callbacks without executing them',()=>{
const callback=()=>{throw Error('needs browser layout')};
assert.equal(validateSpec({type:'treemap',customMark:[{type:'text',style:{x:callback,y:0,text:'Empty'}},{type:'text',style:{x:12,y:callback}}]}).ok,true);
});
test('leaves percentage layout properties and percentage text untouched',()=>assert.equal(validateSpec({type:'bar',padding:'5%',customMark:[{type:'text',style:{x:20,y:30,text:'50%'}}]}).ok,true));
test('accepts no custom marks after restoring data',()=>assert.equal(validateSpec({type:'bar',data:{values:[{category:'A',count:0}]},customMark:[]}).ok,true));

for (const value of ['(text, datum) => datum.count', '__CALLBACK__', 5, true, {}]) {
for (const key of ['label', 'nonLeafLabel']) {
test(`rejects non-function ${key}.formatMethod: ${JSON.stringify(value)}`, () => {
assert.deepEqual(codes({type:'treemap',[key]:{formatMethod:value}}), ['LABEL_FORMAT_METHOD_FUNCTION']);
});
}
}
test('checks series label callbacks', () => {
assert.deepEqual(validateSpec({type:'common',series:[{type:'bar',label:{formatMethod:'bad'}}]}).errors.map(e=>e.path), ['spec.series[0].label.formatMethod']);
});
test('preserves declarative formatting, tooltip strings and ordinary data fields', () => {
assert.equal(validateSpec({type:'bar',data:{values:[{formatMethod:'ordinary data'}]},label:{formatter:'{count}',formatMethod:(text,datum)=>datum.count},tooltip:{mark:{title:{value:'Total'}}}}).ok,true);
});
test('source validation rejects serialized callbacks before HTML generation', () => {
assert.deepEqual(validateSource('const spec = {type:"bar",label:{formatMethod:"(text) => text"}};').errors.map(e=>e.code), ['LABEL_FORMAT_METHOD_FUNCTION']);
});

for (const [name, spec] of [
['root graph object', {type:'sankey',data:{values:graph}}],
['inline series graph object', {type:'common',series:[{type:'sankey',data:{values:graph}}]}],
['referenced graph object', {type:'common',data:[{id:'flow',values:graph}],series:[{type:'sankey',dataId:'flow'}]}],
['indexed graph object', {type:'common',data:[{values:[graph]},{values:graph}],series:[{type:'sankey',dataIndex:1}]}],
['null values', {type:'sankey',data:{values:null}}]
]) test(`rejects malformed inline Sankey values: ${name}`,()=>assert.deepEqual(codes(spec),['SANKEY_GRAPH_REQUIRED']));
test('reports the field containing the bare graph',()=>assert.equal(validateSpec({type:'sankey',data:{values:graph}}).errors[0].path,'spec.data[0].values'));
for (const [name, data] of [
['parsed text', {values:'source,target,value\nA,B,3',parser:{type:'csv'}}],
['parsed object', {values:graph,parser:{type:'custom'}}],
['transformed rows', {values:edges,transforms:[{type:'customGraph'}]}],
['transformed object', {values:graph,transforms:[{type:'customGraph'}]}],
['referenced data', {values:graph,fromDataId:'upstream'}]
]) test(`defers Sankey input requiring runtime data processing: ${name}`,()=>assert.equal(validateSpec({type:'sankey',data}).ok,true));
test('does not impose the Sankey graph wrapper on another chart',()=>assert.equal(validateSpec({type:'custom',data:{values:graph}}).ok,true));
test('source validator catches a bare graph before generation',()=>assert.deepEqual(validateSource('const spec = {type:"sankey", data:{values:{nodes:[],links:[]}}};').errors.map(e=>e.code),['SANKEY_GRAPH_REQUIRED']));

test('does not confuse root datasets with named graph records',()=>assert.deepEqual(codes({type:'sankey',data:[{id:'nodes',values:graph.nodes},{id:'links',values:edges}]}),['SANKEY_GRAPH_REQUIRED']));
test('rejects malformed named graph record values',()=>assert.deepEqual(codes({type:'sankey',data:{values:[{id:'nodes',values:{}},{id:'links',values:edges}]}}),['SANKEY_GRAPH_REQUIRED']));

for (const data of [[], [{label:'A'}], 'items', {}]) {
test(`rejects non-function discrete legend data: ${JSON.stringify(data)}`,()=>assert.deepEqual(codes({type:'bar',legends:{data}}),['DISCRETE_LEGEND_DATA_FUNCTION']));
}
test('checks explicit discrete legends in an array with a field path',()=>assert.deepEqual(validateSpec({legends:[{type:'color',data:[]},{type:'discrete',data:[]}]}).errors.map(({code,path})=>({code,path})),[{code:'DISCRETE_LEGEND_DATA_FUNCTION',path:'spec.legends[1].data'}]));
test('accepts legend data callbacks without evaluating runtime dependencies',()=>assert.equal(validateSpec({legends:{data:()=>{throw Error('requires runtime legend items')}}}).ok,true));
for (const legends of [undefined, {}, {data:null}, {type:'color',data:[]}, {type:'size',data:[]}]) {
test(`leaves omitted and continuous legend data alone: ${JSON.stringify(legends)}`,()=>assert.equal(validateSpec({legends}).ok,true));
}
test('source validation rejects discrete legend arrays',()=>assert.deepEqual(validateSource('const spec={type:"bar",legends:{data:[]}};').errors.map(e=>e.code),['DISCRETE_LEGEND_DATA_FUNCTION']));


test('treemap example leaves optional presentation features to the caller',()=>{
const fs=require('node:fs'),vm=require('node:vm'),ctx={};
vm.runInNewContext(fs.readFileSync(require.resolve('../../../../skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js'),'utf8'),ctx);
const spec=ctx.createTreemapSpec([{department:'Ops',name:'Service',amount:9}],{hierarchyFields:['department','name'],valueField:'amount'});
for (const key of ['legends','leaf','splitType']) assert.equal(Object.hasOwn(spec,key),false);
assert.deepEqual(JSON.parse(JSON.stringify(spec.tooltip)),{visible:true});
assert.deepEqual(JSON.parse(JSON.stringify(spec.label)),{visible:true});
});

for (const value of [{}, 'bar', 42, false]) {
test(`reports non-array series as configuration errors: ${JSON.stringify(value)}`, () => {
const result = validateSpec({type: 'common', series: value});
assert.equal(result.ok, false);
assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
{code: 'SERIES_ARRAY_REQUIRED', path: 'spec.series'},
]);
});
}
for (const value of [null, undefined, 'bar', 42, [], false]) {
test(`reports invalid series entries at their original index: ${String(value)}`, () => {
const result = validateSpec({type: 'common', series: [{type: 'bar'}, value]});
assert.equal(result.ok, false);
assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
{code: 'SERIES_OBJECT_REQUIRED', path: 'spec.series[1]'},
]);
});
}
for (const series of [undefined, null, []]) {
test(`preserves root chart validation without active series: ${String(series)}`, () => {
assert.deepEqual(codes({type: 'sankey', series, data: {values: edges}}), ['SANKEY_GRAPH_REQUIRED']);
});
}
test('source validation distinguishes malformed series from source evaluation failure', () => {
const result = validateSource('const spec = {type:"common", series:{type:"bar"}};');
assert.equal(result.specChecked, true);
assert.deepEqual(result.errors.map(e => e.code), ['SERIES_ARRAY_REQUIRED']);
});

for (const data of ['input.csv', 0, false, [null], [1], [['nested']]]) {
test(`rejects malformed data sources without throwing: ${JSON.stringify(data)}`, () => {
const result = validateSpec({type: 'sankey', data});
assert.equal(result.ok, false);
assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
{code: 'DATA_SOURCE_OBJECT_REQUIRED', path: 'spec.data[0]'},
]);
});
}
test('reports invalid inline series data at the series path', () => {
const result = validateSpec({type: 'common', series: [{type: 'sankey', data: [false]}]});
assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
{code: 'DATA_SOURCE_OBJECT_REQUIRED', path: 'spec.series[0].data[0]'},
]);
});
test('reports invalid referenced root data once at its source path', () => {
const result = validateSpec({type: 'common', data: [{values: []}, 1], series: [{type: 'sankey', dataIndex: 1}]});
assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
{code: 'DATA_SOURCE_OBJECT_REQUIRED', path: 'spec.data[1]'},
]);
});
test('accepts parsed text inside a data descriptor', () => {
assert.equal(validateSpec({type: 'bar', data: {values: 'category,value\nA,1', parser: {type: 'csv'}}}).ok, true);
});
test('source validation classifies malformed data as a configuration error', () => {
const result = validateSource('const spec = {type:"sankey", data:[1]};');
assert.equal(result.specChecked, true);
assert.deepEqual(result.errors.map(e => e.code), ['DATA_SOURCE_OBJECT_REQUIRED']);
});
Loading
Loading