diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75bfc9f2..0deca803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,20 @@ jobs: - name: Lint run: pnpm lint:ts + unit-test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x] + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + uses: ./.github/actions/prepare-install + with: + node-version: ${{ matrix.node-version }} + - name: Run unit tests + run: pnpm test + ssr-test: runs-on: ubuntu-latest if: github.base_ref != 'ai' diff --git a/__tests__/area.test.ts b/__tests__/area.test.ts new file mode 100644 index 00000000..44e7b0bb --- /dev/null +++ b/__tests__/area.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - area chart', () => { + it('should parse basic area chart', () => { + const result = parse(` +vis area +data + - time 1月 + value 23.895 + - time 2月 + value 23.695 + - time 3月 + value 23.655 +title 1月到3月股票价格的变化 +axisXTitle 月份 +axisYTitle 价格 + `); + + expect(result).toEqual({ + type: 'area', + data: [ + { time: '1月', value: 23.895 }, + { time: '2月', value: 23.695 }, + { time: '3月', value: 23.655 }, + ], + title: '1月到3月股票价格的变化', + axisXTitle: '月份', + axisYTitle: '价格', + }); + }); + + it('should parse stacked area chart', () => { + const result = parse(` +vis area +data + - time 2019年 + value 150 + group 北京 + - time 2020年 + value 160 + group 北京 + - time 2019年 + value 100 + group 广州 + - time 2020年 + value 110 + group 广州 +stack true +title 城市空气污染指数变化 + `); + + expect(result.stack).toBe(true); + expect(result.data).toEqual([ + { time: '2019年', value: 150, group: '北京' }, + { time: '2020年', value: 160, group: '北京' }, + { time: '2019年', value: 100, group: '广州' }, + { time: '2020年', value: 110, group: '广州' }, + ]); + }); + + it('should parse area chart with numeric time', () => { + const result = parse(` +vis area +data + - time 2015 + value 7200 + - time 2016 + value 3660 + - time 2017 + value 4100 + `); + + expect(result.data).toEqual([ + { time: 2015, value: 7200 }, + { time: 2016, value: 3660 }, + { time: 2017, value: 4100 }, + ]); + }); +}); diff --git a/__tests__/bar.test.ts b/__tests__/bar.test.ts new file mode 100644 index 00000000..65a617ce --- /dev/null +++ b/__tests__/bar.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - bar chart', () => { + it('should parse basic bar chart', () => { + const result = parse(` +vis bar +data + - category 2015年 + value 80 + - category 2016年 + value 140 + - category 2017年 + value 220 +title 海底捞公司外卖收入 +axisXTitle 年份 +axisYTitle 金额(百万元) + `); + + expect(result).toEqual({ + type: 'bar', + data: [ + { category: '2015年', value: 80 }, + { category: '2016年', value: 140 }, + { category: '2017年', value: 220 }, + ], + title: '海底捞公司外卖收入', + axisXTitle: '年份', + axisYTitle: '金额(百万元)', + }); + }); + + it('should parse grouped bar chart', () => { + const result = parse(` +vis bar +data + - category 北京 + value 825.6 + group 油车 + - category 北京 + value 60.2 + group 新能源汽车 + - category 上海 + value 450 + group 油车 + - category 上海 + value 95 + group 新能源汽车 +group true +title 油车与新能源汽车售卖量 + `); + + expect(result.group).toBe(true); + expect(result.data).toEqual([ + { category: '北京', value: 825.6, group: '油车' }, + { category: '北京', value: 60.2, group: '新能源汽车' }, + { category: '上海', value: 450, group: '油车' }, + { category: '上海', value: 95, group: '新能源汽车' }, + ]); + }); + + it('should parse stacked bar chart', () => { + const result = parse(` +vis bar +data + - category 北京 + value 825.6 + group 油车 + - category 北京 + value 60.2 + group 新能源汽车 +stack true +title 油车与新能源汽车售卖量 + `); + + expect(result.stack).toBe(true); + expect(result.title).toBe('油车与新能源汽车售卖量'); + }); +}); diff --git a/__tests__/boxplot.test.ts b/__tests__/boxplot.test.ts new file mode 100644 index 00000000..b93a0516 --- /dev/null +++ b/__tests__/boxplot.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - boxplot chart', () => { + it('should parse basic boxplot chart', () => { + const result = parse(` +vis boxplot +data + - category 班级A + value 15 + - category 班级A + value 18 + - category 班级A + value 22 + - category 班级A + value 27 + - category 班级A + value 35 + - category 班级B + value 10 + - category 班级B + value 14 + - category 班级B + value 19 + - category 班级B + value 23 + - category 班级B + value 30 +title 成绩分布 + `); + + expect(result.type).toBe('boxplot'); + expect(result.title).toBe('成绩分布'); + expect(result.data).toEqual([ + { category: '班级A', value: 15 }, + { category: '班级A', value: 18 }, + { category: '班级A', value: 22 }, + { category: '班级A', value: 27 }, + { category: '班级A', value: 35 }, + { category: '班级B', value: 10 }, + { category: '班级B', value: 14 }, + { category: '班级B', value: 19 }, + { category: '班级B', value: 23 }, + { category: '班级B', value: 30 }, + ]); + }); + + it('should parse boxplot chart with theme', () => { + const result = parse(` +vis boxplot +data + - category 实验组1 + value 12 + - category 实验组1 + value 15 + - category 实验组1 + value 20 + - category 实验组2 + value 18 + - category 实验组2 + value 22 + - category 实验组2 + value 28 +title 实验数据分布 +theme dark + `); + + expect(result.type).toBe('boxplot'); + expect(result.theme).toBe('dark'); + expect(result.title).toBe('实验数据分布'); + }); + + it('should parse boxplot chart with group field', () => { + const result = parse(` +vis boxplot +data + - category Adelie + group MALE + value 181 + - category Adelie + group FEMALE + value 186 + - category Adelie + group MALE + value 190 + - category Adelie + group FEMALE + value 181 +title 帕尔默企鹅身高性别差异 + `); + + expect(result.type).toBe('boxplot'); + expect(result.data).toEqual([ + { category: 'Adelie', group: 'MALE', value: 181 }, + { category: 'Adelie', group: 'FEMALE', value: 186 }, + { category: 'Adelie', group: 'MALE', value: 190 }, + { category: 'Adelie', group: 'FEMALE', value: 181 }, + ]); + }); +}); diff --git a/__tests__/column.test.ts b/__tests__/column.test.ts new file mode 100644 index 00000000..2cc990ba --- /dev/null +++ b/__tests__/column.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - column chart', () => { + it('should parse basic column chart', () => { + const result = parse(` +vis column +data + - category 2015年 + value 80 + - category 2016年 + value 140 + - category 2017年 + value 220 +title 海底捞公司外卖收入 +axisXTitle 年份 +axisYTitle 金额(百万元) + `); + + expect(result).toEqual({ + type: 'column', + data: [ + { category: '2015年', value: 80 }, + { category: '2016年', value: 140 }, + { category: '2017年', value: 220 }, + ], + title: '海底捞公司外卖收入', + axisXTitle: '年份', + axisYTitle: '金额(百万元)', + }); + }); + + it('should parse grouped column chart', () => { + const result = parse(` +vis column +data + - category 北京 + value 825.6 + group 油车 + - category 北京 + value 60.2 + group 新能源汽车 +group true +title 油车与新能源汽车售卖量 + `); + + expect(result.group).toBe(true); + expect(result.data).toEqual([ + { category: '北京', value: 825.6, group: '油车' }, + { category: '北京', value: 60.2, group: '新能源汽车' }, + ]); + }); + + it('should parse stacked column chart', () => { + const result = parse(` +vis column +data + - category 北京 + value 825.6 + group 油车 + - category 北京 + value 60.2 + group 新能源汽车 +stack true + `); + + expect(result.stack).toBe(true); + }); +}); diff --git a/__tests__/dual-axes.test.ts b/__tests__/dual-axes.test.ts new file mode 100644 index 00000000..820ba30a --- /dev/null +++ b/__tests__/dual-axes.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - dual-axes chart', () => { + it('should parse basic dual-axes chart with categories and series', () => { + const result = parse(` +vis dual-axes +categories + - 2018 + - 2019 + - 2020 + - 2021 + - 2022 +title 2018-2022销售额与利润率 +axisXTitle 年份 +series + - type column + axisYTitle 销售额 + - type line + axisYTitle 利润率 + `); + + expect(result.type).toBe('dual-axes'); + expect(result.title).toBe('2018-2022销售额与利润率'); + expect(result.axisXTitle).toBe('年份'); + // Categories parsed as numbers since they are numeric + expect(result.categories).toEqual([2018, 2019, 2020, 2021, 2022]); + expect(result.series).toEqual([ + { type: 'column', axisYTitle: '销售额' }, + { type: 'line', axisYTitle: '利润率' }, + ]); + }); + + it('should parse dual-axes chart with string categories', () => { + const result = parse(` +vis dual-axes +categories + - Q1 + - Q2 + - Q3 + - Q4 +title 季度数据 + `); + + expect(result.type).toBe('dual-axes'); + expect(result.categories).toEqual(['Q1', 'Q2', 'Q3', 'Q4']); + expect(result.title).toBe('季度数据'); + }); + + it('should parse dual-axes chart with date categories', () => { + const result = parse(` +vis dual-axes +categories + - 20240501 + - 20240502 + - 20240503 +title 五一期间景区人流量 +axisXTitle 日期 +series + - type column + axisYTitle 人数 + - type line + axisYTitle 增长率 + `); + + // Date-like values parsed as numbers + expect(result.categories).toEqual([20240501, 20240502, 20240503]); + expect(result.series).toEqual([ + { type: 'column', axisYTitle: '人数' }, + { type: 'line', axisYTitle: '增长率' }, + ]); + }); + + it('should parse dual-axes chart with theme', () => { + const result = parse(` +vis dual-axes +categories + - Q1 + - Q2 + - Q3 +theme academy + `); + + expect(result.categories).toEqual(['Q1', 'Q2', 'Q3']); + expect(result.theme).toBe('academy'); + }); +}); diff --git a/__tests__/fishbone-diagram.test.ts b/__tests__/fishbone-diagram.test.ts new file mode 100644 index 00000000..eb885f7c --- /dev/null +++ b/__tests__/fishbone-diagram.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - fishbone-diagram chart', () => { + it('should parse basic fishbone-diagram', () => { + const result = parse(` +vis fishbone-diagram +data + - name 产品销量下降 + `); + + expect(result.type).toBe('fishbone-diagram'); + expect(result.data).toEqual([{ name: '产品销量下降' }]); + }); + + it('should parse fishbone-diagram with flat categories', () => { + const result = parse(` +vis fishbone-diagram +data + - name 产品销量下降 + - name 市场推广 + - name 广告投入减少 + - name 促销活动不足 + - name 产品质量 + - name 产品缺陷 + - name 品质不稳定 + `); + + expect(result.type).toBe('fishbone-diagram'); + expect(result.data).toHaveLength(7); + }); + + it('should parse fishbone-diagram with nested children', () => { + const result = parse(` +vis fishbone-diagram +data + - name 产品销量下降 + children + - name 市场推广 + children + - name 广告投入减少 + - name 促销活动不足 + - name 产品质量 + children + - name 产品缺陷 + - name 品质不稳定 + - name 客户服务 + children + - name 响应速度慢 + - name 服务态度差 + - name 价格策略 + children + - name 定价过高 + - name 竞争对手降价 + `); + + expect(result.type).toBe('fishbone-diagram'); + expect(result.data).toEqual([ + { + name: '产品销量下降', + children: [ + { + name: '市场推广', + children: [{ name: '广告投入减少' }, { name: '促销活动不足' }], + }, + { + name: '产品质量', + children: [{ name: '产品缺陷' }, { name: '品质不稳定' }], + }, + { + name: '客户服务', + children: [{ name: '响应速度慢' }, { name: '服务态度差' }], + }, + { + name: '价格策略', + children: [{ name: '定价过高' }, { name: '竞争对手降价' }], + }, + ], + }, + ]); + }); + + it('should parse fishbone-diagram with production efficiency problem', () => { + const result = parse(` +vis fishbone-diagram +data + - name 生产效率低 + children + - name 设备问题 + children + - name 设备老化 + - name 维护不及时 + - name 员工问题 + children + - name 技能不足 + - name 工作态度差 + - name 流程问题 + children + - name 流程繁琐 + - name 缺乏标准化 + `); + + expect(result.type).toBe('fishbone-diagram'); + expect(result.data).toEqual([ + { + name: '生产效率低', + children: [ + { + name: '设备问题', + children: [{ name: '设备老化' }, { name: '维护不及时' }], + }, + { + name: '员工问题', + children: [{ name: '技能不足' }, { name: '工作态度差' }], + }, + { + name: '流程问题', + children: [{ name: '流程繁琐' }, { name: '缺乏标准化' }], + }, + ], + }, + ]); + }); +}); diff --git a/__tests__/flow-diagram.test.ts b/__tests__/flow-diagram.test.ts new file mode 100644 index 00000000..6c6c3d7c --- /dev/null +++ b/__tests__/flow-diagram.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - flow-diagram chart', () => { + it('should parse basic flow-diagram with nodes', () => { + const result = parse(` +vis flow-diagram +data + - name 访问注册页面 + - name 填写并提交注册表单 + - name 验证用户信息 + - name 创建新用户账户 + `); + + expect(result.type).toBe('flow-diagram'); + expect(result.data).toEqual([ + { name: '访问注册页面' }, + { name: '填写并提交注册表单' }, + { name: '验证用户信息' }, + { name: '创建新用户账户' }, + ]); + }); + + it('should parse flow-diagram with edges', () => { + const result = parse(` +vis flow-diagram +data + - source 客户下单 + target 系统生成订单 + - source 系统生成订单 + target 仓库拣货 + - source 仓库拣货 + target 仓库打包 + `); + + expect(result.type).toBe('flow-diagram'); + expect(result.data).toEqual([ + { source: '客户下单', target: '系统生成订单' }, + { source: '系统生成订单', target: '仓库拣货' }, + { source: '仓库拣货', target: '仓库打包' }, + ]); + }); + + it('should parse flow-diagram with named edges', () => { + const result = parse(` +vis flow-diagram +data + - source 验证用户信息 + target 创建新用户账户 + name 信息无误 + - source 验证用户信息 + target 提示修改错误信息 + name 信息有误 + `); + + expect(result.type).toBe('flow-diagram'); + expect(result.data).toEqual([ + { source: '验证用户信息', target: '创建新用户账户', name: '信息无误' }, + { source: '验证用户信息', target: '提示修改错误信息', name: '信息有误' }, + ]); + }); +}); diff --git a/__tests__/funnel.test.ts b/__tests__/funnel.test.ts new file mode 100644 index 00000000..5706dace --- /dev/null +++ b/__tests__/funnel.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - funnel chart', () => { + it('should parse basic funnel chart', () => { + const result = parse(` +vis funnel +data + - category 访问 + value 1000 + - category 咨询 + value 600 + - category 下单 + value 300 + - category 成交 + value 120 +title 销售漏斗 + `); + + expect(result.type).toBe('funnel'); + expect(result.data).toEqual([ + { category: '访问', value: 1000 }, + { category: '咨询', value: 600 }, + { category: '下单', value: 300 }, + { category: '成交', value: 120 }, + ]); + expect(result.title).toBe('销售漏斗'); + }); + + it('should parse funnel chart with theme', () => { + const result = parse(` +vis funnel +data + - category 注册 + value 800 + - category 激活 + value 500 + - category 付费 + value 200 +title 用户转化流程 +theme dark + `); + + expect(result.theme).toBe('dark'); + expect(result.title).toBe('用户转化流程'); + }); + + it('should parse funnel chart with custom style', () => { + const result = parse(` +vis funnel +data + - category 报名 + value 1500 + - category 签到 + value 900 + - category 参与 + value 700 +title 活动参与漏斗 +style + palette #FF7F50 #87CEFA #32CD32 + backgroundColor #FFF8DC + `); + + expect(result.style).toEqual({ + palette: ['#FF7F50', '#87CEFA', '#32CD32'], + backgroundColor: '#FFF8DC', + }); + }); +}); diff --git a/__tests__/heat-map.test.ts b/__tests__/heat-map.test.ts new file mode 100644 index 00000000..d1d3d0f6 --- /dev/null +++ b/__tests__/heat-map.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - heat-map chart', () => { + it('should parse basic heat-map chart', () => { + const result = parse(` +vis heat-map +data + - longitude 116.3974 + latitude 39.9087 + value 5 + - longitude 121.4737 + latitude 31.2304 + value 3 + `); + + expect(result.type).toBe('heat-map'); + expect(result.data).toEqual([ + { longitude: 116.3974, latitude: 39.9087, value: 5 }, + { longitude: 121.4737, latitude: 31.2304, value: 3 }, + ]); + }); + + it('should parse heat-map chart with tourist data', () => { + const result = parse(` +vis heat-map +data + - longitude 121.474856 + latitude 31.249162 + value 800 + - longitude 121.449895 + latitude 31.228609 + value 500 + - longitude 121.449486 + latitude 31.222042 + value 900 + `); + + expect(result.type).toBe('heat-map'); + expect(result.data).toEqual([ + { longitude: 121.474856, latitude: 31.249162, value: 800 }, + { longitude: 121.449895, latitude: 31.228609, value: 500 }, + { longitude: 121.449486, latitude: 31.222042, value: 900 }, + ]); + }); + + it('should parse heat-map chart with intensity data', () => { + const result = parse(` +vis heat-map +data + - longitude 121.449895 + latitude 31.228609 + value 500 + - longitude 121.449486 + latitude 31.222042 + value 900 + - longitude 121.431826 + latitude 31.204638 + value 400 + - longitude 121.448453 + latitude 31.222341 + value 300 + `); + + expect(result.type).toBe('heat-map'); + expect(result.data).toHaveLength(4); + }); +}); diff --git a/__tests__/histogram.test.ts b/__tests__/histogram.test.ts new file mode 100644 index 00000000..18d3c04d --- /dev/null +++ b/__tests__/histogram.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - histogram chart', () => { + it('should parse basic histogram chart with flat number array', () => { + const result = parse(` +vis histogram +data + - 78 + - 88 + - 60 + - 100 + - 95 +binNumber 5 +title 成绩分布 + `); + + expect(result.type).toBe('histogram'); + expect(result.data).toEqual([78, 88, 60, 100, 95]); + expect(result.binNumber).toBe(5); + expect(result.title).toBe('成绩分布'); + }); + + it('should parse histogram chart with decimal values', () => { + const result = parse(` +vis histogram +data + - 1.2 + - 3.4 + - 3.7 + - 4.3 + - 5.2 + - 5.8 + - 6.1 +axisXTitle 花瓣大小分布 +axisYTitle 花瓣分布数量 + `); + + expect(result.data).toEqual([1.2, 3.4, 3.7, 4.3, 5.2, 5.8, 6.1]); + expect(result.axisXTitle).toBe('花瓣大小分布'); + expect(result.axisYTitle).toBe('花瓣分布数量'); + }); + + it('should parse histogram chart with theme', () => { + const result = parse(` +vis histogram +data + - 20 + - 25 + - 30 + - 35 +theme academy + `); + + expect(result.data).toEqual([20, 25, 30, 35]); + expect(result.theme).toBe('academy'); + }); + + it('should parse histogram chart with style', () => { + const result = parse(` +vis histogram +data + - 10 + - 20 + - 30 +style + backgroundColor #f5f5f5 + palette #5B8FF9 + `); + + expect(result.data).toEqual([10, 20, 30]); + expect(result.style).toEqual({ + backgroundColor: '#f5f5f5', + palette: '#5B8FF9', + }); + }); +}); diff --git a/__tests__/line.test.ts b/__tests__/line.test.ts new file mode 100644 index 00000000..2ccb64c8 --- /dev/null +++ b/__tests__/line.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - line chart', () => { + it('should parse basic line chart', () => { + const result = parse(` +vis line +data + - time 2015年 + value 1700 + - time 2016年 + value 1500 + - time 2017年 + value 1200 +title 出生人口变化 +axisXTitle 年份 +axisYTitle 出生人口(万人) + `); + + expect(result).toEqual({ + type: 'line', + data: [ + { time: '2015年', value: 1700 }, + { time: '2016年', value: 1500 }, + { time: '2017年', value: 1200 }, + ], + title: '出生人口变化', + axisXTitle: '年份', + axisYTitle: '出生人口(万人)', + }); + }); + + it('should parse multi-line chart with group field', () => { + const result = parse(` +vis line +data + - time 2015年 + value 1700 + group 出生人口 + - time 2015年 + value 965 + group 死亡人口 + - time 2016年 + value 1500 + group 出生人口 + - time 2016年 + value 846 + group 死亡人口 +title 出生人口与死亡人口变化 + `); + + expect(result.type).toBe('line'); + expect(result.data).toEqual([ + { time: '2015年', value: 1700, group: '出生人口' }, + { time: '2015年', value: 965, group: '死亡人口' }, + { time: '2016年', value: 1500, group: '出生人口' }, + { time: '2016年', value: 846, group: '死亡人口' }, + ]); + }); + + it('should parse line chart with numeric time values', () => { + const result = parse(` +vis line +data + - time 2015 + value 7200 + - time 2016 + value 3660 + - time 2017 + value 4100 +axisXTitle year +axisYTitle industrial + `); + + expect(result.data).toEqual([ + { time: 2015, value: 7200 }, + { time: 2016, value: 3660 }, + { time: 2017, value: 4100 }, + ]); + }); + + it('should parse line chart with style', () => { + const result = parse(` +vis line +data + - time Q1 + value 100 + - time Q2 + value 150 +theme academy +style + lineWidth 3 + palette #5B8FF9 #61DDAA + `); + + expect(result.theme).toBe('academy'); + expect(result.style).toEqual({ + lineWidth: 3, + palette: ['#5B8FF9', '#61DDAA'], + }); + }); +}); diff --git a/__tests__/liquid.test.ts b/__tests__/liquid.test.ts new file mode 100644 index 00000000..c77a26fd --- /dev/null +++ b/__tests__/liquid.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - liquid chart', () => { + it('should parse basic liquid chart', () => { + const result = parse(` +vis liquid +percent 0.75 +title 任务完成度 + `); + + expect(result).toEqual({ + type: 'liquid', + percent: 0.75, + title: '任务完成度', + }); + }); + + it('should parse liquid chart with theme', () => { + const result = parse(` +vis liquid +percent 0.6 +title 资源使用率 +theme dark + `); + + expect(result.theme).toBe('dark'); + expect(result.percent).toBe(0.6); + }); + + it('should parse liquid chart with shape', () => { + const result = parse(` +vis liquid +percent 0.92 +title KPI达成率 +shape triangle + `); + + expect(result.shape).toBe('triangle'); + expect(result.percent).toBe(0.92); + }); + + it('should parse liquid chart with style', () => { + const result = parse(` +vis liquid +percent 0.8 +title 进度 +style + palette #00BFFF + backgroundColor #F0F0F0 + `); + + expect(result.style).toEqual({ + palette: '#00BFFF', + backgroundColor: '#F0F0F0', + }); + }); +}); diff --git a/__tests__/mind-map.test.ts b/__tests__/mind-map.test.ts new file mode 100644 index 00000000..5c16d3ff --- /dev/null +++ b/__tests__/mind-map.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - mind-map chart', () => { + it('should parse basic mind-map chart', () => { + const result = parse(` +vis mind-map +data + - name 项目计划 + `); + + expect(result.type).toBe('mind-map'); + expect(result.data).toEqual([{ name: '项目计划' }]); + }); + + it('should parse mind-map chart with flat nodes', () => { + const result = parse(` +vis mind-map +data + - name 人工智能应用 + - name 智能家居 + - name 自动驾驶 + - name 医疗保健 + `); + + expect(result.type).toBe('mind-map'); + expect(result.data).toEqual([ + { name: '人工智能应用' }, + { name: '智能家居' }, + { name: '自动驾驶' }, + { name: '医疗保健' }, + ]); + }); + + it('should parse mind-map chart with nested children', () => { + const result = parse(` +vis mind-map +data + - name 项目计划 + children + - name 研究阶段 + children + - name 市场调研 + - name 技术可行性分析 + - name 设计阶段 + children + - name 产品功能确定 + - name UI设计 + - name 开发阶段 + children + - name 编写代码 + - name 单元测试 + `); + + expect(result.type).toBe('mind-map'); + expect(result.data).toEqual([ + { + name: '项目计划', + children: [ + { + name: '研究阶段', + children: [{ name: '市场调研' }, { name: '技术可行性分析' }], + }, + { + name: '设计阶段', + children: [{ name: '产品功能确定' }, { name: 'UI设计' }], + }, + { + name: '开发阶段', + children: [{ name: '编写代码' }, { name: '单元测试' }], + }, + ], + }, + ]); + }); + + it('should parse mind-map chart with deeply nested children', () => { + const result = parse(` +vis mind-map +data + - name 人工智能应用 + children + - name 智能家居 + - name 自动驾驶 + - name 医疗保健 + children + - name 精准医疗 + - name 诊断辅助 + - name 金融服务 + `); + + expect(result.type).toBe('mind-map'); + expect(result.data).toEqual([ + { + name: '人工智能应用', + children: [ + { name: '智能家居' }, + { name: '自动驾驶' }, + { + name: '医疗保健', + children: [{ name: '精准医疗' }, { name: '诊断辅助' }], + }, + { name: '金融服务' }, + ], + }, + ]); + }); +}); diff --git a/__tests__/network-graph.test.ts b/__tests__/network-graph.test.ts new file mode 100644 index 00000000..de1222b0 --- /dev/null +++ b/__tests__/network-graph.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - network-graph chart', () => { + it('should parse basic network-graph with nodes', () => { + const result = parse(` +vis network-graph +data + - name 哈利·波特 + - name 赫敏·格兰杰 + - name 罗恩·韦斯莱 + - name 伏地魔 + `); + + expect(result.type).toBe('network-graph'); + expect(result.data).toEqual([ + { name: '哈利·波特' }, + { name: '赫敏·格兰杰' }, + { name: '罗恩·韦斯莱' }, + { name: '伏地魔' }, + ]); + }); + + it('should parse network-graph with edges', () => { + const result = parse(` +vis network-graph +data + - source 哈利·波特 + target 赫敏·格兰杰 + name 朋友 + - source 哈利·波特 + target 罗恩·韦斯莱 + name 朋友 + - source 哈利·波特 + target 伏地魔 + name 敌人 + `); + + expect(result.type).toBe('network-graph'); + expect(result.data).toEqual([ + { source: '哈利·波特', target: '赫敏·格兰杰', name: '朋友' }, + { source: '哈利·波特', target: '罗恩·韦斯莱', name: '朋友' }, + { source: '哈利·波特', target: '伏地魔', name: '敌人' }, + ]); + }); + + it('should parse network-graph with bidirectional edges', () => { + const result = parse(` +vis network-graph +data + - source 哈利·波特 + target 伏地魔 + name 敌人 + - source 伏地魔 + target 哈利·波特 + name 试图杀死 + `); + + expect(result.type).toBe('network-graph'); + expect(result.data).toEqual([ + { source: '哈利·波特', target: '伏地魔', name: '敌人' }, + { source: '伏地魔', target: '哈利·波特', name: '试图杀死' }, + ]); + }); +}); diff --git a/__tests__/organization-chart.test.ts b/__tests__/organization-chart.test.ts new file mode 100644 index 00000000..ebed22b3 --- /dev/null +++ b/__tests__/organization-chart.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - organization-chart', () => { + it('should parse basic organization-chart', () => { + const result = parse(` +vis organization-chart +data + - name Alice Johnson + description Chief Technology Officer + `); + + expect(result.type).toBe('organization-chart'); + expect(result.data).toEqual([ + { name: 'Alice Johnson', description: 'Chief Technology Officer' }, + ]); + }); + + it('should parse organization-chart with multiple nodes', () => { + const result = parse(` +vis organization-chart +data + - name Eric Joplin + description Chief Executive Officer + - name Linda Newland + description Chief Executive Assistant + `); + + expect(result.type).toBe('organization-chart'); + expect(result.data).toEqual([ + { name: 'Eric Joplin', description: 'Chief Executive Officer' }, + { name: 'Linda Newland', description: 'Chief Executive Assistant' }, + ]); + }); + + it('should parse organization-chart with nested children', () => { + const result = parse(` +vis organization-chart +data + - name Alice Johnson + description Chief Technology Officer + children + - name Bob Smith + description Senior Software Engineer + children + - name Charlie Brown + description Software Engineer + - name Diana White + description Software Engineer + - name Eve Black + description IT Support Department Head + children + - name Frank Green + description IT Support Specialist + - name Grace Blue + description IT Support Specialist + `); + + expect(result.type).toBe('organization-chart'); + expect(result.data).toEqual([ + { + name: 'Alice Johnson', + description: 'Chief Technology Officer', + children: [ + { + name: 'Bob Smith', + description: 'Senior Software Engineer', + children: [ + { name: 'Charlie Brown', description: 'Software Engineer' }, + { name: 'Diana White', description: 'Software Engineer' }, + ], + }, + { + name: 'Eve Black', + description: 'IT Support Department Head', + children: [ + { name: 'Frank Green', description: 'IT Support Specialist' }, + { name: 'Grace Blue', description: 'IT Support Specialist' }, + ], + }, + ], + }, + ]); + }); + + it('should parse organization-chart with simple nested children', () => { + const result = parse(` +vis organization-chart +data + - name Eric Joplin + description Chief Executive Officer + children + - name Linda Newland + description Chief Executive Assistant + `); + + expect(result.type).toBe('organization-chart'); + expect(result.data).toEqual([ + { + name: 'Eric Joplin', + description: 'Chief Executive Officer', + children: [{ name: 'Linda Newland', description: 'Chief Executive Assistant' }], + }, + ]); + }); +}); diff --git a/__tests__/parser.test.ts b/__tests__/parser.test.ts new file mode 100644 index 00000000..faec741f --- /dev/null +++ b/__tests__/parser.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import { isVisSyntax, parse } from '../src/ai/parser'; + +describe('isVisSyntax', () => { + it('should return true for valid vis syntax', () => { + expect(isVisSyntax('vis pie')).toBe(true); + expect(isVisSyntax(' vis pie')).toBe(true); + expect(isVisSyntax('vis line')).toBe(true); + }); + + it('should return false for invalid syntax', () => { + expect(isVisSyntax('data')).toBe(false); + expect(isVisSyntax('type pie')).toBe(false); + expect(isVisSyntax('')).toBe(false); + expect(isVisSyntax('{ "type": "pie" }')).toBe(false); + }); +}); + +describe('parse - basic functionality', () => { + it('should parse chart type', () => { + const result = parse('vis pie'); + expect(result.type).toBe('pie'); + }); + + it('should parse chart type with extra spaces', () => { + const result = parse('vis pie'); + expect(result.type).toBe('pie'); + }); + + it('should parse top-level key-value pairs with colon', () => { + const result = parse(` +vis pie +innerRadius: 0.6 + `); + expect(result.type).toBe('pie'); + expect(result.innerRadius).toBe(0.6); + }); + + it('should parse top-level key-value pairs without colon', () => { + const result = parse(` +vis pie +innerRadius 0.6 + `); + expect(result.type).toBe('pie'); + expect(result.innerRadius).toBe(0.6); + }); + + it('should parse boolean values', () => { + const result = parse(` +vis bar +group true +stack false + `); + expect(result.group).toBe(true); + expect(result.stack).toBe(false); + }); + + it('should parse string values', () => { + const result = parse(` +vis pie +title Sales Report + `); + expect(result.title).toBe('Sales Report'); + }); +}); + +describe('parse - data arrays', () => { + it('should parse simple data array', () => { + const result = parse(` +vis pie +data + - category Category1 + value 27 + - category Category2 + value 25 + `); + + expect(result.type).toBe('pie'); + expect(result.data).toEqual([ + { category: 'Category1', value: 27 }, + { category: 'Category2', value: 25 }, + ]); + }); + + it('should parse data array with multiple spaces', () => { + const result = parse(` +vis pie +data + - category Category1 + value 27 + - category Category2 + value 25 + `); + + expect(result.data).toEqual([ + { category: 'Category1', value: 27 }, + { category: 'Category2', value: 25 }, + ]); + }); + + it('should handle data with Chinese characters', () => { + const result = parse(` +vis pie +data + - category 分类一 + value 27 + - category 分类二 + value 25 + `); + + expect(result.data).toEqual([ + { category: '分类一', value: 27 }, + { category: '分类二', value: 25 }, + ]); + }); + + it('should parse simple value arrays (flat arrays)', () => { + const result = parse(` +vis histogram +data + - 78 + - 88 + - 60 + - 100 + `); + + expect(result.data).toEqual([78, 88, 60, 100]); + }); + + it('should parse simple string arrays', () => { + const result = parse(` +vis dual-axes +categories + - 2018 + - 2019 + - 2020 + `); + + // Numeric-looking values are parsed as numbers + expect(result.categories).toEqual([2018, 2019, 2020]); + }); + + it('should parse simple string arrays with non-numeric values', () => { + const result = parse(` +vis dual-axes +categories + - Q1 + - Q2 + - Q3 + `); + + expect(result.categories).toEqual(['Q1', 'Q2', 'Q3']); + }); +}); + +describe('parse - style object', () => { + it('should parse style section', () => { + const result = parse(` +vis pie +style + backgroundColor #333 + `); + + expect(result.style).toEqual({ + backgroundColor: '#333', + }); + }); + + it('should parse palette as array', () => { + const result = parse(` +vis pie +style + palette #ff5a5f #1fb6ff #13ce66 + `); + + expect(result.style).toEqual({ + palette: ['#ff5a5f', '#1fb6ff', '#13ce66'], + }); + }); + + it('should parse style with multiple properties', () => { + const result = parse(` +vis pie +style + backgroundColor #333 + palette #ff5a5f #1fb6ff #13ce66 + lineWidth 2 + `); + + expect(result.style).toEqual({ + backgroundColor: '#333', + palette: ['#ff5a5f', '#1fb6ff', '#13ce66'], + lineWidth: 2, + }); + }); +}); + +describe('parse - error tolerance', () => { + it('should handle multiple consecutive spaces', () => { + const result = parse(` +vis pie +data + - category Category1 + value 27 + `); + + expect(result.type).toBe('pie'); + expect(result.data).toEqual([{ category: 'Category1', value: 27 }]); + }); + + it('should handle tabs mixed with spaces', () => { + const result = parse(` +vis pie +data + - category Category1 + value 27 + `); + + expect(result.type).toBe('pie'); + expect(result.data).toEqual([{ category: 'Category1', value: 27 }]); + }); + + it('should handle empty lines between sections', () => { + const result = parse(` +vis pie + +data + - category Cat1 + value 10 + + - category Cat2 + value 20 + +innerRadius 0.5 + `); + + expect(result.type).toBe('pie'); + expect(result.data).toEqual([ + { category: 'Cat1', value: 10 }, + { category: 'Cat2', value: 20 }, + ]); + expect(result.innerRadius).toBe(0.5); + }); + + it('should handle trailing whitespace', () => { + const result = parse(` +vis pie +data + - category Cat1 + value 10 + `); + + expect(result.type).toBe('pie'); + expect(result.data).toEqual([{ category: 'Cat1', value: 10 }]); + }); + + it('should handle leading whitespace in entire input', () => { + const result = parse(` + +vis pie +data + - category Cat1 + value 10 + `); + + expect(result.type).toBe('pie'); + }); + + it('should handle mixed colon and space formats', () => { + const result = parse(` +vis pie +innerRadius: 0.6 +title My Chart +theme: academy + `); + + expect(result.innerRadius).toBe(0.6); + expect(result.title).toBe('My Chart'); + expect(result.theme).toBe('academy'); + }); +}); + +describe('parse - theme configuration', () => { + it('should parse theme setting', () => { + const result = parse(` +vis pie +data + - category Cat1 + value 10 +theme academy + `); + + expect(result.theme).toBe('academy'); + }); + + it('should parse dark theme', () => { + const result = parse(` +vis bar +theme dark + `); + + expect(result.theme).toBe('dark'); + }); +}); diff --git a/__tests__/pie.test.ts b/__tests__/pie.test.ts new file mode 100644 index 00000000..7b3c8f02 --- /dev/null +++ b/__tests__/pie.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - pie chart', () => { + it('should parse basic pie chart', () => { + const result = parse(` +vis pie +data + - category 火锅 + value 22 + - category 自助餐 + value 12 + - category 川菜 + value 8 + `); + + expect(result.type).toBe('pie'); + expect(result.data).toEqual([ + { category: '火锅', value: 22 }, + { category: '自助餐', value: 12 }, + { category: '川菜', value: 8 }, + ]); + }); + + it('should parse pie chart with innerRadius (donut)', () => { + const result = parse(` +vis pie +data + - category 城镇人口 + value 63.89 + - category 乡村人口 + value 36.11 +innerRadius 0.6 +title 全国人口居住对比 + `); + + expect(result).toEqual({ + type: 'pie', + data: [ + { category: '城镇人口', value: 63.89 }, + { category: '乡村人口', value: 36.11 }, + ], + innerRadius: 0.6, + title: '全国人口居住对比', + }); + }); + + it('should parse pie chart with theme and style', () => { + const result = parse(` +vis pie +data + - category 分类一 + value 27 + - category 分类二 + value 25 +theme academy +style + backgroundColor #333 + palette #ff5a5f #1fb6ff #13ce66 + `); + + expect(result.theme).toBe('academy'); + expect(result.style).toEqual({ + backgroundColor: '#333', + palette: ['#ff5a5f', '#1fb6ff', '#13ce66'], + }); + }); +}); diff --git a/__tests__/pin-map.test.ts b/__tests__/pin-map.test.ts new file mode 100644 index 00000000..3c12e871 --- /dev/null +++ b/__tests__/pin-map.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - pin-map chart', () => { + it('should parse basic pin-map chart', () => { + const result = parse(` +vis pin-map +data + - longitude 120.153576 + latitude 30.287459 + label 杭州 + - longitude 121.4737 + latitude 31.2304 + label 上海 + `); + + expect(result.type).toBe('pin-map'); + expect(result.data).toEqual([ + { longitude: 120.153576, latitude: 30.287459, label: '杭州' }, + { longitude: 121.4737, latitude: 31.2304, label: '上海' }, + ]); + }); + + it('should parse pin-map chart with population data', () => { + const result = parse(` +vis pin-map +data + - longitude 121.549792 + latitude 29.868388 + label 宁波人口:51万 + - longitude 121.4737 + latitude 31.2304 + label 上海人口:151万 + - longitude 120.672111 + latitude 28.000575 + label 温州人口:67万 + `); + + expect(result.type).toBe('pin-map'); + expect(result.data).toEqual([ + { longitude: 121.549792, latitude: 29.868388, label: '宁波人口:51万' }, + { longitude: 121.4737, latitude: 31.2304, label: '上海人口:151万' }, + { longitude: 120.672111, latitude: 28.000575, label: '温州人口:67万' }, + ]); + }); + + it('should parse pin-map chart with scenic spots', () => { + const result = parse(` +vis pin-map +data + - label 杨梅岭 + longitude 120.118362 + latitude 30.217175 + - label 理安寺 + longitude 120.112958 + latitude 30.207319 + - label 九溪烟树 + longitude 120.11335 + latitude 30.202395 + `); + + expect(result.type).toBe('pin-map'); + expect(result.data).toEqual([ + { label: '杨梅岭', longitude: 120.118362, latitude: 30.217175 }, + { label: '理安寺', longitude: 120.112958, latitude: 30.207319 }, + { label: '九溪烟树', longitude: 120.11335, latitude: 30.202395 }, + ]); + }); +}); diff --git a/__tests__/radar.test.ts b/__tests__/radar.test.ts new file mode 100644 index 00000000..deb3fa95 --- /dev/null +++ b/__tests__/radar.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - radar chart', () => { + it('should parse basic radar chart', () => { + const result = parse(` +vis radar +data + - name 沟通能力 + value 2 + - name 协作能力 + value 3 + - name 领导能力 + value 2 + - name 学习能力 + value 5 + - name 创新能力 + value 6 + - name 技术能力 + value 9 + `); + + expect(result.type).toBe('radar'); + expect(result.data).toEqual([ + { name: '沟通能力', value: 2 }, + { name: '协作能力', value: 3 }, + { name: '领导能力', value: 2 }, + { name: '学习能力', value: 5 }, + { name: '创新能力', value: 6 }, + { name: '技术能力', value: 9 }, + ]); + }); + + it('should parse grouped radar chart', () => { + const result = parse(` +vis radar +data + - name 语文 + value 95 + group 一班 + - name 数学 + value 96 + group 一班 + - name 外语 + value 85 + group 一班 + - name 语文 + value 75 + group 二班 + - name 数学 + value 93 + group 二班 + - name 外语 + value 66 + group 二班 + `); + + expect(result.type).toBe('radar'); + expect(result.data).toEqual([ + { name: '语文', value: 95, group: '一班' }, + { name: '数学', value: 96, group: '一班' }, + { name: '外语', value: 85, group: '一班' }, + { name: '语文', value: 75, group: '二班' }, + { name: '数学', value: 93, group: '二班' }, + { name: '外语', value: 66, group: '二班' }, + ]); + }); + + it('should parse radar chart with title and theme', () => { + const result = parse(` +vis radar +data + - name Vitamin C + value 7 + - name Fiber + value 6 + - name Sugar + value 5 +title 营养成分分析 +theme academy + `); + + expect(result.title).toBe('营养成分分析'); + expect(result.theme).toBe('academy'); + }); +}); diff --git a/__tests__/sankey.test.ts b/__tests__/sankey.test.ts new file mode 100644 index 00000000..716d1bc5 --- /dev/null +++ b/__tests__/sankey.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - sankey chart', () => { + it('should parse basic sankey chart', () => { + const result = parse(` +vis sankey +data + - source 煤炭 + target 发电厂 + value 120 + - source 天然气 + target 发电厂 + value 80 + - source 发电厂 + target 工业 + value 100 + - source 发电厂 + target 居民 + value 60 + - source 发电厂 + target 商业 + value 40 +nodeAlign justify +title 能源流动关系 + `); + + expect(result.type).toBe('sankey'); + expect(result.data).toEqual([ + { source: '煤炭', target: '发电厂', value: 120 }, + { source: '天然气', target: '发电厂', value: 80 }, + { source: '发电厂', target: '工业', value: 100 }, + { source: '发电厂', target: '居民', value: 60 }, + { source: '发电厂', target: '商业', value: 40 }, + ]); + expect(result.nodeAlign).toBe('justify'); + expect(result.title).toBe('能源流动关系'); + }); + + it('should parse sankey chart with theme', () => { + const result = parse(` +vis sankey +data + - source 投资人 + target 创业公司 + value 200 + - source 创业公司 + target 市场营销 + value 80 + - source 创业公司 + target 研发 + value 120 +nodeAlign center +title 资金流转路径 +theme dark + `); + + expect(result.theme).toBe('dark'); + expect(result.nodeAlign).toBe('center'); + }); + + it('should parse sankey chart with custom style', () => { + const result = parse(` +vis sankey +data + - source 首页 + target 产品页 + value 300 + - source 产品页 + target 购物车 + value 150 +nodeAlign left +style + palette #5B8FF9 #61DDAA #65789B #F6BD16 #7262FD + backgroundColor #f0f2f5 + `); + + expect(result.style).toEqual({ + palette: ['#5B8FF9', '#61DDAA', '#65789B', '#F6BD16', '#7262FD'], + backgroundColor: '#f0f2f5', + }); + }); +}); diff --git a/__tests__/scatter.test.ts b/__tests__/scatter.test.ts new file mode 100644 index 00000000..083faa32 --- /dev/null +++ b/__tests__/scatter.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - scatter chart', () => { + it('should parse basic scatter chart', () => { + const result = parse(` +vis scatter +data + - x 10 + y 15 + - x 20 + y 25 + - x 30 + y 35 + - x 40 + y 45 + `); + + expect(result.type).toBe('scatter'); + expect(result.data).toEqual([ + { x: 10, y: 15 }, + { x: 20, y: 25 }, + { x: 30, y: 35 }, + { x: 40, y: 45 }, + ]); + }); + + it('should parse scatter chart with title', () => { + const result = parse(` +vis scatter +data + - x 25 + y 5000 + - x 35 + y 7000 + - x 45 + y 10000 +title 年龄与收入关系 + `); + + expect(result.title).toBe('年龄与收入关系'); + }); + + it('should parse scatter chart with group', () => { + const result = parse(` +vis scatter +data + - x 10 + y 20 + group A + - x 15 + y 25 + group A + - x 20 + y 30 + group B + - x 25 + y 35 + group B + `); + + expect(result.data).toEqual([ + { x: 10, y: 20, group: 'A' }, + { x: 15, y: 25, group: 'A' }, + { x: 20, y: 30, group: 'B' }, + { x: 25, y: 35, group: 'B' }, + ]); + }); + + it('should parse scatter chart with theme', () => { + const result = parse(` +vis scatter +data + - x 10 + y 20 +theme academy + `); + + expect(result.theme).toBe('academy'); + }); +}); diff --git a/__tests__/treemap.test.ts b/__tests__/treemap.test.ts new file mode 100644 index 00000000..bccde5fa --- /dev/null +++ b/__tests__/treemap.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - treemap chart', () => { + it('should parse basic treemap chart', () => { + const result = parse(` +vis treemap +data + - name 苹果 + value 800 + - name 橙子 + value 600 + - name 香蕉 + value 500 + `); + + expect(result.type).toBe('treemap'); + expect(result.data).toEqual([ + { name: '苹果', value: 800 }, + { name: '橙子', value: 600 }, + { name: '香蕉', value: 500 }, + ]); + }); + + it('should parse treemap chart with title', () => { + const result = parse(` +vis treemap +data + - name 产品A + value 500 + - name 产品B + value 400 +title 产品销售情况 + `); + + expect(result.title).toBe('产品销售情况'); + }); + + it('should parse treemap chart with nested children', () => { + const result = parse(` +vis treemap +data + - name A部门 + value 100 + children + - name A1 + value 40 + - name A2 + value 30 + - name A3 + value 30 + - name B部门 + value 80 + children + - name B1 + value 50 + - name B2 + value 30 + `); + + expect(result.type).toBe('treemap'); + expect(result.data).toEqual([ + { + name: 'A部门', + value: 100, + children: [ + { name: 'A1', value: 40 }, + { name: 'A2', value: 30 }, + { name: 'A3', value: 30 }, + ], + }, + { + name: 'B部门', + value: 80, + children: [ + { name: 'B1', value: 50 }, + { name: 'B2', value: 30 }, + ], + }, + ]); + }); + + it('should parse treemap chart with deeply nested children', () => { + const result = parse(` +vis treemap +data + - name 苹果 + value 800 + children + - name 红富士 + value 400 + - name 黄元帅 + value 400 + - name 橙子 + value 600 + - name 香蕉 + value 500 + `); + + expect(result.type).toBe('treemap'); + expect(result.data).toEqual([ + { + name: '苹果', + value: 800, + children: [ + { name: '红富士', value: 400 }, + { name: '黄元帅', value: 400 }, + ], + }, + { name: '橙子', value: 600 }, + { name: '香蕉', value: 500 }, + ]); + }); + + it('should parse treemap chart with style', () => { + const result = parse(` +vis treemap +data + - name A + value 100 +style + backgroundColor #f5f5f5 + palette #5B8FF9 #61DDAA #65789B + `); + + expect(result.style).toEqual({ + backgroundColor: '#f5f5f5', + palette: ['#5B8FF9', '#61DDAA', '#65789B'], + }); + }); +}); diff --git a/__tests__/venn.test.ts b/__tests__/venn.test.ts new file mode 100644 index 00000000..a015108a --- /dev/null +++ b/__tests__/venn.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - venn chart', () => { + it('should parse basic venn chart with two sets', () => { + const result = parse(` +vis venn +data + - sets A + value 20 + label 集合A + - sets B + value 15 + label 集合B + - sets A,B + value 5 + label 交集AB +title 集合交集示例 + `); + + expect(result.type).toBe('venn'); + expect(result.data).toEqual([ + { sets: 'A', value: 20, label: '集合A' }, + { sets: 'B', value: 15, label: '集合B' }, + { sets: 'A,B', value: 5, label: '交集AB' }, + ]); + expect(result.title).toBe('集合交集示例'); + }); + + it('should parse venn chart with three sets', () => { + const result = parse(` +vis venn +data + - sets A + value 10 + label 集合A + - sets B + value 8 + label 集合B + - sets C + value 6 + label 集合C + - sets A,B + value 4 + - sets A,C + value 2 + - sets B,C + value 1 + - sets A,B,C + value 1 +title 三集合关系 +theme dark + `); + + expect(result.data).toEqual([ + { sets: 'A', value: 10, label: '集合A' }, + { sets: 'B', value: 8, label: '集合B' }, + { sets: 'C', value: 6, label: '集合C' }, + { sets: 'A,B', value: 4 }, + { sets: 'A,C', value: 2 }, + { sets: 'B,C', value: 1 }, + { sets: 'A,B,C', value: 1 }, + ]); + expect(result.theme).toBe('dark'); + }); + + it('should parse venn chart with custom style', () => { + const result = parse(` +vis venn +data + - sets A + value 30 + label 购买手机 + - sets B + value 25 + label 购买耳机 + - sets A,B + value 10 +title 标签交集 +style + palette #FFB6C1 #87CEFA + backgroundColor #F8F8FF + `); + + expect(result.style).toEqual({ + palette: ['#FFB6C1', '#87CEFA'], + backgroundColor: '#F8F8FF', + }); + }); +}); diff --git a/__tests__/violin.test.ts b/__tests__/violin.test.ts new file mode 100644 index 00000000..76dc666c --- /dev/null +++ b/__tests__/violin.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - violin chart', () => { + it('should parse basic violin chart', () => { + const result = parse(` +vis violin +data + - category 班级A + value 15 + - category 班级A + value 18 + - category 班级A + value 22 + - category 班级A + value 27 + - category 班级A + value 35 + - category 班级B + value 10 + - category 班级B + value 14 + - category 班级B + value 19 + - category 班级B + value 23 + - category 班级B + value 30 +title 成绩分布 + `); + + expect(result.type).toBe('violin'); + expect(result.title).toBe('成绩分布'); + expect(result.data).toEqual([ + { category: '班级A', value: 15 }, + { category: '班级A', value: 18 }, + { category: '班级A', value: 22 }, + { category: '班级A', value: 27 }, + { category: '班级A', value: 35 }, + { category: '班级B', value: 10 }, + { category: '班级B', value: 14 }, + { category: '班级B', value: 19 }, + { category: '班级B', value: 23 }, + { category: '班级B', value: 30 }, + ]); + }); + + it('should parse violin chart with theme', () => { + const result = parse(` +vis violin +data + - category 实验组1 + value 12 + - category 实验组1 + value 15 + - category 实验组1 + value 20 + - category 实验组2 + value 18 + - category 实验组2 + value 22 + - category 实验组2 + value 28 +title 实验数据分布 +theme dark + `); + + expect(result.type).toBe('violin'); + expect(result.theme).toBe('dark'); + expect(result.title).toBe('实验数据分布'); + }); + + it('should parse violin chart with group field', () => { + const result = parse(` +vis violin +data + - group I.setosa + category PetalWidth + value 0.2 + - group I.setosa + category PetalLength + value 1.4 + - group I.versicolor + category PetalWidth + value 1.4 + - group I.versicolor + category PetalLength + value 4.7 +title 鸢尾花特征分布 + `); + + expect(result.type).toBe('violin'); + expect(result.data).toEqual([ + { group: 'I.setosa', category: 'PetalWidth', value: 0.2 }, + { group: 'I.setosa', category: 'PetalLength', value: 1.4 }, + { group: 'I.versicolor', category: 'PetalWidth', value: 1.4 }, + { group: 'I.versicolor', category: 'PetalLength', value: 4.7 }, + ]); + }); +}); diff --git a/__tests__/waterfall.test.ts b/__tests__/waterfall.test.ts new file mode 100644 index 00000000..48bd16c9 --- /dev/null +++ b/__tests__/waterfall.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - waterfall chart', () => { + it('should parse basic waterfall chart', () => { + const result = parse(` +vis waterfall +data + - category 期初利润 + value 100 + - category 销售收入 + value 80 + - category 运营成本 + value -50 + - category 税费 + value -20 + - category 总计 + isTotal true + `); + + expect(result.type).toBe('waterfall'); + expect(result.data).toEqual([ + { category: '期初利润', value: 100 }, + { category: '销售收入', value: 80 }, + { category: '运营成本', value: -50 }, + { category: '税费', value: -20 }, + { category: '总计', isTotal: true }, + ]); + }); + + it('should parse waterfall chart with title and axis titles', () => { + const result = parse(` +vis waterfall +data + - category 起始值 + value 120 + - category 一月 + value 20 + - category 二月 + value -30 + - category 三月 + value 15 +title 利润变化 +axisXTitle 月份 +axisYTitle 金额(万元) + `); + + expect(result.title).toBe('利润变化'); + expect(result.axisXTitle).toBe('月份'); + expect(result.axisYTitle).toBe('金额(万元)'); + }); + + it('should parse waterfall chart with intermediate total', () => { + const result = parse(` +vis waterfall +data + - category 基础预算 + value 500 + - category 市场投入 + value 120 + - category 总投入 + isIntermediateTotal true + - category 采购优化 + value -60 + - category 总利润 + isTotal true + `); + + expect(result.data).toEqual([ + { category: '基础预算', value: 500 }, + { category: '市场投入', value: 120 }, + { category: '总投入', isIntermediateTotal: true }, + { category: '采购优化', value: -60 }, + { category: '总利润', isTotal: true }, + ]); + }); + + it('should parse waterfall chart with theme', () => { + const result = parse(` +vis waterfall +data + - category 起始 + value 100 + - category 增加 + value 50 +theme dark + `); + + expect(result.theme).toBe('dark'); + }); +}); diff --git a/__tests__/word-cloud.test.ts b/__tests__/word-cloud.test.ts new file mode 100644 index 00000000..26363c51 --- /dev/null +++ b/__tests__/word-cloud.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../src/ai/parser'; + +describe('parse - word-cloud chart', () => { + it('should parse basic word-cloud chart', () => { + const result = parse(` +vis word-cloud +data + - text 环境 + value 20 + - text 保护 + value 15 + - text 可持续发展 + value 10 + `); + + expect(result.type).toBe('word-cloud'); + expect(result.data).toEqual([ + { text: '环境', value: 20 }, + { text: '保护', value: 15 }, + { text: '可持续发展', value: 10 }, + ]); + }); + + it('should parse word-cloud chart from product reviews', () => { + const result = parse(` +vis word-cloud +data + - text 质量好 + value 30 + - text 价格合理 + value 20 + - text 服务差 + value 5 + `); + + expect(result.data).toEqual([ + { text: '质量好', value: 30 }, + { text: '价格合理', value: 20 }, + { text: '服务差', value: 5 }, + ]); + }); + + it('should parse word-cloud chart with title', () => { + const result = parse(` +vis word-cloud +data + - text 数据 + value 50 + - text 分析 + value 40 + - text 结果 + value 30 +title 文章关键词分析 + `); + + expect(result.title).toBe('文章关键词分析'); + }); + + it('should parse word-cloud chart with theme and style', () => { + const result = parse(` +vis word-cloud +data + - text 环保 + value 10 + - text 气候变化 + value 8 +theme academy +style + backgroundColor #f5f5f5 + palette #5B8FF9 #61DDAA + `); + + expect(result.theme).toBe('academy'); + expect(result.style).toEqual({ + backgroundColor: '#f5f5f5', + palette: ['#5B8FF9', '#61DDAA'], + }); + }); +}); diff --git a/package.json b/package.json index 6426728b..f2042c1a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "format": "prettier './**/*.{ts,tsx,js,jsx,json,md,css,less}' --write", "lint:ts": "eslint src/**", "lint:ts-fix": "pnpm lint:ts --fix", + "test": "vitest run", + "test:watch": "vitest", "test:size": "pnpm build && limit-size", "changeset": "changeset add", "publish-version": "changeset version", @@ -82,6 +84,7 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3", "typescript-eslint": "^8.46.1", + "vitest": "^4.0.18", "webpack-bundle-analyzer": "^4.10.2" }, "engines": { diff --git a/playground/index.html b/playground/index.html index 79c0a86d..ec0ef092 100644 --- a/playground/index.html +++ b/playground/index.html @@ -155,6 +155,16 @@
+ Explore the new markdown-like syntax for chart configurations. + Reduced token usage for LLM outputs with better error tolerance. +
+ New Feature +